mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-30 02:10:12 -06:00
Compare commits
1 Commits
fix-survey
...
stepsecuri
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93f60db26c |
20
.env.example
20
.env.example
@@ -39,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)
|
||||
@@ -97,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 #
|
||||
##########
|
||||
@@ -188,16 +184,11 @@ 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:
|
||||
|
||||
# The below is used for Rate Limiting for management API
|
||||
UNKEY_ROOT_KEY=
|
||||
|
||||
# Disable custom cache handler if necessary (e.g. if deployed on Vercel)
|
||||
# CUSTOM_CACHE_DISABLED=1
|
||||
|
||||
@@ -207,10 +198,5 @@ UNKEY_ROOT_KEY=
|
||||
# AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID=
|
||||
# AI_AZURE_LLM_DEPLOYMENT_ID=
|
||||
|
||||
# INTERCOM_APP_ID=
|
||||
# INTERCOM_SECRET_KEY=
|
||||
|
||||
# Enable Prometheus metrics
|
||||
# PROMETHEUS_ENABLED=
|
||||
# PROMETHEUS_EXPORTER_PORT=
|
||||
|
||||
# NEXT_PUBLIC_INTERCOM_APP_ID=
|
||||
# INTERCOM_SECRET_KEY=
|
||||
1
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
1
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -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
|
||||
|
||||
96
.github/dependabot.yml
vendored
Normal file
96
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,96 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: daily
|
||||
|
||||
- package-ecosystem: npm
|
||||
directory: /apps/demo-react-native
|
||||
schedule:
|
||||
interval: daily
|
||||
|
||||
- package-ecosystem: npm
|
||||
directory: /apps/demo
|
||||
schedule:
|
||||
interval: daily
|
||||
|
||||
- package-ecosystem: npm
|
||||
directory: /apps/storybook
|
||||
schedule:
|
||||
interval: daily
|
||||
|
||||
- package-ecosystem: docker
|
||||
directory: /apps/web
|
||||
schedule:
|
||||
interval: daily
|
||||
|
||||
- package-ecosystem: npm
|
||||
directory: /apps/web
|
||||
schedule:
|
||||
interval: daily
|
||||
|
||||
- package-ecosystem: npm
|
||||
directory: /
|
||||
schedule:
|
||||
interval: daily
|
||||
|
||||
- package-ecosystem: npm
|
||||
directory: /packages/api
|
||||
schedule:
|
||||
interval: daily
|
||||
|
||||
- package-ecosystem: npm
|
||||
directory: /packages/config-eslint
|
||||
schedule:
|
||||
interval: daily
|
||||
|
||||
- package-ecosystem: npm
|
||||
directory: /packages/config-prettier
|
||||
schedule:
|
||||
interval: daily
|
||||
|
||||
- package-ecosystem: npm
|
||||
directory: /packages/config-typescript
|
||||
schedule:
|
||||
interval: daily
|
||||
|
||||
- package-ecosystem: npm
|
||||
directory: /packages/database
|
||||
schedule:
|
||||
interval: daily
|
||||
|
||||
- package-ecosystem: npm
|
||||
directory: /packages/js-core
|
||||
schedule:
|
||||
interval: daily
|
||||
|
||||
- package-ecosystem: npm
|
||||
directory: /packages/js
|
||||
schedule:
|
||||
interval: daily
|
||||
|
||||
- package-ecosystem: npm
|
||||
directory: /packages/lib
|
||||
schedule:
|
||||
interval: daily
|
||||
|
||||
- package-ecosystem: npm
|
||||
directory: /packages/react-native
|
||||
schedule:
|
||||
interval: daily
|
||||
|
||||
- package-ecosystem: npm
|
||||
directory: /packages/surveys
|
||||
schedule:
|
||||
interval: daily
|
||||
|
||||
- package-ecosystem: npm
|
||||
directory: /packages/types
|
||||
schedule:
|
||||
interval: daily
|
||||
|
||||
- package-ecosystem: npm
|
||||
directory: /packages/vite-plugins
|
||||
schedule:
|
||||
interval: daily
|
||||
78
.github/workflows/codeql.yml
vendored
Normal file
78
.github/workflows/codeql.yml
vendored
Normal file
@@ -0,0 +1,78 @@
|
||||
# For most projects, this workflow file will not need changing; you simply need
|
||||
# to commit it to your repository.
|
||||
#
|
||||
# You may wish to alter this file to override the set of languages analyzed,
|
||||
# or to provide custom queries or build logic.
|
||||
#
|
||||
# ******** NOTE ********
|
||||
# We have attempted to detect the languages in your repository. Please check
|
||||
# the `language` matrix defined below to confirm you have the correct set of
|
||||
# supported CodeQL languages.
|
||||
#
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: ["main"]
|
||||
schedule:
|
||||
- cron: "0 0 * * 1"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: ["javascript", "typescript"]
|
||||
# CodeQL supports [ $supported-codeql-languages ]
|
||||
# Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
|
||||
|
||||
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
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@b56ba49b26e50535fa1e7f7db0f4f7b4bf65d80d # v3.28.10
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@b56ba49b26e50535fa1e7f7db0f4f7b4bf65d80d # v3.28.10
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
|
||||
# If the Autobuild fails above, remove it and uncomment the following three lines.
|
||||
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
|
||||
|
||||
# - run: |
|
||||
# echo "Run, Build Application using script"
|
||||
# ./location_of_script_within_repo/buildscript.sh
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@b56ba49b26e50535fa1e7f7db0f4f7b4bf65d80d # v3.28.10
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
|
||||
uses: step-security/harden-runner@v2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
3
.github/workflows/cron-weeklySummary.yml
vendored
3
.github/workflows/cron-weeklySummary.yml
vendored
@@ -20,9 +20,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481
|
||||
uses: step-security/harden-runner@v2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: cURL request
|
||||
if: ${{ env.APP_URL && env.CRON_SECRET }}
|
||||
run: |
|
||||
|
||||
12
.github/workflows/e2e.yml
vendored
12
.github/workflows/e2e.yml
vendored
@@ -89,7 +89,7 @@ jobs:
|
||||
|
||||
- name: Run App
|
||||
run: |
|
||||
NODE_ENV=test pnpm start --filter=@formbricks/web | tee app.log 2>&1 &
|
||||
NODE_ENV=test pnpm start --filter=@formbricks/web &
|
||||
sleep 10 # Optional: gives some buffer for the app to start
|
||||
for attempt in {1..10}; do
|
||||
if [ $(curl -o /dev/null -s -w "%{http_code}" http://localhost:3000/health) -eq 200 ]; then
|
||||
@@ -141,13 +141,3 @@ jobs:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
|
||||
- uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
|
||||
if: failure()
|
||||
with:
|
||||
name: app-logs
|
||||
path: app.log
|
||||
|
||||
- name: Output App Logs
|
||||
if: failure()
|
||||
run: cat app.log
|
||||
|
||||
3
.github/workflows/pr.yml
vendored
3
.github/workflows/pr.yml
vendored
@@ -51,9 +51,10 @@ jobs:
|
||||
statuses: write
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481
|
||||
uses: step-security/harden-runner@v2
|
||||
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
|
||||
|
||||
67
.github/workflows/prepare-release.yml
vendored
Normal file
67
.github/workflows/prepare-release.yml
vendored
Normal file
@@ -0,0 +1,67 @@
|
||||
name: Prepare release
|
||||
run-name: Prepare release ${{ inputs.next_version }}
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
next_version:
|
||||
required: true
|
||||
type: string
|
||||
description: "Version name"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
prepare_release:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
|
||||
- name: Configure git
|
||||
run: |
|
||||
git config --local user.email "github-actions@github.com"
|
||||
git config --local user.name "GitHub Actions"
|
||||
|
||||
- name: Setup Node.js 20.x
|
||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
|
||||
with:
|
||||
node-version: 20.x
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --config.platform=linux --config.architecture=x64
|
||||
|
||||
- name: Bump version
|
||||
run: |
|
||||
cd apps/web
|
||||
pnpm version ${{ inputs.next_version }} --no-workspaces-update
|
||||
|
||||
- name: Commit changes and create a branch
|
||||
run: |
|
||||
branch_name="release-v${{ inputs.next_version }}"
|
||||
git checkout -b "$branch_name"
|
||||
git add .
|
||||
git commit -m "chore: release v${{ inputs.next_version }}"
|
||||
git push origin "$branch_name"
|
||||
|
||||
- name: Create pull request
|
||||
run: |
|
||||
gh pr create \
|
||||
--base main \
|
||||
--head "release-v${{ inputs.next_version }}" \
|
||||
--title "chore: bump version to v${{ inputs.next_version }}" \
|
||||
--body "This PR contains the changes for the v${{ inputs.next_version }} release."
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
12
.github/workflows/release-docker-github.yml
vendored
12
.github/workflows/release-docker-github.yml
vendored
@@ -42,18 +42,6 @@ jobs:
|
||||
- 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
|
||||
|
||||
- name: Update package.json version
|
||||
run: |
|
||||
sed -i "s/\"version\": \"0.0.0\"/\"version\": \"${{ env.RELEASE_TAG }}\"/" ./apps/web/package.json
|
||||
cat ./apps/web/package.json | grep version
|
||||
|
||||
- name: Set up Depot CLI
|
||||
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
|
||||
|
||||
|
||||
19
.github/workflows/release-docker.yml
vendored
19
.github/workflows/release-docker.yml
vendored
@@ -27,18 +27,6 @@ jobs:
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2.7.0
|
||||
|
||||
- name: Get Release Tag
|
||||
id: extract_release_tag
|
||||
run: |
|
||||
TAG=${{ github.ref }}
|
||||
TAG=${TAG#refs/tags/v}
|
||||
echo "RELEASE_TAG=$TAG" >> $GITHUB_ENV
|
||||
|
||||
- name: Update package.json version
|
||||
run: |
|
||||
sed -i "s/\"version\": \"0.0.0\"/\"version\": \"${{ env.RELEASE_TAG }}\"/" ./apps/web/package.json
|
||||
cat ./apps/web/package.json | grep version
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@465a07811f14bebb1938fbed4728c6a1ff8901fc # v2.2.0
|
||||
with:
|
||||
@@ -48,6 +36,13 @@ jobs:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@885d1462b80bc1c1c7f0b00334ad271f09369c55 # v2.10.0
|
||||
|
||||
- name: Get Release Tag
|
||||
id: extract_release_tag
|
||||
run: |
|
||||
TAG=${{ github.ref }}
|
||||
TAG=${TAG#refs/tags/v}
|
||||
echo "RELEASE_TAG=$TAG" >> $GITHUB_ENV
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@0a97817b6ade9f46837855d676c4cca3a2471fc9 # v4.2.1
|
||||
with:
|
||||
|
||||
51
.github/workflows/release-helm-chart.yml
vendored
51
.github/workflows/release-helm-chart.yml
vendored
@@ -1,51 +0,0 @@
|
||||
name: Publish Helm Chart
|
||||
|
||||
on:
|
||||
release:
|
||||
types:
|
||||
- published
|
||||
|
||||
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 = \"${VERSION#v}\"" helm-chart/Chart.yaml
|
||||
yq -i ".appVersion = \"${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-${VERSION#v}.tgz oci://ghcr.io/formbricks/helm-charts
|
||||
74
.github/workflows/terrafrom-plan-and-apply.yml
vendored
74
.github/workflows/terrafrom-plan-and-apply.yml
vendored
@@ -1,74 +0,0 @@
|
||||
name: 'Terraform'
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
terraform:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Configure AWS Credentials
|
||||
uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2
|
||||
with:
|
||||
role-to-assume: ${{ secrets.AWS_ASSUME_ROLE_ARN }}
|
||||
aws-region: "eu-central-1"
|
||||
|
||||
- name: Setup Terraform
|
||||
uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2
|
||||
|
||||
- name: Terraform Format
|
||||
id: fmt
|
||||
run: terraform fmt -check -recursive
|
||||
continue-on-error: true
|
||||
working-directory: infra/terraform
|
||||
|
||||
- name: Terraform Init
|
||||
id: init
|
||||
run: terraform init
|
||||
working-directory: infra/terraform
|
||||
|
||||
- name: Terraform Validate
|
||||
id: validate
|
||||
run: terraform validate
|
||||
working-directory: infra/terraform
|
||||
|
||||
- name: Terraform Plan
|
||||
id: plan
|
||||
run: terraform plan -out .planfile
|
||||
working-directory: infra/terraform
|
||||
|
||||
- name: Post PR comment
|
||||
uses: borchero/terraform-plan-comment@3399d8dbae8b05185e815e02361ede2949cd99c4 # v2.4.0
|
||||
if: always() && github.ref != 'refs/heads/main' && (steps.validate.outcome == 'success' || steps.validate.outcome == 'failure')
|
||||
with:
|
||||
token: ${{ github.token }}
|
||||
planfile: .planfile
|
||||
working-directory: "infra/terraform"
|
||||
skip-comment: true
|
||||
|
||||
- name: Terraform Apply
|
||||
id: apply
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
run: terraform apply .planfile
|
||||
working-directory: "infra/terraform"
|
||||
|
||||
11
.github/workflows/tolgee-missing-key-check.yml
vendored
11
.github/workflows/tolgee-missing-key-check.yml
vendored
@@ -5,7 +5,7 @@ permissions:
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request_target:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
jobs:
|
||||
@@ -18,14 +18,7 @@ jobs:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.ref }}
|
||||
|
||||
- name: Checkout PR
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
|
||||
|
||||
20
.github/workflows/tolgee.yml
vendored
20
.github/workflows/tolgee.yml
vendored
@@ -3,7 +3,7 @@ permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches:
|
||||
- main
|
||||
@@ -28,16 +28,18 @@ jobs:
|
||||
- name: Get source branch name
|
||||
id: branch-name
|
||||
run: |
|
||||
RAW_BRANCH="${{ github.head_ref }}"
|
||||
SOURCE_BRANCH=$(echo "$RAW_BRANCH" | sed 's/[^a-zA-Z0-9._\/-]//g')
|
||||
# For PR merges, use the head ref from the pull request event
|
||||
SOURCE_BRANCH="${{ github.head_ref }}"
|
||||
|
||||
# Only remove username prefix if needed
|
||||
if [[ "$SOURCE_BRANCH" =~ ^[a-zA-Z0-9][a-zA-Z0-9-]+/ ]]; then
|
||||
PREFIX=${SOURCE_BRANCH%%/*}
|
||||
if [[ ! "$PREFIX" =~ ^(feature|fix|bugfix|hotfix|release|chore|docs|test|refactor|style|perf|build|ci|revert)$ ]]; then
|
||||
SOURCE_BRANCH=${SOURCE_BRANCH#*/}
|
||||
fi
|
||||
fi
|
||||
|
||||
# Safely add to environment variables using GitHub's recommended method
|
||||
# This prevents environment variable injection attacks
|
||||
echo "SOURCE_BRANCH<<EOF" >> $GITHUB_ENV
|
||||
echo "$SOURCE_BRANCH" >> $GITHUB_ENV
|
||||
echo "EOF" >> $GITHUB_ENV
|
||||
|
||||
echo "SOURCE_BRANCH=$SOURCE_BRANCH" >> $GITHUB_ENV
|
||||
echo "Detected source branch: $SOURCE_BRANCH"
|
||||
|
||||
- name: Setup Node.js
|
||||
|
||||
20
.gitignore
vendored
20
.gitignore
vendored
@@ -53,22 +53,4 @@ yarn-error.log*
|
||||
packages/lib/uploads
|
||||
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
|
||||
branch.json
|
||||
@@ -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"
|
||||
|
||||
18
.pre-commit-config.yaml
Normal file
18
.pre-commit-config.yaml
Normal file
@@ -0,0 +1,18 @@
|
||||
repos:
|
||||
- repo: https://github.com/gitleaks/gitleaks
|
||||
rev: v8.16.3
|
||||
hooks:
|
||||
- id: gitleaks
|
||||
- repo: https://github.com/jumanjihouse/pre-commit-hooks
|
||||
rev: 3.0.0
|
||||
hooks:
|
||||
- id: shellcheck
|
||||
- repo: https://github.com/pre-commit/mirrors-eslint
|
||||
rev: v8.38.0
|
||||
hooks:
|
||||
- id: eslint
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.4.0
|
||||
hooks:
|
||||
- id: end-of-file-fixer
|
||||
- id: trailing-whitespace
|
||||
@@ -27,10 +27,6 @@
|
||||
{
|
||||
"language": "zh-Hant-TW",
|
||||
"path": "./packages/lib/messages/zh-Hant-TW.json"
|
||||
},
|
||||
{
|
||||
"language": "pt-PT",
|
||||
"path": "./packages/lib/messages/pt-PT.json"
|
||||
}
|
||||
],
|
||||
"forceMode": "OVERRIDE"
|
||||
|
||||
4
.vscode/extensions.json
vendored
4
.vscode/extensions.json
vendored
@@ -6,8 +6,6 @@
|
||||
"dbaeumer.vscode-eslint", // eslint plugin
|
||||
"esbenp.prettier-vscode", // prettier plugin
|
||||
"Prisma.prisma", // syntax|format|completion for prisma
|
||||
"yzhang.markdown-all-in-one", // nicer markdown support
|
||||
"vitest.explorer", // run tests directly from the code window
|
||||
"sonarsource.sonarlint-vscode" // sonarqube linter for vscode
|
||||
"yzhang.markdown-all-in-one" // nicer markdown support
|
||||
]
|
||||
}
|
||||
|
||||
2
LICENSE
2
LICENSE
@@ -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/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 content that resides under the "packages/js/", "packages/react-native/" 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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 'Reset' 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 'one', 'two', 'three'.
|
||||
</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 'de'
|
||||
</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 'de'.
|
||||
</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>
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"@typescript-eslint/eslint-plugin": "8.18.0",
|
||||
"@typescript-eslint/parser": "8.18.0",
|
||||
"@vitejs/plugin-react": "4.3.4",
|
||||
"esbuild": "0.25.1",
|
||||
"esbuild": "0.25.0",
|
||||
"eslint-plugin-storybook": "0.11.1",
|
||||
"prop-types": "15.8.1",
|
||||
"storybook": "8.4.7",
|
||||
|
||||
@@ -111,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
|
||||
|
||||
@@ -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";var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e),setTimeout(function(){window.formbricks.setup({environmentId: environmentId, appUrl: appUrl})},500)}();
|
||||
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";var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e),setTimeout(function(){window.formbricks.setup({environmentId: environmentId, appUrl: appUrl })},500)}();
|
||||
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}",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -231,7 +231,6 @@ export const ProjectSettings = ({
|
||||
<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}
|
||||
|
||||
@@ -12,12 +12,12 @@ export const FormbricksClient = ({ userId, email }: { userId: string; email: str
|
||||
|
||||
useEffect(() => {
|
||||
if (formbricksEnabled && userId) {
|
||||
formbricks.setup({
|
||||
formbricks.init({
|
||||
environmentId: env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID || "",
|
||||
appUrl: env.NEXT_PUBLIC_FORMBRICKS_API_HOST || "",
|
||||
apiHost: env.NEXT_PUBLIC_FORMBRICKS_API_HOST || "",
|
||||
userId,
|
||||
});
|
||||
|
||||
formbricks.setUserId(userId);
|
||||
formbricks.setEmail(email);
|
||||
}
|
||||
}, [userId, email]);
|
||||
|
||||
@@ -1,41 +1,25 @@
|
||||
"use server";
|
||||
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
|
||||
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
import { z } from "zod";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
|
||||
import { getSpreadsheetNameById } from "@formbricks/lib/googleSheet/service";
|
||||
import { ZIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";
|
||||
import { AuthorizationError } from "@formbricks/types/errors";
|
||||
import { TIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";
|
||||
|
||||
const ZGetSpreadsheetNameByIdAction = z.object({
|
||||
googleSheetIntegration: ZIntegrationGoogleSheets,
|
||||
environmentId: z.string(),
|
||||
spreadsheetId: z.string(),
|
||||
});
|
||||
export async function getSpreadsheetNameByIdAction(
|
||||
googleSheetIntegration: TIntegrationGoogleSheets,
|
||||
environmentId: string,
|
||||
spreadsheetId: string
|
||||
) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthorizationError("Not authorized");
|
||||
|
||||
export const getSpreadsheetNameByIdAction = authenticatedActionClient
|
||||
.schema(ZGetSpreadsheetNameByIdAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId),
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
|
||||
minPermission: "readWrite",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const integrationData = structuredClone(parsedInput.googleSheetIntegration);
|
||||
integrationData.config.data.forEach((data) => {
|
||||
data.createdAt = new Date(data.createdAt);
|
||||
});
|
||||
|
||||
return await getSpreadsheetNameById(integrationData, parsedInput.spreadsheetId);
|
||||
const isAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId);
|
||||
if (!isAuthorized) throw new AuthorizationError("Not authorized");
|
||||
const integrationData = structuredClone(googleSheetIntegration);
|
||||
integrationData.config.data.forEach((data) => {
|
||||
data.createdAt = new Date(data.createdAt);
|
||||
});
|
||||
return await getSpreadsheetNameById(integrationData, spreadsheetId);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
isValidGoogleSheetsUrl,
|
||||
} from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/lib/util";
|
||||
import GoogleSheetLogo from "@/images/googleSheetsLogo.png";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Checkbox } from "@/modules/ui/components/checkbox";
|
||||
@@ -116,18 +115,11 @@ export const AddIntegrationModal = ({
|
||||
throw new Error(t("environments.integrations.select_at_least_one_question_error"));
|
||||
}
|
||||
const spreadsheetId = extractSpreadsheetIdFromUrl(spreadsheetUrl);
|
||||
const spreadsheetNameResponse = await getSpreadsheetNameByIdAction({
|
||||
const spreadsheetName = await getSpreadsheetNameByIdAction(
|
||||
googleSheetIntegration,
|
||||
environmentId,
|
||||
spreadsheetId,
|
||||
});
|
||||
|
||||
if (!spreadsheetNameResponse?.data) {
|
||||
const errorMessage = getFormattedErrorMessage(spreadsheetNameResponse);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const spreadsheetName = spreadsheetNameResponse.data;
|
||||
spreadsheetId
|
||||
);
|
||||
|
||||
setIsLinkingSheet(true);
|
||||
integrationData.spreadsheetId = spreadsheetId;
|
||||
|
||||
@@ -27,7 +27,7 @@ export const EditAlerts = ({
|
||||
return (
|
||||
<>
|
||||
{memberships.map((membership) => (
|
||||
<div key={membership.organization.id}>
|
||||
<>
|
||||
<div className="mb-5 grid grid-cols-6 items-center space-x-3">
|
||||
<div className="col-span-3 flex items-center space-x-3">
|
||||
<UsersIcon className="h-6 w-7 text-slate-600" />
|
||||
@@ -110,7 +110,7 @@ export const EditAlerts = ({
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -18,7 +18,7 @@ export const EditWeeklySummary = ({ memberships, user, environmentId }: EditAler
|
||||
return (
|
||||
<>
|
||||
{memberships.map((membership) => (
|
||||
<div key={membership.organization.id}>
|
||||
<>
|
||||
<div className="mb-5 flex items-center space-x-3 text-sm font-medium">
|
||||
<UsersIcon className="h-6 w-7 text-slate-600" />
|
||||
|
||||
@@ -52,7 +52,7 @@ export const EditWeeklySummary = ({ memberships, user, environmentId }: EditAler
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -9,10 +9,8 @@ import { UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
||||
import {
|
||||
getOrganizationByEnvironmentId,
|
||||
getOrganizationsWhereUserIsSingleOwner,
|
||||
} from "@formbricks/lib/organization/service";
|
||||
import { getOrganizationsWhereUserIsSingleOwner } from "@formbricks/lib/organization/service";
|
||||
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
|
||||
import { getUser } from "@formbricks/lib/user/service";
|
||||
import { SettingsCard } from "../../components/SettingsCard";
|
||||
import { DeleteAccount } from "./components/DeleteAccount";
|
||||
@@ -73,9 +71,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
description={t("environments.settings.profile.two_factor_authentication_description")}
|
||||
buttons={[
|
||||
{
|
||||
text: IS_FORMBRICKS_CLOUD
|
||||
? t("common.start_free_trial")
|
||||
: t("common.request_trial_license"),
|
||||
text: t("common.start_free_trial"),
|
||||
href: IS_FORMBRICKS_CLOUD
|
||||
? `/environments/${params.environmentId}/settings/billing`
|
||||
: "https://formbricks.com/upgrade-self-hosting-license",
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
import {
|
||||
getIsMultiOrgEnabled,
|
||||
getIsOrganizationAIReady,
|
||||
getWhiteLabelPermission,
|
||||
} from "@/modules/ee/license-check/lib/utils";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
|
||||
import { getUser } from "@formbricks/lib/user/service";
|
||||
import { TMembership } from "@formbricks/types/memberships";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import Page from "./page";
|
||||
|
||||
vi.mock("@formbricks/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
IS_PRODUCTION: false,
|
||||
FB_LOGO_URL: "https://example.com/mock-logo.png",
|
||||
ENCRYPTION_KEY: "mock-encryption-key",
|
||||
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
|
||||
GITHUB_ID: "mock-github-id",
|
||||
GITHUB_SECRET: "mock-github-secret",
|
||||
GOOGLE_CLIENT_ID: "mock-google-client-id",
|
||||
GOOGLE_CLIENT_SECRET: "mock-google-client-secret",
|
||||
AZUREAD_CLIENT_ID: "mock-azuread-client-id",
|
||||
AZUREAD_CLIENT_SECRET: "mock-azure-client-secret",
|
||||
AZUREAD_TENANT_ID: "mock-azuread-tenant-id",
|
||||
OIDC_CLIENT_ID: "mock-oidc-client-id",
|
||||
OIDC_CLIENT_SECRET: "mock-oidc-client-secret",
|
||||
OIDC_ISSUER: "mock-oidc-issuer",
|
||||
OIDC_DISPLAY_NAME: "mock-oidc-display-name",
|
||||
OIDC_SIGNING_ALGORITHM: "mock-oidc-signing-algorithm",
|
||||
SAML_DATABASE_URL: "mock-saml-database-url",
|
||||
WEBAPP_URL: "mock-webapp-url",
|
||||
SMTP_HOST: "mock-smtp-host",
|
||||
SMTP_PORT: "mock-smtp-port",
|
||||
}));
|
||||
|
||||
vi.mock("next-auth", () => ({
|
||||
getServerSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/tolgee/server", () => ({
|
||||
getTranslate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/lib/user/service", () => ({
|
||||
getUser: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/lib/organization/service", () => ({
|
||||
getOrganizationByEnvironmentId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/lib/membership/service", () => ({
|
||||
getMembershipByUserIdOrganizationId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/lib/membership/utils", () => ({
|
||||
getAccessFlags: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
|
||||
getIsMultiOrgEnabled: vi.fn(),
|
||||
getIsOrganizationAIReady: vi.fn(),
|
||||
getWhiteLabelPermission: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("Page", () => {
|
||||
const mockParams = { environmentId: "test-environment-id" };
|
||||
const mockSession = { user: { id: "test-user-id" } };
|
||||
const mockUser = { id: "test-user-id" } as TUser;
|
||||
const mockOrganization = { id: "test-organization-id", billing: { plan: "free" } } as TOrganization;
|
||||
const mockMembership = { role: "owner" } as TMembership;
|
||||
const mockTranslate = vi.fn((key) => key);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getServerSession).mockResolvedValue(mockSession);
|
||||
vi.mocked(getTranslate).mockResolvedValue(mockTranslate);
|
||||
vi.mocked(getUser).mockResolvedValue(mockUser);
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization);
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
|
||||
vi.mocked(getAccessFlags).mockReturnValue({
|
||||
isOwner: true,
|
||||
isManager: false,
|
||||
isBilling: false,
|
||||
isMember: false,
|
||||
});
|
||||
vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(true);
|
||||
vi.mocked(getIsOrganizationAIReady).mockResolvedValue(true);
|
||||
vi.mocked(getWhiteLabelPermission).mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it("renders the page with organization settings", async () => {
|
||||
const props = {
|
||||
params: Promise.resolve({ environmentId: "env-123" }),
|
||||
};
|
||||
|
||||
const result = await Page(props);
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders if session user id is null", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValue({ user: { id: null } });
|
||||
|
||||
const props = {
|
||||
params: Promise.resolve({ environmentId: "env-123" }),
|
||||
};
|
||||
|
||||
const result = await Page(props);
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
|
||||
it("throws an error if the session is not found", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValue(null);
|
||||
|
||||
await expect(Page({ params: Promise.resolve(mockParams) })).rejects.toThrow("common.session_not_found");
|
||||
});
|
||||
|
||||
it("throws an error if the organization is not found", async () => {
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null);
|
||||
|
||||
await expect(Page({ params: Promise.resolve(mockParams) })).rejects.toThrow(
|
||||
"common.organization_not_found"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,7 @@ import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { SettingsId } from "@/modules/ui/components/settings-id";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { FB_LOGO_URL, IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
||||
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
|
||||
@@ -84,7 +84,6 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
environmentId={params.environmentId}
|
||||
isReadOnly={!isOwnerOrManager}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
fbLogoUrl={FB_LOGO_URL}
|
||||
user={user}
|
||||
/>
|
||||
{isMultiOrgEnabled && (
|
||||
|
||||
@@ -200,6 +200,13 @@ export const generateResponseTableColumns = (
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="font-normal">
|
||||
{t("environments.surveys.responses.how_to_identify_users")}
|
||||
<Link
|
||||
className="underline underline-offset-2 hover:text-slate-900"
|
||||
href="https://formbricks.com/docs/link-surveys/user-identification"
|
||||
target="_blank">
|
||||
{t("common.link_surveys")}
|
||||
</Link>{" "}
|
||||
or{" "}
|
||||
<Link
|
||||
className="underline underline-offset-2 hover:text-slate-900"
|
||||
href="https://formbricks.com/docs/app-surveys/user-identification"
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { MobileAppTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/MobileAppTab";
|
||||
import { WebAppTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/WebAppTab";
|
||||
import { OptionsSwitch } from "@/modules/ui/components/options-switch";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
|
||||
export const AppTab = () => {
|
||||
export const AppTab = ({ environmentId }) => {
|
||||
const { t } = useTranslate();
|
||||
const [selectedTab, setSelectedTab] = useState("webapp");
|
||||
|
||||
@@ -21,7 +20,79 @@ export const AppTab = () => {
|
||||
handleOptionChange={(value) => setSelectedTab(value)}
|
||||
/>
|
||||
|
||||
<div className="mt-4">{selectedTab === "webapp" ? <WebAppTab /> : <MobileAppTab />}</div>
|
||||
<div className="mt-4">
|
||||
{selectedTab === "webapp" ? <WebAppTab environmentId={environmentId} /> : <MobileAppTab />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MobileAppTab = () => {
|
||||
const { t } = useTranslate();
|
||||
return (
|
||||
<div>
|
||||
<p className="text-lg font-semibold text-slate-800">
|
||||
{t("environments.surveys.summary.how_to_embed_a_survey_on_your_react_native_app")}
|
||||
</p>
|
||||
<ol className="mt-4 list-decimal space-y-2 pl-5 text-sm text-slate-700">
|
||||
<li>
|
||||
{t("common.follow_these")}{" "}
|
||||
<Link
|
||||
href="https://formbricks.com/docs/developer-docs/react-native-in-app-surveys"
|
||||
target="_blank"
|
||||
className="decoration-brand-dark font-medium underline underline-offset-2">
|
||||
{t("environments.surveys.summary.setup_instructions_for_react_native_apps")}
|
||||
</Link>{" "}
|
||||
{t("environments.surveys.summary.to_connect_your_app_with_formbricks")}
|
||||
</li>
|
||||
</ol>
|
||||
<div className="mt-2 text-sm italic text-slate-700">
|
||||
{t("environments.surveys.summary.were_working_on_sdks_for_flutter_swift_and_kotlin")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const WebAppTab = ({ environmentId }) => {
|
||||
const { t } = useTranslate();
|
||||
return (
|
||||
<div>
|
||||
<p className="text-lg font-semibold text-slate-800">
|
||||
{t("environments.surveys.summary.how_to_embed_a_survey_on_your_web_app")}
|
||||
</p>
|
||||
<ol className="mt-4 list-decimal space-y-2 pl-5 text-sm text-slate-700">
|
||||
<li>
|
||||
{t("common.follow_these")}{" "}
|
||||
<Link
|
||||
href={`/environments/${environmentId}/project/app-connection`}
|
||||
target="_blank"
|
||||
className="decoration-brand-dark font-medium underline underline-offset-2">
|
||||
{t("environments.surveys.summary.setup_instructions")}
|
||||
</Link>{" "}
|
||||
{t("environments.surveys.summary.to_connect_your_web_app_with_formbricks")}
|
||||
</li>
|
||||
<li>
|
||||
{t("environments.surveys.summary.learn_how_to")}{" "}
|
||||
<Link
|
||||
href="https://formbricks.com/docs/app-surveys/user-identification"
|
||||
target="_blank"
|
||||
className="decoration-brand-dark font-medium underline underline-offset-2">
|
||||
{t("environments.surveys.summary.identify_users_and_set_attributes")}
|
||||
</Link>{" "}
|
||||
{t("environments.surveys.summary.to_run_highly_targeted_surveys")}.
|
||||
</li>
|
||||
<li>
|
||||
{t("environments.surveys.summary.make_sure_the_survey_type_is_set_to")}{" "}
|
||||
<b>{t("common.app_survey")}</b>
|
||||
</li>
|
||||
<li>{t("environments.surveys.summary.define_when_and_where_the_survey_should_pop_up")}</li>
|
||||
</ol>
|
||||
<div className="mt-4">
|
||||
<video autoPlay loop muted className="w-full rounded-xl border border-slate-200">
|
||||
<source src="/video/tooltips/change-survey-type-app.mp4" type="video/mp4" />
|
||||
{t("environments.surveys.summary.unsupported_video_tag_warning")}
|
||||
</video>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -88,7 +88,7 @@ export const EmbedView = ({
|
||||
locale={locale}
|
||||
/>
|
||||
) : activeId === "app" ? (
|
||||
<AppTab />
|
||||
<AppTab environmentId={environmentId} />
|
||||
) : null}
|
||||
<div className="mt-2 rounded-md p-3 text-center lg:hidden">
|
||||
{tabs.slice(0, 2).map((tab) => (
|
||||
|
||||
@@ -16,8 +16,12 @@ interface LinkTabProps {
|
||||
|
||||
export const LinkTab = ({ survey, webAppUrl, surveyUrl, setSurveyUrl, locale }: LinkTabProps) => {
|
||||
const { t } = useTranslate();
|
||||
|
||||
const docsLinks = [
|
||||
{
|
||||
title: t("environments.surveys.summary.identify_users"),
|
||||
description: t("environments.surveys.summary.identify_users_description"),
|
||||
link: "https://formbricks.com/docs/link-surveys/user-identification",
|
||||
},
|
||||
{
|
||||
title: t("environments.surveys.summary.data_prefilling"),
|
||||
description: t("environments.surveys.summary.data_prefilling_description"),
|
||||
@@ -49,7 +53,6 @@ export const LinkTab = ({ survey, webAppUrl, surveyUrl, setSurveyUrl, locale }:
|
||||
locale={locale}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap justify-between gap-2">
|
||||
<p className="pt-2 font-semibold text-slate-700">
|
||||
{t("environments.surveys.summary.you_can_do_a_lot_more_with_links_surveys")} 💡
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import Link from "next/link";
|
||||
|
||||
export const MobileAppTab = () => {
|
||||
const { t } = useTranslate();
|
||||
return (
|
||||
<Alert>
|
||||
<AlertTitle>{t("environments.surveys.summary.quickstart_mobile_apps")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("environments.surveys.summary.quickstart_mobile_apps_description")}
|
||||
<Button asChild className="w-fit" size="sm" variant="link">
|
||||
<Link
|
||||
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/framework-guides"
|
||||
target="_blank">
|
||||
{t("common.learn_more")}
|
||||
</Link>
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
@@ -85,10 +85,8 @@ export const PanelInfoView = ({ disableBack, handleInitialPageButton }: PanelInf
|
||||
</p>
|
||||
</div>
|
||||
<Button className="justify-center" asChild>
|
||||
<Link
|
||||
href="https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/market-research-panel"
|
||||
target="_blank">
|
||||
{t("common.learn_more")}
|
||||
<Link href="https://formbricks.com/docs/link-surveys/market-research-panel" target="_blank">
|
||||
{t("common.get_started")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import Link from "next/link";
|
||||
|
||||
export const WebAppTab = () => {
|
||||
const { t } = useTranslate();
|
||||
return (
|
||||
<Alert>
|
||||
<AlertTitle>{t("environments.surveys.summary.quickstart_web_apps")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("environments.surveys.summary.quickstart_web_apps_description")}
|
||||
<Button asChild className="w-fit" size="sm" variant="link">
|
||||
<Link
|
||||
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/quickstart"
|
||||
target="_blank">
|
||||
{t("common.learn_more")}
|
||||
</Link>
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
@@ -1,36 +0,0 @@
|
||||
import { Options } from "qr-code-styling";
|
||||
|
||||
export const getQRCodeOptions = (width: number, height: number): Options => ({
|
||||
width,
|
||||
height,
|
||||
type: "svg",
|
||||
data: "",
|
||||
margin: 0,
|
||||
qrOptions: {
|
||||
typeNumber: 0,
|
||||
mode: "Byte",
|
||||
errorCorrectionLevel: "L",
|
||||
},
|
||||
imageOptions: {
|
||||
saveAsBlob: true,
|
||||
hideBackgroundDots: false,
|
||||
imageSize: 0,
|
||||
margin: 0,
|
||||
},
|
||||
dotsOptions: {
|
||||
type: "extra-rounded",
|
||||
color: "#000000",
|
||||
roundSize: true,
|
||||
},
|
||||
backgroundOptions: {
|
||||
color: "#ffffff",
|
||||
},
|
||||
cornersSquareOptions: {
|
||||
type: "dot",
|
||||
color: "#000000",
|
||||
},
|
||||
cornersDotOptions: {
|
||||
type: "dot",
|
||||
color: "#000000",
|
||||
},
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { getQRCodeOptions } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/get-qr-code-options";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import QRCodeStyling from "qr-code-styling";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
|
||||
export const useSurveyQRCode = (surveyUrl: string) => {
|
||||
const qrCodeRef = useRef<HTMLDivElement>(null);
|
||||
const qrInstance = useRef<QRCodeStyling | null>(null);
|
||||
const { t } = useTranslate();
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (!qrInstance.current) {
|
||||
qrInstance.current = new QRCodeStyling(getQRCodeOptions(70, 70));
|
||||
}
|
||||
|
||||
if (surveyUrl && qrInstance.current) {
|
||||
qrInstance.current.update({ data: surveyUrl });
|
||||
|
||||
if (qrCodeRef.current) {
|
||||
qrCodeRef.current.innerHTML = "";
|
||||
qrInstance.current.append(qrCodeRef.current);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(t("environments.surveys.summary.failed_to_generate_qr_code"));
|
||||
}
|
||||
}, [surveyUrl]);
|
||||
|
||||
const downloadQRCode = () => {
|
||||
try {
|
||||
const downloadInstance = new QRCodeStyling(getQRCodeOptions(500, 500));
|
||||
downloadInstance.update({ data: surveyUrl });
|
||||
downloadInstance.download({ name: "survey-qr", extension: "png" });
|
||||
} catch (error) {
|
||||
toast.error(t("environments.surveys.summary.failed_to_generate_qr_code"));
|
||||
}
|
||||
};
|
||||
|
||||
return { qrCodeRef, downloadQRCode };
|
||||
};
|
||||
@@ -380,7 +380,7 @@ export const getQuestionSummary = async (
|
||||
|
||||
let hasValidAnswer = false;
|
||||
|
||||
if (Array.isArray(answer) && question.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti) {
|
||||
if (Array.isArray(answer)) {
|
||||
answer.forEach((value) => {
|
||||
if (value) {
|
||||
totalSelectionCount++;
|
||||
@@ -396,10 +396,7 @@ export const getQuestionSummary = async (
|
||||
hasValidAnswer = true;
|
||||
}
|
||||
});
|
||||
} else if (
|
||||
typeof answer === "string" &&
|
||||
question.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle
|
||||
) {
|
||||
} else if (typeof answer === "string") {
|
||||
if (answer) {
|
||||
totalSelectionCount++;
|
||||
if (questionChoices.includes(answer)) {
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { getUser } from "@formbricks/lib/user/service";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import AppLayout from "./layout";
|
||||
|
||||
vi.mock("next-auth", () => ({
|
||||
getServerSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/lib/user/service", () => ({
|
||||
getUser: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/lib/constants", () => ({
|
||||
INTERCOM_SECRET_KEY: "test-secret-key",
|
||||
IS_INTERCOM_CONFIGURED: true,
|
||||
INTERCOM_APP_ID: "test-app-id",
|
||||
ENCRYPTION_KEY: "test-encryption-key",
|
||||
ENTERPRISE_LICENSE_KEY: "test-enterprise-license-key",
|
||||
GITHUB_ID: "test-github-id",
|
||||
GITHUB_SECRET: "test-githubID",
|
||||
GOOGLE_CLIENT_ID: "test-google-client-id",
|
||||
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
|
||||
AZUREAD_CLIENT_ID: "test-azuread-client-id",
|
||||
AZUREAD_CLIENT_SECRET: "test-azure",
|
||||
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
|
||||
OIDC_DISPLAY_NAME: "test-oidc-display-name",
|
||||
OIDC_CLIENT_ID: "test-oidc-client-id",
|
||||
OIDC_ISSUER: "test-oidc-issuer",
|
||||
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
|
||||
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
|
||||
WEBAPP_URL: "test-webapp-url",
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(app)/components/FormbricksClient", () => ({
|
||||
FormbricksClient: () => <div data-testid="formbricks-client" />,
|
||||
}));
|
||||
vi.mock("@/app/intercom/IntercomClientWrapper", () => ({
|
||||
IntercomClientWrapper: () => <div data-testid="mock-intercom-wrapper" />,
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/no-mobile-overlay", () => ({
|
||||
NoMobileOverlay: () => <div data-testid="no-mobile-overlay" />,
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/post-hog-client", () => ({
|
||||
PHProvider: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="ph-provider">{children}</div>
|
||||
),
|
||||
PostHogPageview: () => <div data-testid="ph-pageview" />,
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/toaster-client", () => ({
|
||||
ToasterClient: () => <div data-testid="toaster-client" />,
|
||||
}));
|
||||
|
||||
describe("(app) AppLayout", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("renders child content and all sub-components when user exists", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } });
|
||||
vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123", email: "test@example.com" } as TUser);
|
||||
|
||||
// Because AppLayout is async, call it like a function
|
||||
const element = await AppLayout({
|
||||
children: <div data-testid="child-content">Hello from children</div>,
|
||||
});
|
||||
|
||||
render(element);
|
||||
|
||||
expect(screen.getByTestId("no-mobile-overlay")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("ph-pageview")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("ph-provider")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("mock-intercom-wrapper")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("toaster-client")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("child-content")).toHaveTextContent("Hello from children");
|
||||
expect(screen.getByTestId("formbricks-client")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("skips FormbricksClient if no user is present", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValueOnce(null);
|
||||
|
||||
const element = await AppLayout({
|
||||
children: <div data-testid="child-content">Hello from children</div>,
|
||||
});
|
||||
render(element);
|
||||
|
||||
expect(screen.queryByTestId("formbricks-client")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,12 @@
|
||||
import { FormbricksClient } from "@/app/(app)/components/FormbricksClient";
|
||||
import { IntercomClientWrapper } from "@/app/intercom/IntercomClientWrapper";
|
||||
import { IntercomClient } from "@/app/IntercomClient";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { NoMobileOverlay } from "@/modules/ui/components/no-mobile-overlay";
|
||||
import { PHProvider, PostHogPageview } from "@/modules/ui/components/post-hog-client";
|
||||
import { ToasterClient } from "@/modules/ui/components/toaster-client";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { Suspense } from "react";
|
||||
import { INTERCOM_SECRET_KEY, IS_INTERCOM_CONFIGURED } from "@formbricks/lib/constants";
|
||||
import { getUser } from "@formbricks/lib/user/service";
|
||||
|
||||
const AppLayout = async ({ children }) => {
|
||||
@@ -21,7 +22,11 @@ const AppLayout = async ({ children }) => {
|
||||
<PHProvider>
|
||||
<>
|
||||
{user ? <FormbricksClient userId={user.id} email={user.email} /> : null}
|
||||
<IntercomClientWrapper user={user} />
|
||||
<IntercomClient
|
||||
isIntercomConfigured={IS_INTERCOM_CONFIGURED}
|
||||
intercomSecretKey={INTERCOM_SECRET_KEY}
|
||||
user={user}
|
||||
/>
|
||||
<ToasterClient />
|
||||
{children}
|
||||
</>
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import AppLayout from "../(auth)/layout";
|
||||
|
||||
vi.mock("@formbricks/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
IS_INTERCOM_CONFIGURED: true,
|
||||
INTERCOM_SECRET_KEY: "mock-intercom-secret-key",
|
||||
INTERCOM_APP_ID: "mock-intercom-app-id",
|
||||
}));
|
||||
|
||||
vi.mock("@/app/intercom/IntercomClientWrapper", () => ({
|
||||
IntercomClientWrapper: () => <div data-testid="mock-intercom-wrapper" />,
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/no-mobile-overlay", () => ({
|
||||
NoMobileOverlay: () => <div data-testid="mock-no-mobile-overlay" />,
|
||||
}));
|
||||
|
||||
describe("(auth) AppLayout", () => {
|
||||
it("renders the NoMobileOverlay and IntercomClient, plus children", async () => {
|
||||
const appLayoutElement = await AppLayout({
|
||||
children: <div data-testid="child-content">Hello from children!</div>,
|
||||
});
|
||||
|
||||
const childContentText = "Hello from children!";
|
||||
|
||||
render(appLayoutElement);
|
||||
|
||||
expect(screen.getByTestId("mock-no-mobile-overlay")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("mock-intercom-wrapper")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("child-content")).toHaveTextContent(childContentText);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,12 @@
|
||||
import { IntercomClientWrapper } from "@/app/intercom/IntercomClientWrapper";
|
||||
import { IntercomClient } from "@/app/IntercomClient";
|
||||
import { NoMobileOverlay } from "@/modules/ui/components/no-mobile-overlay";
|
||||
import { INTERCOM_SECRET_KEY, IS_INTERCOM_CONFIGURED } from "@formbricks/lib/constants";
|
||||
|
||||
const AppLayout = async ({ children }) => {
|
||||
return (
|
||||
<>
|
||||
<NoMobileOverlay />
|
||||
<IntercomClientWrapper />
|
||||
<IntercomClient isIntercomConfigured={IS_INTERCOM_CONFIGURED} intercomSecretKey={INTERCOM_SECRET_KEY} />
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { hasOrganizationAccess } from "@/app/lib/api/apiHelper";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { notFound } from "next/navigation";
|
||||
import { hasOrganizationAccess } from "@formbricks/lib/auth";
|
||||
import { getEnvironments } from "@formbricks/lib/environment/service";
|
||||
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
@@ -16,7 +16,7 @@ export const GET = async (_: Request, context: { params: Promise<{ organizationI
|
||||
// check auth
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthenticationError("Not authenticated");
|
||||
const hasAccess = await hasOrganizationAccess(session.user.id, organizationId);
|
||||
const hasAccess = await hasOrganizationAccess(session.user, organizationId);
|
||||
if (!hasAccess) throw new AuthorizationError("Unauthorized");
|
||||
|
||||
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organizationId);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { hasOrganizationAccess } from "@/app/lib/api/apiHelper";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { hasOrganizationAccess } from "@formbricks/lib/auth";
|
||||
import { getEnvironments } from "@formbricks/lib/environment/service";
|
||||
import { getProject } from "@formbricks/lib/project/service";
|
||||
import { AuthenticationError, AuthorizationError } from "@formbricks/types/errors";
|
||||
@@ -15,7 +15,7 @@ export const GET = async (_: Request, context: { params: Promise<{ projectId: st
|
||||
if (!session) throw new AuthenticationError("Not authenticated");
|
||||
const project = await getProject(projectId);
|
||||
if (!project) return notFound();
|
||||
const hasAccess = await hasOrganizationAccess(session.user.id, project.organizationId);
|
||||
const hasAccess = await hasOrganizationAccess(session.user, project.organizationId);
|
||||
if (!hasAccess) throw new AuthorizationError("Unauthorized");
|
||||
// redirect to project's production environment
|
||||
const environments = await getEnvironments(project.id);
|
||||
|
||||
@@ -1,31 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import Intercom from "@intercom/messenger-js-sdk";
|
||||
import { createHmac } from "crypto";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { env } from "@formbricks/lib/env";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
|
||||
const intercomAppId = env.NEXT_PUBLIC_INTERCOM_APP_ID;
|
||||
|
||||
interface IntercomClientProps {
|
||||
isIntercomConfigured: boolean;
|
||||
intercomUserHash?: string;
|
||||
intercomSecretKey?: string;
|
||||
user?: TUser | null;
|
||||
intercomAppId?: string;
|
||||
}
|
||||
|
||||
export const IntercomClient = ({
|
||||
user,
|
||||
intercomUserHash,
|
||||
isIntercomConfigured,
|
||||
intercomAppId,
|
||||
}: IntercomClientProps) => {
|
||||
export const IntercomClient = ({ user, intercomSecretKey, isIntercomConfigured }: IntercomClientProps) => {
|
||||
const initializeIntercom = useCallback(() => {
|
||||
let initParams = {};
|
||||
|
||||
if (user && intercomUserHash) {
|
||||
if (user) {
|
||||
const { id, name, email, createdAt } = user;
|
||||
const hash = createHmac("sha256", intercomSecretKey!).update(user?.id).digest("hex");
|
||||
|
||||
initParams = {
|
||||
user_id: id,
|
||||
user_hash: intercomUserHash,
|
||||
user_hash: hash,
|
||||
name,
|
||||
email,
|
||||
created_at: createdAt ? Math.floor(createdAt.getTime() / 1000) : undefined,
|
||||
@@ -36,21 +35,11 @@ export const IntercomClient = ({
|
||||
app_id: intercomAppId!,
|
||||
...initParams,
|
||||
});
|
||||
}, [user, intercomUserHash, intercomAppId]);
|
||||
}, [user, intercomSecretKey]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (isIntercomConfigured) {
|
||||
if (!intercomAppId) {
|
||||
throw new Error("Intercom app ID is required");
|
||||
}
|
||||
|
||||
if (user && !intercomUserHash) {
|
||||
throw new Error("Intercom user hash is required");
|
||||
}
|
||||
|
||||
initializeIntercom();
|
||||
}
|
||||
if (isIntercomConfigured) initializeIntercom();
|
||||
|
||||
return () => {
|
||||
// Shutdown Intercom when component unmounts
|
||||
@@ -61,7 +50,7 @@ export const IntercomClient = ({
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize Intercom:", error);
|
||||
}
|
||||
}, [isIntercomConfigured, initializeIntercom, intercomAppId, intercomUserHash, user]);
|
||||
}, [isIntercomConfigured, initializeIntercom]);
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -1,19 +1,16 @@
|
||||
import { getEnvironmentIdFromApiKey } from "@/app/api/v1/lib/api-key";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { getEnvironmentIdFromApiKey } from "./lib/api-key";
|
||||
|
||||
export const authenticateRequest = async (request: Request): Promise<TAuthenticationApiKey | null> => {
|
||||
const apiKey = request.headers.get("x-api-key");
|
||||
if (apiKey) {
|
||||
const environmentId = await getEnvironmentIdFromApiKey(apiKey);
|
||||
if (environmentId) {
|
||||
const hashedApiKey = hashApiKey(apiKey);
|
||||
const authentication: TAuthenticationApiKey = {
|
||||
type: "apiKey",
|
||||
environmentId,
|
||||
hashedApiKey,
|
||||
};
|
||||
return authentication;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@ import { cache } from "@formbricks/lib/cache";
|
||||
import { getHash } from "@formbricks/lib/crypto";
|
||||
import { validateInputs } from "@formbricks/lib/utils/validate";
|
||||
import { ZString } from "@formbricks/types/common";
|
||||
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { InvalidInputError } from "@formbricks/types/errors";
|
||||
|
||||
export const getEnvironmentIdFromApiKey = reactCache(async (apiKey: string): Promise<string | null> => {
|
||||
const hashedKey = getHash(apiKey);
|
||||
@@ -41,7 +42,7 @@ export const getEnvironmentIdFromApiKey = reactCache(async (apiKey: string): Pro
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[`management-api-getEnvironmentIdFromApiKey-${apiKey}`],
|
||||
[`getEnvironmentIdFromApiKey-${apiKey}`],
|
||||
{
|
||||
tags: [apiKeyCache.tag.byHashedKey(hashedKey)],
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
import type { Session } from "next-auth";
|
||||
import { getServerSession } from "next-auth";
|
||||
|
||||
export const getSessionUser = async (req?: NextApiRequest, res?: NextApiResponse) => {
|
||||
// check for session (browser usage)
|
||||
let session: Session | null;
|
||||
if (req && res) {
|
||||
session = await getServerSession(req, res, authOptions);
|
||||
} else {
|
||||
session = await getServerSession(authOptions);
|
||||
}
|
||||
if (session && "user" in session) return session.user;
|
||||
};
|
||||
@@ -1,5 +1,4 @@
|
||||
import { getSessionUser } from "@/app/api/v1/management/me/lib/utils";
|
||||
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
|
||||
import { getSessionUser, hashApiKey } from "@/app/lib/api/apiHelper";
|
||||
import { headers } from "next/headers";
|
||||
import { prisma } from "@formbricks/database";
|
||||
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import {
|
||||
OPTIONS,
|
||||
PUT,
|
||||
} from "@/modules/ee/contacts/api/client/[environmentId]/contacts/[userId]/attributes/route";
|
||||
|
||||
export { OPTIONS, PUT };
|
||||
@@ -1,26 +0,0 @@
|
||||
import { contactCache } from "@/lib/cache/contact";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { cache } from "@formbricks/lib/cache";
|
||||
|
||||
export const doesContactExist = reactCache(
|
||||
(id: string): Promise<boolean> =>
|
||||
cache(
|
||||
async () => {
|
||||
const contact = await prisma.contact.findFirst({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
return !!contact;
|
||||
},
|
||||
[`doesContactExistDisplaysApiV2-${id}`],
|
||||
{
|
||||
tags: [contactCache.tag.byId(id)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
@@ -1,54 +0,0 @@
|
||||
import {
|
||||
TDisplayCreateInputV2,
|
||||
ZDisplayCreateInputV2,
|
||||
} from "@/app/api/v2/client/[environmentId]/displays/types/display";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { displayCache } from "@formbricks/lib/display/cache";
|
||||
import { validateInputs } from "@formbricks/lib/utils/validate";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { doesContactExist } from "./contact";
|
||||
|
||||
export const createDisplay = async (displayInput: TDisplayCreateInputV2): Promise<{ id: string }> => {
|
||||
validateInputs([displayInput, ZDisplayCreateInputV2]);
|
||||
|
||||
const { environmentId, contactId, surveyId } = displayInput;
|
||||
|
||||
try {
|
||||
const contactExists = contactId ? await doesContactExist(contactId) : false;
|
||||
|
||||
const display = await prisma.display.create({
|
||||
data: {
|
||||
survey: {
|
||||
connect: {
|
||||
id: surveyId,
|
||||
},
|
||||
},
|
||||
|
||||
...(contactExists && {
|
||||
contact: {
|
||||
connect: {
|
||||
id: contactId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
select: { id: true, contactId: true, surveyId: true },
|
||||
});
|
||||
|
||||
displayCache.revalidate({
|
||||
id: display.id,
|
||||
contactId: display.contactId,
|
||||
surveyId: display.surveyId,
|
||||
environmentId,
|
||||
});
|
||||
|
||||
return display;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -1,55 +0,0 @@
|
||||
import { ZDisplayCreateInputV2 } from "@/app/api/v2/client/[environmentId]/displays/types/display";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { capturePosthogEnvironmentEvent } from "@formbricks/lib/posthogServer";
|
||||
import { InvalidInputError } from "@formbricks/types/errors";
|
||||
import { createDisplay } from "./lib/display";
|
||||
|
||||
interface Context {
|
||||
params: Promise<{
|
||||
environmentId: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export const OPTIONS = async (): Promise<Response> => {
|
||||
return responses.successResponse({}, true);
|
||||
};
|
||||
|
||||
export const POST = async (request: Request, context: Context): Promise<Response> => {
|
||||
const params = await context.params;
|
||||
const jsonInput = await request.json();
|
||||
const inputValidation = ZDisplayCreateInputV2.safeParse({
|
||||
...jsonInput,
|
||||
environmentId: params.environmentId,
|
||||
});
|
||||
|
||||
if (!inputValidation.success) {
|
||||
return responses.badRequestResponse(
|
||||
"Fields are missing or incorrectly formatted",
|
||||
transformErrorToDetails(inputValidation.error),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
if (inputValidation.data.contactId) {
|
||||
const isContactsEnabled = await getIsContactsEnabled();
|
||||
if (!isContactsEnabled) {
|
||||
return responses.forbiddenResponse("User identification is only available for enterprise users.", true);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await createDisplay(inputValidation.data);
|
||||
|
||||
await capturePosthogEnvironmentEvent(inputValidation.data.environmentId, "display created");
|
||||
return responses.successResponse(response, true);
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidInputError) {
|
||||
return responses.badRequestResponse(error.message);
|
||||
} else {
|
||||
console.error(error);
|
||||
return responses.internalServerErrorResponse(error.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,9 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ZDisplayCreateInput } from "@formbricks/types/displays";
|
||||
|
||||
export const ZDisplayCreateInputV2 = ZDisplayCreateInput.omit({ userId: true }).extend({
|
||||
contactId: ZId.optional(),
|
||||
});
|
||||
|
||||
export type TDisplayCreateInputV2 = z.infer<typeof ZDisplayCreateInputV2>;
|
||||
@@ -1,3 +0,0 @@
|
||||
import { GET, OPTIONS } from "@/app/api/v1/client/[environmentId]/environment/route";
|
||||
|
||||
export { OPTIONS, GET };
|
||||
@@ -1,6 +0,0 @@
|
||||
import {
|
||||
GET,
|
||||
OPTIONS,
|
||||
} from "@/modules/ee/contacts/api/client/[environmentId]/identify/contacts/[userId]/route";
|
||||
|
||||
export { GET, OPTIONS };
|
||||
@@ -1,3 +0,0 @@
|
||||
import { OPTIONS, PUT } from "@/app/api/v1/client/[environmentId]/responses/[responseId]/route";
|
||||
|
||||
export { OPTIONS, PUT };
|
||||
@@ -1,42 +0,0 @@
|
||||
import { contactCache } from "@/lib/cache/contact";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { cache } from "@formbricks/lib/cache";
|
||||
import { TContactAttributes } from "@formbricks/types/contact-attribute";
|
||||
|
||||
export const getContact = reactCache((contactId: string) =>
|
||||
cache(
|
||||
async () => {
|
||||
const contact = await prisma.contact.findUnique({
|
||||
where: { id: contactId },
|
||||
select: {
|
||||
id: true,
|
||||
attributes: {
|
||||
select: {
|
||||
attributeKey: { select: { key: true } },
|
||||
value: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!contact) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const contactAttributes = contact.attributes.reduce((acc, attr) => {
|
||||
acc[attr.attributeKey.key] = attr.value;
|
||||
return acc;
|
||||
}, {}) as TContactAttributes;
|
||||
|
||||
return {
|
||||
id: contact.id,
|
||||
attributes: contactAttributes,
|
||||
};
|
||||
},
|
||||
[`getContact-responses-api-${contactId}`],
|
||||
{
|
||||
tags: [contactCache.tag.byId(contactId)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
@@ -1,145 +0,0 @@
|
||||
import "server-only";
|
||||
import { responseSelection } from "@/app/api/v1/client/[environmentId]/responses/lib/response";
|
||||
import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/types/response";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
||||
import {
|
||||
getMonthlyOrganizationResponseCount,
|
||||
getOrganizationByEnvironmentId,
|
||||
} from "@formbricks/lib/organization/service";
|
||||
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@formbricks/lib/posthogServer";
|
||||
import { responseCache } from "@formbricks/lib/response/cache";
|
||||
import { calculateTtcTotal } from "@formbricks/lib/response/utils";
|
||||
import { responseNoteCache } from "@formbricks/lib/responseNote/cache";
|
||||
import { captureTelemetry } from "@formbricks/lib/telemetry";
|
||||
import { validateInputs } from "@formbricks/lib/utils/validate";
|
||||
import { TContactAttributes } from "@formbricks/types/contact-attribute";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TResponse, ZResponseInput } from "@formbricks/types/responses";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import { getContact } from "./contact";
|
||||
|
||||
export const createResponse = async (responseInput: TResponseInputV2): Promise<TResponse> => {
|
||||
validateInputs([responseInput, ZResponseInput]);
|
||||
captureTelemetry("response created");
|
||||
|
||||
const {
|
||||
environmentId,
|
||||
language,
|
||||
contactId,
|
||||
surveyId,
|
||||
displayId,
|
||||
finished,
|
||||
data,
|
||||
meta,
|
||||
singleUseId,
|
||||
variables,
|
||||
ttc: initialTtc,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
} = responseInput;
|
||||
|
||||
try {
|
||||
let contact: { id: string; attributes: TContactAttributes } | null = null;
|
||||
let userId: string | undefined = undefined;
|
||||
|
||||
const organization = await getOrganizationByEnvironmentId(environmentId);
|
||||
if (!organization) {
|
||||
throw new ResourceNotFoundError("Organization", environmentId);
|
||||
}
|
||||
|
||||
if (contactId) {
|
||||
contact = await getContact(contactId);
|
||||
userId = contact?.attributes.userId;
|
||||
}
|
||||
|
||||
const ttc = initialTtc ? (finished ? calculateTtcTotal(initialTtc) : initialTtc) : {};
|
||||
|
||||
const prismaData: Prisma.ResponseCreateInput = {
|
||||
survey: {
|
||||
connect: {
|
||||
id: surveyId,
|
||||
},
|
||||
},
|
||||
display: displayId ? { connect: { id: displayId } } : undefined,
|
||||
finished: finished,
|
||||
data: data,
|
||||
language: language,
|
||||
...(contact?.id && {
|
||||
contact: {
|
||||
connect: {
|
||||
id: contact.id,
|
||||
},
|
||||
},
|
||||
contactAttributes: contact.attributes,
|
||||
}),
|
||||
...(meta && ({ meta } as Prisma.JsonObject)),
|
||||
singleUseId,
|
||||
...(variables && { variables }),
|
||||
ttc: ttc,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
};
|
||||
|
||||
const responsePrisma = await prisma.response.create({
|
||||
data: prismaData,
|
||||
select: responseSelection,
|
||||
});
|
||||
|
||||
const response: TResponse = {
|
||||
...responsePrisma,
|
||||
contact: contact
|
||||
? {
|
||||
id: contact.id,
|
||||
userId: contact.attributes.userId,
|
||||
}
|
||||
: null,
|
||||
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
|
||||
};
|
||||
|
||||
responseCache.revalidate({
|
||||
environmentId,
|
||||
id: response.id,
|
||||
contactId: contact?.id,
|
||||
...(singleUseId && { singleUseId }),
|
||||
userId,
|
||||
surveyId,
|
||||
});
|
||||
|
||||
responseNoteCache.revalidate({
|
||||
responseId: response.id,
|
||||
});
|
||||
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
const responsesCount = await getMonthlyOrganizationResponseCount(organization.id);
|
||||
const responsesLimit = organization.billing.limits.monthly.responses;
|
||||
|
||||
if (responsesLimit && responsesCount >= responsesLimit) {
|
||||
try {
|
||||
await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, {
|
||||
plan: organization.billing.plan,
|
||||
limits: {
|
||||
projects: null,
|
||||
monthly: {
|
||||
responses: responsesLimit,
|
||||
miu: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
// Log error but do not throw
|
||||
console.error(`Error sending plan limits reached event to Posthog: ${err}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -1,138 +0,0 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { sendToPipeline } from "@/app/lib/pipelines";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { headers } from "next/headers";
|
||||
import { UAParser } from "ua-parser-js";
|
||||
import { capturePosthogEnvironmentEvent } from "@formbricks/lib/posthogServer";
|
||||
import { getSurvey } from "@formbricks/lib/survey/service";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { InvalidInputError } from "@formbricks/types/errors";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
import { createResponse } from "./lib/response";
|
||||
import { TResponseInputV2, ZResponseInputV2 } from "./types/response";
|
||||
|
||||
interface Context {
|
||||
params: Promise<{
|
||||
environmentId: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export const OPTIONS = async (): Promise<Response> => {
|
||||
return responses.successResponse({}, true);
|
||||
};
|
||||
|
||||
export const POST = async (request: Request, context: Context): Promise<Response> => {
|
||||
const params = await context.params;
|
||||
const requestHeaders = await headers();
|
||||
let responseInput;
|
||||
try {
|
||||
responseInput = await request.json();
|
||||
} catch (error) {
|
||||
return responses.badRequestResponse("Invalid JSON in request body", { error: error.message }, true);
|
||||
}
|
||||
|
||||
const { environmentId } = params;
|
||||
const environmentIdValidation = ZId.safeParse(environmentId);
|
||||
const responseInputValidation = ZResponseInputV2.safeParse({ ...responseInput, environmentId });
|
||||
|
||||
if (!environmentIdValidation.success) {
|
||||
return responses.badRequestResponse(
|
||||
"Fields are missing or incorrectly formatted",
|
||||
transformErrorToDetails(environmentIdValidation.error),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
if (!responseInputValidation.success) {
|
||||
return responses.badRequestResponse(
|
||||
"Fields are missing or incorrectly formatted",
|
||||
transformErrorToDetails(responseInputValidation.error),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
const userAgent = request.headers.get("user-agent") || undefined;
|
||||
const agent = new UAParser(userAgent);
|
||||
|
||||
const country =
|
||||
requestHeaders.get("CF-IPCountry") ||
|
||||
requestHeaders.get("X-Vercel-IP-Country") ||
|
||||
requestHeaders.get("CloudFront-Viewer-Country") ||
|
||||
undefined;
|
||||
|
||||
const responseInputData = responseInputValidation.data;
|
||||
|
||||
if (responseInputData.contactId) {
|
||||
const isContactsEnabled = await getIsContactsEnabled();
|
||||
if (!isContactsEnabled) {
|
||||
return responses.forbiddenResponse("User identification is only available for enterprise users.", true);
|
||||
}
|
||||
}
|
||||
|
||||
// get and check survey
|
||||
const survey = await getSurvey(responseInputData.surveyId);
|
||||
if (!survey) {
|
||||
return responses.notFoundResponse("Survey", responseInputData.surveyId, true);
|
||||
}
|
||||
if (survey.environmentId !== environmentId) {
|
||||
return responses.badRequestResponse(
|
||||
"Survey is part of another environment",
|
||||
{
|
||||
"survey.environmentId": survey.environmentId,
|
||||
environmentId,
|
||||
},
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
let response: TResponse;
|
||||
try {
|
||||
const meta: TResponseInputV2["meta"] = {
|
||||
source: responseInputData?.meta?.source,
|
||||
url: responseInputData?.meta?.url,
|
||||
userAgent: {
|
||||
browser: agent.getBrowser().name,
|
||||
device: agent.getDevice().type || "desktop",
|
||||
os: agent.getOS().name,
|
||||
},
|
||||
country: country,
|
||||
action: responseInputData?.meta?.action,
|
||||
};
|
||||
|
||||
response = await createResponse({
|
||||
...responseInputData,
|
||||
meta,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidInputError) {
|
||||
return responses.badRequestResponse(error.message);
|
||||
} else {
|
||||
console.error(error);
|
||||
return responses.internalServerErrorResponse(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
sendToPipeline({
|
||||
event: "responseCreated",
|
||||
environmentId: survey.environmentId,
|
||||
surveyId: response.surveyId,
|
||||
response: response,
|
||||
});
|
||||
|
||||
if (responseInput.finished) {
|
||||
sendToPipeline({
|
||||
event: "responseFinished",
|
||||
environmentId: survey.environmentId,
|
||||
surveyId: response.surveyId,
|
||||
response: response,
|
||||
});
|
||||
}
|
||||
|
||||
await capturePosthogEnvironmentEvent(survey.environmentId, "response created", {
|
||||
surveyId: response.surveyId,
|
||||
surveyType: survey.type,
|
||||
});
|
||||
|
||||
return responses.successResponse({ id: response.id }, true);
|
||||
};
|
||||
@@ -1,6 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ZResponseInput } from "@formbricks/types/responses";
|
||||
|
||||
export const ZResponseInputV2 = ZResponseInput.omit({ userId: true }).extend({ contactId: ZId.nullish() });
|
||||
export type TResponseInputV2 = z.infer<typeof ZResponseInputV2>;
|
||||
@@ -1,3 +0,0 @@
|
||||
import { OPTIONS, POST } from "@/app/api/v1/client/[environmentId]/storage/local/route";
|
||||
|
||||
export { OPTIONS, POST };
|
||||
@@ -1,3 +0,0 @@
|
||||
import { OPTIONS, POST } from "@/app/api/v1/client/[environmentId]/storage/route";
|
||||
|
||||
export { OPTIONS, POST };
|
||||
@@ -1,3 +0,0 @@
|
||||
import { OPTIONS, POST } from "@/modules/ee/contacts/api/client/[environmentId]/user/route";
|
||||
|
||||
export { POST, OPTIONS };
|
||||
@@ -1,3 +0,0 @@
|
||||
import { DELETE, GET, PUT } from "@/modules/api/v2/management/responses/[responseId]/route";
|
||||
|
||||
export { GET, PUT, DELETE };
|
||||
@@ -1,3 +0,0 @@
|
||||
import { GET, POST } from "@/modules/api/v2/management/responses/route";
|
||||
|
||||
export { GET, POST };
|
||||
@@ -1,186 +0,0 @@
|
||||
import Intercom from "@intercom/messenger-js-sdk";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { IntercomClient } from "./IntercomClient";
|
||||
|
||||
// Mock the Intercom package
|
||||
vi.mock("@intercom/messenger-js-sdk", () => ({
|
||||
default: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("IntercomClient", () => {
|
||||
let originalWindowIntercom: any;
|
||||
let mockWindowIntercom = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
// Save original window.Intercom so we can restore it later
|
||||
originalWindowIntercom = global.window?.Intercom;
|
||||
// Mock window.Intercom so we can verify the shutdown call on unmount
|
||||
global.window.Intercom = mockWindowIntercom;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
// Restore the original window.Intercom
|
||||
global.window.Intercom = originalWindowIntercom;
|
||||
});
|
||||
|
||||
it("calls Intercom with user data when isIntercomConfigured is true and user is provided", () => {
|
||||
const testUser = {
|
||||
id: "test-id",
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
createdAt: new Date("2020-01-01T00:00:00Z"),
|
||||
} as TUser;
|
||||
|
||||
render(
|
||||
<IntercomClient
|
||||
isIntercomConfigured={true}
|
||||
intercomUserHash="my-user-hash"
|
||||
intercomAppId="my-app-id"
|
||||
user={testUser}
|
||||
/>
|
||||
);
|
||||
|
||||
// Verify Intercom was called with the expected params
|
||||
expect(Intercom).toHaveBeenCalledTimes(1);
|
||||
expect(Intercom).toHaveBeenCalledWith({
|
||||
app_id: "my-app-id",
|
||||
user_id: "test-id",
|
||||
user_hash: "my-user-hash",
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
created_at: 1577836800, // Epoch for 2020-01-01T00:00:00Z
|
||||
});
|
||||
});
|
||||
|
||||
it("calls Intercom with user data without createdAt", () => {
|
||||
const testUser = {
|
||||
id: "test-id",
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
} as TUser;
|
||||
|
||||
render(
|
||||
<IntercomClient
|
||||
isIntercomConfigured={true}
|
||||
intercomUserHash="my-user-hash"
|
||||
intercomAppId="my-app-id"
|
||||
user={testUser}
|
||||
/>
|
||||
);
|
||||
|
||||
// Verify Intercom was called with the expected params
|
||||
expect(Intercom).toHaveBeenCalledTimes(1);
|
||||
expect(Intercom).toHaveBeenCalledWith({
|
||||
app_id: "my-app-id",
|
||||
user_id: "test-id",
|
||||
user_hash: "my-user-hash",
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
created_at: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("calls Intercom with minimal params if user is not provided", () => {
|
||||
render(
|
||||
<IntercomClient isIntercomConfigured={true} intercomAppId="my-app-id" intercomUserHash="my-user-hash" />
|
||||
);
|
||||
|
||||
expect(Intercom).toHaveBeenCalledTimes(1);
|
||||
expect(Intercom).toHaveBeenCalledWith({
|
||||
app_id: "my-app-id",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not call Intercom if isIntercomConfigured is false", () => {
|
||||
render(
|
||||
<IntercomClient
|
||||
isIntercomConfigured={false}
|
||||
intercomAppId="my-app-id"
|
||||
user={{ id: "whatever" } as TUser}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(Intercom).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shuts down Intercom on unmount", () => {
|
||||
const { unmount } = render(
|
||||
<IntercomClient isIntercomConfigured={true} intercomAppId="my-app-id" intercomUserHash="my-user-hash" />
|
||||
);
|
||||
|
||||
// Reset call count; we only care about the shutdown after unmount
|
||||
mockWindowIntercom.mockClear();
|
||||
|
||||
unmount();
|
||||
|
||||
// Intercom should be shut down on unmount
|
||||
expect(mockWindowIntercom).toHaveBeenCalledWith("shutdown");
|
||||
});
|
||||
|
||||
it("logs an error if Intercom initialization fails", () => {
|
||||
// Spy on console.error
|
||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
// Force Intercom to throw an error on invocation
|
||||
vi.mocked(Intercom).mockImplementationOnce(() => {
|
||||
throw new Error("Intercom test error");
|
||||
});
|
||||
|
||||
// Render the component with isIntercomConfigured=true so it tries to initialize
|
||||
render(
|
||||
<IntercomClient isIntercomConfigured={true} intercomAppId="my-app-id" intercomUserHash="my-user-hash" />
|
||||
);
|
||||
|
||||
// Verify that console.error was called with the correct message
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to initialize Intercom:", expect.any(Error));
|
||||
|
||||
// Clean up the spy
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("logs an error if isIntercomConfigured is true but no intercomAppId is provided", () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
render(
|
||||
<IntercomClient
|
||||
isIntercomConfigured={true}
|
||||
// missing intercomAppId
|
||||
intercomUserHash="my-user-hash"
|
||||
/>
|
||||
);
|
||||
|
||||
// We expect a caught error: "Intercom app ID is required"
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to initialize Intercom:", expect.any(Error));
|
||||
const [, caughtError] = consoleErrorSpy.mock.calls[0];
|
||||
expect((caughtError as Error).message).toBe("Intercom app ID is required");
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("logs an error if isIntercomConfigured is true but no intercomUserHash is provided", () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const testUser = {
|
||||
id: "test-id",
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
} as TUser;
|
||||
|
||||
render(
|
||||
<IntercomClient
|
||||
isIntercomConfigured={true}
|
||||
intercomAppId="some-app-id"
|
||||
user={testUser}
|
||||
// missing intercomUserHash
|
||||
/>
|
||||
);
|
||||
|
||||
// We expect a caught error: "Intercom user hash is required"
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to initialize Intercom:", expect.any(Error));
|
||||
const [, caughtError] = consoleErrorSpy.mock.calls[0];
|
||||
expect((caughtError as Error).message).toBe("Intercom user hash is required");
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -1,64 +0,0 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { IntercomClientWrapper } from "./IntercomClientWrapper";
|
||||
|
||||
vi.mock("@formbricks/lib/constants", () => ({
|
||||
IS_INTERCOM_CONFIGURED: true,
|
||||
INTERCOM_APP_ID: "mock-intercom-app-id",
|
||||
INTERCOM_SECRET_KEY: "mock-intercom-secret-key",
|
||||
}));
|
||||
|
||||
// Mock the crypto createHmac function to return a fake hash.
|
||||
// Vite global setup doesn't work here due to Intercom probably using crypto themselves.
|
||||
vi.mock("crypto", () => ({
|
||||
default: {
|
||||
createHmac: vi.fn(() => ({
|
||||
update: vi.fn().mockReturnThis(),
|
||||
digest: vi.fn().mockReturnValue("fake-hash"),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./IntercomClient", () => ({
|
||||
IntercomClient: (props: any) => (
|
||||
<div data-testid="mock-intercom-client" data-props={JSON.stringify(props)} />
|
||||
),
|
||||
}));
|
||||
|
||||
describe("IntercomClientWrapper", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("renders IntercomClient with computed user hash when user is provided", () => {
|
||||
const testUser = { id: "user-123", name: "Test User", email: "test@example.com" } as TUser;
|
||||
|
||||
render(<IntercomClientWrapper user={testUser} />);
|
||||
|
||||
const intercomClientEl = screen.getByTestId("mock-intercom-client");
|
||||
expect(intercomClientEl).toBeInTheDocument();
|
||||
|
||||
const props = JSON.parse(intercomClientEl.getAttribute("data-props") ?? "{}");
|
||||
|
||||
// Check that the computed hash equals "fake-hash" (as per our crypto mock)
|
||||
expect(props.intercomUserHash).toBe("fake-hash");
|
||||
expect(props.intercomAppId).toBe("mock-intercom-app-id");
|
||||
expect(props.isIntercomConfigured).toBe(true);
|
||||
expect(props.user).toEqual(testUser);
|
||||
});
|
||||
|
||||
it("renders IntercomClient without computing a hash when no user is provided", () => {
|
||||
render(<IntercomClientWrapper user={null} />);
|
||||
|
||||
const intercomClientEl = screen.getByTestId("mock-intercom-client");
|
||||
expect(intercomClientEl).toBeInTheDocument();
|
||||
|
||||
const props = JSON.parse(intercomClientEl.getAttribute("data-props") ?? "{}");
|
||||
|
||||
expect(props.intercomUserHash).toBeUndefined();
|
||||
expect(props.intercomAppId).toBe("mock-intercom-app-id");
|
||||
expect(props.isIntercomConfigured).toBe(true);
|
||||
expect(props.user).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,26 +0,0 @@
|
||||
import { createHmac } from "crypto";
|
||||
import { INTERCOM_APP_ID, INTERCOM_SECRET_KEY, IS_INTERCOM_CONFIGURED } from "@formbricks/lib/constants";
|
||||
import type { TUser } from "@formbricks/types/user";
|
||||
import { IntercomClient } from "./IntercomClient";
|
||||
|
||||
interface IntercomClientWrapperProps {
|
||||
user?: TUser | null;
|
||||
}
|
||||
|
||||
export const IntercomClientWrapper = ({ user }: IntercomClientWrapperProps) => {
|
||||
let intercomUserHash: string | undefined;
|
||||
if (user) {
|
||||
const secretKey = INTERCOM_SECRET_KEY;
|
||||
if (secretKey) {
|
||||
intercomUserHash = createHmac("sha256", secretKey).update(user.id).digest("hex");
|
||||
}
|
||||
}
|
||||
return (
|
||||
<IntercomClient
|
||||
isIntercomConfigured={IS_INTERCOM_CONFIGURED}
|
||||
user={user}
|
||||
intercomAppId={INTERCOM_APP_ID}
|
||||
intercomUserHash={intercomUserHash}
|
||||
/>
|
||||
);
|
||||
};
|
||||
75
apps/web/app/lib/api/apiHelper.ts
Normal file
75
apps/web/app/lib/api/apiHelper.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { createHash } from "crypto";
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
import type { Session } from "next-auth";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
|
||||
|
||||
export const hashApiKey = (key: string): string => createHash("sha256").update(key).digest("hex");
|
||||
|
||||
export const hasEnvironmentAccess = async (
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
environmentId: string
|
||||
) => {
|
||||
if (req.headers["x-api-key"]) {
|
||||
const ownership = await hasApiEnvironmentAccess(req.headers["x-api-key"].toString(), environmentId);
|
||||
if (!ownership) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
const user = await getSessionUser(req, res);
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
const ownership = await hasUserEnvironmentAccess(user.id, environmentId);
|
||||
if (!ownership) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
export const hasApiEnvironmentAccess = async (apiKey, environmentId) => {
|
||||
// write function to check if the API Key has access to the environment
|
||||
const apiKeyData = await prisma.apiKey.findUnique({
|
||||
where: {
|
||||
hashedKey: hashApiKey(apiKey),
|
||||
},
|
||||
select: {
|
||||
environmentId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (apiKeyData?.environmentId === environmentId) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const hasOrganizationAccess = async (user, organizationId) => {
|
||||
const membership = await prisma.membership.findUnique({
|
||||
where: {
|
||||
userId_organizationId: {
|
||||
userId: user.id,
|
||||
organizationId: organizationId,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (membership) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const getSessionUser = async (req?: NextApiRequest, res?: NextApiResponse) => {
|
||||
// check for session (browser usage)
|
||||
let session: Session | null;
|
||||
if (req && res) {
|
||||
session = await getServerSession(req, res, authOptions);
|
||||
} else {
|
||||
session = await getServerSession(authOptions);
|
||||
}
|
||||
if (session && "user" in session) return session.user;
|
||||
};
|
||||
@@ -15,8 +15,7 @@ interface ApiErrorResponse {
|
||||
| "unauthorized"
|
||||
| "method_not_allowed"
|
||||
| "not_authenticated"
|
||||
| "forbidden"
|
||||
| "too_many_requests";
|
||||
| "forbidden";
|
||||
message: string;
|
||||
details: {
|
||||
[key: string]: string | string[] | number | number[] | boolean | boolean[];
|
||||
@@ -248,7 +247,7 @@ const tooManyRequestsResponse = (
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
code: "too_many_requests",
|
||||
code: "internal_server_error",
|
||||
message,
|
||||
details: {},
|
||||
} as ApiErrorResponse,
|
||||
|
||||
@@ -14,11 +14,6 @@ export const isClientSideApiRoute = (url: string): boolean => {
|
||||
return regex.test(url);
|
||||
};
|
||||
|
||||
export const isManagementApiRoute = (url: string): boolean => {
|
||||
const regex = /^\/api\/v\d+\/management\//;
|
||||
return regex.test(url);
|
||||
};
|
||||
|
||||
export const isShareUrlRoute = (url: string): boolean => {
|
||||
const regex = /\/share\/[A-Za-z0-9]+\/(?:summary|responses)/;
|
||||
return regex.test(url);
|
||||
|
||||
@@ -11,7 +11,7 @@ const createTimeoutPromise = (ms, rejectReason) => {
|
||||
CacheHandler.onCreation(async () => {
|
||||
let client;
|
||||
|
||||
if (process.env.REDIS_URL) {
|
||||
if (process.env.REDIS_URL && process.env.ENTERPRISE_LICENSE_KEY) {
|
||||
try {
|
||||
// Create a Redis client.
|
||||
client = createClient({
|
||||
@@ -45,22 +45,20 @@ CacheHandler.onCreation(async () => {
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (process.env.REDIS_URL) {
|
||||
console.log("Redis clustering requires an Enterprise License. Falling back to LRU cache.");
|
||||
}
|
||||
|
||||
/** @type {import("@neshca/cache-handler").Handler | null} */
|
||||
let handler;
|
||||
|
||||
if (client?.isReady) {
|
||||
const redisHandlerOptions = {
|
||||
// Create the `redis-stack` Handler if the client is available and connected.
|
||||
handler = await createRedisHandler({
|
||||
client,
|
||||
keyPrefix: "fb:",
|
||||
timeoutMs: 1000,
|
||||
};
|
||||
|
||||
redisHandlerOptions.ttl = Number(process.env.REDIS_DEFAULT_TTL) || 86400; // 1 day
|
||||
|
||||
// Create the `redis-stack` Handler if the client is available and connected.
|
||||
handler = await createRedisHandler(redisHandlerOptions);
|
||||
});
|
||||
} else {
|
||||
// Fallback to LRU handler if Redis client is not available.
|
||||
// The application will still work, but the cache will be in memory only and not shared.
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
// instrumentation-node.ts
|
||||
import { PrometheusExporter } from "@opentelemetry/exporter-prometheus";
|
||||
import { HostMetrics } from "@opentelemetry/host-metrics";
|
||||
import { registerInstrumentations } from "@opentelemetry/instrumentation";
|
||||
import { HttpInstrumentation } from "@opentelemetry/instrumentation-http";
|
||||
import { RuntimeNodeInstrumentation } from "@opentelemetry/instrumentation-runtime-node";
|
||||
import {
|
||||
Resource,
|
||||
detectResourcesSync,
|
||||
envDetector,
|
||||
hostDetector,
|
||||
processDetector,
|
||||
} from "@opentelemetry/resources";
|
||||
import { MeterProvider } from "@opentelemetry/sdk-metrics";
|
||||
import { env } from "@formbricks/lib/env";
|
||||
|
||||
const exporter = new PrometheusExporter({
|
||||
port: env.PROMETHEUS_EXPORTER_PORT ? parseInt(env.PROMETHEUS_EXPORTER_PORT) : 9464,
|
||||
endpoint: "/metrics",
|
||||
host: "0.0.0.0", // Listen on all network interfaces
|
||||
});
|
||||
|
||||
const detectedResources = detectResourcesSync({
|
||||
detectors: [envDetector, processDetector, hostDetector],
|
||||
});
|
||||
|
||||
const customResources = new Resource({});
|
||||
|
||||
const resources = detectedResources.merge(customResources);
|
||||
|
||||
const meterProvider = new MeterProvider({
|
||||
readers: [exporter],
|
||||
resource: resources,
|
||||
});
|
||||
|
||||
const hostMetrics = new HostMetrics({
|
||||
name: `otel-metrics`,
|
||||
meterProvider,
|
||||
});
|
||||
|
||||
registerInstrumentations({
|
||||
meterProvider,
|
||||
instrumentations: [new HttpInstrumentation(), new RuntimeNodeInstrumentation()],
|
||||
});
|
||||
|
||||
hostMetrics.start();
|
||||
|
||||
process.on("SIGTERM", async () => {
|
||||
try {
|
||||
// Stop collecting metrics or flush them if needed
|
||||
await meterProvider.shutdown();
|
||||
// Possibly close other instrumentation resources
|
||||
} catch (e) {
|
||||
console.error("Error during graceful shutdown:", e);
|
||||
} finally {
|
||||
process.exit(0);
|
||||
}
|
||||
});
|
||||
@@ -1,8 +1,25 @@
|
||||
import { registerOTel } from "@vercel/otel";
|
||||
import { LangfuseExporter } from "langfuse-vercel";
|
||||
import { env } from "@formbricks/lib/env";
|
||||
|
||||
// instrumentation.ts
|
||||
export const register = async () => {
|
||||
if (process.env.NEXT_RUNTIME === "nodejs" && env.PROMETHEUS_ENABLED) {
|
||||
await import("./instrumentation-node");
|
||||
export async function register() {
|
||||
if (env.LANGFUSE_SECRET_KEY && env.LANGFUSE_PUBLIC_KEY && env.LANGFUSE_BASEURL) {
|
||||
registerOTel({
|
||||
serviceName: "formbricks-cloud-dev",
|
||||
traceExporter: new LangfuseExporter({
|
||||
debug: false,
|
||||
secretKey: env.LANGFUSE_SECRET_KEY,
|
||||
publicKey: env.LANGFUSE_PUBLIC_KEY,
|
||||
baseUrl: env.LANGFUSE_BASEURL,
|
||||
}),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||
await import("./sentry.server.config");
|
||||
}
|
||||
|
||||
if (process.env.NEXT_RUNTIME === "edge") {
|
||||
await import("./sentry.edge.config");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,36 +12,22 @@ import {
|
||||
isClientSideApiRoute,
|
||||
isForgotPasswordRoute,
|
||||
isLoginRoute,
|
||||
isManagementApiRoute,
|
||||
isShareUrlRoute,
|
||||
isSignupRoute,
|
||||
isSyncWithUserIdentificationEndpoint,
|
||||
isVerifyEmailRoute,
|
||||
} from "@/app/middleware/endpoint-validator";
|
||||
import { logApiError } from "@/modules/api/v2/lib/utils";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { ipAddress } from "@vercel/functions";
|
||||
import { getToken } from "next-auth/jwt";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { E2E_TESTING, IS_PRODUCTION, RATE_LIMITING_DISABLED, WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { RATE_LIMITING_DISABLED, WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import { isValidCallbackUrl } from "@formbricks/lib/utils/url";
|
||||
|
||||
const enforceHttps = (request: NextRequest): Response | null => {
|
||||
const forwardedProto = request.headers.get("x-forwarded-proto") ?? "http";
|
||||
if (IS_PRODUCTION && !E2E_TESTING && forwardedProto !== "https") {
|
||||
const apiError: ApiErrorResponseV2 = {
|
||||
type: "forbidden",
|
||||
details: [{ field: "", issue: "Only HTTPS connections are allowed on the management endpoint." }],
|
||||
};
|
||||
logApiError(request, apiError);
|
||||
return NextResponse.json(apiError, { status: 403 });
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleAuth = async (request: NextRequest): Promise<Response | null> => {
|
||||
export const middleware = async (request: NextRequest) => {
|
||||
// issue with next auth types; let's review when new fixes are available
|
||||
const token = await getToken({ req: request as any });
|
||||
|
||||
if (isAuthProtectedRoute(request.nextUrl.pathname) && !token) {
|
||||
const loginUrl = `${WEBAPP_URL}/auth/login?callbackUrl=${encodeURIComponent(WEBAPP_URL + request.nextUrl.pathname + request.nextUrl.search)}`;
|
||||
return NextResponse.redirect(loginUrl);
|
||||
@@ -49,62 +35,13 @@ const handleAuth = async (request: NextRequest): Promise<Response | null> => {
|
||||
|
||||
const callbackUrl = request.nextUrl.searchParams.get("callbackUrl");
|
||||
if (callbackUrl && !isValidCallbackUrl(callbackUrl, WEBAPP_URL)) {
|
||||
return NextResponse.json({ error: "Invalid callback URL" }, { status: 400 });
|
||||
return NextResponse.json({ error: "Invalid callback URL" });
|
||||
}
|
||||
if (token && callbackUrl) {
|
||||
return NextResponse.redirect(WEBAPP_URL + callbackUrl);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const applyRateLimiting = (request: NextRequest, ip: string) => {
|
||||
if (isLoginRoute(request.nextUrl.pathname)) {
|
||||
loginLimiter(`login-${ip}`);
|
||||
} else if (isSignupRoute(request.nextUrl.pathname)) {
|
||||
signupLimiter(`signup-${ip}`);
|
||||
} else if (isVerifyEmailRoute(request.nextUrl.pathname)) {
|
||||
verifyEmailLimiter(`verify-email-${ip}`);
|
||||
} else if (isForgotPasswordRoute(request.nextUrl.pathname)) {
|
||||
forgotPasswordLimiter(`forgot-password-${ip}`);
|
||||
} else if (isClientSideApiRoute(request.nextUrl.pathname)) {
|
||||
clientSideApiEndpointsLimiter(`client-side-api-${ip}`);
|
||||
const envIdAndUserId = isSyncWithUserIdentificationEndpoint(request.nextUrl.pathname);
|
||||
if (envIdAndUserId) {
|
||||
const { environmentId, userId } = envIdAndUserId;
|
||||
syncUserIdentificationLimiter(`sync-${environmentId}-${userId}`);
|
||||
}
|
||||
} else if (isShareUrlRoute(request.nextUrl.pathname)) {
|
||||
shareUrlLimiter(`share-${ip}`);
|
||||
}
|
||||
};
|
||||
|
||||
export const middleware = async (originalRequest: NextRequest) => {
|
||||
// Create a new Request object to override headers and add a unique request ID header
|
||||
const request = new NextRequest(originalRequest, {
|
||||
headers: new Headers(originalRequest.headers),
|
||||
});
|
||||
|
||||
request.headers.set("x-request-id", uuidv4());
|
||||
|
||||
// Create a new NextResponse object to forward the new request with headers
|
||||
const nextResponseWithCustomHeader = NextResponse.next({
|
||||
request: {
|
||||
headers: request.headers,
|
||||
},
|
||||
});
|
||||
|
||||
// Enforce HTTPS for management endpoints
|
||||
if (isManagementApiRoute(request.nextUrl.pathname)) {
|
||||
const httpsResponse = enforceHttps(request);
|
||||
if (httpsResponse) return httpsResponse;
|
||||
}
|
||||
|
||||
// Handle authentication
|
||||
const authResponse = await handleAuth(request);
|
||||
if (authResponse) return authResponse;
|
||||
|
||||
if (!IS_PRODUCTION || RATE_LIMITING_DISABLED) {
|
||||
return nextResponseWithCustomHeader;
|
||||
if (process.env.NODE_ENV !== "production" || RATE_LIMITING_DISABLED) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
let ip =
|
||||
@@ -114,19 +51,32 @@ export const middleware = async (originalRequest: NextRequest) => {
|
||||
|
||||
if (ip) {
|
||||
try {
|
||||
applyRateLimiting(request, ip);
|
||||
return nextResponseWithCustomHeader;
|
||||
if (isLoginRoute(request.nextUrl.pathname)) {
|
||||
await loginLimiter(`login-${ip}`);
|
||||
} else if (isSignupRoute(request.nextUrl.pathname)) {
|
||||
await signupLimiter(`signup-${ip}`);
|
||||
} else if (isVerifyEmailRoute(request.nextUrl.pathname)) {
|
||||
await verifyEmailLimiter(`verify-email-${ip}`);
|
||||
} else if (isForgotPasswordRoute(request.nextUrl.pathname)) {
|
||||
await forgotPasswordLimiter(`forgot-password-${ip}`);
|
||||
} else if (isClientSideApiRoute(request.nextUrl.pathname)) {
|
||||
await clientSideApiEndpointsLimiter(`client-side-api-${ip}`);
|
||||
|
||||
const envIdAndUserId = isSyncWithUserIdentificationEndpoint(request.nextUrl.pathname);
|
||||
if (envIdAndUserId) {
|
||||
const { environmentId, userId } = envIdAndUserId;
|
||||
await syncUserIdentificationLimiter(`sync-${environmentId}-${userId}`);
|
||||
}
|
||||
} else if (isShareUrlRoute(request.nextUrl.pathname)) {
|
||||
await shareUrlLimiter(`share-${ip}`);
|
||||
}
|
||||
return NextResponse.next();
|
||||
} catch (e) {
|
||||
const apiError: ApiErrorResponseV2 = {
|
||||
type: "too_many_requests",
|
||||
details: [{ field: "", issue: "Too many requests. Please try again later." }],
|
||||
};
|
||||
logApiError(request, apiError);
|
||||
return NextResponse.json(apiError, { status: 429 });
|
||||
console.log(`Rate Limiting IP: ${ip}`);
|
||||
return NextResponse.json({ error: "Too many requests, Please try after a while!" }, { status: 429 });
|
||||
}
|
||||
}
|
||||
|
||||
return nextResponseWithCustomHeader;
|
||||
return NextResponse.next();
|
||||
};
|
||||
|
||||
export const config = {
|
||||
@@ -144,7 +94,5 @@ export const config = {
|
||||
"/api/packages/:path*",
|
||||
"/auth/verification-requested",
|
||||
"/auth/forgot-password",
|
||||
"/api/v1/management/:path*",
|
||||
"/api/v2/management/:path*",
|
||||
],
|
||||
};
|
||||
|
||||
@@ -8,7 +8,7 @@ export const SurveyLinkDisplay = ({ surveyUrl }: SurveyLinkDisplayProps) => {
|
||||
return (
|
||||
<Input
|
||||
autoFocus={true}
|
||||
className="mt-2 w-full min-w-96 text-ellipsis rounded-lg border bg-white px-4 py-2 text-slate-800 caret-transparent"
|
||||
className="mt-2 w-full min-w-96 text-ellipsis rounded-lg border bg-white px-4 py-2 text-center text-slate-800 caret-transparent"
|
||||
defaultValue={surveyUrl}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useSurveyQRCode } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/survey-qr-code";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { generateSingleUseIdAction } from "@/modules/survey/list/actions";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { Copy, QrCode, RefreshCcw, SquareArrowOutUpRight } from "lucide-react";
|
||||
import { Copy, RefreshCcw, SquareArrowOutUpRight } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
@@ -69,12 +68,10 @@ export const ShareSurveyLink = ({
|
||||
getUrl();
|
||||
}, [survey, getUrl, language]);
|
||||
|
||||
const { downloadQRCode } = useSurveyQRCode(surveyUrl);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex max-w-full flex-col items-center justify-center space-x-2 ${survey.singleUse?.enabled ? "flex-col" : "lg:flex-row"}`}>
|
||||
<SurveyLinkDisplay surveyUrl={surveyUrl} key={surveyUrl} />
|
||||
<SurveyLinkDisplay surveyUrl={surveyUrl} />
|
||||
<div className="mt-2 flex items-center justify-center space-x-2">
|
||||
<LanguageDropdown survey={survey} setLanguage={setLanguage} locale={locale} />
|
||||
<Button
|
||||
@@ -103,14 +100,6 @@ export const ShareSurveyLink = ({
|
||||
{t("common.copy")}
|
||||
<Copy />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
title={t("environments.surveys.summary.download_qr_code")}
|
||||
aria-label={t("environments.surveys.summary.download_qr_code")}
|
||||
size={"icon"}
|
||||
onClick={downloadQRCode}>
|
||||
<QrCode style={{ width: "24px", height: "24px" }} />
|
||||
</Button>
|
||||
{survey.singleUse?.enabled && (
|
||||
<Button
|
||||
title="Regenerate single use survey link"
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { type LimitOptions, Ratelimit, type RatelimitResponse } from "@unkey/ratelimit";
|
||||
import { MANAGEMENT_API_RATE_LIMIT, RATE_LIMITING_DISABLED, UNKEY_ROOT_KEY } from "@formbricks/lib/constants";
|
||||
import { Result, err, okVoid } from "@formbricks/types/error-handlers";
|
||||
|
||||
export type RateLimitHelper = {
|
||||
identifier: string;
|
||||
opts?: LimitOptions;
|
||||
/**
|
||||
* Using a callback instead of a regular return to provide headers even
|
||||
* when the rate limit is reached and an error is thrown.
|
||||
**/
|
||||
onRateLimiterResponse?: (response: RatelimitResponse) => void;
|
||||
};
|
||||
|
||||
let warningDisplayed = false;
|
||||
|
||||
/** Prevent flooding the logs while testing/building */
|
||||
function logOnce(message: string) {
|
||||
if (warningDisplayed) return;
|
||||
console.warn(message);
|
||||
warningDisplayed = true;
|
||||
}
|
||||
|
||||
export function rateLimiter() {
|
||||
if (RATE_LIMITING_DISABLED) {
|
||||
logOnce("Rate limiting disabled");
|
||||
return () => ({ success: true, limit: 10, remaining: 999, reset: 0 }) as RatelimitResponse;
|
||||
}
|
||||
|
||||
if (!UNKEY_ROOT_KEY) {
|
||||
logOnce("Disabled due to not finding UNKEY_ROOT_KEY env variable");
|
||||
return () => ({ success: true, limit: 10, remaining: 999, reset: 0 }) as RatelimitResponse;
|
||||
}
|
||||
const timeout = {
|
||||
fallback: { success: true, limit: 10, remaining: 999, reset: 0 },
|
||||
ms: 5000,
|
||||
};
|
||||
|
||||
const limiter = {
|
||||
api: new Ratelimit({
|
||||
rootKey: UNKEY_ROOT_KEY,
|
||||
namespace: "api",
|
||||
limit: MANAGEMENT_API_RATE_LIMIT.allowedPerInterval,
|
||||
duration: MANAGEMENT_API_RATE_LIMIT.interval * 1000,
|
||||
timeout,
|
||||
}),
|
||||
};
|
||||
|
||||
async function rateLimit({ identifier, opts }: RateLimitHelper) {
|
||||
return await limiter.api.limit(identifier, opts);
|
||||
}
|
||||
|
||||
return rateLimit;
|
||||
}
|
||||
|
||||
export const checkRateLimitAndThrowError = async ({
|
||||
identifier,
|
||||
opts,
|
||||
}: RateLimitHelper): Promise<Result<void, ApiErrorResponseV2>> => {
|
||||
const response = await rateLimiter()({ identifier, opts });
|
||||
const { success } = response;
|
||||
|
||||
if (!success) {
|
||||
return err({
|
||||
type: "too_many_requests",
|
||||
});
|
||||
}
|
||||
return okVoid();
|
||||
};
|
||||
@@ -1,270 +0,0 @@
|
||||
import { ApiErrorDetails, ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { ApiSuccessResponse } from "@/modules/api/v2/types/api-success";
|
||||
|
||||
export type ApiResponse<T> = ApiSuccessResponse<T> | ApiErrorResponseV2;
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
||||
};
|
||||
|
||||
const badRequestResponse = ({
|
||||
details = [],
|
||||
cors = false,
|
||||
cache = "private, no-store",
|
||||
}: {
|
||||
details?: ApiErrorDetails;
|
||||
cors?: boolean;
|
||||
cache?: string;
|
||||
} = {}) => {
|
||||
const headers = {
|
||||
...(cors && corsHeaders),
|
||||
"Cache-Control": cache,
|
||||
};
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
error: {
|
||||
code: 400,
|
||||
message: "Bad Request",
|
||||
details,
|
||||
},
|
||||
},
|
||||
{
|
||||
status: 400,
|
||||
headers,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const unauthorizedResponse = ({
|
||||
cors = false,
|
||||
cache = "private, no-store",
|
||||
}: {
|
||||
cors?: boolean;
|
||||
cache?: string;
|
||||
} = {}) => {
|
||||
const headers = {
|
||||
...(cors && corsHeaders),
|
||||
"Cache-Control": cache,
|
||||
};
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
error: {
|
||||
code: 401,
|
||||
message: "Unauthorized",
|
||||
},
|
||||
},
|
||||
{
|
||||
status: 401,
|
||||
headers,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const forbiddenResponse = ({
|
||||
cors = false,
|
||||
cache = "private, no-store",
|
||||
}: {
|
||||
cors?: boolean;
|
||||
cache?: string;
|
||||
} = {}) => {
|
||||
const headers = {
|
||||
...(cors && corsHeaders),
|
||||
"Cache-Control": cache,
|
||||
};
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
error: {
|
||||
code: 403,
|
||||
message: "Forbidden",
|
||||
},
|
||||
},
|
||||
{
|
||||
status: 403,
|
||||
headers,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const notFoundResponse = ({
|
||||
details = [],
|
||||
cors = false,
|
||||
cache = "private, no-store",
|
||||
}: {
|
||||
details?: ApiErrorDetails;
|
||||
cors?: boolean;
|
||||
cache?: string;
|
||||
}) => {
|
||||
const headers = {
|
||||
...(cors && corsHeaders),
|
||||
"Cache-Control": cache,
|
||||
};
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
error: {
|
||||
code: 404,
|
||||
message: "Not Found",
|
||||
details,
|
||||
},
|
||||
},
|
||||
{
|
||||
status: 404,
|
||||
headers,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const conflictResponse = ({
|
||||
cors = false,
|
||||
cache = "private, no-store",
|
||||
}: {
|
||||
cors?: boolean;
|
||||
cache?: string;
|
||||
} = {}) => {
|
||||
const headers = {
|
||||
...(cors && corsHeaders),
|
||||
"Cache-Control": cache,
|
||||
};
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
error: {
|
||||
code: 409,
|
||||
message: "Conflict",
|
||||
},
|
||||
},
|
||||
{
|
||||
status: 409,
|
||||
headers,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const unprocessableEntityResponse = ({
|
||||
details = [],
|
||||
cors = false,
|
||||
cache = "private, no-store",
|
||||
}: {
|
||||
details: ApiErrorDetails;
|
||||
cors?: boolean;
|
||||
cache?: string;
|
||||
}) => {
|
||||
const headers = {
|
||||
...(cors && corsHeaders),
|
||||
"Cache-Control": cache,
|
||||
};
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
error: {
|
||||
code: 422,
|
||||
message: "Unprocessable Entity",
|
||||
details,
|
||||
},
|
||||
},
|
||||
{
|
||||
status: 422,
|
||||
headers,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const tooManyRequestsResponse = ({
|
||||
cors = false,
|
||||
cache = "private, no-store",
|
||||
}: {
|
||||
cors?: boolean;
|
||||
cache?: string;
|
||||
} = {}) => {
|
||||
const headers = {
|
||||
...(cors && corsHeaders),
|
||||
"Cache-Control": cache,
|
||||
};
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
error: {
|
||||
code: 429,
|
||||
message: "Too Many Requests",
|
||||
},
|
||||
},
|
||||
{
|
||||
status: 429,
|
||||
headers,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const internalServerErrorResponse = ({
|
||||
details = [],
|
||||
cors = false,
|
||||
cache = "private, no-store",
|
||||
}: {
|
||||
details?: ApiErrorDetails;
|
||||
cors?: boolean;
|
||||
cache?: string;
|
||||
} = {}) => {
|
||||
const headers = {
|
||||
...(cors && corsHeaders),
|
||||
"Cache-Control": cache,
|
||||
};
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
error: {
|
||||
code: 500,
|
||||
message: "Internal Server Error",
|
||||
details,
|
||||
},
|
||||
},
|
||||
{
|
||||
status: 500,
|
||||
headers,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const successResponse = ({
|
||||
data,
|
||||
meta,
|
||||
cors = false,
|
||||
cache = "private, no-store",
|
||||
}: {
|
||||
data: Object;
|
||||
meta?: Record<string, unknown>;
|
||||
cors?: boolean;
|
||||
cache?: string;
|
||||
}) => {
|
||||
const headers = {
|
||||
...(cors && corsHeaders),
|
||||
"Cache-Control": cache,
|
||||
};
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
data,
|
||||
meta,
|
||||
} as ApiSuccessResponse,
|
||||
{
|
||||
status: 200,
|
||||
headers,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const responses = {
|
||||
badRequestResponse,
|
||||
unauthorizedResponse,
|
||||
forbiddenResponse,
|
||||
notFoundResponse,
|
||||
conflictResponse,
|
||||
unprocessableEntityResponse,
|
||||
tooManyRequestsResponse,
|
||||
internalServerErrorResponse,
|
||||
successResponse,
|
||||
};
|
||||
@@ -1,107 +0,0 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
vi.mock("@unkey/ratelimit", () => ({
|
||||
Ratelimit: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("when rate limiting is disabled", () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
const constants = await vi.importActual("@formbricks/lib/constants");
|
||||
vi.doMock("@formbricks/lib/constants", () => ({
|
||||
...constants,
|
||||
MANAGEMENT_API_RATE_LIMIT: { allowedPerInterval: 5, interval: 60 },
|
||||
RATE_LIMITING_DISABLED: true,
|
||||
}));
|
||||
});
|
||||
|
||||
test("should log a warning once and return a stubbed response", async () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const { rateLimiter } = await import("@/modules/api/v2/lib/rate-limit");
|
||||
|
||||
const res1 = await rateLimiter()({ identifier: "test-id" });
|
||||
expect(res1).toEqual({ success: true, limit: 10, remaining: 999, reset: 0 });
|
||||
expect(warnSpy).toHaveBeenCalledWith("Rate limiting disabled");
|
||||
|
||||
// Subsequent calls won't log again.
|
||||
await rateLimiter()({ identifier: "another-id" });
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("when UNKEY_ROOT_KEY is missing", () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
const constants = await vi.importActual("@formbricks/lib/constants");
|
||||
vi.doMock("@formbricks/lib/constants", () => ({
|
||||
...constants,
|
||||
MANAGEMENT_API_RATE_LIMIT: { allowedPerInterval: 5, interval: 60 },
|
||||
RATE_LIMITING_DISABLED: false,
|
||||
UNKEY_ROOT_KEY: "",
|
||||
}));
|
||||
});
|
||||
|
||||
test("should log a warning about missing UNKEY_ROOT_KEY and return stub response", async () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const { rateLimiter } = await import("@/modules/api/v2/lib/rate-limit");
|
||||
const limiterFunc = rateLimiter();
|
||||
|
||||
const res = await limiterFunc({ identifier: "test-id" });
|
||||
expect(res).toEqual({ success: true, limit: 10, remaining: 999, reset: 0 });
|
||||
expect(warnSpy).toHaveBeenCalledWith("Disabled due to not finding UNKEY_ROOT_KEY env variable");
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("when rate limiting is active (enabled)", () => {
|
||||
const mockResponse = { success: true, limit: 5, remaining: 2, reset: 1000 };
|
||||
let limitMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
const constants = await vi.importActual("@formbricks/lib/constants");
|
||||
vi.doMock("@formbricks/lib/constants", () => ({
|
||||
...constants,
|
||||
MANAGEMENT_API_RATE_LIMIT: { allowedPerInterval: 5, interval: 60 },
|
||||
RATE_LIMITING_DISABLED: false,
|
||||
UNKEY_ROOT_KEY: "valid-key",
|
||||
}));
|
||||
|
||||
limitMock = vi.fn().mockResolvedValue(mockResponse);
|
||||
const RatelimitMock = vi.fn().mockImplementation(() => {
|
||||
return { limit: limitMock };
|
||||
});
|
||||
vi.doMock("@unkey/ratelimit", () => ({
|
||||
Ratelimit: RatelimitMock,
|
||||
}));
|
||||
});
|
||||
|
||||
test("should create a rate limiter that calls the limit method with the proper arguments", async () => {
|
||||
const { rateLimiter } = await import("../rate-limit");
|
||||
const limiterFunc = rateLimiter();
|
||||
const res = await limiterFunc({ identifier: "abc", opts: { cost: 1 } });
|
||||
expect(limitMock).toHaveBeenCalledWith("abc", { cost: 1 });
|
||||
expect(res).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
test("checkRateLimitAndThrowError returns okVoid when rate limit is not exceeded", async () => {
|
||||
limitMock.mockResolvedValueOnce({ success: true, limit: 5, remaining: 3, reset: 1000 });
|
||||
|
||||
const { checkRateLimitAndThrowError } = await import("../rate-limit");
|
||||
const result = await checkRateLimitAndThrowError({ identifier: "abc" });
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
test("checkRateLimitAndThrowError returns an error when the rate limit is exceeded", async () => {
|
||||
limitMock.mockResolvedValueOnce({ success: false, limit: 5, remaining: 0, reset: 1000 });
|
||||
|
||||
const { checkRateLimitAndThrowError } = await import("../rate-limit");
|
||||
const result = await checkRateLimitAndThrowError({ identifier: "abc" });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({ type: "too_many_requests" });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,183 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { responses } from "../response";
|
||||
|
||||
describe("API Responses", () => {
|
||||
describe("badRequestResponse", () => {
|
||||
test("return a 400 response with error details", async () => {
|
||||
const details = [{ field: "param", issue: "invalid" }];
|
||||
const res = responses.badRequestResponse({ details });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.headers.get("Cache-Control")).toBe("private, no-store");
|
||||
const body = await res.json();
|
||||
expect(body).toEqual({
|
||||
error: {
|
||||
code: 400,
|
||||
message: "Bad Request",
|
||||
details,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("include CORS headers when cors is true", () => {
|
||||
const res = responses.badRequestResponse({ cors: true });
|
||||
expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
|
||||
});
|
||||
});
|
||||
|
||||
describe("unauthorizedResponse", () => {
|
||||
test("return a 401 response with the proper error message", async () => {
|
||||
const res = responses.unauthorizedResponse();
|
||||
expect(res.status).toBe(401);
|
||||
const body = await res.json();
|
||||
expect(body).toEqual({
|
||||
error: {
|
||||
code: 401,
|
||||
message: "Unauthorized",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("include CORS headers when cors is true", () => {
|
||||
const res = responses.unauthorizedResponse({ cors: true });
|
||||
expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
|
||||
});
|
||||
});
|
||||
|
||||
describe("forbiddenResponse", () => {
|
||||
test("return a 403 response", async () => {
|
||||
const res = responses.forbiddenResponse();
|
||||
expect(res.status).toBe(403);
|
||||
const body = await res.json();
|
||||
expect(body).toEqual({
|
||||
error: {
|
||||
code: 403,
|
||||
message: "Forbidden",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("include CORS headers when cors is true", () => {
|
||||
const res = responses.forbiddenResponse({ cors: true });
|
||||
expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
|
||||
});
|
||||
});
|
||||
|
||||
describe("notFoundResponse", () => {
|
||||
test("return a 404 response with error details", async () => {
|
||||
const details = [{ field: "resource", issue: "not found" }];
|
||||
const res = responses.notFoundResponse({ details });
|
||||
expect(res.status).toBe(404);
|
||||
const body = await res.json();
|
||||
expect(body).toEqual({
|
||||
error: {
|
||||
code: 404,
|
||||
message: "Not Found",
|
||||
details,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("include CORS headers when cors is true", () => {
|
||||
const res = responses.notFoundResponse({ cors: true });
|
||||
expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
|
||||
});
|
||||
});
|
||||
|
||||
describe("conflictResponse", () => {
|
||||
test("return a 409 response", async () => {
|
||||
const res = responses.conflictResponse();
|
||||
expect(res.status).toBe(409);
|
||||
const body = await res.json();
|
||||
expect(body).toEqual({
|
||||
error: {
|
||||
code: 409,
|
||||
message: "Conflict",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("include CORS headers when cors is true", () => {
|
||||
const res = responses.conflictResponse({ cors: true });
|
||||
expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
|
||||
});
|
||||
});
|
||||
|
||||
describe("unprocessableEntityResponse", () => {
|
||||
test("return a 422 response with error details", async () => {
|
||||
const details = [{ field: "data", issue: "malformed" }];
|
||||
const res = responses.unprocessableEntityResponse({ details });
|
||||
expect(res.status).toBe(422);
|
||||
const body = await res.json();
|
||||
expect(body).toEqual({
|
||||
error: {
|
||||
code: 422,
|
||||
message: "Unprocessable Entity",
|
||||
details,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("include CORS headers when cors is true", () => {
|
||||
const res = responses.unprocessableEntityResponse({ cors: true });
|
||||
expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
|
||||
});
|
||||
});
|
||||
|
||||
describe("tooManyRequestsResponse", () => {
|
||||
test("return a 429 response", async () => {
|
||||
const res = responses.tooManyRequestsResponse();
|
||||
expect(res.status).toBe(429);
|
||||
const body = await res.json();
|
||||
expect(body).toEqual({
|
||||
error: {
|
||||
code: 429,
|
||||
message: "Too Many Requests",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("include CORS headers when cors is true", () => {
|
||||
const res = responses.tooManyRequestsResponse({ cors: true });
|
||||
expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
|
||||
});
|
||||
});
|
||||
|
||||
describe("internalServerErrorResponse", () => {
|
||||
test("return a 500 response with error details", async () => {
|
||||
const details = [{ field: "server", issue: "crashed" }];
|
||||
const res = responses.internalServerErrorResponse({ details });
|
||||
expect(res.status).toBe(500);
|
||||
const body = await res.json();
|
||||
expect(body).toEqual({
|
||||
error: {
|
||||
code: 500,
|
||||
message: "Internal Server Error",
|
||||
details,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("include CORS headers when cors is true", () => {
|
||||
const res = responses.internalServerErrorResponse({ cors: true });
|
||||
expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
|
||||
});
|
||||
});
|
||||
|
||||
describe("successResponse", () => {
|
||||
test("return a success response with the provided data", async () => {
|
||||
const data = { foo: "bar" };
|
||||
const meta = { page: 1 };
|
||||
const res = responses.successResponse({ data, meta });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.data).toEqual(data);
|
||||
expect(body.meta).toEqual(meta);
|
||||
});
|
||||
|
||||
test("include CORS headers when cors is true", () => {
|
||||
const data = { foo: "bar" };
|
||||
const res = responses.successResponse({ data, cors: true });
|
||||
expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,201 +0,0 @@
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { ZodError } from "zod";
|
||||
import { formatZodError, handleApiError, logApiError, logApiRequest } from "../utils";
|
||||
|
||||
const mockRequest = new Request("http://localhost");
|
||||
|
||||
// Add the request id header
|
||||
mockRequest.headers.set("x-request-id", "123");
|
||||
|
||||
describe("utils", () => {
|
||||
describe("handleApiError", () => {
|
||||
test('return bad request response for "bad_request" error', async () => {
|
||||
const details = [{ field: "param", issue: "invalid" }];
|
||||
const error: ApiErrorResponseV2 = { type: "bad_request", details };
|
||||
|
||||
const response = handleApiError(mockRequest, error);
|
||||
expect(response.status).toBe(400);
|
||||
const body = await response.json();
|
||||
expect(body.error.code).toBe(400);
|
||||
expect(body.error.message).toBe("Bad Request");
|
||||
expect(body.error.details).toEqual(details);
|
||||
});
|
||||
|
||||
test('return unauthorized response for "unauthorized" error', async () => {
|
||||
const error: ApiErrorResponseV2 = { type: "unauthorized" };
|
||||
const response = handleApiError(mockRequest, error);
|
||||
expect(response.status).toBe(401);
|
||||
const body = await response.json();
|
||||
expect(body.error.code).toBe(401);
|
||||
expect(body.error.message).toBe("Unauthorized");
|
||||
});
|
||||
|
||||
test('return forbidden response for "forbidden" error', async () => {
|
||||
const error: ApiErrorResponseV2 = { type: "forbidden" };
|
||||
const response = handleApiError(mockRequest, error);
|
||||
expect(response.status).toBe(403);
|
||||
const body = await response.json();
|
||||
expect(body.error.code).toBe(403);
|
||||
expect(body.error.message).toBe("Forbidden");
|
||||
});
|
||||
|
||||
test('return not found response for "not_found" error', async () => {
|
||||
const details = [{ field: "resource", issue: "not found" }];
|
||||
const error: ApiErrorResponseV2 = { type: "not_found", details };
|
||||
|
||||
const response = handleApiError(mockRequest, error);
|
||||
expect(response.status).toBe(404);
|
||||
const body = await response.json();
|
||||
expect(body.error.code).toBe(404);
|
||||
expect(body.error.message).toBe("Not Found");
|
||||
expect(body.error.details).toEqual(details);
|
||||
});
|
||||
|
||||
test('return conflict response for "conflict" error', async () => {
|
||||
const error: ApiErrorResponseV2 = { type: "conflict" };
|
||||
const response = handleApiError(mockRequest, error);
|
||||
expect(response.status).toBe(409);
|
||||
const body = await response.json();
|
||||
expect(body.error.code).toBe(409);
|
||||
expect(body.error.message).toBe("Conflict");
|
||||
});
|
||||
|
||||
test('return unprocessable entity response for "unprocessable_entity" error', async () => {
|
||||
const details = [{ field: "data", issue: "malformed" }];
|
||||
const error: ApiErrorResponseV2 = { type: "unprocessable_entity", details };
|
||||
|
||||
const response = handleApiError(mockRequest, error);
|
||||
expect(response.status).toBe(422);
|
||||
const body = await response.json();
|
||||
expect(body.error.code).toBe(422);
|
||||
expect(body.error.message).toBe("Unprocessable Entity");
|
||||
expect(body.error.details).toEqual(details);
|
||||
});
|
||||
|
||||
test('return too many requests response for "too_many_requests" error', async () => {
|
||||
const error: ApiErrorResponseV2 = { type: "too_many_requests" };
|
||||
const response = handleApiError(mockRequest, error);
|
||||
expect(response.status).toBe(429);
|
||||
const body = await response.json();
|
||||
expect(body.error.code).toBe(429);
|
||||
expect(body.error.message).toBe("Too Many Requests");
|
||||
});
|
||||
|
||||
test('return internal server error response for "internal_server_error" error with default message', async () => {
|
||||
const details = [{ field: "server", issue: "error occurred" }];
|
||||
const error: ApiErrorResponseV2 = { type: "internal_server_error", details };
|
||||
|
||||
const response = handleApiError(mockRequest, error);
|
||||
expect(response.status).toBe(500);
|
||||
const body = await response.json();
|
||||
expect(body.error.code).toBe(500);
|
||||
expect(body.error.message).toBe("Internal Server Error");
|
||||
expect(body.error.details).toEqual([
|
||||
{ field: "error", issue: "An error occurred while processing your request. Please try again later." },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatZodError", () => {
|
||||
test("correctly format a Zod error", () => {
|
||||
const zodError = {
|
||||
issues: [
|
||||
{
|
||||
path: ["field1"],
|
||||
message: "Invalid value for field1",
|
||||
},
|
||||
{
|
||||
path: ["field2", "subfield"],
|
||||
message: "Field2 subfield is required",
|
||||
},
|
||||
],
|
||||
} as ZodError;
|
||||
|
||||
const formatted = formatZodError(zodError);
|
||||
expect(formatted).toEqual([
|
||||
{ field: "field1", issue: "Invalid value for field1" },
|
||||
{ field: "field2.subfield", issue: "Field2 subfield is required" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("return an empty array if there are no issues", () => {
|
||||
const zodError = { issues: [] } as unknown as ZodError;
|
||||
const formatted = formatZodError(zodError);
|
||||
expect(formatted).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("logApiRequest", () => {
|
||||
test("logs API request details", () => {
|
||||
const consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
const mockRequest = new Request("http://localhost/api/test?apikey=123&token=abc&safeParam=value");
|
||||
mockRequest.headers.set("x-request-id", "123");
|
||||
|
||||
logApiRequest(mockRequest, 200, 100);
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
`[API REQUEST DETAILS] GET /api/test - 200 - 100ms\n correlationId: 123\n queryParams: {"safeParam":"value"}`
|
||||
);
|
||||
|
||||
consoleLogSpy.mockRestore();
|
||||
});
|
||||
|
||||
test("logs API request details without correlationId and without safe query params", () => {
|
||||
const consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
const mockRequest = new Request("http://localhost/api/test?apikey=123&token=abc");
|
||||
mockRequest.headers.delete("x-request-id");
|
||||
|
||||
logApiRequest(mockRequest, 200, 100);
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
`[API REQUEST DETAILS] GET /api/test - 200 - 100ms\n queryParams: {}`
|
||||
);
|
||||
|
||||
consoleLogSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("logApiError", () => {
|
||||
test("logs API error details", () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
const mockRequest = new Request("http://localhost/api/test");
|
||||
mockRequest.headers.set("x-request-id", "123");
|
||||
|
||||
const error: ApiErrorResponseV2 = {
|
||||
type: "internal_server_error",
|
||||
details: [{ field: "server", issue: "error occurred" }],
|
||||
};
|
||||
|
||||
logApiError(mockRequest, error);
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
`[API ERROR DETAILS]\n correlationId: 123\n error: ${JSON.stringify(error, null, 2)}`
|
||||
);
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
test("logs API error details without correlationId", () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
const mockRequest = new Request("http://localhost/api/test");
|
||||
mockRequest.headers.delete("x-request-id");
|
||||
|
||||
const error: ApiErrorResponseV2 = {
|
||||
type: "internal_server_error",
|
||||
details: [{ field: "server", issue: "error occurred" }],
|
||||
};
|
||||
|
||||
logApiError(mockRequest, error);
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
`[API ERROR DETAILS]\n error: ${JSON.stringify(error, null, 2)}`
|
||||
);
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,65 +0,0 @@
|
||||
import { responses } from "@/modules/api/v2/lib/response";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { ZodError } from "zod";
|
||||
|
||||
export const handleApiError = (request: Request, err: ApiErrorResponseV2): Response => {
|
||||
logApiError(request, err);
|
||||
|
||||
switch (err.type) {
|
||||
case "bad_request":
|
||||
return responses.badRequestResponse({ details: err.details });
|
||||
case "unauthorized":
|
||||
return responses.unauthorizedResponse();
|
||||
case "forbidden":
|
||||
return responses.forbiddenResponse();
|
||||
case "not_found":
|
||||
return responses.notFoundResponse({ details: err.details });
|
||||
case "conflict":
|
||||
return responses.conflictResponse();
|
||||
case "unprocessable_entity":
|
||||
return responses.unprocessableEntityResponse({ details: err.details });
|
||||
case "too_many_requests":
|
||||
return responses.tooManyRequestsResponse();
|
||||
default:
|
||||
// Replace with a generic error message, because we don't want to expose internal errors to API users.
|
||||
return responses.internalServerErrorResponse({
|
||||
details: [
|
||||
{
|
||||
field: "error",
|
||||
issue: "An error occurred while processing your request. Please try again later.",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const formatZodError = (error: ZodError) => {
|
||||
return error.issues.map((issue) => ({
|
||||
field: issue.path.join("."),
|
||||
issue: issue.message,
|
||||
}));
|
||||
};
|
||||
|
||||
export const logApiRequest = (request: Request, responseStatus: number, duration: number): void => {
|
||||
const method = request.method;
|
||||
const url = new URL(request.url);
|
||||
const path = url.pathname;
|
||||
const correlationId = request.headers.get("x-request-id") || "";
|
||||
const queryParams = Object.fromEntries(url.searchParams.entries());
|
||||
|
||||
const sensitiveParams = ["apikey", "token", "secret"];
|
||||
const safeQueryParams = Object.fromEntries(
|
||||
Object.entries(queryParams).filter(([key]) => !sensitiveParams.includes(key.toLowerCase()))
|
||||
);
|
||||
|
||||
console.log(
|
||||
`[API REQUEST DETAILS] ${method} ${path} - ${responseStatus} - ${duration}ms${correlationId ? `\n correlationId: ${correlationId}` : ""}\n queryParams: ${JSON.stringify(safeQueryParams)}`
|
||||
);
|
||||
};
|
||||
|
||||
export const logApiError = (request: Request, error: ApiErrorResponseV2): void => {
|
||||
const correlationId = request.headers.get("x-request-id") || "";
|
||||
console.error(
|
||||
`[API ERROR DETAILS]${correlationId ? `\n correlationId: ${correlationId}` : ""}\n error: ${JSON.stringify(error, null, 2)}`
|
||||
);
|
||||
};
|
||||
@@ -1,106 +0,0 @@
|
||||
import { checkRateLimitAndThrowError } from "@/modules/api/v2/lib/rate-limit";
|
||||
import { formatZodError, handleApiError } from "@/modules/api/v2/lib/utils";
|
||||
import { ZodRawShape, z } from "zod";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { err } from "@formbricks/types/error-handlers";
|
||||
import { authenticateRequest } from "./authenticate-request";
|
||||
|
||||
export type HandlerFn<TInput = Record<string, unknown>> = ({
|
||||
authentication,
|
||||
parsedInput,
|
||||
request,
|
||||
}: {
|
||||
authentication: TAuthenticationApiKey;
|
||||
parsedInput: TInput;
|
||||
request: Request;
|
||||
}) => Promise<Response>;
|
||||
|
||||
export type ExtendedSchemas = {
|
||||
body?: z.ZodObject<ZodRawShape>;
|
||||
query?: z.ZodObject<ZodRawShape>;
|
||||
params?: z.ZodObject<ZodRawShape>;
|
||||
};
|
||||
|
||||
// Define a type that returns separate keys for each input type.
|
||||
export type ParsedSchemas<S extends ExtendedSchemas | undefined> = {
|
||||
body?: S extends { body: z.ZodObject<any> } ? z.infer<S["body"]> : undefined;
|
||||
query?: S extends { query: z.ZodObject<any> } ? z.infer<S["query"]> : undefined;
|
||||
params?: S extends { params: z.ZodObject<any> } ? z.infer<S["params"]> : undefined;
|
||||
};
|
||||
|
||||
export const apiWrapper = async <S extends ExtendedSchemas>({
|
||||
request,
|
||||
schemas,
|
||||
externalParams,
|
||||
rateLimit = true,
|
||||
handler,
|
||||
}: {
|
||||
request: Request;
|
||||
schemas?: S;
|
||||
externalParams?: Promise<Record<string, any>>;
|
||||
rateLimit?: boolean;
|
||||
handler: HandlerFn<ParsedSchemas<S>>;
|
||||
}): Promise<Response> => {
|
||||
try {
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication.ok) throw authentication.error;
|
||||
|
||||
let parsedInput: ParsedSchemas<S> = {} as ParsedSchemas<S>;
|
||||
|
||||
if (schemas?.body) {
|
||||
const bodyData = await request.json();
|
||||
const bodyResult = schemas.body.safeParse(bodyData);
|
||||
|
||||
if (!bodyResult.success) {
|
||||
throw err({
|
||||
type: "forbidden",
|
||||
details: formatZodError(bodyResult.error),
|
||||
});
|
||||
}
|
||||
parsedInput.body = bodyResult.data as ParsedSchemas<S>["body"];
|
||||
}
|
||||
|
||||
if (schemas?.query) {
|
||||
const url = new URL(request.url);
|
||||
const queryObject = Object.fromEntries(url.searchParams.entries());
|
||||
const queryResult = schemas.query.safeParse(queryObject);
|
||||
if (!queryResult.success) {
|
||||
throw err({
|
||||
type: "unprocessable_entity",
|
||||
details: formatZodError(queryResult.error),
|
||||
});
|
||||
}
|
||||
parsedInput.query = queryResult.data as ParsedSchemas<S>["query"];
|
||||
}
|
||||
|
||||
if (schemas?.params) {
|
||||
const paramsObject = (await externalParams) || {};
|
||||
console.log("paramsObject: ", paramsObject);
|
||||
const paramsResult = schemas.params.safeParse(paramsObject);
|
||||
if (!paramsResult.success) {
|
||||
throw err({
|
||||
type: "unprocessable_entity",
|
||||
details: formatZodError(paramsResult.error),
|
||||
});
|
||||
}
|
||||
parsedInput.params = paramsResult.data as ParsedSchemas<S>["params"];
|
||||
}
|
||||
|
||||
if (rateLimit) {
|
||||
const rateLimitResponse = await checkRateLimitAndThrowError({
|
||||
identifier: authentication.data.hashedApiKey,
|
||||
});
|
||||
if (!rateLimitResponse.ok) {
|
||||
throw rateLimitResponse.error;
|
||||
}
|
||||
}
|
||||
|
||||
return handler({
|
||||
authentication: authentication.data,
|
||||
parsedInput,
|
||||
request,
|
||||
});
|
||||
} catch (err) {
|
||||
return handleApiError(request, err);
|
||||
}
|
||||
};
|
||||
@@ -1,33 +0,0 @@
|
||||
import { getEnvironmentIdFromApiKey } from "@/modules/api/v2/management/lib/api-key";
|
||||
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
|
||||
export const authenticateRequest = async (
|
||||
request: Request
|
||||
): Promise<Result<TAuthenticationApiKey, ApiErrorResponseV2>> => {
|
||||
const apiKey = request.headers.get("x-api-key");
|
||||
if (apiKey) {
|
||||
const environmentIdResult = await getEnvironmentIdFromApiKey(apiKey);
|
||||
if (!environmentIdResult.ok) {
|
||||
return err(environmentIdResult.error);
|
||||
}
|
||||
const environmentId = environmentIdResult.data;
|
||||
const hashedApiKey = hashApiKey(apiKey);
|
||||
if (environmentId) {
|
||||
const authentication: TAuthenticationApiKey = {
|
||||
type: "apiKey",
|
||||
environmentId,
|
||||
hashedApiKey,
|
||||
};
|
||||
return ok(authentication);
|
||||
}
|
||||
return err({
|
||||
type: "forbidden",
|
||||
});
|
||||
}
|
||||
return err({
|
||||
type: "unauthorized",
|
||||
});
|
||||
};
|
||||
@@ -1,32 +0,0 @@
|
||||
import { logApiRequest } from "@/modules/api/v2/lib/utils";
|
||||
import { ExtendedSchemas, HandlerFn, ParsedSchemas, apiWrapper } from "./api-wrapper";
|
||||
|
||||
export const authenticatedApiClient = async <S extends ExtendedSchemas>({
|
||||
request,
|
||||
schemas,
|
||||
externalParams,
|
||||
rateLimit = true,
|
||||
handler,
|
||||
}: {
|
||||
request: Request;
|
||||
schemas?: S;
|
||||
externalParams?: Promise<Record<string, any>>;
|
||||
rateLimit?: boolean;
|
||||
handler: HandlerFn<ParsedSchemas<S>>;
|
||||
}): Promise<Response> => {
|
||||
const startTime = Date.now();
|
||||
|
||||
const response = await apiWrapper({
|
||||
request,
|
||||
schemas,
|
||||
externalParams,
|
||||
rateLimit,
|
||||
handler,
|
||||
});
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
logApiRequest(request, response.status, duration);
|
||||
|
||||
return response;
|
||||
};
|
||||
@@ -1,18 +0,0 @@
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { Result, err, okVoid } from "@formbricks/types/error-handlers";
|
||||
|
||||
export const checkAuthorization = ({
|
||||
authentication,
|
||||
environmentId,
|
||||
}: {
|
||||
authentication: TAuthenticationApiKey;
|
||||
environmentId: string;
|
||||
}): Result<void, ApiErrorResponseV2> => {
|
||||
if (authentication.type === "apiKey" && authentication.environmentId !== environmentId) {
|
||||
return err({
|
||||
type: "unauthorized",
|
||||
});
|
||||
}
|
||||
return okVoid();
|
||||
};
|
||||
@@ -1,300 +0,0 @@
|
||||
import { checkRateLimitAndThrowError } from "@/modules/api/v2/lib/rate-limit";
|
||||
import { handleApiError } from "@/modules/api/v2/lib/utils";
|
||||
import { apiWrapper } from "@/modules/api/v2/management/auth/api-wrapper";
|
||||
import { authenticateRequest } from "@/modules/api/v2/management/auth/authenticate-request";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { err, ok, okVoid } from "@formbricks/types/error-handlers";
|
||||
|
||||
vi.mock("../authenticate-request", () => ({
|
||||
authenticateRequest: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/api/v2/lib/rate-limit", () => ({
|
||||
checkRateLimitAndThrowError: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/api/v2/lib/utils", () => ({
|
||||
handleApiError: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("apiWrapper", () => {
|
||||
it("should handle request and return response", async () => {
|
||||
const request = new Request("http://localhost", {
|
||||
headers: { "x-api-key": "valid-api-key" },
|
||||
});
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(
|
||||
ok({
|
||||
type: "apiKey",
|
||||
environmentId: "env-id",
|
||||
hashedApiKey: "hashed-api-key",
|
||||
})
|
||||
);
|
||||
vi.mocked(checkRateLimitAndThrowError).mockResolvedValue(okVoid());
|
||||
|
||||
const handler = vi.fn().mockResolvedValue(new Response("ok", { status: 200 }));
|
||||
const response = await apiWrapper({
|
||||
request,
|
||||
handler,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(handler).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle errors and return error response", async () => {
|
||||
const request = new Request("http://localhost", {
|
||||
headers: { "x-api-key": "invalid-api-key" },
|
||||
});
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(err({ type: "unauthorized" }));
|
||||
vi.mocked(handleApiError).mockResolvedValue(new Response("error", { status: 401 }));
|
||||
|
||||
const handler = vi.fn();
|
||||
const response = await apiWrapper({
|
||||
request,
|
||||
handler,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should parse body schema correctly", async () => {
|
||||
const request = new Request("http://localhost", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ key: "value" }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(
|
||||
ok({
|
||||
type: "apiKey",
|
||||
environmentId: "env-id",
|
||||
hashedApiKey: "hashed-api-key",
|
||||
})
|
||||
);
|
||||
|
||||
const bodySchema = z.object({ key: z.string() });
|
||||
const handler = vi.fn().mockResolvedValue(new Response("ok", { status: 200 }));
|
||||
|
||||
const response = await apiWrapper({
|
||||
request,
|
||||
schemas: { body: bodySchema },
|
||||
rateLimit: false,
|
||||
handler,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(handler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
parsedInput: { body: { key: "value" } },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle body schema errors", async () => {
|
||||
const request = new Request("http://localhost", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ key: 123 }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(
|
||||
ok({
|
||||
type: "apiKey",
|
||||
environmentId: "env-id",
|
||||
hashedApiKey: "hashed-api-key",
|
||||
})
|
||||
);
|
||||
|
||||
vi.mocked(handleApiError).mockResolvedValue(new Response("error", { status: 400 }));
|
||||
|
||||
const bodySchema = z.object({ key: z.string() });
|
||||
const handler = vi.fn();
|
||||
|
||||
const response = await apiWrapper({
|
||||
request,
|
||||
schemas: { body: bodySchema },
|
||||
rateLimit: false,
|
||||
handler,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should parse query schema correctly", async () => {
|
||||
const request = new Request("http://localhost?key=value");
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(
|
||||
ok({
|
||||
type: "apiKey",
|
||||
environmentId: "env-id",
|
||||
hashedApiKey: "hashed-api-key",
|
||||
})
|
||||
);
|
||||
|
||||
const querySchema = z.object({ key: z.string() });
|
||||
const handler = vi.fn().mockResolvedValue(new Response("ok", { status: 200 }));
|
||||
|
||||
const response = await apiWrapper({
|
||||
request,
|
||||
schemas: { query: querySchema },
|
||||
rateLimit: false,
|
||||
handler,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(handler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
parsedInput: { query: { key: "value" } },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle query schema errors", async () => {
|
||||
const request = new Request("http://localhost?foo%ZZ=abc");
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(
|
||||
ok({
|
||||
type: "apiKey",
|
||||
environmentId: "env-id",
|
||||
hashedApiKey: "hashed-api-key",
|
||||
})
|
||||
);
|
||||
|
||||
vi.mocked(handleApiError).mockResolvedValue(new Response("error", { status: 400 }));
|
||||
|
||||
const querySchema = z.object({ key: z.string() });
|
||||
const handler = vi.fn();
|
||||
|
||||
const response = await apiWrapper({
|
||||
request,
|
||||
schemas: { query: querySchema },
|
||||
rateLimit: false,
|
||||
handler,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should parse params schema correctly", async () => {
|
||||
const request = new Request("http://localhost");
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(
|
||||
ok({
|
||||
type: "apiKey",
|
||||
environmentId: "env-id",
|
||||
hashedApiKey: "hashed-api-key",
|
||||
})
|
||||
);
|
||||
|
||||
const paramsSchema = z.object({ key: z.string() });
|
||||
const handler = vi.fn().mockResolvedValue(new Response("ok", { status: 200 }));
|
||||
|
||||
const response = await apiWrapper({
|
||||
request,
|
||||
schemas: { params: paramsSchema },
|
||||
externalParams: Promise.resolve({ key: "value" }),
|
||||
rateLimit: false,
|
||||
handler,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(handler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
parsedInput: { params: { key: "value" } },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle no external params", async () => {
|
||||
const request = new Request("http://localhost");
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(
|
||||
ok({
|
||||
type: "apiKey",
|
||||
environmentId: "env-id",
|
||||
hashedApiKey: "hashed-api-key",
|
||||
})
|
||||
);
|
||||
|
||||
vi.mocked(handleApiError).mockResolvedValue(new Response("error", { status: 400 }));
|
||||
|
||||
const paramsSchema = z.object({ key: z.string() });
|
||||
const handler = vi.fn();
|
||||
|
||||
const response = await apiWrapper({
|
||||
request,
|
||||
schemas: { params: paramsSchema },
|
||||
externalParams: undefined,
|
||||
rateLimit: false,
|
||||
handler,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle params schema errors", async () => {
|
||||
const request = new Request("http://localhost");
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(
|
||||
ok({
|
||||
type: "apiKey",
|
||||
environmentId: "env-id",
|
||||
hashedApiKey: "hashed-api-key",
|
||||
})
|
||||
);
|
||||
|
||||
vi.mocked(handleApiError).mockResolvedValue(new Response("error", { status: 400 }));
|
||||
|
||||
const paramsSchema = z.object({ key: z.string() });
|
||||
const handler = vi.fn();
|
||||
|
||||
const response = await apiWrapper({
|
||||
request,
|
||||
schemas: { params: paramsSchema },
|
||||
externalParams: Promise.resolve({ notKey: "value" }),
|
||||
rateLimit: false,
|
||||
handler,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle rate limit errors", async () => {
|
||||
const request = new Request("http://localhost", {
|
||||
headers: { "x-api-key": "valid-api-key" },
|
||||
});
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(
|
||||
ok({
|
||||
type: "apiKey",
|
||||
environmentId: "env-id",
|
||||
hashedApiKey: "hashed-api-key",
|
||||
})
|
||||
);
|
||||
vi.mocked(checkRateLimitAndThrowError).mockResolvedValue(
|
||||
err({ type: "rateLimitExceeded" } as unknown as ApiErrorResponseV2)
|
||||
);
|
||||
vi.mocked(handleApiError).mockImplementation(
|
||||
(_request: Request, _error: ApiErrorResponseV2): Response =>
|
||||
new Response("rate limit exceeded", { status: 429 })
|
||||
);
|
||||
|
||||
const handler = vi.fn();
|
||||
const response = await apiWrapper({
|
||||
request,
|
||||
handler,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(429);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,73 +0,0 @@
|
||||
import { getEnvironmentIdFromApiKey } from "@/modules/api/v2/management/lib/api-key";
|
||||
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { err, ok } from "@formbricks/types/error-handlers";
|
||||
import { authenticateRequest } from "../authenticate-request";
|
||||
|
||||
vi.mock("@/modules/api/v2/management/lib/api-key", () => ({
|
||||
getEnvironmentIdFromApiKey: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/api/v2/management/lib/utils", () => ({
|
||||
hashApiKey: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("authenticateRequest", () => {
|
||||
it("should return authentication data if apiKey is valid", async () => {
|
||||
const request = new Request("http://localhost", {
|
||||
headers: { "x-api-key": "valid-api-key" },
|
||||
});
|
||||
vi.mocked(getEnvironmentIdFromApiKey).mockResolvedValue(ok("env-id"));
|
||||
vi.mocked(hashApiKey).mockReturnValue("hashed-api-key");
|
||||
|
||||
const result = await authenticateRequest(request);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toEqual({
|
||||
type: "apiKey",
|
||||
environmentId: "env-id",
|
||||
hashedApiKey: "hashed-api-key",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("should return forbidden error if environmentId is not found", async () => {
|
||||
const request = new Request("http://localhost", {
|
||||
headers: { "x-api-key": "invalid-api-key" },
|
||||
});
|
||||
vi.mocked(getEnvironmentIdFromApiKey).mockResolvedValue(err({ type: "forbidden" }));
|
||||
|
||||
const result = await authenticateRequest(request);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({ type: "forbidden" });
|
||||
}
|
||||
});
|
||||
|
||||
it("should return forbidden error if environmentId is empty", async () => {
|
||||
const request = new Request("http://localhost", {
|
||||
headers: { "x-api-key": "invalid-api-key" },
|
||||
});
|
||||
vi.mocked(getEnvironmentIdFromApiKey).mockResolvedValue(ok(""));
|
||||
|
||||
const result = await authenticateRequest(request);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({ type: "forbidden" });
|
||||
}
|
||||
});
|
||||
|
||||
it("should return unauthorized error if apiKey is missing", async () => {
|
||||
const request = new Request("http://localhost");
|
||||
|
||||
const result = await authenticateRequest(request);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({ type: "unauthorized" });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,32 +0,0 @@
|
||||
import { logApiRequest } from "@/modules/api/v2/lib/utils";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { apiWrapper } from "../api-wrapper";
|
||||
import { authenticatedApiClient } from "../authenticated-api-client";
|
||||
|
||||
vi.mock("../api-wrapper", () => ({
|
||||
apiWrapper: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/api/v2/lib/utils", () => ({
|
||||
logApiRequest: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("authenticatedApiClient", () => {
|
||||
it("should log request and return response", async () => {
|
||||
const request = new Request("http://localhost", {
|
||||
headers: { "x-api-key": "valid-api-key" },
|
||||
});
|
||||
|
||||
vi.mocked(apiWrapper).mockResolvedValue(new Response("ok", { status: 200 }));
|
||||
vi.mocked(logApiRequest).mockReturnValue();
|
||||
|
||||
const handler = vi.fn().mockResolvedValue(new Response("ok", { status: 200 }));
|
||||
const response = await authenticatedApiClient({
|
||||
request,
|
||||
handler,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(logApiRequest).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,31 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { checkAuthorization } from "../check-authorization";
|
||||
|
||||
describe("checkAuthorization", () => {
|
||||
it("should return ok if authentication is valid", () => {
|
||||
const authentication: TAuthenticationApiKey = {
|
||||
type: "apiKey",
|
||||
environmentId: "env-id",
|
||||
hashedApiKey: "hashed-api-key",
|
||||
};
|
||||
const result = checkAuthorization({ authentication, environmentId: "env-id" });
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("should return unauthorized error if environmentId does not match", () => {
|
||||
const authentication: TAuthenticationApiKey = {
|
||||
type: "apiKey",
|
||||
environmentId: "env-id",
|
||||
hashedApiKey: "hashed-api-key",
|
||||
};
|
||||
const result = checkAuthorization({ authentication, environmentId: "different-env-id" });
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({ type: "unauthorized" });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,79 +0,0 @@
|
||||
import { ZContactAttributeKeyInput } from "@/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys";
|
||||
import { z } from "zod";
|
||||
import { ZodOpenApiOperationObject } from "zod-openapi";
|
||||
import { ZContactAttributeKey } from "@formbricks/database/zod/contact-attribute-keys";
|
||||
|
||||
export const getContactAttributeKeyEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "getContactAttributeKey",
|
||||
summary: "Get a contact attribute key",
|
||||
description: "Gets a contact attribute key from the database.",
|
||||
requestParams: {
|
||||
path: z.object({
|
||||
contactAttributeKeyId: z.string().cuid2(),
|
||||
}),
|
||||
},
|
||||
tags: ["Management API > Contact Attribute Keys"],
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Contact attribute key retrieved successfully.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: ZContactAttributeKey,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const deleteContactAttributeKeyEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "deleteContactAttributeKey",
|
||||
summary: "Delete a contact attribute key",
|
||||
description: "Deletes a contact attribute key from the database.",
|
||||
tags: ["Management API > Contact Attribute Keys"],
|
||||
requestParams: {
|
||||
path: z.object({
|
||||
contactAttributeId: z.string().cuid2(),
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Contact attribute key deleted successfully.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: ZContactAttributeKey,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const updateContactAttributeKeyEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "updateContactAttributeKey",
|
||||
summary: "Update a contact attribute key",
|
||||
description: "Updates a contact attribute key in the database.",
|
||||
tags: ["Management API > Contact Attribute Keys"],
|
||||
requestParams: {
|
||||
path: z.object({
|
||||
contactAttributeKeyId: z.string().cuid2(),
|
||||
}),
|
||||
},
|
||||
requestBody: {
|
||||
required: true,
|
||||
description: "The contact attribute key to update",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: ZContactAttributeKeyInput,
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Contact attribute key updated successfully.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: ZContactAttributeKey,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user