Compare commits

..

24 Commits

Author SHA1 Message Date
Matthias Nannt
87867cb2f2 chore: address coderabbit suggestions 2025-08-07 15:46:46 +02:00
StepSecurity Bot
ad03196ede [StepSecurity] ci: Harden GitHub Actions
Signed-off-by: StepSecurity Bot <bot@stepsecurity.io>
2025-08-07 13:32:27 +00:00
Matti Nannt
e80fc2ee61 chore: remove unused github workflows/actions (#6372) 2025-08-07 15:26:11 +02:00
Jakob Schott
9b489b0682 chore: Optimize styling for MultiLanguageCard (#6353) 2025-08-07 10:23:25 +00:00
Jakob Schott
2ee0efa1c2 fix: dynamic width for InputCombobox (#6365) 2025-08-06 23:52:09 -07:00
Anshuman Pandey
9ffd67262c fix: updates tolgee key (#6367) 2025-08-07 06:30:00 +00:00
Dhruwang Jariwala
68dc63ce0b chore: search bar and preview on survey list page (#6349)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-08-07 04:57:28 +00:00
Piyush Gupta
f239ee9697 feat: adds multiLanguageSurveys and accessControl license features (#6331) 2025-08-06 14:35:28 +00:00
Piyush Gupta
282b3e070c fix: sonarqube medium vulnerability issues (#6362) 2025-08-06 11:23:27 +00:00
Johannes
b5f0bd8f9a fix: update wording to match actual behaviour (#6364) 2025-08-06 03:38:47 -07:00
Piyush Gupta
3784bd6b5e fix: Missing space in Access Control Modal (#6356)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-08-06 08:17:47 +00:00
Piyush Gupta
41d27c2093 fix: use full width on sidebar elements (#6357) 2025-08-06 07:58:28 +00:00
Piyush Gupta
7400ce2e67 fix: secure cookies fix for callback URL (#6358) 2025-08-05 17:44:13 +00:00
Piyush Gupta
355782f404 chore: sonarqube low reliability issues (#6359) 2025-08-05 10:06:53 +00:00
Anshuman Pandey
de70e97940 fix: adds loading state to the responses download button (#6352) 2025-08-05 04:22:22 +00:00
Dhruwang Jariwala
287c45f996 feat: surface option ids (#6339) 2025-08-05 04:03:12 +00:00
Harsh Bhat
3b07a6d013 docs: update multi-language surveys (#6354) 2025-08-04 10:02:31 -07:00
Jonas Höbenreich
0cc2606ec6 fix: Remove rounded-lg Class from Company Logo (#6347) 2025-08-04 01:42:05 -07:00
Dhruwang Jariwala
0fada94b80 chore: Replace entity ids (#6317) 2025-08-04 04:10:41 +00:00
Piyush Gupta
a59ede20c7 fix: one leet security issues (#6303) 2025-08-01 14:35:11 +00:00
Piyush Gupta
84294f9df2 feat: adds debug logs (#6237)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-08-01 11:10:21 +00:00
Johannes
855e7c78ce docs: add quota docs (#6343) 2025-07-31 06:25:34 -07:00
Piotr Gaczkowski
6c506d90c7 fix: Make EKS endpoint private (#6333)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-07-31 13:08:18 +00:00
Piyush Gupta
53f6e02ca1 fix: XLSX security vulnerability | Update XLSX to SheetJS (#6321) 2025-07-31 12:12:17 +00:00
155 changed files with 7066 additions and 1230 deletions

View File

@@ -62,10 +62,12 @@ runs:
shell: bash
- name: Fill ENCRYPTION_KEY, ENTERPRISE_LICENSE_KEY and E2E_TESTING in .env
env:
E2E_TESTING_MODE: ${{ inputs.e2e_testing_mode }}
run: |
RANDOM_KEY=$(openssl rand -hex 32)
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
echo "E2E_TESTING=${{ inputs.e2e_testing_mode }}" >> .env
echo "E2E_TESTING=$E2E_TESTING_MODE" >> .env
shell: bash
- run: |

View File

@@ -1,23 +1,23 @@
name: 'Upload Sentry Sourcemaps'
description: 'Extract sourcemaps from Docker image and upload to Sentry'
name: "Upload Sentry Sourcemaps"
description: "Extract sourcemaps from Docker image and upload to Sentry"
inputs:
docker_image:
description: 'Docker image to extract sourcemaps from'
description: "Docker image to extract sourcemaps from"
required: true
release_version:
description: 'Sentry release version (e.g., v1.2.3)'
description: "Sentry release version (e.g., v1.2.3)"
required: true
sentry_auth_token:
description: 'Sentry authentication token'
description: "Sentry authentication token"
required: true
environment:
description: 'Sentry environment (e.g., production, staging)'
description: "Sentry environment (e.g., production, staging)"
required: false
default: 'staging'
default: "staging"
runs:
using: 'composite'
using: "composite"
steps:
- name: Checkout code
uses: actions/checkout@v4
@@ -26,13 +26,12 @@ runs:
- name: Validate Sentry auth token
shell: bash
env:
SENTRY_TOKEN: ${{ inputs.sentry_auth_token }}
run: |
set -euo pipefail
echo "🔐 Validating Sentry authentication token..."
# Assign token to local variable for secure handling
SENTRY_TOKEN="${{ inputs.sentry_auth_token }}"
# Test the token by making a simple API call to Sentry
response=$(curl -s -w "%{http_code}" -o /tmp/sentry_response.json \
-H "Authorization: Bearer $SENTRY_TOKEN" \
@@ -57,13 +56,23 @@ runs:
- name: Extract sourcemaps from Docker image
shell: bash
env:
DOCKER_IMAGE: ${{ inputs.docker_image }}
run: |
set -euo pipefail
echo "📦 Extracting sourcemaps from Docker image: ${{ inputs.docker_image }}"
# Validate docker image format (basic validation)
if [[ ! "$DOCKER_IMAGE" =~ ^[a-zA-Z0-9._/-]+:[a-zA-Z0-9._-]+$ ]] && [[ ! "$DOCKER_IMAGE" =~ ^[a-zA-Z0-9._/-]+@sha256:[A-Fa-f0-9]{64}$ ]]; then
echo "❌ Error: Invalid docker image format. Must be in format 'image:tag' or 'image@sha256:hash'"
echo "Provided: $DOCKER_IMAGE"
exit 1
fi
echo "📦 Extracting sourcemaps from Docker image: $DOCKER_IMAGE"
# Create temporary container from the image and capture its ID
echo "Creating temporary container..."
CONTAINER_ID=$(docker create "${{ inputs.docker_image }}")
CONTAINER_ID=$(docker create "$DOCKER_IMAGE")
echo "Container created with ID: $CONTAINER_ID"
# Set up cleanup function to ensure container is removed on script exit
@@ -82,7 +91,7 @@ runs:
# Exit with the original exit code to preserve script success/failure status
exit $original_exit_code
}
# Register cleanup function to run on script exit (success or failure)
trap cleanup_container EXIT
@@ -113,7 +122,7 @@ runs:
with:
environment: ${{ inputs.environment }}
version: ${{ inputs.release_version }}
sourcemaps: './extracted-next/'
sourcemaps: "./extracted-next/"
- name: Clean up extracted files
shell: bash

View File

@@ -1,82 +0,0 @@
name: "Apply issue labels to PR"
on:
pull_request_target:
types:
- opened
permissions:
contents: read
jobs:
label_on_pr:
runs-on: ubuntu-latest
permissions:
contents: none
issues: read
pull-requests: write
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: Apply labels from linked issue to PR
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
async function getLinkedIssues(owner, repo, prNumber) {
const query = `query GetLinkedIssues($owner: String!, $repo: String!, $prNumber: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $prNumber) {
closingIssuesReferences(first: 10) {
nodes {
number
labels(first: 10) {
nodes {
name
}
}
}
}
}
}
}`;
const variables = {
owner: owner,
repo: repo,
prNumber: prNumber,
};
const result = await github.graphql(query, variables);
return result.repository.pullRequest.closingIssuesReferences.nodes;
}
const pr = context.payload.pull_request;
const linkedIssues = await getLinkedIssues(
context.repo.owner,
context.repo.repo,
pr.number
);
const labelsToAdd = new Set();
for (const issue of linkedIssues) {
if (issue.labels && issue.labels.nodes) {
for (const label of issue.labels.nodes) {
labelsToAdd.add(label.name);
}
}
}
if (labelsToAdd.size) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
labels: Array.from(labelsToAdd),
});
}

View File

@@ -6,12 +6,14 @@ on:
- main
workflow_dispatch:
permissions:
contents: read
jobs:
chromatic:
name: Run Chromatic
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
actions: read

View File

@@ -1,27 +0,0 @@
# Dependency Review Action
#
# This Action will scan dependency manifest files that change as part of a Pull Request,
# surfacing known-vulnerable versions of the packages declared or updated in the PR.
# Once installed, if the workflow run is marked as required,
# PRs introducing known-vulnerable packages will be blocked from merging.
#
# Source repository: https://github.com/actions/dependency-review-action
name: 'Dependency Review'
on: [pull_request]
permissions:
contents: read
jobs:
dependency-review:
runs-on: ubuntu-latest
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: 'Checkout Repository'
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: 'Dependency Review'
uses: actions/dependency-review-action@38ecb5b593bf0eb19e335c03f97670f792489a8b # v4.7.0

View File

@@ -43,11 +43,16 @@ jobs:
helmfile-deploy:
runs-on: ubuntu-latest
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@v4.2.2
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Tailscale
uses: tailscale/github-action@v3
uses: tailscale/github-action@84a3f23bb4d843bcf4da6cf824ec1be473daf4de # v3.2.3
with:
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
@@ -66,7 +71,7 @@ jobs:
env:
AWS_REGION: eu-central-1
- uses: helmfile/helmfile-action@v2
- uses: helmfile/helmfile-action@712000e3d4e28c72778ecc53857746082f555ef3 # v2.0.4
name: Deploy Formbricks Cloud Production
if: inputs.ENVIRONMENT == 'production'
env:
@@ -84,7 +89,7 @@ jobs:
helmfile-auto-init: "false"
helmfile-workdirectory: infra/formbricks-cloud-helm
- uses: helmfile/helmfile-action@v2
- uses: helmfile/helmfile-action@712000e3d4e28c72778ecc53857746082f555ef3 # v2.0.4
name: Deploy Formbricks Cloud Staging
if: inputs.ENVIRONMENT == 'staging'
env:
@@ -106,15 +111,16 @@ jobs:
env:
CF_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }}
CF_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
ENVIRONMENT: ${{ inputs.ENVIRONMENT }}
run: |
# Set hostname based on environment
if [[ "${{ inputs.ENVIRONMENT }}" == "production" ]]; then
if [[ "$ENVIRONMENT" == "production" ]]; then
PURGE_HOST="app.formbricks.com"
else
PURGE_HOST="stage.app.formbricks.com"
fi
echo "Purging Cloudflare cache for host: $PURGE_HOST (environment: ${{ inputs.ENVIRONMENT }}, zone: $CF_ZONE_ID)"
echo "Purging Cloudflare cache for host: $PURGE_HOST (environment: $ENVIRONMENT, zone: $CF_ZONE_ID)"
# Prepare JSON payload for selective cache purge
json_payload=$(cat << EOF

View File

@@ -4,15 +4,9 @@ on:
pull_request:
branches:
- main
paths-ignore:
- helm-chart/**
- infra/**
merge_group:
branches:
- main
paths-ignore:
- helm-chart/**
- infra/**
workflow_dispatch:
permissions:
@@ -45,20 +39,29 @@ jobs:
--health-retries 5
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout Repository
uses: actions/checkout@v4.2.2
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Build Docker Image
uses: docker/build-push-action@v6
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
env:
GITHUB_SHA: ${{ github.sha }}
with:
context: .
file: ./apps/web/Dockerfile
push: false
load: true
tags: formbricks-test:${{ github.sha }}
tags: formbricks-test:${{ env.GITHUB_SHA }}
cache-from: type=gha
cache-to: type=gha,mode=max
secrets: |
@@ -95,6 +98,9 @@ jobs:
- name: Test Docker Image with Health Check
shell: bash
env:
GITHUB_SHA: ${{ github.sha }}
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
run: |
echo "🧪 Testing if the Docker image starts correctly..."
@@ -106,8 +112,8 @@ jobs:
$DOCKER_RUN_ARGS \
-p 3000:3000 \
-e DATABASE_URL="postgresql://test:test@host.docker.internal:5432/formbricks" \
-e ENCRYPTION_KEY="${{ secrets.DUMMY_ENCRYPTION_KEY }}" \
-d formbricks-test:${{ github.sha }}
-e ENCRYPTION_KEY="$DUMMY_ENCRYPTION_KEY" \
-d "formbricks-test:$GITHUB_SHA"
# Start health check polling immediately (every 5 seconds for up to 5 minutes)
echo "🏥 Polling /health endpoint every 5 seconds for up to 5 minutes..."

View File

@@ -47,8 +47,13 @@ jobs:
- docker-build
- deploy-formbricks-cloud
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@v4.2.2
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0

View File

@@ -41,14 +41,16 @@ jobs:
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- name: Generate SemVer version from branch or tag
id: generate_version
env:
REF_NAME: ${{ github.ref_name }}
REF_TYPE: ${{ github.ref_type }}
run: |
# Get reference name and type
REF_NAME="${{ github.ref_name }}"
REF_TYPE="${{ github.ref_type }}"
# Get reference name and type from environment variables
echo "Reference type: $REF_TYPE"
echo "Reference name: $REF_NAME"
@@ -172,8 +174,13 @@ jobs:
needs:
- build
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@v4.2.2
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0

View File

@@ -9,7 +9,7 @@ on:
workflow_call:
inputs:
IS_PRERELEASE:
description: 'Whether this is a prerelease (affects latest tag)'
description: "Whether this is a prerelease (affects latest tag)"
required: false
type: boolean
default: false
@@ -26,6 +26,9 @@ env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
@@ -52,9 +55,20 @@ jobs:
id: extract_release_tag
run: |
# Extract version from tag (e.g., refs/tags/v1.2.3 -> 1.2.3)
TAG=${{ github.ref }}
TAG="$GITHUB_REF"
TAG=${TAG#refs/tags/v}
# Validate the extracted tag format
if [[ ! "$TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$ ]]; then
echo "❌ Error: Invalid release tag format after extraction. Must be semver (e.g., 1.2.3, 1.2.3-alpha)"
echo "Original ref: $GITHUB_REF"
echo "Extracted tag: $TAG"
exit 1
fi
# Safely add to environment variables
echo "RELEASE_TAG=$TAG" >> $GITHUB_ENV
echo "VERSION=$TAG" >> $GITHUB_OUTPUT
echo "Using tag-based version: $TAG"

View File

@@ -26,8 +26,23 @@ jobs:
- 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: Validate input version
env:
INPUT_VERSION: ${{ inputs.VERSION }}
run: |
set -euo pipefail
# Validate input version format (expects clean semver without 'v' prefix)
if [[ ! "$INPUT_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$ ]]; then
echo "❌ Error: Invalid version format. Must be clean semver (e.g., 1.2.3, 1.2.3-alpha)"
echo "Expected: clean version without 'v' prefix"
echo "Provided: $INPUT_VERSION"
exit 1
fi
# Store validated version in environment variable
echo "VERSION<<EOF" >> $GITHUB_ENV
echo "$INPUT_VERSION" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
- name: Set up Helm
uses: azure/setup-helm@5119fcb9089d432beecbf79bb2c7915207344b78 # v3.5
@@ -35,15 +50,18 @@ jobs:
version: latest
- name: Log in to GitHub Container Registry
run: echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ghcr.io --username ${{ github.actor }} --password-stdin
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_ACTOR: ${{ github.actor }}
run: printf '%s' "$GITHUB_TOKEN" | helm registry login ghcr.io --username "$GITHUB_ACTOR" --password-stdin
- name: Install YQ
uses: dcarbone/install-yq-action@4075b4dca348d74bd83f2bf82d30f25d7c54539b # v1.3.1
- name: Update Chart.yaml with new version
run: |
yq -i ".version = \"${{ inputs.VERSION }}\"" helm-chart/Chart.yaml
yq -i ".appVersion = \"v${{ inputs.VERSION }}\"" helm-chart/Chart.yaml
yq -i ".version = \"$VERSION\"" helm-chart/Chart.yaml
yq -i ".appVersion = \"v$VERSION\"" helm-chart/Chart.yaml
- name: Package Helm chart
run: |
@@ -51,4 +69,4 @@ jobs:
- name: Push Helm chart to GitHub Container Registry
run: |
helm push formbricks-${{ inputs.VERSION }}.tgz oci://ghcr.io/formbricks/helm-charts
helm push "formbricks-$VERSION.tgz" oci://ghcr.io/formbricks/helm-charts

View File

@@ -1,81 +0,0 @@
# This workflow uses actions that are not certified by GitHub. They are provided
# by a third-party and are governed by separate terms of service, privacy
# policy, and support documentation.
name: Scorecard supply-chain security
on:
# For Branch-Protection check. Only the default branch is supported. See
# https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection
branch_protection_rule:
# To guarantee Maintained check is occasionally updated. See
# https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained
schedule:
- cron: "17 17 * * 6"
push:
branches: ["main"]
workflow_dispatch:
# Declare default permissions as read only.
permissions: read-all
jobs:
analysis:
name: Scorecard analysis
runs-on: ubuntu-latest
permissions:
# Needed to upload the results to code-scanning dashboard.
security-events: write
# Needed to publish results and get a badge (see publish_results below).
id-token: write
# Add this permission
actions: write # Required for artifact upload
# Uncomment the permissions below if installing in a private repository.
# contents: read
# actions: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: "Checkout code"
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: "Run analysis"
uses: ossf/scorecard-action@0864cf19026789058feabb7e87baa5f140aac736 # v2.3.1
with:
results_file: results.sarif
results_format: sarif
# (Optional) "write" PAT token. Uncomment the `repo_token` line below if:
# - you want to enable the Branch-Protection check on a *public* repository, or
# - you are installing Scorecard on a *private* repository
# To create the PAT, follow the steps in https://github.com/ossf/scorecard-action?tab=readme-ov-file#authentication-with-fine-grained-pat-optional.
# repo_token: ${{ secrets.SCORECARD_TOKEN }}
# Public repositories:
# - Publish results to OpenSSF REST API for easy access by consumers
# - Allows the repository to include the Scorecard badge.
# - See https://github.com/ossf/scorecard-action#publishing-results.
# For private repositories:
# - `publish_results` will always be set to `false`, regardless
# of the value entered here.
publish_results: true
# Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
# format to the repository Actions tab.
- name: "Upload artifact"
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: sarif
path: results.sarif
retention-days: 5
# Upload the results to GitHub's code scanning dashboard (optional).
# Commenting out will disable upload of results to your repo's Code Scanning dashboard
- name: "Upload to code-scanning"
uses: github/codeql-action/upload-sarif@b56ba49b26e50535fa1e7f7db0f4f7b4bf65d80d # v3.28.10
with:
sarif_file: results.sarif

View File

@@ -14,12 +14,14 @@ on:
paths:
- "infra/terraform/**"
permissions:
contents: read
jobs:
terraform:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
pull-requests: write
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -33,7 +35,7 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Tailscale
uses: tailscale/github-action@v3
uses: tailscale/github-action@84a3f23bb4d843bcf4da6cf824ec1be473daf4de # v3.2.3
with:
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}

View File

@@ -27,10 +27,18 @@ jobs:
- name: Get source branch name
id: branch-name
env:
RAW_BRANCH: ${{ github.head_ref }}
run: |
RAW_BRANCH="${{ github.head_ref }}"
# Validate and sanitize branch name - only allow alphanumeric, dots, underscores, hyphens, and forward slashes
SOURCE_BRANCH=$(echo "$RAW_BRANCH" | sed 's/[^a-zA-Z0-9._\/-]//g')
# Additional validation - ensure branch name is not empty after sanitization
if [[ -z "$SOURCE_BRANCH" ]]; then
echo "❌ Error: Branch name is empty after sanitization"
echo "Original branch: $RAW_BRANCH"
exit 1
fi
# Safely add to environment variables using GitHub's recommended method
# This prevents environment variable injection attacks

View File

@@ -23,24 +23,26 @@ jobs:
upload-sourcemaps:
name: Upload Sourcemaps to Sentry
runs-on: ubuntu-latest
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@v4.2.2
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- name: Set Docker Image
run: |
if [ -n "${{ inputs.tag_version }}" ]; then
echo "DOCKER_IMAGE=${{ inputs.docker_image }}:${{ inputs.tag_version }}" >> $GITHUB_ENV
else
echo "DOCKER_IMAGE=${{ inputs.docker_image }}:${{ inputs.release_version }}" >> $GITHUB_ENV
fi
run: echo "DOCKER_IMAGE=${DOCKER_IMAGE}" >> $GITHUB_ENV
env:
DOCKER_IMAGE: ${{ inputs.docker_image }}:${{ inputs.tag_version != '' && inputs.tag_version || inputs.release_version }}
- name: Upload Sourcemaps to Sentry
uses: ./.github/actions/upload-sentry-sourcemaps
with:
docker_image: ${{ env.DOCKER_IMAGE }}
release_version: ${{ inputs.release_version }}
sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }}
sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }}

View File

@@ -1,32 +0,0 @@
name: "Welcome new contributors"
on:
issues:
types: opened
pull_request_target:
types: opened
permissions:
pull-requests: write
issues: write
jobs:
welcome-message:
name: Welcoming New Users
runs-on: ubuntu-latest
timeout-minutes: 10
if: github.event.action == 'opened'
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- uses: actions/first-interaction@3c71ce730280171fd1cfb57c00c774f8998586f7 # v1
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
pr-message: |-
Thank you so much for making your first Pull Request and taking the time to improve Formbricks! 🚀🙏❤️
Feel free to join the conversation on [Github Discussions](https://github.com/formbricks/formbricks/discussions) if you need any help or have any questions. 😊
issue-message: |
Thank you for opening your first issue! 🙏❤️ One of our team members will review it and get back to you as soon as it possible. 😊

View File

@@ -80,25 +80,25 @@ export const LandingSidebar = ({
<DropdownMenuTrigger
asChild
id="userDropdownTrigger"
className="w-full rounded-br-xl border-t py-4 pl-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-none">
<div tabIndex={0} className={cn("flex cursor-pointer flex-row items-center space-x-3")}>
className="w-full rounded-br-xl border-t p-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-none">
<div tabIndex={0} className={cn("flex cursor-pointer flex-row items-center gap-3")}>
<ProfileAvatar userId={user.id} imageUrl={user.imageUrl} />
<>
<div>
<div className="grow overflow-hidden">
<p
title={user?.email}
className={cn(
"ph-no-capture ph-no-capture -mb-0.5 max-w-28 truncate text-sm font-bold text-slate-700"
"ph-no-capture ph-no-capture -mb-0.5 truncate text-sm font-bold text-slate-700"
)}>
{user?.name ? <span>{user?.name}</span> : <span>{user?.email}</span>}
</p>
<p
title={capitalizeFirstLetter(organization?.name)}
className="max-w-28 truncate text-sm text-slate-500">
className="truncate text-sm text-slate-500">
{capitalizeFirstLetter(organization?.name)}
</p>
</div>
<ChevronRightIcon className={cn("h-5 w-5 text-slate-700 hover:text-slate-500")} />
<ChevronRightIcon className={cn("h-5 w-5 shrink-0 text-slate-700 hover:text-slate-500")} />
</>
</div>
</DropdownMenuTrigger>

View File

@@ -62,7 +62,7 @@ describe("ProjectSettings component", () => {
industry: "ind",
defaultBrandColor: "#fff",
organizationTeams: [],
canDoRoleManagement: false,
isAccessControlAllowed: false,
userProjectsCount: 0,
} as any;

View File

@@ -42,7 +42,7 @@ interface ProjectSettingsProps {
industry: TProjectConfigIndustry;
defaultBrandColor: string;
organizationTeams: TOrganizationTeam[];
canDoRoleManagement: boolean;
isAccessControlAllowed: boolean;
userProjectsCount: number;
}
@@ -53,7 +53,7 @@ export const ProjectSettings = ({
industry,
defaultBrandColor,
organizationTeams,
canDoRoleManagement = false,
isAccessControlAllowed = false,
userProjectsCount,
}: ProjectSettingsProps) => {
const [createTeamModalOpen, setCreateTeamModalOpen] = useState(false);
@@ -174,7 +174,7 @@ export const ProjectSettings = ({
)}
/>
{canDoRoleManagement && userProjectsCount > 0 && (
{isAccessControlAllowed && userProjectsCount > 0 && (
<FormField
control={form.control}
name="teamIds"

View File

@@ -1,6 +1,6 @@
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
import { getUserProjects } from "@/lib/project/service";
import { getRoleManagementPermission } from "@/modules/ee/license-check/lib/utils";
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
@@ -12,7 +12,7 @@ vi.mock("@/lib/constants", () => ({ DEFAULT_BRAND_COLOR: "#fff" }));
// Mocks before component import
vi.mock("@/app/(app)/(onboarding)/lib/onboarding", () => ({ getTeamsByOrganizationId: vi.fn() }));
vi.mock("@/lib/project/service", () => ({ getUserProjects: vi.fn() }));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({ getRoleManagementPermission: vi.fn() }));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({ getAccessControlPermission: vi.fn() }));
vi.mock("@/modules/organization/lib/utils", () => ({ getOrganizationAuth: vi.fn() }));
vi.mock("@/tolgee/server", () => ({ getTranslate: () => Promise.resolve((key: string) => key) }));
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
@@ -61,7 +61,7 @@ describe("ProjectSettingsPage", () => {
} as any);
vi.mocked(getUserProjects).mockResolvedValueOnce([] as any);
vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce(null as any);
vi.mocked(getRoleManagementPermission).mockResolvedValueOnce(false as any);
vi.mocked(getAccessControlPermission).mockResolvedValueOnce(false as any);
await expect(Page({ params, searchParams })).rejects.toThrow("common.organization_teams_not_found");
});
@@ -73,7 +73,7 @@ describe("ProjectSettingsPage", () => {
} as any);
vi.mocked(getUserProjects).mockResolvedValueOnce([{ id: "p1" }] as any);
vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce([{ id: "t1", name: "Team1" }] as any);
vi.mocked(getRoleManagementPermission).mockResolvedValueOnce(true as any);
vi.mocked(getAccessControlPermission).mockResolvedValueOnce(true as any);
const element = await Page({ params, searchParams });
render(element as React.ReactElement);
@@ -96,7 +96,7 @@ describe("ProjectSettingsPage", () => {
} as any);
vi.mocked(getUserProjects).mockResolvedValueOnce([] as any);
vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce([{ id: "t1", name: "Team1" }] as any);
vi.mocked(getRoleManagementPermission).mockResolvedValueOnce(true as any);
vi.mocked(getAccessControlPermission).mockResolvedValueOnce(true as any);
const element = await Page({ params, searchParams });
render(element as React.ReactElement);

View File

@@ -2,7 +2,7 @@ import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboardin
import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings";
import { DEFAULT_BRAND_COLOR } from "@/lib/constants";
import { getUserProjects } from "@/lib/project/service";
import { getRoleManagementPermission } from "@/modules/ee/license-check/lib/utils";
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { Button } from "@/modules/ui/components/button";
import { Header } from "@/modules/ui/components/header";
@@ -41,7 +41,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
const organizationTeams = await getTeamsByOrganizationId(params.organizationId);
const canDoRoleManagement = await getRoleManagementPermission(organization.billing.plan);
const isAccessControlAllowed = await getAccessControlPermission(organization.billing.plan);
if (!organizationTeams) {
throw new Error(t("common.organization_teams_not_found"));
@@ -60,7 +60,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
industry={industry}
defaultBrandColor={DEFAULT_BRAND_COLOR}
organizationTeams={organizationTeams}
canDoRoleManagement={canDoRoleManagement}
isAccessControlAllowed={isAccessControlAllowed}
userProjectsCount={projects.length}
/>
{projects.length >= 1 && (

View File

@@ -8,8 +8,8 @@ import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-clie
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import {
getAccessControlPermission,
getOrganizationProjectsLimit,
getRoleManagementPermission,
} from "@/modules/ee/license-check/lib/utils";
import { createProject } from "@/modules/projects/settings/lib/project";
import { z } from "zod";
@@ -58,9 +58,9 @@ export const createProjectAction = authenticatedActionClient.schema(ZCreateProje
}
if (parsedInput.data.teamIds && parsedInput.data.teamIds.length > 0) {
const canDoRoleManagement = await getRoleManagementPermission(organization.billing.plan);
const isAccessControlAllowed = await getAccessControlPermission(organization.billing.plan);
if (!canDoRoleManagement) {
if (!isAccessControlAllowed) {
throw new OperationNotAllowedError("You do not have permission to manage roles");
}
}

View File

@@ -10,8 +10,8 @@ import {
import { getUserProjects } from "@/lib/project/service";
import { getUser } from "@/lib/user/service";
import {
getAccessControlPermission,
getOrganizationProjectsLimit,
getRoleManagementPermission,
} from "@/modules/ee/license-check/lib/utils";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { getTeamsByOrganizationId } from "@/modules/ee/teams/team-list/lib/team";
@@ -53,7 +53,7 @@ vi.mock("@/lib/membership/utils", () => ({
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getOrganizationProjectsLimit: vi.fn(),
getRoleManagementPermission: vi.fn(),
getAccessControlPermission: vi.fn(),
}));
vi.mock("@/modules/ee/teams/lib/roles", () => ({
getProjectPermissionByUserId: vi.fn(),
@@ -79,11 +79,11 @@ vi.mock("@/lib/constants", () => ({
// Mock components
vi.mock("@/app/(app)/environments/[environmentId]/components/MainNavigation", () => ({
MainNavigation: ({ organizationTeams, canDoRoleManagement }: any) => (
MainNavigation: ({ organizationTeams, isAccessControlAllowed }: any) => (
<div data-testid="main-navigation">
MainNavigation
<div data-testid="organization-teams">{JSON.stringify(organizationTeams || [])}</div>
<div data-testid="can-do-role-management">{canDoRoleManagement?.toString() || "false"}</div>
<div data-testid="is-access-control-allowed">{isAccessControlAllowed?.toString() || "false"}</div>
</div>
),
}));
@@ -202,7 +202,7 @@ describe("EnvironmentLayout", () => {
vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(null as any);
vi.mocked(getProjectPermissionByUserId).mockResolvedValue(mockProjectPermission);
vi.mocked(getTeamsByOrganizationId).mockResolvedValue(mockOrganizationTeams);
vi.mocked(getRoleManagementPermission).mockResolvedValue(true);
vi.mocked(getAccessControlPermission).mockResolvedValue(true);
mockIsDevelopment = false;
mockIsFormbricksCloud = false;
});
@@ -315,7 +315,7 @@ describe("EnvironmentLayout", () => {
expect(screen.getByTestId("downgrade-banner")).toBeInTheDocument();
});
test("passes canDoRoleManagement props to MainNavigation", async () => {
test("passes isAccessControlAllowed props to MainNavigation", async () => {
vi.resetModules();
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({
@@ -337,8 +337,8 @@ describe("EnvironmentLayout", () => {
})
);
expect(screen.getByTestId("can-do-role-management")).toHaveTextContent("true");
expect(vi.mocked(getRoleManagementPermission)).toHaveBeenCalledWith(mockOrganization.billing.plan);
expect(screen.getByTestId("is-access-control-allowed")).toHaveTextContent("true");
expect(vi.mocked(getAccessControlPermission)).toHaveBeenCalledWith(mockOrganization.billing.plan);
});
test("handles empty organizationTeams array", async () => {
@@ -393,8 +393,8 @@ describe("EnvironmentLayout", () => {
expect(screen.getByTestId("organization-teams")).toHaveTextContent("[]");
});
test("handles canDoRoleManagement false", async () => {
vi.mocked(getRoleManagementPermission).mockResolvedValue(false);
test("handles isAccessControlAllowed false", async () => {
vi.mocked(getAccessControlPermission).mockResolvedValue(false);
vi.resetModules();
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({
@@ -416,7 +416,7 @@ describe("EnvironmentLayout", () => {
})
);
expect(screen.getByTestId("can-do-role-management")).toHaveTextContent("false");
expect(screen.getByTestId("is-access-control-allowed")).toHaveTextContent("false");
});
test("throws error if user not found", async () => {

View File

@@ -14,8 +14,8 @@ import { getUserProjects } from "@/lib/project/service";
import { getUser } from "@/lib/user/service";
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
import {
getAccessControlPermission,
getOrganizationProjectsLimit,
getRoleManagementPermission,
} from "@/modules/ee/license-check/lib/utils";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { DevEnvironmentBanner } from "@/modules/ui/components/dev-environment-banner";
@@ -51,10 +51,10 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
throw new Error(t("common.environment_not_found"));
}
const [projects, environments, canDoRoleManagement] = await Promise.all([
const [projects, environments, isAccessControlAllowed] = await Promise.all([
getUserProjects(user.id, organization.id),
getEnvironments(environment.projectId),
getRoleManagementPermission(organization.billing.plan),
getAccessControlPermission(organization.billing.plan),
]);
if (!projects || !environments || !organizations) {
@@ -121,16 +121,16 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
membershipRole={membershipRole}
isMultiOrgEnabled={isMultiOrgEnabled}
isLicenseActive={active}
canDoRoleManagement={canDoRoleManagement}
isAccessControlAllowed={isAccessControlAllowed}
/>
<div id="mainContent" className="flex-1 overflow-y-auto bg-slate-50">
<div id="mainContent" className="flex flex-1 flex-col overflow-hidden bg-slate-50">
<TopControlBar
environment={environment}
environments={environments}
membershipRole={membershipRole}
projectPermission={projectPermission}
/>
<div className="mt-14">{children}</div>
<div className="flex-1 overflow-y-auto">{children}</div>
</div>
</div>
</div>

View File

@@ -56,16 +56,16 @@ vi.mock("@/modules/projects/components/project-switcher", () => ({
ProjectSwitcher: ({
isCollapsed,
organizationTeams,
canDoRoleManagement,
isAccessControlAllowed,
}: {
isCollapsed: boolean;
organizationTeams: TOrganizationTeam[];
canDoRoleManagement: boolean;
isAccessControlAllowed: boolean;
}) => (
<div data-testid="project-switcher" data-collapsed={isCollapsed}>
Project Switcher
<div data-testid="organization-teams-count">{organizationTeams?.length || 0}</div>
<div data-testid="can-do-role-management">{canDoRoleManagement.toString()}</div>
<div data-testid="is-access-control-allowed">{isAccessControlAllowed.toString()}</div>
</div>
),
}));
@@ -157,7 +157,7 @@ const defaultProps = {
membershipRole: "owner" as const,
organizationProjectsLimit: 5,
isLicenseActive: true,
canDoRoleManagement: true,
isAccessControlAllowed: true,
};
describe("MainNavigation", () => {
@@ -347,11 +347,11 @@ describe("MainNavigation", () => {
expect(screen.queryByText("common.license")).not.toBeInTheDocument();
});
test("passes canDoRoleManagement props to ProjectSwitcher", () => {
test("passes isAccessControlAllowed props to ProjectSwitcher", () => {
render(<MainNavigation {...defaultProps} />);
expect(screen.getByTestId("organization-teams-count")).toHaveTextContent("0");
expect(screen.getByTestId("can-do-role-management")).toHaveTextContent("true");
expect(screen.getByTestId("is-access-control-allowed")).toHaveTextContent("true");
});
test("handles no organizationTeams", () => {
@@ -360,9 +360,9 @@ describe("MainNavigation", () => {
expect(screen.getByTestId("organization-teams-count")).toHaveTextContent("0");
});
test("handles canDoRoleManagement false", () => {
render(<MainNavigation {...defaultProps} canDoRoleManagement={false} />);
test("handles isAccessControlAllowed false", () => {
render(<MainNavigation {...defaultProps} isAccessControlAllowed={false} />);
expect(screen.getByTestId("can-do-role-management")).toHaveTextContent("false");
expect(screen.getByTestId("is-access-control-allowed")).toHaveTextContent("false");
});
});

View File

@@ -66,7 +66,7 @@ interface NavigationProps {
membershipRole?: TOrganizationRole;
organizationProjectsLimit: number;
isLicenseActive: boolean;
canDoRoleManagement: boolean;
isAccessControlAllowed: boolean;
}
export const MainNavigation = ({
@@ -81,7 +81,7 @@ export const MainNavigation = ({
organizationProjectsLimit,
isLicenseActive,
isDevelopment,
canDoRoleManagement,
isAccessControlAllowed,
}: NavigationProps) => {
const router = useRouter();
const pathname = usePathname();
@@ -325,7 +325,7 @@ export const MainNavigation = ({
isTextVisible={isTextVisible}
organization={organization}
organizationProjectsLimit={organizationProjectsLimit}
canDoRoleManagement={canDoRoleManagement}
isAccessControlAllowed={isAccessControlAllowed}
/>
)}
@@ -339,27 +339,30 @@ export const MainNavigation = ({
<div
tabIndex={0}
className={cn(
"flex cursor-pointer flex-row items-center space-x-3",
isCollapsed ? "pl-2" : "pl-4"
"flex cursor-pointer flex-row items-center gap-3",
isCollapsed ? "justify-center px-2" : "px-4"
)}>
<ProfileAvatar userId={user.id} imageUrl={user.imageUrl} />
{!isCollapsed && !isTextVisible && (
<>
<div className={cn(isTextVisible ? "opacity-0" : "opacity-100")}>
<div
className={cn(isTextVisible ? "opacity-0" : "opacity-100", "grow overflow-hidden")}>
<p
title={user?.email}
className={cn(
"ph-no-capture ph-no-capture -mb-0.5 max-w-28 truncate text-sm font-bold text-slate-700"
"ph-no-capture ph-no-capture -mb-0.5 truncate text-sm font-bold text-slate-700"
)}>
{user?.name ? <span>{user?.name}</span> : <span>{user?.email}</span>}
</p>
<p
title={capitalizeFirstLetter(organization?.name)}
className="max-w-28 truncate text-sm text-slate-500">
className="truncate text-sm text-slate-500">
{capitalizeFirstLetter(organization?.name)}
</p>
</div>
<ChevronRightIcon className={cn("h-5 w-5 text-slate-700 hover:text-slate-500")} />
<ChevronRightIcon
className={cn("h-5 w-5 shrink-0 text-slate-700 hover:text-slate-500")}
/>
</>
)}
</div>

View File

@@ -44,10 +44,8 @@ describe("TopControlBar", () => {
);
// Check if the main div is rendered
const mainDiv = screen.getByTestId("top-control-buttons").parentElement?.parentElement?.parentElement;
expect(mainDiv).toHaveClass(
"fixed inset-0 top-0 z-30 flex h-14 w-full items-center justify-end bg-slate-50 px-6"
);
const mainDiv = screen.getByTestId("fb__global-top-control-bar");
expect(mainDiv).toHaveClass("flex h-14 w-full items-center justify-end bg-slate-50 px-6");
// Check if the mocked child component is rendered
expect(screen.getByTestId("top-control-buttons")).toBeInTheDocument();

View File

@@ -17,7 +17,9 @@ export const TopControlBar = ({
projectPermission,
}: SideBarProps) => {
return (
<div className="fixed inset-0 top-0 z-30 flex h-14 w-full items-center justify-end bg-slate-50 px-6">
<div
className="flex h-14 w-full items-center justify-end bg-slate-50 px-6"
data-testid="fb__global-top-control-bar">
<div className="shadow-xs z-10">
<div className="flex w-fit items-center space-x-2 py-2">
<TopControlButtons

View File

@@ -121,8 +121,9 @@ describe("ProfilePage", () => {
expect(screen.getByTestId("account-security")).toBeInTheDocument(); // Shown because 2FA license is enabled
expect(screen.queryByTestId("upgrade-prompt")).not.toBeInTheDocument();
expect(screen.getByTestId("delete-account")).toBeInTheDocument();
// Use a regex to match the text content, allowing for variable whitespace
expect(screen.getByText(new RegExp(`common\\.profile\\s*:\\s*${mockUser.id}`))).toBeInTheDocument(); // SettingsId
// Check for IdBadge content
expect(screen.getByText("common.profile_id")).toBeInTheDocument();
expect(screen.getByText(mockUser.id)).toBeInTheDocument();
});
});

View File

@@ -5,9 +5,9 @@ import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/servi
import { getUser } from "@/lib/user/service";
import { getIsMultiOrgEnabled, getIsTwoFactorAuthEnabled } from "@/modules/ee/license-check/lib/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { SettingsId } from "@/modules/ui/components/settings-id";
import { UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
import { getTranslate } from "@/tolgee/server";
import { SettingsCard } from "../../components/SettingsCard";
@@ -103,7 +103,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
isMultiOrgEnabled={isMultiOrgEnabled}
/>
</SettingsCard>
<SettingsId title={t("common.profile")} id={user.id}></SettingsId>
<IdBadge id={user.id} label={t("common.profile_id")} variant="column" />
</div>
)}
</PageContentWrapper>

View File

@@ -5,7 +5,7 @@ import { getIsMultiOrgEnabled, getWhiteLabelPermission } from "@/modules/ee/lice
import { EmailCustomizationSettings } from "@/modules/ee/whitelabel/email-customization/components/email-customization-settings";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { TEnvironmentAuth } from "@/modules/environments/types/environment-auth";
import { SettingsId } from "@/modules/ui/components/settings-id";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { getTranslate } from "@/tolgee/server";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
@@ -78,8 +78,8 @@ vi.mock("./components/DeleteOrganization", () => ({
DeleteOrganization: vi.fn(() => <div>DeleteOrganization</div>),
}));
vi.mock("@/modules/ui/components/settings-id", () => ({
SettingsId: vi.fn(() => <div>SettingsId</div>),
vi.mock("@/modules/ui/components/id-badge", () => ({
IdBadge: vi.fn(() => <div>IdBadge</div>),
}));
describe("Page", () => {
@@ -156,10 +156,11 @@ describe("Page", () => {
},
undefined
);
expect(SettingsId).toHaveBeenCalledWith(
expect(IdBadge).toHaveBeenCalledWith(
{
title: "common.organization_id",
id: mockEnvironmentAuth.organization.id,
label: "common.organization_id",
variant: "column",
},
undefined
);

View File

@@ -4,9 +4,9 @@ import { getUser } from "@/lib/user/service";
import { getIsMultiOrgEnabled, getWhiteLabelPermission } from "@/modules/ee/license-check/lib/utils";
import { EmailCustomizationSettings } from "@/modules/ee/whitelabel/email-customization/components/email-customization-settings";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { SettingsId } from "@/modules/ui/components/settings-id";
import { getTranslate } from "@/tolgee/server";
import { SettingsCard } from "../../components/SettingsCard";
import { DeleteOrganization } from "./components/DeleteOrganization";
@@ -70,7 +70,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
</SettingsCard>
)}
<SettingsId title={t("common.organization_id")} id={organization.id}></SettingsId>
<IdBadge id={organization.id} label={t("common.organization_id")} variant="column" />
</PageContentWrapper>
);
};

View File

@@ -1,10 +1,9 @@
import { extractChoiceIdsFromResponse } from "@/lib/response/utils";
import { processResponseData } from "@/lib/responses";
import { getContactIdentifier } from "@/lib/utils/contact";
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
import { getSelectionColumn } from "@/modules/ui/components/data-table";
import { ResponseBadges } from "@/modules/ui/components/response-badges";
import { cleanup } from "@testing-library/react";
import { AnyActionArg } from "react";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TResponseNote, TResponseNoteUser, TResponseTableData } from "@formbricks/types/responses";
import {
@@ -60,6 +59,7 @@ vi.mock("@/modules/survey/lib/questions", () => ({
getQuestionIconMap: vi.fn(() => ({
[TSurveyQuestionTypeEnum.OpenText]: <span>OT</span>,
[TSurveyQuestionTypeEnum.MultipleChoiceSingle]: <span>MCS</span>,
[TSurveyQuestionTypeEnum.MultipleChoiceMulti]: <span>MCM</span>,
[TSurveyQuestionTypeEnum.Matrix]: <span>MX</span>,
[TSurveyQuestionTypeEnum.Address]: <span>AD</span>,
[TSurveyQuestionTypeEnum.ContactInfo]: <span>CI</span>,
@@ -104,6 +104,27 @@ vi.mock("lucide-react", () => ({
TagIcon: () => <span>Tag</span>,
}));
// Mock new dependencies
vi.mock("@/lib/response/utils", () => ({
extractChoiceIdsFromResponse: vi.fn((responseValue) => {
// Mock implementation that returns choice IDs based on response value
if (Array.isArray(responseValue)) {
return responseValue.map((_, index) => `choice-${index + 1}`);
} else if (typeof responseValue === "string") {
return [`choice-single`];
}
return [];
}),
}));
vi.mock("@/modules/ui/components/id-badge", () => ({
IdBadge: vi.fn(({ id }) => <div data-testid="id-badge">{id}</div>),
}));
vi.mock("@/modules/ui/lib/utils", () => ({
cn: vi.fn((...classes) => classes.filter(Boolean).join(" ")),
}));
const mockSurvey = {
id: "survey1",
name: "Test Survey",
@@ -136,6 +157,28 @@ const mockSurvey = {
headline: { default: "Contact Info Question" },
required: false,
} as unknown as TSurveyQuestion,
{
id: "q5single",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
headline: { default: "Single Choice Question" },
required: false,
choices: [
{ id: "choice-1", label: { default: "Option 1" } },
{ id: "choice-2", label: { default: "Option 2" } },
{ id: "choice-3", label: { default: "Option 3" } },
],
} as unknown as TSurveyQuestion,
{
id: "q6multi",
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
headline: { default: "Multi Choice Question" },
required: false,
choices: [
{ id: "choice-a", label: { default: "Choice A" } },
{ id: "choice-b", label: { default: "Choice B" } },
{ id: "choice-c", label: { default: "Choice C" } },
],
} as unknown as TSurveyQuestion,
],
variables: [
{ id: "var1", name: "User Segment", type: "text" } as TSurveyVariable,
@@ -173,6 +216,8 @@ const mockResponseData = {
firstName: "John",
email: "john.doe@example.com",
hf1: "Hidden Field 1 Value",
q5single: "Option 1", // Single choice response
q6multi: ["Choice A", "Choice C"], // Multi choice response
},
variables: {
var1: "Segment A",
@@ -495,3 +540,281 @@ describe("ResponseTableColumns - Column Implementations", () => {
expect(hfColumn).toBeUndefined();
});
});
describe("ResponseTableColumns - Multiple Choice Questions", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
test("generates two columns for multipleChoiceSingle questions", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
// Should have main response column
const mainColumn = columns.find((col) => (col as any).accessorKey === "q5single");
expect(mainColumn).toBeDefined();
// Should have option IDs column
const optionIdsColumn = columns.find((col) => (col as any).accessorKey === "q5singleoptionIds");
expect(optionIdsColumn).toBeDefined();
});
test("generates two columns for multipleChoiceMulti questions", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
// Should have main response column
const mainColumn = columns.find((col) => (col as any).accessorKey === "q6multi");
expect(mainColumn).toBeDefined();
// Should have option IDs column
const optionIdsColumn = columns.find((col) => (col as any).accessorKey === "q6multioptionIds");
expect(optionIdsColumn).toBeDefined();
});
test("multipleChoiceSingle main column renders RenderResponse component", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const mainColumn: any = columns.find((col) => (col as any).accessorKey === "q5single");
const mockRow = {
original: {
responseData: { q5single: "Option 1" },
language: "default",
},
};
const cellResult = mainColumn?.cell?.({ row: mockRow } as any);
// Check that RenderResponse component is returned
expect(cellResult).toBeDefined();
});
test("multipleChoiceMulti main column renders RenderResponse component", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const mainColumn: any = columns.find((col) => (col as any).accessorKey === "q6multi");
const mockRow = {
original: {
responseData: { q6multi: ["Choice A", "Choice C"] },
language: "default",
},
};
const cellResult = mainColumn?.cell?.({ row: mockRow } as any);
// Check that RenderResponse component is returned
expect(cellResult).toBeDefined();
});
});
describe("ResponseTableColumns - Choice ID Columns", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
test("option IDs column calls extractChoiceIdsFromResponse for string response", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const optionIdsColumn: any = columns.find((col) => (col as any).accessorKey === "q5singleoptionIds");
const mockRow = {
original: {
responseData: { q5single: "Option 1" },
language: "default",
},
};
optionIdsColumn?.cell?.({ row: mockRow } as any);
expect(vi.mocked(extractChoiceIdsFromResponse)).toHaveBeenCalledWith(
"Option 1",
expect.objectContaining({ id: "q5single", type: "multipleChoiceSingle" }),
"default"
);
});
test("option IDs column calls extractChoiceIdsFromResponse for array response", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const optionIdsColumn: any = columns.find((col) => (col as any).accessorKey === "q6multioptionIds");
const mockRow = {
original: {
responseData: { q6multi: ["Choice A", "Choice C"] },
language: "default",
},
};
optionIdsColumn?.cell?.({ row: mockRow } as any);
expect(vi.mocked(extractChoiceIdsFromResponse)).toHaveBeenCalledWith(
["Choice A", "Choice C"],
expect.objectContaining({ id: "q6multi", type: "multipleChoiceMulti" }),
"default"
);
});
test("option IDs column renders IdBadge components for choice IDs", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const optionIdsColumn: any = columns.find((col) => (col as any).accessorKey === "q6multioptionIds");
const mockRow = {
original: {
responseData: { q6multi: ["Choice A", "Choice C"] },
language: "default",
},
};
// Mock extractChoiceIdsFromResponse to return specific choice IDs
vi.mocked(extractChoiceIdsFromResponse).mockReturnValueOnce(["choice-1", "choice-3"]);
const cellResult = optionIdsColumn?.cell?.({ row: mockRow } as any);
// Should render something for choice IDs
expect(cellResult).toBeDefined();
// Verify that extractChoiceIdsFromResponse was called
expect(vi.mocked(extractChoiceIdsFromResponse)).toHaveBeenCalled();
});
test("option IDs column returns null for non-string/array response values", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const optionIdsColumn: any = columns.find((col) => (col as any).accessorKey === "q5singleoptionIds");
const mockRow = {
original: {
responseData: { q5single: 123 }, // Invalid type
language: "default",
},
};
const cellResult = optionIdsColumn?.cell?.({ row: mockRow } as any);
expect(cellResult).toBeNull();
expect(vi.mocked(extractChoiceIdsFromResponse)).not.toHaveBeenCalled();
});
test("option IDs column returns null when no choice IDs found", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const optionIdsColumn: any = columns.find((col) => (col as any).accessorKey === "q5singleoptionIds");
const mockRow = {
original: {
responseData: { q5single: "Non-existent option" },
language: "default",
},
};
// Mock extractChoiceIdsFromResponse to return empty array
vi.mocked(extractChoiceIdsFromResponse).mockReturnValueOnce([]);
const cellResult = optionIdsColumn?.cell?.({ row: mockRow } as any);
expect(cellResult).toBeNull();
});
test("option IDs column handles missing language gracefully", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const optionIdsColumn: any = columns.find((col) => (col as any).accessorKey === "q5singleoptionIds");
const mockRow = {
original: {
responseData: { q5single: "Option 1" },
language: null, // No language
},
};
optionIdsColumn?.cell?.({ row: mockRow } as any);
expect(vi.mocked(extractChoiceIdsFromResponse)).toHaveBeenCalledWith(
"Option 1",
expect.objectContaining({ id: "q5single" }),
undefined
);
});
});
describe("ResponseTableColumns - Helper Functions", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
test("question headers are properly created for multiple choice questions", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const mainColumn: any = columns.find((col) => (col as any).accessorKey === "q5single");
const optionIdsColumn: any = columns.find((col) => (col as any).accessorKey === "q5singleoptionIds");
// Test main column header
const mainHeader = mainColumn?.header?.();
expect(mainHeader).toBeDefined();
expect(mainHeader?.props?.className).toContain("flex items-center justify-between");
// Test option IDs column header
const optionHeader = optionIdsColumn?.header?.();
expect(optionHeader).toBeDefined();
expect(optionHeader?.props?.className).toContain("flex items-center justify-between");
});
test("question headers include proper icons for multiple choice questions", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const singleChoiceColumn: any = columns.find((col) => (col as any).accessorKey === "q5single");
const multiChoiceColumn: any = columns.find((col) => (col as any).accessorKey === "q6multi");
// Headers should be functions that return JSX
expect(typeof singleChoiceColumn?.header).toBe("function");
expect(typeof multiChoiceColumn?.header).toBe("function");
// Call headers to ensure they don't throw
expect(() => singleChoiceColumn?.header?.()).not.toThrow();
expect(() => multiChoiceColumn?.header?.()).not.toThrow();
});
});
describe("ResponseTableColumns - Integration Tests", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
test("multiple choice questions work end-to-end with real data", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
// Find all multiple choice related columns
const singleMainCol = columns.find((col) => (col as any).accessorKey === "q5single");
const singleIdsCol = columns.find((col) => (col as any).accessorKey === "q5singleoptionIds");
const multiMainCol = columns.find((col) => (col as any).accessorKey === "q6multi");
const multiIdsCol = columns.find((col) => (col as any).accessorKey === "q6multioptionIds");
expect(singleMainCol).toBeDefined();
expect(singleIdsCol).toBeDefined();
expect(multiMainCol).toBeDefined();
expect(multiIdsCol).toBeDefined();
// Test with actual mock response data
const mockRow = { original: mockResponseData };
// Test single choice main column
const singleMainResult = (singleMainCol?.cell as any)?.({ row: mockRow });
expect(singleMainResult).toBeDefined();
// Test multi choice main column
const multiMainResult = (multiMainCol?.cell as any)?.({ row: mockRow });
expect(multiMainResult).toBeDefined();
// Test that choice ID columns exist and can be called
const singleIdsResult = (singleIdsCol?.cell as any)?.({ row: mockRow });
const multiIdsResult = (multiIdsCol?.cell as any)?.({ row: mockRow });
// Should not error when calling the cell functions
expect(() => singleIdsResult).not.toThrow();
expect(() => multiIdsResult).not.toThrow();
});
});

View File

@@ -1,6 +1,7 @@
"use client";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { extractChoiceIdsFromResponse } from "@/lib/response/utils";
import { processResponseData } from "@/lib/responses";
import { getContactIdentifier } from "@/lib/utils/contact";
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
@@ -8,8 +9,10 @@ import { recallToHeadline } from "@/lib/utils/recall";
import { RenderResponse } from "@/modules/analysis/components/SingleResponseCard/components/RenderResponse";
import { VARIABLES_ICON_MAP, getQuestionIconMap } from "@/modules/survey/lib/questions";
import { getSelectionColumn } from "@/modules/ui/components/data-table";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { ResponseBadges } from "@/modules/ui/components/response-badges";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
import { cn } from "@/modules/ui/lib/utils";
import { ColumnDef } from "@tanstack/react-table";
import { TFnType } from "@tolgee/react";
import { CircleHelpIcon, EyeOffIcon, MailIcon, TagIcon } from "lucide-react";
@@ -61,6 +64,42 @@ const getQuestionColumnsData = (
t: TFnType
): ColumnDef<TResponseTableData>[] => {
const QUESTIONS_ICON_MAP = getQuestionIconMap(t);
// Helper function to create consistent column headers
const createQuestionHeader = (questionType: string, headline: string, suffix?: string) => {
const title = suffix ? `${headline} - ${suffix}` : headline;
const QuestionHeader = () => (
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2 overflow-hidden">
<span className="h-4 w-4">{QUESTIONS_ICON_MAP[questionType]}</span>
<span className="truncate">{title}</span>
</div>
</div>
);
QuestionHeader.displayName = "QuestionHeader";
return QuestionHeader;
};
// Helper function to get localized question headline
const getQuestionHeadline = (question: TSurveyQuestion, survey: TSurvey) => {
return getLocalizedValue(recallToHeadline(question.headline, survey, false, "default"), "default");
};
// Helper function to render choice ID badges
const renderChoiceIdBadges = (choiceIds: string[], isExpanded: boolean) => {
if (choiceIds.length === 0) return null;
const containerClasses = cn("flex gap-x-1 w-full", isExpanded && "flex-wrap gap-y-1");
return (
<div className={containerClasses}>
{choiceIds.map((choiceId, index) => (
<IdBadge key={`${choiceId}-${index}`} id={choiceId} />
))}
</div>
);
};
switch (question.type) {
case "matrix":
return question.rows.map((matrixRow) => {
@@ -137,6 +176,50 @@ const getQuestionColumnsData = (
};
});
case "multipleChoiceMulti":
case "multipleChoiceSingle":
case "ranking":
case "pictureSelection": {
const questionHeadline = getQuestionHeadline(question, survey);
return [
{
accessorKey: question.id,
header: createQuestionHeader(question.type, questionHeadline),
cell: ({ row }) => {
const responseValue = row.original.responseData[question.id];
const language = row.original.language;
return (
<RenderResponse
question={question}
survey={survey}
responseData={responseValue}
language={language}
isExpanded={isExpanded}
showId={false}
/>
);
},
},
{
accessorKey: question.id + "optionIds",
header: createQuestionHeader(question.type, questionHeadline, t("common.option_id")),
cell: ({ row }) => {
const responseValue = row.original.responseData[question.id];
// Type guard to ensure responseValue is the correct type
if (typeof responseValue === "string" || Array.isArray(responseValue)) {
const choiceIds = extractChoiceIdsFromResponse(
responseValue,
question,
row.original.language || undefined
);
return renderChoiceIdBadges(choiceIds, isExpanded);
}
return null;
},
},
];
}
default:
return [
{
@@ -164,6 +247,7 @@ const getQuestionColumnsData = (
responseData={responseValue}
language={language}
isExpanded={isExpanded}
showId={false}
/>
);
},
@@ -230,7 +314,7 @@ export const generateResponseTableColumns = (
header: t("common.status"),
cell: ({ row }) => {
const status = row.original.status;
return <ResponseBadges items={[status]} />;
return <ResponseBadges items={[{ value: status }]} showId={false} />;
},
};
@@ -243,9 +327,10 @@ export const generateResponseTableColumns = (
const tagsArray = tags.map((tag) => tag.name);
return (
<ResponseBadges
items={tagsArray}
items={tagsArray.map((tag) => ({ value: tag }))}
isExpanded={isExpanded}
icon={<TagIcon className="h-4 w-4 text-slate-500" />}
showId={false}
/>
);
}
@@ -317,7 +402,6 @@ export const generateResponseTableColumns = (
};
// Combine the selection column with the dynamic question columns
const baseColumns = [
personColumn,
dateColumn,

View File

@@ -7,6 +7,13 @@ vi.mock("@/modules/ui/components/avatars", () => ({
PersonAvatar: ({ personId }: any) => <div data-testid="avatar">{personId}</div>,
}));
vi.mock("./QuestionSummaryHeader", () => ({ QuestionSummaryHeader: () => <div data-testid="header" /> }));
vi.mock("@/modules/ui/components/id-badge", () => ({
IdBadge: ({ id }: { id: string }) => (
<div data-testid="id-badge" data-id={id}>
ID: {id}
</div>
),
}));
describe("MultipleChoiceSummary", () => {
afterEach(() => {
@@ -160,8 +167,8 @@ describe("MultipleChoiceSummary", () => {
/>
);
const btns = screen.getAllByRole("button");
expect(btns[0]).toHaveTextContent("2 - Y50%2 common.selections");
expect(btns[1]).toHaveTextContent("1 - X50%1 common.selection");
expect(btns[0]).toHaveTextContent("2 - YID: other2 common.selections50%");
expect(btns[1]).toHaveTextContent("1 - XID: other1 common.selection50%");
});
test("places choice with others after one without when reversed inputs", () => {
@@ -272,4 +279,127 @@ describe("MultipleChoiceSummary", () => {
["O5"]
);
});
// New tests for IdBadge functionality
test("renders IdBadge when choice ID is found", () => {
const setFilter = vi.fn();
const q = {
question: {
id: "q6",
headline: "H6",
type: "multipleChoiceSingle",
choices: [
{ id: "choice1", label: { default: "Option A" } },
{ id: "choice2", label: { default: "Option B" } },
],
},
choices: {
"Option A": { value: "Option A", count: 5, percentage: 50, others: [] },
"Option B": { value: "Option B", count: 5, percentage: 50, others: [] },
},
type: "multipleChoiceSingle",
selectionCount: 0,
} as any;
render(
<MultipleChoiceSummary
questionSummary={q}
environmentId="env"
surveyType="link"
survey={baseSurvey}
setFilter={setFilter}
/>
);
const idBadges = screen.getAllByTestId("id-badge");
expect(idBadges).toHaveLength(2);
expect(idBadges[0]).toHaveAttribute("data-id", "choice1");
expect(idBadges[1]).toHaveAttribute("data-id", "choice2");
expect(idBadges[0]).toHaveTextContent("ID: choice1");
expect(idBadges[1]).toHaveTextContent("ID: choice2");
});
test("getChoiceIdByValue function correctly maps values to IDs", () => {
const setFilter = vi.fn();
const q = {
question: {
id: "q8",
headline: "H8",
type: "multipleChoiceMulti",
choices: [
{ id: "id-apple", label: { default: "Apple" } },
{ id: "id-banana", label: { default: "Banana" } },
{ id: "id-cherry", label: { default: "Cherry" } },
],
},
choices: {
Apple: { value: "Apple", count: 3, percentage: 30, others: [] },
Banana: { value: "Banana", count: 4, percentage: 40, others: [] },
Cherry: { value: "Cherry", count: 3, percentage: 30, others: [] },
},
type: "multipleChoiceMulti",
selectionCount: 0,
} as any;
render(
<MultipleChoiceSummary
questionSummary={q}
environmentId="env"
surveyType="link"
survey={baseSurvey}
setFilter={setFilter}
/>
);
const idBadges = screen.getAllByTestId("id-badge");
expect(idBadges).toHaveLength(3);
// Check that each badge has the correct ID
const expectedMappings = [
{ text: "Banana", id: "id-banana" }, // Highest count appears first
{ text: "Apple", id: "id-apple" },
{ text: "Cherry", id: "id-cherry" },
];
expectedMappings.forEach(({ text, id }, index) => {
expect(screen.getByText(`${3 - index} - ${text}`)).toBeInTheDocument();
expect(idBadges[index]).toHaveAttribute("data-id", id);
});
});
test("handles choices with special characters in labels", () => {
const setFilter = vi.fn();
const q = {
question: {
id: "q9",
headline: "H9",
type: "multipleChoiceSingle",
choices: [
{ id: "special-1", label: { default: "Option & Choice" } },
{ id: "special-2", label: { default: "Choice with 'quotes'" } },
],
},
choices: {
"Option & Choice": { value: "Option & Choice", count: 2, percentage: 50, others: [] },
"Choice with 'quotes'": { value: "Choice with 'quotes'", count: 2, percentage: 50, others: [] },
},
type: "multipleChoiceSingle",
selectionCount: 0,
} as any;
render(
<MultipleChoiceSummary
questionSummary={q}
environmentId="env"
surveyType="link"
survey={baseSurvey}
setFilter={setFilter}
/>
);
const idBadges = screen.getAllByTestId("id-badge");
expect(idBadges).toHaveLength(2);
expect(idBadges[0]).toHaveAttribute("data-id", "special-1");
expect(idBadges[1]).toHaveAttribute("data-id", "special-2");
});
});

View File

@@ -1,8 +1,10 @@
"use client";
import { getChoiceIdByValue } from "@/lib/response/utils";
import { getContactIdentifier } from "@/lib/utils/contact";
import { PersonAvatar } from "@/modules/ui/components/avatars";
import { Button } from "@/modules/ui/components/button";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { ProgressBar } from "@/modules/ui/components/progress-bar";
import { useTranslate } from "@tolgee/react";
import { InboxIcon } from "lucide-react";
@@ -84,90 +86,95 @@ export const MultipleChoiceSummary = ({
}
/>
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{results.map((result, resultsIdx) => (
<Fragment key={result.value}>
<button
className="group w-full cursor-pointer"
onClick={() =>
setFilter(
questionSummary.question.id,
questionSummary.question.headline,
questionSummary.question.type,
questionSummary.type === "multipleChoiceSingle" || otherValue === result.value
? t("environments.surveys.summary.includes_either")
: t("environments.surveys.summary.includes_all"),
[result.value]
)
}>
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
<div className="mr-8 flex w-full justify-between space-x-1 sm:justify-normal">
<p className="font-semibold text-slate-700 underline-offset-4 group-hover:underline">
{results.length - resultsIdx} - {result.value}
</p>
<div>
{results.map((result, resultsIdx) => {
const choiceId = getChoiceIdByValue(result.value, questionSummary.question);
return (
<Fragment key={result.value}>
<button
type="button"
className="group w-full cursor-pointer"
onClick={() =>
setFilter(
questionSummary.question.id,
questionSummary.question.headline,
questionSummary.question.type,
questionSummary.type === "multipleChoiceSingle" || otherValue === result.value
? t("environments.surveys.summary.includes_either")
: t("environments.surveys.summary.includes_all"),
[result.value]
)
}>
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
<div className="mr-8 flex w-full justify-between space-x-2 sm:justify-normal">
<p className="font-semibold text-slate-700 underline-offset-4 group-hover:underline">
{results.length - resultsIdx} - {result.value}
</p>
{choiceId && <IdBadge id={choiceId} />}
</div>
<div className="flex w-full space-x-2">
<p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0">
{result.count} {result.count === 1 ? t("common.selection") : t("common.selections")}
</p>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(result.percentage, 2)}%
</p>
</div>
</div>
<p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0">
{result.count} {result.count === 1 ? t("common.selection") : t("common.selections")}
</p>
</div>
<div className="group-hover:opacity-80">
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
</div>
</button>
{result.others && result.others.length > 0 && (
<div className="mt-4 rounded-lg border border-slate-200">
<div className="grid h-12 grid-cols-2 content-center rounded-t-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
<div className="col-span-1 pl-6">
{t("environments.surveys.summary.other_values_found")}
</div>
<div className="col-span-1 pl-6">{surveyType === "app" && t("common.user")}</div>
<div className="group-hover:opacity-80">
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
</div>
{result.others
.filter((otherValue) => otherValue.value !== "")
.slice(0, visibleOtherResponses)
.map((otherValue, idx) => (
<div key={`${idx}-${otherValue}`} dir="auto">
{surveyType === "link" && (
<div className="ph-no-capture col-span-1 m-2 flex h-10 items-center rounded-lg pl-4 text-sm font-medium text-slate-900">
<span>{otherValue.value}</span>
</div>
)}
{surveyType === "app" && otherValue.contact && (
<Link
href={
otherValue.contact.id
? `/environments/${environmentId}/contacts/${otherValue.contact.id}`
: { pathname: null }
}
className="m-2 grid h-16 grid-cols-2 items-center rounded-lg text-sm hover:bg-slate-100">
<div className="ph-no-capture col-span-1 pl-4 font-medium text-slate-900">
</button>
{result.others && result.others.length > 0 && (
<div className="mt-4 rounded-lg border border-slate-200">
<div className="grid h-12 grid-cols-2 content-center rounded-t-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
<div className="col-span-1 pl-6">
{t("environments.surveys.summary.other_values_found")}
</div>
<div className="col-span-1 pl-6">{surveyType === "app" && t("common.user")}</div>
</div>
{result.others
.filter((otherValue) => otherValue.value !== "")
.slice(0, visibleOtherResponses)
.map((otherValue, idx) => (
<div key={`${idx}-${otherValue}`} dir="auto">
{surveyType === "link" && (
<div className="ph-no-capture col-span-1 m-2 flex h-10 items-center rounded-lg pl-4 text-sm font-medium text-slate-900">
<span>{otherValue.value}</span>
</div>
<div className="ph-no-capture col-span-1 flex items-center space-x-4 pl-6 font-medium text-slate-900">
{otherValue.contact.id && <PersonAvatar personId={otherValue.contact.id} />}
<span>
{getContactIdentifier(otherValue.contact, otherValue.contactAttributes)}
</span>
</div>
</Link>
)}
)}
{surveyType === "app" && otherValue.contact && (
<Link
href={
otherValue.contact.id
? `/environments/${environmentId}/contacts/${otherValue.contact.id}`
: { pathname: null }
}
className="m-2 grid h-16 grid-cols-2 items-center rounded-lg text-sm hover:bg-slate-100">
<div className="ph-no-capture col-span-1 pl-4 font-medium text-slate-900">
<span>{otherValue.value}</span>
</div>
<div className="ph-no-capture col-span-1 flex items-center space-x-4 pl-6 font-medium text-slate-900">
{otherValue.contact.id && <PersonAvatar personId={otherValue.contact.id} />}
<span>
{getContactIdentifier(otherValue.contact, otherValue.contactAttributes)}
</span>
</div>
</Link>
)}
</div>
))}
{visibleOtherResponses < result.others.length && (
<div className="flex justify-center py-4">
<Button onClick={handleLoadMore} variant="secondary" size="sm">
{t("common.load_more")}
</Button>
</div>
))}
{visibleOtherResponses < result.others.length && (
<div className="flex justify-center py-4">
<Button onClick={handleLoadMore} variant="secondary" size="sm">
{t("common.load_more")}
</Button>
</div>
)}
</div>
)}
</Fragment>
))}
)}
</div>
)}
</Fragment>
);
})}
</div>
</div>
);

View File

@@ -1,7 +1,11 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import {
TSurvey,
TSurveyPictureSelectionQuestion,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import { PictureChoiceSummary } from "./PictureChoiceSummary";
vi.mock("@/modules/ui/components/progress-bar", () => ({
@@ -12,6 +16,19 @@ vi.mock("@/modules/ui/components/progress-bar", () => ({
vi.mock("./QuestionSummaryHeader", () => ({
QuestionSummaryHeader: ({ additionalInfo }: any) => <div data-testid="header">{additionalInfo}</div>,
}));
vi.mock("@/modules/ui/components/id-badge", () => ({
IdBadge: ({ id }: { id: string }) => (
<div data-testid="id-badge" data-id={id}>
ID: {id}
</div>
),
}));
vi.mock("@/lib/response/utils", () => ({
getChoiceIdByValue: (value: string, question: TSurveyPictureSelectionQuestion) => {
return question.choices?.find((choice) => choice.imageUrl === value)?.id ?? "other";
},
}));
// mock next image
vi.mock("next/image", () => ({
@@ -88,4 +105,73 @@ describe("PictureChoiceSummary", () => {
expect(screen.getByTestId("header")).toBeEmptyDOMElement();
});
// New tests for IdBadge functionality
test("renders IdBadge when choice ID is found via imageUrl", () => {
const choices = [
{ id: "choice1", imageUrl: "https://example.com/img1.png", percentage: 50, count: 5 },
{ id: "choice2", imageUrl: "https://example.com/img2.png", percentage: 50, count: 5 },
];
const questionSummary = {
choices,
question: {
id: "q2",
type: TSurveyQuestionTypeEnum.PictureSelection,
headline: "Picture Question",
allowMulti: true,
choices: [
{ id: "pic-choice-1", imageUrl: "https://example.com/img1.png" },
{ id: "pic-choice-2", imageUrl: "https://example.com/img2.png" },
],
},
selectionCount: 10,
} as any;
render(<PictureChoiceSummary questionSummary={questionSummary} survey={survey} setFilter={() => {}} />);
const idBadges = screen.getAllByTestId("id-badge");
expect(idBadges).toHaveLength(2);
expect(idBadges[0]).toHaveAttribute("data-id", "pic-choice-1");
expect(idBadges[1]).toHaveAttribute("data-id", "pic-choice-2");
expect(idBadges[0]).toHaveTextContent("ID: pic-choice-1");
expect(idBadges[1]).toHaveTextContent("ID: pic-choice-2");
});
test("getChoiceIdByValue function correctly maps imageUrl to choice ID", () => {
const choices = [
{ id: "choice1", imageUrl: "https://cdn.example.com/photo1.jpg", percentage: 33.33, count: 2 },
{ id: "choice2", imageUrl: "https://cdn.example.com/photo2.jpg", percentage: 33.33, count: 2 },
{ id: "choice3", imageUrl: "https://cdn.example.com/photo3.jpg", percentage: 33.33, count: 2 },
];
const questionSummary = {
choices,
question: {
id: "q4",
type: TSurveyQuestionTypeEnum.PictureSelection,
headline: "Photo Selection",
allowMulti: true,
choices: [
{ id: "photo-a", imageUrl: "https://cdn.example.com/photo1.jpg" },
{ id: "photo-b", imageUrl: "https://cdn.example.com/photo2.jpg" },
{ id: "photo-c", imageUrl: "https://cdn.example.com/photo3.jpg" },
],
},
selectionCount: 6,
} as any;
render(<PictureChoiceSummary questionSummary={questionSummary} survey={survey} setFilter={() => {}} />);
const idBadges = screen.getAllByTestId("id-badge");
expect(idBadges).toHaveLength(3);
expect(idBadges[0]).toHaveAttribute("data-id", "photo-a");
expect(idBadges[1]).toHaveAttribute("data-id", "photo-b");
expect(idBadges[2]).toHaveAttribute("data-id", "photo-c");
// Verify the images are also rendered correctly
const images = screen.getAllByRole("img");
expect(images).toHaveLength(3);
expect(images[0]).toHaveAttribute("src", "https://cdn.example.com/photo1.jpg");
expect(images[1]).toHaveAttribute("src", "https://cdn.example.com/photo2.jpg");
expect(images[2]).toHaveAttribute("src", "https://cdn.example.com/photo3.jpg");
});
});

View File

@@ -1,5 +1,7 @@
"use client";
import { getChoiceIdByValue } from "@/lib/response/utils";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { ProgressBar } from "@/modules/ui/components/progress-bar";
import { useTranslate } from "@tolgee/react";
import { InboxIcon } from "lucide-react";
@@ -29,6 +31,7 @@ interface PictureChoiceSummaryProps {
export const PictureChoiceSummary = ({ questionSummary, survey, setFilter }: PictureChoiceSummaryProps) => {
const results = questionSummary.choices;
const { t } = useTranslate();
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<QuestionSummaryHeader
@@ -44,43 +47,48 @@ export const PictureChoiceSummary = ({ questionSummary, survey, setFilter }: Pic
}
/>
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{results.map((result, index) => (
<button
className="w-full cursor-pointer hover:opacity-80"
key={result.id}
onClick={() =>
setFilter(
questionSummary.question.id,
questionSummary.question.headline,
questionSummary.question.type,
t("environments.surveys.summary.includes_all"),
[`${t("environments.surveys.edit.picture_idx", { idx: index + 1 })}`]
)
}>
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
<div className="mr-8 flex w-full justify-between space-x-1 sm:justify-normal">
<div className="relative h-32 w-[220px]">
<Image
src={result.imageUrl}
alt="choice-image"
layout="fill"
objectFit="cover"
className="rounded-md"
/>
{results.map((result, index) => {
const choiceId = getChoiceIdByValue(result.imageUrl, questionSummary.question);
return (
<button
type="button"
className="w-full cursor-pointer hover:opacity-80"
key={result.id}
onClick={() =>
setFilter(
questionSummary.question.id,
questionSummary.question.headline,
questionSummary.question.type,
t("environments.surveys.summary.includes_all"),
[`${t("environments.surveys.edit.picture_idx", { idx: index + 1 })}`]
)
}>
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
<div className="mr-8 flex w-full justify-between space-x-2 sm:justify-normal">
<div className="relative h-32 w-[220px]">
<Image
src={result.imageUrl}
alt="choice-image"
layout="fill"
objectFit="cover"
className="rounded-md"
/>
</div>
<div className="self-end">{choiceId && <IdBadge id={choiceId} />}</div>
</div>
<div className="self-end">
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
<div className="flex w-full space-x-2">
<p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0">
{result.count} {result.count === 1 ? t("common.selection") : t("common.selections")}
</p>
<p className="self-end rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(result.percentage, 2)}%
</p>
</div>
</div>
<p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0">
{result.count} {result.count === 1 ? t("common.selection") : t("common.selections")}
</p>
</div>
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100 || 0} />
</button>
))}
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100 || 0} />
</button>
);
})}
</div>
</div>
);

View File

@@ -27,10 +27,10 @@ vi.mock("@/modules/survey/lib/questions", () => ({
],
}));
vi.mock("@/modules/ui/components/settings-id", () => ({
SettingsId: ({ title, id }: { title: string; id: string }) => (
<div data-testid="settings-id">
{title}: {id}
vi.mock("@/modules/ui/components/id-badge", () => ({
IdBadge: ({ label, id }: { label: string; id: string }) => (
<div data-testid="id-badge">
{label}: {id}
</div>
),
}));
@@ -76,7 +76,7 @@ describe("QuestionSummaryHeader", () => {
).toBeInTheDocument();
expect(screen.getByTestId("question-icon")).toBeInTheDocument();
expect(screen.getByTestId("settings-id")).toHaveTextContent("common.question_id: q1");
expect(screen.getByTestId("id-badge")).toHaveTextContent("common.question_id: q1");
expect(screen.queryByText("environments.surveys.edit.optional")).not.toBeInTheDocument();
});

View File

@@ -3,7 +3,7 @@
import { recallToHeadline } from "@/lib/utils/recall";
import { formatTextWithSlashes } from "@/modules/survey/editor/lib/utils";
import { getQuestionTypes } from "@/modules/survey/lib/questions";
import { SettingsId } from "@/modules/ui/components/settings-id";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { useTranslate } from "@tolgee/react";
import { InboxIcon } from "lucide-react";
import type { JSX } from "react";
@@ -55,7 +55,7 @@ export const QuestionSummaryHeader = ({
</div>
)}
</div>
<SettingsId title={t("common.question_id")} id={questionSummary.question.id}></SettingsId>
<IdBadge id={questionSummary.question.id} label={t("common.question_id")} />
</div>
);
};

View File

@@ -1,6 +1,6 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TSurvey, TSurveyQuestionSummaryRanking, TSurveyType } from "@formbricks/types/surveys/types";
import { TSurvey, TSurveyQuestionSummaryRanking } from "@formbricks/types/surveys/types";
import { RankingSummary } from "./RankingSummary";
// Mock dependencies
@@ -12,17 +12,32 @@ vi.mock("../lib/utils", () => ({
convertFloatToNDecimal: (value: number) => value.toFixed(2),
}));
vi.mock("@/modules/ui/components/id-badge", () => ({
IdBadge: ({ id }: { id: string }) => (
<div data-testid="id-badge" data-id={id}>
ID: {id}
</div>
),
}));
describe("RankingSummary", () => {
afterEach(() => {
cleanup();
});
const survey = {} as TSurvey;
const surveyType: TSurveyType = "app";
test("renders ranking results in correct order", () => {
const questionSummary = {
question: { id: "q1", headline: "Rank the following" },
question: {
id: "q1",
headline: "Rank the following",
choices: [
{ id: "choice1", label: { default: "Option A" } },
{ id: "choice2", label: { default: "Option B" } },
{ id: "choice3", label: { default: "Option C" } },
],
},
choices: {
option1: { value: "Option A", avgRanking: 1.5, others: [] },
option2: { value: "Option B", avgRanking: 2.3, others: [] },
@@ -30,7 +45,7 @@ describe("RankingSummary", () => {
},
} as unknown as TSurveyQuestionSummaryRanking;
render(<RankingSummary questionSummary={questionSummary} survey={survey} surveyType={surveyType} />);
render(<RankingSummary questionSummary={questionSummary} survey={survey} />);
expect(screen.getByTestId("question-summary-header")).toBeInTheDocument();
@@ -51,43 +66,13 @@ describe("RankingSummary", () => {
expect(screen.getByText("#2.30")).toBeInTheDocument();
});
test("renders 'other values found' section when others exist", () => {
const questionSummary = {
question: { id: "q1", headline: "Rank the following" },
choices: {
option1: {
value: "Option A",
avgRanking: 1.0,
others: [{ value: "Other value", count: 2 }],
},
},
} as unknown as TSurveyQuestionSummaryRanking;
render(<RankingSummary questionSummary={questionSummary} survey={survey} surveyType={surveyType} />);
expect(screen.getByText("environments.surveys.summary.other_values_found")).toBeInTheDocument();
});
test("shows 'User' column in other values section for app survey type", () => {
const questionSummary = {
question: { id: "q1", headline: "Rank the following" },
choices: {
option1: {
value: "Option A",
avgRanking: 1.0,
others: [{ value: "Other value", count: 1 }],
},
},
} as unknown as TSurveyQuestionSummaryRanking;
render(<RankingSummary questionSummary={questionSummary} survey={survey} surveyType="app" />);
expect(screen.getByText("common.user")).toBeInTheDocument();
});
test("doesn't show 'User' column for link survey type", () => {
const questionSummary = {
question: { id: "q1", headline: "Rank the following" },
question: {
id: "q1",
headline: "Rank the following",
choices: [{ id: "choice1", label: { default: "Option A" } }],
},
choices: {
option1: {
value: "Option A",
@@ -97,8 +82,132 @@ describe("RankingSummary", () => {
},
} as unknown as TSurveyQuestionSummaryRanking;
render(<RankingSummary questionSummary={questionSummary} survey={survey} surveyType="link" />);
render(<RankingSummary questionSummary={questionSummary} survey={survey} />);
expect(screen.queryByText("common.user")).not.toBeInTheDocument();
});
// New tests for IdBadge functionality
test("renders IdBadge when choice ID is found via label", () => {
const questionSummary = {
question: {
id: "q2",
headline: "Rank these options",
choices: [
{ id: "rank-choice-1", label: { default: "First Option" } },
{ id: "rank-choice-2", label: { default: "Second Option" } },
{ id: "rank-choice-3", label: { default: "Third Option" } },
],
},
choices: {
option1: { value: "First Option", avgRanking: 1.5, others: [] },
option2: { value: "Second Option", avgRanking: 2.1, others: [] },
option3: { value: "Third Option", avgRanking: 2.8, others: [] },
},
} as unknown as TSurveyQuestionSummaryRanking;
render(<RankingSummary questionSummary={questionSummary} survey={survey} />);
const idBadges = screen.getAllByTestId("id-badge");
expect(idBadges).toHaveLength(3);
expect(idBadges[0]).toHaveAttribute("data-id", "rank-choice-1");
expect(idBadges[1]).toHaveAttribute("data-id", "rank-choice-2");
expect(idBadges[2]).toHaveAttribute("data-id", "rank-choice-3");
expect(idBadges[0]).toHaveTextContent("ID: rank-choice-1");
expect(idBadges[1]).toHaveTextContent("ID: rank-choice-2");
expect(idBadges[2]).toHaveTextContent("ID: rank-choice-3");
});
test("getChoiceIdByValue function correctly maps ranking values to choice IDs", () => {
const questionSummary = {
question: {
id: "q4",
headline: "Rate importance",
choices: [
{ id: "importance-high", label: { default: "Very Important" } },
{ id: "importance-medium", label: { default: "Somewhat Important" } },
{ id: "importance-low", label: { default: "Not Important" } },
],
},
choices: {
option1: { value: "Very Important", avgRanking: 1.2, others: [] },
option2: { value: "Somewhat Important", avgRanking: 2.0, others: [] },
option3: { value: "Not Important", avgRanking: 2.8, others: [] },
},
} as unknown as TSurveyQuestionSummaryRanking;
render(<RankingSummary questionSummary={questionSummary} survey={survey} />);
const idBadges = screen.getAllByTestId("id-badge");
expect(idBadges).toHaveLength(3);
// Should be ordered by avgRanking (ascending)
expect(screen.getByText("Very Important")).toBeInTheDocument(); // avgRanking: 1.2
expect(screen.getByText("Somewhat Important")).toBeInTheDocument(); // avgRanking: 2.0
expect(screen.getByText("Not Important")).toBeInTheDocument(); // avgRanking: 2.8
expect(idBadges[0]).toHaveAttribute("data-id", "importance-high");
expect(idBadges[1]).toHaveAttribute("data-id", "importance-medium");
expect(idBadges[2]).toHaveAttribute("data-id", "importance-low");
});
test("handles mixed choices with and without matching IDs", () => {
const questionSummary = {
question: {
id: "q5",
headline: "Mixed options",
choices: [
{ id: "valid-choice-1", label: { default: "Valid Option" } },
{ id: "valid-choice-2", label: { default: "Another Valid Option" } },
],
},
choices: {
option1: { value: "Valid Option", avgRanking: 1.5, others: [] },
option2: { value: "Unknown Option", avgRanking: 2.0, others: [] },
option3: { value: "Another Valid Option", avgRanking: 2.5, others: [] },
},
} as unknown as TSurveyQuestionSummaryRanking;
render(<RankingSummary questionSummary={questionSummary} survey={survey} />);
const idBadges = screen.getAllByTestId("id-badge");
expect(idBadges).toHaveLength(3); // Only 2 out of 3 should have badges
// Check that all options are still displayed
expect(screen.getByText("Valid Option")).toBeInTheDocument();
expect(screen.getByText("Unknown Option")).toBeInTheDocument();
expect(screen.getByText("Another Valid Option")).toBeInTheDocument();
// Check that only the valid choices have badges
expect(idBadges[0]).toHaveAttribute("data-id", "valid-choice-1");
expect(idBadges[1]).toHaveAttribute("data-id", "other");
expect(idBadges[2]).toHaveAttribute("data-id", "valid-choice-2");
});
test("handles special characters in choice labels", () => {
const questionSummary = {
question: {
id: "q6",
headline: "Special characters test",
choices: [
{ id: "special-1", label: { default: "Option with 'quotes'" } },
{ id: "special-2", label: { default: "Option & Ampersand" } },
],
},
choices: {
option1: { value: "Option with 'quotes'", avgRanking: 1.0, others: [] },
option2: { value: "Option & Ampersand", avgRanking: 2.0, others: [] },
},
} as unknown as TSurveyQuestionSummaryRanking;
render(<RankingSummary questionSummary={questionSummary} survey={survey} />);
const idBadges = screen.getAllByTestId("id-badge");
expect(idBadges).toHaveLength(2);
expect(idBadges[0]).toHaveAttribute("data-id", "special-1");
expect(idBadges[1]).toHaveAttribute("data-id", "special-2");
expect(screen.getByText("Option with 'quotes'")).toBeInTheDocument();
expect(screen.getByText("Option & Ampersand")).toBeInTheDocument();
});
});

View File

@@ -1,15 +1,16 @@
import { getChoiceIdByValue } from "@/lib/response/utils";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { useTranslate } from "@tolgee/react";
import { TSurvey, TSurveyQuestionSummaryRanking, TSurveyType } from "@formbricks/types/surveys/types";
import { TSurvey, TSurveyQuestionSummaryRanking } from "@formbricks/types/surveys/types";
import { convertFloatToNDecimal } from "../lib/utils";
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
interface RankingSummaryProps {
questionSummary: TSurveyQuestionSummaryRanking;
surveyType: TSurveyType;
survey: TSurvey;
}
export const RankingSummary = ({ questionSummary, surveyType, survey }: RankingSummaryProps) => {
export const RankingSummary = ({ questionSummary, survey }: RankingSummaryProps) => {
// sort by count and transform to array
const { t } = useTranslate();
const results = Object.values(questionSummary.choices).sort((a, b) => {
@@ -20,35 +21,30 @@ export const RankingSummary = ({ questionSummary, surveyType, survey }: RankingS
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{results.map((result, resultsIdx) => (
<div key={result.value} className="group cursor-pointer">
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
<div className="mr-8 flex w-full justify-between space-x-1 sm:justify-normal">
<div className="flex w-full items-center">
<span className="mr-2 text-slate-400">#{resultsIdx + 1}</span>
<div className="rounded bg-slate-100 px-2 py-1">{result.value}</div>
<span className="ml-auto flex items-center space-x-1">
<span className="font-bold text-slate-600">
#{convertFloatToNDecimal(result.avgRanking, 2)}
{results.map((result, resultsIdx) => {
const choiceId = getChoiceIdByValue(result.value, questionSummary.question);
return (
<div key={result.value} className="group cursor-pointer">
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
<div className="mr-8 flex w-full justify-between space-x-2 sm:justify-normal">
<div className="flex w-full items-center">
<div className="flex items-center space-x-2">
<span className="mr-2 text-slate-400">#{resultsIdx + 1}</span>
<div className="rounded bg-slate-100 px-2 py-1">{result.value}</div>
{choiceId && <IdBadge id={choiceId} />}
</div>
<span className="ml-auto flex items-center space-x-1">
<span className="font-bold text-slate-600">
#{convertFloatToNDecimal(result.avgRanking, 2)}
</span>
<span>{t("environments.surveys.summary.average")}</span>
</span>
<span>{t("environments.surveys.summary.average")}</span>
</span>
</div>
</div>
</div>
</div>
{result.others && result.others.length > 0 && (
<div className="mt-4 rounded-lg border border-slate-200">
<div className="grid h-12 grid-cols-2 content-center rounded-t-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
<div className="col-span-1 pl-6">
{t("environments.surveys.summary.other_values_found")}
</div>
<div className="col-span-1 pl-6">{surveyType === "app" && t("common.user")}</div>
</div>
</div>
)}
</div>
))}
);
})}
</div>
</div>
);

View File

@@ -244,7 +244,6 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
<RankingSummary
key={questionSummary.question.id}
questionSummary={questionSummary}
surveyType={survey.type}
survey={survey}
/>
);

View File

@@ -98,8 +98,8 @@ vi.mock("@/modules/ui/components/page-header", () => ({
PageHeader: vi.fn(({ children }) => <div data-testid="page-header">{children}</div>),
}));
vi.mock("@/modules/ui/components/settings-id", () => ({
SettingsId: vi.fn(() => <div data-testid="settings-id"></div>),
vi.mock("@/modules/ui/components/id-badge", () => ({
IdBadge: vi.fn(() => <div data-testid="id-badge"></div>),
}));
vi.mock("@/tolgee/server", () => ({
@@ -227,7 +227,7 @@ describe("SurveyPage", () => {
expect(screen.getByTestId("page-header")).toBeInTheDocument();
expect(screen.getByTestId("survey-analysis-navigation")).toBeInTheDocument();
expect(screen.getByTestId("summary-page")).toBeInTheDocument();
expect(screen.getByTestId("settings-id")).toBeInTheDocument();
expect(screen.getByTestId("id-badge")).toBeInTheDocument();
expect(vi.mocked(getEnvironmentAuth)).toHaveBeenCalledWith(mockEnvironmentId);
expect(vi.mocked(getSurvey)).toHaveBeenCalledWith(mockSurveyId);

View File

@@ -9,9 +9,9 @@ import { getUser } from "@/lib/user/service";
import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { SettingsId } from "@/modules/ui/components/settings-id";
import { getTranslate } from "@/tolgee/server";
import { notFound } from "next/navigation";
@@ -74,7 +74,7 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
initialSurveySummary={initialSurveySummary}
/>
<SettingsId title={t("common.survey_id")} id={surveyId} />
<IdBadge id={surveyId} label={t("common.survey_id")} variant="column" />
</PageContentWrapper>
);
};

View File

@@ -188,4 +188,70 @@ describe("CustomFilter", () => {
expect(screen.queryByTestId("calendar-mock")).not.toBeInTheDocument();
});
});
test("downloading all and filtered responses in csv and xlsx formats", async () => {
const user = userEvent.setup();
render(<CustomFilter survey={mockSurvey} />);
// Mock the action to return undefined data to avoid DOM manipulation
vi.mocked(getResponsesDownloadUrlAction).mockResolvedValue({
data: undefined,
});
// Test CSV download
const downloadButton = screen.getByTestId("fb__custom-filter-download-responses-button");
await user.click(downloadButton);
const downloadAllCsv = screen.getByTestId("fb__custom-filter-download-all-csv");
await user.click(downloadAllCsv);
await waitFor(() => {
expect(getResponsesDownloadUrlAction).toHaveBeenCalledWith({
surveyId: "survey-1",
format: "csv",
filterCriteria: {},
});
});
// Test XLSX download
await user.click(downloadButton);
const downloadAllXlsx = screen.getByTestId("fb__custom-filter-download-all-xlsx");
await user.click(downloadAllXlsx);
await waitFor(() => {
expect(getResponsesDownloadUrlAction).toHaveBeenCalledWith({
surveyId: "survey-1",
format: "xlsx",
filterCriteria: {},
});
});
// Test filtered CSV download
await user.click(downloadButton);
const downloadFilteredCsv = screen.getByTestId("fb__custom-filter-download-filtered-csv");
await user.click(downloadFilteredCsv);
await waitFor(() => {
expect(getResponsesDownloadUrlAction).toHaveBeenCalledWith({
surveyId: "survey-1",
format: "csv",
filterCriteria: {},
});
});
// Test filtered XLSX download
await user.click(downloadButton);
const downloadFilteredXlsx = screen.getByTestId("fb__custom-filter-download-filtered-xlsx");
await user.click(downloadFilteredXlsx);
await waitFor(() => {
expect(getResponsesDownloadUrlAction).toHaveBeenCalledWith({
surveyId: "survey-1",
format: "xlsx",
filterCriteria: {},
});
});
vi.restoreAllMocks();
});
});

View File

@@ -15,6 +15,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { cn } from "@/modules/ui/lib/utils";
import { TFnType, useTranslate } from "@tolgee/react";
import {
differenceInDays,
@@ -31,7 +32,7 @@ import {
subQuarters,
subYears,
} from "date-fns";
import { ArrowDownToLineIcon, ChevronDown, ChevronUp, DownloadIcon } from "lucide-react";
import { ArrowDownToLineIcon, ChevronDown, ChevronUp, DownloadIcon, Loader2Icon } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import toast from "react-hot-toast";
import { TSurvey } from "@formbricks/types/surveys/types";
@@ -135,6 +136,7 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
const [isDatePickerOpen, setIsDatePickerOpen] = useState<boolean>(false);
const [isFilterDropDownOpen, setIsFilterDropDownOpen] = useState<boolean>(false);
const [hoveredRange, setHoveredRange] = useState<DateRange | null>(null);
const [isDownloading, setIsDownloading] = useState<boolean>(false);
const firstMountRef = useRef(true);
@@ -236,28 +238,29 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
setSelectingDate(DateSelected.FROM);
};
const handleDowndloadResponses = async (filter: FilterDownload, filetype: "csv" | "xlsx") => {
try {
const responseFilters = filter === FilterDownload.ALL ? {} : filters;
const responsesDownloadUrlResponse = await getResponsesDownloadUrlAction({
surveyId: survey.id,
format: filetype,
filterCriteria: responseFilters,
});
if (responsesDownloadUrlResponse?.data) {
const link = document.createElement("a");
link.href = responsesDownloadUrlResponse.data;
link.download = "";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} else {
const errorMessage = getFormattedErrorMessage(responsesDownloadUrlResponse);
toast.error(errorMessage);
}
} catch (error) {
toast.error("Error downloading responses");
const handleDownloadResponses = async (filter: FilterDownload, filetype: "csv" | "xlsx") => {
const responseFilters = filter === FilterDownload.ALL ? {} : filters;
setIsDownloading(true);
const responsesDownloadUrlResponse = await getResponsesDownloadUrlAction({
surveyId: survey.id,
format: filetype,
filterCriteria: responseFilters,
});
if (responsesDownloadUrlResponse?.data) {
const link = document.createElement("a");
link.href = responsesDownloadUrlResponse.data;
link.download = "";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} else {
const errorMessage = getFormattedErrorMessage(responsesDownloadUrlResponse);
toast.error(errorMessage);
}
setIsDownloading(false);
};
useClickOutside(datePickerRef, () => handleDatePickerClose());
@@ -386,11 +389,22 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
onOpenChange={(value) => {
value && handleDatePickerClose();
}}>
<DropdownMenuTrigger asChild className="focus:bg-muted cursor-pointer outline-none">
<DropdownMenuTrigger
asChild
className={cn(
"focus:bg-muted cursor-pointer outline-none",
isDownloading && "cursor-not-allowed opacity-50"
)}
disabled={isDownloading}
data-testid="fb__custom-filter-download-responses-button">
<div className="min-w-auto h-auto rounded-md border border-slate-200 bg-white p-3 hover:border-slate-300 sm:flex sm:px-6 sm:py-3">
<div className="hidden w-full items-center justify-between sm:flex">
<span className="text-sm text-slate-700">{t("common.download")}</span>
<ArrowDownToLineIcon className="ml-2 h-4 w-4" />
{isDownloading ? (
<Loader2Icon className="ml-2 h-4 w-4 animate-spin" />
) : (
<ArrowDownToLineIcon className="ml-2 h-4 w-4" />
)}
</div>
<DownloadIcon className="block h-4 sm:hidden" />
</div>
@@ -398,26 +412,30 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
<DropdownMenuContent align="start">
<DropdownMenuItem
onClick={() => {
handleDowndloadResponses(FilterDownload.ALL, "csv");
data-testid="fb__custom-filter-download-all-csv"
onClick={async () => {
await handleDownloadResponses(FilterDownload.ALL, "csv");
}}>
<p className="text-slate-700">{t("environments.surveys.summary.all_responses_csv")}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
handleDowndloadResponses(FilterDownload.ALL, "xlsx");
data-testid="fb__custom-filter-download-all-xlsx"
onClick={async () => {
await handleDownloadResponses(FilterDownload.ALL, "xlsx");
}}>
<p className="text-slate-700">{t("environments.surveys.summary.all_responses_excel")}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
handleDowndloadResponses(FilterDownload.FILTER, "csv");
data-testid="fb__custom-filter-download-filtered-csv"
onClick={async () => {
await handleDownloadResponses(FilterDownload.FILTER, "csv");
}}>
<p className="text-slate-700">{t("environments.surveys.summary.filtered_responses_csv")}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
handleDowndloadResponses(FilterDownload.FILTER, "xlsx");
data-testid="fb__custom-filter-download-filtered-xlsx"
onClick={async () => {
await handleDownloadResponses(FilterDownload.FILTER, "xlsx");
}}>
<p className="text-slate-700">{t("environments.surveys.summary.filtered_responses_excel")}</p>
</DropdownMenuItem>

View File

@@ -13,7 +13,7 @@ export default function GlobalError({ error }: { error: Error & { digest?: strin
}
}, [error]);
return (
<html>
<html lang="en-US">
<body>
<NextError statusCode={0} />
</body>

View File

@@ -224,16 +224,8 @@ export const getMonthlyActiveOrganizationPeopleCount = reactCache(
async (organizationId: string): Promise<number> => {
validateInputs([organizationId, ZId]);
try {
// temporary solution until we have a better way to track active users
return 0;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
// temporary solution until we have a better way to track active users
return 0;
}
);

View File

@@ -1,11 +1,17 @@
import { Prisma } from "@prisma/client";
import { describe, expect, test, vi } from "vitest";
import { describe, expect, test } from "vitest";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import {
TSurvey,
TSurveyOpenTextQuestion,
TSurveyQuestion,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import {
buildWhereClause,
calculateTtcTotal,
extracMetadataKeys,
extractChoiceIdsFromResponse,
extractSurveyDetails,
generateAllPermutationsOfSubsets,
getResponseContactAttributes,
@@ -555,3 +561,176 @@ describe("Response Utils", () => {
});
});
});
describe("extractChoiceIdsFromResponse", () => {
const multipleChoiceMultiQuestion: TSurveyQuestion = {
id: "multi-choice-id",
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
headline: { default: "Select multiple options" },
required: false,
choices: [
{
id: "choice-1",
label: { default: "Option 1", es: "Opción 1" },
},
{
id: "choice-2",
label: { default: "Option 2", es: "Opción 2" },
},
{
id: "choice-3",
label: { default: "Option 3", es: "Opción 3" },
},
],
};
const multipleChoiceSingleQuestion: TSurveyQuestion = {
id: "single-choice-id",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
headline: { default: "Select one option" },
required: false,
choices: [
{
id: "choice-a",
label: { default: "Choice A", fr: "Choix A" },
},
{
id: "choice-b",
label: { default: "Choice B", fr: "Choix B" },
},
],
};
const textQuestion: TSurveyOpenTextQuestion = {
id: "text-id",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "What do you think?" },
required: false,
inputType: "text",
charLimit: { enabled: false, min: 0, max: 0 },
};
describe("multipleChoiceMulti questions", () => {
test("should extract choice IDs from array response with default language", () => {
const responseValue = ["Option 1", "Option 3"];
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceMultiQuestion, "default");
expect(result).toEqual(["choice-1", "choice-3"]);
});
test("should extract choice IDs from array response with specific language", () => {
const responseValue = ["Opción 1", "Opción 2"];
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceMultiQuestion, "es");
expect(result).toEqual(["choice-1", "choice-2"]);
});
test("should fall back to checking all language values when exact language match fails", () => {
const responseValue = ["Opción 1", "Option 2"];
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceMultiQuestion, "default");
expect(result).toEqual(["choice-1", "choice-2"]);
});
test("should render other option when non-matching choice is selected", () => {
const responseValue = ["Option 1", "Non-existent option", "Option 3"];
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceMultiQuestion, "default");
expect(result).toEqual(["choice-1", "other", "choice-3"]);
});
test("should return empty array for empty response", () => {
const responseValue: string[] = [];
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceMultiQuestion, "default");
expect(result).toEqual([]);
});
});
describe("multipleChoiceSingle questions", () => {
test("should extract choice ID from string response with default language", () => {
const responseValue = "Choice A";
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceSingleQuestion, "default");
expect(result).toEqual(["choice-a"]);
});
test("should extract choice ID from string response with specific language", () => {
const responseValue = "Choix B";
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceSingleQuestion, "fr");
expect(result).toEqual(["choice-b"]);
});
test("should fall back to checking all language values for single choice", () => {
const responseValue = "Choix A";
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceSingleQuestion, "default");
expect(result).toEqual(["choice-a"]);
});
test("should return empty array for empty string response", () => {
const responseValue = "";
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceSingleQuestion, "default");
expect(result).toEqual([]);
});
});
describe("edge cases", () => {
test("should return empty array for non-multiple choice questions", () => {
const responseValue = "Some text response";
const result = extractChoiceIdsFromResponse(responseValue, textQuestion, "default");
expect(result).toEqual([]);
});
test("should handle missing language parameter by defaulting to 'default'", () => {
const responseValue = "Option 1";
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceMultiQuestion);
expect(result).toEqual(["choice-1"]);
});
test("should handle numeric or other types by returning empty array", () => {
const responseValue = 123;
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceMultiQuestion, "default");
expect(result).toEqual([]);
});
test("should handle object responses by returning empty array", () => {
const responseValue = { invalid: "object" };
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceMultiQuestion, "default");
expect(result).toEqual([]);
});
});
describe("language handling", () => {
test("should use provided language parameter", () => {
const responseValue = ["Opción 1"];
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceMultiQuestion, "es");
expect(result).toEqual(["choice-1"]);
});
test("should handle null language parameter by defaulting to 'default'", () => {
const responseValue = ["Option 1"];
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceMultiQuestion, null as any);
expect(result).toEqual(["choice-1"]);
});
test("should handle undefined language parameter by defaulting to 'default'", () => {
const responseValue = ["Option 1"];
const result = extractChoiceIdsFromResponse(
responseValue,
multipleChoiceMultiQuestion,
undefined as any
);
expect(result).toEqual(["choice-1"]);
});
});
});

View File

@@ -1,20 +1,103 @@
import "server-only";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { Prisma } from "@prisma/client";
import {
TResponse,
TResponseDataValue,
TResponseFilterCriteria,
TResponseHiddenFieldsFilter,
TResponseTtc,
TSurveyContactAttributes,
TSurveyMetaFieldFilter,
} from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import {
TSurvey,
TSurveyMultipleChoiceQuestion,
TSurveyPictureSelectionQuestion,
TSurveyQuestion,
TSurveyRankingQuestion,
} from "@formbricks/types/surveys/types";
import { processResponseData } from "../responses";
import { getTodaysDateTimeFormatted } from "../time";
import { getFormattedDateTimeString } from "../utils/datetime";
import { sanitizeString } from "../utils/strings";
/**
* Extracts choice IDs from response values for multiple choice questions
* @param responseValue - The response value (string for single choice, array for multi choice)
* @param question - The survey question containing choices
* @param language - The language to match against (defaults to "default")
* @returns Array of choice IDs
*/
export const extractChoiceIdsFromResponse = (
responseValue: TResponseDataValue,
question: TSurveyQuestion,
language: string = "default"
): string[] => {
// Type guard to ensure the question has choices
if (
question.type !== "multipleChoiceMulti" &&
question.type !== "multipleChoiceSingle" &&
question.type !== "ranking" &&
question.type !== "pictureSelection"
) {
return [];
}
const isPictureSelection = question.type === "pictureSelection";
if (!responseValue) {
return [];
}
// For picture selection questions, the response value is already choice ID(s)
if (isPictureSelection) {
if (Array.isArray(responseValue)) {
// Multi-selection: array of choice IDs
return responseValue.filter((id): id is string => typeof id === "string");
} else if (typeof responseValue === "string") {
// Single selection: single choice ID
return [responseValue];
}
return [];
}
const defaultLanguage = language ?? "default";
// Helper function to find choice by label - eliminates duplication
const findChoiceByLabel = (choiceLabel: string): string | null => {
const targetChoice = question.choices.find((c) => {
// Try exact language match first
if (c.label[defaultLanguage] === choiceLabel) {
return true;
}
// Fall back to checking all language values
return Object.values(c.label).includes(choiceLabel);
});
return targetChoice?.id || "other";
};
if (Array.isArray(responseValue)) {
// Multiple choice case - response is an array of selected choice labels
return responseValue.map(findChoiceByLabel).filter((choiceId): choiceId is string => choiceId !== null);
} else if (typeof responseValue === "string") {
// Single choice case - response is a single choice label
const choiceId = findChoiceByLabel(responseValue);
return choiceId ? [choiceId] : [];
}
return [];
};
export const getChoiceIdByValue = (
value: string,
question: TSurveyMultipleChoiceQuestion | TSurveyRankingQuestion | TSurveyPictureSelectionQuestion
) => {
if (question.type === "pictureSelection") {
return question.choices.find((choice) => choice.imageUrl === value)?.id ?? "other";
}
return question.choices.find((choice) => choice.label.default === value)?.id ?? "other";
};
export const calculateTtcTotal = (ttc: TResponseTtc) => {
const result = { ...ttc };
result._total = Object.values(result).reduce((acc: number, val: number) => acc + val, 0);
@@ -490,10 +573,17 @@ export const extractSurveyDetails = (survey: TSurvey, responses: TResponse[]) =>
return question.rows.map((row) => {
return `${idx + 1}. ${headline} - ${getLocalizedValue(row, "default")}`;
});
} else if (
question.type === "multipleChoiceMulti" ||
question.type === "multipleChoiceSingle" ||
question.type === "ranking"
) {
return [`${idx + 1}. ${headline}`, `${idx + 1}. ${headline} - Option ID`];
} else {
return [`${idx + 1}. ${headline}`];
}
});
const hiddenFields = survey.hiddenFields?.fieldIds || [];
const userAttributes =
survey.type === "app"
@@ -556,6 +646,19 @@ export const getResponsesJson = (
}
}
});
} else if (
question.type === "multipleChoiceMulti" ||
question.type === "multipleChoiceSingle" ||
question.type === "ranking"
) {
// Set the main response value
jsonData[idx][questionHeadline[0]] = processResponseData(answer);
// Set the option IDs using the reusable function
if (questionHeadline[1]) {
const choiceIds = extractChoiceIdsFromResponse(answer, question, response.language || "default");
jsonData[idx][questionHeadline[1]] = choiceIds.join(", ");
}
} else {
jsonData[idx][questionHeadline[0]] = processResponseData(answer);
}

View File

@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { deepDiff, redactPII } from "./utils";
import { deepDiff, redactPII, sanitizeUrlForLogging } from "./logger-helpers";
// Patch redis multi before any imports
beforeEach(async () => {
@@ -104,7 +104,7 @@ describe("withAuditLogging", () => {
});
test("logs audit event for successful handler", async () => {
const handler = vi.fn().mockResolvedValue("ok");
const { withAuditLogging } = await import("./handler");
const { withAuditLogging } = await import("../../modules/ee/audit-logs/lib/handler");
const wrapped = withAuditLogging("created", "survey", handler);
const ctx = {
user: {
@@ -143,7 +143,7 @@ describe("withAuditLogging", () => {
});
test("logs audit event for failed handler and throws", async () => {
const handler = vi.fn().mockRejectedValue(new Error("fail"));
const { withAuditLogging } = await import("./handler");
const { withAuditLogging } = await import("../../modules/ee/audit-logs/lib/handler");
const wrapped = withAuditLogging("created", "survey", handler);
const ctx = {
user: {
@@ -181,3 +181,37 @@ describe("withAuditLogging", () => {
expect(handler).toHaveBeenCalled();
});
});
describe("sanitizeUrlForLogging", () => {
test("returns sanitized URL with token", () => {
expect(sanitizeUrlForLogging("https://example.com?token=1234567890")).toBe(
"https://example.com/?token=********"
);
});
test("returns sanitized URL with code", () => {
expect(sanitizeUrlForLogging("https://example.com?code=1234567890")).toBe(
"https://example.com/?code=********"
);
});
test("returns sanitized URL with state", () => {
expect(sanitizeUrlForLogging("https://example.com?state=1234567890")).toBe(
"https://example.com/?state=********"
);
});
test("returns sanitized URL with multiple keys", () => {
expect(
sanitizeUrlForLogging("https://example.com?token=1234567890&code=1234567890&state=1234567890")
).toBe("https://example.com/?token=********&code=********&state=********");
});
test("returns sanitized URL without query params", () => {
expect(sanitizeUrlForLogging("https://example.com")).toBe("https://example.com/");
});
test("returns sanitized URL with invalid URL", () => {
expect(sanitizeUrlForLogging("not-a-valid-url")).toBe("[invalid-url]");
});
});

View File

@@ -1,3 +1,5 @@
import { isStringUrl } from "@/lib/utils/url";
const SENSITIVE_KEYS = [
"email",
"name",
@@ -33,8 +35,11 @@ const SENSITIVE_KEYS = [
"image",
"stripeCustomerId",
"fileName",
"state",
];
const URL_SENSITIVE_KEYS = ["token", "code", "state"];
/**
* Redacts sensitive data from the object by replacing the sensitive keys with "********".
* @param obj - The object to redact.
@@ -45,6 +50,10 @@ export const redactPII = (obj: any, seen: WeakSet<any> = new WeakSet()): any =>
return obj.toISOString();
}
if (typeof obj === "string" && isStringUrl(obj)) {
return sanitizeUrlForLogging(obj);
}
if (obj && typeof obj === "object") {
if (seen.has(obj)) return "[Circular]";
seen.add(obj);
@@ -89,3 +98,24 @@ export const deepDiff = (oldObj: any, newObj: any): any => {
}
return Object.keys(diff).length > 0 ? diff : undefined;
};
/**
* Sanitizes a URL for logging by redacting sensitive parameters.
* @param url - The URL to sanitize.
* @returns The sanitized URL.
*/
export const sanitizeUrlForLogging = (url: string): string => {
try {
const urlObj = new URL(url);
URL_SENSITIVE_KEYS.forEach((key) => {
if (urlObj.searchParams.has(key)) {
urlObj.searchParams.set(key, "********");
}
});
return urlObj.origin + urlObj.pathname + (urlObj.search ? `${urlObj.search}` : "");
} catch {
return "[invalid-url]";
}
};

View File

@@ -1,7 +1,7 @@
import { cleanup } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import { TActionClassPageUrlRule } from "@formbricks/types/action-classes";
import { isValidCallbackUrl, testURLmatch } from "./url";
import { isStringUrl, isValidCallbackUrl, testURLmatch } from "./url";
afterEach(() => {
cleanup();
@@ -91,3 +91,13 @@ describe("isValidCallbackUrl", () => {
expect(isValidCallbackUrl("not-a-valid-url", WEBAPP_URL)).toBe(false);
});
});
describe("isStringUrl", () => {
test("returns true for valid URL", () => {
expect(isStringUrl("https://example.com")).toBe(true);
});
test("returns false for invalid URL", () => {
expect(isStringUrl("not-a-valid-url")).toBe(false);
});
});

View File

@@ -49,3 +49,12 @@ export const isValidCallbackUrl = (url: string, WEBAPP_URL: string): boolean =>
return false;
}
};
export const isStringUrl = (url: string): boolean => {
try {
new URL(url);
return true;
} catch {
return false;
}
};

View File

@@ -124,6 +124,7 @@
"add_action": "Aktion hinzufügen",
"add_filter": "Filter hinzufügen",
"add_logo": "Logo hinzufügen",
"add_member": "Mitglied hinzufügen",
"add_project": "Projekt hinzufügen",
"add_to_team": "Zum Team hinzufügen",
"all": "Alle",
@@ -279,6 +280,8 @@
"on": "An",
"only_one_file_allowed": "Es ist nur eine Datei erlaubt",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Nur Eigentümer, Manager und Mitglieder mit Zugriff auf das Management können diese Aktion ausführen.",
"option_id": "Option-ID",
"option_ids": "Option-IDs",
"or": "oder",
"organization": "Organisation",
"organization_id": "Organisations-ID",
@@ -305,6 +308,7 @@
"privacy": "Datenschutz",
"product_manager": "Produktmanager",
"profile": "Profil",
"profile_id": "Profil-ID",
"project_configuration": "Projektkonfiguration",
"project_creation_description": "Organisieren Sie Umfragen in Projekten für eine bessere Zugriffskontrolle.",
"project_id": "Projekt-ID",
@@ -385,6 +389,7 @@
"targeting": "Targeting",
"team": "Team",
"team_access": "Teamzugriff",
"team_id": "Team-ID",
"team_name": "Teamname",
"teams": "Zugriffskontrolle",
"teams_not_found": "Teams nicht gefunden",
@@ -1311,7 +1316,7 @@
"columns": "Spalten",
"company": "Firma",
"company_logo": "Firmenlogo",
"completed_responses": "abgeschlossene Antworten",
"completed_responses": "unvollständige oder vollständige Antworten.",
"concat": "Verketten +",
"conditional_logic": "Bedingte Logik",
"confirm_default_language": "Standardsprache bestätigen",
@@ -1639,6 +1644,7 @@
"company": "Firma",
"completed": "Erledigt ✅",
"country": "Land",
"delete_response_confirmation": "Dies wird die Umfrageantwort einschließlich aller Antworten, Notizen, Tags, angehängter Dokumente und Antwortmetadaten löschen.",
"device": "Gerät",
"device_info": "Geräteinfo",
"email": "E-Mail",
@@ -1813,7 +1819,6 @@
"last_quarter": "Letztes Quartal",
"last_year": "Letztes Jahr",
"no_responses_found": "Keine Antworten gefunden",
"only_completed": "Nur vollständige Antworten",
"other_values_found": "Andere Werte gefunden",
"overall": "Insgesamt",
"qr_code": "QR-Code",

View File

@@ -124,6 +124,7 @@
"add_action": "Add action",
"add_filter": "Add filter",
"add_logo": "Add logo",
"add_member": "Add member",
"add_project": "Add project",
"add_to_team": "Add to team",
"all": "All",
@@ -279,6 +280,8 @@
"on": "On",
"only_one_file_allowed": "Only one file is allowed",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Only owners and managers can perform this action.",
"option_id": "Option ID",
"option_ids": "Option IDs",
"or": "or",
"organization": "Organization",
"organization_id": "Organization ID",
@@ -305,6 +308,7 @@
"privacy": "Privacy Policy",
"product_manager": "Product Manager",
"profile": "Profile",
"profile_id": "Profile ID",
"project_configuration": "Project's Configuration",
"project_creation_description": "Organize surveys in projects for better access control.",
"project_id": "Project ID",
@@ -385,6 +389,7 @@
"targeting": "Targeting",
"team": "Team",
"team_access": "Team Access",
"team_id": "Team ID",
"team_name": "Team name",
"teams": "Access Control",
"teams_not_found": "Teams not found",
@@ -1311,7 +1316,7 @@
"columns": "Columns",
"company": "Company",
"company_logo": "Company logo",
"completed_responses": "completed responses.",
"completed_responses": "partial or completed responses.",
"concat": "Concat +",
"conditional_logic": "Conditional Logic",
"confirm_default_language": "Confirm default language",
@@ -1639,6 +1644,7 @@
"company": "Company",
"completed": "Completed ✅",
"country": "Country",
"delete_response_confirmation": "This will delete the survey response, including all answers, notes, tags, attached documents, and response metadata.",
"device": "Device",
"device_info": "Device info",
"email": "Email",
@@ -1813,7 +1819,6 @@
"last_quarter": "Last quarter",
"last_year": "Last year",
"no_responses_found": "No responses found",
"only_completed": "Only completed",
"other_values_found": "Other values found",
"overall": "Overall",
"qr_code": "QR code",

View File

@@ -124,6 +124,7 @@
"add_action": "Ajouter une action",
"add_filter": "Ajouter un filtre",
"add_logo": "Ajouter un logo",
"add_member": "Ajouter un membre",
"add_project": "Ajouter un projet",
"add_to_team": "Ajouter à l'équipe",
"all": "Tout",
@@ -279,6 +280,8 @@
"on": "Sur",
"only_one_file_allowed": "Un seul fichier est autorisé",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Seules les propriétaires, les gestionnaires et les membres ayant accès à la gestion peuvent effectuer cette action.",
"option_id": "Identifiant de l'option",
"option_ids": "Identifiants des options",
"or": "ou",
"organization": "Organisation",
"organization_id": "ID de l'organisation",
@@ -305,6 +308,7 @@
"privacy": "Politique de confidentialité",
"product_manager": "Chef de produit",
"profile": "Profil",
"profile_id": "Identifiant de profil",
"project_configuration": "Configuration du projet",
"project_creation_description": "Organisez les enquêtes en projets pour un meilleur contrôle d'accès.",
"project_id": "ID de projet",
@@ -385,6 +389,7 @@
"targeting": "Ciblage",
"team": "Équipe",
"team_access": "Accès Équipe",
"team_id": "Équipe ID",
"team_name": "Nom de l'équipe",
"teams": "Contrôle d'accès",
"teams_not_found": "Équipes non trouvées",
@@ -1202,7 +1207,7 @@
"delete_survey_and_responses_warning": "Êtes-vous sûr de vouloir supprimer cette enquête et toutes ses réponses?",
"edit": {
"1_choose_the_default_language_for_this_survey": "1. Choisissez la langue par défaut pour ce sondage :",
"2_activate_translation_for_specific_languages": "2. Activer la traduction pour des langues spécifiques :",
"2_activate_translation_for_specific_languages": "2. Activer la traduction pour des langues spécifiques:",
"add": "Ajouter +",
"add_a_delay_or_auto_close_the_survey": "Ajouter un délai ou fermer automatiquement l'enquête",
"add_a_four_digit_pin": "Ajoutez un code PIN à quatre chiffres.",
@@ -1311,7 +1316,7 @@
"columns": "Colonnes",
"company": "Société",
"company_logo": "Logo de l'entreprise",
"completed_responses": "réponses complètes.",
"completed_responses": "des réponses partielles ou complètes.",
"concat": "Concat +",
"conditional_logic": "Logique conditionnelle",
"confirm_default_language": "Confirmer la langue par défaut",
@@ -1639,6 +1644,7 @@
"company": "Société",
"completed": "Terminé ✅",
"country": "Pays",
"delete_response_confirmation": "\"Cela supprimera la réponse au sondage, y compris toutes les réponses, notes, étiquettes, documents joints et métadonnées de réponse.\"",
"device": "Dispositif",
"device_info": "Informations sur l'appareil",
"email": "Email",
@@ -1813,7 +1819,6 @@
"last_quarter": "dernier trimestre",
"last_year": "l'année dernière",
"no_responses_found": "Aucune réponse trouvée",
"only_completed": "Uniquement terminé",
"other_values_found": "D'autres valeurs trouvées",
"overall": "Globalement",
"qr_code": "Code QR",

View File

@@ -124,6 +124,7 @@
"add_action": "Adicionar ação",
"add_filter": "Adicionar filtro",
"add_logo": "Adicionar logo",
"add_member": "Adicionar membro",
"add_project": "Adicionar projeto",
"add_to_team": "Adicionar à equipe",
"all": "Todos",
@@ -279,6 +280,8 @@
"on": "ligado",
"only_one_file_allowed": "É permitido apenas um arquivo",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Apenas proprietários, gerentes e membros com acesso de gerenciamento podem realizar essa ação.",
"option_id": "ID da opção",
"option_ids": "IDs da Opção",
"or": "ou",
"organization": "organização",
"organization_id": "ID da Organização",
@@ -305,6 +308,7 @@
"privacy": "Política de Privacidade",
"product_manager": "Gerente de Produto",
"profile": "Perfil",
"profile_id": "ID de Perfil",
"project_configuration": "Configuração do Projeto",
"project_creation_description": "Organize pesquisas em projetos para melhor controle de acesso.",
"project_id": "ID do Projeto",
@@ -385,6 +389,7 @@
"targeting": "mirando",
"team": "Time",
"team_access": "Acesso da equipe",
"team_id": "ID da Equipe",
"team_name": "Nome da equipe",
"teams": "Controle de Acesso",
"teams_not_found": "Equipes não encontradas",
@@ -1311,7 +1316,7 @@
"columns": "colunas",
"company": "empresa",
"company_logo": "Logo da empresa",
"completed_responses": "respostas completas",
"completed_responses": "respostas parciais ou completas.",
"concat": "Concatenar +",
"conditional_logic": "Lógica Condicional",
"confirm_default_language": "Confirmar idioma padrão",
@@ -1639,6 +1644,7 @@
"company": "empresa",
"completed": "Concluído ✅",
"country": "País",
"delete_response_confirmation": "Isso excluirá a resposta da pesquisa, incluindo todas as respostas, notas, etiquetas, documentos anexados e metadados da resposta.",
"device": "dispositivo",
"device_info": "Informações do dispositivo",
"email": "Email",
@@ -1813,7 +1819,6 @@
"last_quarter": "Último trimestre",
"last_year": "Último ano",
"no_responses_found": "Nenhuma resposta encontrada",
"only_completed": "Somente concluído",
"other_values_found": "Outros valores encontrados",
"overall": "No geral",
"qr_code": "Código QR",

View File

@@ -124,6 +124,7 @@
"add_action": "Adicionar ação",
"add_filter": "Adicionar filtro",
"add_logo": "Adicionar logótipo",
"add_member": "Adicionar membro",
"add_project": "Adicionar projeto",
"add_to_team": "Adicionar à equipa",
"all": "Todos",
@@ -279,6 +280,8 @@
"on": "Ligado",
"only_one_file_allowed": "Apenas um ficheiro é permitido",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Apenas proprietários e gestores podem realizar esta ação.",
"option_id": "ID de Opção",
"option_ids": "IDs de Opção",
"or": "ou",
"organization": "Organização",
"organization_id": "ID da Organização",
@@ -305,6 +308,7 @@
"privacy": "Política de Privacidade",
"product_manager": "Gestor de Produto",
"profile": "Perfil",
"profile_id": "ID do Perfil",
"project_configuration": "Configuração do Projeto",
"project_creation_description": "Organize questionários em projetos para um melhor controlo de acesso.",
"project_id": "ID do Projeto",
@@ -385,6 +389,7 @@
"targeting": "Segmentação",
"team": "Equipa",
"team_access": "Acesso da Equipa",
"team_id": "ID da Equipa",
"team_name": "Nome da equipa",
"teams": "Controlo de Acesso",
"teams_not_found": "Equipas não encontradas",
@@ -1311,7 +1316,7 @@
"columns": "Colunas",
"company": "Empresa",
"company_logo": "Logotipo da empresa",
"completed_responses": "respostas concluídas",
"completed_responses": "respostas parciais ou completas",
"concat": "Concatenar +",
"conditional_logic": "Lógica Condicional",
"confirm_default_language": "Confirmar idioma padrão",
@@ -1639,6 +1644,7 @@
"company": "Empresa",
"completed": "Concluído ✅",
"country": "País",
"delete_response_confirmation": "Isto irá eliminar a resposta ao questionário, incluindo todas as respostas, notas, etiquetas, documentos anexados e metadados da resposta.",
"device": "Dispositivo",
"device_info": "Informações do dispositivo",
"email": "Email",
@@ -1813,7 +1819,6 @@
"last_quarter": "Último trimestre",
"last_year": "Ano passado",
"no_responses_found": "Nenhuma resposta encontrada",
"only_completed": "Apenas concluído",
"other_values_found": "Outros valores encontrados",
"overall": "Geral",
"qr_code": "Código QR",

2864
apps/web/locales/ro-RO.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -124,6 +124,7 @@
"add_action": "新增操作",
"add_filter": "新增篩選器",
"add_logo": "新增標誌",
"add_member": "新增成員",
"add_project": "新增專案",
"add_to_team": "新增至團隊",
"all": "全部",
@@ -279,6 +280,8 @@
"on": "開啟",
"only_one_file_allowed": "僅允許一個檔案",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "只有擁有者、管理員和管理存取權限的成員才能執行此操作。",
"option_id": "選項 ID",
"option_ids": "選項 IDs",
"or": "或",
"organization": "組織",
"organization_id": "組織 ID",
@@ -305,6 +308,7 @@
"privacy": "隱私權政策",
"product_manager": "產品經理",
"profile": "個人資料",
"profile_id": "個人資料 ID",
"project_configuration": "專案組態",
"project_creation_description": "組織調查 在 專案中以便更好地存取控制。",
"project_id": "專案 ID",
@@ -385,6 +389,7 @@
"targeting": "目標設定",
"team": "團隊",
"team_access": "團隊存取權限",
"team_id": "團隊 ID",
"team_name": "團隊名稱",
"teams": "存取控制",
"teams_not_found": "找不到團隊",
@@ -1311,7 +1316,7 @@
"columns": "欄位",
"company": "公司",
"company_logo": "公司標誌",
"completed_responses": "完成的回應。",
"completed_responses": "部分或完整答复。",
"concat": "串連 +",
"conditional_logic": "條件邏輯",
"confirm_default_language": "確認預設語言",
@@ -1639,6 +1644,7 @@
"company": "公司",
"completed": "已完成 ✅",
"country": "國家/地區",
"delete_response_confirmation": "這將刪除調查回覆,包括所有答案、註解、標籤、附加文件和回覆元數據。",
"device": "裝置",
"device_info": "裝置資訊",
"email": "電子郵件",
@@ -1813,7 +1819,6 @@
"last_quarter": "上一季",
"last_year": "去年",
"no_responses_found": "找不到回應",
"only_completed": "僅已完成",
"other_values_found": "找到其他值",
"overall": "整體",
"qr_code": "QR 碼",

View File

@@ -12,9 +12,10 @@ vi.mock("@/modules/ui/components/file-upload-response", () => ({
),
}));
vi.mock("@/modules/ui/components/picture-selection-response", () => ({
PictureSelectionResponse: ({ selected, isExpanded }: any) => (
<div data-testid="PictureSelectionResponse">
PictureSelection: {selected.join(",")} ({isExpanded ? "expanded" : "collapsed"})
PictureSelectionResponse: ({ selected, isExpanded, showId }: any) => (
<div data-testid="PictureSelectionResponse" data-show-id={showId}>
PictureSelection: {selected.join(",")} ({isExpanded ? "expanded" : "collapsed"}) showId:{" "}
{String(showId)}
</div>
),
}));
@@ -22,10 +23,28 @@ vi.mock("@/modules/ui/components/array-response", () => ({
ArrayResponse: ({ value }: any) => <div data-testid="ArrayResponse">{value.join(",")}</div>,
}));
vi.mock("@/modules/ui/components/response-badges", () => ({
ResponseBadges: ({ items }: any) => <div data-testid="ResponseBadges">{items.join(",")}</div>,
ResponseBadges: ({ items, showId }: any) => (
<div data-testid="ResponseBadges" data-show-id={showId}>
{Array.isArray(items)
? items
.map((item) => (typeof item === "object" ? `${item.value}:${item.id || "no-id"}` : item))
.join(",")
: items}{" "}
showId: {String(showId)}
</div>
),
}));
vi.mock("@/modules/ui/components/ranking-response", () => ({
RankingResponse: ({ value }: any) => <div data-testid="RankingResponse">{value.join(",")}</div>,
RankingResponse: ({ value, showId }: any) => (
<div data-testid="RankingResponse" data-show-id={showId}>
{Array.isArray(value)
? value
.map((item) => (typeof item === "object" ? `${item.value}:${item.id || "no-id"}` : item))
.join(",")
: value}{" "}
showId: {String(showId)}
</div>
),
}));
vi.mock("@/modules/analysis/utils", () => ({
renderHyperlinkedContent: vi.fn((text: string) => "hyper:" + text),
@@ -50,7 +69,14 @@ describe("RenderResponse", () => {
});
const defaultSurvey = { languages: [] } as any;
const defaultQuestion = { id: "q1", type: "Unknown" } as any;
const defaultQuestion = {
id: "q1",
type: "Unknown",
choices: [
{ id: "choice1", label: { default: "Option 1" } },
{ id: "choice2", label: { default: "Option 2" } },
],
} as any;
const dummyLanguage = "default";
test("returns '-' for empty responseData (string)", () => {
@@ -60,6 +86,7 @@ describe("RenderResponse", () => {
question={defaultQuestion}
survey={defaultSurvey}
language={dummyLanguage}
showId={false}
/>
);
expect(container.textContent).toBe("-");
@@ -72,6 +99,7 @@ describe("RenderResponse", () => {
question={defaultQuestion}
survey={defaultSurvey}
language={dummyLanguage}
showId={false}
/>
);
expect(container.textContent).toBe("-");
@@ -84,6 +112,7 @@ describe("RenderResponse", () => {
question={defaultQuestion}
survey={defaultSurvey}
language={dummyLanguage}
showId={false}
/>
);
expect(container.textContent).toBe("-");
@@ -92,7 +121,13 @@ describe("RenderResponse", () => {
test("renders RatingResponse for 'Rating' question with number", () => {
const question = { ...defaultQuestion, type: "rating", scale: 5, range: [1, 5] };
render(
<RenderResponse responseData={4} question={question} survey={defaultSurvey} language={dummyLanguage} />
<RenderResponse
responseData={4}
question={question}
survey={defaultSurvey}
language={dummyLanguage}
showId={false}
/>
);
expect(screen.getByTestId("RatingResponse")).toHaveTextContent("Rating: 4");
});
@@ -106,6 +141,7 @@ describe("RenderResponse", () => {
question={question}
survey={defaultSurvey}
language={dummyLanguage}
showId={false}
/>
);
expect(screen.getByText(/formatted_/)).toBeInTheDocument();
@@ -119,6 +155,7 @@ describe("RenderResponse", () => {
question={question}
survey={defaultSurvey}
language={dummyLanguage}
showId={false}
/>
);
expect(screen.getByTestId("PictureSelectionResponse")).toHaveTextContent(
@@ -134,6 +171,7 @@ describe("RenderResponse", () => {
question={question}
survey={defaultSurvey}
language={dummyLanguage}
showId={false}
/>
);
expect(screen.getByTestId("FileUploadResponse")).toHaveTextContent("FileUpload: file1,file2");
@@ -149,6 +187,7 @@ describe("RenderResponse", () => {
question={question}
survey={{ languages: [] } as any}
language={dummyLanguage}
showId={false}
/>
);
expect(screen.getByText("row1:processed:answer1")).toBeInTheDocument();
@@ -163,6 +202,7 @@ describe("RenderResponse", () => {
question={question}
survey={defaultSurvey}
language={dummyLanguage}
showId={false}
/>
);
expect(screen.getByTestId("ArrayResponse")).toHaveTextContent("addr1,addr2");
@@ -176,6 +216,7 @@ describe("RenderResponse", () => {
question={question}
survey={defaultSurvey}
language={dummyLanguage}
showId={false}
/>
);
expect(screen.getByTestId("ResponseBadges")).toHaveTextContent("Value");
@@ -184,7 +225,13 @@ describe("RenderResponse", () => {
test("renders ResponseBadges for 'Consent' question (number)", () => {
const question = { ...defaultQuestion, type: "consent" };
render(
<RenderResponse responseData={5} question={question} survey={defaultSurvey} language={dummyLanguage} />
<RenderResponse
responseData={5}
question={question}
survey={defaultSurvey}
language={dummyLanguage}
showId={false}
/>
);
expect(screen.getByTestId("ResponseBadges")).toHaveTextContent("5");
});
@@ -197,56 +244,67 @@ describe("RenderResponse", () => {
question={question}
survey={defaultSurvey}
language={dummyLanguage}
showId={false}
/>
);
expect(screen.getByTestId("ResponseBadges")).toHaveTextContent("Click");
});
test("renders ResponseBadges for 'MultipleChoiceSingle' question (string)", () => {
const question = { ...defaultQuestion, type: "multipleChoiceSingle" };
const question = { ...defaultQuestion, type: "multipleChoiceSingle", choices: [] };
render(
<RenderResponse
responseData={"option1"}
question={question}
survey={defaultSurvey}
language={dummyLanguage}
showId={false}
/>
);
expect(screen.getByTestId("ResponseBadges")).toHaveTextContent("option1");
});
test("renders ResponseBadges for 'MultipleChoiceMulti' question (array)", () => {
const question = { ...defaultQuestion, type: "multipleChoiceMulti" };
const question = { ...defaultQuestion, type: "multipleChoiceMulti", choices: [] };
render(
<RenderResponse
responseData={["opt1", "opt2"]}
question={question}
survey={defaultSurvey}
language={dummyLanguage}
showId={false}
/>
);
expect(screen.getByTestId("ResponseBadges")).toHaveTextContent("opt1,opt2");
expect(screen.getByTestId("ResponseBadges")).toHaveTextContent("opt1:other,opt2:other");
});
test("renders ResponseBadges for 'NPS' question (number)", () => {
const question = { ...defaultQuestion, type: "nps" };
render(
<RenderResponse responseData={9} question={question} survey={defaultSurvey} language={dummyLanguage} />
<RenderResponse
responseData={9}
question={question}
survey={defaultSurvey}
language={dummyLanguage}
showId={false}
/>
);
expect(screen.getByTestId("ResponseBadges")).toHaveTextContent("9");
// NPS questions render as simple text, not ResponseBadges
expect(screen.getByText("9")).toBeInTheDocument();
});
test("renders RankingResponse for 'Ranking' question", () => {
const question = { ...defaultQuestion, type: "ranking" };
const question = { ...defaultQuestion, type: "ranking", choices: [] };
render(
<RenderResponse
responseData={["first", "second"]}
question={question}
survey={defaultSurvey}
language={dummyLanguage}
showId={false}
/>
);
expect(screen.getByTestId("RankingResponse")).toHaveTextContent("first,second");
expect(screen.getByTestId("RankingResponse")).toHaveTextContent("first:other,second:other showId: false");
});
test("renders default branch for unknown question type with string", () => {
@@ -257,6 +315,7 @@ describe("RenderResponse", () => {
question={question}
survey={defaultSurvey}
language={dummyLanguage}
showId={false}
/>
);
expect(screen.getByText("hyper:some text")).toBeInTheDocument();
@@ -270,8 +329,178 @@ describe("RenderResponse", () => {
question={question}
survey={defaultSurvey}
language={dummyLanguage}
showId={false}
/>
);
expect(screen.getByText("a, b")).toBeInTheDocument();
});
// New tests for showId functionality
test("passes showId prop to PictureSelectionResponse", () => {
const question = {
...defaultQuestion,
type: "pictureSelection",
choices: [{ id: "choice1", imageUrl: "url1" }],
};
render(
<RenderResponse
responseData={["choice1"]}
question={question}
survey={defaultSurvey}
language={dummyLanguage}
showId={true}
/>
);
const component = screen.getByTestId("PictureSelectionResponse");
expect(component).toHaveAttribute("data-show-id", "true");
expect(component).toHaveTextContent("showId: true");
});
test("passes showId prop to RankingResponse with choice ID extraction", () => {
const question = {
...defaultQuestion,
type: "ranking",
choices: [
{ id: "choice1", label: { default: "Option 1" } },
{ id: "choice2", label: { default: "Option 2" } },
],
};
render(
<RenderResponse
responseData={["Option 1", "Option 2"]}
question={question}
survey={defaultSurvey}
language={dummyLanguage}
showId={true}
/>
);
const component = screen.getByTestId("RankingResponse");
expect(component).toHaveAttribute("data-show-id", "true");
expect(component).toHaveTextContent("showId: true");
// Should extract choice IDs and pass them as value objects
expect(component).toHaveTextContent("Option 1:choice1,Option 2:choice2");
});
test("handles ranking response with missing choice IDs", () => {
const question = {
...defaultQuestion,
type: "ranking",
choices: [
{ id: "choice1", label: { default: "Option 1" } },
{ id: "choice2", label: { default: "Option 2" } },
],
};
render(
<RenderResponse
responseData={["Option 1", "Unknown Option"]}
question={question}
survey={defaultSurvey}
language={dummyLanguage}
showId={true}
/>
);
const component = screen.getByTestId("RankingResponse");
expect(component).toHaveTextContent("Option 1:choice1,Unknown Option:other");
});
test("passes showId prop to ResponseBadges for multiple choice single", () => {
const question = {
...defaultQuestion,
type: "multipleChoiceSingle",
choices: [{ id: "choice1", label: { default: "Option 1" } }],
};
render(
<RenderResponse
responseData={"Option 1"}
question={question}
survey={defaultSurvey}
language={dummyLanguage}
showId={true}
/>
);
const component = screen.getByTestId("ResponseBadges");
expect(component).toHaveAttribute("data-show-id", "true");
expect(component).toHaveTextContent("showId: true");
expect(component).toHaveTextContent("Option 1:choice1");
});
test("passes showId prop to ResponseBadges for multiple choice multi", () => {
const question = {
...defaultQuestion,
type: "multipleChoiceMulti",
choices: [
{ id: "choice1", label: { default: "Option 1" } },
{ id: "choice2", label: { default: "Option 2" } },
],
};
render(
<RenderResponse
responseData={["Option 1", "Option 2"]}
question={question}
survey={defaultSurvey}
language={dummyLanguage}
showId={true}
/>
);
const component = screen.getByTestId("ResponseBadges");
expect(component).toHaveAttribute("data-show-id", "true");
expect(component).toHaveTextContent("showId: true");
expect(component).toHaveTextContent("Option 1:choice1,Option 2:choice2");
});
test("handles multiple choice with missing choice IDs", () => {
const question = {
...defaultQuestion,
type: "multipleChoiceMulti",
choices: [{ id: "choice1", label: { default: "Option 1" } }],
};
render(
<RenderResponse
responseData={["Option 1", "Unknown Option"]}
question={question}
survey={defaultSurvey}
language={dummyLanguage}
showId={true}
/>
);
const component = screen.getByTestId("ResponseBadges");
expect(component).toHaveTextContent("Option 1:choice1,Unknown Option:other");
});
test("passes showId=false to components when showId is false", () => {
const question = {
...defaultQuestion,
type: "multipleChoiceMulti",
choices: [{ id: "choice1", label: { default: "Option 1" } }],
};
render(
<RenderResponse
responseData={["Option 1"]}
question={question}
survey={defaultSurvey}
language={dummyLanguage}
showId={false}
/>
);
const component = screen.getByTestId("ResponseBadges");
expect(component).toHaveAttribute("data-show-id", "false");
expect(component).toHaveTextContent("showId: false");
// Should still extract IDs but showId=false
expect(component).toHaveTextContent("Option 1:choice1");
});
test("handles questions without choices property", () => {
const question = { ...defaultQuestion, type: "multipleChoiceSingle" }; // No choices property
render(
<RenderResponse
responseData={"Option 1"}
question={question}
survey={defaultSurvey}
language={dummyLanguage}
showId={true}
/>
);
const component = screen.getByTestId("ResponseBadges");
expect(component).toHaveTextContent("Option 1:choice1");
});
});

View File

@@ -1,5 +1,6 @@
import { cn } from "@/lib/cn";
import { getLanguageCode, getLocalizedValue } from "@/lib/i18n/utils";
import { getChoiceIdByValue } from "@/lib/response/utils";
import { processResponseData } from "@/lib/responses";
import { formatDateWithOrdinal } from "@/lib/utils/datetime";
import { capitalizeFirstLetter } from "@/lib/utils/strings";
@@ -27,6 +28,7 @@ interface RenderResponseProps {
survey: TSurvey;
language: string | null;
isExpanded?: boolean;
showId: boolean;
}
export const RenderResponse: React.FC<RenderResponseProps> = ({
@@ -35,6 +37,7 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
survey,
language,
isExpanded = true,
showId,
}) => {
if (
(typeof responseData === "string" && responseData === "") ||
@@ -81,6 +84,7 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
choices={(question as TSurveyPictureSelectionQuestion).choices}
selected={responseData}
isExpanded={isExpanded}
showId={showId}
/>
);
}
@@ -121,9 +125,10 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
if (typeof responseData === "string" || typeof responseData === "number") {
return (
<ResponseBadges
items={[capitalizeFirstLetter(responseData.toString())]}
items={[{ value: capitalizeFirstLetter(responseData.toString()) }]}
isExpanded={isExpanded}
icon={<PhoneIcon className="h-4 w-4 text-slate-500" />}
showId={showId}
/>
);
}
@@ -132,9 +137,10 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
if (typeof responseData === "string" || typeof responseData === "number") {
return (
<ResponseBadges
items={[capitalizeFirstLetter(responseData.toString())]}
items={[{ value: capitalizeFirstLetter(responseData.toString()) }]}
isExpanded={isExpanded}
icon={<CheckCheckIcon className="h-4 w-4 text-slate-500" />}
showId={showId}
/>
);
}
@@ -143,26 +149,43 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
if (typeof responseData === "string" || typeof responseData === "number") {
return (
<ResponseBadges
items={[capitalizeFirstLetter(responseData.toString())]}
items={[{ value: capitalizeFirstLetter(responseData.toString()) }]}
isExpanded={isExpanded}
icon={<MousePointerClickIcon className="h-4 w-4 text-slate-500" />}
showId={showId}
/>
);
}
break;
case TSurveyQuestionTypeEnum.MultipleChoiceMulti:
case TSurveyQuestionTypeEnum.MultipleChoiceSingle:
case TSurveyQuestionTypeEnum.NPS:
case TSurveyQuestionTypeEnum.Ranking:
if (typeof responseData === "string" || typeof responseData === "number") {
return <ResponseBadges items={[responseData.toString()]} isExpanded={isExpanded} />;
const choiceId = getChoiceIdByValue(responseData.toString(), question);
return (
<ResponseBadges
items={[{ value: responseData.toString(), id: choiceId }]}
isExpanded={isExpanded}
showId={showId}
/>
);
} else if (Array.isArray(responseData)) {
return <ResponseBadges items={responseData} isExpanded={isExpanded} />;
const itemsArray = responseData.map((choice) => {
const choiceId = getChoiceIdByValue(choice, question);
return { value: choice, id: choiceId };
});
return (
<>
{questionType === TSurveyQuestionTypeEnum.Ranking ? (
<RankingResponse value={itemsArray} isExpanded={isExpanded} showId={showId} />
) : (
<ResponseBadges items={itemsArray} isExpanded={isExpanded} showId={showId} />
)}
</>
);
}
break;
case TSurveyQuestionTypeEnum.Ranking:
if (Array.isArray(responseData)) {
return <RankingResponse value={responseData} isExpanded={isExpanded} />;
}
default:
if (
typeof responseData === "string" ||

View File

@@ -76,7 +76,7 @@ export const SingleResponseCardBody = ({
<div key={`${question.id}`}>
{isValidValue(response.data[question.id]) ? (
<div>
<p className="text-sm text-slate-500">
<p className="mb-1 text-sm text-slate-500">
{formatTextWithSlashes(
parseRecallInfo(
getLocalizedValue(question.headline, "default"),
@@ -92,6 +92,7 @@ export const SingleResponseCardBody = ({
survey={survey}
responseData={response.data[question.id]}
language={response.language}
showId={true}
/>
</div>
</div>

View File

@@ -3,6 +3,7 @@
import { timeSince } from "@/lib/time";
import { getContactIdentifier } from "@/lib/utils/contact";
import { PersonAvatar } from "@/modules/ui/components/avatars";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { SurveyStatusIndicator } from "@/modules/ui/components/survey-status-indicator";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
import { useTranslate } from "@tolgee/react";
@@ -162,19 +163,21 @@ export const SingleResponseCardHeader = ({
{response.contact?.id ? (
user ? (
<Link
className="flex items-center"
className="flex items-center space-x-2"
href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
<PersonAvatar personId={response.contact.id} />
<h3 className="ph-no-capture ml-4 pb-1 font-semibold text-slate-600 hover:underline">
{displayIdentifier}
</h3>
{response.contact.userId && <IdBadge id={response.contact.userId} />}
</Link>
) : (
<div className="flex items-center">
<div className="flex items-center space-x-2">
<PersonAvatar personId={response.contact.id} />
<h3 className="ph-no-capture ml-4 pb-1 font-semibold text-slate-600">
{displayIdentifier}
</h3>
{response.contact.userId && <IdBadge id={response.contact.userId} />}
</div>
)
) : (

View File

@@ -319,11 +319,16 @@ export const authOptions: NextAuthOptions = {
async signIn({ user, account }: { user: TUser; account: Account }) {
const cookieStore = await cookies();
const callbackUrl = cookieStore.get("next-auth.callback-url")?.value || "";
// get callback url from the cookie store,
const callbackUrl =
cookieStore.get("__Secure-next-auth.callback-url")?.value ||
cookieStore.get("next-auth.callback-url")?.value ||
"";
if (account?.provider === "credentials" || account?.provider === "token") {
// check if user's email is verified or not
if (!user.emailVerified && !EMAIL_VERIFICATION_DISABLED) {
logger.error("Email Verification is Pending");
throw new Error("Email Verification is Pending");
}
await updateUserLastLoginAt(user.email);

View File

@@ -52,7 +52,7 @@ export const PasswordChecks = ({ password }: PasswordChecksProps) => {
return (
<div className="my-2 text-left text-slate-700 sm:text-sm">
<ul role="list" aria-label="Password requirements">
<ul aria-label="Password requirements">
{validations.map((validation) => (
<li key={validation.label} className="flex items-center">
<ValidationIcon state={validation.state} />

View File

@@ -2,6 +2,7 @@ import { AUDIT_LOG_ENABLED, AUDIT_LOG_GET_USER_IP } from "@/lib/constants";
import { ActionClientCtx, AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { deepDiff, redactPII } from "@/lib/utils/logger-helpers";
import { logAuditEvent } from "@/modules/ee/audit-logs/lib/service";
import {
TActor,
@@ -13,7 +14,6 @@ import {
} from "@/modules/ee/audit-logs/types/audit-log";
import { getIsAuditLogsEnabled } from "@/modules/ee/license-check/lib/utils";
import { logger } from "@formbricks/logger";
import { deepDiff, redactPII } from "./utils";
/**
* Builds an audit event and logs it.

View File

@@ -2,6 +2,7 @@ import { getResponsesByContactId } from "@/lib/response/service";
import { capitalizeFirstLetter } from "@/lib/utils/strings";
import { getContactAttributes } from "@/modules/ee/contacts/lib/contact-attributes";
import { getContact } from "@/modules/ee/contacts/lib/contacts";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { getTranslate } from "@/tolgee/server";
export const AttributesSection = async ({ contactId }: { contactId: string }) => {
@@ -42,7 +43,7 @@ export const AttributesSection = async ({ contactId }: { contactId: string }) =>
<dt className="text-sm font-medium text-slate-500">{t("common.user_id")}</dt>
<dd className="ph-no-capture mt-1 text-sm text-slate-900">
{attributes.userId ? (
<span>{attributes.userId}</span>
<IdBadge id={attributes.userId} />
) : (
<span className="text-slate-300">{t("environments.contacts.not_provided")}</span>
)}

View File

@@ -2,6 +2,7 @@
import { getSelectionColumn } from "@/modules/ui/components/data-table";
import { HighlightedText } from "@/modules/ui/components/highlighted-text";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { ColumnDef } from "@tanstack/react-table";
import { TContactTableData } from "../types/contact";
@@ -26,7 +27,7 @@ export const generateContactTableColumns = (
header: "User ID",
cell: ({ row }) => {
const userId = row.original.userId;
return <HighlightedText value={userId} searchValue={searchValue} />;
return <IdBadge id={userId} showCopyIconOnHover={true} />;
},
};

View File

@@ -1,6 +1,7 @@
"use client";
import { convertDateTimeStringShort } from "@/lib/time";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { Label } from "@/modules/ui/components/label";
import { useTranslate } from "@tolgee/react";
import { TSegment } from "@formbricks/types/segment";
@@ -52,10 +53,7 @@ export const SegmentActivityTab = ({ currentSegment }: SegmentActivityTabProps)
</p>
</div>
<div>
<Label className="text-xs font-normal text-slate-500">
{t("environments.segments.segment_id")}
</Label>
<p className="text-xs text-slate-700">{currentSegment.id.toString()}</p>
<IdBadge id={currentSegment.id} label={t("environments.segments.segment_id")} variant="column" />
</div>
</div>
</div>

View File

@@ -102,6 +102,8 @@ describe("License Core Logic", () => {
spamProtection: true,
ai: false,
auditLogs: true,
multiLanguageSurveys: true,
accessControl: true,
};
const mockFetchedLicenseDetails: TEnterpriseLicenseDetails = {
status: "active",
@@ -157,7 +159,6 @@ describe("License Core Logic", () => {
active: true,
features: mockFetchedLicenseDetails.features,
lastChecked: expect.any(Date),
version: 1,
},
expect.any(Number)
);
@@ -231,9 +232,10 @@ describe("License Core Logic", () => {
saml: false,
spamProtection: false,
auditLogs: false,
multiLanguageSurveys: false,
accessControl: false,
},
lastChecked: expect.any(Date),
version: 1,
},
expect.any(Number)
);
@@ -251,6 +253,8 @@ describe("License Core Logic", () => {
saml: false,
spamProtection: false,
auditLogs: false,
multiLanguageSurveys: false,
accessControl: false,
},
lastChecked: expect.any(Date),
isPendingDowngrade: false,
@@ -278,6 +282,8 @@ describe("License Core Logic", () => {
saml: false,
spamProtection: false,
auditLogs: false,
multiLanguageSurveys: false,
accessControl: false,
};
expect(mockCache.set).toHaveBeenCalledWith(
expect.stringContaining("fb:license:"),
@@ -285,7 +291,6 @@ describe("License Core Logic", () => {
active: false,
features: expectedFeatures,
lastChecked: expect.any(Date),
version: 1,
},
expect.any(Number)
);

View File

@@ -36,7 +36,6 @@ type TPreviousResult = {
active: boolean;
lastChecked: Date;
features: TEnterpriseLicenseFeatures | null;
version: number; // For cache versioning
};
// Validation schemas
@@ -52,6 +51,8 @@ const LicenseFeaturesSchema = z.object({
saml: z.boolean(),
spamProtection: z.boolean(),
auditLogs: z.boolean(),
multiLanguageSurveys: z.boolean(),
accessControl: z.boolean(),
});
const LicenseDetailsSchema = z.object({
@@ -112,6 +113,8 @@ const DEFAULT_FEATURES: TEnterpriseLicenseFeatures = {
saml: false,
spamProtection: false,
auditLogs: false,
multiLanguageSurveys: false,
accessControl: false,
};
// Helper functions
@@ -137,7 +140,6 @@ const getPreviousResult = async (): Promise<TPreviousResult> => {
active: false,
lastChecked: new Date(0),
features: DEFAULT_FEATURES,
version: 1,
};
}
@@ -158,7 +160,6 @@ const getPreviousResult = async (): Promise<TPreviousResult> => {
active: false,
lastChecked: new Date(0),
features: DEFAULT_FEATURES,
version: 1,
};
};
@@ -197,7 +198,6 @@ const trackApiError = (error: LicenseApiError) => {
const validateFallback = (previousResult: TPreviousResult): boolean => {
if (!previousResult.features) return false;
if (previousResult.lastChecked.getTime() === new Date(0).getTime()) return false;
if (previousResult.version !== 1) return false; // Add version check
return true;
};
@@ -224,7 +224,6 @@ const handleInitialFailure = async (currentTime: Date) => {
active: false,
features: DEFAULT_FEATURES,
lastChecked: currentTime,
version: 1,
};
await setPreviousResult(initialFailResult);
return {
@@ -370,7 +369,6 @@ export const getEnterpriseLicense = reactCache(
active: liveLicenseDetails.status === "active",
features: liveLicenseDetails.features,
lastChecked: currentTime,
version: 1,
};
await setPreviousResult(currentLicenseState);
return {

View File

@@ -4,6 +4,7 @@ import { Organization } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import * as licenseModule from "./license";
import {
getAccessControlPermission,
getBiggerUploadFileSizePermission,
getIsContactsEnabled,
getIsMultiOrgEnabled,
@@ -14,7 +15,6 @@ import {
getMultiLanguagePermission,
getOrganizationProjectsLimit,
getRemoveBrandingPermission,
getRoleManagementPermission,
getWhiteLabelPermission,
} from "./utils";
@@ -46,6 +46,8 @@ const defaultFeatures: TEnterpriseLicenseFeatures = {
spamProtection: false,
ai: false,
auditLogs: false,
multiLanguageSurveys: false,
accessControl: false,
};
const defaultLicense = {
@@ -141,41 +143,59 @@ describe("License Utils", () => {
});
});
describe("getRoleManagementPermission", () => {
test("should return true if license active (self-hosted)", async () => {
describe("getAccessControlPermission", () => {
test("should return true if license active and accessControl feature enabled (self-hosted)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = false;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense);
const result = await getRoleManagementPermission(mockOrganization.billing.plan);
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
...defaultLicense,
features: { ...defaultFeatures, accessControl: true },
});
const result = await getAccessControlPermission(mockOrganization.billing.plan);
expect(result).toBe(true);
});
test("should return true if license active and plan is SCALE (cloud)", async () => {
test("should return true if license active, accessControl enabled and plan is SCALE (cloud)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense);
const result = await getRoleManagementPermission(constants.PROJECT_FEATURE_KEYS.SCALE);
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
...defaultLicense,
features: { ...defaultFeatures, accessControl: true },
});
const result = await getAccessControlPermission(constants.PROJECT_FEATURE_KEYS.SCALE);
expect(result).toBe(true);
});
test("should return true if license active and plan is ENTERPRISE (cloud)", async () => {
test("should return true if license active, accessControl enabled and plan is ENTERPRISE (cloud)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense);
const result = await getRoleManagementPermission(constants.PROJECT_FEATURE_KEYS.ENTERPRISE);
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
...defaultLicense,
features: { ...defaultFeatures, accessControl: true },
});
const result = await getAccessControlPermission(constants.PROJECT_FEATURE_KEYS.ENTERPRISE);
expect(result).toBe(true);
});
test("should return false if license active and plan is not SCALE or ENTERPRISE (cloud)", async () => {
test("should return false if license active, accessControl enabled but plan is not SCALE or ENTERPRISE (cloud)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense);
const result = await getRoleManagementPermission(constants.PROJECT_FEATURE_KEYS.STARTUP);
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
...defaultLicense,
features: { ...defaultFeatures, accessControl: true },
});
const result = await getAccessControlPermission(constants.PROJECT_FEATURE_KEYS.STARTUP);
expect(result).toBe(false);
});
test("should return true if license active but accessControl feature disabled because of fallback", async () => {
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense);
const result = await getAccessControlPermission(mockOrganization.billing.plan);
expect(result).toBe(true);
});
test("should return false if license is inactive", async () => {
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
...defaultLicense,
active: false,
});
const result = await getRoleManagementPermission(mockOrganization.billing.plan);
const result = await getAccessControlPermission(mockOrganization.billing.plan);
expect(result).toBe(false);
});
});
@@ -213,20 +233,52 @@ describe("License Utils", () => {
});
describe("getMultiLanguagePermission", () => {
test("should return true if license active (self-hosted)", async () => {
test("should return true if license active and multiLanguageSurveys feature enabled (self-hosted)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = false;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense);
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
...defaultLicense,
features: { ...defaultFeatures, multiLanguageSurveys: true },
});
const result = await getMultiLanguagePermission(mockOrganization.billing.plan);
expect(result).toBe(true);
});
test("should return true if license active and plan is SCALE (cloud)", async () => {
test("should return true if license active, multiLanguageSurveys enabled and plan is SCALE (cloud)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense);
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
...defaultLicense,
features: { ...defaultFeatures, multiLanguageSurveys: true },
});
const result = await getMultiLanguagePermission(constants.PROJECT_FEATURE_KEYS.SCALE);
expect(result).toBe(true);
});
test("should return true if license active, multiLanguageSurveys enabled and plan is ENTERPRISE (cloud)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
...defaultLicense,
features: { ...defaultFeatures, multiLanguageSurveys: true },
});
const result = await getMultiLanguagePermission(constants.PROJECT_FEATURE_KEYS.ENTERPRISE);
expect(result).toBe(true);
});
test("should return false if license active, multiLanguageSurveys enabled but plan is not SCALE or ENTERPRISE (cloud)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
...defaultLicense,
features: { ...defaultFeatures, multiLanguageSurveys: true },
});
const result = await getMultiLanguagePermission(constants.PROJECT_FEATURE_KEYS.STARTUP);
expect(result).toBe(false);
});
test("should return true if license active but multiLanguageSurveys feature disabled because of fallback", async () => {
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense);
const result = await getMultiLanguagePermission(mockOrganization.billing.plan);
expect(result).toBe(true);
});
test("should return false if license is inactive", async () => {
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
...defaultLicense,

View File

@@ -35,20 +35,6 @@ export const getWhiteLabelPermission = async (
return getFeaturePermission(billingPlan, "whitelabel");
};
export const getRoleManagementPermission = async (
billingPlan: Organization["billing"]["plan"]
): Promise<boolean> => {
const license = await getEnterpriseLicense();
if (IS_FORMBRICKS_CLOUD)
return (
license.active &&
(billingPlan === PROJECT_FEATURE_KEYS.SCALE || billingPlan === PROJECT_FEATURE_KEYS.ENTERPRISE)
);
else if (!IS_FORMBRICKS_CLOUD) return license.active;
return false;
};
export const getBiggerUploadFileSizePermission = async (
billingPlan: Organization["billing"]["plan"]
): Promise<boolean> => {
@@ -59,25 +45,16 @@ export const getBiggerUploadFileSizePermission = async (
return false;
};
export const getMultiLanguagePermission = async (
billingPlan: Organization["billing"]["plan"]
): Promise<boolean> => {
const license = await getEnterpriseLicense();
if (IS_FORMBRICKS_CLOUD)
return (
license.active &&
(billingPlan === PROJECT_FEATURE_KEYS.SCALE || billingPlan === PROJECT_FEATURE_KEYS.ENTERPRISE)
);
else if (!IS_FORMBRICKS_CLOUD) return license.active;
return false;
};
// Helper function for simple boolean feature flags
const getSpecificFeatureFlag = async (
featureKey: keyof Pick<
TEnterpriseLicenseFeatures,
"isMultiOrgEnabled" | "contacts" | "twoFactorAuth" | "sso" | "auditLogs"
| "isMultiOrgEnabled"
| "contacts"
| "twoFactorAuth"
| "sso"
| "auditLogs"
| "multiLanguageSurveys"
| "accessControl"
>
): Promise<boolean> => {
const licenseFeatures = await getLicenseFeatures();
@@ -133,6 +110,39 @@ export const getIsSpamProtectionEnabled = async (
return license.active && !!license.features?.spamProtection;
};
const featureFlagFallback = async (billingPlan: Organization["billing"]["plan"]): Promise<boolean> => {
const license = await getEnterpriseLicense();
if (IS_FORMBRICKS_CLOUD)
return (
license.active &&
(billingPlan === PROJECT_FEATURE_KEYS.SCALE || billingPlan === PROJECT_FEATURE_KEYS.ENTERPRISE)
);
else if (!IS_FORMBRICKS_CLOUD) return license.active;
return false;
};
export const getMultiLanguagePermission = async (
billingPlan: Organization["billing"]["plan"]
): Promise<boolean> => {
const isEnabled = await getSpecificFeatureFlag("multiLanguageSurveys");
// If the feature is enabled in the license, return true
if (isEnabled) return true;
// If the feature is not enabled in the license, check the fallback(Backwards compatibility)
return featureFlagFallback(billingPlan);
};
export const getAccessControlPermission = async (
billingPlan: Organization["billing"]["plan"]
): Promise<boolean> => {
const isEnabled = await getSpecificFeatureFlag("accessControl");
// If the feature is enabled in the license, return true
if (isEnabled) return true;
// If the feature is not enabled in the license, check the fallback(Backwards compatibility)
return featureFlagFallback(billingPlan);
};
export const getOrganizationProjectsLimit = async (
limits: Organization["billing"]["limits"]
): Promise<number> => {

View File

@@ -16,6 +16,8 @@ const ZEnterpriseLicenseFeatures = z.object({
spamProtection: z.boolean(),
ai: z.boolean(),
auditLogs: z.boolean(),
multiLanguageSurveys: z.boolean(),
accessControl: z.boolean(),
});
export type TEnterpriseLicenseFeatures = z.infer<typeof ZEnterpriseLicenseFeatures>;

View File

@@ -1,6 +1,7 @@
"use client";
import { DefaultTag } from "@/modules/ui/components/default-tag";
import { Label } from "@/modules/ui/components/label";
import {
Select,
SelectContent,
@@ -30,11 +31,9 @@ export function DefaultLanguageSelect({
}: DefaultLanguageSelectProps) {
const { t } = useTranslate();
return (
<div className="space-y-4">
<p className="text-sm">
{t("environments.surveys.edit.1_choose_the_default_language_for_this_survey")}:
</p>
<div className="flex items-center space-x-4">
<div className="space-y-2">
<Label>{t("environments.surveys.edit.1_choose_the_default_language_for_this_survey")}</Label>
<div className="flex items-center space-x-2">
<div className="w-48">
<Select
defaultValue={`${defaultLanguage?.code}`}
@@ -57,7 +56,7 @@ export function DefaultLanguageSelect({
});
}}
value={`${defaultLanguage?.code}`}>
<SelectTrigger className="xs:w-[180px] xs:text-base w-full px-4 text-xs text-slate-800 dark:border-slate-400 dark:bg-slate-700 dark:text-slate-300">
<SelectTrigger className="xs:w-[180px] xs:text-sm w-full px-4 text-xs text-slate-800 dark:border-slate-400 dark:bg-slate-700 dark:text-slate-300">
<SelectValue />
</SelectTrigger>
<SelectContent>

View File

@@ -223,7 +223,7 @@ export const MultiLanguageCard: FC<MultiLanguageCardProps> = ({
</div>
</Collapsible.CollapsibleTrigger>
<Collapsible.CollapsibleContent className={`flex flex-col px-4 ${open && "pb-6"}`} ref={parent}>
<div className="space-y-4">
<div className="space-y-6 pt-3">
{!isMultiLanguageAllowed && !isMultiLanguageActivated ? (
<UpgradePrompt
title={t("environments.surveys.edit.upgrade_notice_title")}
@@ -257,17 +257,15 @@ export const MultiLanguageCard: FC<MultiLanguageCardProps> = ({
</div>
)}
{projectLanguages.length > 1 && (
<div className="my-4 space-y-4">
<div>
{isMultiLanguageAllowed && !isMultiLanguageActivated ? (
<div className="text-sm italic text-slate-500">
{t("environments.surveys.edit.switch_multi_lanugage_on_to_get_started")}
</div>
) : null}
</div>
<div className="space-y-6">
{isMultiLanguageAllowed && !isMultiLanguageActivated ? (
<div className="text-sm italic text-slate-500">
{t("environments.surveys.edit.switch_multi_lanugage_on_to_get_started")}
</div>
) : null}
{isMultiLanguageActivated ? (
<div className="space-y-4">
<div className="space-y-6">
<DefaultLanguageSelect
defaultLanguage={defaultLanguage}
handleDefaultLanguageChange={handleDefaultLanguageChange}
@@ -291,15 +289,15 @@ export const MultiLanguageCard: FC<MultiLanguageCardProps> = ({
</div>
)}
<Link href={`/environments/${environmentId}/project/languages`} target="_blank">
<Button className="mt-2" size="sm" variant="secondary">
{t("environments.surveys.edit.manage_languages")}{" "}
<ArrowUpRight className="ml-2 h-4 w-4" />
</Button>
</Link>
<Button asChild size="sm" variant="secondary">
<Link href={`/environments/${environmentId}/project/languages`} target="_blank">
{t("environments.surveys.edit.manage_languages")}
<ArrowUpRight />
</Link>
</Button>
{isMultiLanguageActivated && (
<AdvancedOptionToggle
customContainerClass="px-0 pt-2"
customContainerClass="px-0 pt-0"
htmlId="languageSwitch"
isChecked={!!localSurvey.showLanguageSwitch}
onToggle={handleLanguageSwitchToggle}

View File

@@ -33,10 +33,10 @@ export function SecondaryLanguageSelect({
};
return (
<div className="space-y-4">
<p className="text-sm">
{t("environments.surveys.edit.2_activate_translation_for_specific_languages")}:
</p>
<div className="space-y-2">
<p className="text-sm font-medium text-slate-800">
{t("environments.surveys.edit.2_activate_translation_for_specific_languages")}
</p>{" "}
{projectLanguages
.filter((lang) => lang.id !== defaultLanguage.id)
.map((language) => (

View File

@@ -8,7 +8,7 @@ import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { getRoleManagementPermission } from "@/modules/ee/license-check/lib/utils";
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
import { updateInvite } from "@/modules/ee/role-management/lib/invite";
import { updateMembership } from "@/modules/ee/role-management/lib/membership";
import { ZInviteUpdateInput } from "@/modules/ee/role-management/types/invites";
@@ -24,8 +24,8 @@ export const checkRoleManagementPermission = async (organizationId: string) => {
throw new Error("Organization not found");
}
const isRoleManagementAllowed = await getRoleManagementPermission(organization.billing.plan);
if (!isRoleManagementAllowed) {
const isAccessControlAllowed = await getAccessControlPermission(organization.billing.plan);
if (!isAccessControlAllowed) {
throw new OperationNotAllowedError("Role management is not allowed for this organization");
}
};

View File

@@ -18,14 +18,14 @@ import { TOrganizationRole } from "@formbricks/types/memberships";
interface AddMemberRoleProps {
control: Control<{ name: string; email: string; role: TOrganizationRole; teamIds: string[] }>;
canDoRoleManagement: boolean;
isAccessControlAllowed: boolean;
isFormbricksCloud: boolean;
membershipRole?: TOrganizationRole;
}
export function AddMemberRole({
control,
canDoRoleManagement,
isAccessControlAllowed,
isFormbricksCloud,
membershipRole,
}: AddMemberRoleProps) {
@@ -62,8 +62,8 @@ export function AddMemberRole({
<div className="flex flex-col space-y-2">
<Label>{t("common.role_organization")}</Label>
<Select
defaultValue={canDoRoleManagement ? "member" : "owner"}
disabled={!canDoRoleManagement}
defaultValue={isAccessControlAllowed ? "member" : "owner"}
disabled={!isAccessControlAllowed}
onValueChange={(v) => {
onChange(v as TOrganizationRole);
}}

View File

@@ -11,14 +11,20 @@ vi.mock("@tolgee/react", () => ({
}));
// Create a wrapper component that provides the form context
const FormWrapper = ({ children, defaultValues, membershipRole, canDoRoleManagement, isFormbricksCloud }) => {
const FormWrapper = ({
children,
defaultValues,
membershipRole,
isAccessControlAllowed,
isFormbricksCloud,
}) => {
const methods = useForm({ defaultValues });
return (
<FormProvider {...methods}>
<AddMemberRole
control={methods.control}
membershipRole={membershipRole}
canDoRoleManagement={canDoRoleManagement}
isAccessControlAllowed={isAccessControlAllowed}
isFormbricksCloud={isFormbricksCloud}
/>
{children}
@@ -44,7 +50,7 @@ describe("AddMemberRole Component", () => {
<FormWrapper
defaultValues={defaultValues}
membershipRole="owner"
canDoRoleManagement={true}
isAccessControlAllowed={true}
isFormbricksCloud={true}>
<div />
</FormWrapper>
@@ -59,7 +65,7 @@ describe("AddMemberRole Component", () => {
<FormWrapper
defaultValues={defaultValues}
membershipRole="member"
canDoRoleManagement={true}
isAccessControlAllowed={true}
isFormbricksCloud={true}>
<div data-testid="child" />
</FormWrapper>
@@ -69,12 +75,12 @@ describe("AddMemberRole Component", () => {
expect(screen.getByTestId("child")).toBeInTheDocument();
});
test("disables the role selector when canDoRoleManagement is false", () => {
test("disables the role selector when isAccessControlAllowed is false", () => {
render(
<FormWrapper
defaultValues={defaultValues}
membershipRole="owner"
canDoRoleManagement={false}
isAccessControlAllowed={false}
isFormbricksCloud={true}>
<div />
</FormWrapper>
@@ -91,7 +97,7 @@ describe("AddMemberRole Component", () => {
<FormWrapper
defaultValues={defaultValues}
membershipRole="owner"
canDoRoleManagement={true}
isAccessControlAllowed={true}
isFormbricksCloud={true}>
<div />
</FormWrapper>

View File

@@ -4,15 +4,16 @@ import { getIsFreshInstance } from "@/lib/instance/service";
import { verifyInviteToken } from "@/lib/jwt";
import { createMembership } from "@/lib/membership/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { redactPII } from "@/lib/utils/logger-helpers";
import { createBrevoCustomer } from "@/modules/auth/lib/brevo";
import { createUser, getUserByEmail, updateUser } from "@/modules/auth/lib/user";
import { getIsValidInviteToken } from "@/modules/auth/signup/lib/invite";
import { TOidcNameFields, TSamlNameFields } from "@/modules/auth/types/auth";
import {
getAccessControlPermission,
getIsMultiOrgEnabled,
getIsSamlSsoEnabled,
getIsSsoEnabled,
getRoleManagementPermission,
} from "@/modules/ee/license-check/lib/utils";
import { getFirstOrganization } from "@/modules/ee/sso/lib/organization";
import { createDefaultTeamMembership, getOrganizationByTeamId } from "@/modules/ee/sso/lib/team";
@@ -31,12 +32,36 @@ export const handleSsoCallback = async ({
account: Account;
callbackUrl: string;
}) => {
const contextLogger = logger.withContext({
correlationId: crypto.randomUUID(),
name: "formbricks",
});
contextLogger.debug(
{
...redactPII({ user, account, callbackUrl }),
hasEmail: !!user.email,
hasName: !!user.name,
},
"SSO callback initiated"
);
const isSsoEnabled = await getIsSsoEnabled();
if (!isSsoEnabled) {
contextLogger.debug({ isSsoEnabled }, "SSO not enabled");
return false;
}
if (!user.email || account.type !== "oauth") {
contextLogger.debug(
{
hasEmail: !!user.email,
accountType: account.type,
reason: !user.email ? "missing_email" : "invalid_account_type",
},
"SSO callback rejected: missing email or invalid account type"
);
return false;
}
@@ -45,12 +70,18 @@ export const handleSsoCallback = async ({
if (provider === "saml") {
const isSamlSsoEnabled = await getIsSamlSsoEnabled();
if (!isSamlSsoEnabled) {
contextLogger.debug({ provider: "saml" }, "SSO callback rejected: SAML not enabled in license");
return false;
}
}
if (account.provider) {
// check if accounts for this provider / account Id already exists
contextLogger.debug(
{ lookupType: "sso_provider_account" },
"Checking for existing user with SSO provider"
);
const existingUserWithAccount = await prisma.user.findFirst({
include: {
accounts: {
@@ -66,12 +97,29 @@ export const handleSsoCallback = async ({
});
if (existingUserWithAccount) {
contextLogger.debug(
{
existingUserId: existingUserWithAccount.id,
emailMatches: existingUserWithAccount.email === user.email,
},
"Found existing user with SSO provider"
);
// User with this provider found
// check if email still the same
if (existingUserWithAccount.email === user.email) {
contextLogger.debug(
{ existingUserId: existingUserWithAccount.id },
"SSO callback successful: existing user, email matches"
);
return true;
}
contextLogger.debug(
{ existingUserId: existingUserWithAccount.id },
"Email changed in SSO provider, checking for conflicts"
);
// user seemed to change his email within the provider
// check if user with this email already exist
// if not found just update user with new email address
@@ -79,9 +127,20 @@ export const handleSsoCallback = async ({
const otherUserWithEmail = await getUserByEmail(user.email);
if (!otherUserWithEmail) {
contextLogger.debug(
{ existingUserId: existingUserWithAccount.id, action: "email_update" },
"No other user with this email found, updating user email after SSO provider change"
);
await updateUser(existingUserWithAccount.id, { email: user.email });
return true;
}
contextLogger.debug(
{ existingUserId: existingUserWithAccount.id, conflictingUserId: otherUserWithEmail.id },
"SSO callback failed: email conflict after provider change"
);
throw new Error(
"Looks like you updated your email somewhere else. A user with this new email exists already."
);
@@ -90,13 +149,24 @@ export const handleSsoCallback = async ({
// There is no existing account for this identity provider / account id
// check if user account with this email already exists
// if user already exists throw error and request password login
contextLogger.debug({ lookupType: "email" }, "No existing SSO account found, checking for user by email");
const existingUserWithEmail = await getUserByEmail(user.email);
if (existingUserWithEmail) {
contextLogger.debug(
{ existingUserId: existingUserWithEmail.id, action: "existing_user_login" },
"SSO callback successful: existing user found by email"
);
// Sign in the user with the existing account
return true;
}
contextLogger.debug(
{ action: "new_user_creation" },
"No existing user found, proceeding with new user creation"
);
let userName = user.name;
if (provider === "openid") {
@@ -108,6 +178,16 @@ export const handleSsoCallback = async ({
} else if (oidcUser.preferred_username) {
userName = oidcUser.preferred_username;
}
contextLogger.debug(
{
hasName: !!oidcUser.name,
hasGivenName: !!oidcUser.given_name,
hasFamilyName: !!oidcUser.family_name,
hasPreferredUsername: !!oidcUser.preferred_username,
},
"Extracted OIDC user name"
);
}
if (provider === "saml") {
@@ -117,6 +197,14 @@ export const handleSsoCallback = async ({
} else if (samlUser.firstName || samlUser.lastName) {
userName = `${samlUser.firstName} ${samlUser.lastName}`;
}
contextLogger.debug(
{
hasName: !!samlUser.name,
hasFirstName: !!samlUser.firstName,
hasLastName: !!samlUser.lastName,
},
"Extracted SAML user name"
);
}
// Get multi-org license status
@@ -124,9 +212,23 @@ export const handleSsoCallback = async ({
const isFirstUser = await getIsFreshInstance();
contextLogger.debug(
{
isMultiOrgEnabled,
isFirstUser,
skipInviteForSso: SKIP_INVITE_FOR_SSO,
hasDefaultTeamId: !!DEFAULT_TEAM_ID,
},
"License and instance configuration checked"
);
// Additional security checks for self-hosted instances without auto-provisioning and no multi-org enabled
if (!isFirstUser && !SKIP_INVITE_FOR_SSO && !isMultiOrgEnabled) {
if (!callbackUrl) {
contextLogger.debug(
{ reason: "missing_callback_url" },
"SSO callback rejected: missing callback URL for invite validation"
);
return false;
}
@@ -139,6 +241,10 @@ export const handleSsoCallback = async ({
// Allow sign-in if multi-org is enabled, otherwise check for invite token
if (source === "signin" && !inviteToken) {
contextLogger.debug(
{ reason: "signin_without_invite_token" },
"SSO callback rejected: signin without invite token"
);
return false;
}
@@ -146,16 +252,32 @@ export const handleSsoCallback = async ({
// Verify invite token and check email match
const { email, inviteId } = verifyInviteToken(inviteToken);
if (email !== user.email) {
contextLogger.debug(
{ reason: "invite_email_mismatch", inviteId },
"SSO callback rejected: invite token email mismatch"
);
return false;
}
// Check if invite token is still valid
const isValidInviteToken = await getIsValidInviteToken(inviteId);
if (!isValidInviteToken) {
contextLogger.debug(
{ reason: "invalid_invite_token", inviteId },
"SSO callback rejected: invalid or expired invite token"
);
return false;
}
contextLogger.debug({ inviteId }, "Invite token validation successful");
} catch (err) {
contextLogger.debug(
{
reason: "invite_token_validation_error",
error: err instanceof Error ? err.message : "unknown_error",
},
"SSO callback rejected: invite token validation failed"
);
// Log and reject on any validation errors
logger.error(err, "Invalid callbackUrl");
contextLogger.error(err, "Invalid callbackUrl");
return false;
}
}
@@ -163,6 +285,12 @@ export const handleSsoCallback = async ({
let organization: Organization | null = null;
if (!isFirstUser && !isMultiOrgEnabled) {
contextLogger.debug(
{
assignmentStrategy: SKIP_INVITE_FOR_SSO && DEFAULT_TEAM_ID ? "default_team" : "first_organization",
},
"Determining organization assignment"
);
if (SKIP_INVITE_FOR_SSO && DEFAULT_TEAM_ID) {
organization = await getOrganizationByTeamId(DEFAULT_TEAM_ID);
} else {
@@ -170,13 +298,29 @@ export const handleSsoCallback = async ({
}
if (!organization) {
contextLogger.debug(
{ reason: "no_organization_found" },
"SSO callback rejected: no organization found for assignment"
);
return false;
}
const canDoRoleManagement = await getRoleManagementPermission(organization.billing.plan);
if (!canDoRoleManagement && !callbackUrl) return false;
const isAccessControlAllowed = await getAccessControlPermission(organization.billing.plan);
if (!isAccessControlAllowed && !callbackUrl) {
contextLogger.debug(
{
reason: "insufficient_role_permissions",
organizationId: organization.id,
isAccessControlAllowed,
},
"SSO callback rejected: insufficient role management permissions"
);
return false;
}
}
contextLogger.debug({ hasUserName: !!userName, identityProvider: provider }, "Creating new SSO user");
const userProfile = await createUser({
name:
userName ||
@@ -191,13 +335,28 @@ export const handleSsoCallback = async ({
locale: await findMatchingLocale(),
});
contextLogger.debug(
{ newUserId: userProfile.id, identityProvider: provider },
"New SSO user created successfully"
);
// send new user to brevo
createBrevoCustomer({ id: userProfile.id, email: userProfile.email });
if (isMultiOrgEnabled) return true;
if (isMultiOrgEnabled) {
contextLogger.debug(
{ isMultiOrgEnabled, newUserId: userProfile.id },
"Multi-org enabled, skipping organization assignment"
);
return true;
}
// Default organization assignment if env variable is set
if (organization) {
contextLogger.debug(
{ newUserId: userProfile.id, organizationId: organization.id, role: "member" },
"Assigning user to organization"
);
await createMembership(organization.id, userProfile.id, { role: "member", accepted: true });
await createAccount({
...account,
@@ -205,6 +364,10 @@ export const handleSsoCallback = async ({
});
if (SKIP_INVITE_FOR_SSO && DEFAULT_TEAM_ID) {
contextLogger.debug(
{ newUserId: userProfile.id, defaultTeamId: DEFAULT_TEAM_ID },
"Creating default team membership"
);
await createDefaultTeamMembership(userProfile.id);
}
@@ -226,6 +389,7 @@ export const handleSsoCallback = async ({
// Without default organization assignment
return true;
}
contextLogger.debug("SSO callback successful: default return");
return true;
};

View File

@@ -5,10 +5,10 @@ import { createBrevoCustomer } from "@/modules/auth/lib/brevo";
import { createUser, getUserByEmail, updateUser } from "@/modules/auth/lib/user";
import type { TSamlNameFields } from "@/modules/auth/types/auth";
import {
getAccessControlPermission,
getIsMultiOrgEnabled,
getIsSamlSsoEnabled,
getIsSsoEnabled,
getRoleManagementPermission,
} from "@/modules/ee/license-check/lib/utils";
import { createDefaultTeamMembership, getOrganizationByTeamId } from "@/modules/ee/sso/lib/team";
import { beforeEach, describe, expect, test, vi } from "vitest";
@@ -43,7 +43,7 @@ vi.mock("@/modules/auth/signup/lib/invite", () => ({
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getIsSamlSsoEnabled: vi.fn(),
getIsSsoEnabled: vi.fn(),
getRoleManagementPermission: vi.fn(),
getAccessControlPermission: vi.fn(),
getIsMultiOrgEnabled: vi.fn(),
}));
@@ -85,6 +85,13 @@ vi.mock("@formbricks/lib/jwt", () => ({
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
debug: vi.fn(),
withContext: (context: Record<string, any>) => {
return {
...context,
debug: vi.fn(),
};
},
},
}));
@@ -303,7 +310,7 @@ describe("handleSsoCallback", () => {
});
expect(result).toBe(true);
expect(getRoleManagementPermission).not.toHaveBeenCalled();
expect(getAccessControlPermission).not.toHaveBeenCalled();
});
test("should return true when organization exists but role management is not enabled", async () => {
@@ -311,7 +318,7 @@ describe("handleSsoCallback", () => {
vi.mocked(getUserByEmail).mockResolvedValue(null);
vi.mocked(createUser).mockResolvedValue(mockCreatedUser());
vi.mocked(getOrganizationByTeamId).mockResolvedValue(mockOrganization);
vi.mocked(getRoleManagementPermission).mockResolvedValue(false);
vi.mocked(getAccessControlPermission).mockResolvedValue(false);
const result = await handleSsoCallback({
user: mockUser,

View File

@@ -2,6 +2,7 @@
import { TProjectTeam } from "@/modules/ee/teams/project-teams/types/team";
import { TeamPermissionMapping } from "@/modules/ee/teams/utils/teams";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
import { useTranslate } from "@tolgee/react";
@@ -21,6 +22,7 @@ export const AccessTable = ({ teams }: AccessTableProps) => {
{t("environments.project.teams.team_name")}
</TableHead>
<TableHead className="font-medium text-slate-500">{t("common.size")}</TableHead>
<TableHead className="font-medium text-slate-500">{t("common.team_id")}</TableHead>
<TableHead className="font-medium text-slate-500">
{t("environments.project.teams.permission")}
</TableHead>
@@ -40,6 +42,9 @@ export const AccessTable = ({ teams }: AccessTableProps) => {
<TableCell>
{team.memberCount} {team.memberCount === 1 ? t("common.member") : t("common.members")}
</TableCell>
<TableCell>
<IdBadge id={team.id} showCopyIconOnHover={true} />
</TableCell>
<TableCell>
<p className="capitalize">{TeamPermissionMapping[team.permission]}</p>
</TableCell>

View File

@@ -107,7 +107,7 @@ describe("TeamSettingsModal", () => {
expect(screen.getByText("common.team_name")).toBeInTheDocument();
expect(screen.getByText("common.members")).toBeInTheDocument();
expect(screen.getByText("environments.settings.teams.add_members_description")).toBeInTheDocument();
expect(screen.getByText("Add member")).toBeInTheDocument();
expect(screen.getByText("common.add_member")).toBeInTheDocument();
expect(screen.getByText("common.projects")).toBeInTheDocument();
expect(screen.getByText("common.add_project")).toBeInTheDocument();
expect(screen.getByText("environments.settings.teams.add_projects_description")).toBeInTheDocument();

View File

@@ -26,6 +26,7 @@ import {
DialogTitle,
} from "@/modules/ui/components/dialog";
import { FormControl, FormError, FormField, FormItem, FormLabel } from "@/modules/ui/components/form";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { Input } from "@/modules/ui/components/input";
import {
Select,
@@ -235,6 +236,8 @@ export const TeamSettingsModal = ({
)}
/>
<IdBadge id={team.id} label={t("common.team_id")} variant="column" />
{/* Members Section */}
<div className="space-y-2">
<div className="flex flex-col space-y-1">
@@ -350,6 +353,7 @@ export const TeamSettingsModal = ({
/>
<TooltipRenderer
shouldRender={selectedMemberIds.length === orgMembers.length || hasEmptyMember}
triggerClass="inline-block"
tooltipContent={
hasEmptyMember
? t("environments.settings.teams.please_fill_all_member_fields")
@@ -366,7 +370,7 @@ export const TeamSettingsModal = ({
hasEmptyMember
}>
<PlusIcon className="h-4 w-4" />
<span>Add member</span>
<span>{t("common.add_member")}</span>
</Button>
</TooltipRenderer>
</div>
@@ -470,6 +474,7 @@ export const TeamSettingsModal = ({
<TooltipRenderer
shouldRender={selectedProjectIds.length === orgProjects.length || hasEmptyProject}
triggerClass="inline-block"
tooltipContent={
hasEmptyProject
? t("environments.settings.teams.please_fill_all_project_fields")

View File

@@ -12,7 +12,7 @@ interface TeamsViewProps {
organizationId: string;
membershipRole?: TOrganizationRole;
currentUserId: string;
canDoRoleManagement: boolean;
isAccessControlAllowed: boolean;
environmentId: string;
}
@@ -20,7 +20,7 @@ export const TeamsView = async ({
organizationId,
membershipRole,
currentUserId,
canDoRoleManagement,
isAccessControlAllowed,
environmentId,
}: TeamsViewProps) => {
const t = await getTranslate();
@@ -52,7 +52,7 @@ export const TeamsView = async ({
<SettingsCard
title={t("environments.settings.teams.teams")}
description={t("environments.settings.teams.teams_description")}>
{canDoRoleManagement ? (
{isAccessControlAllowed ? (
<TeamsTable
teams={teams}
membershipRole={membershipRole}

View File

@@ -313,32 +313,32 @@ export async function PreviewEmailTemplate({
<EmailFooter />
</EmailTemplateWrapper>
);
case TSurveyQuestionTypeEnum.PictureSelection:
return (
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
<QuestionHeader headline={headline} subheader={subheader} className="mr-8" />
<Section className="mx-0 mt-4">
{firstQuestion.choices.map((choice) =>
firstQuestion.allowMulti ? (
<Img
className="rounded-custom mb-3 mr-3 inline-block h-[150px] w-[250px]"
key={choice.id}
src={choice.imageUrl}
/>
) : (
<Link
className="rounded-custom mb-3 mr-3 inline-block h-[150px] w-[250px]"
href={`${urlWithPrefilling}${firstQuestion.id}=${choice.id}`}
key={choice.id}
target="_blank">
<Img className="rounded-custom h-full w-full" src={choice.imageUrl} />
</Link>
)
)}
</Section>
<EmailFooter />
</EmailTemplateWrapper>
);
case TSurveyQuestionTypeEnum.PictureSelection:
return (
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
<QuestionHeader headline={headline} subheader={subheader} className="mr-8" />
<Section className="mx-0 mt-4">
{firstQuestion.choices.map((choice) =>
firstQuestion.allowMulti ? (
<Img
className="rounded-custom mb-3 mr-3 inline-block h-[150px] w-[250px]"
key={choice.id}
src={choice.imageUrl}
/>
) : (
<Link
className="rounded-custom mb-3 mr-3 inline-block h-[150px] w-[250px]"
href={`${urlWithPrefilling}${firstQuestion.id}=${choice.id}`}
key={choice.id}
target="_blank">
<Img className="rounded-custom h-full w-full" src={choice.imageUrl} />
</Link>
)
)}
</Section>
<EmailFooter />
</EmailTemplateWrapper>
);
case TSurveyQuestionTypeEnum.Cal:
return (
<EmailTemplateWrapper styling={styling} surveyUrl={url}>

View File

@@ -67,7 +67,7 @@ describe("EditMemberships", () => {
organization: mockOrg,
currentUserId: "user-1",
role: "owner",
canDoRoleManagement: true,
isAccessControlAllowed: true,
isUserManagementDisabledFromUi: false,
});
render(ui);
@@ -81,18 +81,18 @@ describe("EditMemberships", () => {
expect(props.organization.id).toBe("org-1");
expect(props.currentUserId).toBe("user-1");
expect(props.currentUserRole).toBe("owner");
expect(props.canDoRoleManagement).toBe(true);
expect(props.isAccessControlAllowed).toBe(true);
expect(props.isUserManagementDisabledFromUi).toBe(false);
expect(Array.isArray(props.invites)).toBe(true);
expect(Array.isArray(props.members)).toBe(true);
});
test("does not render role/actions columns if canDoRoleManagement or isUserManagementDisabledFromUi is false", async () => {
test("does not render role/actions columns if isAccessControlAllowed or isUserManagementDisabledFromUi is false", async () => {
const ui = await EditMemberships({
organization: mockOrg,
currentUserId: "user-1",
role: "member",
canDoRoleManagement: false,
isAccessControlAllowed: false,
isUserManagementDisabledFromUi: true,
});
render(ui);
@@ -109,7 +109,7 @@ describe("EditMemberships", () => {
organization: mockOrg,
currentUserId: "user-1",
role: undefined as any,
canDoRoleManagement: true,
isAccessControlAllowed: true,
isUserManagementDisabledFromUi: false,
});
render(ui);

View File

@@ -10,7 +10,7 @@ interface EditMembershipsProps {
organization: TOrganization;
currentUserId: string;
role: TOrganizationRole;
canDoRoleManagement: boolean;
isAccessControlAllowed: boolean;
isUserManagementDisabledFromUi: boolean;
}
@@ -18,7 +18,7 @@ export const EditMemberships = async ({
organization,
currentUserId,
role,
canDoRoleManagement,
isAccessControlAllowed,
isUserManagementDisabledFromUi,
}: EditMembershipsProps) => {
const members = await getMembershipByOrganizationId(organization.id);
@@ -32,7 +32,9 @@ export const EditMemberships = async ({
<div className="w-1/2 overflow-hidden">{t("common.full_name")}</div>
<div className="w-1/2 overflow-hidden">{t("common.email")}</div>
{canDoRoleManagement && <div className="min-w-[100px] whitespace-nowrap">{t("common.role")}</div>}
{isAccessControlAllowed && (
<div className="min-w-[100px] whitespace-nowrap">{t("common.role")}</div>
)}
<div className="min-w-[80px] whitespace-nowrap">{t("common.status")}</div>
@@ -48,7 +50,7 @@ export const EditMemberships = async ({
invites={invites ?? []}
members={members ?? []}
currentUserRole={role}
canDoRoleManagement={canDoRoleManagement}
isAccessControlAllowed={isAccessControlAllowed}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
isUserManagementDisabledFromUi={isUserManagementDisabledFromUi}
/>

View File

@@ -77,7 +77,7 @@ describe("MembersInfo", () => {
cleanup();
});
test("renders member info and EditMembershipRole when canDoRoleManagement", () => {
test("renders member info and EditMembershipRole when isAccessControlAllowed", () => {
render(
<MembersInfo
organization={org}
@@ -85,7 +85,7 @@ describe("MembersInfo", () => {
invites={[]}
currentUserRole="owner"
currentUserId="user-1"
canDoRoleManagement={true}
isAccessControlAllowed={true}
isFormbricksCloud={true}
isUserManagementDisabledFromUi={false}
/>
@@ -105,7 +105,7 @@ describe("MembersInfo", () => {
invites={[]}
currentUserRole="owner"
currentUserId="user-1"
canDoRoleManagement={true}
isAccessControlAllowed={true}
isFormbricksCloud={true}
isUserManagementDisabledFromUi={false}
/>
@@ -121,7 +121,7 @@ describe("MembersInfo", () => {
invites={[invite]}
currentUserRole="owner"
currentUserId="user-1"
canDoRoleManagement={true}
isAccessControlAllowed={true}
isFormbricksCloud={true}
isUserManagementDisabledFromUi={false}
/>
@@ -139,7 +139,7 @@ describe("MembersInfo", () => {
invites={[invite]}
currentUserRole="owner"
currentUserId="user-1"
canDoRoleManagement={true}
isAccessControlAllowed={true}
isFormbricksCloud={true}
isUserManagementDisabledFromUi={false}
/>
@@ -147,7 +147,7 @@ describe("MembersInfo", () => {
expect(screen.getByTestId("expired-badge")).toHaveTextContent("Expired");
});
test("does not render EditMembershipRole if canDoRoleManagement is false", () => {
test("does not render EditMembershipRole if isAccessControlAllowed is false", () => {
render(
<MembersInfo
organization={org}
@@ -155,7 +155,7 @@ describe("MembersInfo", () => {
invites={[]}
currentUserRole="owner"
currentUserId="user-1"
canDoRoleManagement={false}
isAccessControlAllowed={false}
isFormbricksCloud={true}
isUserManagementDisabledFromUi={false}
/>
@@ -171,7 +171,7 @@ describe("MembersInfo", () => {
invites={[]}
currentUserRole="owner"
currentUserId="user-1"
canDoRoleManagement={true}
isAccessControlAllowed={true}
isFormbricksCloud={true}
isUserManagementDisabledFromUi={true}
/>
@@ -193,7 +193,7 @@ describe("MembersInfo", () => {
invites={[invite]}
currentUserRole="owner"
currentUserId="user-1"
canDoRoleManagement={true}
isAccessControlAllowed={true}
isFormbricksCloud={true}
isUserManagementDisabledFromUi={false}
/>

View File

@@ -18,7 +18,7 @@ interface MembersInfoProps {
invites: TInvite[];
currentUserRole: TOrganizationRole;
currentUserId: string;
canDoRoleManagement: boolean;
isAccessControlAllowed: boolean;
isFormbricksCloud: boolean;
isUserManagementDisabledFromUi: boolean;
}
@@ -34,7 +34,7 @@ export const MembersInfo = ({
currentUserRole,
members,
currentUserId,
canDoRoleManagement,
isAccessControlAllowed,
isFormbricksCloud,
isUserManagementDisabledFromUi,
}: MembersInfoProps) => {
@@ -105,7 +105,7 @@ export const MembersInfo = ({
<p className="w-full truncate"> {member.email}</p>
</div>
{canDoRoleManagement && allMembers?.length > 0 && (
{isAccessControlAllowed && allMembers?.length > 0 && (
<div className="ph-no-capture min-w-[100px]">
<EditMembershipRole
currentUserRole={currentUserRole}

View File

@@ -123,7 +123,7 @@ describe("OrganizationActions Component", () => {
organization: { id: "org-123", name: "Test Org" } as TOrganization,
teams: [{ id: "team-1", name: "Team 1" }],
isInviteDisabled: false,
canDoRoleManagement: true,
isAccessControlAllowed: true,
isFormbricksCloud: false,
environmentId: "env-123",
isMultiOrgEnabled: true,
@@ -310,7 +310,7 @@ describe("OrganizationActions Component", () => {
expect
.objectContaining({
environmentId: "env-123",
canDoRoleManagement: true,
isAccessControlAllowed: true,
isFormbricksCloud: false,
teams: expect.arrayContaining(defaultProps.teams),
membershipRole: "owner",

View File

@@ -31,7 +31,7 @@ interface OrganizationActionsProps {
organization: TOrganization;
teams: TOrganizationTeam[];
isInviteDisabled: boolean;
canDoRoleManagement: boolean;
isAccessControlAllowed: boolean;
isFormbricksCloud: boolean;
environmentId: string;
isMultiOrgEnabled: boolean;
@@ -45,7 +45,7 @@ export const OrganizationActions = ({
teams,
isLeaveOrganizationDisabled,
isInviteDisabled,
canDoRoleManagement,
isAccessControlAllowed,
isFormbricksCloud,
environmentId,
isMultiOrgEnabled,
@@ -154,7 +154,7 @@ export const OrganizationActions = ({
setOpen={setInviteMemberModalOpen}
onSubmit={handleAddMembers}
membershipRole={membershipRole}
canDoRoleManagement={canDoRoleManagement}
isAccessControlAllowed={isAccessControlAllowed}
isFormbricksCloud={isFormbricksCloud}
environmentId={environmentId}
teams={teams}

View File

@@ -15,14 +15,14 @@ import { TOrganizationRole } from "@formbricks/types/memberships";
interface BulkInviteTabProps {
setOpen: (v: boolean) => void;
onSubmit: (data: { name: string; email: string; role: TOrganizationRole }[]) => void;
canDoRoleManagement: boolean;
isAccessControlAllowed: boolean;
isFormbricksCloud: boolean;
}
export const BulkInviteTab = ({
setOpen,
onSubmit,
canDoRoleManagement,
isAccessControlAllowed,
isFormbricksCloud,
}: BulkInviteTabProps) => {
const { t } = useTranslate();
@@ -48,7 +48,7 @@ export const BulkInviteTab = ({
},
complete: (results: ParseResult<{ name: string; email: string; role: string }>) => {
const members = results.data.map((csv) => {
let orgRole = canDoRoleManagement ? csv.role.trim().toLowerCase() : "owner";
let orgRole = isAccessControlAllowed ? csv.role.trim().toLowerCase() : "owner";
if (!isFormbricksCloud) {
orgRole = orgRole === "billing" ? "owner" : orgRole;
}
@@ -119,7 +119,7 @@ export const BulkInviteTab = ({
</div>
)}
{!canDoRoleManagement && (
{!isAccessControlAllowed && (
<Alert variant="default" className="mt-1.5 flex items-start bg-slate-50">
<AlertDescription className="ml-2">
<p className="text-sm">

View File

@@ -35,7 +35,7 @@ const defaultProps = {
{ id: "team-1", name: "Team 1" },
{ id: "team-2", name: "Team 2" },
],
canDoRoleManagement: true,
isAccessControlAllowed: true,
isFormbricksCloud: true,
environmentId: "env-1",
membershipRole: "owner" as TOrganizationRole,
@@ -85,21 +85,21 @@ describe("IndividualInviteTab", () => {
});
test("shows member role info alert when role is member", async () => {
render(<IndividualInviteTab {...defaultProps} canDoRoleManagement={true} />);
render(<IndividualInviteTab {...defaultProps} isAccessControlAllowed={true} />);
await userEvent.type(screen.getByLabelText("common.full_name"), "Test User");
await userEvent.type(screen.getByLabelText("common.email"), "test@example.com");
// Simulate selecting member role
// Not needed as default is member if canDoRoleManagement is true
// Not needed as default is member if isAccessControlAllowed is true
expect(screen.getByText("environments.settings.teams.member_role_info_message")).toBeInTheDocument();
});
test("shows team select when canDoRoleManagement is true", () => {
render(<IndividualInviteTab {...defaultProps} canDoRoleManagement={true} />);
test("shows team select when isAccessControlAllowed is true", () => {
render(<IndividualInviteTab {...defaultProps} isAccessControlAllowed={true} />);
expect(screen.getByTestId("multi-select")).toBeInTheDocument();
});
test("shows upgrade alert when canDoRoleManagement is false", () => {
render(<IndividualInviteTab {...defaultProps} canDoRoleManagement={false} />);
test("shows upgrade alert when isAccessControlAllowed is false", () => {
render(<IndividualInviteTab {...defaultProps} isAccessControlAllowed={false} />);
expect(screen.getByText("environments.settings.teams.upgrade_plan_notice_message")).toBeInTheDocument();
expect(screen.getByText("common.start_free_trial")).toBeInTheDocument();
});

View File

@@ -23,7 +23,7 @@ interface IndividualInviteTabProps {
setOpen: (v: boolean) => void;
onSubmit: (data: { name: string; email: string; role: TOrganizationRole }[]) => void;
teams: TOrganizationTeam[];
canDoRoleManagement: boolean;
isAccessControlAllowed: boolean;
isFormbricksCloud: boolean;
environmentId: string;
membershipRole?: TOrganizationRole;
@@ -33,7 +33,7 @@ export const IndividualInviteTab = ({
setOpen,
onSubmit,
teams,
canDoRoleManagement,
isAccessControlAllowed,
isFormbricksCloud,
environmentId,
membershipRole,
@@ -52,7 +52,7 @@ export const IndividualInviteTab = ({
const form = useForm<TFormData>({
resolver: zodResolver(ZFormSchema),
defaultValues: {
role: canDoRoleManagement ? "member" : "owner",
role: isAccessControlAllowed ? "member" : "owner",
teamIds: [],
},
});
@@ -106,7 +106,7 @@ export const IndividualInviteTab = ({
<div>
<AddMemberRole
control={control}
canDoRoleManagement={canDoRoleManagement}
isAccessControlAllowed={isAccessControlAllowed}
isFormbricksCloud={isFormbricksCloud}
membershipRole={membershipRole}
/>
@@ -117,7 +117,7 @@ export const IndividualInviteTab = ({
)}
</div>
{canDoRoleManagement && (
{isAccessControlAllowed && (
<FormField
control={control}
name="teamIds"
@@ -143,7 +143,7 @@ export const IndividualInviteTab = ({
/>
)}
{!canDoRoleManagement && (
{!isAccessControlAllowed && (
<Alert>
<AlertDescription className="flex">
{t("environments.settings.teams.upgrade_plan_notice_message")}

View File

@@ -55,7 +55,7 @@ const defaultProps = {
setOpen: vi.fn(),
onSubmit: vi.fn(),
teams: [],
canDoRoleManagement: true,
isAccessControlAllowed: true,
isFormbricksCloud: true,
environmentId: "env-1",
membershipRole: "owner" as TOrganizationRole,

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