Compare commits

..

5 Commits

Author SHA1 Message Date
Matthias Nannt
f4bd2b9289 fix coderabbit issue 2025-04-09 11:19:30 +09:00
Matti Nannt
dba4f53bd8 Potential fix for code scanning alert no. 211: Artifact poisoning
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-03-11 13:52:52 +01:00
Matthias Nannt
f8cbbb15d6 add second action to address security risk 2025-03-11 13:47:47 +01:00
Matthias Nannt
2fa77b8ef6 update action to address codeQL feedback 2025-03-11 13:46:07 +01:00
Matthias Nannt
ec487899da fix: sonarqube not running for contributors 2025-03-11 13:13:14 +01:00
1602 changed files with 35577 additions and 76259 deletions

View File

@@ -25,9 +25,6 @@ NEXTAUTH_SECRET=
# You can use: `openssl rand -hex 32` to generate a secure one
CRON_SECRET=
# Set the minimum log level(debug, info, warn, error, fatal)
LOG_LEVEL=info
##############
# DATABASE #
##############
@@ -42,7 +39,6 @@ DATABASE_URL='postgresql://postgres:postgres@localhost:5432/formbricks?schema=pu
# See optional configurations below if you want to disable these features.
MAIL_FROM=noreply@example.com
MAIL_FROM_NAME=Formbricks
SMTP_HOST=localhost
SMTP_PORT=1025
# Enable SMTP_SECURE_ENABLED for TLS (port 465)
@@ -80,9 +76,6 @@ S3_ENDPOINT_URL=
# Force path style for S3 compatible storage (0 for disabled, 1 for enabled)
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 #
#####################
@@ -103,9 +96,6 @@ PASSWORD_RESET_DISABLED=1
# Organization Invite. Disable the ability for invited users to create an account.
# INVITE_DISABLED=1
# Docker cron jobs. Disable the supercronic cron jobs in the Docker image (useful for cluster setups).
# DOCKER_CRON_ENABLED=1
##########
# Other #
##########
@@ -117,7 +107,7 @@ IMPRINT_URL=
IMPRINT_ADDRESS=
# Configure Turnstile in signup flow
# TURNSTILE_SITE_KEY=
# NEXT_PUBLIC_TURNSTILE_SITE_KEY=
# TURNSTILE_SECRET_KEY=
# Configure Github Login
@@ -155,8 +145,9 @@ STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
# Configure Formbricks usage within Formbricks
FORMBRICKS_API_HOST=
FORMBRICKS_ENVIRONMENT_ID=
NEXT_PUBLIC_FORMBRICKS_API_HOST=
NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID=
NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID=
# Oauth credentials for Google sheet integration
GOOGLE_SHEETS_CLIENT_ID=
@@ -193,9 +184,7 @@ ENTERPRISE_LICENSE_KEY=
UNSPLASH_ACCESS_KEY=
# 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_DEFAULT_TTL=86400 # 1 day
# REDIS_URL=redis://localhost:6379
# 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:
@@ -206,18 +195,11 @@ UNKEY_ROOT_KEY=
# Disable custom cache handler if necessary (e.g. if deployed on Vercel)
# CUSTOM_CACHE_DISABLED=1
# INTERCOM_APP_ID=
# INTERCOM_SECRET_KEY=
# Azure AI settings
# AI_AZURE_RESSOURCE_NAME=
# AI_AZURE_API_KEY=
# AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID=
# AI_AZURE_LLM_DEPLOYMENT_ID=
# Enable Prometheus metrics
# PROMETHEUS_ENABLED=
# PROMETHEUS_EXPORTER_PORT=
# The SENTRY_DSN is used for error tracking and performance monitoring with Sentry.
# SENTRY_DSN=
# The SENTRY_AUTH_TOKEN variable is picked up by the Sentry Build Plugin.
# It's used automatically by Sentry during the build for authentication when uploading source maps.
# SENTRY_AUTH_TOKEN=
# Disable the user management from UI
# DISABLE_USER_MANAGEMENT
# NEXT_PUBLIC_INTERCOM_APP_ID=
# INTERCOM_SECRET_KEY=

View File

@@ -1,7 +1,6 @@
name: Bug report
description: "Found a bug? Please fill out the sections below. \U0001F44D"
type: bug
labels: ["bug"]
body:
- type: textarea
id: issue-summary

View File

@@ -8,14 +8,6 @@ on:
required: false
default: "0"
inputs:
turbo_token:
description: "Turborepo token"
required: false
turbo_team:
description: "Turborepo team"
required: false
runs:
using: "composite"
steps:
@@ -65,13 +57,14 @@ runs:
run: |
RANDOM_KEY=$(openssl rand -hex 32)
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
shell: bash
- run: |
pnpm build --filter=@formbricks/web...
if: steps.cache-build.outputs.cache-hit != 'true'
shell: bash
env:
TURBO_TOKEN: ${{ inputs.turbo_token }}
TURBO_TEAM: ${{ inputs.turbo_team }}

View File

@@ -1,27 +0,0 @@
# Testing Instructions
When generating test files inside the "/app/web" path, follow these rules:
- Use vitest
- Ensure 100% code coverage
- Add as few comments as possible
- The test file should be located in the same folder as the original file
- Use the `test` function instead of `it`
- Follow the same test pattern used for other files in the package where the file is located
- All imports should be at the top of the file, not inside individual tests
- For mocking inside "test" blocks use "vi.mocked"
- Add the original file path to the "test.coverage.include"array in the "apps/web/vite.config.mts" file. Do this only when the test file is created.
- Don't mock functions that are already mocked in the "apps/web/vitestSetup.ts" file
- When using "screen.getByText" check for the tolgee string if it is being used in the file.
If it's a test for a ".tsx" file, follow these extra instructions:
- Add this code inside the "describe" block and before any test:
afterEach(() => {
cleanup();
});
- the "afterEach" function should only have "cleanup()" inside it and should be adde to the "vitest" imports
- For click events, import userEvent from "@testing-library/user-event"
- Mock other components that can make the text more complex and but at the same time mocking it wouldn't make the test flaky. It's ok to leave basic and simple components.

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"

View File

@@ -5,9 +5,6 @@ on:
types:
- opened
permissions:
contents: read
jobs:
label_on_pr:
runs-on: ubuntu-latest
@@ -18,13 +15,8 @@ jobs:
pull-requests: write
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
uses: actions/github-script@211cb3fefb35a799baa5156f9321bb774fe56294 # v5.2.0
uses: actions/github-script@v5
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |

View File

@@ -4,7 +4,7 @@ on:
permissions:
contents: read
jobs:
build:
name: Build Formbricks-web
@@ -12,12 +12,7 @@ jobs:
timeout-minutes: 30
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@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: actions/checkout@v3
- uses: ./.github/actions/dangerous-git-checkout
- name: Build & Cache Web Binaries
@@ -25,5 +20,3 @@ jobs:
id: cache-build-web
with:
e2e_testing_mode: "0"
turbo_token: ${{ secrets.TURBO_TOKEN }}
turbo_team: ${{ vars.TURBO_TEAM }}

View File

@@ -11,24 +11,19 @@ jobs:
name: Run Chromatic
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 code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Install pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
uses: pnpm/action-setup@v4
- name: Install dependencies
run: pnpm install --config.platform=linux --config.architecture=x64
- name: Run Chromatic
uses: chromaui/action@c93e0bc3a63aa176e14a75b61a31847cbfdd341c # latest
uses: chromaui/action@latest
with:
# ⚠️ Make sure to configure a `CHROMATIC_PROJECT_TOKEN` repository secret
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}

View File

@@ -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

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

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

View File

@@ -1,100 +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'
ENVIRONMENT:
description: 'The environment to deploy to'
required: true
type: choice
options:
- stage
- prod
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'
ENVIRONMENT:
description: 'The environment to deploy to'
required: true
type: string
permissions:
id-token: write
contents: write
jobs:
helmfile-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Tailscale
uses: tailscale/github-action@v3
with:
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
tags: tag:github
- 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
name: Deploy Formbricks Cloud Prod
if: (github.event_name == 'workflow_call' || github.event_name == 'workflow_dispatch') && github.event.inputs.ENVIRONMENT == 'prod'
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 -l environment=prod
helmfile-auto-init: "false"
helmfile-workdirectory: infra/formbricks-cloud-helm
- uses: helmfile/helmfile-action@v2
name: Deploy Formbricks Cloud Stage
if: github.event_name == 'workflow_dispatch' && github.event.inputs.ENVIRONMENT == 'stage'
env:
VERSION: ${{ inputs.VERSION }}
REPOSITORY: ${{ inputs.REPOSITORY }}
FORMBRICKS_INGRESS_CERT_ARN: ${{ secrets.STAGE_FORMBRICKS_INGRESS_CERT_ARN }}
FORMBRICKS_ROLE_ARN: ${{ secrets.STAGE_FORMBRICKS_ROLE_ARN }}
with:
helm-plugins: >
https://github.com/databus23/helm-diff,
https://github.com/jkroepke/helm-secrets
helmfile-args: apply -l environment=stage
helmfile-auto-init: "false"
helmfile-workdirectory: infra/formbricks-cloud-helm

View File

@@ -1,167 +0,0 @@
name: Docker Build Validation
on:
pull_request:
branches:
- main
merge_group:
branches:
- main
workflow_dispatch:
permissions:
contents: read
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
jobs:
validate-docker-build:
name: Validate Docker Build
runs-on: ubuntu-latest
# Add PostgreSQL service container
services:
postgres:
image: pgvector/pgvector:pg17
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: formbricks
ports:
- 5432:5432
# Health check to ensure PostgreSQL is ready before using it
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Checkout Repository
uses: actions/checkout@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build Docker Image
uses: docker/build-push-action@v6
with:
context: .
file: ./apps/web/Dockerfile
push: false
load: true
tags: formbricks-test:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
secrets: |
database_url=${{ secrets.DUMMY_DATABASE_URL }}
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
- name: Verify PostgreSQL Connection
run: |
echo "Verifying PostgreSQL connection..."
# Install PostgreSQL client to test connection
sudo apt-get update && sudo apt-get install -y postgresql-client
# Test connection using psql
PGPASSWORD=test psql -h localhost -U test -d formbricks -c "\dt" || echo "Failed to connect to PostgreSQL"
# Show network configuration
echo "Network configuration:"
ip addr show
netstat -tulpn | grep 5432 || echo "No process listening on port 5432"
- name: Test Docker Image with Health Check
shell: bash
run: |
echo "🧪 Testing if the Docker image starts correctly..."
# Add extra docker run args to support host.docker.internal on Linux
DOCKER_RUN_ARGS="--add-host=host.docker.internal:host-gateway"
# Start the container with host.docker.internal pointing to the host
docker run --name formbricks-test \
$DOCKER_RUN_ARGS \
-p 3000:3000 \
-e DATABASE_URL="postgresql://test:test@host.docker.internal:5432/formbricks" \
-e ENCRYPTION_KEY="${{ secrets.DUMMY_ENCRYPTION_KEY }}" \
-d formbricks-test:${{ github.sha }}
# Give it more time to start up
echo "Waiting 45 seconds for application to start..."
sleep 45
# Check if the container is running
if [ "$(docker inspect -f '{{.State.Running}}' formbricks-test)" != "true" ]; then
echo "❌ Container failed to start properly!"
docker logs formbricks-test
exit 1
else
echo "✅ Container started successfully!"
fi
# Try connecting to PostgreSQL from inside the container
echo "Testing PostgreSQL connection from inside container..."
docker exec formbricks-test sh -c 'apt-get update && apt-get install -y postgresql-client && PGPASSWORD=test psql -h host.docker.internal -U test -d formbricks -c "\dt" || echo "Failed to connect to PostgreSQL from container"'
# Try to access the health endpoint
echo "🏥 Testing /health endpoint..."
MAX_RETRIES=10
RETRY_COUNT=0
HEALTH_CHECK_SUCCESS=false
set +e # Disable exit on error to allow for retries
while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do
RETRY_COUNT=$((RETRY_COUNT + 1))
echo "Attempt $RETRY_COUNT of $MAX_RETRIES..."
# Show container logs before each attempt to help debugging
if [ $RETRY_COUNT -gt 1 ]; then
echo "📋 Current container logs:"
docker logs --tail 20 formbricks-test
fi
# Get detailed curl output for debugging
HTTP_OUTPUT=$(curl -v -s -m 30 http://localhost:3000/health 2>&1)
CURL_EXIT_CODE=$?
echo "Curl exit code: $CURL_EXIT_CODE"
echo "Curl output: $HTTP_OUTPUT"
if [ $CURL_EXIT_CODE -eq 0 ]; then
STATUS_CODE=$(echo "$HTTP_OUTPUT" | grep -oP "HTTP/\d(\.\d)? \K\d+")
echo "Status code detected: $STATUS_CODE"
if [ "$STATUS_CODE" = "200" ]; then
echo "✅ Health check successful!"
HEALTH_CHECK_SUCCESS=true
break
else
echo "❌ Health check returned non-200 status code: $STATUS_CODE"
fi
else
echo "❌ Curl command failed with exit code: $CURL_EXIT_CODE"
fi
echo "Waiting 15 seconds before next attempt..."
sleep 15
done
# Show full container logs for debugging
echo "📋 Full container logs:"
docker logs formbricks-test
# Clean up the container
echo "🧹 Cleaning up..."
docker rm -f formbricks-test
# Exit with failure if health check did not succeed
if [ "$HEALTH_CHECK_SUCCESS" != "true" ]; then
echo "❌ Health check failed after $MAX_RETRIES attempts"
exit 1
fi
echo "✨ Docker validation complete - all checks passed!"

View File

@@ -16,8 +16,6 @@ on:
env:
TELEMETRY_DISABLED: 1
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
permissions:
id-token: write
@@ -45,21 +43,16 @@ jobs:
--health-timeout=5s
--health-retries=5
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@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: actions/checkout@v3
- uses: ./.github/actions/dangerous-git-checkout
- name: Setup Node.js 20.x
uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # v3.8.2
uses: actions/setup-node@v3
with:
node-version: 20.x
- name: Install pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
uses: pnpm/action-setup@v4
- name: Install dependencies
run: pnpm install --config.platform=linux --config.architecture=x64
@@ -119,7 +112,7 @@ jobs:
- name: Azure login
if: env.AZURE_ENABLED == 'true'
uses: azure/login@a65d910e8af852a8061c627c456678983e180302 # v2.2.0
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
@@ -137,14 +130,14 @@ jobs:
run: |
pnpm test:e2e
- uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 30
- uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
- uses: actions/upload-artifact@v4
if: failure()
with:
name: app-logs

View File

@@ -1,34 +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 }}
ENVIRONMENT: "prod"

