Compare commits

..

12 Commits

Author SHA1 Message Date
Piyush Gupta 9d1167ca80 Merge branch 'main' of https://github.com/formbricks/formbricks into feature/mail-from-name 2025-03-10 14:17:21 +05:30
Piyush Gupta 480fd8b729 fix: email_from_name default value 2025-03-10 14:15:40 +05:30
IllimarR d294dfb9e8 Merge branch 'formbricks:main' into feature/mail-from-name 2025-03-05 10:58:25 +02:00
IllimarR d8e286082a Update environment-variables.mdx, added MAIL_FROM_NAME 2025-03-03 22:37:26 +02:00
IllimarR a8f42ff429 Update migration.mdx, added MAIL_FROM_NAME 2025-03-03 22:36:14 +02:00
IllimarR 133b4072bb Update .env.example, added MAIL_FROM_NAME 2025-03-03 22:35:29 +02:00
IllimarR e8687ca854 Update turbo.json, added MAIL_FROM_NAME 2025-03-03 22:34:49 +02:00
IllimarR 714ff94c9b Update constants.ts, added MAIL_FROM_NAME 2025-03-03 22:33:56 +02:00
IllimarR 1a1ed296f8 Update smtp.mdx, added MAIL_FROM_NAME 2025-03-03 22:32:31 +02:00
IllimarR 4233321ee2 Update env.ts, added MAIL_FROM_NAME 2025-03-03 22:29:15 +02:00
IllimarR 9a75b7f145 Update formbricks.sh, added MAIL_FROM_NAME 2025-03-03 22:27:33 +02:00
IllimarR 717c092e2c Update index.tsx, added MAIL_FROM_NAME 2025-03-03 22:23:03 +02:00
938 changed files with 14178 additions and 45678 deletions
+3 -19
View File
@@ -25,9 +25,6 @@ NEXTAUTH_SECRET=
# You can use: `openssl rand -hex 32` to generate a secure one # You can use: `openssl rand -hex 32` to generate a secure one
CRON_SECRET= CRON_SECRET=
# Set the minimum log level(debug, info, warn, error, fatal)
LOG_LEVEL=info
############## ##############
# DATABASE # # DATABASE #
############## ##############
@@ -80,9 +77,6 @@ S3_ENDPOINT_URL=
# Force path style for S3 compatible storage (0 for disabled, 1 for enabled) # Force path style for S3 compatible storage (0 for disabled, 1 for enabled)
S3_FORCE_PATH_STYLE=0 S3_FORCE_PATH_STYLE=0
# Set this URL to add a custom domain to your survey links(default is WEBAPP_URL)
# SURVEY_URL=https://survey.example.com
##################### #####################
# Disable Features # # Disable Features #
##################### #####################
@@ -103,9 +97,6 @@ PASSWORD_RESET_DISABLED=1
# Organization Invite. Disable the ability for invited users to create an account. # Organization Invite. Disable the ability for invited users to create an account.
# INVITE_DISABLED=1 # INVITE_DISABLED=1
# Docker cron jobs. Disable the supercronic cron jobs in the Docker image (useful for cluster setups).
# DOCKER_CRON_ENABLED=1
########## ##########
# Other # # Other #
########## ##########
@@ -117,7 +108,7 @@ IMPRINT_URL=
IMPRINT_ADDRESS= IMPRINT_ADDRESS=
# Configure Turnstile in signup flow # Configure Turnstile in signup flow
# TURNSTILE_SITE_KEY= # NEXT_PUBLIC_TURNSTILE_SITE_KEY=
# TURNSTILE_SECRET_KEY= # TURNSTILE_SECRET_KEY=
# Configure Github Login # Configure Github Login
@@ -194,9 +185,7 @@ ENTERPRISE_LICENSE_KEY=
UNSPLASH_ACCESS_KEY= UNSPLASH_ACCESS_KEY=
# The below is used for Next Caching (uses In-Memory from Next Cache if not provided) # The below is used for Next Caching (uses In-Memory from Next Cache if not provided)
# You can also add more configuration to Redis using the redis.conf file in the root directory # REDIS_URL=redis://localhost:6379
REDIS_URL=redis://localhost:6379
REDIS_DEFAULT_TTL=86400 # 1 day
# The below is used for Rate Limiting (uses In-Memory LRU Cache if not provided) (You can use a service like Webdis for this) # The below is used for Rate Limiting (uses In-Memory LRU Cache if not provided) (You can use a service like Webdis for this)
# REDIS_HTTP_URL: # REDIS_HTTP_URL:
@@ -213,10 +202,5 @@ UNKEY_ROOT_KEY=
# AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID= # AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID=
# AI_AZURE_LLM_DEPLOYMENT_ID= # AI_AZURE_LLM_DEPLOYMENT_ID=
# INTERCOM_APP_ID= # NEXT_PUBLIC_INTERCOM_APP_ID=
# INTERCOM_SECRET_KEY= # INTERCOM_SECRET_KEY=
# Enable Prometheus metrics
# PROMETHEUS_ENABLED=
# PROMETHEUS_EXPORTER_PORT=
-1
View File
@@ -1,7 +1,6 @@
name: Bug report name: Bug report
description: "Found a bug? Please fill out the sections below. \U0001F44D" description: "Found a bug? Please fill out the sections below. \U0001F44D"
type: bug type: bug
labels: ["bug"]
body: body:
- type: textarea - type: textarea
id: issue-summary id: issue-summary
@@ -57,6 +57,9 @@ runs:
run: | run: |
RANDOM_KEY=$(openssl rand -hex 32) RANDOM_KEY=$(openssl rand -hex 32)
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
sed -i "s/ENTERPRISE_LICENSE_KEY=.*/ENTERPRISE_LICENSE_KEY=${RANDOM_KEY}/" .env
echo "E2E_TESTING=${{ inputs.e2e_testing_mode }}" >> .env echo "E2E_TESTING=${{ inputs.e2e_testing_mode }}" >> .env
shell: bash shell: bash
-84
View File
@@ -1,84 +0,0 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
updates:
- package-ecosystem: "npm" # For pnpm monorepos, use npm ecosystem
directory: "/" # Root package.json
schedule:
interval: "weekly"
versioning-strategy: increase
# Apps directory packages
- package-ecosystem: "npm"
directory: "/apps/demo"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/apps/demo-react-native"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/apps/storybook"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/apps/web"
schedule:
interval: "weekly"
# Packages directory
- package-ecosystem: "npm"
directory: "/packages/database"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/packages/lib"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/packages/types"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/packages/config-eslint"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/packages/config-prettier"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/packages/config-typescript"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/packages/js-core"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/packages/surveys"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/packages/logger"
schedule:
interval: "weekly"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
@@ -5,9 +5,6 @@ on:
types: types:
- opened - opened
permissions:
contents: read
jobs: jobs:
label_on_pr: label_on_pr:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -18,13 +15,8 @@ jobs:
pull-requests: write pull-requests: write
steps: steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit
- name: Apply labels from linked issue to PR - name: Apply labels from linked issue to PR
uses: actions/github-script@211cb3fefb35a799baa5156f9321bb774fe56294 # v5.2.0 uses: actions/github-script@v5
with: with:
github-token: ${{ secrets.GITHUB_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }}
script: | script: |
+1 -6
View File
@@ -12,12 +12,7 @@ jobs:
timeout-minutes: 30 timeout-minutes: 30
steps: steps:
- name: Harden the runner (Audit all outbound calls) - uses: actions/checkout@v3
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: ./.github/actions/dangerous-git-checkout - uses: ./.github/actions/dangerous-git-checkout
- name: Build & Cache Web Binaries - name: Build & Cache Web Binaries
+4 -9
View File
@@ -11,24 +11,19 @@ jobs:
name: Run Chromatic name: Run Chromatic
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit
- name: Checkout code - name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0 - uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: 20
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 uses: pnpm/action-setup@v4
- name: Install dependencies - name: Install dependencies
run: pnpm install --config.platform=linux --config.architecture=x64 run: pnpm install --config.platform=linux --config.architecture=x64
- name: Run Chromatic - name: Run Chromatic
uses: chromaui/action@c93e0bc3a63aa176e14a75b61a31847cbfdd341c # latest uses: chromaui/action@latest
with: with:
# ⚠️ Make sure to configure a `CHROMATIC_PROJECT_TOKEN` repository secret # ⚠️ Make sure to configure a `CHROMATIC_PROJECT_TOKEN` repository secret
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
@@ -0,0 +1,28 @@
name: Cron - Survey status update
on:
workflow_dispatch:
# "Scheduled workflows run on the latest commit on the default or base branch."
# — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule
schedule:
# Runs "At 00:00." (see https://crontab.guru)
- cron: "0 0 * * *"
permissions:
contents: read
jobs:
cron-weeklySummary:
env:
APP_URL: ${{ secrets.APP_URL }}
CRON_SECRET: ${{ secrets.CRON_SECRET }}
runs-on: ubuntu-latest
steps:
- name: cURL request
if: ${{ env.APP_URL && env.CRON_SECRET }}
run: |
curl ${{ env.APP_URL }}/api/cron/survey-status \
-X POST \
-H 'content-type: application/json' \
-H 'x-api-key: ${{ env.CRON_SECRET }}' \
--fail
+26
View File
@@ -0,0 +1,26 @@
name: Cron - Weekly summary
on:
workflow_dispatch:
# "Scheduled workflows run on the latest commit on the default or base branch."
# — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule
schedule:
# Runs “At 08:00 on Monday.” (see https://crontab.guru)
- cron: "0 8 * * 1"
jobs:
cron-weeklySummary:
permissions:
contents: read
env:
APP_URL: ${{ secrets.APP_URL }}
CRON_SECRET: ${{ secrets.CRON_SECRET }}
runs-on: ubuntu-latest
steps:
- name: cURL request
if: ${{ env.APP_URL && env.CRON_SECRET }}
run: |
curl ${{ env.APP_URL }}/api/cron/weekly-summary \
-X POST \
-H 'content-type: application/json' \
-H 'x-api-key: ${{ env.CRON_SECRET }}' \
--fail
-27
View File
@@ -1,27 +0,0 @@
# Dependency Review Action
#
# This Action will scan dependency manifest files that change as part of a Pull Request,
# surfacing known-vulnerable versions of the packages declared or updated in the PR.
# Once installed, if the workflow run is marked as required,
# PRs introducing known-vulnerable packages will be blocked from merging.
#
# Source repository: https://github.com/actions/dependency-review-action
name: 'Dependency Review'
on: [pull_request]
permissions:
contents: read
jobs:
dependency-review:
runs-on: ubuntu-latest
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit
- name: 'Checkout Repository'
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: 'Dependency Review'
uses: actions/dependency-review-action@3b139cfc5fae8b618d3eae3675e383bb1769c019 # v4.5.0
@@ -1,64 +0,0 @@
name: Formbricks Cloud Deployment
on:
workflow_dispatch:
inputs:
VERSION:
description: 'The version of the Docker image to release'
required: true
type: string
REPOSITORY:
description: 'The repository to use for the Docker image'
required: false
type: string
default: 'ghcr.io/formbricks/formbricks'
workflow_call:
inputs:
VERSION:
description: 'The version of the Docker image to release'
required: true
type: string
REPOSITORY:
description: 'The repository to use for the Docker image'
required: false
type: string
default: 'ghcr.io/formbricks/formbricks'
permissions:
id-token: write
contents: write
jobs:
helmfile-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2
with:
role-to-assume: ${{ secrets.AWS_ASSUME_ROLE_ARN }}
aws-region: "eu-central-1"
- name: Setup Cluster Access
run: |
aws eks update-kubeconfig --name formbricks-prod-eks --region eu-central-1
env:
AWS_REGION: eu-central-1
- uses: helmfile/helmfile-action@v2
env:
VERSION: ${{ inputs.VERSION }}
REPOSITORY: ${{ inputs.REPOSITORY }}
FORMBRICKS_S3_BUCKET: ${{ secrets.FORMBRICKS_S3_BUCKET }}
FORMBRICKS_INGRESS_CERT_ARN: ${{ secrets.FORMBRICKS_INGRESS_CERT_ARN }}
FORMBRICKS_ROLE_ARN: ${{ secrets.FORMBRICKS_ROLE_ARN }}
with:
helm-plugins: >
https://github.com/databus23/helm-diff,
https://github.com/jkroepke/helm-secrets
helmfile-args: apply
helmfile-auto-init: "false"
helmfile-workdirectory: infra/formbricks-cloud-helm
@@ -1,163 +0,0 @@
name: Docker Build Validation
on:
pull_request:
branches:
- main
merge_group:
branches:
- main
workflow_dispatch:
permissions:
contents: read
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!"
+6 -11
View File
@@ -43,21 +43,16 @@ jobs:
--health-timeout=5s --health-timeout=5s
--health-retries=5 --health-retries=5
steps: steps:
- name: Harden the runner (Audit all outbound calls) - uses: actions/checkout@v3
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: ./.github/actions/dangerous-git-checkout - uses: ./.github/actions/dangerous-git-checkout
- name: Setup Node.js 20.x - name: Setup Node.js 20.x
uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # v3.8.2 uses: actions/setup-node@v3
with: with:
node-version: 20.x node-version: 20.x
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 uses: pnpm/action-setup@v4
- name: Install dependencies - name: Install dependencies
run: pnpm install --config.platform=linux --config.architecture=x64 run: pnpm install --config.platform=linux --config.architecture=x64
@@ -117,7 +112,7 @@ jobs:
- name: Azure login - name: Azure login
if: env.AZURE_ENABLED == 'true' if: env.AZURE_ENABLED == 'true'
uses: azure/login@a65d910e8af852a8061c627c456678983e180302 # v2.2.0 uses: azure/login@v2
with: with:
client-id: ${{ secrets.AZURE_CLIENT_ID }} client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }} tenant-id: ${{ secrets.AZURE_TENANT_ID }}
@@ -135,14 +130,14 @@ jobs:
run: | run: |
pnpm test:e2e pnpm test:e2e
- uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 - uses: actions/upload-artifact@v4
if: always() if: always()
with: with:
name: playwright-report name: playwright-report
path: playwright-report/ path: playwright-report/
retention-days: 30 retention-days: 30
- uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 - uses: actions/upload-artifact@v4
if: failure() if: failure()
with: with:
name: app-logs name: app-logs
-33
View File
@@ -1,33 +0,0 @@
name: Build, release & deploy Formbricks images
on:
workflow_dispatch:
push:
tags:
- "v*"
jobs:
docker-build:
name: Build & release stable docker image
if: startsWith(github.ref, 'refs/tags/v')
uses: ./.github/workflows/release-docker-github.yml
secrets: inherit
helm-chart-release:
name: Release Helm Chart
uses: ./.github/workflows/release-helm-chart.yml
secrets: inherit
needs:
- docker-build
with:
VERSION: ${{ needs.docker-build.outputs.VERSION }}
deploy-formbricks-cloud:
name: Deploy Helm Chart to Formbricks Cloud
secrets: inherit
uses: ./.github/workflows/deploy-formbricks-cloud.yml
needs:
- docker-build
- helm-chart-release
with:
VERSION: ${{ needs.docker-build.outputs.VERSION }}
+1 -9
View File
@@ -4,9 +4,6 @@ on:
concurrency: concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true cancel-in-progress: true
permissions:
contents: read
jobs: jobs:
labeler: labeler:
name: Pull Request Labeler name: Pull Request Labeler
@@ -15,12 +12,7 @@ jobs:
pull-requests: write pull-requests: write
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Harden the runner (Audit all outbound calls) - uses: actions/labeler@v4
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit
- uses: actions/labeler@ac9175f8a1f3625fd0d4fb234536d26811351594 # v4.3.0
with: with:
repo-token: "${{ secrets.GITHUB_TOKEN }}" repo-token: "${{ secrets.GITHUB_TOKEN }}"
# https://github.com/actions/labeler/issues/442#issuecomment-1297359481 # https://github.com/actions/labeler/issues/442#issuecomment-1297359481
-5
View File
@@ -12,11 +12,6 @@ jobs:
timeout-minutes: 15 timeout-minutes: 15
steps: steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- uses: ./.github/actions/dangerous-git-checkout - uses: ./.github/actions/dangerous-git-checkout
-4
View File
@@ -50,10 +50,6 @@ jobs:
checks: write checks: write
statuses: write statuses: write
steps: steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481
with:
egress-policy: audit
- name: fail if conditional jobs failed - name: fail if conditional jobs failed
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'skipped') || contains(needs.*.result, 'cancelled') if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'skipped') || contains(needs.*.result, 'cancelled')
run: exit 1 run: exit 1
+62
View File
@@ -0,0 +1,62 @@
name: Prepare release
run-name: Prepare release ${{ inputs.next_version }}
on:
workflow_dispatch:
inputs:
next_version:
required: true
type: string
description: "Version name"
permissions:
contents: write
pull-requests: write
jobs:
prepare_release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- uses: ./.github/actions/dangerous-git-checkout
- name: Configure git
run: |
git config --local user.email "github-actions@github.com"
git config --local user.name "GitHub Actions"
- name: Setup Node.js 20.x
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
with:
node-version: 20.x
- name: Install pnpm
uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2
- name: Install dependencies
run: pnpm install --config.platform=linux --config.architecture=x64
- name: Bump version
run: |
cd apps/web
pnpm version ${{ inputs.next_version }} --no-workspaces-update
- name: Commit changes and create a branch
run: |
branch_name="release-v${{ inputs.next_version }}"
git checkout -b "$branch_name"
git add .
git commit -m "chore: release v${{ inputs.next_version }}"
git push origin "$branch_name"
- name: Create pull request
run: |
gh pr create \
--base main \
--head "release-v${{ inputs.next_version }}" \
--title "chore: bump version to v${{ inputs.next_version }}" \
--body "This PR contains the changes for the v${{ inputs.next_version }} release."
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+4 -9
View File
@@ -26,28 +26,23 @@ jobs:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }} TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
steps: steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit
- name: Checkout Repo - name: Checkout Repo
uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2.7.0 uses: actions/checkout@v2
- name: Setup Node.js 18.x - name: Setup Node.js 18.x
uses: actions/setup-node@7c12f8017d5436eb855f1ed4399f037a36fbd9e8 # v2.5.2 uses: actions/setup-node@v2
with: with:
node-version: 18.x node-version: 18.x
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@c3b53f6a16e57305370b4ae5a540c2077a1d50dd # v2.2.4 uses: pnpm/action-setup@v2.2.4
- name: Install Dependencies - name: Install Dependencies
run: pnpm install --config.platform=linux --config.architecture=x64 run: pnpm install --config.platform=linux --config.architecture=x64
- name: Create Release Pull Request or Publish to npm - name: Create Release Pull Request or Publish to npm
id: changesets id: changesets
uses: changesets/action@c8bada60c408975afd1a20b3db81d6eee6789308 # v1.4.9 uses: changesets/action@v1
with: with:
# This expects you to have a script called release which does a build for your packages and calls changeset publish # This expects you to have a script called release which does a build for your packages and calls changeset publish
publish: pnpm release publish: pnpm release
@@ -15,9 +15,7 @@ env:
IMAGE_NAME: ${{ github.repository }}-experimental IMAGE_NAME: ${{ github.repository }}-experimental
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }} TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/formbricks?schema=public"
permissions:
contents: read
jobs: jobs:
build: build:
@@ -30,28 +28,23 @@ jobs:
id-token: write id-token: write
steps: steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 uses: actions/checkout@v3
- name: Set up Depot CLI - name: Set up Depot CLI
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0 uses: depot/setup-action@v1
# Install the cosign tool except on PR # Install the cosign tool except on PR
# https://github.com/sigstore/cosign-installer # https://github.com/sigstore/cosign-installer
- name: Install cosign - name: Install cosign
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
uses: sigstore/cosign-installer@59acb6260d9c0ba8f4a2f9d9b48431a222b68e20 # v3.5.0 uses: sigstore/cosign-installer@v3.5.0
# Login against a Docker registry except on PR # Login against a Docker registry except on PR
# https://github.com/docker/login-action # https://github.com/docker/login-action
- name: Log into registry ${{ env.REGISTRY }} - name: Log into registry ${{ env.REGISTRY }}
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0 uses: docker/login-action@v3 # v3.0.0
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
username: ${{ github.actor }} username: ${{ github.actor }}
@@ -61,7 +54,7 @@ jobs:
# https://github.com/docker/metadata-action # https://github.com/docker/metadata-action
- name: Extract Docker metadata - name: Extract Docker metadata
id: meta id: meta
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0 uses: docker/metadata-action@v5 # v5.0.0
with: with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
@@ -69,7 +62,7 @@ jobs:
# https://github.com/docker/build-push-action # https://github.com/docker/build-push-action
- name: Build and push Docker image - name: Build and push Docker image
id: build-and-push id: build-and-push
uses: depot/build-push-action@636daae76684e38c301daa0c5eca1c095b24e780 # v1.14.0 uses: depot/build-push-action@v1
with: with:
project: tw0fqmsx3c project: tw0fqmsx3c
token: ${{ secrets.DEPOT_PROJECT_TOKEN }} token: ${{ secrets.DEPOT_PROJECT_TOKEN }}
@@ -79,9 +72,6 @@ jobs:
push: ${{ github.event_name != 'pull_request' }} push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
secrets: |
database_url=${{ secrets.DUMMY_DATABASE_URL }}
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max cache-to: type=gha,mode=max
+11 -38
View File
@@ -6,11 +6,10 @@ name: Docker Release to Github
# documentation. # documentation.
on: on:
workflow_call: workflow_dispatch:
outputs: push:
VERSION: tags:
description: release version - "v*"
value: ${{ jobs.build.outputs.VERSION }}
env: env:
# Use docker.io for Docker Hub if empty # Use docker.io for Docker Hub if empty
@@ -19,9 +18,7 @@ env:
IMAGE_NAME: ${{ github.repository }} IMAGE_NAME: ${{ github.repository }}
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }} TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/formbricks?schema=public"
permissions:
contents: read
jobs: jobs:
build: build:
@@ -33,45 +30,24 @@ jobs:
# with sigstore/fulcio when running outside of PRs. # with sigstore/fulcio when running outside of PRs.
id-token: write id-token: write
outputs:
VERSION: ${{ steps.extract_release_tag.outputs.VERSION }}
steps: steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 uses: actions/checkout@v3
- name: Get Release Tag
id: extract_release_tag
run: |
TAG=${{ github.ref }}
TAG=${TAG#refs/tags/v}
echo "RELEASE_TAG=$TAG" >> $GITHUB_ENV
echo "VERSION=$TAG" >> $GITHUB_OUTPUT
- name: Update package.json version
run: |
sed -i "s/\"version\": \"0.0.0\"/\"version\": \"${{ env.RELEASE_TAG }}\"/" ./apps/web/package.json
cat ./apps/web/package.json | grep version
- name: Set up Depot CLI - name: Set up Depot CLI
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0 uses: depot/setup-action@v1
# Install the cosign tool except on PR # Install the cosign tool except on PR
# https://github.com/sigstore/cosign-installer # https://github.com/sigstore/cosign-installer
- name: Install cosign - name: Install cosign
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
uses: sigstore/cosign-installer@59acb6260d9c0ba8f4a2f9d9b48431a222b68e20 # v3.5.0 uses: sigstore/cosign-installer@v3.5.0
# Login against a Docker registry except on PR # Login against a Docker registry except on PR
# https://github.com/docker/login-action # https://github.com/docker/login-action
- name: Log into registry ${{ env.REGISTRY }} - name: Log into registry ${{ env.REGISTRY }}
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0 uses: docker/login-action@v3 # v3.0.0
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
username: ${{ github.actor }} username: ${{ github.actor }}
@@ -81,7 +57,7 @@ jobs:
# https://github.com/docker/metadata-action # https://github.com/docker/metadata-action
- name: Extract Docker metadata - name: Extract Docker metadata
id: meta id: meta
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0 uses: docker/metadata-action@v5 # v5.0.0
with: with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
@@ -89,7 +65,7 @@ jobs:
# https://github.com/docker/build-push-action # https://github.com/docker/build-push-action
- name: Build and push Docker image - name: Build and push Docker image
id: build-and-push id: build-and-push
uses: depot/build-push-action@636daae76684e38c301daa0c5eca1c095b24e780 # v1.14.0 uses: depot/build-push-action@v1
with: with:
project: tw0fqmsx3c project: tw0fqmsx3c
token: ${{ secrets.DEPOT_PROJECT_TOKEN }} token: ${{ secrets.DEPOT_PROJECT_TOKEN }}
@@ -99,9 +75,6 @@ jobs:
push: ${{ github.event_name != 'pull_request' }} push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
secrets: |
database_url=${{ secrets.DUMMY_DATABASE_URL }}
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max cache-to: type=gha,mode=max
+46
View File
@@ -0,0 +1,46 @@
name: Release on Dockerhub
on:
push:
tags:
- "v*"
jobs:
release-image-on-dockerhub:
name: Release on Dockerhub
permissions:
contents: read
runs-on: ubuntu-latest
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/formbricks?schema=public"
steps:
- name: Checkout Repo
uses: actions/checkout@v2
- name: Log in to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Get Release Tag
id: extract_release_tag
run: |
TAG=${{ github.ref }}
TAG=${TAG#refs/tags/v}
echo "RELEASE_TAG=$TAG" >> $GITHUB_ENV
- name: Build and push Docker image
uses: docker/build-push-action@v4
with:
context: .
file: ./apps/web/Dockerfile
push: true
tags: |
${{ secrets.DOCKER_USERNAME }}/formbricks:${{ env.RELEASE_TAG }}
${{ secrets.DOCKER_USERNAME }}/formbricks:latest
-54
View File
@@ -1,54 +0,0 @@
name: Publish Helm Chart
on:
workflow_call:
inputs:
VERSION:
description: 'The version of the Helm chart to release'
required: true
type: string
permissions:
contents: read
jobs:
publish:
runs-on: ubuntu-latest
permissions:
packages: write
contents: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Extract release version
run: echo "VERSION=${{ github.event.release.tag_name }}" >> $GITHUB_ENV
- name: Set up Helm
uses: azure/setup-helm@5119fcb9089d432beecbf79bb2c7915207344b78 # v3.5
with:
version: latest
- name: Log in to GitHub Container Registry
run: echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ghcr.io --username ${{ github.actor }} --password-stdin
- name: Install YQ
uses: dcarbone/install-yq-action@4075b4dca348d74bd83f2bf82d30f25d7c54539b # v1.3.1
- name: Update Chart.yaml with new version
run: |
yq -i ".version = \"${{ inputs.VERSION }}\"" helm-chart/Chart.yaml
yq -i ".appVersion = \"v${{ inputs.VERSION }}\"" helm-chart/Chart.yaml
- name: Package Helm chart
run: |
helm package ./helm-chart
- name: Push Helm chart to GitHub Container Registry
run: |
helm push formbricks-${{ inputs.VERSION }}.tgz oci://ghcr.io/formbricks/helm-charts
+1 -6
View File
@@ -34,11 +34,6 @@ jobs:
# actions: read # actions: read
steps: steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit
- name: "Checkout code" - name: "Checkout code"
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
with: with:
@@ -76,6 +71,6 @@ jobs:
# Upload the results to GitHub's code scanning dashboard (optional). # Upload the results to GitHub's code scanning dashboard (optional).
# Commenting out will disable upload of results to your repo's Code Scanning dashboard # Commenting out will disable upload of results to your repo's Code Scanning dashboard
- name: "Upload to code-scanning" - name: "Upload to code-scanning"
uses: github/codeql-action/upload-sarif@b56ba49b26e50535fa1e7f7db0f4f7b4bf65d80d # v3.28.10 uses: github/codeql-action/upload-sarif@v3
with: with:
sarif_file: results.sarif sarif_file: results.sarif
+3 -8
View File
@@ -16,12 +16,7 @@ jobs:
name: PR title name: PR title
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Harden the runner (Audit all outbound calls) - uses: amannn/action-semantic-pull-request@v5
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit
- uses: amannn/action-semantic-pull-request@0723387faaf9b38adef4775cd42cfd5155ed6017 # v5.5.3
id: lint_pr_title id: lint_pr_title
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -40,7 +35,7 @@ jobs:
revert revert
ossgg ossgg
- uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1 - uses: marocchino/sticky-pull-request-comment@v2
# When the previous steps fails, the workflow would stop. By adding this # When the previous steps fails, the workflow would stop. By adding this
# condition you can continue the execution with the populated error message. # condition you can continue the execution with the populated error message.
if: always() && (steps.lint_pr_title.outputs.error_message != null) if: always() && (steps.lint_pr_title.outputs.error_message != null)
@@ -59,7 +54,7 @@ jobs:
# Delete a previous comment when the issue has been resolved # Delete a previous comment when the issue has been resolved
- if: ${{ steps.lint_pr_title.outputs.error_message == null }} - if: ${{ steps.lint_pr_title.outputs.error_message == null }}
uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1 uses: marocchino/sticky-pull-request-comment@v2
with: with:
header: pr-title-lint-error header: pr-title-lint-error
message: | message: |
+9 -10
View File
@@ -4,7 +4,7 @@ on:
push: push:
branches: branches:
- main - main
pull_request_target: pull_request:
types: [opened, synchronize, reopened] types: [opened, synchronize, reopened]
merge_group: merge_group:
permissions: permissions:
@@ -14,19 +14,14 @@ jobs:
name: SonarQube name: SonarQube
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Harden the runner (Audit all outbound calls) - uses: actions/checkout@v4
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
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 22.x - name: Setup Node.js 20.x
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
with: with:
node-version: 22.x node-version: 20.x
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2 uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2
@@ -46,9 +41,13 @@ jobs:
- name: Run tests with coverage - name: Run tests with coverage
run: | run: |
cd apps/web
pnpm test:coverage pnpm test:coverage
cd ../../
# The Vitest coverage config is in your vite.config.mts
- name: SonarQube Scan - name: SonarQube Scan
uses: SonarSource/sonarqube-scan-action@aa494459d7c39c106cc77b166de8b4250a32bb97 uses: SonarSource/sonarqube-scan-action@bfd4e558cda28cda6b5defafb9232d191be8c203
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 }}
@@ -1,79 +0,0 @@
name: 'Terraform'
on:
workflow_dispatch:
# TODO: enable it back when migration is completed.
push:
branches:
- main
paths:
- "infra/terraform/**"
pull_request:
branches:
- main
paths:
- "infra/terraform/**"
permissions:
id-token: write
contents: write
pull-requests: write
jobs:
terraform:
runs-on: ubuntu-latest
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2
with:
role-to-assume: ${{ secrets.AWS_ASSUME_ROLE_ARN }}
aws-region: "eu-central-1"
- name: Setup Terraform
uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2
- name: Terraform Format
id: fmt
run: terraform fmt -check -recursive
continue-on-error: true
working-directory: infra/terraform
- name: Terraform Init
id: init
run: terraform init
working-directory: infra/terraform
- name: Terraform Validate
id: validate
run: terraform validate
working-directory: infra/terraform
- name: Terraform Plan
id: plan
run: terraform plan -out .planfile
working-directory: infra/terraform
- name: Post PR comment
uses: borchero/terraform-plan-comment@3399d8dbae8b05185e815e02361ede2949cd99c4 # v2.4.0
if: always() && github.ref != 'refs/heads/main' && (steps.plan.outcome == 'success' || steps.plan.outcome == 'failure')
with:
token: ${{ github.token }}
planfile: .planfile
working-directory: "infra/terraform"
- name: Terraform Apply
id: apply
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
run: terraform apply .planfile
working-directory: "infra/terraform"
+3 -11
View File
@@ -1,9 +1,6 @@
name: Tests name: Tests
on: on:
workflow_call: workflow_call:
permissions:
contents: read
jobs: jobs:
build: build:
name: Unit Tests name: Unit Tests
@@ -13,21 +10,16 @@ jobs:
contents: read contents: read
steps: steps:
- name: Harden the runner (Audit all outbound calls) - uses: actions/checkout@v3
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: ./.github/actions/dangerous-git-checkout - uses: ./.github/actions/dangerous-git-checkout
- name: Setup Node.js 20.x - name: Setup Node.js 20.x
uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # v3.8.2 uses: actions/setup-node@v3
with: with:
node-version: 20.x node-version: 20.x
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 uses: pnpm/action-setup@v4
- name: Install dependencies - name: Install dependencies
run: pnpm install --config.platform=linux --config.architecture=x64 run: pnpm install --config.platform=linux --config.architecture=x64
+3 -15
View File
@@ -5,30 +5,18 @@ permissions:
on: on:
workflow_dispatch: workflow_dispatch:
pull_request_target: pull_request:
types: [opened, synchronize, reopened] types: [opened, synchronize, reopened]
jobs: jobs:
check-missing-translations: check-missing-translations:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.base.ref }}
- name: Checkout PR
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0 uses: actions/setup-node@v4
with: with:
node-version: 18 node-version: 18
+14 -17
View File
@@ -3,7 +3,7 @@ permissions:
contents: read contents: read
on: on:
pull_request_target: pull_request:
types: [closed] types: [closed]
branches: branches:
- main - main
@@ -15,33 +15,30 @@ jobs:
if: github.event.pull_request.merged == true if: github.event.pull_request.merged == true
steps: steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@v4
with: with:
fetch-depth: 0 # This ensures we get the full git history fetch-depth: 0 # This ensures we get the full git history
- name: Get source branch name - name: Get source branch name
id: branch-name id: branch-name
run: | run: |
RAW_BRANCH="${{ github.head_ref }}" # For PR merges, use the head ref from the pull request event
SOURCE_BRANCH=$(echo "$RAW_BRANCH" | sed 's/[^a-zA-Z0-9._\/-]//g') SOURCE_BRANCH="${{ github.head_ref }}"
# Only remove username prefix if needed
if [[ "$SOURCE_BRANCH" =~ ^[a-zA-Z0-9][a-zA-Z0-9-]+/ ]]; then
PREFIX=${SOURCE_BRANCH%%/*}
if [[ ! "$PREFIX" =~ ^(feature|fix|bugfix|hotfix|release|chore|docs|test|refactor|style|perf|build|ci|revert)$ ]]; then
SOURCE_BRANCH=${SOURCE_BRANCH#*/}
fi
fi
# Safely add to environment variables using GitHub's recommended method echo "SOURCE_BRANCH=$SOURCE_BRANCH" >> $GITHUB_ENV
# This prevents environment variable injection attacks
echo "SOURCE_BRANCH<<EOF" >> $GITHUB_ENV
echo "$SOURCE_BRANCH" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
echo "Detected source branch: $SOURCE_BRANCH" echo "Detected source branch: $SOURCE_BRANCH"
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0 uses: actions/setup-node@v4
with: with:
node-version: 18 # Ensure compatibility with your project node-version: 18 # Ensure compatibility with your project
@@ -80,7 +77,7 @@ jobs:
--yes --yes
- name: Upload backup as artifact - name: Upload backup as artifact
uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 uses: actions/upload-artifact@v4
with: with:
name: tolgee-backup-${{ github.sha }} name: tolgee-backup-${{ github.sha }}
path: ./tolgee-backup path: ./tolgee-backup
@@ -17,12 +17,7 @@ jobs:
timeout-minutes: 10 timeout-minutes: 10
if: github.event.action == 'opened' if: github.event.action == 'opened'
steps: steps:
- name: Harden the runner (Audit all outbound calls) - uses: actions/first-interaction@v1
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit
- uses: actions/first-interaction@3c71ce730280171fd1cfb57c00c774f8998586f7 # v1
with: with:
repo-token: ${{ secrets.GITHUB_TOKEN }} repo-token: ${{ secrets.GITHUB_TOKEN }}
pr-message: |- pr-message: |-
+1 -19
View File
@@ -53,22 +53,4 @@ yarn-error.log*
packages/lib/uploads packages/lib/uploads
apps/web/public/js apps/web/public/js
packages/database/migrations packages/database/migrations
branch.json branch.json
.vercel
# Terraform
infra/terraform/.terraform/
**/.terraform.lock.hcl
**/terraform.tfstate
**/terraform.tfstate.*
**/crash.log
**/override.tf
**/override.tf.json
**/*.tfvars
**/*.tfvars.json
**/.terraformrc
**/terraform.rc
# IntelliJ IDEA
/.idea/
/*.iml
+1 -1
View File
@@ -1,6 +1,6 @@
#!/bin/bash #!/bin/bash
images=($(yq eval '.services.*.image' docker-compose.dev.yml)) images=($(yq eval '.services.*.image' packages/database/docker-compose.yml))
pull_image() { pull_image() {
docker pull "$1" docker pull "$1"
-4
View File
@@ -27,10 +27,6 @@
{ {
"language": "zh-Hant-TW", "language": "zh-Hant-TW",
"path": "./packages/lib/messages/zh-Hant-TW.json" "path": "./packages/lib/messages/zh-Hant-TW.json"
},
{
"language": "pt-PT",
"path": "./packages/lib/messages/pt-PT.json"
} }
], ],
"forceMode": "OVERRIDE" "forceMode": "OVERRIDE"
+1 -1
View File
@@ -18,7 +18,7 @@
"expo-status-bar": "2.0.1", "expo-status-bar": "2.0.1",
"react": "18.3.1", "react": "18.3.1",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"react-native": "0.78.2", "react-native": "0.76.6",
"react-native-webview": "13.12.5" "react-native-webview": "13.12.5"
}, },
"devDependencies": { "devDependencies": {
+2 -2
View File
@@ -27,7 +27,7 @@ const secondaryNavigation = [
export function Sidebar(): React.JSX.Element { export function Sidebar(): React.JSX.Element {
return ( return (
<div className="flex grow flex-col overflow-y-auto bg-cyan-700 pb-4 pt-5"> <div className="flex flex-grow flex-col overflow-y-auto bg-cyan-700 pb-4 pt-5">
<nav <nav
className="mt-5 flex flex-1 flex-col divide-y divide-cyan-800 overflow-y-auto" className="mt-5 flex flex-1 flex-col divide-y divide-cyan-800 overflow-y-auto"
aria-label="Sidebar"> aria-label="Sidebar">
@@ -41,7 +41,7 @@ export function Sidebar(): React.JSX.Element {
"group flex items-center rounded-md px-2 py-2 text-sm font-medium leading-6" "group flex items-center rounded-md px-2 py-2 text-sm font-medium leading-6"
)} )}
aria-current={item.current ? "page" : undefined}> aria-current={item.current ? "page" : undefined}>
<item.icon className="mr-4 h-6 w-6 shrink-0 text-cyan-200" aria-hidden="true" /> <item.icon className="mr-4 h-6 w-6 flex-shrink-0 text-cyan-200" aria-hidden="true" />
{item.name} {item.name}
</a> </a>
))} ))}
+3 -23
View File
@@ -1,23 +1,3 @@
@import 'tailwindcss'; @tailwind base;
@tailwind components;
@plugin '@tailwindcss/forms'; @tailwind utilities;
@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);
}
}
+4 -8
View File
@@ -1,6 +1,6 @@
{ {
"name": "@formbricks/demo", "name": "@formbricks/demo",
"version": "0.0.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"clean": "rimraf .turbo node_modules .next", "clean": "rimraf .turbo node_modules .next",
@@ -12,14 +12,10 @@
}, },
"dependencies": { "dependencies": {
"@formbricks/js": "workspace:*", "@formbricks/js": "workspace:*",
"@tailwindcss/forms": "0.5.9", "lucide-react": "0.468.0",
"@tailwindcss/postcss": "4.1.3", "next": "15.1.2",
"lucide-react": "0.486.0",
"next": "15.2.4",
"postcss": "8.5.3",
"react": "19.0.0", "react": "19.0.0",
"react-dom": "19.0.0", "react-dom": "19.0.0"
"tailwindcss": "4.1.3"
}, },
"devDependencies": { "devDependencies": {
"@formbricks/config-typescript": "workspace:*", "@formbricks/config-typescript": "workspace:*",
+22 -124
View File
@@ -9,12 +9,6 @@ declare const window: Window;
export default function AppPage(): React.JSX.Element { export default function AppPage(): React.JSX.Element {
const [darkMode, setDarkMode] = useState(false); const [darkMode, setDarkMode] = useState(false);
const router = useRouter(); const router = useRouter();
const userId = "THIS-IS-A-VERY-LONG-USER-ID-FOR-TESTING";
const userAttributes = {
"Attribute 1": "one",
"Attribute 2": "two",
"Attribute 3": "three",
};
useEffect(() => { useEffect(() => {
if (darkMode) { if (darkMode) {
@@ -39,9 +33,18 @@ export default function AppPage(): React.JSX.Element {
addFormbricksDebugParam(); addFormbricksDebugParam();
if (process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID && process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST) { if (process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID && process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST) {
void formbricks.setup({ const userId = "THIS-IS-A-VERY-LONG-USER-ID-FOR-TESTING";
const userInitAttributes = {
language: "de",
"Init Attribute 1": "eight",
"Init Attribute 2": "two",
};
void formbricks.init({
environmentId: process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID, environmentId: process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID,
appUrl: process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST, apiHost: process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST,
userId,
attributes: userInitAttributes,
}); });
} }
@@ -96,7 +99,7 @@ export default function AppPage(): React.JSX.Element {
<p className="text-slate-700 dark:text-slate-300"> <p className="text-slate-700 dark:text-slate-300">
Copy the environment ID of your Formbricks app to the env variable in /apps/demo/.env Copy the environment ID of your Formbricks app to the env variable in /apps/demo/.env
</p> </p>
<Image src={fbsetup} alt="fb setup" className="rounded-xs mt-4" priority /> <Image src={fbsetup} alt="fb setup" className="mt-4 rounded" priority />
<div className="mt-4 flex-col items-start text-sm text-slate-700 sm:flex sm:items-center sm:text-base dark:text-slate-300"> <div className="mt-4 flex-col items-start text-sm text-slate-700 sm:flex sm:items-center sm:text-base dark:text-slate-300">
<p className="mb-1 sm:mb-0 sm:mr-2">You&apos;re connected with env:</p> <p className="mb-1 sm:mb-0 sm:mr-2">You&apos;re connected with env:</p>
@@ -123,19 +126,19 @@ export default function AppPage(): React.JSX.Element {
<div className="md:grid md:grid-cols-3"> <div className="md:grid md:grid-cols-3">
<div className="col-span-3 self-start rounded-lg border border-slate-300 bg-slate-100 p-6 dark:border-slate-600 dark:bg-slate-900"> <div className="col-span-3 self-start rounded-lg border border-slate-300 bg-slate-100 p-6 dark:border-slate-600 dark:bg-slate-900">
<h3 className="text-lg font-semibold dark:text-white"> <h3 className="text-lg font-semibold dark:text-white">
Set a user ID / pull data from Formbricks app Reset person / pull data from Formbricks app
</h3> </h3>
<p className="text-slate-700 dark:text-slate-300"> <p className="text-slate-700 dark:text-slate-300">
On formbricks.setUserId() the user state will <strong>be fetched from Formbricks</strong> and On formbricks.reset() the local state will <strong>be deleted</strong> and formbricks gets{" "}
the local state gets <strong>updated with the user state</strong>. <strong>reinitialized</strong>.
</p> </p>
<button <button
className="my-4 rounded-lg bg-slate-500 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600" className="my-4 rounded-lg bg-slate-500 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600"
type="button" type="button"
onClick={() => { onClick={() => {
void formbricks.setUserId(userId); void formbricks.reset();
}}> }}>
Set user ID Reset
</button> </button>
<p className="text-xs text-slate-700 dark:text-slate-300"> <p className="text-xs text-slate-700 dark:text-slate-300">
If you made a change in Formbricks app and it does not seem to work, hit &apos;Reset&apos; and If you made a change in Formbricks app and it does not seem to work, hit &apos;Reset&apos; and
@@ -155,7 +158,7 @@ export default function AppPage(): React.JSX.Element {
<p className="text-xs text-slate-700 dark:text-slate-300"> <p className="text-xs text-slate-700 dark:text-slate-300">
This button sends a{" "} This button sends a{" "}
<a <a
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/actions#setting-up-no-code-actions" href="https://formbricks.com/docs/actions/no-code"
rel="noopener noreferrer" rel="noopener noreferrer"
className="underline dark:text-blue-500" className="underline dark:text-blue-500"
target="_blank"> target="_blank">
@@ -163,7 +166,7 @@ export default function AppPage(): React.JSX.Element {
</a>{" "} </a>{" "}
as long as you created it beforehand in the Formbricks App.{" "} as long as you created it beforehand in the Formbricks App.{" "}
<a <a
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/actions#setting-up-no-code-actions" href="https://formbricks.com/docs/actions/no-code"
rel="noopener noreferrer" rel="noopener noreferrer"
target="_blank" target="_blank"
className="underline dark:text-blue-500"> className="underline dark:text-blue-500">
@@ -172,7 +175,6 @@ export default function AppPage(): React.JSX.Element {
</p> </p>
</div> </div>
</div> </div>
<div className="p-6"> <div className="p-6">
<div> <div>
<button <button
@@ -188,7 +190,7 @@ export default function AppPage(): React.JSX.Element {
<p className="text-xs text-slate-700 dark:text-slate-300"> <p className="text-xs text-slate-700 dark:text-slate-300">
This button sets the{" "} This button sets the{" "}
<a <a
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/user-identification#setting-custom-user-attributes" href="https://formbricks.com/docs/attributes/custom-attributes"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="underline dark:text-blue-500"> className="underline dark:text-blue-500">
@@ -213,7 +215,7 @@ export default function AppPage(): React.JSX.Element {
<p className="text-xs text-slate-700 dark:text-slate-300"> <p className="text-xs text-slate-700 dark:text-slate-300">
This button sets the{" "} This button sets the{" "}
<a <a
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/user-identification#setting-custom-user-attributes" href="https://formbricks.com/docs/attributes/custom-attributes"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="underline dark:text-blue-500"> className="underline dark:text-blue-500">
@@ -238,7 +240,7 @@ export default function AppPage(): React.JSX.Element {
<p className="text-xs text-slate-700 dark:text-slate-300"> <p className="text-xs text-slate-700 dark:text-slate-300">
This button sets the{" "} This button sets the{" "}
<a <a
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/user-identification" href="https://formbricks.com/docs/attributes/identify-users"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="underline dark:text-blue-500"> className="underline dark:text-blue-500">
@@ -248,110 +250,6 @@ export default function AppPage(): React.JSX.Element {
</p> </p>
</div> </div>
</div> </div>
<div className="p-6">
<div>
<button
type="button"
onClick={() => {
void formbricks.setAttributes(userAttributes);
}}
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600">
Set Multiple Attributes
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-slate-300">
This button sets the{" "}
<a
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/user-identification#setting-custom-user-attributes"
target="_blank"
rel="noopener noreferrer"
className="underline dark:text-blue-500">
user attributes
</a>{" "}
to &apos;one&apos;, &apos;two&apos;, &apos;three&apos;.
</p>
</div>
</div>
<div className="p-6">
<div>
<button
type="button"
onClick={() => {
void formbricks.setLanguage("de");
}}
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600">
Set Language to &apos;de&apos;
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-slate-300">
This button sets the{" "}
<a
href="https://formbricks.com/docs/xm-and-surveys/surveys/general-features/multi-language-surveys"
target="_blank"
rel="noopener noreferrer"
className="underline dark:text-blue-500">
language
</a>{" "}
to &apos;de&apos;.
</p>
</div>
</div>
<div className="p-6">
<div>
<button
type="button"
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600"
onClick={() => {
void formbricks.track("code");
}}>
Code Action
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-slate-300">
This button sends a{" "}
<a
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/actions#setting-up-code-actions"
rel="noopener noreferrer"
className="underline dark:text-blue-500"
target="_blank">
Code Action
</a>{" "}
as long as you created it beforehand in the Formbricks App.{" "}
<a
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/actions#setting-up-code-actions"
rel="noopener noreferrer"
target="_blank"
className="underline dark:text-blue-500">
Here are instructions on how to do it.
</a>
</p>
</div>
</div>
<div className="p-6">
<div>
<button
type="button"
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600"
onClick={() => {
void formbricks.logout();
}}>
Logout
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-slate-300">
This button logs out the user and syncs the local state with Formbricks. (Only works if a
userId is set)
</p>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
+2 -1
View File
@@ -1,5 +1,6 @@
module.exports = { module.exports = {
plugins: { plugins: {
"@tailwindcss/postcss": {}, tailwindcss: {},
autoprefixer: {},
}, },
}; };
+13
View File
@@ -0,0 +1,13 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./app/**/*.{js,ts,jsx,tsx}",
"./pages/**/*.{js,ts,jsx,tsx}",
"./components/**/*.{js,ts,jsx,tsx}",
],
darkMode: "class",
theme: {
extend: {},
},
plugins: [require("@tailwindcss/forms")],
};
+20 -20
View File
@@ -11,30 +11,30 @@
"clean": "rimraf .turbo node_modules dist storybook-static" "clean": "rimraf .turbo node_modules dist storybook-static"
}, },
"dependencies": { "dependencies": {
"eslint-plugin-react-refresh": "0.4.19", "eslint-plugin-react-refresh": "0.4.16",
"react": "19.1.0", "react": "19.0.0",
"react-dom": "19.1.0" "react-dom": "19.0.0"
}, },
"devDependencies": { "devDependencies": {
"@chromatic-com/storybook": "3.2.6", "@chromatic-com/storybook": "3.2.2",
"@formbricks/config-typescript": "workspace:*", "@formbricks/config-typescript": "workspace:*",
"@storybook/addon-a11y": "8.6.12", "@storybook/addon-a11y": "8.4.7",
"@storybook/addon-essentials": "8.6.12", "@storybook/addon-essentials": "8.4.7",
"@storybook/addon-interactions": "8.6.12", "@storybook/addon-interactions": "8.4.7",
"@storybook/addon-links": "8.6.12", "@storybook/addon-links": "8.4.7",
"@storybook/addon-onboarding": "8.6.12", "@storybook/addon-onboarding": "8.4.7",
"@storybook/blocks": "8.6.12", "@storybook/blocks": "8.4.7",
"@storybook/react": "8.6.12", "@storybook/react": "8.4.7",
"@storybook/react-vite": "8.6.12", "@storybook/react-vite": "8.4.7",
"@storybook/test": "8.6.12", "@storybook/test": "8.4.7",
"@typescript-eslint/eslint-plugin": "8.29.0", "@typescript-eslint/eslint-plugin": "8.18.0",
"@typescript-eslint/parser": "8.29.0", "@typescript-eslint/parser": "8.18.0",
"@vitejs/plugin-react": "4.3.4", "@vitejs/plugin-react": "4.3.4",
"esbuild": "0.25.2", "esbuild": "0.25.0",
"eslint-plugin-storybook": "0.12.0", "eslint-plugin-storybook": "0.11.1",
"prop-types": "15.8.1", "prop-types": "15.8.1",
"storybook": "8.6.12", "storybook": "8.4.7",
"tsup": "8.4.0", "tsup": "8.3.5",
"vite": "6.2.4" "vite": "6.0.9"
} }
} }
+21 -70
View File
@@ -1,4 +1,4 @@
FROM node:22-alpine3.20@sha256:40be979442621049f40b1d51a26b55e281246b5de4e5f51a18da7beb6e17e3f9 AS base FROM node:22-alpine3.20 AS base
# #
## step 1: Prune monorepo ## step 1: Prune monorepo
@@ -22,27 +22,19 @@ 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 cmake g++ gcc jq make openssl-dev python3 RUN apk update && apk add --no-cache g++ cmake make gcc python3 openssl-dev jq
# BuildKit secret handling without hardcoded fallback values # Set hardcoded environment variables
# This approach relies entirely on secrets passed from GitHub Actions ENV DATABASE_URL="postgresql://placeholder:for@build:5432/gets_overwritten_at_runtime?schema=public"
RUN echo '#!/bin/sh' > /tmp/read-secrets.sh && \ ENV NEXTAUTH_SECRET="placeholder_for_next_auth_of_64_chars_get_overwritten_at_runtime"
echo 'if [ -f "/run/secrets/database_url" ]; then' >> /tmp/read-secrets.sh && \ ENV ENCRYPTION_KEY="placeholder_for_build_key_of_64_chars_get_overwritten_at_runtime"
echo ' export DATABASE_URL=$(cat /run/secrets/database_url)' >> /tmp/read-secrets.sh && \ ENV CRON_SECRET="placeholder_for_cron_secret_of_64_chars_get_overwritten_at_runtime"
echo 'else' >> /tmp/read-secrets.sh && \
echo ' echo "DATABASE_URL secret not found. Build may fail if this is required."' >> /tmp/read-secrets.sh && \
echo 'fi' >> /tmp/read-secrets.sh && \
echo 'if [ -f "/run/secrets/encryption_key" ]; then' >> /tmp/read-secrets.sh && \
echo ' export ENCRYPTION_KEY=$(cat /run/secrets/encryption_key)' >> /tmp/read-secrets.sh && \
echo 'else' >> /tmp/read-secrets.sh && \
echo ' echo "ENCRYPTION_KEY secret not found. Build may fail if this is required."' >> /tmp/read-secrets.sh && \
echo 'fi' >> /tmp/read-secrets.sh && \
echo 'exec "$@"' >> /tmp/read-secrets.sh && \
chmod +x /tmp/read-secrets.sh
# Increase Node.js memory limit as a regular build argument ARG NEXT_PUBLIC_SENTRY_DSN
ARG NODE_OPTIONS="--max_old_space_size=4096" ARG SENTRY_AUTH_TOKEN
ENV NODE_OPTIONS=${NODE_OPTIONS}
# Increase Node.js memory limit
# ENV NODE_OPTIONS="--max_old_space_size=4096"
# Set the working directory # Set the working directory
WORKDIR /app WORKDIR /app
@@ -61,11 +53,8 @@ RUN touch apps/web/.env
# Install the dependencies # Install the dependencies
RUN pnpm install RUN pnpm install
# Build the project using our secret reader script # Build the project
# This mounts the secrets only during this build step without storing them in layers RUN NODE_OPTIONS="--max_old_space_size=4096" pnpm build --filter=@formbricks/web...
RUN --mount=type=secret,id=database_url \
--mount=type=secret,id=encryption_key \
/tmp/read-secrets.sh pnpm build --filter=@formbricks/web...
# Extract Prisma version # Extract Prisma version
RUN jq -r '.devDependencies.prisma' packages/database/package.json > /prisma_version.txt RUN jq -r '.devDependencies.prisma' packages/database/package.json > /prisma_version.txt
@@ -85,66 +74,33 @@ RUN apk add --no-cache curl \
WORKDIR /home/nextjs WORKDIR /home/nextjs
# Ensure no write permissions are assigned to the copied resources
COPY --from=installer --chown=nextjs:nextjs /app/apps/web/.next/standalone ./
RUN 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 .
RUN chmod 644 ./package.json # Leverage output traces to reduce image size
COPY --from=installer --chown=nextjs:nextjs /app/apps/web/.next/standalone ./
COPY --from=installer --chown=nextjs:nextjs /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 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/apps/web/public ./apps/web/public
RUN chmod -R 755 ./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/schema.prisma ./packages/database/schema.prisma
RUN chmod 644 ./packages/database/schema.prisma
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/package.json ./packages/database/package.json COPY --from=installer --chown=nextjs:nextjs /app/packages/database/package.json ./packages/database/package.json
RUN chmod 644 ./packages/database/package.json
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/migration ./packages/database/migration COPY --from=installer --chown=nextjs:nextjs /app/packages/database/migration ./packages/database/migration
RUN chmod -R 755 ./packages/database/migration
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/src ./packages/database/src COPY --from=installer --chown=nextjs:nextjs /app/packages/database/src ./packages/database/src
RUN chmod -R 755 ./packages/database/src
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/node_modules ./packages/database/node_modules
RUN chmod -R 755 ./packages/database/node_modules
COPY --from=installer --chown=nextjs:nextjs /app/packages/logger/dist ./packages/database/node_modules/@formbricks/logger/dist
RUN chmod -R 755 ./packages/database/node_modules/@formbricks/logger/dist
# Copy Prisma-specific generated files
COPY --from=installer --chown=nextjs:nextjs /app/node_modules/@prisma/client ./node_modules/@prisma/client COPY --from=installer --chown=nextjs:nextjs /app/node_modules/@prisma/client ./node_modules/@prisma/client
RUN chmod -R 755 ./node_modules/@prisma/client
COPY --from=installer --chown=nextjs:nextjs /app/node_modules/.prisma ./node_modules/.prisma COPY --from=installer --chown=nextjs:nextjs /app/node_modules/.prisma ./node_modules/.prisma
RUN chmod -R 755 ./node_modules/.prisma
COPY --from=installer --chown=nextjs:nextjs /prisma_version.txt . COPY --from=installer --chown=nextjs:nextjs /prisma_version.txt .
RUN chmod 644 ./prisma_version.txt
COPY /docker/cronjobs /app/docker/cronjobs COPY /docker/cronjobs /app/docker/cronjobs
RUN chmod -R 755 /app/docker/cronjobs
# Copy only @paralleldrive/cuid2 and @noble/hashes
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 RUN npm install -g tsx typescript prisma
RUN chmod -R 755 ./node_modules/zod
RUN npm install -g tsx typescript prisma pino-pretty
EXPOSE 3000 EXPOSE 3000
ENV HOSTNAME "0.0.0.0" ENV HOSTNAME "0.0.0.0"
ENV NODE_ENV="production"
# USER nextjs # USER nextjs
# Prepare volume for uploads # Prepare volume for uploads
@@ -155,12 +111,7 @@ VOLUME /home/nextjs/apps/web/uploads/
RUN mkdir -p /home/nextjs/apps/web/saml-connection RUN mkdir -p /home/nextjs/apps/web/saml-connection
VOLUME /home/nextjs/apps/web/saml-connection VOLUME /home/nextjs/apps/web/saml-connection
CMD if [ "${DOCKER_CRON_ENABLED:-1}" = "1" ]; then \ CMD supercronic -quiet /app/docker/cronjobs & \
echo "Starting cron jobs..."; \
supercronic -quiet /app/docker/cronjobs & \
else \
echo "Docker cron jobs are disabled via DOCKER_CRON_ENABLED=0"; \
fi; \
(cd packages/database && npm run db:migrate:deploy) && \ (cd packages/database && npm run db:migrate:deploy) && \
(cd packages/database && npm run db:create-saml-database:deploy) && \ (cd packages/database && npm run db:create-saml-database:deploy) && \
exec node apps/web/server.js exec node apps/web/server.js
@@ -1,103 +0,0 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import toast from "react-hot-toast";
import { afterEach, beforeAll, describe, expect, test, vi } from "vitest";
import { OnboardingSetupInstructions } from "./OnboardingSetupInstructions";
// Mock react-hot-toast so we can assert that a success message is shown
vi.mock("react-hot-toast", () => ({
__esModule: true,
default: {
success: vi.fn(),
},
}));
// Set up a spy for navigator.clipboard.writeText so it becomes a ViTest spy.
beforeAll(() => {
Object.defineProperty(navigator, "clipboard", {
configurable: true,
writable: true,
value: {
// Using a mockResolvedValue resolves the promise as writeText is async.
writeText: vi.fn().mockResolvedValue(undefined),
},
});
});
describe("OnboardingSetupInstructions", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
// Provide some default props for testing
const defaultProps = {
environmentId: "env-123",
webAppUrl: "https://example.com",
channel: "app" as const, // Assuming channel is either "app" or "website"
widgetSetupCompleted: false,
};
test("renders HTML tab content by default", () => {
render(<OnboardingSetupInstructions {...defaultProps} />);
// Since the default active tab is "html", we check for a unique text
expect(
screen.getByText(/environments.connect.insert_this_code_into_the_head_tag_of_your_website/i)
).toBeInTheDocument();
// The HTML snippet contains a marker comment
expect(screen.getByText("START")).toBeInTheDocument();
// Verify the "Copy Code" button is present
expect(screen.getByRole("button", { name: /common.copy_code/i })).toBeInTheDocument();
});
test("renders NPM tab content when selected", async () => {
render(<OnboardingSetupInstructions {...defaultProps} />);
const user = userEvent.setup();
// Click on the "NPM" tab to switch views.
const npmTab = screen.getByText("NPM");
await user.click(npmTab);
// Check that the install commands are present
expect(screen.getByText(/npm install @formbricks\/js/)).toBeInTheDocument();
expect(screen.getByText(/yarn add @formbricks\/js/)).toBeInTheDocument();
// Verify the "Read Docs" link has the correct URL (based on channel prop)
const readDocsLink = screen.getByRole("link", { name: /common.read_docs/i });
expect(readDocsLink).toHaveAttribute("href", "https://formbricks.com/docs/app-surveys/framework-guides");
});
test("copies HTML snippet to clipboard and shows success toast when Copy Code button is clicked", async () => {
render(<OnboardingSetupInstructions {...defaultProps} />);
const user = userEvent.setup();
const writeTextSpy = vi.spyOn(navigator.clipboard, "writeText");
// Click the "Copy Code" button
const copyButton = screen.getByRole("button", { name: /common.copy_code/i });
await user.click(copyButton);
// Ensure navigator.clipboard.writeText was called.
expect(writeTextSpy).toHaveBeenCalled();
const writtenText = (navigator.clipboard.writeText as any).mock.calls[0][0] as string;
// Check that the pasted snippet contains the expected environment values
expect(writtenText).toContain('var appUrl = "https://example.com"');
expect(writtenText).toContain('var environmentId = "env-123"');
// Verify that a success toast was shown
expect(toast.success).toHaveBeenCalledWith("common.copied_to_clipboard");
});
test("renders step-by-step manual link with correct URL in HTML tab", () => {
render(<OnboardingSetupInstructions {...defaultProps} />);
const manualLink = screen.getByRole("link", { name: /common.step_by_step_manual/i });
expect(manualLink).toHaveAttribute(
"href",
"https://formbricks.com/docs/app-surveys/framework-guides#html"
);
});
});
@@ -34,9 +34,10 @@ export const OnboardingSetupInstructions = ({
const htmlSnippetForAppSurveys = `<!-- START Formbricks Surveys --> const htmlSnippetForAppSurveys = `<!-- START Formbricks Surveys -->
<script type="text/javascript"> <script type="text/javascript">
!function(){ !function(){
var appUrl = "${webAppUrl}"; var apiHost = "${webAppUrl}";
var environmentId = "${environmentId}"; var environmentId = "${environmentId}";
var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=appUrl+"/js/formbricks.umd.cjs",t.onload=function(){window.formbricks?window.formbricks.setup({environmentId:environmentId,appUrl:appUrl}):console.error("Formbricks library failed to load properly. The formbricks object is not available.");};var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e)}(); var userId = "testUser";
var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=apiHost+"/js/formbricks.umd.cjs";var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e),setTimeout(function(){window.formbricks.init({environmentId: environmentId, apiHost: apiHost, userId: userId})},500)}();
</script> </script>
<!-- END Formbricks Surveys --> <!-- END Formbricks Surveys -->
`; `;
@@ -44,9 +45,9 @@ export const OnboardingSetupInstructions = ({
const htmlSnippetForWebsiteSurveys = `<!-- START Formbricks Surveys --> const htmlSnippetForWebsiteSurveys = `<!-- START Formbricks Surveys -->
<script type="text/javascript"> <script type="text/javascript">
!function(){ !function(){
var appUrl = "${webAppUrl}"; var apiHost = "${webAppUrl}";
var environmentId = "${environmentId}"; var environmentId = "${environmentId}";
var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=appUrl+"/js/formbricks.umd.cjs",t.onload=function(){window.formbricks?window.formbricks.setup({environmentId:environmentId,appUrl:appUrl}):console.error("Formbricks library failed to load properly. The formbricks object is not available.");};var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e)}(); var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=apiHost+"/js/formbricks.umd.cjs";var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e),setTimeout(function(){window.formbricks.init({environmentId: environmentId, apiHost: apiHost})},500)}();
</script> </script>
<!-- END Formbricks Surveys --> <!-- END Formbricks Surveys -->
`; `;
@@ -55,9 +56,10 @@ export const OnboardingSetupInstructions = ({
import formbricks from "@formbricks/js"; import formbricks from "@formbricks/js";
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
formbricks.setup({ formbricks.init({
environmentId: "${environmentId}", environmentId: "${environmentId}",
appUrl: "${webAppUrl}", apiHost: "${webAppUrl}",
userId: "testUser",
}); });
} }
@@ -73,9 +75,9 @@ export const OnboardingSetupInstructions = ({
import formbricks from "@formbricks/js"; import formbricks from "@formbricks/js";
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
formbricks.setup({ formbricks.init({
environmentId: "${environmentId}", environmentId: "${environmentId}",
appUrl: "${webAppUrl}", apiHost: "${webAppUrl}",
}); });
} }
@@ -1,10 +1,13 @@
import { getDefaultEndingCard } from "@/app/lib/templates"; import { getDefaultEndingCard } from "@/app/lib/templates";
import { createId } from "@paralleldrive/cuid2"; import { createId } from "@paralleldrive/cuid2";
import { TFnType } from "@tolgee/react"; import { TFnType } from "@tolgee/react";
import { logger } from "@formbricks/logger";
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types"; import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TXMTemplate } from "@formbricks/types/templates"; import { TXMTemplate } from "@formbricks/types/templates";
function logError(error: Error, context: string) {
console.error(`Error in ${context}:`, error);
}
export const getXMSurveyDefault = (t: TFnType): TXMTemplate => { export const getXMSurveyDefault = (t: TFnType): TXMTemplate => {
try { try {
return { return {
@@ -16,7 +19,7 @@ export const getXMSurveyDefault = (t: TFnType): TXMTemplate => {
}, },
}; };
} catch (error) { } catch (error) {
logger.error(error, "Failed to create default XM survey template"); logError(error, "getXMSurveyDefault");
throw error; // Re-throw after logging throw error; // Re-throw after logging
} }
}; };
@@ -446,7 +449,7 @@ export const getXMTemplates = (t: TFnType): TXMTemplate[] => {
enpsSurvey(t), enpsSurvey(t),
]; ];
} catch (error) { } catch (error) {
logger.error(error, "Unable to load XM templates, returning empty array"); logError(error, "getXMTemplates");
return []; // Return an empty array or handle as needed return []; // Return an empty array or handle as needed
} }
}; };
@@ -1,25 +1,27 @@
import { LandingSidebar } from "@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar"; import { LandingSidebar } from "@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/utils"; import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { Header } from "@/modules/ui/components/header"; import { Header } from "@/modules/ui/components/header";
import { getTranslate } from "@/tolgee/server"; import { getTranslate } from "@/tolgee/server";
import { getServerSession } from "next-auth";
import { notFound, redirect } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import { getOrganizationsByUserId } from "@formbricks/lib/organization/service"; import { getOrganization, getOrganizationsByUserId } from "@formbricks/lib/organization/service";
import { getUser } from "@formbricks/lib/user/service"; import { getUser } from "@formbricks/lib/user/service";
const Page = async (props) => { const Page = async (props) => {
const params = await props.params; const params = await props.params;
const t = await getTranslate(); const t = await getTranslate();
const session = await getServerSession(authOptions);
const { session, organization } = await getOrganizationAuth(params.organizationId); if (!session || !session.user) {
if (!session?.user) {
return redirect(`/auth/login`); return redirect(`/auth/login`);
} }
const user = await getUser(session.user.id); const user = await getUser(session.user.id);
if (!user) return notFound(); if (!user) return notFound();
const organization = await getOrganization(params.organizationId);
if (!organization) return notFound();
const organizations = await getOrganizationsByUserId(session.user.id); const organizations = await getOrganizationsByUserId(session.user.id);
const { features } = await getEnterpriseLicense(); const { features } = await getEnterpriseLicense();
@@ -1,156 +0,0 @@
import "@testing-library/jest-dom/vitest";
import { act, cleanup, render, screen } from "@testing-library/react";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { canUserAccessOrganization } from "@formbricks/lib/organization/auth";
import { getOrganization } from "@formbricks/lib/organization/service";
import { getUser } from "@formbricks/lib/user/service";
import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
import ProjectOnboardingLayout from "./layout";
// Mock all the modules and functions that this layout uses:
vi.mock("@formbricks/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
POSTHOG_API_KEY: "mock-posthog-api-key",
POSTHOG_HOST: "mock-posthog-host",
IS_POSTHOG_CONFIGURED: true,
ENCRYPTION_KEY: "mock-encryption-key",
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
GITHUB_ID: "mock-github-id",
GITHUB_SECRET: "test-githubID",
GOOGLE_CLIENT_ID: "test-google-client-id",
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
AZUREAD_CLIENT_ID: "test-azuread-client-id",
AZUREAD_CLIENT_SECRET: "test-azure",
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
OIDC_DISPLAY_NAME: "test-oidc-display-name",
OIDC_CLIENT_ID: "test-oidc-client-id",
OIDC_ISSUER: "test-oidc-issuer",
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false,
}));
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
vi.mock("next/navigation", () => ({
redirect: vi.fn(),
}));
vi.mock("@formbricks/lib/organization/auth", () => ({
canUserAccessOrganization: vi.fn(),
}));
vi.mock("@formbricks/lib/organization/service", () => ({
getOrganization: vi.fn(),
}));
vi.mock("@formbricks/lib/user/service", () => ({
getUser: vi.fn(),
}));
vi.mock("@/tolgee/server", () => ({
getTranslate: vi.fn(() => {
// Return a mock translator that just returns the key
return (key: string) => key;
}),
}));
// mock the child components
vi.mock("@/app/(app)/environments/[environmentId]/components/PosthogIdentify", () => ({
PosthogIdentify: () => <div data-testid="posthog-identify" />,
}));
vi.mock("@/modules/ui/components/toaster-client", () => ({
ToasterClient: () => <div data-testid="toaster-client" />,
}));
describe("ProjectOnboardingLayout", () => {
beforeEach(() => {
cleanup();
});
it("redirects to /auth/login if there is no session", async () => {
// Mock no session
vi.mocked(getServerSession).mockResolvedValueOnce(null);
const layoutElement = await ProjectOnboardingLayout({
params: { organizationId: "org-123" },
children: <div data-testid="child-content">Hello!</div>,
});
expect(redirect).toHaveBeenCalledWith("/auth/login");
// Layout returns nothing after redirect
expect(layoutElement).toBeUndefined();
});
it("throws an error if user does not exist", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({
user: { id: "user-123" },
});
vi.mocked(getUser).mockResolvedValueOnce(null); // no user in DB
await expect(
ProjectOnboardingLayout({
params: { organizationId: "org-123" },
children: <div data-testid="child-content">Hello!</div>,
})
).rejects.toThrow("common.user_not_found");
});
it("throws AuthorizationError if user cannot access organization", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } });
vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123" } as TUser);
vi.mocked(canUserAccessOrganization).mockResolvedValueOnce(false);
await expect(
ProjectOnboardingLayout({
params: { organizationId: "org-123" },
children: <div data-testid="child-content">Child</div>,
})
).rejects.toThrow("common.not_authorized");
});
it("throws an error if organization does not exist", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } });
vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123" } as TUser);
vi.mocked(canUserAccessOrganization).mockResolvedValueOnce(true);
vi.mocked(getOrganization).mockResolvedValueOnce(null);
await expect(
ProjectOnboardingLayout({
params: { organizationId: "org-123" },
children: <div data-testid="child-content">Hello!</div>,
})
).rejects.toThrow("common.organization_not_found");
});
it("renders child content plus PosthogIdentify & ToasterClient if everything is valid", async () => {
// Provide valid data
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } });
vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123", name: "Test User" } as TUser);
vi.mocked(canUserAccessOrganization).mockResolvedValueOnce(true);
vi.mocked(getOrganization).mockResolvedValueOnce({
id: "org-123",
name: "Test Org",
billing: {
plan: "enterprise",
},
} as TOrganization);
let layoutElement: React.ReactNode;
// Because it's an async server component, do it in an act
await act(async () => {
layoutElement = await ProjectOnboardingLayout({
params: { organizationId: "org-123" },
children: <div data-testid="child-content">Hello!</div>,
});
render(layoutElement);
});
expect(screen.getByTestId("child-content")).toHaveTextContent("Hello!");
expect(screen.getByTestId("posthog-identify")).toBeInTheDocument();
expect(screen.getByTestId("toaster-client")).toBeInTheDocument();
});
});
@@ -4,7 +4,6 @@ import { ToasterClient } from "@/modules/ui/components/toaster-client";
import { getTranslate } from "@/tolgee/server"; import { getTranslate } from "@/tolgee/server";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { IS_POSTHOG_CONFIGURED } from "@formbricks/lib/constants";
import { canUserAccessOrganization } from "@formbricks/lib/organization/auth"; import { canUserAccessOrganization } from "@formbricks/lib/organization/auth";
import { getOrganization } from "@formbricks/lib/organization/service"; import { getOrganization } from "@formbricks/lib/organization/service";
import { getUser } from "@formbricks/lib/user/service"; import { getUser } from "@formbricks/lib/user/service";
@@ -17,8 +16,7 @@ const ProjectOnboardingLayout = async (props) => {
const t = await getTranslate(); const t = await getTranslate();
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
if (!session || !session.user) {
if (!session?.user) {
return redirect(`/auth/login`); return redirect(`/auth/login`);
} }
@@ -28,9 +26,8 @@ const ProjectOnboardingLayout = async (props) => {
} }
const isAuthorized = await canUserAccessOrganization(session.user.id, params.organizationId); const isAuthorized = await canUserAccessOrganization(session.user.id, params.organizationId);
if (!isAuthorized) { if (!isAuthorized) {
throw new AuthorizationError(t("common.not_authorized")); throw AuthorizationError;
} }
const organization = await getOrganization(params.organizationId); const organization = await getOrganization(params.organizationId);
@@ -46,7 +43,6 @@ const ProjectOnboardingLayout = async (props) => {
organizationId={organization.id} organizationId={organization.id}
organizationName={organization.name} organizationName={organization.name}
organizationBilling={organization.billing} organizationBilling={organization.billing}
isPosthogEnabled={IS_POSTHOG_CONFIGURED}
/> />
<ToasterClient /> <ToasterClient />
{children} {children}
@@ -1,9 +1,10 @@
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer"; import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
import { getOrganizationAuth } from "@/modules/organization/lib/utils"; import { authOptions } from "@/modules/auth/lib/authOptions";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { Header } from "@/modules/ui/components/header"; import { Header } from "@/modules/ui/components/header";
import { getTranslate } from "@/tolgee/server"; import { getTranslate } from "@/tolgee/server";
import { PictureInPicture2Icon, SendIcon, XIcon } from "lucide-react"; import { PictureInPicture2Icon, SendIcon, XIcon } from "lucide-react";
import { getServerSession } from "next-auth";
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getUserProjects } from "@formbricks/lib/project/service"; import { getUserProjects } from "@formbricks/lib/project/service";
@@ -16,10 +17,8 @@ interface ChannelPageProps {
const Page = async (props: ChannelPageProps) => { const Page = async (props: ChannelPageProps) => {
const params = await props.params; const params = await props.params;
const session = await getServerSession(authOptions);
const { session } = await getOrganizationAuth(params.organizationId); if (!session || !session.user) {
if (!session?.user) {
return redirect(`/auth/login`); return redirect(`/auth/login`);
} }
@@ -1,9 +1,10 @@
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer"; import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
import { getOrganizationAuth } from "@/modules/organization/lib/utils"; import { authOptions } from "@/modules/auth/lib/authOptions";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { Header } from "@/modules/ui/components/header"; import { Header } from "@/modules/ui/components/header";
import { getTranslate } from "@/tolgee/server"; import { getTranslate } from "@/tolgee/server";
import { HeartIcon, ListTodoIcon, XIcon } from "lucide-react"; import { HeartIcon, ListTodoIcon, XIcon } from "lucide-react";
import { getServerSession } from "next-auth";
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getUserProjects } from "@formbricks/lib/project/service"; import { getUserProjects } from "@formbricks/lib/project/service";
@@ -16,10 +17,8 @@ interface ModePageProps {
const Page = async (props: ModePageProps) => { const Page = async (props: ModePageProps) => {
const params = await props.params; const params = await props.params;
const session = await getServerSession(authOptions);
const { session } = await getOrganizationAuth(params.organizationId); if (!session || !session.user) {
if (!session?.user) {
return redirect(`/auth/login`); return redirect(`/auth/login`);
} }
@@ -231,7 +231,6 @@ export const ProjectSettings = ({
<p className="text-sm text-slate-400">{t("common.preview")}</p> <p className="text-sm text-slate-400">{t("common.preview")}</p>
<div className="z-0 h-3/4 w-3/4"> <div className="z-0 h-3/4 w-3/4">
<SurveyInline <SurveyInline
isPreviewMode={true}
survey={previewSurvey(projectName || "my Product", t)} survey={previewSurvey(projectName || "my Product", t)}
styling={{ brandColor: { light: brandColor } }} styling={{ brandColor: { light: brandColor } }}
isBrandingEnabled={false} isBrandingEnabled={false}
@@ -1,14 +1,16 @@
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding"; import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings"; import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getRoleManagementPermission } from "@/modules/ee/license-check/lib/utils"; import { getRoleManagementPermission } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { Header } from "@/modules/ui/components/header"; import { Header } from "@/modules/ui/components/header";
import { getTranslate } from "@/tolgee/server"; import { getTranslate } from "@/tolgee/server";
import { XIcon } from "lucide-react"; import { XIcon } from "lucide-react";
import { getServerSession } from "next-auth";
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { DEFAULT_BRAND_COLOR } from "@formbricks/lib/constants"; import { DEFAULT_BRAND_COLOR } from "@formbricks/lib/constants";
import { getOrganization } from "@formbricks/lib/organization/service";
import { getUserProjects } from "@formbricks/lib/project/service"; import { getUserProjects } from "@formbricks/lib/project/service";
import { TProjectConfigChannel, TProjectConfigIndustry, TProjectMode } from "@formbricks/types/project"; import { TProjectConfigChannel, TProjectConfigIndustry, TProjectMode } from "@formbricks/types/project";
@@ -27,20 +29,25 @@ const Page = async (props: ProjectSettingsPageProps) => {
const searchParams = await props.searchParams; const searchParams = await props.searchParams;
const params = await props.params; const params = await props.params;
const t = await getTranslate(); const t = await getTranslate();
const session = await getServerSession(authOptions);
const { session, organization } = await getOrganizationAuth(params.organizationId); if (!session || !session.user) {
if (!session?.user) {
return redirect(`/auth/login`); return redirect(`/auth/login`);
} }
const channel = searchParams.channel ?? null; const channel = searchParams.channel || null;
const industry = searchParams.industry ?? null; const industry = searchParams.industry || null;
const mode = searchParams.mode ?? "surveys"; const mode = searchParams.mode || "surveys";
const projects = await getUserProjects(session.user.id, params.organizationId); const projects = await getUserProjects(session.user.id, params.organizationId);
const organizationTeams = await getTeamsByOrganizationId(params.organizationId); const organizationTeams = await getTeamsByOrganizationId(params.organizationId);
const organization = await getOrganization(params.organizationId);
if (!organization) {
throw new Error(t("common.organization_not_found"));
}
const canDoRoleManagement = await getRoleManagementPermission(organization.billing.plan); const canDoRoleManagement = await getRoleManagementPermission(organization.billing.plan);
if (!organizationTeams) { if (!organizationTeams) {
@@ -1,191 +0,0 @@
import "@testing-library/jest-dom/vitest";
import { act, cleanup, render, screen } from "@testing-library/react";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { getUser } from "@formbricks/lib/user/service";
import { TEnvironment } from "@formbricks/types/environment";
import { AuthorizationError } from "@formbricks/types/errors";
import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
import SurveyEditorEnvironmentLayout from "./layout";
// mock all dependencies
vi.mock("@formbricks/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
POSTHOG_API_KEY: "mock-posthog-api-key",
POSTHOG_HOST: "mock-posthog-host",
IS_POSTHOG_CONFIGURED: true,
ENCRYPTION_KEY: "mock-encryption-key",
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
GITHUB_ID: "mock-github-id",
GITHUB_SECRET: "test-githubID",
GOOGLE_CLIENT_ID: "test-google-client-id",
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
AZUREAD_CLIENT_ID: "test-azuread-client-id",
AZUREAD_CLIENT_SECRET: "test-azure",
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
OIDC_DISPLAY_NAME: "test-oidc-display-name",
OIDC_CLIENT_ID: "test-oidc-client-id",
OIDC_ISSUER: "test-oidc-issuer",
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false,
}));
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
vi.mock("next/navigation", () => ({
redirect: vi.fn(),
}));
vi.mock("@formbricks/lib/environment/auth", () => ({
hasUserEnvironmentAccess: vi.fn(),
}));
vi.mock("@formbricks/lib/environment/service", () => ({
getEnvironment: vi.fn(),
}));
vi.mock("@formbricks/lib/organization/service", () => ({
getOrganizationByEnvironmentId: vi.fn(),
}));
vi.mock("@formbricks/lib/user/service", () => ({
getUser: vi.fn(),
}));
vi.mock("@/tolgee/server", () => ({
getTranslate: vi.fn(() => {
return (key: string) => key; // trivial translator returning the key
}),
}));
// mock child components rendered by the layout:
vi.mock("@/app/(app)/components/FormbricksClient", () => ({
FormbricksClient: () => <div data-testid="formbricks-client" />,
}));
vi.mock("@/app/(app)/environments/[environmentId]/components/PosthogIdentify", () => ({
PosthogIdentify: () => <div data-testid="posthog-identify" />,
}));
vi.mock("@/modules/ui/components/toaster-client", () => ({
ToasterClient: () => <div data-testid="mock-toaster" />,
}));
vi.mock("@/modules/ui/components/dev-environment-banner", () => ({
DevEnvironmentBanner: ({ environment }: { environment: TEnvironment }) => (
<div data-testid="dev-environment-banner">{environment?.id || "no-env"}</div>
),
}));
vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext", () => ({
ResponseFilterProvider: ({ children }: { children: React.ReactNode }) => (
<div data-testid="mock-response-filter-provider">{children}</div>
),
}));
describe("SurveyEditorEnvironmentLayout", () => {
beforeEach(() => {
cleanup();
vi.clearAllMocks();
});
it("redirects to /auth/login if there is no session", async () => {
// Mock no session
vi.mocked(getServerSession).mockResolvedValueOnce(null);
const layoutElement = await SurveyEditorEnvironmentLayout({
params: { environmentId: "env-123" },
children: <div data-testid="child-content">Hello!</div>,
});
expect(redirect).toHaveBeenCalledWith("/auth/login");
// No JSX is returned after redirect
expect(layoutElement).toBeUndefined();
});
it("throws error if user does not exist in DB", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } });
vi.mocked(getUser).mockResolvedValueOnce(null); // user not found
await expect(
SurveyEditorEnvironmentLayout({
params: { environmentId: "env-123" },
children: <div data-testid="child-content">Hello!</div>,
})
).rejects.toThrow("common.user_not_found");
});
it("throws AuthorizationError if user does not have environment access", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } });
vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123", email: "test@example.com" } as TUser);
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(false);
await expect(
SurveyEditorEnvironmentLayout({
params: { environmentId: "env-123" },
children: <div>Child</div>,
})
).rejects.toThrow(AuthorizationError);
});
it("throws if no organization is found", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } });
vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123" } as TUser);
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(true);
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce(null);
await expect(
SurveyEditorEnvironmentLayout({
params: { environmentId: "env-123" },
children: <div data-testid="child-content">Hello from children!</div>,
})
).rejects.toThrow("common.organization_not_found");
});
it("throws if no environment is found", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } });
vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123" } as TUser);
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(true);
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce({ id: "org-999" } as TOrganization);
vi.mocked(getEnvironment).mockResolvedValueOnce(null);
await expect(
SurveyEditorEnvironmentLayout({
params: { environmentId: "env-123" },
children: <div>Child</div>,
})
).rejects.toThrow("common.environment_not_found");
});
it("renders environment layout if everything is valid", async () => {
// Provide all valid data
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } });
vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123", email: "test@example.com" } as TUser);
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(true);
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce({ id: "org-999" } as TOrganization);
vi.mocked(getEnvironment).mockResolvedValueOnce({
id: "env-123",
name: "My Test Environment",
} as unknown as TEnvironment);
// Because it's an async server component, we typically wrap in act(...)
let layoutElement: React.ReactNode;
await act(async () => {
layoutElement = await SurveyEditorEnvironmentLayout({
params: { environmentId: "env-123" },
children: <div data-testid="child-content">Hello from children!</div>,
});
render(layoutElement);
});
// Now confirm we got the child plus all the mocked sub-components
expect(screen.getByTestId("child-content")).toHaveTextContent("Hello from children!");
expect(screen.getByTestId("posthog-identify")).toBeInTheDocument();
expect(screen.getByTestId("formbricks-client")).toBeInTheDocument();
expect(screen.getByTestId("mock-toaster")).toBeInTheDocument();
expect(screen.getByTestId("mock-response-filter-provider")).toBeInTheDocument();
expect(screen.getByTestId("dev-environment-banner")).toHaveTextContent("env-123");
});
});
@@ -7,7 +7,6 @@ import { ToasterClient } from "@/modules/ui/components/toaster-client";
import { getTranslate } from "@/tolgee/server"; import { getTranslate } from "@/tolgee/server";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { IS_POSTHOG_CONFIGURED } from "@formbricks/lib/constants";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth"; import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { getEnvironment } from "@formbricks/lib/environment/service"; import { getEnvironment } from "@formbricks/lib/environment/service";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service"; import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
@@ -21,8 +20,7 @@ const SurveyEditorEnvironmentLayout = async (props) => {
const t = await getTranslate(); const t = await getTranslate();
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
if (!session || !session.user) {
if (!session?.user) {
return redirect(`/auth/login`); return redirect(`/auth/login`);
} }
@@ -48,23 +46,24 @@ const SurveyEditorEnvironmentLayout = async (props) => {
} }
return ( return (
<ResponseFilterProvider> <>
<PosthogIdentify <ResponseFilterProvider>
session={session} <PosthogIdentify
user={user} session={session}
environmentId={params.environmentId} user={user}
organizationId={organization.id} environmentId={params.environmentId}
organizationName={organization.name} organizationId={organization.id}
organizationBilling={organization.billing} organizationName={organization.name}
isPosthogEnabled={IS_POSTHOG_CONFIGURED} organizationBilling={organization.billing}
/> />
<FormbricksClient userId={user.id} email={user.email} /> <FormbricksClient userId={user.id} email={user.email} />
<ToasterClient /> <ToasterClient />
<div className="flex h-screen flex-col"> <div className="flex h-screen flex-col">
<DevEnvironmentBanner environment={environment} /> <DevEnvironmentBanner environment={environment} />
<div className="h-full overflow-y-auto bg-slate-50">{children}</div> <div className="h-full overflow-y-auto bg-slate-50">{children}</div>
</div> </div>
</ResponseFilterProvider> </ResponseFilterProvider>
</>
); );
}; };
@@ -1,77 +0,0 @@
import { render } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import formbricks from "@formbricks/js";
import { FormbricksClient } from "./FormbricksClient";
// Mock next/navigation hooks.
vi.mock("next/navigation", () => ({
usePathname: () => "/test-path",
useSearchParams: () => new URLSearchParams("foo=bar"),
}));
// Mock the environment variables.
vi.mock("@formbricks/lib/env", () => ({
env: {
NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID: "env-test",
NEXT_PUBLIC_FORMBRICKS_API_HOST: "https://api.test.com",
},
}));
// Mock the flag that enables Formbricks.
vi.mock("@/app/lib/formbricks", () => ({
formbricksEnabled: true,
}));
// Mock the Formbricks SDK module.
vi.mock("@formbricks/js", () => ({
__esModule: true,
default: {
setup: vi.fn(),
setUserId: vi.fn(),
setEmail: vi.fn(),
registerRouteChange: vi.fn(),
},
}));
describe("FormbricksClient", () => {
afterEach(() => {
vi.clearAllMocks();
});
test("calls setup, setUserId, setEmail and registerRouteChange on mount when enabled", () => {
const mockSetup = vi.spyOn(formbricks, "setup");
const mockSetUserId = vi.spyOn(formbricks, "setUserId");
const mockSetEmail = vi.spyOn(formbricks, "setEmail");
const mockRegisterRouteChange = vi.spyOn(formbricks, "registerRouteChange");
render(<FormbricksClient userId="user-123" email="test@example.com" />);
// Expect the first effect to call setup and assign the provided user details.
expect(mockSetup).toHaveBeenCalledWith({
environmentId: "env-test",
appUrl: "https://api.test.com",
});
expect(mockSetUserId).toHaveBeenCalledWith("user-123");
expect(mockSetEmail).toHaveBeenCalledWith("test@example.com");
// And the second effect should always register the route change when Formbricks is enabled.
expect(mockRegisterRouteChange).toHaveBeenCalled();
});
test("does not call setup, setUserId, or setEmail if userId is not provided yet still calls registerRouteChange", () => {
const mockSetup = vi.spyOn(formbricks, "setup");
const mockSetUserId = vi.spyOn(formbricks, "setUserId");
const mockSetEmail = vi.spyOn(formbricks, "setEmail");
const mockRegisterRouteChange = vi.spyOn(formbricks, "registerRouteChange");
render(<FormbricksClient userId="" email="test@example.com" />);
// Since userId is falsy, the first effect should not call setup or assign user details.
expect(mockSetup).not.toHaveBeenCalled();
expect(mockSetUserId).not.toHaveBeenCalled();
expect(mockSetEmail).not.toHaveBeenCalled();
// The second effect only checks formbricksEnabled, so registerRouteChange should be called.
expect(mockRegisterRouteChange).toHaveBeenCalled();
});
});
@@ -12,12 +12,12 @@ export const FormbricksClient = ({ userId, email }: { userId: string; email: str
useEffect(() => { useEffect(() => {
if (formbricksEnabled && userId) { if (formbricksEnabled && userId) {
formbricks.setup({ formbricks.init({
environmentId: env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID || "", environmentId: env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID || "",
appUrl: env.NEXT_PUBLIC_FORMBRICKS_API_HOST || "", apiHost: env.NEXT_PUBLIC_FORMBRICKS_API_HOST || "",
userId,
}); });
formbricks.setUserId(userId);
formbricks.setEmail(email); formbricks.setEmail(email);
} }
}, [userId, email]); }, [userId, email]);
@@ -2,14 +2,21 @@ import { ActionClassesTable } from "@/app/(app)/environments/[environmentId]/act
import { ActionClassDataRow } from "@/app/(app)/environments/[environmentId]/actions/components/ActionRowData"; import { ActionClassDataRow } from "@/app/(app)/environments/[environmentId]/actions/components/ActionRowData";
import { ActionTableHeading } from "@/app/(app)/environments/[environmentId]/actions/components/ActionTableHeading"; import { ActionTableHeading } from "@/app/(app)/environments/[environmentId]/actions/components/ActionTableHeading";
import { AddActionModal } from "@/app/(app)/environments/[environmentId]/actions/components/AddActionModal"; import { AddActionModal } from "@/app/(app)/environments/[environmentId]/actions/components/AddActionModal";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; import { authOptions } from "@/modules/auth/lib/authOptions";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header"; import { PageHeader } from "@/modules/ui/components/page-header";
import { getTranslate } from "@/tolgee/server"; import { getTranslate } from "@/tolgee/server";
import { Metadata } from "next"; import { Metadata } from "next";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getActionClasses } from "@formbricks/lib/actionClass/service"; import { getActionClasses } from "@formbricks/lib/actionClass/service";
import { getEnvironments } from "@formbricks/lib/environment/service"; import { getEnvironments } from "@formbricks/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
import { findMatchingLocale } from "@formbricks/lib/utils/locale"; import { findMatchingLocale } from "@formbricks/lib/utils/locale";
export const metadata: Metadata = { export const metadata: Metadata = {
@@ -18,24 +25,51 @@ export const metadata: Metadata = {
const Page = async (props) => { const Page = async (props) => {
const params = await props.params; const params = await props.params;
const session = await getServerSession(authOptions);
const { isReadOnly, project, isBilling, environment } = await getEnvironmentAuth(params.environmentId);
const t = await getTranslate(); const t = await getTranslate();
const [actionClasses, organization, project] = await Promise.all([
const [actionClasses] = await Promise.all([getActionClasses(params.environmentId)]); getActionClasses(params.environmentId),
getOrganizationByEnvironmentId(params.environmentId),
getProjectByEnvironmentId(params.environmentId),
]);
const locale = await findMatchingLocale(); const locale = await findMatchingLocale();
if (!session) {
throw new Error(t("common.session_not_found"));
}
if (!organization) {
throw new Error(t("common.organization_not_found"));
}
if (!project) {
throw new Error(t("common.project_not_found"));
}
const environments = await getEnvironments(project.id); const environments = await getEnvironments(project.id);
const currentEnvironment = environments.find((env) => env.id === params.environmentId);
if (!currentEnvironment) {
throw new Error(t("common.environment_not_found"));
}
const otherEnvironment = environments.filter((env) => env.id !== params.environmentId)[0]; const otherEnvironment = environments.filter((env) => env.id !== params.environmentId)[0];
const otherEnvActionClasses = await getActionClasses(otherEnvironment.id); const otherEnvActionClasses = await getActionClasses(otherEnvironment.id);
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
const { isMember, isBilling } = getAccessFlags(currentUserMembership?.role);
const projectPermission = await getProjectPermissionByUserId(session?.user.id, project.id);
const { hasReadAccess } = getTeamPermissionFlags(projectPermission);
if (isBilling) { if (isBilling) {
return redirect(`/environments/${params.environmentId}/settings/billing`); return redirect(`/environments/${params.environmentId}/settings/billing`);
} }
const isReadOnly = isMember && hasReadAccess;
const renderAddActionButton = () => ( const renderAddActionButton = () => (
<AddActionModal <AddActionModal
environmentId={params.environmentId} environmentId={params.environmentId}
@@ -48,7 +82,7 @@ const Page = async (props) => {
<PageContentWrapper> <PageContentWrapper>
<PageHeader pageTitle={t("common.actions")} cta={!isReadOnly ? renderAddActionButton() : undefined} /> <PageHeader pageTitle={t("common.actions")} cta={!isReadOnly ? renderAddActionButton() : undefined} />
<ActionClassesTable <ActionClassesTable
environment={environment} environment={currentEnvironment}
otherEnvironment={otherEnvironment} otherEnvironment={otherEnvironment}
otherEnvActionClasses={otherEnvActionClasses} otherEnvActionClasses={otherEnvActionClasses}
environmentId={params.environmentId} environmentId={params.environmentId}
@@ -7,7 +7,7 @@ import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-bann
import { PendingDowngradeBanner } from "@/modules/ui/components/pending-downgrade-banner"; import { PendingDowngradeBanner } from "@/modules/ui/components/pending-downgrade-banner";
import { getTranslate } from "@/tolgee/server"; import { getTranslate } from "@/tolgee/server";
import type { Session } from "next-auth"; import type { Session } from "next-auth";
import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants"; import { 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,7 +111,6 @@ 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,7 +63,6 @@ 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;
@@ -80,7 +79,6 @@ export const MainNavigation = ({
isFormbricksCloud, isFormbricksCloud,
organizationProjectsLimit, organizationProjectsLimit,
isLicenseActive, isLicenseActive,
isDevelopment,
}: NavigationProps) => { }: NavigationProps) => {
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
@@ -298,7 +296,7 @@ export const MainNavigation = ({
<div> <div>
{/* New Version Available */} {/* New Version Available */}
{!isCollapsed && isOwnerOrManager && latestVersion && !isFormbricksCloud && !isDevelopment && ( {!isCollapsed && isOwnerOrManager && latestVersion && !isFormbricksCloud && (
<Link <Link
href="https://github.com/formbricks/formbricks/releases" href="https://github.com/formbricks/formbricks/releases"
target="_blank" target="_blank"
@@ -1,151 +0,0 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render } from "@testing-library/react";
import { Session } from "next-auth";
import { usePostHog } from "posthog-js/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { TOrganizationBilling } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
import { PosthogIdentify } from "./PosthogIdentify";
type PartialPostHog = Partial<ReturnType<typeof usePostHog>>;
vi.mock("posthog-js/react", () => ({
usePostHog: vi.fn(),
}));
describe("PosthogIdentify", () => {
beforeEach(() => {
cleanup();
});
it("identifies the user and sets groups when isPosthogEnabled is true", () => {
const mockIdentify = vi.fn();
const mockGroup = vi.fn();
const mockPostHog: PartialPostHog = {
identify: mockIdentify,
group: mockGroup,
};
vi.mocked(usePostHog).mockReturnValue(mockPostHog as ReturnType<typeof usePostHog>);
render(
<PosthogIdentify
session={{ user: { id: "user-123" } } as Session}
user={
{
name: "Test User",
email: "test@example.com",
role: "engineer",
objective: "increase_conversion",
} as TUser
}
environmentId="env-456"
organizationId="org-789"
organizationName="Test Org"
organizationBilling={
{
plan: "enterprise",
limits: { monthly: { responses: 1000, miu: 5000 }, projects: 10 },
} as TOrganizationBilling
}
isPosthogEnabled
/>
);
// verify that identify is called with the session user id + extra info
expect(mockIdentify).toHaveBeenCalledWith("user-123", {
name: "Test User",
email: "test@example.com",
role: "engineer",
objective: "increase_conversion",
});
// environment + organization groups
expect(mockGroup).toHaveBeenCalledTimes(2);
expect(mockGroup).toHaveBeenCalledWith("environment", "env-456", { name: "env-456" });
expect(mockGroup).toHaveBeenCalledWith("organization", "org-789", {
name: "Test Org",
plan: "enterprise",
responseLimit: 1000,
miuLimit: 5000,
});
});
it("does nothing if isPosthogEnabled is false", () => {
const mockIdentify = vi.fn();
const mockGroup = vi.fn();
const mockPostHog: PartialPostHog = {
identify: mockIdentify,
group: mockGroup,
};
vi.mocked(usePostHog).mockReturnValue(mockPostHog as ReturnType<typeof usePostHog>);
render(
<PosthogIdentify
session={{ user: { id: "user-123" } } as Session}
user={{ name: "Test User", email: "test@example.com" } as TUser}
isPosthogEnabled={false}
/>
);
expect(mockIdentify).not.toHaveBeenCalled();
expect(mockGroup).not.toHaveBeenCalled();
});
it("does nothing if session user is missing", () => {
const mockIdentify = vi.fn();
const mockGroup = vi.fn();
const mockPostHog: PartialPostHog = {
identify: mockIdentify,
group: mockGroup,
};
vi.mocked(usePostHog).mockReturnValue(mockPostHog as ReturnType<typeof usePostHog>);
render(
<PosthogIdentify
// no user in session
session={{} as any}
user={{ name: "Test User", email: "test@example.com" } as TUser}
isPosthogEnabled
/>
);
// Because there's no session.user, we skip identify
expect(mockIdentify).not.toHaveBeenCalled();
expect(mockGroup).not.toHaveBeenCalled();
});
it("identifies user but does not group if environmentId/organizationId not provided", () => {
const mockIdentify = vi.fn();
const mockGroup = vi.fn();
const mockPostHog: PartialPostHog = {
identify: mockIdentify,
group: mockGroup,
};
vi.mocked(usePostHog).mockReturnValue(mockPostHog as ReturnType<typeof usePostHog>);
render(
<PosthogIdentify
session={{ user: { id: "user-123" } } as Session}
user={{ name: "Test User", email: "test@example.com" } as TUser}
isPosthogEnabled
/>
);
expect(mockIdentify).toHaveBeenCalledWith("user-123", {
name: "Test User",
email: "test@example.com",
role: undefined,
objective: undefined,
});
// No environmentId or organizationId => no group calls
expect(mockGroup).not.toHaveBeenCalled();
});
});
@@ -3,9 +3,12 @@
import type { Session } from "next-auth"; import type { Session } from "next-auth";
import { usePostHog } from "posthog-js/react"; import { usePostHog } from "posthog-js/react";
import { useEffect } from "react"; import { useEffect } from "react";
import { env } from "@formbricks/lib/env";
import { TOrganizationBilling } from "@formbricks/types/organizations"; import { TOrganizationBilling } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user"; import { TUser } from "@formbricks/types/user";
const posthogEnabled = env.NEXT_PUBLIC_POSTHOG_API_KEY && env.NEXT_PUBLIC_POSTHOG_API_HOST;
interface PosthogIdentifyProps { interface PosthogIdentifyProps {
session: Session; session: Session;
user: TUser; user: TUser;
@@ -13,7 +16,6 @@ interface PosthogIdentifyProps {
organizationId?: string; organizationId?: string;
organizationName?: string; organizationName?: string;
organizationBilling?: TOrganizationBilling; organizationBilling?: TOrganizationBilling;
isPosthogEnabled: boolean;
} }
export const PosthogIdentify = ({ export const PosthogIdentify = ({
@@ -23,12 +25,11 @@ export const PosthogIdentify = ({
organizationId, organizationId,
organizationName, organizationName,
organizationBilling, organizationBilling,
isPosthogEnabled,
}: PosthogIdentifyProps) => { }: PosthogIdentifyProps) => {
const posthog = usePostHog(); const posthog = usePostHog();
useEffect(() => { useEffect(() => {
if (isPosthogEnabled && session.user && posthog) { if (posthogEnabled && session.user && posthog) {
posthog.identify(session.user.id, { posthog.identify(session.user.id, {
name: user.name, name: user.name,
email: user.email, email: user.email,
@@ -58,7 +59,6 @@ export const PosthogIdentify = ({
user.email, user.email,
user.role, user.role,
user.objective, user.objective,
isPosthogEnabled,
]); ]);
return null; return null;
@@ -6,7 +6,7 @@ import {
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox"; } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox";
import { QuestionFilterOptions } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResponseFilter"; import { QuestionFilterOptions } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResponseFilter";
import { getTodayDate } from "@/app/lib/surveys/surveys"; import { getTodayDate } from "@/app/lib/surveys/surveys";
import React, { createContext, useCallback, useContext, useState } from "react"; import { createContext, useCallback, useContext, useState } from "react";
export interface FilterValue { export interface FilterValue {
questionType: Partial<QuestionOption>; questionType: Partial<QuestionOption>;
@@ -1,5 +1,6 @@
import { TopControlButtons } from "@/app/(app)/environments/[environmentId]/components/TopControlButtons"; import { TopControlButtons } from "@/app/(app)/environments/[environmentId]/components/TopControlButtons";
import { TTeamPermission } from "@/modules/ee/teams/project-teams/types/team"; import { TTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { TEnvironment } from "@formbricks/types/environment"; import { TEnvironment } from "@formbricks/types/environment";
import { TOrganizationRole } from "@formbricks/types/memberships"; import { TOrganizationRole } from "@formbricks/types/memberships";
@@ -23,6 +24,7 @@ export const TopControlBar = ({
<TopControlButtons <TopControlButtons
environment={environment} environment={environment}
environments={environments} environments={environments}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
membershipRole={membershipRole} membershipRole={membershipRole}
projectPermission={projectPermission} projectPermission={projectPermission}
/> />
@@ -6,9 +6,9 @@ import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { TooltipRenderer } from "@/modules/ui/components/tooltip"; import { TooltipRenderer } from "@/modules/ui/components/tooltip";
import { useTranslate } from "@tolgee/react"; import { useTranslate } from "@tolgee/react";
import { BugIcon, CircleUserIcon, PlusIcon } from "lucide-react"; import { CircleUserIcon, MessageCircleQuestionIcon, PlusIcon } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import formbricks from "@formbricks/js";
import { getAccessFlags } from "@formbricks/lib/membership/utils"; import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { TEnvironment } from "@formbricks/types/environment"; import { TEnvironment } from "@formbricks/types/environment";
import { TOrganizationRole } from "@formbricks/types/memberships"; import { TOrganizationRole } from "@formbricks/types/memberships";
@@ -16,6 +16,7 @@ import { TOrganizationRole } from "@formbricks/types/memberships";
interface TopControlButtonsProps { interface TopControlButtonsProps {
environment: TEnvironment; environment: TEnvironment;
environments: TEnvironment[]; environments: TEnvironment[];
isFormbricksCloud: boolean;
membershipRole?: TOrganizationRole; membershipRole?: TOrganizationRole;
projectPermission: TTeamPermission | null; projectPermission: TTeamPermission | null;
} }
@@ -23,6 +24,7 @@ interface TopControlButtonsProps {
export const TopControlButtons = ({ export const TopControlButtons = ({
environment, environment,
environments, environments,
isFormbricksCloud,
membershipRole, membershipRole,
projectPermission, projectPermission,
}: TopControlButtonsProps) => { }: TopControlButtonsProps) => {
@@ -36,15 +38,19 @@ export const TopControlButtons = ({
return ( return (
<div className="z-50 flex items-center space-x-2"> <div className="z-50 flex items-center space-x-2">
{!isBilling && <EnvironmentSwitch environment={environment} environments={environments} />} {!isBilling && <EnvironmentSwitch environment={environment} environments={environments} />}
{isFormbricksCloud && (
<TooltipRenderer tooltipContent={t("common.share_feedback")}> <TooltipRenderer tooltipContent={t("common.share_feedback")}>
<Button variant="ghost" size="icon" className="h-fit w-fit bg-slate-50 p-1" asChild> <Button
<Link href="https://github.com/formbricks/formbricks/issues" target="_blank"> variant="ghost"
<BugIcon /> size="icon"
</Link> className="h-fit w-fit bg-slate-50 p-1"
</Button> onClick={() => {
</TooltipRenderer> formbricks.track("Top Menu: Product Feedback");
}}>
<MessageCircleQuestionIcon />
</Button>
</TooltipRenderer>
)}
<TooltipRenderer tooltipContent={t("common.account")}> <TooltipRenderer tooltipContent={t("common.account")}>
<Button <Button
variant="ghost" variant="ghost"
@@ -1,4 +1,3 @@
import { logger } from "@formbricks/logger";
import { TIntegrationAirtableTables } from "@formbricks/types/integration/airtable"; import { TIntegrationAirtableTables } from "@formbricks/types/integration/airtable";
export const fetchTables = async (environmentId: string, baseId: string) => { export const fetchTables = async (environmentId: string, baseId: string) => {
@@ -18,8 +17,7 @@ export const authorize = async (environmentId: string, apiHost: string): Promise
}); });
if (!res.ok) { if (!res.ok) {
const errorText = await res.text(); console.error(res.text);
logger.error({ errorText }, "authorize: Could not fetch airtable config");
throw new Error("Could not create response"); throw new Error("Could not create response");
} }
const resJSON = await res.json(); const resJSON = await res.json();
@@ -1,14 +1,21 @@
import { AirtableWrapper } from "@/app/(app)/environments/[environmentId]/integrations/airtable/components/AirtableWrapper"; import { AirtableWrapper } from "@/app/(app)/environments/[environmentId]/integrations/airtable/components/AirtableWrapper";
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys"; import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; import { authOptions } from "@/modules/auth/lib/authOptions";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
import { GoBackButton } from "@/modules/ui/components/go-back-button"; import { GoBackButton } from "@/modules/ui/components/go-back-button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header"; import { PageHeader } from "@/modules/ui/components/page-header";
import { getTranslate } from "@/tolgee/server"; import { getTranslate } from "@/tolgee/server";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getAirtableTables } from "@formbricks/lib/airtable/service"; import { getAirtableTables } from "@formbricks/lib/airtable/service";
import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@formbricks/lib/constants"; import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@formbricks/lib/constants";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getIntegrations } from "@formbricks/lib/integration/service"; import { getIntegrations } from "@formbricks/lib/integration/service";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
import { findMatchingLocale } from "@formbricks/lib/utils/locale"; import { findMatchingLocale } from "@formbricks/lib/utils/locale";
import { TIntegrationItem } from "@formbricks/types/integration"; import { TIntegrationItem } from "@formbricks/types/integration";
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable"; import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
@@ -17,25 +24,48 @@ const Page = async (props) => {
const params = await props.params; const params = await props.params;
const t = await getTranslate(); const t = await getTranslate();
const isEnabled = !!AIRTABLE_CLIENT_ID; const isEnabled = !!AIRTABLE_CLIENT_ID;
const [session, surveys, integrations, environment] = await Promise.all([
const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId); getServerSession(authOptions),
const [surveys, integrations] = await Promise.all([
getSurveys(params.environmentId), getSurveys(params.environmentId),
getIntegrations(params.environmentId), getIntegrations(params.environmentId),
getEnvironment(params.environmentId),
]); ]);
if (!session) {
throw new Error(t("common.session_not_found"));
}
if (!environment) {
throw new Error(t("common.environment_not_found"));
}
const project = await getProjectByEnvironmentId(params.environmentId);
if (!project) {
throw new Error(t("common.project_not_found"));
}
const airtableIntegration: TIntegrationAirtable | undefined = integrations?.find( const airtableIntegration: TIntegrationAirtable | undefined = integrations?.find(
(integration): integration is TIntegrationAirtable => integration.type === "airtable" (integration): integration is TIntegrationAirtable => integration.type === "airtable"
); );
let airtableArray: TIntegrationItem[] = []; let airtableArray: TIntegrationItem[] = [];
if (airtableIntegration?.config.key) { if (airtableIntegration && airtableIntegration.config.key) {
airtableArray = await getAirtableTables(params.environmentId); airtableArray = await getAirtableTables(params.environmentId);
} }
const locale = await findMatchingLocale(); const locale = await findMatchingLocale();
const currentUserMembership = await getMembershipByUserIdOrganizationId(
session?.user.id,
project.organizationId
);
const { isMember } = getAccessFlags(currentUserMembership?.role);
const projectPermission = await getProjectPermissionByUserId(session?.user.id, environment?.projectId);
const { hasReadAccess } = getTeamPermissionFlags(projectPermission);
const isReadOnly = isMember && hasReadAccess;
if (isReadOnly) { if (isReadOnly) {
redirect("./"); redirect("./");
} }
@@ -1,41 +1,25 @@
"use server"; "use server";
import { authenticatedActionClient } from "@/lib/utils/action-client"; import { authOptions } from "@/modules/auth/lib/authOptions";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware"; import { getServerSession } from "next-auth";
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper"; import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { z } from "zod";
import { getSpreadsheetNameById } from "@formbricks/lib/googleSheet/service"; import { getSpreadsheetNameById } from "@formbricks/lib/googleSheet/service";
import { ZIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet"; import { AuthorizationError } from "@formbricks/types/errors";
import { TIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";
const ZGetSpreadsheetNameByIdAction = z.object({ export async function getSpreadsheetNameByIdAction(
googleSheetIntegration: ZIntegrationGoogleSheets, googleSheetIntegration: TIntegrationGoogleSheets,
environmentId: z.string(), environmentId: string,
spreadsheetId: z.string(), spreadsheetId: string
}); ) {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
export const getSpreadsheetNameByIdAction = authenticatedActionClient const isAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId);
.schema(ZGetSpreadsheetNameByIdAction) if (!isAuthorized) throw new AuthorizationError("Not authorized");
.action(async ({ ctx, parsedInput }) => { const integrationData = structuredClone(googleSheetIntegration);
await checkAuthorizationUpdated({ integrationData.config.data.forEach((data) => {
userId: ctx.user.id, data.createdAt = new Date(data.createdAt);
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
minPermission: "readWrite",
},
],
});
const integrationData = structuredClone(parsedInput.googleSheetIntegration);
integrationData.config.data.forEach((data) => {
data.createdAt = new Date(data.createdAt);
});
return await getSpreadsheetNameById(integrationData, parsedInput.spreadsheetId);
}); });
return await getSpreadsheetNameById(integrationData, spreadsheetId);
}
@@ -8,7 +8,6 @@ import {
isValidGoogleSheetsUrl, isValidGoogleSheetsUrl,
} from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/lib/util"; } from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/lib/util";
import GoogleSheetLogo from "@/images/googleSheetsLogo.png"; import GoogleSheetLogo from "@/images/googleSheetsLogo.png";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings"; import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { Checkbox } from "@/modules/ui/components/checkbox"; import { Checkbox } from "@/modules/ui/components/checkbox";
@@ -116,18 +115,11 @@ export const AddIntegrationModal = ({
throw new Error(t("environments.integrations.select_at_least_one_question_error")); throw new Error(t("environments.integrations.select_at_least_one_question_error"));
} }
const spreadsheetId = extractSpreadsheetIdFromUrl(spreadsheetUrl); const spreadsheetId = extractSpreadsheetIdFromUrl(spreadsheetUrl);
const spreadsheetNameResponse = await getSpreadsheetNameByIdAction({ const spreadsheetName = await getSpreadsheetNameByIdAction(
googleSheetIntegration, googleSheetIntegration,
environmentId, environmentId,
spreadsheetId, spreadsheetId
}); );
if (!spreadsheetNameResponse?.data) {
const errorMessage = getFormattedErrorMessage(spreadsheetNameResponse);
throw new Error(errorMessage);
}
const spreadsheetName = spreadsheetNameResponse.data;
setIsLinkingSheet(true); setIsLinkingSheet(true);
integrationData.spreadsheetId = spreadsheetId; integrationData.spreadsheetId = spreadsheetId;
@@ -1,5 +1,3 @@
import { logger } from "@formbricks/logger";
export const authorize = async (environmentId: string, apiHost: string): Promise<string> => { export const authorize = async (environmentId: string, apiHost: string): Promise<string> => {
const res = await fetch(`${apiHost}/api/google-sheet`, { const res = await fetch(`${apiHost}/api/google-sheet`, {
method: "GET", method: "GET",
@@ -7,8 +5,7 @@ export const authorize = async (environmentId: string, apiHost: string): Promise
}); });
if (!res.ok) { if (!res.ok) {
const errorText = await res.text(); console.error(res.text);
logger.error({ errorText }, "authorize: Could not fetch google sheet config");
throw new Error("Could not create response"); throw new Error("Could not create response");
} }
const resJSON = await res.json(); const resJSON = await res.json();
@@ -1,10 +1,13 @@
import { GoogleSheetWrapper } from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/components/GoogleSheetWrapper"; import { GoogleSheetWrapper } from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/components/GoogleSheetWrapper";
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys"; import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; import { authOptions } from "@/modules/auth/lib/authOptions";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
import { GoBackButton } from "@/modules/ui/components/go-back-button"; import { GoBackButton } from "@/modules/ui/components/go-back-button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header"; import { PageHeader } from "@/modules/ui/components/page-header";
import { getTranslate } from "@/tolgee/server"; import { getTranslate } from "@/tolgee/server";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { import {
GOOGLE_SHEETS_CLIENT_ID, GOOGLE_SHEETS_CLIENT_ID,
@@ -12,7 +15,11 @@ import {
GOOGLE_SHEETS_REDIRECT_URL, GOOGLE_SHEETS_REDIRECT_URL,
WEBAPP_URL, WEBAPP_URL,
} from "@formbricks/lib/constants"; } from "@formbricks/lib/constants";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getIntegrations } from "@formbricks/lib/integration/service"; import { getIntegrations } from "@formbricks/lib/integration/service";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
import { findMatchingLocale } from "@formbricks/lib/utils/locale"; import { findMatchingLocale } from "@formbricks/lib/utils/locale";
import { TIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet"; import { TIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";
@@ -20,20 +27,43 @@ const Page = async (props) => {
const params = await props.params; const params = await props.params;
const t = await getTranslate(); const t = await getTranslate();
const isEnabled = !!(GOOGLE_SHEETS_CLIENT_ID && GOOGLE_SHEETS_CLIENT_SECRET && GOOGLE_SHEETS_REDIRECT_URL); const isEnabled = !!(GOOGLE_SHEETS_CLIENT_ID && GOOGLE_SHEETS_CLIENT_SECRET && GOOGLE_SHEETS_REDIRECT_URL);
const [session, surveys, integrations, environment] = await Promise.all([
const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId); getServerSession(authOptions),
const [surveys, integrations] = await Promise.all([
getSurveys(params.environmentId), getSurveys(params.environmentId),
getIntegrations(params.environmentId), getIntegrations(params.environmentId),
getEnvironment(params.environmentId),
]); ]);
if (!session) {
throw new Error(t("common.session_not_found"));
}
if (!environment) {
throw new Error(t("common.environment_not_found"));
}
const project = await getProjectByEnvironmentId(params.environmentId);
if (!project) {
throw new Error(t("common.project_not_found"));
}
const googleSheetIntegration: TIntegrationGoogleSheets | undefined = integrations?.find( const googleSheetIntegration: TIntegrationGoogleSheets | undefined = integrations?.find(
(integration): integration is TIntegrationGoogleSheets => integration.type === "googleSheets" (integration): integration is TIntegrationGoogleSheets => integration.type === "googleSheets"
); );
const locale = await findMatchingLocale(); const locale = await findMatchingLocale();
const currentUserMembership = await getMembershipByUserIdOrganizationId(
session?.user.id,
project.organizationId
);
const { isMember } = getAccessFlags(currentUserMembership?.role);
const projectPermission = await getProjectPermissionByUserId(session?.user.id, environment?.projectId);
const { hasReadAccess } = getTeamPermissionFlags(projectPermission);
const isReadOnly = isMember && hasReadAccess;
if (isReadOnly) { if (isReadOnly) {
redirect("./"); redirect("./");
} }
@@ -7,7 +7,6 @@ import { surveyCache } from "@formbricks/lib/survey/cache";
import { selectSurvey } from "@formbricks/lib/survey/service"; import { selectSurvey } from "@formbricks/lib/survey/service";
import { transformPrismaSurvey } from "@formbricks/lib/survey/utils"; import { transformPrismaSurvey } from "@formbricks/lib/survey/utils";
import { validateInputs } from "@formbricks/lib/utils/validate"; import { validateInputs } from "@formbricks/lib/utils/validate";
import { logger } from "@formbricks/logger";
import { ZId } from "@formbricks/types/common"; import { ZId } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors"; import { DatabaseError } from "@formbricks/types/errors";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types";
@@ -35,7 +34,7 @@ export const getSurveys = reactCache(
return surveysPrisma.map((surveyPrisma) => transformPrismaSurvey<TSurvey>(surveyPrisma)); return surveysPrisma.map((surveyPrisma) => transformPrismaSurvey<TSurvey>(surveyPrisma));
} catch (error) { } catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) { if (error instanceof Prisma.PrismaClientKnownRequestError) {
logger.error({ error }, "getSurveys: Could not fetch surveys"); console.error(error);
throw new DatabaseError(error.message); throw new DatabaseError(error.message);
} }
throw error; throw error;
@@ -1,5 +1,3 @@
import { logger } from "@formbricks/logger";
export const authorize = async (environmentId: string, apiHost: string): Promise<string> => { export const authorize = async (environmentId: string, apiHost: string): Promise<string> => {
const res = await fetch(`${apiHost}/api/v1/integrations/notion`, { const res = await fetch(`${apiHost}/api/v1/integrations/notion`, {
method: "GET", method: "GET",
@@ -7,8 +5,7 @@ export const authorize = async (environmentId: string, apiHost: string): Promise
}); });
if (!res.ok) { if (!res.ok) {
const errorText = await res.text(); console.error(res.text);
logger.error({ errorText }, "authorize: Could not fetch notion config");
throw new Error("Could not create response"); throw new Error("Could not create response");
} }
const resJSON = await res.json(); const resJSON = await res.json();
@@ -1,10 +1,13 @@
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys"; import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
import { NotionWrapper } from "@/app/(app)/environments/[environmentId]/integrations/notion/components/NotionWrapper"; import { NotionWrapper } from "@/app/(app)/environments/[environmentId]/integrations/notion/components/NotionWrapper";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; import { authOptions } from "@/modules/auth/lib/authOptions";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
import { GoBackButton } from "@/modules/ui/components/go-back-button"; import { GoBackButton } from "@/modules/ui/components/go-back-button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header"; import { PageHeader } from "@/modules/ui/components/page-header";
import { getTranslate } from "@/tolgee/server"; import { getTranslate } from "@/tolgee/server";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { import {
NOTION_AUTH_URL, NOTION_AUTH_URL,
@@ -13,8 +16,12 @@ import {
NOTION_REDIRECT_URI, NOTION_REDIRECT_URI,
WEBAPP_URL, WEBAPP_URL,
} from "@formbricks/lib/constants"; } from "@formbricks/lib/constants";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getIntegrationByType } from "@formbricks/lib/integration/service"; import { getIntegrationByType } from "@formbricks/lib/integration/service";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getNotionDatabases } from "@formbricks/lib/notion/service"; import { getNotionDatabases } from "@formbricks/lib/notion/service";
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
import { findMatchingLocale } from "@formbricks/lib/utils/locale"; import { findMatchingLocale } from "@formbricks/lib/utils/locale";
import { TIntegrationNotion, TIntegrationNotionDatabase } from "@formbricks/types/integration/notion"; import { TIntegrationNotion, TIntegrationNotionDatabase } from "@formbricks/types/integration/notion";
@@ -27,20 +34,44 @@ const Page = async (props) => {
NOTION_AUTH_URL && NOTION_AUTH_URL &&
NOTION_REDIRECT_URI NOTION_REDIRECT_URI
); );
const [session, surveys, notionIntegration, environment] = await Promise.all([
const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId); getServerSession(authOptions),
const [surveys, notionIntegration] = await Promise.all([
getSurveys(params.environmentId), getSurveys(params.environmentId),
getIntegrationByType(params.environmentId, "notion"), getIntegrationByType(params.environmentId, "notion"),
getEnvironment(params.environmentId),
]); ]);
if (!session) {
throw new Error(t("common.session_not_found"));
}
if (!environment) {
throw new Error(t("common.environment_not_found"));
}
const project = await getProjectByEnvironmentId(params.environmentId);
if (!project) {
throw new Error(t("common.project_not_found"));
}
let databasesArray: TIntegrationNotionDatabase[] = []; let databasesArray: TIntegrationNotionDatabase[] = [];
if (notionIntegration && (notionIntegration as TIntegrationNotion).config.key?.bot_id) { if (notionIntegration && (notionIntegration as TIntegrationNotion).config.key?.bot_id) {
databasesArray = (await getNotionDatabases(environment.id)) ?? []; databasesArray = (await getNotionDatabases(environment.id)) ?? [];
} }
const locale = await findMatchingLocale(); const locale = await findMatchingLocale();
const currentUserMembership = await getMembershipByUserIdOrganizationId(
session?.user.id,
project.organizationId
);
const { isMember } = getAccessFlags(currentUserMembership?.role);
const projectPermission = await getProjectPermissionByUserId(session?.user.id, environment?.projectId);
const { hasReadAccess } = getTeamPermissionFlags(projectPermission);
const isReadOnly = isMember && hasReadAccess;
if (isReadOnly) { if (isReadOnly) {
redirect("./"); redirect("./");
} }
@@ -9,40 +9,71 @@ import notionLogo from "@/images/notion.png";
import SlackLogo from "@/images/slacklogo.png"; import SlackLogo from "@/images/slacklogo.png";
import WebhookLogo from "@/images/webhook.png"; import WebhookLogo from "@/images/webhook.png";
import ZapierLogo from "@/images/zapier-small.png"; import ZapierLogo from "@/images/zapier-small.png";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; import { authOptions } from "@/modules/auth/lib/authOptions";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
import { Card } from "@/modules/ui/components/integration-card"; import { Card } from "@/modules/ui/components/integration-card";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header"; import { PageHeader } from "@/modules/ui/components/page-header";
import { getTranslate } from "@/tolgee/server"; import { getTranslate } from "@/tolgee/server";
import { getServerSession } from "next-auth";
import Image from "next/image"; import Image from "next/image";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getIntegrations } from "@formbricks/lib/integration/service"; import { getIntegrations } from "@formbricks/lib/integration/service";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { TIntegrationType } from "@formbricks/types/integration"; import { TIntegrationType } from "@formbricks/types/integration";
const Page = async (props) => { const Page = async (props) => {
const params = await props.params; const params = await props.params;
const environmentId = params.environmentId;
const t = await getTranslate(); const t = await getTranslate();
const { isReadOnly, environment, isBilling } = await getEnvironmentAuth(params.environmentId);
const [ const [
environment,
integrations, integrations,
organization,
session,
userWebhookCount, userWebhookCount,
zapierWebhookCount, zapierWebhookCount,
makeWebhookCount, makeWebhookCount,
n8nwebhookCount, n8nwebhookCount,
activePiecesWebhookCount, activePiecesWebhookCount,
] = await Promise.all([ ] = await Promise.all([
getIntegrations(params.environmentId), getEnvironment(environmentId),
getWebhookCountBySource(params.environmentId, "user"), getIntegrations(environmentId),
getWebhookCountBySource(params.environmentId, "zapier"), getOrganizationByEnvironmentId(params.environmentId),
getWebhookCountBySource(params.environmentId, "make"), getServerSession(authOptions),
getWebhookCountBySource(params.environmentId, "n8n"), getWebhookCountBySource(environmentId, "user"),
getWebhookCountBySource(params.environmentId, "activepieces"), getWebhookCountBySource(environmentId, "zapier"),
getWebhookCountBySource(environmentId, "make"),
getWebhookCountBySource(environmentId, "n8n"),
getWebhookCountBySource(environmentId, "activepieces"),
]); ]);
const isIntegrationConnected = (type: TIntegrationType) => const isIntegrationConnected = (type: TIntegrationType) =>
integrations.some((integration) => integration.type === type); integrations.some((integration) => integration.type === type);
if (!session) {
throw new Error(t("common.session_not_found"));
}
if (!organization) {
throw new Error(t("common.organization_not_found"));
}
if (!environment) {
throw new Error(t("common.environment_not_found"));
}
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
const { isMember, isBilling } = getAccessFlags(currentUserMembership?.role);
const projectPermission = await getProjectPermissionByUserId(session?.user.id, environment?.projectId);
const { hasReadAccess } = getTeamPermissionFlags(projectPermission);
const isReadOnly = isMember && hasReadAccess;
if (isBilling) { if (isBilling) {
return redirect(`/environments/${params.environmentId}/settings/billing`); return redirect(`/environments/${params.environmentId}/settings/billing`);
@@ -213,7 +244,7 @@ const Page = async (props) => {
docsHref: "https://formbricks.com/docs/app-surveys/quickstart", docsHref: "https://formbricks.com/docs/app-surveys/quickstart",
docsText: t("common.docs"), docsText: t("common.docs"),
docsNewTab: true, docsNewTab: true,
connectHref: `/environments/${params.environmentId}/project/app-connection`, connectHref: `/environments/${environmentId}/project/app-connection`,
connectText: t("common.connect"), connectText: t("common.connect"),
connectNewTab: false, connectNewTab: false,
label: "Javascript SDK", label: "Javascript SDK",
@@ -1,5 +1,3 @@
import { logger } from "@formbricks/logger";
export const authorize = async (environmentId: string, apiHost: string): Promise<string> => { export const authorize = async (environmentId: string, apiHost: string): Promise<string> => {
const res = await fetch(`${apiHost}/api/v1/integrations/slack`, { const res = await fetch(`${apiHost}/api/v1/integrations/slack`, {
method: "GET", method: "GET",
@@ -7,8 +5,7 @@ export const authorize = async (environmentId: string, apiHost: string): Promise
}); });
if (!res.ok) { if (!res.ok) {
const errorText = await res.text(); console.error(res.text);
logger.error({ errorText }, "authorize: Could not fetch slack config");
throw new Error("Could not create response"); throw new Error("Could not create response");
} }
const resJSON = await res.json(); const resJSON = await res.json();
@@ -1,13 +1,20 @@
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys"; import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
import { SlackWrapper } from "@/app/(app)/environments/[environmentId]/integrations/slack/components/SlackWrapper"; import { SlackWrapper } from "@/app/(app)/environments/[environmentId]/integrations/slack/components/SlackWrapper";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; import { authOptions } from "@/modules/auth/lib/authOptions";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
import { GoBackButton } from "@/modules/ui/components/go-back-button"; import { GoBackButton } from "@/modules/ui/components/go-back-button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header"; import { PageHeader } from "@/modules/ui/components/page-header";
import { getTranslate } from "@/tolgee/server"; import { getTranslate } from "@/tolgee/server";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@formbricks/lib/constants"; import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@formbricks/lib/constants";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getIntegrationByType } from "@formbricks/lib/integration/service"; import { getIntegrationByType } from "@formbricks/lib/integration/service";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
import { findMatchingLocale } from "@formbricks/lib/utils/locale"; import { findMatchingLocale } from "@formbricks/lib/utils/locale";
import { TIntegrationSlack } from "@formbricks/types/integration/slack"; import { TIntegrationSlack } from "@formbricks/types/integration/slack";
@@ -16,16 +23,40 @@ const Page = async (props) => {
const isEnabled = !!(SLACK_CLIENT_ID && SLACK_CLIENT_SECRET); const isEnabled = !!(SLACK_CLIENT_ID && SLACK_CLIENT_SECRET);
const t = await getTranslate(); const t = await getTranslate();
const [session, surveys, slackIntegration, environment] = await Promise.all([
const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId); getServerSession(authOptions),
const [surveys, slackIntegration] = await Promise.all([
getSurveys(params.environmentId), getSurveys(params.environmentId),
getIntegrationByType(params.environmentId, "slack"), getIntegrationByType(params.environmentId, "slack"),
getEnvironment(params.environmentId),
]); ]);
if (!session) {
throw new Error(t("common.session_not_found"));
}
if (!environment) {
throw new Error(t("common.environment_not_found"));
}
const project = await getProjectByEnvironmentId(params.environmentId);
if (!project) {
throw new Error(t("common.project_not_found"));
}
const locale = await findMatchingLocale(); const locale = await findMatchingLocale();
const currentUserMembership = await getMembershipByUserIdOrganizationId(
session?.user.id,
project.organizationId
);
const { isMember } = getAccessFlags(currentUserMembership?.role);
const projectPermission = await getProjectPermissionByUserId(session?.user.id, environment?.projectId);
const { hasReadAccess } = getTeamPermissionFlags(projectPermission);
const isReadOnly = isMember && hasReadAccess;
if (isReadOnly) { if (isReadOnly) {
redirect("./"); redirect("./");
} }
@@ -1,250 +0,0 @@
import "@testing-library/jest-dom/vitest";
import { act, cleanup, render, screen } from "@testing-library/react";
import { getServerSession } from "next-auth";
import { notFound, redirect } from "next/navigation";
import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
import { getUser } from "@formbricks/lib/user/service";
import { AuthorizationError } from "@formbricks/types/errors";
import { TMembership } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations";
import { TProject } from "@formbricks/types/project";
import { TUser } from "@formbricks/types/user";
import EnvLayout from "./layout";
// mock all the dependencies
vi.mock("@formbricks/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
POSTHOG_API_KEY: "mock-posthog-api-key",
POSTHOG_HOST: "mock-posthog-host",
IS_POSTHOG_CONFIGURED: true,
ENCRYPTION_KEY: "mock-encryption-key",
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
GITHUB_ID: "mock-github-id",
GITHUB_SECRET: "test-githubID",
GOOGLE_CLIENT_ID: "test-google-client-id",
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
AZUREAD_CLIENT_ID: "test-azuread-client-id",
AZUREAD_CLIENT_SECRET: "test-azure",
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
OIDC_DISPLAY_NAME: "test-oidc-display-name",
OIDC_CLIENT_ID: "test-oidc-client-id",
OIDC_ISSUER: "test-oidc-issuer",
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false,
}));
vi.mock("@/tolgee/server", () => ({
getTranslate: vi.fn(() => {
return (key: string) => {
return key;
};
}),
}));
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
vi.mock("@formbricks/lib/environment/auth", () => ({
hasUserEnvironmentAccess: vi.fn(),
}));
vi.mock("@formbricks/lib/membership/service", () => ({
getMembershipByUserIdOrganizationId: vi.fn(),
}));
vi.mock("@formbricks/lib/organization/service", () => ({
getOrganizationByEnvironmentId: vi.fn(),
}));
vi.mock("@formbricks/lib/project/service", () => ({
getProjectByEnvironmentId: vi.fn(),
}));
vi.mock("@formbricks/lib/user/service", () => ({
getUser: vi.fn(),
}));
vi.mock("@formbricks/lib/aiModels", () => ({
llmModel: {},
}));
// mock all the components that are rendered in the layout
vi.mock("./components/PosthogIdentify", () => ({
PosthogIdentify: () => <div data-testid="posthog-identify" />,
}));
vi.mock("@/app/(app)/components/FormbricksClient", () => ({
FormbricksClient: () => <div data-testid="formbricks-client" />,
}));
vi.mock("@/modules/ui/components/toaster-client", () => ({
ToasterClient: () => <div data-testid="mock-toaster" />,
}));
vi.mock("./components/EnvironmentStorageHandler", () => ({
default: () => <div data-testid="mock-storage-handler" />,
}));
vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext", () => ({
ResponseFilterProvider: ({ children }: { children: React.ReactNode }) => (
<div data-testid="mock-response-filter-provider">{children}</div>
),
}));
vi.mock("@/app/(app)/environments/[environmentId]/components/EnvironmentLayout", () => ({
EnvironmentLayout: ({ children }: { children: React.ReactNode }) => (
<div data-testid="mock-environment-result">{children}</div>
),
}));
describe("EnvLayout", () => {
beforeEach(() => {
cleanup();
});
it("redirects to /auth/login if there is no session", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce(null);
// Since it's an async server component, call EnvLayout yourself:
const layoutElement = await EnvLayout({
params: Promise.resolve({ environmentId: "env-123" }),
children: <div data-testid="child-content">Hello!</div>,
});
// Because we have no session, we expect a redirect to "/auth/login"
expect(redirect).toHaveBeenCalledWith("/auth/login");
// If your code calls redirect() early and returns no JSX,
// layoutElement might be undefined or null.
expect(layoutElement).toBeUndefined();
});
it("redirects to /auth/login if user does not exist in DB", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({
user: { id: "user-123" },
});
vi.mocked(getUser).mockResolvedValueOnce(null); // user not found
const layoutElement = await EnvLayout({
params: Promise.resolve({ environmentId: "env-123" }),
children: <div data-testid="child-content">Hello!</div>,
});
expect(redirect).toHaveBeenCalledWith("/auth/login");
expect(layoutElement).toBeUndefined();
});
it("throws AuthorizationError if user does not have environment access", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({
user: { id: "user-123" },
});
vi.mocked(getUser).mockResolvedValueOnce({
id: "user-123",
email: "test@example.com",
} as TUser);
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(false);
await expect(
EnvLayout({
params: Promise.resolve({ environmentId: "env-123" }),
children: <div>Child</div>,
})
).rejects.toThrow(AuthorizationError);
});
it("throws if no organization is found", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({
user: { id: "user-123" },
});
vi.mocked(getUser).mockResolvedValueOnce({
id: "user-123",
email: "test@example.com",
} as TUser);
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(true);
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce(null);
await expect(
EnvLayout({
params: Promise.resolve({ environmentId: "env-123" }),
children: <div data-testid="child-content">Hello from children!</div>,
})
).rejects.toThrow("common.organization_not_found");
});
it("throws if no project is found", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({
user: { id: "user-123" },
});
vi.mocked(getUser).mockResolvedValueOnce({
id: "user-123",
email: "test@example.com",
} as TUser);
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(true);
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce({ id: "org-999" } as TOrganization);
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce(null);
await expect(
EnvLayout({
params: Promise.resolve({ environmentId: "env-123" }),
children: <div>Child</div>,
})
).rejects.toThrow("project_not_found");
});
it("calls notFound if membership is missing", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({
user: { id: "user-123" },
});
vi.mocked(getUser).mockResolvedValueOnce({
id: "user-123",
email: "test@example.com",
} as TUser);
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(true);
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce({ id: "org-999" } as TOrganization);
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce({ id: "proj-111" } as TProject);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce(null);
await expect(
EnvLayout({
params: Promise.resolve({ environmentId: "env-123" }),
children: <div>Child</div>,
})
).rejects.toThrow("membership_not_found");
});
it("renders environment layout if everything is valid", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({
user: { id: "user-123" },
});
vi.mocked(getUser).mockResolvedValueOnce({
id: "user-123",
email: "test@example.com",
} as TUser);
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(true);
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce({ id: "org-999" } as TOrganization);
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce({ id: "proj-111" } as TProject);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce({
id: "membership-123",
} as unknown as TMembership);
let layoutElement: React.ReactNode;
await act(async () => {
layoutElement = await EnvLayout({
params: Promise.resolve({ environmentId: "env-123" }),
children: <div data-testid="child-content">Hello from children!</div>,
});
// Now render the fully resolved layout
render(layoutElement);
});
expect(screen.getByTestId("child-content")).toHaveTextContent("Hello from children!");
expect(screen.getByTestId("posthog-identify")).toBeInTheDocument();
expect(screen.getByTestId("formbricks-client")).toBeInTheDocument();
expect(screen.getByTestId("mock-toaster")).toBeInTheDocument();
expect(screen.getByTestId("mock-storage-handler")).toBeInTheDocument();
expect(screen.getByTestId("mock-response-filter-provider")).toBeInTheDocument();
expect(screen.getByTestId("mock-environment-result")).toBeInTheDocument();
});
});
@@ -4,8 +4,7 @@ import { authOptions } from "@/modules/auth/lib/authOptions";
import { ToasterClient } from "@/modules/ui/components/toaster-client"; import { ToasterClient } from "@/modules/ui/components/toaster-client";
import { getTranslate } from "@/tolgee/server"; import { getTranslate } from "@/tolgee/server";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { redirect } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import { IS_POSTHOG_CONFIGURED } from "@formbricks/lib/constants";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth"; import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service"; import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service"; import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
@@ -26,8 +25,7 @@ const EnvLayout = async (props: {
const t = await getTranslate(); const t = await getTranslate();
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
if (!session || !session.user) {
if (!session?.user) {
return redirect(`/auth/login`); return redirect(`/auth/login`);
} }
@@ -51,29 +49,27 @@ const EnvLayout = async (props: {
} }
const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id); const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id);
if (!membership) return notFound();
if (!membership) {
throw new Error(t("common.membership_not_found"));
}
return ( return (
<ResponseFilterProvider> <>
<PosthogIdentify <ResponseFilterProvider>
session={session} <PosthogIdentify
user={user} session={session}
environmentId={params.environmentId} user={user}
organizationId={organization.id} environmentId={params.environmentId}
organizationName={organization.name} organizationId={organization.id}
organizationBilling={organization.billing} organizationName={organization.name}
isPosthogEnabled={IS_POSTHOG_CONFIGURED} organizationBilling={organization.billing}
/> />
<FormbricksClient userId={user.id} email={user.email} /> <FormbricksClient userId={user.id} email={user.email} />
<ToasterClient /> <ToasterClient />
<EnvironmentStorageHandler environmentId={params.environmentId} /> <EnvironmentStorageHandler environmentId={params.environmentId} />
<EnvironmentLayout environmentId={params.environmentId} session={session}> <EnvironmentLayout environmentId={params.environmentId} session={session}>
{children} {children}
</EnvironmentLayout> </EnvironmentLayout>
</ResponseFilterProvider> </ResponseFilterProvider>
</>
); );
}; };
@@ -1,11 +1,24 @@
import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; import { authOptions } from "@/modules/auth/lib/authOptions";
import { getTranslate } from "@/tolgee/server";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service"; import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils"; import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
const EnvironmentPage = async (props) => { const EnvironmentPage = async (props) => {
const params = await props.params; const params = await props.params;
const { session, organization } = await getEnvironmentAuth(params.environmentId); const session = await getServerSession(authOptions);
const t = await getTranslate();
const organization = await getOrganizationByEnvironmentId(params.environmentId);
if (!session) {
return redirect(`/auth/login`);
}
if (!organization) {
throw new Error(t("common.organization_not_found"));
}
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id); const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
const { isBilling } = getAccessFlags(currentUserMembership?.role); const { isBilling } = getAccessFlags(currentUserMembership?.role);
@@ -0,0 +1,3 @@
import { APIKeysLoading } from "@/modules/projects/settings/api-keys/loading";
export default APIKeysLoading;
@@ -0,0 +1,3 @@
import { APIKeysPage } from "@/modules/projects/settings/api-keys/page";
export default APIKeysPage;
@@ -27,7 +27,7 @@ export const EditAlerts = ({
return ( return (
<> <>
{memberships.map((membership) => ( {memberships.map((membership) => (
<div key={membership.organization.id}> <>
<div className="mb-5 grid grid-cols-6 items-center space-x-3"> <div className="mb-5 grid grid-cols-6 items-center space-x-3">
<div className="col-span-3 flex items-center space-x-3"> <div className="col-span-3 flex items-center space-x-3">
<UsersIcon className="h-6 w-7 text-slate-600" /> <UsersIcon className="h-6 w-7 text-slate-600" />
@@ -110,7 +110,7 @@ export const EditAlerts = ({
</Link> </Link>
</p> </p>
</div> </div>
</div> </>
))} ))}
</> </>
); );
@@ -18,7 +18,7 @@ export const EditWeeklySummary = ({ memberships, user, environmentId }: EditAler
return ( return (
<> <>
{memberships.map((membership) => ( {memberships.map((membership) => (
<div key={membership.organization.id}> <>
<div className="mb-5 flex items-center space-x-3 text-sm font-medium"> <div className="mb-5 flex items-center space-x-3 text-sm font-medium">
<UsersIcon className="h-6 w-7 text-slate-600" /> <UsersIcon className="h-6 w-7 text-slate-600" />
@@ -52,7 +52,7 @@ export const EditWeeklySummary = ({ memberships, user, environmentId }: EditAler
</Link> </Link>
</p> </p>
</div> </div>
</div> </>
))} ))}
</> </>
); );
@@ -157,10 +157,6 @@ const Page = async (props) => {
throw new Error(t("common.user_not_found")); throw new Error(t("common.user_not_found"));
} }
if (!memberships) {
throw new Error(t("common.membership_not_found"));
}
if (user?.notificationSettings) { if (user?.notificationSettings) {
user.notificationSettings = setCompleteNotificationSettings(user.notificationSettings, memberships); user.notificationSettings = setCompleteNotificationSettings(user.notificationSettings, memberships);
} }
@@ -1,14 +1,16 @@
import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar"; import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
import { AccountSecurity } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/AccountSecurity"; import { AccountSecurity } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/AccountSecurity";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getIsMultiOrgEnabled, getIsTwoFactorAuthEnabled } from "@/modules/ee/license-check/lib/utils"; import { getIsMultiOrgEnabled, getIsTwoFactorAuthEnabled } from "@/modules/ee/license-check/lib/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header"; import { PageHeader } from "@/modules/ui/components/page-header";
import { SettingsId } from "@/modules/ui/components/settings-id"; import { SettingsId } from "@/modules/ui/components/settings-id";
import { UpgradePrompt } from "@/modules/ui/components/upgrade-prompt"; import { UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
import { getTranslate } from "@/tolgee/server"; import { getTranslate } from "@/tolgee/server";
import { getServerSession } from "next-auth";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants"; import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { getOrganizationsWhereUserIsSingleOwner } from "@formbricks/lib/organization/service"; import { getOrganizationsWhereUserIsSingleOwner } from "@formbricks/lib/organization/service";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { getUser } from "@formbricks/lib/user/service"; import { getUser } from "@formbricks/lib/user/service";
import { SettingsCard } from "../../components/SettingsCard"; import { SettingsCard } from "../../components/SettingsCard";
import { DeleteAccount } from "./components/DeleteAccount"; import { DeleteAccount } from "./components/DeleteAccount";
@@ -21,16 +23,20 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params; const params = await props.params;
const t = await getTranslate(); const t = await getTranslate();
const { environmentId } = params; const { environmentId } = params;
const session = await getServerSession(authOptions);
if (!session) {
throw new Error(t("common.session_not_found"));
}
const { session } = await getEnvironmentAuth(params.environmentId); const organization = await getOrganizationByEnvironmentId(environmentId);
if (!organization) {
throw new Error(t("common.organization_not_found"));
}
const organizationsWithSingleOwner = await getOrganizationsWhereUserIsSingleOwner(session.user.id); const organizationsWithSingleOwner = await getOrganizationsWhereUserIsSingleOwner(session.user.id);
const user = session?.user ? await getUser(session.user.id) : null; const user = session && session.user ? await getUser(session.user.id) : null;
if (!user) {
throw new Error(t("common.user_not_found"));
}
return ( return (
<PageContentWrapper> <PageContentWrapper>
@@ -65,9 +71,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
description={t("environments.settings.profile.two_factor_authentication_description")} description={t("environments.settings.profile.two_factor_authentication_description")}
buttons={[ buttons={[
{ {
text: IS_FORMBRICKS_CLOUD text: t("common.start_free_trial"),
? t("common.start_free_trial")
: t("common.request_trial_license"),
href: IS_FORMBRICKS_CLOUD href: IS_FORMBRICKS_CLOUD
? `/environments/${params.environmentId}/settings/billing` ? `/environments/${params.environmentId}/settings/billing`
: "https://formbricks.com/upgrade-self-hosting-license", : "https://formbricks.com/upgrade-self-hosting-license",
@@ -1,6 +0,0 @@
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} />;
}
@@ -1,3 +0,0 @@
import { APIKeysPage } from "@/modules/organization/settings/api-keys/page";
export default APIKeysPage;
@@ -54,12 +54,6 @@ export const OrganizationSettingsNavbar = ({
hidden: isFormbricksCloud || isPricingDisabled, hidden: isFormbricksCloud || isPricingDisabled,
current: pathname?.includes("/enterprise"), current: pathname?.includes("/enterprise"),
}, },
{
id: "api-keys",
label: t("common.api_keys"),
href: `/environments/${environmentId}/settings/api-keys`,
current: pathname?.includes("/api-keys"),
},
]; ];
return <SecondaryNavigation navigation={navigation} activeId={activeId} loading={loading} />; return <SecondaryNavigation navigation={navigation} activeId={activeId} loading={loading} />;
@@ -1,14 +1,18 @@
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar"; import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/utils"; import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header"; import { PageHeader } from "@/modules/ui/components/page-header";
import { getTranslate } from "@/tolgee/server"; import { getTranslate } from "@/tolgee/server";
import { CheckIcon } from "lucide-react"; import { CheckIcon } from "lucide-react";
import { getServerSession } from "next-auth";
import Link from "next/link"; import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants"; import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
const Page = async (props) => { const Page = async (props) => {
const params = await props.params; const params = await props.params;
@@ -17,8 +21,20 @@ const Page = async (props) => {
notFound(); notFound();
} }
const { isMember, currentUserMembership } = await getEnvironmentAuth(params.environmentId); const session = await getServerSession(authOptions);
const organization = await getOrganizationByEnvironmentId(params.environmentId);
if (!session) {
throw new Error(t("common.session_not_found"));
}
if (!organization) {
throw new Error(t("common.organization_not_found"));
}
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
const { isMember } = getAccessFlags(currentUserMembership?.role);
const isPricingDisabled = isMember; const isPricingDisabled = isMember;
if (isPricingDisabled) { if (isPricingDisabled) {
@@ -3,11 +3,15 @@ import {
getIsOrganizationAIReady, getIsOrganizationAIReady,
getWhiteLabelPermission, getWhiteLabelPermission,
} from "@/modules/ee/license-check/lib/utils"; } from "@/modules/ee/license-check/lib/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { TEnvironmentAuth } from "@/modules/environments/types/environment-auth";
import { getTranslate } from "@/tolgee/server"; import { getTranslate } from "@/tolgee/server";
import { getServerSession } from "next-auth";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { getUser } from "@formbricks/lib/user/service"; import { getUser } from "@formbricks/lib/user/service";
import { TMembership } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user"; import { TUser } from "@formbricks/types/user";
import Page from "./page"; import Page from "./page";
@@ -33,12 +37,6 @@ vi.mock("@formbricks/lib/constants", () => ({
WEBAPP_URL: "mock-webapp-url", WEBAPP_URL: "mock-webapp-url",
SMTP_HOST: "mock-smtp-host", SMTP_HOST: "mock-smtp-host",
SMTP_PORT: "mock-smtp-port", SMTP_PORT: "mock-smtp-port",
AI_AZURE_LLM_RESSOURCE_NAME: "mock-ai-azure-llm-ressource-name",
AI_AZURE_LLM_API_KEY: "mock-ai",
AI_AZURE_LLM_DEPLOYMENT_ID: "mock-ai-azure-llm-deployment-id",
AI_AZURE_EMBEDDINGS_RESSOURCE_NAME: "mock-ai-azure-embeddings-ressource-name",
AI_AZURE_EMBEDDINGS_API_KEY: "mock-ai-azure-embeddings-api-key",
AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID: "mock-ai-azure-embeddings-deployment-id",
})); }));
vi.mock("next-auth", () => ({ vi.mock("next-auth", () => ({
@@ -53,8 +51,16 @@ vi.mock("@formbricks/lib/user/service", () => ({
getUser: vi.fn(), getUser: vi.fn(),
})); }));
vi.mock("@/modules/environments/lib/utils", () => ({ vi.mock("@formbricks/lib/organization/service", () => ({
getEnvironmentAuth: vi.fn(), getOrganizationByEnvironmentId: vi.fn(),
}));
vi.mock("@formbricks/lib/membership/service", () => ({
getMembershipByUserIdOrganizationId: vi.fn(),
}));
vi.mock("@formbricks/lib/membership/utils", () => ({
getAccessFlags: vi.fn(),
})); }));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({ vi.mock("@/modules/ee/license-check/lib/utils", () => ({
@@ -64,21 +70,26 @@ vi.mock("@/modules/ee/license-check/lib/utils", () => ({
})); }));
describe("Page", () => { describe("Page", () => {
let mockEnvironmentAuth = { const mockParams = { environmentId: "test-environment-id" };
session: { user: { id: "test-user-id" } }, const mockSession = { user: { id: "test-user-id" } };
currentUserMembership: { role: "owner" },
organization: { id: "test-organization-id", billing: { plan: "free" } },
isOwner: true,
isManager: false,
} as unknown as TEnvironmentAuth;
const mockUser = { id: "test-user-id" } as TUser; const mockUser = { id: "test-user-id" } as TUser;
const mockOrganization = { id: "test-organization-id", billing: { plan: "free" } } as TOrganization;
const mockMembership = { role: "owner" } as TMembership;
const mockTranslate = vi.fn((key) => key); const mockTranslate = vi.fn((key) => key);
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getServerSession).mockResolvedValue(mockSession);
vi.mocked(getTranslate).mockResolvedValue(mockTranslate); vi.mocked(getTranslate).mockResolvedValue(mockTranslate);
vi.mocked(getUser).mockResolvedValue(mockUser); vi.mocked(getUser).mockResolvedValue(mockUser);
vi.mocked(getEnvironmentAuth).mockResolvedValue(mockEnvironmentAuth); vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
vi.mocked(getAccessFlags).mockReturnValue({
isOwner: true,
isManager: false,
isBilling: false,
isMember: false,
});
vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(true); vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(true);
vi.mocked(getIsOrganizationAIReady).mockResolvedValue(true); vi.mocked(getIsOrganizationAIReady).mockResolvedValue(true);
vi.mocked(getWhiteLabelPermission).mockResolvedValue(true); vi.mocked(getWhiteLabelPermission).mockResolvedValue(true);
@@ -94,10 +105,8 @@ describe("Page", () => {
expect(result).toBeTruthy(); expect(result).toBeTruthy();
}); });
it("renders if session user id empty", async () => { it("renders if session user id is null", async () => {
mockEnvironmentAuth.session.user.id = ""; vi.mocked(getServerSession).mockResolvedValue({ user: { id: null } });
vi.mocked(getEnvironmentAuth).mockResolvedValue(mockEnvironmentAuth);
const props = { const props = {
params: Promise.resolve({ environmentId: "env-123" }), params: Promise.resolve({ environmentId: "env-123" }),
@@ -108,13 +117,17 @@ describe("Page", () => {
expect(result).toBeTruthy(); expect(result).toBeTruthy();
}); });
it("handles getEnvironmentAuth error", async () => { it("throws an error if the session is not found", async () => {
vi.mocked(getEnvironmentAuth).mockRejectedValue(new Error("Authentication error")); vi.mocked(getServerSession).mockResolvedValue(null);
const props = { await expect(Page({ params: Promise.resolve(mockParams) })).rejects.toThrow("common.session_not_found");
params: Promise.resolve({ environmentId: "env-123" }), });
};
await expect(Page(props)).rejects.toThrow("Authentication error"); it("throws an error if the organization is not found", async () => {
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null);
await expect(Page({ params: Promise.resolve(mockParams) })).rejects.toThrow(
"common.organization_not_found"
);
}); });
}); });
@@ -1,17 +1,22 @@
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar"; import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
import { AIToggle } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/components/AIToggle"; import { AIToggle } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/components/AIToggle";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { import {
getIsMultiOrgEnabled, getIsMultiOrgEnabled,
getIsOrganizationAIReady, getIsOrganizationAIReady,
getWhiteLabelPermission, getWhiteLabelPermission,
} from "@/modules/ee/license-check/lib/utils"; } from "@/modules/ee/license-check/lib/utils";
import { EmailCustomizationSettings } from "@/modules/ee/whitelabel/email-customization/components/email-customization-settings"; import { EmailCustomizationSettings } from "@/modules/ee/whitelabel/email-customization/components/email-customization-settings";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header"; import { PageHeader } from "@/modules/ui/components/page-header";
import { SettingsId } from "@/modules/ui/components/settings-id"; import { SettingsId } from "@/modules/ui/components/settings-id";
import { getTranslate } from "@/tolgee/server"; import { getTranslate } from "@/tolgee/server";
import { getServerSession } from "next-auth";
import React from "react";
import { FB_LOGO_URL, IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants"; import { FB_LOGO_URL, IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { getUser } from "@formbricks/lib/user/service"; import { getUser } from "@formbricks/lib/user/service";
import { SettingsCard } from "../../components/SettingsCard"; import { SettingsCard } from "../../components/SettingsCard";
import { DeleteOrganization } from "./components/DeleteOrganization"; import { DeleteOrganization } from "./components/DeleteOrganization";
@@ -20,13 +25,20 @@ import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm"
const Page = async (props: { params: Promise<{ environmentId: string }> }) => { const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params; const params = await props.params;
const t = await getTranslate(); const t = await getTranslate();
const session = await getServerSession(authOptions);
const { session, currentUserMembership, organization, isOwner, isManager } = await getEnvironmentAuth( if (!session) {
params.environmentId throw new Error(t("common.session_not_found"));
); }
const user = session?.user?.id ? await getUser(session.user.id) : null; const user = session?.user?.id ? await getUser(session.user.id) : null;
const organization = await getOrganizationByEnvironmentId(params.environmentId);
if (!organization) {
throw new Error(t("common.organization_not_found"));
}
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
const { isOwner, isManager } = getAccessFlags(currentUserMembership?.role);
const isMultiOrgEnabled = await getIsMultiOrgEnabled(); const isMultiOrgEnabled = await getIsMultiOrgEnabled();
const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.billing.plan); const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.billing.plan);
@@ -88,7 +100,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
</SettingsCard> </SettingsCard>
)} )}
<SettingsId title={t("common.organization_id")} id={organization.id}></SettingsId> <SettingsId title={t("common.organization")} id={organization.id}></SettingsId>
</PageContentWrapper> </PageContentWrapper>
); );
}; };
@@ -2,6 +2,7 @@
import { Badge } from "@/modules/ui/components/badge"; import { Badge } from "@/modules/ui/components/badge";
import { useTranslate } from "@tolgee/react"; import { useTranslate } from "@tolgee/react";
import React from "react";
import { cn } from "@formbricks/lib/cn"; import { cn } from "@formbricks/lib/cn";
export const SettingsCard = ({ export const SettingsCard = ({
@@ -3,17 +3,24 @@ import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[
import { EnableInsightsBanner } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/EnableInsightsBanner"; import { EnableInsightsBanner } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/EnableInsightsBanner";
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA"; import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
import { needsInsightsGeneration } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils"; import { needsInsightsGeneration } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getIsAIEnabled } from "@/modules/ee/license-check/lib/utils"; import { getIsAIEnabled } from "@/modules/ee/license-check/lib/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header"; import { PageHeader } from "@/modules/ui/components/page-header";
import { getTranslate } from "@/tolgee/server"; import { getTranslate } from "@/tolgee/server";
import { getServerSession } from "next-auth";
import { import {
MAX_RESPONSES_FOR_INSIGHT_GENERATION, MAX_RESPONSES_FOR_INSIGHT_GENERATION,
RESPONSES_PER_PAGE, RESPONSES_PER_PAGE,
WEBAPP_URL, WEBAPP_URL,
} from "@formbricks/lib/constants"; } from "@formbricks/lib/constants";
import { getSurveyDomain } from "@formbricks/lib/getSurveyUrl"; import { getEnvironment } from "@formbricks/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
import { getResponseCountBySurveyId } from "@formbricks/lib/response/service"; import { getResponseCountBySurveyId } from "@formbricks/lib/response/service";
import { getSurvey } from "@formbricks/lib/survey/service"; import { getSurvey } from "@formbricks/lib/survey/service";
import { getTagsByEnvironmentId } from "@formbricks/lib/tag/service"; import { getTagsByEnvironmentId } from "@formbricks/lib/tag/service";
@@ -23,32 +30,53 @@ import { findMatchingLocale } from "@formbricks/lib/utils/locale";
const Page = async (props) => { const Page = async (props) => {
const params = await props.params; const params = await props.params;
const t = await getTranslate(); const t = await getTranslate();
const session = await getServerSession(authOptions);
if (!session) {
throw new Error(t("common.session_not_found"));
}
const [survey, environment] = await Promise.all([
getSurvey(params.surveyId),
getEnvironment(params.environmentId),
]);
const { session, environment, organization, isReadOnly } = await getEnvironmentAuth(params.environmentId); if (!environment) {
throw new Error(t("common.environment_not_found"));
const survey = await getSurvey(params.surveyId); }
if (!survey) { if (!survey) {
throw new Error(t("common.survey_not_found")); throw new Error(t("common.survey_not_found"));
} }
const project = await getProjectByEnvironmentId(environment.id);
if (!project) {
throw new Error(t("common.project_not_found"));
}
const user = await getUser(session.user.id); const user = await getUser(session.user.id);
if (!user) { if (!user) {
throw new Error(t("common.user_not_found")); throw new Error(t("common.user_not_found"));
} }
const tags = await getTagsByEnvironmentId(params.environmentId); const tags = await getTagsByEnvironmentId(params.environmentId);
const organization = await getOrganizationByEnvironmentId(params.environmentId);
if (!organization) {
throw new Error(t("common.organization_not_found"));
}
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
const totalResponseCount = await getResponseCountBySurveyId(params.surveyId); const totalResponseCount = await getResponseCountBySurveyId(params.surveyId);
const { isMember } = getAccessFlags(currentUserMembership?.role);
const permission = await getProjectPermissionByUserId(session.user.id, project.id);
const { hasReadAccess } = getTeamPermissionFlags(permission);
const isReadOnly = isMember && hasReadAccess;
const isAIEnabled = await getIsAIEnabled({ const isAIEnabled = await getIsAIEnabled({
isAIEnabled: organization.isAIEnabled, isAIEnabled: organization.isAIEnabled,
billing: organization.billing, billing: organization.billing,
}); });
const shouldGenerateInsights = needsInsightsGeneration(survey); const shouldGenerateInsights = needsInsightsGeneration(survey);
const locale = await findMatchingLocale(); const locale = await findMatchingLocale();
const surveyDomain = getSurveyDomain();
return ( return (
<PageContentWrapper> <PageContentWrapper>
@@ -59,8 +87,8 @@ const Page = async (props) => {
environment={environment} environment={environment}
survey={survey} survey={survey}
isReadOnly={isReadOnly} isReadOnly={isReadOnly}
webAppUrl={WEBAPP_URL}
user={user} user={user}
surveyDomain={surveyDomain}
/> />
}> }>
{isAIEnabled && shouldGenerateInsights && ( {isAIEnabled && shouldGenerateInsights && (
@@ -23,19 +23,19 @@ import { PanelInfoView } from "./shareEmbedModal/PanelInfoView";
interface ShareEmbedSurveyProps { interface ShareEmbedSurveyProps {
survey: TSurvey; survey: TSurvey;
surveyDomain: string;
open: boolean; open: boolean;
modalView: "start" | "embed" | "panel"; modalView: "start" | "embed" | "panel";
setOpen: React.Dispatch<React.SetStateAction<boolean>>; setOpen: React.Dispatch<React.SetStateAction<boolean>>;
webAppUrl: string;
user: TUser; user: TUser;
} }
export const ShareEmbedSurvey = ({ export const ShareEmbedSurvey = ({
survey, survey,
surveyDomain,
open, open,
modalView, modalView,
setOpen, setOpen,
webAppUrl,
user, user,
}: ShareEmbedSurveyProps) => { }: ShareEmbedSurveyProps) => {
const router = useRouter(); const router = useRouter();
@@ -60,7 +60,7 @@ export const ShareEmbedSurvey = ({
const [activeId, setActiveId] = useState(survey.type === "link" ? tabs[0].id : tabs[3].id); const [activeId, setActiveId] = useState(survey.type === "link" ? tabs[0].id : tabs[3].id);
const [showView, setShowView] = useState<"start" | "embed" | "panel">("start"); const [showView, setShowView] = useState<"start" | "embed" | "panel">("start");
const [surveyUrl, setSurveyUrl] = useState(""); const [surveyUrl, setSurveyUrl] = useState(webAppUrl + "/s/" + survey.id);
useEffect(() => { useEffect(() => {
if (survey.type !== "link") { if (survey.type !== "link") {
@@ -104,8 +104,8 @@ export const ShareEmbedSurvey = ({
<DialogDescription className="hidden" /> <DialogDescription className="hidden" />
<ShareSurveyLink <ShareSurveyLink
survey={survey} survey={survey}
webAppUrl={webAppUrl}
surveyUrl={surveyUrl} surveyUrl={surveyUrl}
surveyDomain={surveyDomain}
setSurveyUrl={setSurveyUrl} setSurveyUrl={setSurveyUrl}
locale={user.locale} locale={user.locale}
/> />
@@ -159,8 +159,8 @@ export const ShareEmbedSurvey = ({
survey={survey} survey={survey}
email={email} email={email}
surveyUrl={surveyUrl} surveyUrl={surveyUrl}
surveyDomain={surveyDomain}
setSurveyUrl={setSurveyUrl} setSurveyUrl={setSurveyUrl}
webAppUrl={webAppUrl}
locale={user.locale} locale={user.locale}
/> />
) : showView === "panel" ? ( ) : showView === "panel" ? (
@@ -3,8 +3,6 @@
import { ShareEmbedSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareEmbedSurvey"; import { ShareEmbedSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareEmbedSurvey";
import { SuccessMessage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SuccessMessage"; import { SuccessMessage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SuccessMessage";
import { SurveyStatusDropdown } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SurveyStatusDropdown"; import { SurveyStatusDropdown } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SurveyStatusDropdown";
import { useSingleUseId } from "@/modules/survey/hooks/useSingleUseId";
import { copySurveyLink } from "@/modules/survey/lib/client-utils";
import { Badge } from "@/modules/ui/components/badge"; import { Badge } from "@/modules/ui/components/badge";
import { IconBar } from "@/modules/ui/components/iconbar"; import { IconBar } from "@/modules/ui/components/iconbar";
import { useTranslate } from "@tolgee/react"; import { useTranslate } from "@tolgee/react";
@@ -20,8 +18,8 @@ interface SurveyAnalysisCTAProps {
survey: TSurvey; survey: TSurvey;
environment: TEnvironment; environment: TEnvironment;
isReadOnly: boolean; isReadOnly: boolean;
webAppUrl: string;
user: TUser; user: TUser;
surveyDomain: string;
} }
interface ModalState { interface ModalState {
@@ -35,8 +33,8 @@ export const SurveyAnalysisCTA = ({
survey, survey,
environment, environment,
isReadOnly, isReadOnly,
webAppUrl,
user, user,
surveyDomain,
}: SurveyAnalysisCTAProps) => { }: SurveyAnalysisCTAProps) => {
const { t } = useTranslate(); const { t } = useTranslate();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@@ -50,8 +48,7 @@ export const SurveyAnalysisCTA = ({
dropdown: false, dropdown: false,
}); });
const surveyUrl = useMemo(() => `${surveyDomain}/s/${survey.id}`, [survey.id, surveyDomain]); const surveyUrl = useMemo(() => `${webAppUrl}/s/${survey.id}`, [survey.id, webAppUrl]);
const { refreshSingleUseId } = useSingleUseId(survey);
const widgetSetupCompleted = survey.type === "app" && environment.appSetupCompleted; const widgetSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
@@ -74,11 +71,8 @@ export const SurveyAnalysisCTA = ({
}; };
const handleCopyLink = () => { const handleCopyLink = () => {
refreshSingleUseId() navigator.clipboard
.then((newId) => { .writeText(surveyUrl)
const linkToCopy = copySurveyLink(surveyUrl, newId);
return navigator.clipboard.writeText(linkToCopy);
})
.then(() => { .then(() => {
toast.success(t("common.copied_to_clipboard")); toast.success(t("common.copied_to_clipboard"));
}) })
@@ -172,9 +166,9 @@ export const SurveyAnalysisCTA = ({
<ShareEmbedSurvey <ShareEmbedSurvey
key={key} key={key}
survey={survey} survey={survey}
surveyDomain={surveyDomain}
open={modalState[key as keyof ModalState]} open={modalState[key as keyof ModalState]}
setOpen={setOpen} setOpen={setOpen}
webAppUrl={webAppUrl}
user={user} user={user}
modalView={modalView} modalView={modalView}
/> />
@@ -1,12 +1,11 @@
"use client"; "use client";
import { MobileAppTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/MobileAppTab";
import { WebAppTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/WebAppTab";
import { OptionsSwitch } from "@/modules/ui/components/options-switch"; import { OptionsSwitch } from "@/modules/ui/components/options-switch";
import { useTranslate } from "@tolgee/react"; import { useTranslate } from "@tolgee/react";
import Link from "next/link";
import { useState } from "react"; import { useState } from "react";
export const AppTab = () => { export const AppTab = ({ environmentId }) => {
const { t } = useTranslate(); const { t } = useTranslate();
const [selectedTab, setSelectedTab] = useState("webapp"); const [selectedTab, setSelectedTab] = useState("webapp");
@@ -21,7 +20,79 @@ export const AppTab = () => {
handleOptionChange={(value) => setSelectedTab(value)} handleOptionChange={(value) => setSelectedTab(value)}
/> />
<div className="mt-4">{selectedTab === "webapp" ? <WebAppTab /> : <MobileAppTab />}</div> <div className="mt-4">
{selectedTab === "webapp" ? <WebAppTab environmentId={environmentId} /> : <MobileAppTab />}
</div>
</div>
);
};
const MobileAppTab = () => {
const { t } = useTranslate();
return (
<div>
<p className="text-lg font-semibold text-slate-800">
{t("environments.surveys.summary.how_to_embed_a_survey_on_your_react_native_app")}
</p>
<ol className="mt-4 list-decimal space-y-2 pl-5 text-sm text-slate-700">
<li>
{t("common.follow_these")}{" "}
<Link
href="https://formbricks.com/docs/developer-docs/react-native-in-app-surveys"
target="_blank"
className="decoration-brand-dark font-medium underline underline-offset-2">
{t("environments.surveys.summary.setup_instructions_for_react_native_apps")}
</Link>{" "}
{t("environments.surveys.summary.to_connect_your_app_with_formbricks")}
</li>
</ol>
<div className="mt-2 text-sm italic text-slate-700">
{t("environments.surveys.summary.were_working_on_sdks_for_flutter_swift_and_kotlin")}
</div>
</div>
);
};
const WebAppTab = ({ environmentId }) => {
const { t } = useTranslate();
return (
<div>
<p className="text-lg font-semibold text-slate-800">
{t("environments.surveys.summary.how_to_embed_a_survey_on_your_web_app")}
</p>
<ol className="mt-4 list-decimal space-y-2 pl-5 text-sm text-slate-700">
<li>
{t("common.follow_these")}{" "}
<Link
href={`/environments/${environmentId}/project/app-connection`}
target="_blank"
className="decoration-brand-dark font-medium underline underline-offset-2">
{t("environments.surveys.summary.setup_instructions")}
</Link>{" "}
{t("environments.surveys.summary.to_connect_your_web_app_with_formbricks")}
</li>
<li>
{t("environments.surveys.summary.learn_how_to")}{" "}
<Link
href="https://formbricks.com/docs/app-surveys/user-identification"
target="_blank"
className="decoration-brand-dark font-medium underline underline-offset-2">
{t("environments.surveys.summary.identify_users_and_set_attributes")}
</Link>{" "}
{t("environments.surveys.summary.to_run_highly_targeted_surveys")}.
</li>
<li>
{t("environments.surveys.summary.make_sure_the_survey_type_is_set_to")}{" "}
<b>{t("common.app_survey")}</b>
</li>
<li>{t("environments.surveys.summary.define_when_and_where_the_survey_should_pop_up")}</li>
</ol>
<div className="mt-4">
<video autoPlay loop muted className="w-full rounded-xl border border-slate-200">
<source src="/video/tooltips/change-survey-type-app.mp4" type="video/mp4" />
{t("environments.surveys.summary.unsupported_video_tag_warning")}
</video>
</div>
</div> </div>
); );
}; };
@@ -20,8 +20,8 @@ interface EmbedViewProps {
survey: any; survey: any;
email: string; email: string;
surveyUrl: string; surveyUrl: string;
surveyDomain: string;
setSurveyUrl: React.Dispatch<React.SetStateAction<string>>; setSurveyUrl: React.Dispatch<React.SetStateAction<string>>;
webAppUrl: string;
locale: TUserLocale; locale: TUserLocale;
} }
@@ -35,8 +35,8 @@ export const EmbedView = ({
survey, survey,
email, email,
surveyUrl, surveyUrl,
surveyDomain,
setSurveyUrl, setSurveyUrl,
webAppUrl,
locale, locale,
}: EmbedViewProps) => { }: EmbedViewProps) => {
const { t } = useTranslate(); const { t } = useTranslate();
@@ -82,13 +82,13 @@ export const EmbedView = ({
) : activeId === "link" ? ( ) : activeId === "link" ? (
<LinkTab <LinkTab
survey={survey} survey={survey}
webAppUrl={webAppUrl}
surveyUrl={surveyUrl} surveyUrl={surveyUrl}
surveyDomain={surveyDomain}
setSurveyUrl={setSurveyUrl} setSurveyUrl={setSurveyUrl}
locale={locale} locale={locale}
/> />
) : activeId === "app" ? ( ) : activeId === "app" ? (
<AppTab /> <AppTab environmentId={environmentId} />
) : null} ) : null}
<div className="mt-2 rounded-md p-3 text-center lg:hidden"> <div className="mt-2 rounded-md p-3 text-center lg:hidden">
{tabs.slice(0, 2).map((tab) => ( {tabs.slice(0, 2).map((tab) => (
@@ -8,15 +8,14 @@ import { TUserLocale } from "@formbricks/types/user";
interface LinkTabProps { interface LinkTabProps {
survey: TSurvey; survey: TSurvey;
webAppUrl: string;
surveyUrl: string; surveyUrl: string;
surveyDomain: string;
setSurveyUrl: (url: string) => void; setSurveyUrl: (url: string) => void;
locale: TUserLocale; locale: TUserLocale;
} }
export const LinkTab = ({ survey, surveyUrl, surveyDomain, setSurveyUrl, locale }: LinkTabProps) => { export const LinkTab = ({ survey, webAppUrl, surveyUrl, setSurveyUrl, locale }: LinkTabProps) => {
const { t } = useTranslate(); const { t } = useTranslate();
const docsLinks = [ const docsLinks = [
{ {
title: t("environments.surveys.summary.data_prefilling"), title: t("environments.surveys.summary.data_prefilling"),
@@ -43,13 +42,12 @@ export const LinkTab = ({ survey, surveyUrl, surveyDomain, setSurveyUrl, locale
</p> </p>
<ShareSurveyLink <ShareSurveyLink
survey={survey} survey={survey}
webAppUrl={webAppUrl}
surveyUrl={surveyUrl} surveyUrl={surveyUrl}
surveyDomain={surveyDomain}
setSurveyUrl={setSurveyUrl} setSurveyUrl={setSurveyUrl}
locale={locale} locale={locale}
/> />
</div> </div>
<div className="flex flex-wrap justify-between gap-2"> <div className="flex flex-wrap justify-between gap-2">
<p className="pt-2 font-semibold text-slate-700"> <p className="pt-2 font-semibold text-slate-700">
{t("environments.surveys.summary.you_can_do_a_lot_more_with_links_surveys")} 💡 {t("environments.surveys.summary.you_can_do_a_lot_more_with_links_surveys")} 💡
@@ -1,25 +0,0 @@
"use client";
import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { useTranslate } from "@tolgee/react";
import Link from "next/link";
export const MobileAppTab = () => {
const { t } = useTranslate();
return (
<Alert>
<AlertTitle>{t("environments.surveys.summary.quickstart_mobile_apps")}</AlertTitle>
<AlertDescription>
{t("environments.surveys.summary.quickstart_mobile_apps_description")}
<Button asChild className="w-fit" size="sm" variant="link">
<Link
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/framework-guides"
target="_blank">
{t("common.learn_more")}
</Link>
</Button>
</AlertDescription>
</Alert>
);
};

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