View File

@@ -4,9 +4,6 @@ on:
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
labeler:
name: Pull Request Labeler
@@ -15,12 +12,7 @@ jobs:
pull-requests: write
runs-on: ubuntu-latest
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit
- uses: actions/labeler@ac9175f8a1f3625fd0d4fb234536d26811351594 # v4.3.0
- uses: actions/labeler@v4
with:
repo-token: "${{ secrets.GITHUB_TOKEN }}"
# https://github.com/actions/labeler/issues/442#issuecomment-1297359481

View File

@@ -12,11 +12,6 @@ jobs:
timeout-minutes: 15
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- uses: ./.github/actions/dangerous-git-checkout

View File

@@ -50,10 +50,6 @@ jobs:
checks: write
statuses: write
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
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'skipped') || contains(needs.*.result, 'cancelled')
run: exit 1

62
.github/workflows/prepare-release.yml vendored Normal file
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 }}

View File

@@ -0,0 +1,51 @@
name: Release Changesets
on:
workflow_dispatch:
#push:
# branches:
# - main
permissions:
contents: write
pull-requests: write
packages: write
concurrency: ${{ github.workflow }}-${{ github.ref }}
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
jobs:
release:
name: Release
runs-on: ubuntu-latest
timeout-minutes: 15
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
steps:
- name: Checkout Repo
uses: actions/checkout@v2
- name: Setup Node.js 18.x
uses: actions/setup-node@v2
with:
node-version: 18.x
- name: Install pnpm
uses: pnpm/action-setup@v2.2.4
- name: Install Dependencies
run: pnpm install --config.platform=linux --config.architecture=x64
- name: Create Release Pull Request or Publish to npm
id: changesets
uses: changesets/action@v1
with:
# This expects you to have a script called release which does a build for your packages and calls changeset publish
publish: pnpm release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

View File

@@ -15,9 +15,7 @@ env:
IMAGE_NAME: ${{ github.repository }}-experimental
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
permissions:
contents: read
DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/formbricks?schema=public"
jobs:
build:
@@ -30,28 +28,23 @@ jobs:
id-token: write
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@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
uses: actions/checkout@v3
- 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
# https://github.com/sigstore/cosign-installer
- name: Install cosign
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
# https://github.com/docker/login-action
- name: Log into registry ${{ env.REGISTRY }}
if: github.event_name != 'pull_request'
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
uses: docker/login-action@v3 # v3.0.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -61,7 +54,7 @@ jobs:
# https://github.com/docker/metadata-action
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
uses: docker/metadata-action@v5 # v5.0.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
@@ -69,7 +62,7 @@ jobs:
# https://github.com/docker/build-push-action
- name: Build and push Docker image
id: build-and-push
uses: depot/build-push-action@636daae76684e38c301daa0c5eca1c095b24e780 # v1.14.0
uses: depot/build-push-action@v1
with:
project: tw0fqmsx3c
token: ${{ secrets.DEPOT_PROJECT_TOKEN }}
@@ -79,9 +72,8 @@ jobs:
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
secrets: |
database_url=${{ secrets.DUMMY_DATABASE_URL }}
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
cache-from: type=gha
cache-to: type=gha,mode=max
# Sign the resulting Docker image digest except on PRs.
# This will only write to the public Rekor transparency log when the Docker

View File

@@ -6,11 +6,10 @@ name: Docker Release to Github
# documentation.
on:
workflow_call:
outputs:
VERSION:
description: release version
value: ${{ jobs.build.outputs.VERSION }}
workflow_dispatch:
push:
tags:
- "v*"
env:
# Use docker.io for Docker Hub if empty
@@ -19,9 +18,7 @@ env:
IMAGE_NAME: ${{ github.repository }}
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
permissions:
contents: read
DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/formbricks?schema=public"
jobs:
build:
@@ -33,45 +30,24 @@ jobs:
# with sigstore/fulcio when running outside of PRs.
id-token: write
outputs:
VERSION: ${{ steps.extract_release_tag.outputs.VERSION }}
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@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- name: Get Release Tag
id: extract_release_tag
run: |
TAG=${{ github.ref }}
TAG=${TAG#refs/tags/v}
echo "RELEASE_TAG=$TAG" >> $GITHUB_ENV
echo "VERSION=$TAG" >> $GITHUB_OUTPUT
- name: Update package.json version
run: |
sed -i "s/\"version\": \"0.0.0\"/\"version\": \"${{ env.RELEASE_TAG }}\"/" ./apps/web/package.json
cat ./apps/web/package.json | grep version
uses: actions/checkout@v3
- 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
# https://github.com/sigstore/cosign-installer
- name: Install cosign
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
# https://github.com/docker/login-action
- name: Log into registry ${{ env.REGISTRY }}
if: github.event_name != 'pull_request'
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
uses: docker/login-action@v3 # v3.0.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -81,7 +57,7 @@ jobs:
# https://github.com/docker/metadata-action
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
uses: docker/metadata-action@v5 # v5.0.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
@@ -89,7 +65,7 @@ jobs:
# https://github.com/docker/build-push-action
- name: Build and push Docker image
id: build-and-push
uses: depot/build-push-action@636daae76684e38c301daa0c5eca1c095b24e780 # v1.14.0
uses: depot/build-push-action@v1
with:
project: tw0fqmsx3c
token: ${{ secrets.DEPOT_PROJECT_TOKEN }}
@@ -99,9 +75,8 @@ jobs:
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
secrets: |
database_url=${{ secrets.DUMMY_DATABASE_URL }}
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
cache-from: type=gha
cache-to: type=gha,mode=max
# Sign the resulting Docker image digest except on PRs.
# This will only write to the public Rekor transparency log when the Docker

46
.github/workflows/release-docker.yml vendored Normal file
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

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

View File

@@ -34,11 +34,6 @@ jobs:
# actions: 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 code"
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
with:
@@ -76,6 +71,6 @@ jobs:
# Upload the results to GitHub's code scanning dashboard (optional).
# Commenting out will disable upload of results to your repo's Code Scanning dashboard
- name: "Upload to code-scanning"
uses: github/codeql-action/upload-sarif@b56ba49b26e50535fa1e7f7db0f4f7b4bf65d80d # v3.28.10
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: results.sarif

View File

@@ -16,12 +16,7 @@ jobs:
name: PR title
runs-on: ubuntu-latest
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit
- uses: amannn/action-semantic-pull-request@0723387faaf9b38adef4775cd42cfd5155ed6017 # v5.5.3
- uses: amannn/action-semantic-pull-request@v5
id: lint_pr_title
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -40,7 +35,7 @@ jobs:
revert
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
# condition you can continue the execution with the populated error message.
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
- 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:
header: pr-title-lint-error
message: |

134
.github/workflows/sonarqube-report.yml vendored Normal file
View File

@@ -0,0 +1,134 @@
name: SonarQube Report
on:
workflow_dispatch:
push:
branches:
- main
workflow_run:
workflows: ["SonarQube Analysis"]
types:
- completed
permissions:
# Needed to download artifacts from previous workflow
actions: read
contents: read
jobs:
report:
name: SonarCloud Report
runs-on: ubuntu-latest
# Only run if the previous workflow was successful
if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name == 'push' || github.event_name == 'workflow_dispatch' }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
# Download artifacts only for workflow_run events
- name: Create temporary directory for artifacts
if: ${{ github.event_name == 'workflow_run' }}
run: mkdir -p ${{ runner.temp }}/artifacts/
- name: Download analysis package
if: ${{ github.event_name == 'workflow_run' }}
uses: actions/github-script@v7
with:
script: |
const artifacts = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: ${{ github.event.workflow_run.id }}
})
const matchArtifact = artifacts.data.artifacts.find((artifact) => {
return artifact.name == "sonarqube-analysis"
})
if (!matchArtifact) {
throw new Error('No SonarQube analysis artifact found')
}
const download = await github.rest.actions.downloadArtifact({
owner: context.repo.owner,
repo: context.repo.repo,
artifact_id: matchArtifact.id,
archive_format: 'zip'
})
const fs = require('fs')
const path = require('path')
const tempDir = process.env.RUNNER_TEMP
const artifactPath = path.join(tempDir, 'artifacts', 'sonarqube-analysis.zip')
fs.writeFileSync(artifactPath, Buffer.from(download.data))
# Extract the analysis package
- name: Extract analysis package
if: ${{ github.event_name == 'workflow_run' }}
run: |
unzip -q ${{ runner.temp }}/artifacts/sonarqube-analysis.zip -d ${{ runner.temp }}/artifacts/
tar -xzf ${{ runner.temp }}/artifacts/sonarqube-analysis.tar.gz -C ${{ runner.temp }}/artifacts/
# For direct push to main or manual runs, set up environment and run tests
- name: Setup Node.js 20.x
if: ${{ github.event_name != 'workflow_run' }}
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
with:
node-version: 20.x
- name: Install pnpm
if: ${{ github.event_name != 'workflow_run' }}
uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2
- name: Install dependencies
if: ${{ github.event_name != 'workflow_run' }}
run: pnpm install --config.platform=linux --config.architecture=x64
- name: Create .env
if: ${{ github.event_name != 'workflow_run' }}
run: cp .env.example .env
- name: Generate Random ENCRYPTION_KEY, CRON_SECRET & NEXTAUTH_SECRET and fill in .env
if: ${{ github.event_name != 'workflow_run' }}
run: |
RANDOM_KEY=$(openssl rand -hex 32)
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
- name: Run tests with coverage
if: ${{ github.event_name != 'workflow_run' }}
run: |
# Verify repository integrity before running tests
git -c protocol.version=2 fetch --no-tags --prune --progress --no-recurse-submodules --depth=1 origin ${{ github.sha }}
git checkout --progress --force ${{ github.sha }}
# Verify that only expected files exist in the test directory
echo "Verifying test directory integrity..."
UNEXPECTED_FILES=$(find apps/web -type f -not -path "*/node_modules/*" -not -path "*/\.*" -not -path "*/coverage/*" | grep -v -E '\.tsx?$|\.jsx?$|package\.json|tsconfig\.json|\.html$|\.css$|\.svg$')
if [ -n "$UNEXPECTED_FILES" ]; then
echo "Warning: Unexpected files detected in apps/web directory:"
echo "$UNEXPECTED_FILES"
exit 1
fi
# Run tests in a more restricted environment
cd apps/web
NODE_OPTIONS="--max-old-space-size=4096 --max-http-header-size=8192" pnpm test:coverage
cd ../../
# The Vitest coverage config is in your vite.config.mts
# Run SonarQube scan
- name: SonarQube Scan
uses: SonarSource/sonarqube-scan-action@bfd4e558cda28cda6b5defafb9232d191be8c203
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL || 'https://sonarcloud.io' }}
# Optional: Get the Quality Gate status
- name: SonarQube Quality Gate check
id: sonarqube-quality-gate-check
uses: sonarsource/sonarqube-quality-gate-action@master
# Force the job to fail if Quality Gate fails
# timeout-minutes: 5
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL || 'https://sonarcloud.io' }}

View File

@@ -1,32 +1,25 @@
name: SonarQube
name: SonarQube Analysis
on:
workflow_dispatch:
push:
branches:
- main
pull_request:
types: [opened, synchronize, reopened]
merge_group:
permissions:
contents: read
jobs:
sonarqube:
name: SonarQube
analyze:
name: Analyze
runs-on: ubuntu-latest
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Checkout code
uses: actions/checkout@v4
with:
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
with:
node-version: 22.x
node-version: 20.x
- name: Install pnpm
uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2
@@ -34,7 +27,7 @@ jobs:
- name: Install dependencies
run: pnpm install --config.platform=linux --config.architecture=x64
- name: create .env
- name: Create .env
run: cp .env.example .env
- name: Generate Random ENCRYPTION_KEY, CRON_SECRET & NEXTAUTH_SECRET and fill in .env
@@ -46,9 +39,53 @@ jobs:
- name: Run tests with coverage
run: |
cd apps/web
pnpm test:coverage
- name: SonarQube Scan
uses: SonarSource/sonarqube-scan-action@aa494459d7c39c106cc77b166de8b4250a32bb97
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
cd ../../
# The Vitest coverage config is in your vite.config.mts
- name: Prepare SonarQube scan info
id: sonarqube-prep
# We'll collect info about the PR without running the actual scan
run: |
echo "PR_NUMBER=${{ github.event.pull_request.number || 'main' }}" >> $GITHUB_OUTPUT
echo "PR_SOURCE=${{ github.event.pull_request.head.repo.full_name || github.repository }}" >> $GITHUB_OUTPUT
echo "PR_BRANCH=${{ github.event.pull_request.head.ref || github.ref_name }}" >> $GITHUB_OUTPUT
echo "PR_BASE=${{ github.event.pull_request.base.ref || '' }}" >> $GITHUB_OUTPUT
# Create analysis properties file
- name: Generate sonar-scanner properties
run: |
{
echo "sonar.projectKey=${{ github.repository_owner }}_formbricks"
echo "sonar.organization=${{ github.repository_owner }}-github"
# If this is a PR, add PR specific properties
if [[ ! -z "${{ steps.sonarqube-prep.outputs.PR_NUMBER }}" && "${{ steps.sonarqube-prep.outputs.PR_NUMBER }}" != "main" ]]; then
echo "sonar.pullrequest.key=${{ steps.sonarqube-prep.outputs.PR_NUMBER }}"
echo "sonar.pullrequest.branch=${{ steps.sonarqube-prep.outputs.PR_BRANCH }}"
echo "sonar.pullrequest.base=${{ steps.sonarqube-prep.outputs.PR_BASE }}"
else
echo "sonar.branch.name=${{ github.ref_name }}"
fi
} > sonar-scanner.properties
# Prepare the project for analysis and save all necessary files
- name: Package project for SonarQube analysis
run: |
# Create a tar file containing all necessary files
tar -czf sonarqube-analysis.tar.gz \
--exclude=node_modules \
--exclude=.git \
sonar-scanner.properties \
sonar-project.properties \
apps/web/coverage \
$(find . -type f -name "*.ts" -o -name "*.tsx" -o -name "*.js" -o -name "*.jsx")
# Upload the packaged project as an artifact
- name: Upload SonarQube analysis package
uses: actions/upload-artifact@v4
with:
name: sonarqube-analysis
path: sonarqube-analysis.tar.gz
retention-days: 1

View File

@@ -1,86 +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: Tailscale
uses: tailscale/github-action@v3
with:
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
tags: tag:github
- 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"

View File

@@ -1,9 +1,6 @@
name: Tests
on:
workflow_call:
permissions:
contents: read
jobs:
build:
name: Unit Tests
@@ -13,21 +10,16 @@ jobs:
contents: read
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@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: actions/checkout@v3
- uses: ./.github/actions/dangerous-git-checkout
- name: Setup Node.js 20.x
uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # v3.8.2
uses: actions/setup-node@v3
with:
node-version: 20.x
- name: Install pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
uses: pnpm/action-setup@v4
- name: Install dependencies
run: pnpm install --config.platform=linux --config.architecture=x64

View File

@@ -12,23 +12,18 @@ jobs:
check-missing-translations:
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
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.base.ref }}
- name: Checkout PR
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Setup Node.js
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
uses: actions/setup-node@v4
with:
node-version: 18

View File

@@ -15,13 +15,8 @@ jobs:
if: github.event.pull_request.merged == true
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
uses: actions/checkout@v4
with:
fetch-depth: 0 # This ensures we get the full git history
@@ -41,7 +36,7 @@ jobs:
echo "Detected source branch: $SOURCE_BRANCH"
- name: Setup Node.js
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
uses: actions/setup-node@v4
with:
node-version: 18 # Ensure compatibility with your project
@@ -56,6 +51,7 @@ jobs:
--filter-tag "draft:${SOURCE_BRANCH}" \
--tag production \
--untag "draft:${SOURCE_BRANCH}"
--verbose
- name: Tag unused production keys as Deprecated
run: |
@@ -63,6 +59,7 @@ jobs:
--api-key ${{ secrets.TOLGEE_API_KEY }} \
--filter-not-extracted --filter-tag production \
--tag deprecated --untag production
--verbose
- name: Tag unused draft:current-branch keys as Deprecated
run: |
@@ -70,6 +67,7 @@ jobs:
--api-key ${{ secrets.TOLGEE_API_KEY }} \
--filter-not-extracted --filter-tag "draft:${SOURCE_BRANCH}" \
--tag deprecated --untag "draft:${SOURCE_BRANCH}"
--verbose
- name: Sync with backup
run: |
@@ -80,7 +78,7 @@ jobs:
--yes
- name: Upload backup as artifact
uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
uses: actions/upload-artifact@v4
with:
name: tolgee-backup-${{ github.sha }}
path: ./tolgee-backup

View File

@@ -17,12 +17,7 @@ jobs:
timeout-minutes: 10
if: github.event.action == 'opened'
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit
- uses: actions/first-interaction@3c71ce730280171fd1cfb57c00c774f8998586f7 # v1
- uses: actions/first-interaction@v1
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
pr-message: |-

18
.gitignore vendored
View File

@@ -55,21 +55,3 @@ apps/web/public/js
packages/database/migrations
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
packages/ios/FormbricksSDK/FormbricksSDK.xcodeproj/project.xcworkspace/xcuserdata

View File

@@ -1,6 +1,6 @@
#!/bin/bash
images=($(yq eval '.services.*.image' docker-compose.dev.yml))
images=($(yq eval '.services.*.image' packages/database/docker-compose.yml))
pull_image() {
docker pull "$1"

View File

@@ -16,6 +16,6 @@ if [ -f branch.json ]; then
echo "Skipping tolgee-pull: NEXT_PUBLIC_TOLGEE_API_KEY is not set"
else
pnpm run tolgee-pull
git add apps/web/locales
git add packages/lib/messages
fi
fi

View File

@@ -4,33 +4,33 @@
"patterns": ["./apps/web/**/*.ts?(x)"],
"projectId": 10304,
"pull": {
"path": "./apps/web/locales"
"path": "./packages/lib/messages"
},
"push": {
"files": [
{
"language": "en-US",
"path": "./apps/web/locales/en-US.json"
"path": "./packages/lib/messages/en-US.json"
},
{
"language": "de-DE",
"path": "./apps/web/locales/de-DE.json"
"path": "./packages/lib/messages/de-DE.json"
},
{
"language": "fr-FR",
"path": "./apps/web/locales/fr-FR.json"
"path": "./packages/lib/messages/fr-FR.json"
},
{
"language": "pt-BR",
"path": "./apps/web/locales/pt-BR.json"
"path": "./packages/lib/messages/pt-BR.json"
},
{
"language": "zh-Hant-TW",
"path": "./apps/web/locales/zh-Hant-TW.json"
"path": "./packages/lib/messages/zh-Hant-TW.json"
},
{
"language": "pt-PT",
"path": "./apps/web/locales/pt-PT.json"
"path": "./packages/lib/messages/pt-PT.json"
}
],
"forceMode": "OVERRIDE"

View File

@@ -1,10 +1,4 @@
{
"javascript.updateImportsOnFileMove.enabled": "always",
"sonarlint.connectedMode.project": {
"connectionId": "formbricks",
"projectKey": "formbricks_formbricks"
},
"typescript.preferences.importModuleSpecifier": "non-relative",
"typescript.tsdk": "node_modules/typescript/lib",
"typescript.updateImportsOnFileMove.enabled": "always"
"typescript.tsdk": "node_modules/typescript/lib"
}

View File

@@ -3,7 +3,7 @@ Copyright (c) 2024 Formbricks GmbH
Portions of this software are licensed as follows:
- All content that resides under the "apps/web/modules/ee" directory of this repository, if these directories exist, is licensed under the license defined in "apps/web/modules/ee/LICENSE".
- All content that resides under the "packages/js/", "packages/android/", "packages/ios/" and "packages/api/" directories of this repository, if that directories exist, is licensed under the "MIT" license as defined in the "LICENSE" files of these packages.
- All content that resides under the "packages/js/", "packages/react-native/", "packages/android/", "packages/ios/" and "packages/api/" directories of this repository, if that directories exist, is licensed under the "MIT" license as defined in the "LICENSE" files of these packages.
- All third party components incorporated into the Formbricks Software are licensed under the original license provided by the owner of the applicable component.
- Content outside of the above mentioned directories or restrictions above is available under the "AGPLv3" license as defined below.

View File

@@ -0,0 +1,2 @@
EXPO_PUBLIC_APP_URL=http://192.168.0.197:3000
EXPO_PUBLIC_FORMBRICKS_ENVIRONMENT_ID=cm5p0cs7r000819182b32j0a1

View File

@@ -1,5 +1,5 @@
module.exports = {
extends: ["@formbricks/eslint-config/library.js"],
extends: ["@formbricks/eslint-config/react.js"],
parserOptions: {
project: "tsconfig.json",
tsconfigRootDir: __dirname,

35
apps/demo-react-native/.gitignore vendored Normal file
View File

@@ -0,0 +1,35 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
# Native
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo

View File

@@ -0,0 +1,35 @@
{
"expo": {
"android": {
"adaptiveIcon": {
"backgroundColor": "#ffffff",
"foregroundImage": "./assets/adaptive-icon.png"
}
},
"assetBundlePatterns": ["**/*"],
"icon": "./assets/icon.png",
"ios": {
"infoPlist": {
"NSCameraUsageDescription": "Take pictures for certain activities.",
"NSMicrophoneUsageDescription": "Need microphone access for recording videos.",
"NSPhotoLibraryUsageDescription": "Select pictures for certain activities."
},
"supportsTablet": true
},
"jsEngine": "hermes",
"name": "react-native-demo",
"newArchEnabled": true,
"orientation": "portrait",
"slug": "react-native-demo",
"splash": {
"backgroundColor": "#ffffff",
"image": "./assets/splash.png",
"resizeMode": "contain"
},
"userInterfaceStyle": "light",
"version": "1.0.0",
"web": {
"favicon": "./assets/favicon.png"
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View File

@@ -0,0 +1,6 @@
module.exports = function babel(api) {
api.cache(true);
return {
presets: ["babel-preset-expo"],
};
};

View File

@@ -0,0 +1,7 @@
import { registerRootComponent } from "expo";
import { LogBox } from "react-native";
import App from "./src/app";
registerRootComponent(App);
LogBox.ignoreAllLogs();

View File

@@ -0,0 +1,21 @@
// Learn more https://docs.expo.io/guides/customizing-metro
const path = require("node:path");
const { getDefaultConfig } = require("expo/metro-config");
// Find the workspace root, this can be replaced with `find-yarn-workspace-root`
const workspaceRoot = path.resolve(__dirname, "../..");
const projectRoot = __dirname;
const config = getDefaultConfig(projectRoot);
// 1. Watch all files within the monorepo
config.watchFolders = [workspaceRoot];
// 2. Let Metro know where to resolve packages, and in what order
config.resolver.nodeModulesPaths = [
path.resolve(projectRoot, "node_modules"),
path.resolve(workspaceRoot, "node_modules"),
];
// 3. Force Metro to resolve (sub)dependencies only from the `nodeModulesPaths`
config.resolver.disableHierarchicalLookup = true;
module.exports = config;

View File

@@ -0,0 +1,30 @@
{
"name": "@formbricks/demo-react-native",
"version": "1.0.0",
"main": "./index.js",
"scripts": {
"dev": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web",
"eject": "expo eject",
"clean": "rimraf .turbo node_modules .expo"
},
"dependencies": {
"@formbricks/js": "workspace:*",
"@formbricks/react-native": "workspace:*",
"@react-native-async-storage/async-storage": "2.1.0",
"expo": "52.0.28",
"expo-status-bar": "2.0.1",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-native": "0.76.6",
"react-native-webview": "13.12.5"
},
"devDependencies": {
"@babel/core": "7.26.0",
"@types/react": "18.3.18",
"typescript": "5.7.2"
},
"private": true
}

View File

@@ -0,0 +1,117 @@
import { StatusBar } from "expo-status-bar";
import React, { type JSX } from "react";
import { Button, LogBox, StyleSheet, Text, View } from "react-native";
import Formbricks, {
logout,
setAttribute,
setAttributes,
setLanguage,
setUserId,
track,
} from "@formbricks/react-native";
LogBox.ignoreAllLogs();
export default function App(): JSX.Element {
if (!process.env.EXPO_PUBLIC_FORMBRICKS_ENVIRONMENT_ID) {
throw new Error("EXPO_PUBLIC_FORMBRICKS_ENVIRONMENT_ID is required");
}
if (!process.env.EXPO_PUBLIC_APP_URL) {
throw new Error("EXPO_PUBLIC_APP_URL is required");
}
return (
<View style={styles.container}>
<Text>Formbricks React Native SDK Demo</Text>
<View
style={{
display: "flex",
flexDirection: "column",
gap: 10,
}}>
<Button
title="Trigger Code Action"
onPress={() => {
track("code").catch((error: unknown) => {
// eslint-disable-next-line no-console -- logging is allowed in demo apps
console.error("Error tracking event:", error);
});
}}
/>
<Button
title="Set User Id"
onPress={() => {
setUserId("random-user-id").catch((error: unknown) => {
// eslint-disable-next-line no-console -- logging is allowed in demo apps
console.error("Error setting user id:", error);
});
}}
/>
<Button
title="Set User Attributess (multiple)"
onPress={() => {
setAttributes({
testAttr: "attr-test",
testAttr2: "attr-test-2",
testAttr3: "attr-test-3",
testAttr4: "attr-test-4",
}).catch((error: unknown) => {
// eslint-disable-next-line no-console -- logging is allowed in demo apps
console.error("Error setting user attributes:", error);
});
}}
/>
<Button
title="Set User Attributes (single)"
onPress={() => {
setAttribute("testSingleAttr", "testSingleAttr").catch((error: unknown) => {
// eslint-disable-next-line no-console -- logging is allowed in demo apps
console.error("Error setting user attributes:", error);
});
}}
/>
<Button
title="Logout"
onPress={() => {
logout().catch((error: unknown) => {
// eslint-disable-next-line no-console -- logging is allowed in demo apps
console.error("Error logging out:", error);
});
}}
/>
<Button
title="Set Language (de)"
onPress={() => {
setLanguage("de").catch((error: unknown) => {
// eslint-disable-next-line no-console -- logging is allowed in demo apps
console.error("Error setting language:", error);
});
}}
/>
</View>
<StatusBar style="auto" />
<Formbricks
appUrl={process.env.EXPO_PUBLIC_APP_URL as string}
environmentId={process.env.EXPO_PUBLIC_FORMBRICKS_ENVIRONMENT_ID as string}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#fff",
alignItems: "center",
justifyContent: "center",
},
});

View File

@@ -0,0 +1,6 @@
{
"compilerOptions": {
"strict": true
},
"extends": "expo/tsconfig.base"
}

View File

@@ -27,7 +27,7 @@ const secondaryNavigation = [
export function Sidebar(): React.JSX.Element {
return (
<div className="flex grow flex-col overflow-y-auto bg-cyan-700 pt-5 pb-4">
<div className="flex flex-grow flex-col overflow-y-auto bg-cyan-700 pb-4 pt-5">
<nav
className="mt-5 flex flex-1 flex-col divide-y divide-cyan-800 overflow-y-auto"
aria-label="Sidebar">
@@ -38,10 +38,10 @@ export function Sidebar(): React.JSX.Element {
href={item.href}
className={classNames(
item.current ? "bg-cyan-800 text-white" : "text-cyan-100 hover:bg-cyan-600 hover:text-white",
"group flex items-center rounded-md px-2 py-2 text-sm leading-6 font-medium"
"group flex items-center rounded-md px-2 py-2 text-sm font-medium leading-6"
)}
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}
</a>
))}
@@ -52,7 +52,7 @@ export function Sidebar(): React.JSX.Element {
<a
key={item.name}
href={item.href}
className="group flex items-center rounded-md px-2 py-2 text-sm leading-6 font-medium text-cyan-100 hover:bg-cyan-600 hover:text-white">
className="group flex items-center rounded-md px-2 py-2 text-sm font-medium leading-6 text-cyan-100 hover:bg-cyan-600 hover:text-white">
<item.icon className="mr-4 h-6 w-6 text-cyan-200" aria-hidden="true" />
{item.name}
</a>

View File

@@ -1,23 +1,3 @@
@import 'tailwindcss';
@plugin '@tailwindcss/forms';
@custom-variant dark (&:is(.dark *));
/*
The default border color has changed to `currentcolor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
}
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@@ -1,6 +1,6 @@
{
"name": "@formbricks/demo",
"version": "0.0.0",
"version": "0.1.0",
"private": true,
"scripts": {
"clean": "rimraf .turbo node_modules .next",
@@ -12,14 +12,10 @@
},
"dependencies": {
"@formbricks/js": "workspace:*",
"@tailwindcss/forms": "0.5.9",
"@tailwindcss/postcss": "4.1.3",
"lucide-react": "0.486.0",
"next": "15.2.4",
"postcss": "8.5.3",
"react": "19.1.0",
"react-dom": "19.1.0",
"tailwindcss": "4.1.3"
"lucide-react": "0.468.0",
"next": "15.1.2",
"react": "19.0.0",
"react-dom": "19.0.0"
},
"devDependencies": {
"@formbricks/config-typescript": "workspace:*",

View File

@@ -9,12 +9,6 @@ declare const window: Window;
export default function AppPage(): React.JSX.Element {
const [darkMode, setDarkMode] = useState(false);
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(() => {
if (darkMode) {
@@ -39,9 +33,18 @@ export default function AppPage(): React.JSX.Element {
addFormbricksDebugParam();
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,
appUrl: process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST,
apiHost: process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST,
userId,
attributes: userInitAttributes,
});
}
@@ -96,10 +99,10 @@ export default function AppPage(): React.JSX.Element {
<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
</p>
<Image src={fbsetup} alt="fb setup" className="mt-4 rounded-xs" 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">
<p className="mb-1 sm:mr-2 sm:mb-0">You&apos;re connected with env:</p>
<p className="mb-1 sm:mb-0 sm:mr-2">You&apos;re connected with env:</p>
<div className="flex items-center">
<strong className="w-32 truncate sm:w-auto">
{process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID}
@@ -123,19 +126,19 @@ export default function AppPage(): React.JSX.Element {
<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">
<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>
<p className="text-slate-700 dark:text-slate-300">
On formbricks.setUserId() the user state will <strong>be fetched from Formbricks</strong> and
the local state gets <strong>updated with the user state</strong>.
On formbricks.reset() the local state will <strong>be deleted</strong> and formbricks gets{" "}
<strong>reinitialized</strong>.
</p>
<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"
type="button"
onClick={() => {
void formbricks.setUserId(userId);
void formbricks.reset();
}}>
Set user ID
Reset
</button>
<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
@@ -155,7 +158,7 @@ export default function AppPage(): React.JSX.Element {
<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-no-code-actions"
href="https://formbricks.com/docs/actions/no-code"
rel="noopener noreferrer"
className="underline dark:text-blue-500"
target="_blank">
@@ -163,7 +166,7 @@ export default function AppPage(): React.JSX.Element {
</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-no-code-actions"
href="https://formbricks.com/docs/actions/no-code"
rel="noopener noreferrer"
target="_blank"
className="underline dark:text-blue-500">
@@ -172,7 +175,6 @@ export default function AppPage(): React.JSX.Element {
</p>
</div>
</div>
<div className="p-6">
<div>
<button
@@ -188,7 +190,7 @@ export default function AppPage(): React.JSX.Element {
<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"
href="https://formbricks.com/docs/attributes/custom-attributes"
target="_blank"
rel="noopener noreferrer"
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">
This button sets the{" "}
<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"
rel="noopener noreferrer"
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">
This button sets the{" "}
<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"
rel="noopener noreferrer"
className="underline dark:text-blue-500">
@@ -248,110 +250,6 @@ export default function AppPage(): React.JSX.Element {
</p>
</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>

View File

@@ -1,5 +1,6 @@
module.exports = {
plugins: {
"@tailwindcss/postcss": {},
tailwindcss: {},
autoprefixer: {},
},
};

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")],
};

View File

@@ -11,30 +11,30 @@
"clean": "rimraf .turbo node_modules dist storybook-static"
},
"dependencies": {
"eslint-plugin-react-refresh": "0.4.19",
"react": "19.1.0",
"react-dom": "19.1.0"
"eslint-plugin-react-refresh": "0.4.16",
"react": "19.0.0",
"react-dom": "19.0.0"
},
"devDependencies": {
"@chromatic-com/storybook": "3.2.6",
"@chromatic-com/storybook": "3.2.2",
"@formbricks/config-typescript": "workspace:*",
"@storybook/addon-a11y": "8.6.12",
"@storybook/addon-essentials": "8.6.12",
"@storybook/addon-interactions": "8.6.12",
"@storybook/addon-links": "8.6.12",
"@storybook/addon-onboarding": "8.6.12",
"@storybook/blocks": "8.6.12",
"@storybook/react": "8.6.12",
"@storybook/react-vite": "8.6.12",
"@storybook/test": "8.6.12",
"@typescript-eslint/eslint-plugin": "8.29.1",
"@typescript-eslint/parser": "8.29.1",
"@storybook/addon-a11y": "8.4.7",
"@storybook/addon-essentials": "8.4.7",
"@storybook/addon-interactions": "8.4.7",
"@storybook/addon-links": "8.4.7",
"@storybook/addon-onboarding": "8.4.7",
"@storybook/blocks": "8.4.7",
"@storybook/react": "8.4.7",
"@storybook/react-vite": "8.4.7",
"@storybook/test": "8.4.7",
"@typescript-eslint/eslint-plugin": "8.18.0",
"@typescript-eslint/parser": "8.18.0",
"@vitejs/plugin-react": "4.3.4",
"esbuild": "0.25.2",
"eslint-plugin-storybook": "0.12.0",
"esbuild": "0.25.0",
"eslint-plugin-storybook": "0.11.1",
"prop-types": "15.8.1",
"storybook": "8.6.12",
"tsup": "8.4.0",
"vite": "6.2.5"
"storybook": "8.4.7",
"tsup": "8.3.5",
"vite": "6.0.9"
}
}

View File

@@ -1,20 +1,3 @@
module.exports = {
extends: ["@formbricks/eslint-config/legacy-next.js"],
ignorePatterns: ["**/package.json", "**/tsconfig.json"],
overrides: [
{
files: ["locales/*.json"],
plugins: ["i18n-json"],
rules: {
"i18n-json/identical-keys": [
"error",
{
filePath: require("path").join(__dirname, "locales", "en-US.json"),
checkExtraKeys: false,
checkMissingKeys: true,
},
],
},
},
],
};

2
apps/web/.gitignore vendored
View File

@@ -50,4 +50,4 @@ uploads/
.sentryclirc
# SAML Preloaded Connections
saml-connection/
saml-connection/

View File

@@ -1,4 +1,4 @@
FROM node:22-alpine3.21 AS base
FROM node:22-alpine3.20 AS base
#
## step 1: Prune monorepo
@@ -22,27 +22,19 @@ RUN npm install -g corepack@latest
RUN corepack enable
# 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
# This approach relies entirely on secrets passed from GitHub Actions
RUN echo '#!/bin/sh' > /tmp/read-secrets.sh && \
echo 'if [ -f "/run/secrets/database_url" ]; then' >> /tmp/read-secrets.sh && \
echo ' export DATABASE_URL=$(cat /run/secrets/database_url)' >> /tmp/read-secrets.sh && \
echo 'else' >> /tmp/read-secrets.sh && \
echo ' echo "DATABASE_URL secret not found. Build may fail if this is required."' >> /tmp/read-secrets.sh && \
echo 'fi' >> /tmp/read-secrets.sh && \
echo 'if [ -f "/run/secrets/encryption_key" ]; then' >> /tmp/read-secrets.sh && \
echo ' export ENCRYPTION_KEY=$(cat /run/secrets/encryption_key)' >> /tmp/read-secrets.sh && \
echo 'else' >> /tmp/read-secrets.sh && \
echo ' echo "ENCRYPTION_KEY secret not found. Build may fail if this is required."' >> /tmp/read-secrets.sh && \
echo 'fi' >> /tmp/read-secrets.sh && \
echo 'exec "$@"' >> /tmp/read-secrets.sh && \
chmod +x /tmp/read-secrets.sh
# Set hardcoded environment variables
ENV DATABASE_URL="postgresql://placeholder:for@build:5432/gets_overwritten_at_runtime?schema=public"
ENV NEXTAUTH_SECRET="placeholder_for_next_auth_of_64_chars_get_overwritten_at_runtime"
ENV ENCRYPTION_KEY="placeholder_for_build_key_of_64_chars_get_overwritten_at_runtime"
ENV CRON_SECRET="placeholder_for_cron_secret_of_64_chars_get_overwritten_at_runtime"
# Increase Node.js memory limit as a regular build argument
ARG NODE_OPTIONS="--max_old_space_size=4096"
ENV NODE_OPTIONS=${NODE_OPTIONS}
ARG NEXT_PUBLIC_SENTRY_DSN
ARG SENTRY_AUTH_TOKEN
# Increase Node.js memory limit
# ENV NODE_OPTIONS="--max_old_space_size=4096"
# Set the working directory
WORKDIR /app
@@ -61,11 +53,8 @@ RUN touch apps/web/.env
# Install the dependencies
RUN pnpm install
# Build the project using our secret reader script
# This mounts the secrets only during this build step without storing them in layers
RUN --mount=type=secret,id=database_url \
--mount=type=secret,id=encryption_key \
/tmp/read-secrets.sh pnpm build --filter=@formbricks/web...
# Build the project
RUN NODE_OPTIONS="--max_old_space_size=4096" pnpm build --filter=@formbricks/web...
# Extract Prisma version
RUN jq -r '.devDependencies.prisma' packages/database/package.json > /prisma_version.txt
@@ -81,71 +70,37 @@ RUN corepack enable
RUN apk add --no-cache curl \
&& apk add --no-cache supercronic \
# && addgroup --system --gid 1001 nodejs \
&& addgroup -S nextjs \
&& adduser -S -u 1001 -G nextjs nextjs
&& adduser --system --uid 1001 nextjs
WORKDIR /home/nextjs
# Ensure no write permissions are assigned to the copied resources
COPY --from=installer /app/apps/web/.next/standalone ./
RUN chown -R nextjs:nextjs ./ && chmod -R 755 ./
COPY --from=installer /app/apps/web/next.config.mjs .
RUN chmod 644 ./next.config.mjs
COPY --from=installer /app/apps/web/package.json .
RUN chmod 644 ./package.json
# Leverage output traces to reduce image size
COPY --from=installer /app/apps/web/.next/static ./apps/web/.next/static
RUN chown -R nextjs:nextjs ./apps/web/.next/static && chmod -R 755 ./apps/web/.next/static
COPY --from=installer --chown=nextjs:nextjs /app/apps/web/.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/public ./apps/web/public
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/schema.prisma ./packages/database/schema.prisma
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/package.json ./packages/database/package.json
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/migration ./packages/database/migration
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/src ./packages/database/src
COPY --from=installer /app/apps/web/public ./apps/web/public
RUN chown -R nextjs:nextjs ./apps/web/public && chmod -R 755 ./apps/web/public
COPY --from=installer /app/packages/database/schema.prisma ./packages/database/schema.prisma
RUN chown nextjs:nextjs ./packages/database/schema.prisma && chmod 644 ./packages/database/schema.prisma
COPY --from=installer /app/packages/database/package.json ./packages/database/package.json
RUN chown nextjs:nextjs ./packages/database/package.json && chmod 644 ./packages/database/package.json
COPY --from=installer /app/packages/database/migration ./packages/database/migration
RUN chown -R nextjs:nextjs ./packages/database/migration && chmod -R 755 ./packages/database/migration
COPY --from=installer /app/packages/database/src ./packages/database/src
RUN chown -R nextjs:nextjs ./packages/database/src && chmod -R 755 ./packages/database/src
COPY --from=installer /app/packages/database/node_modules ./packages/database/node_modules
RUN chown -R nextjs:nextjs ./packages/database/node_modules && chmod -R 755 ./packages/database/node_modules
COPY --from=installer /app/packages/logger/dist ./packages/database/node_modules/@formbricks/logger/dist
RUN chown -R nextjs:nextjs ./packages/database/node_modules/@formbricks/logger/dist && chmod -R 755 ./packages/database/node_modules/@formbricks/logger/dist
COPY --from=installer /app/node_modules/@prisma/client ./node_modules/@prisma/client
RUN chown -R nextjs:nextjs ./node_modules/@prisma/client && chmod -R 755 ./node_modules/@prisma/client
COPY --from=installer /app/node_modules/.prisma ./node_modules/.prisma
RUN chown -R nextjs:nextjs ./node_modules/.prisma && chmod -R 755 ./node_modules/.prisma
COPY --from=installer /prisma_version.txt .
RUN chown nextjs:nextjs ./prisma_version.txt && chmod 644 ./prisma_version.txt
# Copy 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 ./node_modules/.prisma
COPY --from=installer --chown=nextjs:nextjs /prisma_version.txt .
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
RUN chmod -R 755 ./node_modules/@paralleldrive/cuid2
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 chmod -R 755 ./node_modules/zod
RUN npm install -g tsx typescript prisma pino-pretty
RUN npm install -g tsx typescript prisma
EXPOSE 3000
ENV HOSTNAME "0.0.0.0"
ENV NODE_ENV="production"
# USER nextjs
# Prepare volume for uploads
@@ -156,12 +111,7 @@ VOLUME /home/nextjs/apps/web/uploads/
RUN mkdir -p /home/nextjs/apps/web/saml-connection
VOLUME /home/nextjs/apps/web/saml-connection
CMD if [ "${DOCKER_CRON_ENABLED:-1}" = "1" ]; then \
echo "Starting cron jobs..."; \
supercronic -quiet /app/docker/cronjobs & \
else \
echo "Docker cron jobs are disabled via DOCKER_CRON_ENABLED=0"; \
fi; \
CMD supercronic -quiet /app/docker/cronjobs & \
(cd packages/database && npm run db:migrate:deploy) && \
(cd packages/database && npm run db:create-saml-database:deploy) && \
exec node apps/web/server.js
exec node apps/web/server.js

View File

@@ -1,11 +1,11 @@
"use client";
import { cn } from "@/lib/cn";
import { Button } from "@/modules/ui/components/button";
import { useTranslate } from "@tolgee/react";
import { ArrowRight } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { cn } from "@formbricks/lib/cn";
import { TEnvironment } from "@formbricks/types/environment";
import { TProjectConfigChannel } from "@formbricks/types/project";
import { OnboardingSetupInstructions } from "./OnboardingSetupInstructions";

View File

@@ -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"
);
});
});

View File

@@ -34,9 +34,10 @@ export const OnboardingSetupInstructions = ({
const htmlSnippetForAppSurveys = `<!-- START Formbricks Surveys -->
<script type="text/javascript">
!function(){
var appUrl = "${webAppUrl}";
var apiHost = "${webAppUrl}";
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>
<!-- END Formbricks Surveys -->
`;
@@ -44,9 +45,9 @@ export const OnboardingSetupInstructions = ({
const htmlSnippetForWebsiteSurveys = `<!-- START Formbricks Surveys -->
<script type="text/javascript">
!function(){
var appUrl = "${webAppUrl}";
var apiHost = "${webAppUrl}";
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>
<!-- END Formbricks Surveys -->
`;
@@ -55,9 +56,10 @@ export const OnboardingSetupInstructions = ({
import formbricks from "@formbricks/js";
if (typeof window !== "undefined") {
formbricks.setup({
formbricks.init({
environmentId: "${environmentId}",
appUrl: "${webAppUrl}",
apiHost: "${webAppUrl}",
userId: "testUser",
});
}
@@ -73,9 +75,9 @@ export const OnboardingSetupInstructions = ({
import formbricks from "@formbricks/js";
if (typeof window !== "undefined") {
formbricks.setup({
formbricks.init({
environmentId: "${environmentId}",
appUrl: "${webAppUrl}",
apiHost: "${webAppUrl}",
});
}

View File

@@ -1,12 +1,12 @@
import { ConnectWithFormbricks } from "@/app/(app)/(onboarding)/environments/[environmentId]/connect/components/ConnectWithFormbricks";
import { WEBAPP_URL } from "@/lib/constants";
import { getEnvironment } from "@/lib/environment/service";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { Button } from "@/modules/ui/components/button";
import { Header } from "@/modules/ui/components/header";
import { getTranslate } from "@/tolgee/server";
import { XIcon } from "lucide-react";
import Link from "next/link";
import { WEBAPP_URL } from "@formbricks/lib/constants";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
interface ConnectPageProps {
params: Promise<{
@@ -44,7 +44,7 @@ const Page = async (props: ConnectPageProps) => {
channel={channel}
/>
<Button
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
asChild>
<Link href={`/environments/${environment.id}`}>

View File

@@ -1,7 +1,7 @@
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { AuthorizationError } from "@formbricks/types/errors";
const OnboardingLayout = async (props) => {

View File

@@ -1,4 +1,4 @@
import { replaceQuestionPresetPlaceholders } from "@/lib/utils/templates";
import { replaceQuestionPresetPlaceholders } from "@formbricks/lib/utils/templates";
import { TProject } from "@formbricks/types/project";
import { TXMTemplate } from "@formbricks/types/templates";

View File

@@ -1,15 +1,13 @@
import {
buildCTAQuestion,
buildNPSQuestion,
buildOpenTextQuestion,
buildRatingQuestion,
getDefaultEndingCard,
} from "@/app/lib/survey-builder";
import { getDefaultEndingCard } from "@/app/lib/templates";
import { createId } from "@paralleldrive/cuid2";
import { TFnType } from "@tolgee/react";
import { logger } from "@formbricks/logger";
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TXMTemplate } from "@formbricks/types/templates";
function logError(error: Error, context: string) {
console.error(`Error in ${context}:`, error);
}
export const getXMSurveyDefault = (t: TFnType): TXMTemplate => {
try {
return {
@@ -21,7 +19,7 @@ export const getXMSurveyDefault = (t: TFnType): TXMTemplate => {
},
};
} catch (error) {
logger.error(error, "Failed to create default XM survey template");
logError(error, "getXMSurveyDefault");
throw error; // Re-throw after logging
}
};
@@ -31,26 +29,35 @@ const npsSurvey = (t: TFnType): TXMTemplate => {
...getXMSurveyDefault(t),
name: t("templates.nps_survey_name"),
questions: [
buildNPSQuestion({
headline: t("templates.nps_survey_question_1_headline"),
{
id: createId(),
type: TSurveyQuestionTypeEnum.NPS,
headline: { default: t("templates.nps_survey_question_1_headline") },
required: true,
lowerLabel: t("templates.nps_survey_question_1_lower_label"),
upperLabel: t("templates.nps_survey_question_1_upper_label"),
lowerLabel: { default: t("templates.nps_survey_question_1_lower_label") },
upperLabel: { default: t("templates.nps_survey_question_1_upper_label") },
isColorCodingEnabled: true,
t,
}),
buildOpenTextQuestion({
headline: t("templates.nps_survey_question_2_headline"),
},
{
id: createId(),
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: t("templates.nps_survey_question_2_headline") },
required: false,
inputType: "text",
t,
}),
buildOpenTextQuestion({
headline: t("templates.nps_survey_question_3_headline"),
charLimit: {
enabled: false,
},
},
{
id: createId(),
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: t("templates.nps_survey_question_3_headline") },
required: false,
inputType: "text",
t,
}),
charLimit: {
enabled: false,
},
},
],
};
};
@@ -63,8 +70,9 @@ const starRatingSurvey = (t: TFnType): TXMTemplate => {
...defaultSurvey,
name: t("templates.star_rating_survey_name"),
questions: [
buildRatingQuestion({
{
id: reusableQuestionIds[0],
type: TSurveyQuestionTypeEnum.Rating,
logic: [
{
id: createId(),
@@ -97,15 +105,16 @@ const starRatingSurvey = (t: TFnType): TXMTemplate => {
],
range: 5,
scale: "number",
headline: t("templates.star_rating_survey_question_1_headline"),
headline: { default: t("templates.star_rating_survey_question_1_headline") },
required: true,
lowerLabel: t("templates.star_rating_survey_question_1_lower_label"),
upperLabel: t("templates.star_rating_survey_question_1_upper_label"),
t,
}),
buildCTAQuestion({
lowerLabel: { default: t("templates.star_rating_survey_question_1_lower_label") },
upperLabel: { default: t("templates.star_rating_survey_question_1_upper_label") },
isColorCodingEnabled: false,
},
{
id: reusableQuestionIds[1],
html: t("templates.star_rating_survey_question_2_html"),
html: { default: t("templates.star_rating_survey_question_2_html") },
type: TSurveyQuestionTypeEnum.CTA,
logic: [
{
id: createId(),
@@ -132,23 +141,25 @@ const starRatingSurvey = (t: TFnType): TXMTemplate => {
],
},
],
headline: t("templates.star_rating_survey_question_2_headline"),
headline: { default: t("templates.star_rating_survey_question_2_headline") },
required: true,
buttonUrl: "https://formbricks.com/github",
buttonLabel: t("templates.star_rating_survey_question_2_button_label"),
buttonLabel: { default: t("templates.star_rating_survey_question_2_button_label") },
buttonExternal: true,
t,
}),
buildOpenTextQuestion({
},
{
id: reusableQuestionIds[2],
headline: t("templates.star_rating_survey_question_3_headline"),
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: t("templates.star_rating_survey_question_3_headline") },
required: true,
subheader: t("templates.star_rating_survey_question_3_subheader"),
buttonLabel: t("templates.star_rating_survey_question_3_button_label"),
placeholder: t("templates.star_rating_survey_question_3_placeholder"),
subheader: { default: t("templates.star_rating_survey_question_3_subheader") },
buttonLabel: { default: t("templates.star_rating_survey_question_3_button_label") },
placeholder: { default: t("templates.star_rating_survey_question_3_placeholder") },
inputType: "text",
t,
}),
charLimit: {
enabled: false,
},
},
],
};
};
@@ -161,8 +172,9 @@ const csatSurvey = (t: TFnType): TXMTemplate => {
...defaultSurvey,
name: t("templates.csat_survey_name"),
questions: [
buildRatingQuestion({
{
id: reusableQuestionIds[0],
type: TSurveyQuestionTypeEnum.Rating,
logic: [
{
id: createId(),
@@ -195,14 +207,15 @@ const csatSurvey = (t: TFnType): TXMTemplate => {
],
range: 5,
scale: "smiley",
headline: t("templates.csat_survey_question_1_headline"),
headline: { default: t("templates.csat_survey_question_1_headline") },
required: true,
lowerLabel: t("templates.csat_survey_question_1_lower_label"),
upperLabel: t("templates.csat_survey_question_1_upper_label"),
t,
}),
buildOpenTextQuestion({
lowerLabel: { default: t("templates.csat_survey_question_1_lower_label") },
upperLabel: { default: t("templates.csat_survey_question_1_upper_label") },
isColorCodingEnabled: false,
},
{
id: reusableQuestionIds[1],
type: TSurveyQuestionTypeEnum.OpenText,
logic: [
{
id: createId(),
@@ -229,20 +242,25 @@ const csatSurvey = (t: TFnType): TXMTemplate => {
],
},
],
headline: t("templates.csat_survey_question_2_headline"),
headline: { default: t("templates.csat_survey_question_2_headline") },
required: false,
placeholder: t("templates.csat_survey_question_2_placeholder"),
placeholder: { default: t("templates.csat_survey_question_2_placeholder") },
inputType: "text",
t,
}),
buildOpenTextQuestion({
charLimit: {
enabled: false,
},
},
{
id: reusableQuestionIds[2],
headline: t("templates.csat_survey_question_3_headline"),
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: t("templates.csat_survey_question_3_headline") },
required: false,
placeholder: t("templates.csat_survey_question_3_placeholder"),
placeholder: { default: t("templates.csat_survey_question_3_placeholder") },
inputType: "text",
t,
}),
charLimit: {
enabled: false,
},
},
],
};
};
@@ -252,22 +270,28 @@ const cessSurvey = (t: TFnType): TXMTemplate => {
...getXMSurveyDefault(t),
name: t("templates.cess_survey_name"),
questions: [
buildRatingQuestion({
{
id: createId(),
type: TSurveyQuestionTypeEnum.Rating,
range: 5,
scale: "number",
headline: t("templates.cess_survey_question_1_headline"),
headline: { default: t("templates.cess_survey_question_1_headline") },
required: true,
lowerLabel: t("templates.cess_survey_question_1_lower_label"),
upperLabel: t("templates.cess_survey_question_1_upper_label"),
t,
}),
buildOpenTextQuestion({
headline: t("templates.cess_survey_question_2_headline"),
lowerLabel: { default: t("templates.cess_survey_question_1_lower_label") },
upperLabel: { default: t("templates.cess_survey_question_1_upper_label") },
isColorCodingEnabled: false,
},
{
id: createId(),
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: t("templates.cess_survey_question_2_headline") },
required: true,
placeholder: t("templates.cess_survey_question_2_placeholder"),
placeholder: { default: t("templates.cess_survey_question_2_placeholder") },
inputType: "text",
t,
}),
charLimit: {
enabled: false,
},
},
],
};
};
@@ -280,8 +304,9 @@ const smileysRatingSurvey = (t: TFnType): TXMTemplate => {
...defaultSurvey,
name: t("templates.smileys_survey_name"),
questions: [
buildRatingQuestion({
{
id: reusableQuestionIds[0],
type: TSurveyQuestionTypeEnum.Rating,
logic: [
{
id: createId(),
@@ -314,15 +339,16 @@ const smileysRatingSurvey = (t: TFnType): TXMTemplate => {
],
range: 5,
scale: "smiley",
headline: t("templates.smileys_survey_question_1_headline"),
headline: { default: t("templates.smileys_survey_question_1_headline") },
required: true,
lowerLabel: t("templates.smileys_survey_question_1_lower_label"),
upperLabel: t("templates.smileys_survey_question_1_upper_label"),
t,
}),
buildCTAQuestion({
lowerLabel: { default: t("templates.smileys_survey_question_1_lower_label") },
upperLabel: { default: t("templates.smileys_survey_question_1_upper_label") },
isColorCodingEnabled: false,
},
{
id: reusableQuestionIds[1],
html: t("templates.smileys_survey_question_2_html"),
html: { default: t("templates.smileys_survey_question_2_html") },
type: TSurveyQuestionTypeEnum.CTA,
logic: [
{
id: createId(),
@@ -349,23 +375,25 @@ const smileysRatingSurvey = (t: TFnType): TXMTemplate => {
],
},
],
headline: t("templates.smileys_survey_question_2_headline"),
headline: { default: t("templates.smileys_survey_question_2_headline") },
required: true,
buttonUrl: "https://formbricks.com/github",
buttonLabel: t("templates.smileys_survey_question_2_button_label"),
buttonLabel: { default: t("templates.smileys_survey_question_2_button_label") },
buttonExternal: true,
t,
}),
buildOpenTextQuestion({
},
{
id: reusableQuestionIds[2],
headline: t("templates.smileys_survey_question_3_headline"),
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: t("templates.smileys_survey_question_3_headline") },
required: true,
subheader: t("templates.smileys_survey_question_3_subheader"),
buttonLabel: t("templates.smileys_survey_question_3_button_label"),
placeholder: t("templates.smileys_survey_question_3_placeholder"),
subheader: { default: t("templates.smileys_survey_question_3_subheader") },
buttonLabel: { default: t("templates.smileys_survey_question_3_button_label") },
placeholder: { default: t("templates.smileys_survey_question_3_placeholder") },
inputType: "text",
t,
}),
charLimit: {
enabled: false,
},
},
],
};
};
@@ -375,26 +403,37 @@ const enpsSurvey = (t: TFnType): TXMTemplate => {
...getXMSurveyDefault(t),
name: t("templates.enps_survey_name"),
questions: [
buildNPSQuestion({
headline: t("templates.enps_survey_question_1_headline"),
{
id: createId(),
type: TSurveyQuestionTypeEnum.NPS,
headline: {
default: t("templates.enps_survey_question_1_headline"),
},
required: false,
lowerLabel: t("templates.enps_survey_question_1_lower_label"),
upperLabel: t("templates.enps_survey_question_1_upper_label"),
lowerLabel: { default: t("templates.enps_survey_question_1_lower_label") },
upperLabel: { default: t("templates.enps_survey_question_1_upper_label") },
isColorCodingEnabled: true,
t,
}),
buildOpenTextQuestion({
headline: t("templates.enps_survey_question_2_headline"),
},
{
id: createId(),
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: t("templates.enps_survey_question_2_headline") },
required: false,
inputType: "text",
t,
}),
buildOpenTextQuestion({
headline: t("templates.enps_survey_question_3_headline"),
charLimit: {
enabled: false,
},
},
{
id: createId(),
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: t("templates.enps_survey_question_3_headline") },
required: false,
inputType: "text",
t,
}),
charLimit: {
enabled: false,
},
},
],
};
};
@@ -410,7 +449,7 @@ export const getXMTemplates = (t: TFnType): TXMTemplate[] => {
enpsSurvey(t),
];
} 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
}
};

View File

@@ -1,7 +1,4 @@
import { XMTemplateList } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/components/XMTemplateList";
import { getEnvironment } from "@/lib/environment/service";
import { getProjectByEnvironmentId, getUserProjects } from "@/lib/project/service";
import { getUser } from "@/lib/user/service";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { Button } from "@/modules/ui/components/button";
@@ -10,6 +7,9 @@ import { getTranslate } from "@/tolgee/server";
import { XIcon } from "lucide-react";
import { getServerSession } from "next-auth";
import Link from "next/link";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getProjectByEnvironmentId, getUserProjects } from "@formbricks/lib/project/service";
import { getUser } from "@formbricks/lib/user/service";
interface XMTemplatePageProps {
params: Promise<{
@@ -49,7 +49,7 @@ const Page = async (props: XMTemplatePageProps) => {
<XMTemplateList project={project} user={user} environmentId={environment.id} />
{projects.length >= 2 && (
<Button
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
asChild>
<Link href={`/environments/${environment.id}/surveys`}>

View File

@@ -1,12 +1,12 @@
"use server";
import { TOrganizationTeam } from "@/app/(app)/(onboarding)/types/onboarding";
import { cache } from "@/lib/cache";
import { teamCache } from "@/lib/cache/team";
import { validateInputs } from "@/lib/utils/validate";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { validateInputs } from "@formbricks/lib/utils/validate";
import { ZId } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";

View File

@@ -2,8 +2,6 @@
import { formbricksLogout } from "@/app/lib/formbricks";
import FBLogo from "@/images/formbricks-wordmark.svg";
import { cn } from "@/lib/cn";
import { capitalizeFirstLetter } from "@/lib/utils/strings";
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
import { ProfileAvatar } from "@/modules/ui/components/avatars";
import {
@@ -26,6 +24,8 @@ import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import { cn } from "@formbricks/lib/cn";
import { capitalizeFirstLetter } from "@formbricks/lib/utils/strings";
import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
@@ -112,7 +112,7 @@ export const LandingSidebar = ({
{/* Dropdown Items */}
{dropdownNavigation.map((link) => (
<Link id={link.href} href={link.href} target={link.target} className="flex w-full items-center">
<Link href={link.href} target={link.target} className="flex w-full items-center">
<DropdownMenuItem>
<link.icon className="mr-2 h-4 w-4" strokeWidth={1.5} />
{link.label}

View File

@@ -1,9 +1,9 @@
import { getEnvironments } from "@/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getUserProjects } from "@/lib/project/service";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getServerSession } from "next-auth";
import { notFound, redirect } from "next/navigation";
import { getEnvironments } from "@formbricks/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getUserProjects } from "@formbricks/lib/project/service";
const LandingLayout = async (props) => {
const params = await props.params;

View File

@@ -1,25 +1,27 @@
import { LandingSidebar } from "@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar";
import { getOrganizationsByUserId } from "@/lib/organization/service";
import { getUser } from "@/lib/user/service";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { Header } from "@/modules/ui/components/header";
import { getTranslate } from "@/tolgee/server";
import { getServerSession } from "next-auth";
import { notFound, redirect } from "next/navigation";
import { getOrganization, getOrganizationsByUserId } from "@formbricks/lib/organization/service";
import { getUser } from "@formbricks/lib/user/service";
const Page = async (props) => {
const params = await props.params;
const t = await getTranslate();
const { session, organization } = await getOrganizationAuth(params.organizationId);
if (!session?.user) {
const session = await getServerSession(authOptions);
if (!session || !session.user) {
return redirect(`/auth/login`);
}
const user = await getUser(session.user.id);
if (!user) return notFound();
const organization = await getOrganization(params.organizationId);
if (!organization) return notFound();
const organizations = await getOrganizationsByUserId(session.user.id);
const { features } = await getEnterpriseLicense();

View File

@@ -1,156 +0,0 @@
import { canUserAccessOrganization } from "@/lib/organization/auth";
import { getOrganization } from "@/lib/organization/service";
import { getUser } from "@/lib/user/service";
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, test, vi } from "vitest";
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("@/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("@/lib/organization/auth", () => ({
canUserAccessOrganization: vi.fn(),
}));
vi.mock("@/lib/organization/service", () => ({
getOrganization: vi.fn(),
}));
vi.mock("@/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();
});
test("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();
});
test("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");
});
test("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");
});
test("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");
});
test("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();
});
});

View File

@@ -1,13 +1,12 @@
import { PosthogIdentify } from "@/app/(app)/environments/[environmentId]/components/PosthogIdentify";
import { IS_POSTHOG_CONFIGURED } from "@/lib/constants";
import { canUserAccessOrganization } from "@/lib/organization/auth";
import { getOrganization } from "@/lib/organization/service";
import { getUser } from "@/lib/user/service";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { ToasterClient } from "@/modules/ui/components/toaster-client";
import { getTranslate } from "@/tolgee/server";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { canUserAccessOrganization } from "@formbricks/lib/organization/auth";
import { getOrganization } from "@formbricks/lib/organization/service";
import { getUser } from "@formbricks/lib/user/service";
import { AuthorizationError } from "@formbricks/types/errors";
const ProjectOnboardingLayout = async (props) => {
@@ -17,8 +16,7 @@ const ProjectOnboardingLayout = async (props) => {
const t = await getTranslate();
const session = await getServerSession(authOptions);
if (!session?.user) {
if (!session || !session.user) {
return redirect(`/auth/login`);
}
@@ -28,9 +26,8 @@ const ProjectOnboardingLayout = async (props) => {
}
const isAuthorized = await canUserAccessOrganization(session.user.id, params.organizationId);
if (!isAuthorized) {
throw new AuthorizationError(t("common.not_authorized"));
throw AuthorizationError;
}
const organization = await getOrganization(params.organizationId);
@@ -46,7 +43,6 @@ const ProjectOnboardingLayout = async (props) => {
organizationId={organization.id}
organizationName={organization.name}
organizationBilling={organization.billing}
isPosthogEnabled={IS_POSTHOG_CONFIGURED}
/>
<ToasterClient />
{children}

View File

@@ -1,12 +1,13 @@
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
import { getUserProjects } from "@/lib/project/service";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { Button } from "@/modules/ui/components/button";
import { Header } from "@/modules/ui/components/header";
import { getTranslate } from "@/tolgee/server";
import { PictureInPicture2Icon, SendIcon, XIcon } from "lucide-react";
import { getServerSession } from "next-auth";
import Link from "next/link";
import { redirect } from "next/navigation";
import { getUserProjects } from "@formbricks/lib/project/service";
interface ChannelPageProps {
params: Promise<{
@@ -16,10 +17,8 @@ interface ChannelPageProps {
const Page = async (props: ChannelPageProps) => {
const params = await props.params;
const { session } = await getOrganizationAuth(params.organizationId);
if (!session?.user) {
const session = await getServerSession(authOptions);
if (!session || !session.user) {
return redirect(`/auth/login`);
}
@@ -50,7 +49,7 @@ const Page = async (props: ChannelPageProps) => {
<OnboardingOptionsContainer options={channelOptions} />
{projects.length >= 1 && (
<Button
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
asChild>
<Link href={"/"}>

View File

@@ -1,12 +1,12 @@
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils";
import { getOrganization } from "@/lib/organization/service";
import { getOrganizationProjectsCount } from "@/lib/project/service";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
import { getTranslate } from "@/tolgee/server";
import { getServerSession } from "next-auth";
import { notFound, redirect } from "next/navigation";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getOrganization } from "@formbricks/lib/organization/service";
import { getOrganizationProjectsCount } from "@formbricks/lib/project/service";
const OnboardingLayout = async (props) => {
const params = await props.params;

View File

@@ -1,12 +1,13 @@
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
import { getUserProjects } from "@/lib/project/service";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { Button } from "@/modules/ui/components/button";
import { Header } from "@/modules/ui/components/header";
import { getTranslate } from "@/tolgee/server";
import { HeartIcon, ListTodoIcon, XIcon } from "lucide-react";
import { getServerSession } from "next-auth";
import Link from "next/link";
import { redirect } from "next/navigation";
import { getUserProjects } from "@formbricks/lib/project/service";
interface ModePageProps {
params: Promise<{
@@ -16,10 +17,8 @@ interface ModePageProps {
const Page = async (props: ModePageProps) => {
const params = await props.params;
const { session } = await getOrganizationAuth(params.organizationId);
if (!session?.user) {
const session = await getServerSession(authOptions);
if (!session || !session.user) {
return redirect(`/auth/login`);
}
@@ -47,7 +46,7 @@ const Page = async (props: ModePageProps) => {
<OnboardingOptionsContainer options={channelOptions} />
{projects.length >= 1 && (
<Button
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
asChild>
<Link href={"/"}>

View File

@@ -2,7 +2,6 @@
import { createProjectAction } from "@/app/(app)/environments/[environmentId]/actions";
import { previewSurvey } from "@/app/lib/templates";
import { FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@/lib/localStorage";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { TOrganizationTeam } from "@/modules/ee/teams/project-teams/types/team";
import { CreateTeamModal } from "@/modules/ee/teams/team-list/components/create-team-modal";
@@ -27,6 +26,7 @@ import { useRouter } from "next/navigation";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "react-hot-toast";
import { FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@formbricks/lib/localStorage";
import {
TProjectConfigChannel,
TProjectConfigIndustry,
@@ -225,13 +225,12 @@ export const ProjectSettings = ({
alt="Logo"
width={256}
height={56}
className="absolute top-2 left-2 -mb-6 h-20 w-auto max-w-64 rounded-lg border object-contain p-1"
className="absolute left-2 top-2 -mb-6 h-20 w-auto max-w-64 rounded-lg border object-contain p-1"
/>
)}
<p className="text-sm text-slate-400">{t("common.preview")}</p>
<div className="z-0 h-3/4 w-3/4">
<SurveyInline
isPreviewMode={true}
survey={previewSurvey(projectName || "my Product", t)}
styling={{ brandColor: { light: brandColor } }}
isBrandingEnabled={false}

View File

@@ -1,15 +1,17 @@
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings";
import { DEFAULT_BRAND_COLOR } from "@/lib/constants";
import { getUserProjects } from "@/lib/project/service";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getRoleManagementPermission } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { Button } from "@/modules/ui/components/button";
import { Header } from "@/modules/ui/components/header";
import { getTranslate } from "@/tolgee/server";
import { XIcon } from "lucide-react";
import { getServerSession } from "next-auth";
import Link from "next/link";
import { redirect } from "next/navigation";
import { DEFAULT_BRAND_COLOR } from "@formbricks/lib/constants";
import { getOrganization } from "@formbricks/lib/organization/service";
import { getUserProjects } from "@formbricks/lib/project/service";
import { TProjectConfigChannel, TProjectConfigIndustry, TProjectMode } from "@formbricks/types/project";
interface ProjectSettingsPageProps {
@@ -27,20 +29,25 @@ const Page = async (props: ProjectSettingsPageProps) => {
const searchParams = await props.searchParams;
const params = await props.params;
const t = await getTranslate();
const session = await getServerSession(authOptions);
const { session, organization } = await getOrganizationAuth(params.organizationId);
if (!session?.user) {
if (!session || !session.user) {
return redirect(`/auth/login`);
}
const channel = searchParams.channel ?? null;
const industry = searchParams.industry ?? null;
const mode = searchParams.mode ?? "surveys";
const channel = searchParams.channel || null;
const industry = searchParams.industry || null;
const mode = searchParams.mode || "surveys";
const projects = await getUserProjects(session.user.id, 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);
if (!organizationTeams) {
@@ -65,7 +72,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
/>
{projects.length >= 1 && (
<Button
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
asChild>
<Link href={"/"}>

View File

@@ -1,120 +0,0 @@
import { getEnvironment } from "@/lib/environment/service";
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
import { cleanup, render, screen } from "@testing-library/react";
import { Session } from "next-auth";
import { redirect } from "next/navigation";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
import SurveyEditorEnvironmentLayout from "./layout";
// Mock sub-components to render identifiable elements
vi.mock("@/modules/ui/components/environmentId-base-layout", () => ({
EnvironmentIdBaseLayout: ({ children, environmentId }: any) => (
<div data-testid="EnvironmentIdBaseLayout">
{environmentId}
{children}
</div>
),
}));
vi.mock("@/modules/ui/components/dev-environment-banner", () => ({
DevEnvironmentBanner: ({ environment }: any) => (
<div data-testid="DevEnvironmentBanner">{environment.id}</div>
),
}));
// Mocks for dependencies
vi.mock("@/modules/environments/lib/utils", () => ({
environmentIdLayoutChecks: vi.fn(),
}));
vi.mock("@/lib/environment/service", () => ({
getEnvironment: vi.fn(),
}));
vi.mock("next/navigation", () => ({
redirect: vi.fn(),
}));
describe("SurveyEditorEnvironmentLayout", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders successfully when environment is found", async () => {
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
t: ((key: string) => key) as any, // Mock translation function, we don't need to implement it for the test
session: { user: { id: "user1" } } as Session,
user: { id: "user1", email: "user1@example.com" } as TUser,
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
});
vi.mocked(getEnvironment).mockResolvedValueOnce({ id: "env1" } as TEnvironment);
const result = await SurveyEditorEnvironmentLayout({
params: Promise.resolve({ environmentId: "env1" }),
children: <div data-testid="child">Survey Editor Content</div>,
});
render(result);
expect(screen.getByTestId("EnvironmentIdBaseLayout")).toHaveTextContent("env1");
expect(screen.getByTestId("DevEnvironmentBanner")).toHaveTextContent("env1");
expect(screen.getByTestId("child")).toHaveTextContent("Survey Editor Content");
});
test("throws an error when environment is not found", async () => {
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
t: ((key: string) => key) as any,
session: { user: { id: "user1" } } as Session,
user: { id: "user1", email: "user1@example.com" } as TUser,
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
});
vi.mocked(getEnvironment).mockResolvedValueOnce(null);
await expect(
SurveyEditorEnvironmentLayout({
params: Promise.resolve({ environmentId: "env1" }),
children: <div>Content</div>,
})
).rejects.toThrow("common.environment_not_found");
});
test("calls redirect when session is null", async () => {
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
t: ((key: string) => key) as any,
session: undefined as unknown as Session,
user: undefined as unknown as TUser,
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
});
vi.mocked(redirect).mockImplementationOnce(() => {
throw new Error("Redirect called");
});
await expect(
SurveyEditorEnvironmentLayout({
params: Promise.resolve({ environmentId: "env1" }),
children: <div>Content</div>,
})
).rejects.toThrow("Redirect called");
});
test("throws error if user is null", async () => {
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
t: ((key: string) => key) as any,
session: { user: { id: "user1" } } as Session,
user: undefined as unknown as TUser,
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
});
vi.mocked(redirect).mockImplementationOnce(() => {
throw new Error("Redirect called");
});
await expect(
SurveyEditorEnvironmentLayout({
params: Promise.resolve({ environmentId: "env1" }),
children: <div>Content</div>,
})
).rejects.toThrow("common.user_not_found");
});
});

View File

@@ -1,24 +1,44 @@
import { getEnvironment } from "@/lib/environment/service";
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
import { FormbricksClient } from "@/app/(app)/components/FormbricksClient";
import { PosthogIdentify } from "@/app/(app)/environments/[environmentId]/components/PosthogIdentify";
import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { DevEnvironmentBanner } from "@/modules/ui/components/dev-environment-banner";
import { EnvironmentIdBaseLayout } from "@/modules/ui/components/environmentId-base-layout";
import { ToasterClient } from "@/modules/ui/components/toaster-client";
import { getTranslate } from "@/tolgee/server";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { 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 { AuthorizationError } from "@formbricks/types/errors";
const SurveyEditorEnvironmentLayout = async (props) => {
const params = await props.params;
const { children } = props;
const { t, session, user, organization } = await environmentIdLayoutChecks(params.environmentId);
if (!session) {
const t = await getTranslate();
const session = await getServerSession(authOptions);
if (!session || !session.user) {
return redirect(`/auth/login`);
}
const user = await getUser(session.user.id);
if (!user) {
throw new Error(t("common.user_not_found"));
}
const hasAccess = await hasUserEnvironmentAccess(session.user.id, params.environmentId);
if (!hasAccess) {
throw new AuthorizationError(t("common.not_authorized"));
}
const organization = await getOrganizationByEnvironmentId(params.environmentId);
if (!organization) {
throw new Error(t("common.organization_not_found"));
}
const environment = await getEnvironment(params.environmentId);
if (!environment) {
@@ -26,16 +46,24 @@ const SurveyEditorEnvironmentLayout = async (props) => {
}
return (
<EnvironmentIdBaseLayout
environmentId={params.environmentId}
session={session}
user={user}
organization={organization}>
<div className="flex h-screen flex-col">
<DevEnvironmentBanner environment={environment} />
<div className="h-full overflow-y-auto bg-slate-50">{children}</div>
</div>
</EnvironmentIdBaseLayout>
<>
<ResponseFilterProvider>
<PosthogIdentify
session={session}
user={user}
environmentId={params.environmentId}
organizationId={organization.id}
organizationName={organization.name}
organizationBilling={organization.billing}
/>
<FormbricksClient userId={user.id} email={user.email} />
<ToasterClient />
<div className="flex h-screen flex-col">
<DevEnvironmentBanner environment={environment} />
<div className="h-full overflow-y-auto bg-slate-50">{children}</div>
</div>
</ResponseFilterProvider>
</>
);
};

View File

@@ -1,81 +0,0 @@
import { render } from "@testing-library/react";
import { 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 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", () => {
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"
formbricksEnvironmentId="env-test"
formbricksApiHost="https://api.test.com"
formbricksEnabled={true}
/>
);
// 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"
formbricksEnvironmentId="env-test"
formbricksApiHost="https://api.test.com"
formbricksEnabled={true}
/>
);
// Since userId is falsy, the first effect should not call setup or assign user details.
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();
});
});

View File

@@ -1,44 +1,32 @@
"use client";
import { formbricksEnabled } from "@/app/lib/formbricks";
import { usePathname, useSearchParams } from "next/navigation";
import { useEffect } from "react";
import formbricks from "@formbricks/js";
import { env } from "@formbricks/lib/env";
interface FormbricksClientProps {
userId: string;
email: string;
formbricksEnvironmentId?: string;
formbricksApiHost?: string;
formbricksEnabled?: boolean;
}
export const FormbricksClient = ({
userId,
email,
formbricksEnvironmentId,
formbricksApiHost,
formbricksEnabled,
}: FormbricksClientProps) => {
export const FormbricksClient = ({ userId, email }: { userId: string; email: string }) => {
const pathname = usePathname();
const searchParams = useSearchParams();
useEffect(() => {
if (formbricksEnabled && userId) {
formbricks.setup({
environmentId: formbricksEnvironmentId ?? "",
appUrl: formbricksApiHost ?? "",
formbricks.init({
environmentId: env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID || "",
apiHost: env.NEXT_PUBLIC_FORMBRICKS_API_HOST || "",
userId,
});
formbricks.setUserId(userId);
formbricks.setEmail(email);
}
}, [userId, email, formbricksEnvironmentId, formbricksApiHost, formbricksEnabled]);
}, [userId, email]);
useEffect(() => {
if (formbricksEnabled) {
formbricks.registerRouteChange();
}
}, [pathname, searchParams, formbricksEnabled]);
}, [pathname, searchParams]);
return null;
};

View File

@@ -1,5 +1,5 @@
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { cn } from "@/lib/cn";
import { cn } from "@formbricks/lib/cn";
export const LoadingCard = ({
title,

View File

@@ -1,8 +1,5 @@
"use server";
import { getOrganization } from "@/lib/organization/service";
import { getOrganizationProjectsCount } from "@/lib/project/service";
import { updateUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import {
@@ -11,6 +8,9 @@ import {
} from "@/modules/ee/license-check/lib/utils";
import { createProject } from "@/modules/projects/settings/lib/project";
import { z } from "zod";
import { getOrganization } from "@formbricks/lib/organization/service";
import { getOrganizationProjectsCount } from "@formbricks/lib/project/service";
import { updateUser } from "@formbricks/lib/user/service";
import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError } from "@formbricks/types/errors";
import { ZProjectUpdateInput } from "@formbricks/types/project";

View File

@@ -1,12 +1,12 @@
"use server";
import { deleteActionClass, getActionClass, updateActionClass } from "@/lib/actionClass/service";
import { cache } from "@/lib/cache";
import { getSurveysByActionClassId } from "@/lib/survey/service";
import { actionClient, authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import { getOrganizationIdFromActionClassId, getProjectIdFromActionClassId } from "@/lib/utils/helper";
import { z } from "zod";
import { deleteActionClass, getActionClass, updateActionClass } from "@formbricks/lib/actionClass/service";
import { cache } from "@formbricks/lib/cache";
import { getSurveysByActionClassId } from "@formbricks/lib/survey/service";
import { ZActionClassInput } from "@formbricks/types/action-classes";
import { ZId } from "@formbricks/types/common";
import { ResourceNotFoundError } from "@formbricks/types/errors";

View File

@@ -1,9 +1,7 @@
"use client";
import { ACTION_TYPE_ICON_LOOKUP } from "@/app/(app)/environments/[environmentId]/actions/utils";
import { convertDateTimeStringShort } from "@/lib/time";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { capitalizeFirstLetter } from "@/lib/utils/strings";
import { createActionClassAction } from "@/modules/survey/editor/actions";
import { Button } from "@/modules/ui/components/button";
import { ErrorComponent } from "@/modules/ui/components/error-component";
@@ -12,6 +10,8 @@ import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
import { useTranslate } from "@tolgee/react";
import { useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { convertDateTimeStringShort } from "@formbricks/lib/time";
import { capitalizeFirstLetter } from "@formbricks/lib/utils/strings";
import { TActionClass, TActionClassInput, TActionClassInputCode } from "@formbricks/types/action-classes";
import { TEnvironment } from "@formbricks/types/environment";
import { getActiveInactiveSurveysAction } from "../actions";

View File

@@ -1,5 +1,5 @@
import { ACTION_TYPE_ICON_LOOKUP } from "@/app/(app)/environments/[environmentId]/actions/utils";
import { timeSince } from "@/lib/time";
import { timeSince } from "@formbricks/lib/time";
import { TActionClass } from "@formbricks/types/action-classes";
import { TUserLocale } from "@formbricks/types/user";
@@ -23,7 +23,7 @@ export const ActionClassDataRow = ({
</div>
</div>
</div>
<div className="col-span-2 my-auto text-center text-sm whitespace-nowrap text-slate-500">
<div className="col-span-2 my-auto whitespace-nowrap text-center text-sm text-slate-500">
{timeSince(actionClass.createdAt.toString(), locale)}
</div>
<div className="text-center"></div>

View File

@@ -2,15 +2,22 @@ import { ActionClassesTable } from "@/app/(app)/environments/[environmentId]/act
import { ActionClassDataRow } from "@/app/(app)/environments/[environmentId]/actions/components/ActionRowData";
import { ActionTableHeading } from "@/app/(app)/environments/[environmentId]/actions/components/ActionTableHeading";
import { AddActionModal } from "@/app/(app)/environments/[environmentId]/actions/components/AddActionModal";
import { getActionClasses } from "@/lib/actionClass/service";
import { getEnvironments } from "@/lib/environment/service";
import { findMatchingLocale } from "@/lib/utils/locale";
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 { PageHeader } from "@/modules/ui/components/page-header";
import { getTranslate } from "@/tolgee/server";
import { Metadata } from "next";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { getActionClasses } from "@formbricks/lib/actionClass/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";
export const metadata: Metadata = {
title: "Actions",
@@ -18,24 +25,51 @@ export const metadata: Metadata = {
const Page = async (props) => {
const params = await props.params;
const { isReadOnly, project, isBilling, environment } = await getEnvironmentAuth(params.environmentId);
const session = await getServerSession(authOptions);
const t = await getTranslate();
const [actionClasses] = await Promise.all([getActionClasses(params.environmentId)]);
const [actionClasses, organization, project] = await Promise.all([
getActionClasses(params.environmentId),
getOrganizationByEnvironmentId(params.environmentId),
getProjectByEnvironmentId(params.environmentId),
]);
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 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 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) {
return redirect(`/environments/${params.environmentId}/settings/billing`);
}
const isReadOnly = isMember && hasReadAccess;
const renderAddActionButton = () => (
<AddActionModal
environmentId={params.environmentId}
@@ -48,7 +82,7 @@ const Page = async (props) => {
<PageContentWrapper>
<PageHeader pageTitle={t("common.actions")} cta={!isReadOnly ? renderAddActionButton() : undefined} />
<ActionClassesTable
environment={environment}
environment={currentEnvironment}
otherEnvironment={otherEnvironment}
otherEnvActionClasses={otherEnvActionClasses}
environmentId={params.environmentId}

View File

@@ -1,17 +1,5 @@
import { MainNavigation } from "@/app/(app)/environments/[environmentId]/components/MainNavigation";
import { TopControlBar } from "@/app/(app)/environments/[environmentId]/components/TopControlBar";
import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getEnvironment, getEnvironments } from "@/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils";
import {
getMonthlyActiveOrganizationPeopleCount,
getMonthlyOrganizationResponseCount,
getOrganizationByEnvironmentId,
getOrganizationsByUserId,
} from "@/lib/organization/service";
import { getUserProjects } from "@/lib/project/service";
import { getUser } from "@/lib/user/service";
import { getEnterpriseLicense, getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { DevEnvironmentBanner } from "@/modules/ui/components/dev-environment-banner";
@@ -19,6 +7,18 @@ import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-bann
import { PendingDowngradeBanner } from "@/modules/ui/components/pending-downgrade-banner";
import { getTranslate } from "@/tolgee/server";
import type { Session } from "next-auth";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { getEnvironment, getEnvironments } from "@formbricks/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import {
getMonthlyActiveOrganizationPeopleCount,
getMonthlyOrganizationResponseCount,
getOrganizationByEnvironmentId,
getOrganizationsByUserId,
} from "@formbricks/lib/organization/service";
import { getUserProjects } from "@formbricks/lib/project/service";
import { getUser } from "@formbricks/lib/user/service";
interface EnvironmentLayoutProps {
environmentId: string;
@@ -111,7 +111,6 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
organizationProjectsLimit={organizationProjectsLimit}
user={user}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
isDevelopment={IS_DEVELOPMENT}
membershipRole={membershipRole}
isMultiOrgEnabled={isMultiOrgEnabled}
isLicenseActive={active}

View File

@@ -1,7 +1,7 @@
"use client";
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
import { useEffect } from "react";
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@formbricks/lib/localStorage";
interface EnvironmentStorageHandlerProps {
environmentId: string;

View File

@@ -1,11 +1,11 @@
"use client";
import { cn } from "@/lib/cn";
import { Label } from "@/modules/ui/components/label";
import { Switch } from "@/modules/ui/components/switch";
import { useTranslate } from "@tolgee/react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { cn } from "@formbricks/lib/cn";
import { TEnvironment } from "@formbricks/types/environment";
interface EnvironmentSwitchProps {

View File

@@ -4,9 +4,6 @@ import { getLatestStableFbReleaseAction } from "@/app/(app)/environments/[enviro
import { NavigationLink } from "@/app/(app)/environments/[environmentId]/components/NavigationLink";
import { formbricksLogout } from "@/app/lib/formbricks";
import FBLogo from "@/images/formbricks-wordmark.svg";
import { cn } from "@/lib/cn";
import { getAccessFlags } from "@/lib/membership/utils";
import { capitalizeFirstLetter } from "@/lib/utils/strings";
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
import { ProjectSwitcher } from "@/modules/projects/components/project-switcher";
import { ProfileAvatar } from "@/modules/ui/components/avatars";
@@ -48,6 +45,9 @@ import Image from "next/image";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { cn } from "@formbricks/lib/cn";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { capitalizeFirstLetter } from "@formbricks/lib/utils/strings";
import { TEnvironment } from "@formbricks/types/environment";
import { TOrganizationRole } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations";
@@ -63,7 +63,6 @@ interface NavigationProps {
projects: TProject[];
isMultiOrgEnabled: boolean;
isFormbricksCloud: boolean;
isDevelopment: boolean;
membershipRole?: TOrganizationRole;
organizationProjectsLimit: number;
isLicenseActive: boolean;
@@ -80,7 +79,6 @@ export const MainNavigation = ({
isFormbricksCloud,
organizationProjectsLimit,
isLicenseActive,
isDevelopment,
}: NavigationProps) => {
const router = useRouter();
const pathname = usePathname();
@@ -265,7 +263,7 @@ export const MainNavigation = ({
size="icon"
onClick={toggleSidebar}
className={cn(
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:ring-0 focus:ring-transparent focus:outline-none"
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:outline-none focus:ring-0 focus:ring-transparent"
)}>
{isCollapsed ? (
<PanelLeftOpenIcon strokeWidth={1.5} />
@@ -298,7 +296,7 @@ export const MainNavigation = ({
<div>
{/* New Version Available */}
{!isCollapsed && isOwnerOrManager && latestVersion && !isFormbricksCloud && !isDevelopment && (
{!isCollapsed && isOwnerOrManager && latestVersion && !isFormbricksCloud && (
<Link
href="https://github.com/formbricks/formbricks/releases"
target="_blank"

View File

@@ -1,7 +1,7 @@
import { cn } from "@/lib/cn";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
import Link from "next/link";
import React from "react";
import { cn } from "@formbricks/lib/cn";
interface NavigationLinkProps {
href: string;

View File

@@ -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, test, 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();
});
test("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,
});
});
test("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();
});
test("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();
});
test("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();
});
});

View File

@@ -3,9 +3,12 @@
import type { Session } from "next-auth";
import { usePostHog } from "posthog-js/react";
import { useEffect } from "react";
import { env } from "@formbricks/lib/env";
import { TOrganizationBilling } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
const posthogEnabled = env.NEXT_PUBLIC_POSTHOG_API_KEY && env.NEXT_PUBLIC_POSTHOG_API_HOST;
interface PosthogIdentifyProps {
session: Session;
user: TUser;
@@ -13,7 +16,6 @@ interface PosthogIdentifyProps {
organizationId?: string;
organizationName?: string;
organizationBilling?: TOrganizationBilling;
isPosthogEnabled: boolean;
}
export const PosthogIdentify = ({
@@ -23,12 +25,11 @@ export const PosthogIdentify = ({
organizationId,
organizationName,
organizationBilling,
isPosthogEnabled,
}: PosthogIdentifyProps) => {
const posthog = usePostHog();
useEffect(() => {
if (isPosthogEnabled && session.user && posthog) {
if (posthogEnabled && session.user && posthog) {
posthog.identify(session.user.id, {
name: user.name,
email: user.email,
@@ -58,7 +59,6 @@ export const PosthogIdentify = ({
user.email,
user.role,
user.objective,
isPosthogEnabled,
]);
return null;

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