Compare commits

..

46 Commits

Author SHA1 Message Date
pandeymangg 9525e48da6 chore: merge with main 2025-10-17 23:37:06 +05:30
pandeymangg defbcad5d7 fix: remove zod import and move to types package 2025-10-17 23:12:53 +05:30
Johannes 11a42c8757 fix to validate email and url with zod, add missing error message via lingo 2025-10-17 15:47:14 +02:00
Aashish e030cd6cb5 Update open-text-question.tsx
safer, lookbehind-free equivalent:
2025-10-13 14:02:12 +05:30
Aashish d2f5e21bb6 Update open-text-question.tsx
updated instant brwoser feedback
2025-10-13 13:46:00 +05:30
Aashish bd218bf96d Update open-text-question.tsx 2025-10-13 13:29:53 +05:30
Aashish-png 7be0ac23b1 Merge branch '6687-invalidEmail' of https://github.com/Aashish-png/formbricks into 6687-invalidEmail 2025-10-13 10:30:18 +05:30
Aashish-png 378f45a102 fix:other invalid format as well 2025-10-13 10:25:48 +05:30
Aashish 8b3e093a81 Update open-text-question.tsx
removed this line input.setCustomValidity("");

since it was alreayd happening inside the handleInputChange
2025-10-13 00:23:33 +05:30
Aashish-png ae676a340f fixed invalid email #6687 2025-10-13 00:01:44 +05:30
pandeymangg b5fa058444 adds tests 2025-10-08 12:56:12 +05:30
pandeymangg 1615614d7d useClickOutside 2025-10-07 11:20:16 +05:30
pandeymangg 714f0400b9 removes gitignore addition 2025-10-07 11:15:07 +05:30
pandeymangg b50ff8335f fixes isDOMElement function 2025-10-07 11:14:16 +05:30
Raghuraj Pratap Singh f92f4a0fcd Merge branch 'formbricks:main' into fix/sentry-useClickOutside-contains-error 2025-10-01 21:11:39 +05:30
Johannes 4afb95b92a fix: switch Manage Subscription button bg to stripe color (#6633) 2025-10-01 12:00:44 +00:00
Piyush Gupta 38089241b4 chore: adds surveys package readme (#6598) 2025-10-01 11:26:03 +00:00
Raghuraj Pratap Singh f08a10eefc fix: prevent TypeError in useClickOutside hook during component unmounting
- Add DOM element validation before calling contains() method
- Fix race condition where ref.current becomes non-DOM object during React unmounting
- Apply fix to both apps/web and packages/surveys useClickOutside hooks
- Resolves Sentry issue FORMBRICKS-CLOUD-3WH

The issue occurred when React temporarily assigns non-DOM objects to refs
during component unmounting, causing "t.contains is not a function" error.
The fix adds proper validation to ensure contains() is only called on
actual DOM elements.
2025-10-01 15:31:38 +05:30
Johannes 07487d4871 docs: update license pages (#6631) 2025-10-01 01:40:19 -07:00
Johannes fa0879e3a0 chore: increase visibility of hover effect to indicate clickability (#6622)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-09-30 12:44:13 +00:00
Anshuman Pandey 3733c22a6f fix: file uploads and cluster setup docs (#6623) 2025-09-30 01:46:02 -07:00
Anshuman Pandey 5e5baa76ab fix: fixes the formbricks.sh redis undefined volume bug (#6604) 2025-09-25 13:55:43 +00:00
Dhruwang Jariwala 2153d2aa16 fix: replace button with div in IdBadge to prevent hydration issues (#6601) 2025-09-25 13:42:41 +00:00
Matti Nannt 7fa4862fd9 feat: make S3_REGION optional in storage client configuration (#6577) 2025-09-25 12:25:35 +00:00
Matti Nannt 411e9a26ee fix(ci): update release tag validation to accept format without v prefix (#6585) 2025-09-25 12:09:19 +00:00
Victor Hugo dos Santos eb1349f205 fix: enhance JWT handling with improved encryption and decryption logic (#6596) 2025-09-25 11:45:08 +00:00
Johannes 5c25f25212 docs: remove beta note (#6593) 2025-09-24 02:51:58 -07:00
Victor Hugo dos Santos 6af81e46ee chore: improve Sentry API logs with correlation ID and request context (#6584) 2025-09-24 09:25:51 +00:00
Jakob Schott 7423fc9472 fix: Improve messaging for mobile users (#6579)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-09-23 10:13:00 +00:00
Victor Hugo dos Santos 1557ffcca1 feat: add redis migration script (#6575)
Co-authored-by: Matti Nannt <matti@formbricks.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-09-22 11:18:02 +00:00
Piyush Gupta 5d53ed76ed fix: logic fallback cleanup (#6568) 2025-09-22 08:10:27 +00:00
Dhruwang Jariwala ebd399e611 fix: block previews for completed and paused surveys (#6576) 2025-09-22 07:21:38 +00:00
Dhruwang Jariwala 843110b0d6 fix: followup toast (#6565) 2025-09-19 13:03:56 +00:00
Anshuman Pandey 51babf2f98 fix: minor csp change and removes uploads volume (#6566) 2025-09-19 10:20:38 +00:00
Victor Hugo dos Santos 6bc5f1e168 feat: add cache integration tests and update E2E workflow (#6551) 2025-09-19 08:44:31 +00:00
Piyush Gupta c9016802e7 docs: updated screenshots in docs (#6562) 2025-09-18 19:19:14 +00:00
Anshuman Pandey 6a49fb4700 feat: adds one-click MinIO migration script for Formbricks 4.0 (#6553)
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-09-18 16:23:03 +00:00
Dhruwang Jariwala 646921cd37 fix: logic issues (#6561) 2025-09-18 18:31:44 +02:00
Dhruwang Jariwala 34d3145fcd fix: broken churn survey template (#6559) 2025-09-18 11:18:39 +00:00
Dhruwang Jariwala c3c06eb309 fix: empty container in template UI (#6556) 2025-09-18 06:45:20 +00:00
Dhruwang Jariwala bf4c6238d5 fix: api key modal tweaks (#6552)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-09-17 15:00:42 +00:00
Dhruwang Jariwala 8972ef0fef fix: integration redirect links (#6555) 2025-09-17 14:59:35 +00:00
Matti Nannt 4e59924a5a fix: e2e tests issue due to security policy (#6558) 2025-09-17 16:54:07 +02:00
Matti Nannt 8b28353b79 fix: release tag extraction in release action (#6554) 2025-09-16 17:33:32 +00:00
Matti Nannt abbc7a065b chore: update release pipeline for new infrastructure (#6541) 2025-09-16 10:33:24 +00:00
Harsh Bhat 00e8ee27a2 docs: Add redirect error handling (#6548) 2025-09-15 06:03:41 -07:00
155 changed files with 6977 additions and 1583 deletions
@@ -0,0 +1,312 @@
name: Build and Push Docker Image
description: |
Unified Docker build and push action for both ECR and GHCR registries.
Supports:
- ECR builds for Formbricks Cloud deployment
- GHCR builds for community self-hosting
- Automatic version resolution and tagging
- Conditional signing and deployment tags
inputs:
registry_type:
description: "Registry type: 'ecr' or 'ghcr'"
required: true
# Version input
version:
description: "Explicit version (SemVer only, e.g., 1.2.3). If provided, this version is used directly. If empty, version is auto-generated from branch name."
required: false
experimental_mode:
description: "Enable experimental timestamped versions"
required: false
default: "false"
# ECR specific inputs
ecr_registry:
description: "ECR registry URL (required for ECR builds)"
required: false
ecr_repository:
description: "ECR repository name (required for ECR builds)"
required: false
ecr_region:
description: "ECR AWS region (required for ECR builds)"
required: false
aws_role_arn:
description: "AWS role ARN for ECR authentication (required for ECR builds)"
required: false
# GHCR specific inputs
ghcr_image_name:
description: "GHCR image name (required for GHCR builds)"
required: false
# Deployment options
deploy_production:
description: "Tag image for production deployment"
required: false
default: "false"
deploy_staging:
description: "Tag image for staging deployment"
required: false
default: "false"
is_prerelease:
description: "Whether this is a prerelease (auto-tags for staging/production)"
required: false
default: "false"
# Build options
dockerfile:
description: "Path to Dockerfile"
required: false
default: "apps/web/Dockerfile"
context:
description: "Build context"
required: false
default: "."
outputs:
image_tag:
description: "Resolved image tag used for the build"
value: ${{ steps.version.outputs.version }}
registry_tags:
description: "Complete registry tags that were pushed"
value: ${{ steps.build.outputs.tags }}
image_digest:
description: "Image digest from the build"
value: ${{ steps.build.outputs.digest }}
runs:
using: "composite"
steps:
- name: Validate inputs
shell: bash
env:
REGISTRY_TYPE: ${{ inputs.registry_type }}
ECR_REGISTRY: ${{ inputs.ecr_registry }}
ECR_REPOSITORY: ${{ inputs.ecr_repository }}
ECR_REGION: ${{ inputs.ecr_region }}
AWS_ROLE_ARN: ${{ inputs.aws_role_arn }}
GHCR_IMAGE_NAME: ${{ inputs.ghcr_image_name }}
run: |
set -euo pipefail
if [[ "$REGISTRY_TYPE" != "ecr" && "$REGISTRY_TYPE" != "ghcr" ]]; then
echo "ERROR: registry_type must be 'ecr' or 'ghcr', got: $REGISTRY_TYPE"
exit 1
fi
if [[ "$REGISTRY_TYPE" == "ecr" ]]; then
if [[ -z "$ECR_REGISTRY" || -z "$ECR_REPOSITORY" || -z "$ECR_REGION" || -z "$AWS_ROLE_ARN" ]]; then
echo "ERROR: ECR builds require ecr_registry, ecr_repository, ecr_region, and aws_role_arn"
exit 1
fi
fi
if [[ "$REGISTRY_TYPE" == "ghcr" ]]; then
if [[ -z "$GHCR_IMAGE_NAME" ]]; then
echo "ERROR: GHCR builds require ghcr_image_name"
exit 1
fi
fi
echo "SUCCESS: Input validation passed for $REGISTRY_TYPE build"
- name: Resolve Docker version
id: version
uses: ./.github/actions/resolve-docker-version
with:
version: ${{ inputs.version }}
current_branch: ${{ github.ref_name }}
experimental_mode: ${{ inputs.experimental_mode }}
- name: Update package.json version
uses: ./.github/actions/update-package-version
with:
version: ${{ steps.version.outputs.version }}
- name: Configure AWS credentials (ECR only)
if: ${{ inputs.registry_type == 'ecr' }}
uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4.2.0
with:
role-to-assume: ${{ inputs.aws_role_arn }}
aws-region: ${{ inputs.ecr_region }}
- name: Log in to Amazon ECR (ECR only)
if: ${{ inputs.registry_type == 'ecr' }}
uses: aws-actions/amazon-ecr-login@062b18b96a7aff071d4dc91bc00c4c1a7945b076 # v2.0.1
- name: Set up Docker build tools
uses: ./.github/actions/docker-build-setup
with:
registry: ${{ inputs.registry_type == 'ghcr' && 'ghcr.io' || '' }}
setup_cosign: ${{ inputs.registry_type == 'ghcr' && 'true' || 'false' }}
skip_login_on_pr: ${{ inputs.registry_type == 'ghcr' && 'true' || 'false' }}
- name: Build ECR tag list
if: ${{ inputs.registry_type == 'ecr' }}
id: ecr-tags
shell: bash
env:
IMAGE_TAG: ${{ steps.version.outputs.version }}
ECR_REGISTRY: ${{ inputs.ecr_registry }}
ECR_REPOSITORY: ${{ inputs.ecr_repository }}
DEPLOY_PRODUCTION: ${{ inputs.deploy_production }}
DEPLOY_STAGING: ${{ inputs.deploy_staging }}
IS_PRERELEASE: ${{ inputs.is_prerelease }}
run: |
set -euo pipefail
# Start with the base image tag
TAGS="${ECR_REGISTRY}/${ECR_REPOSITORY}:${IMAGE_TAG}"
# Handle automatic tagging based on release type
if [[ "${IS_PRERELEASE}" == "true" ]]; then
TAGS="${TAGS}\n${ECR_REGISTRY}/${ECR_REPOSITORY}:staging"
echo "Adding staging tag for prerelease"
elif [[ "${IS_PRERELEASE}" == "false" ]]; then
TAGS="${TAGS}\n${ECR_REGISTRY}/${ECR_REPOSITORY}:production"
echo "Adding production tag for stable release"
fi
# Handle manual deployment overrides
if [[ "${DEPLOY_PRODUCTION}" == "true" ]]; then
TAGS="${TAGS}\n${ECR_REGISTRY}/${ECR_REPOSITORY}:production"
echo "Adding production tag (manual override)"
fi
if [[ "${DEPLOY_STAGING}" == "true" ]]; then
TAGS="${TAGS}\n${ECR_REGISTRY}/${ECR_REPOSITORY}:staging"
echo "Adding staging tag (manual override)"
fi
echo "ECR tags generated:"
echo -e "${TAGS}"
{
echo "tags<<EOF"
echo -e "${TAGS}"
echo "EOF"
} >> "${GITHUB_OUTPUT}"
- name: Generate additional GHCR tags for releases
if: ${{ inputs.registry_type == 'ghcr' && inputs.experimental_mode == 'false' && (github.event_name == 'workflow_call' || github.event_name == 'release' || github.event_name == 'workflow_dispatch') }}
id: ghcr-extra-tags
shell: bash
env:
VERSION: ${{ steps.version.outputs.version }}
IMAGE_NAME: ${{ inputs.ghcr_image_name }}
IS_PRERELEASE: ${{ inputs.is_prerelease }}
run: |
set -euo pipefail
# Start with base version tag
TAGS="ghcr.io/${IMAGE_NAME}:${VERSION}"
# For proper SemVer releases, add major.minor and major tags
if [[ "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
# Extract major and minor versions
MAJOR=$(echo "${VERSION}" | cut -d. -f1)
MINOR=$(echo "${VERSION}" | cut -d. -f2)
TAGS="${TAGS}\nghcr.io/${IMAGE_NAME}:${MAJOR}.${MINOR}"
TAGS="${TAGS}\nghcr.io/${IMAGE_NAME}:${MAJOR}"
echo "Added SemVer tags: ${MAJOR}.${MINOR}, ${MAJOR}"
fi
# Add latest tag for stable releases
if [[ "${IS_PRERELEASE}" == "false" ]]; then
TAGS="${TAGS}\nghcr.io/${IMAGE_NAME}:latest"
echo "Added latest tag for stable release"
fi
echo "Generated GHCR tags:"
echo -e "${TAGS}"
# Debug: Show what will be passed to Docker build
echo "DEBUG: Tags for Docker build step:"
echo -e "${TAGS}"
{
echo "tags<<EOF"
echo -e "${TAGS}"
echo "EOF"
} >> "${GITHUB_OUTPUT}"
- name: Build GHCR metadata (experimental)
if: ${{ inputs.registry_type == 'ghcr' && inputs.experimental_mode == 'true' }}
id: ghcr-meta-experimental
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
with:
images: ghcr.io/${{ inputs.ghcr_image_name }}
tags: |
type=ref,event=branch
type=raw,value=${{ steps.version.outputs.version }}
- name: Debug Docker build tags
shell: bash
run: |
echo "=== DEBUG: Docker Build Configuration ==="
echo "Registry Type: ${{ inputs.registry_type }}"
echo "Experimental Mode: ${{ inputs.experimental_mode }}"
echo "Event Name: ${{ github.event_name }}"
echo "Is Prerelease: ${{ inputs.is_prerelease }}"
echo "Version: ${{ steps.version.outputs.version }}"
if [[ "${{ inputs.registry_type }}" == "ecr" ]]; then
echo "ECR Tags: ${{ steps.ecr-tags.outputs.tags }}"
elif [[ "${{ inputs.experimental_mode }}" == "true" ]]; then
echo "GHCR Experimental Tags: ${{ steps.ghcr-meta-experimental.outputs.tags }}"
else
echo "GHCR Extra Tags: ${{ steps.ghcr-extra-tags.outputs.tags }}"
fi
- name: Build and push Docker image
id: build
uses: depot/build-push-action@636daae76684e38c301daa0c5eca1c095b24e780 # v1.14.0
with:
project: tw0fqmsx3c
token: ${{ env.DEPOT_PROJECT_TOKEN }}
context: ${{ inputs.context }}
file: ${{ inputs.dockerfile }}
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ inputs.registry_type == 'ecr' && steps.ecr-tags.outputs.tags || (inputs.registry_type == 'ghcr' && inputs.experimental_mode == 'true' && steps.ghcr-meta-experimental.outputs.tags) || (inputs.registry_type == 'ghcr' && inputs.experimental_mode == 'false' && steps.ghcr-extra-tags.outputs.tags) || (inputs.registry_type == 'ghcr' && format('ghcr.io/{0}:{1}', inputs.ghcr_image_name, steps.version.outputs.version)) || (inputs.registry_type == 'ecr' && format('{0}/{1}:{2}', inputs.ecr_registry, inputs.ecr_repository, steps.version.outputs.version)) }}
labels: ${{ inputs.registry_type == 'ghcr' && inputs.experimental_mode == 'true' && steps.ghcr-meta-experimental.outputs.labels || '' }}
secrets: |
database_url=${{ env.DUMMY_DATABASE_URL }}
encryption_key=${{ env.DUMMY_ENCRYPTION_KEY }}
redis_url=${{ env.DUMMY_REDIS_URL }}
sentry_auth_token=${{ env.SENTRY_AUTH_TOKEN }}
env:
DEPOT_PROJECT_TOKEN: ${{ env.DEPOT_PROJECT_TOKEN }}
DUMMY_DATABASE_URL: ${{ env.DUMMY_DATABASE_URL }}
DUMMY_ENCRYPTION_KEY: ${{ env.DUMMY_ENCRYPTION_KEY }}
DUMMY_REDIS_URL: ${{ env.DUMMY_REDIS_URL }}
SENTRY_AUTH_TOKEN: ${{ env.SENTRY_AUTH_TOKEN }}
- name: Sign GHCR image (GHCR only)
if: ${{ inputs.registry_type == 'ghcr' && (github.event_name == 'workflow_call' || github.event_name == 'release' || github.event_name == 'workflow_dispatch') }}
shell: bash
env:
TAGS: ${{ inputs.experimental_mode == 'true' && steps.ghcr-meta-experimental.outputs.tags || steps.ghcr-extra-tags.outputs.tags }}
DIGEST: ${{ steps.build.outputs.digest }}
run: |
set -euo pipefail
echo "${TAGS}" | xargs -I {} cosign sign --yes "{}@${DIGEST}"
- name: Output build summary
shell: bash
env:
REGISTRY_TYPE: ${{ inputs.registry_type }}
IMAGE_TAG: ${{ steps.version.outputs.version }}
VERSION_SOURCE: ${{ steps.version.outputs.source }}
run: |
echo "SUCCESS: Built and pushed Docker image to $REGISTRY_TYPE"
echo "Image Tag: $IMAGE_TAG (source: $VERSION_SOURCE)"
if [[ "$REGISTRY_TYPE" == "ecr" ]]; then
echo "ECR Registry: ${{ inputs.ecr_registry }}"
echo "ECR Repository: ${{ inputs.ecr_repository }}"
else
echo "GHCR Image: ghcr.io/${{ inputs.ghcr_image_name }}"
fi
@@ -0,0 +1,106 @@
name: Docker Build Setup
description: |
Sets up common Docker build tools and authentication with security validation.
Security Features:
- Registry URL validation
- Input sanitization
- Conditional setup based on event type
- Post-setup verification
Supports Depot CLI, Cosign signing, and Docker registry authentication.
inputs:
registry:
description: "Docker registry hostname to login to (e.g., ghcr.io, registry.example.com:5000). No paths allowed."
required: false
default: "ghcr.io"
setup_cosign:
description: "Whether to install cosign for image signing"
required: false
default: "true"
skip_login_on_pr:
description: "Whether to skip registry login on pull requests"
required: false
default: "true"
runs:
using: "composite"
steps:
- name: Validate inputs
shell: bash
env:
REGISTRY: ${{ inputs.registry }}
SETUP_COSIGN: ${{ inputs.setup_cosign }}
SKIP_LOGIN_ON_PR: ${{ inputs.skip_login_on_pr }}
run: |
set -euo pipefail
# Security: Validate registry input - must be hostname[:port] only, no paths
# Allow empty registry for cases where login is handled externally (e.g., ECR)
if [[ -n "$REGISTRY" ]]; then
if [[ "$REGISTRY" =~ / ]]; then
echo "ERROR: Invalid registry format: $REGISTRY"
echo "Registry must be host[:port] with no path (e.g., 'ghcr.io' or 'registry.example.com:5000')"
echo "Path components like 'ghcr.io/org' are not allowed as they break docker login"
exit 1
fi
# Validate hostname with optional port format
if [[ ! "$REGISTRY" =~ ^[a-zA-Z0-9.-]+(\:[0-9]+)?$ ]]; then
echo "ERROR: Invalid registry hostname format: $REGISTRY"
echo "Registry must be a valid hostname optionally with port (e.g., 'ghcr.io' or 'registry.example.com:5000')"
exit 1
fi
fi
# Validate boolean inputs
if [[ "$SETUP_COSIGN" != "true" && "$SETUP_COSIGN" != "false" ]]; then
echo "ERROR: setup_cosign must be 'true' or 'false', got: $SETUP_COSIGN"
exit 1
fi
if [[ "$SKIP_LOGIN_ON_PR" != "true" && "$SKIP_LOGIN_ON_PR" != "false" ]]; then
echo "ERROR: skip_login_on_pr must be 'true' or 'false', got: $SKIP_LOGIN_ON_PR"
exit 1
fi
echo "SUCCESS: Input validation passed"
- name: Set up Depot CLI
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
- name: Install cosign
# Install cosign when requested AND when we might actually sign images
# (i.e., non-PR contexts or when we login on PRs)
if: ${{ inputs.setup_cosign == 'true' && (inputs.skip_login_on_pr == 'false' || github.event_name != 'pull_request') }}
uses: sigstore/cosign-installer@3454372f43399081ed03b604cb2d021dabca52bb # v3.8.2
- name: Log into registry
if: ${{ inputs.registry != '' && (inputs.skip_login_on_pr == 'false' || github.event_name != 'pull_request') }}
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with:
registry: ${{ inputs.registry }}
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Verify setup completion
shell: bash
run: |
set -euo pipefail
# Verify Depot CLI is available
if ! command -v depot >/dev/null 2>&1; then
echo "ERROR: Depot CLI not found in PATH"
exit 1
fi
# Verify cosign if it should be installed (same conditions as install step)
if [[ "${{ inputs.setup_cosign }}" == "true" ]] && [[ "${{ inputs.skip_login_on_pr }}" == "false" || "${{ github.event_name }}" != "pull_request" ]]; then
if ! command -v cosign >/dev/null 2>&1; then
echo "ERROR: Cosign not found in PATH despite being requested"
exit 1
fi
fi
echo "SUCCESS: Docker build setup completed successfully"
@@ -0,0 +1,192 @@
name: Resolve Docker Version
description: |
Resolves and validates Docker-compatible SemVer versions for container builds with comprehensive security.
Security Features:
- Command injection protection
- Input sanitization and validation
- Docker tag character restrictions
- Length limits and boundary checks
- Safe branch name handling
Supports multiple modes: release, manual override, branch auto-detection, and experimental timestamped versions.
inputs:
version:
description: "Explicit version (SemVer only, e.g., 1.2.3-beta). If provided, this version is used directly. If empty, version is auto-generated from branch name."
required: false
current_branch:
description: "Current branch name for auto-detection"
required: true
experimental_mode:
description: "Enable experimental mode with timestamp-based versions"
required: false
default: "false"
outputs:
version:
description: "Resolved Docker-compatible SemVer version"
value: ${{ steps.resolve.outputs.version }}
source:
description: "Source of version (release|override|branch)"
value: ${{ steps.resolve.outputs.source }}
normalized:
description: "Whether the version was normalized (true/false)"
value: ${{ steps.resolve.outputs.normalized }}
runs:
using: "composite"
steps:
- name: Resolve and validate Docker version
id: resolve
shell: bash
env:
EXPLICIT_VERSION: ${{ inputs.version }}
CURRENT_BRANCH: ${{ inputs.current_branch }}
EXPERIMENTAL_MODE: ${{ inputs.experimental_mode }}
run: |
set -euo pipefail
# Function to validate SemVer format (Docker-compatible, no '+' build metadata)
validate_semver() {
local version="$1"
local context="$2"
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]]; then
echo "ERROR: Invalid $context format. Must be semver without build metadata (e.g., 1.2.3, 1.2.3-alpha)"
echo "Provided: $version"
echo "Note: Docker tags cannot contain '+' characters. Use prerelease identifiers instead."
exit 1
fi
}
# Function to generate branch-based version
generate_branch_version() {
local branch="$1"
local use_timestamp="${2:-true}"
local timestamp
if [[ "$use_timestamp" == "true" ]]; then
timestamp=$(date +%s)
else
timestamp=""
fi
# Sanitize branch name for Docker compatibility
local sanitized_branch=$(echo "$branch" | sed 's/[^a-zA-Z0-9.-]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g')
# Additional safety: truncate if too long (reserve space for prefix and timestamp)
if (( ${#sanitized_branch} > 80 )); then
sanitized_branch="${sanitized_branch:0:80}"
echo "INFO: Branch name truncated for Docker compatibility" >&2
fi
local version
# Generate version based on branch name (unified approach)
# All branches get alpha versions with sanitized branch name
if [[ -n "$timestamp" ]]; then
version="0.0.0-alpha-$sanitized_branch-$timestamp"
echo "INFO: Branch '$branch' detected - alpha version: $version" >&2
else
version="0.0.0-alpha-$sanitized_branch"
echo "INFO: Branch '$branch' detected - alpha version: $version" >&2
fi
echo "$version"
}
# Input validation and sanitization
if [[ -z "$CURRENT_BRANCH" ]]; then
echo "ERROR: current_branch input is required"
exit 1
fi
# Security: Validate inputs to prevent command injection
# Use grep to check for dangerous characters (more reliable than bash regex)
validate_input() {
local input="$1"
local name="$2"
# Check for dangerous characters using grep
if echo "$input" | grep -q '[;|&`$(){}\\[:space:]]'; then
echo "ERROR: $name contains potentially dangerous characters: $input"
echo "Input should only contain letters, numbers, hyphens, underscores, dots, and forward slashes"
return 1
fi
return 0
}
# Validate current branch
if ! validate_input "$CURRENT_BRANCH" "Branch name"; then
exit 1
fi
# Validate explicit version if provided
if [[ -n "$EXPLICIT_VERSION" ]] && ! validate_input "$EXPLICIT_VERSION" "Explicit version"; then
exit 1
fi
# Main resolution logic (ultra-simplified)
NORMALIZED="false"
if [[ -n "$EXPLICIT_VERSION" ]]; then
# Use provided explicit version (from either workflow_call or manual input)
validate_semver "$EXPLICIT_VERSION" "explicit version"
# Normalize to lowercase for Docker/ECR compatibility
RESOLVED_VERSION="${EXPLICIT_VERSION,,}"
if [[ "$EXPLICIT_VERSION" != "$RESOLVED_VERSION" ]]; then
NORMALIZED="true"
echo "INFO: Original version contained uppercase characters, normalized: $EXPLICIT_VERSION -> $RESOLVED_VERSION"
fi
SOURCE="explicit"
echo "INFO: Using explicit version: $RESOLVED_VERSION"
else
# Auto-generate version from branch name
if [[ "$EXPERIMENTAL_MODE" == "true" ]]; then
# Use timestamped version generation
echo "INFO: Experimental mode: generating timestamped version from branch: $CURRENT_BRANCH"
RESOLVED_VERSION=$(generate_branch_version "$CURRENT_BRANCH" "true")
SOURCE="experimental"
else
# Standard branch version (no timestamp)
echo "INFO: Auto-detecting version from branch: $CURRENT_BRANCH"
RESOLVED_VERSION=$(generate_branch_version "$CURRENT_BRANCH" "false")
SOURCE="branch"
fi
echo "Generated version: $RESOLVED_VERSION"
fi
# Final validation - ensure result is valid Docker tag
if [[ -z "$RESOLVED_VERSION" ]]; then
echo "ERROR: Failed to resolve version"
exit 1
fi
if (( ${#RESOLVED_VERSION} > 128 )); then
echo "ERROR: Version must be at most 128 characters (Docker limitation)"
echo "Generated version: $RESOLVED_VERSION (${#RESOLVED_VERSION} chars)"
exit 1
fi
if [[ ! "$RESOLVED_VERSION" =~ ^[a-z0-9._-]+$ ]]; then
echo "ERROR: Version contains invalid characters for Docker tags"
echo "Version: $RESOLVED_VERSION"
exit 1
fi
if [[ "$RESOLVED_VERSION" =~ ^[.-] || "$RESOLVED_VERSION" =~ [.-]$ ]]; then
echo "ERROR: Version must not start or end with '.' or '-'"
echo "Version: $RESOLVED_VERSION"
exit 1
fi
# Output results
echo "SUCCESS: Resolved Docker version: $RESOLVED_VERSION (source: $SOURCE)"
echo "version=$RESOLVED_VERSION" >> $GITHUB_OUTPUT
echo "source=$SOURCE" >> $GITHUB_OUTPUT
echo "normalized=$NORMALIZED" >> $GITHUB_OUTPUT
@@ -0,0 +1,160 @@
name: Update Package Version
description: |
Safely updates package.json version with comprehensive validation and atomic operations.
Security Features:
- Path traversal protection
- SemVer validation with length limits
- Atomic file operations with backup/recovery
- JSON validation before applying changes
This action is designed to be secure by default and prevent common attack vectors.
inputs:
version:
description: "Version to set in package.json (must be valid SemVer)"
required: true
package_path:
description: "Path to package.json file"
required: false
default: "./apps/web/package.json"
outputs:
updated_version:
description: "The version that was actually set in package.json"
value: ${{ steps.update.outputs.updated_version }}
runs:
using: "composite"
steps:
- name: Update and verify package.json version
id: update
shell: bash
env:
VERSION: ${{ inputs.version }}
PACKAGE_PATH: ${{ inputs.package_path }}
run: |
set -euo pipefail
# Validate inputs
if [[ -z "$VERSION" ]]; then
echo "ERROR: version input is required"
exit 1
fi
# Security: Validate package_path to prevent path traversal attacks
# Only allow paths within the workspace and must end with package.json
if [[ "$PACKAGE_PATH" =~ \.\./|^/|^~ ]]; then
echo "ERROR: Invalid package path - path traversal detected: $PACKAGE_PATH"
echo "Package path must be relative to workspace root and cannot contain '../', start with '/', or '~'"
exit 1
fi
if [[ ! "$PACKAGE_PATH" =~ package\.json$ ]]; then
echo "ERROR: Package path must end with 'package.json': $PACKAGE_PATH"
exit 1
fi
# Resolve to absolute path within workspace for additional security
WORKSPACE_ROOT="${GITHUB_WORKSPACE:-$(pwd)}"
# Use realpath to resolve both paths and handle symlinks properly
WORKSPACE_ROOT=$(realpath "$WORKSPACE_ROOT")
RESOLVED_PATH=$(realpath "${WORKSPACE_ROOT}/${PACKAGE_PATH}")
# Ensure WORKSPACE_ROOT has a trailing slash for proper prefix matching
WORKSPACE_ROOT="${WORKSPACE_ROOT}/"
# Use shell string matching to ensure RESOLVED_PATH is within workspace
# This is more secure than regex and handles edge cases properly
if [[ "$RESOLVED_PATH" != "$WORKSPACE_ROOT"* ]]; then
echo "ERROR: Resolved path is outside workspace: $RESOLVED_PATH"
echo "Workspace root: $WORKSPACE_ROOT"
exit 1
fi
if [[ ! -f "$RESOLVED_PATH" ]]; then
echo "ERROR: package.json not found at: $RESOLVED_PATH"
exit 1
fi
# Use resolved path for operations
PACKAGE_PATH="$RESOLVED_PATH"
# Validate SemVer format with additional security checks
if [[ ${#VERSION} -gt 128 ]]; then
echo "ERROR: Version string too long (${#VERSION} chars, max 128): $VERSION"
exit 1
fi
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]]; then
echo "ERROR: Invalid SemVer format: $VERSION"
echo "Expected format: MAJOR.MINOR.PATCH[-PRERELEASE]"
echo "Only alphanumeric characters, dots, and hyphens allowed in prerelease"
exit 1
fi
# Additional validation: Check for reasonable version component sizes
# Extract base version (MAJOR.MINOR.PATCH) without prerelease/build metadata
if [[ "$VERSION" =~ ^([0-9]+\.[0-9]+\.[0-9]+) ]]; then
BASE_VERSION="${BASH_REMATCH[1]}"
else
echo "ERROR: Could not extract base version from: $VERSION"
exit 1
fi
# Split version components safely
IFS='.' read -ra VERSION_PARTS <<< "$BASE_VERSION"
# Validate component sizes (should have exactly 3 parts due to regex above)
if (( ${VERSION_PARTS[0]} > 999 || ${VERSION_PARTS[1]} > 999 || ${VERSION_PARTS[2]} > 999 )); then
echo "ERROR: Version components too large (max 999 each): $VERSION"
echo "Components: ${VERSION_PARTS[0]}.${VERSION_PARTS[1]}.${VERSION_PARTS[2]}"
exit 1
fi
echo "Updating package.json version to: $VERSION"
# Create backup for atomic operations
BACKUP_PATH="${PACKAGE_PATH}.backup.$$"
cp "$PACKAGE_PATH" "$BACKUP_PATH"
# Use jq to safely update the version field with error handling
if ! jq --arg version "$VERSION" '.version = $version' "$PACKAGE_PATH" > "${PACKAGE_PATH}.tmp"; then
echo "ERROR: jq failed to process package.json"
rm -f "${PACKAGE_PATH}.tmp" "$BACKUP_PATH"
exit 1
fi
# Validate the generated JSON before applying changes
if ! jq empty "${PACKAGE_PATH}.tmp" 2>/dev/null; then
echo "ERROR: Generated invalid JSON"
rm -f "${PACKAGE_PATH}.tmp" "$BACKUP_PATH"
exit 1
fi
# Atomic move operation
if ! mv "${PACKAGE_PATH}.tmp" "$PACKAGE_PATH"; then
echo "ERROR: Failed to update package.json"
# Restore backup
mv "$BACKUP_PATH" "$PACKAGE_PATH"
exit 1
fi
# Verify the update was successful
UPDATED_VERSION=$(jq -r '.version' "$PACKAGE_PATH" 2>/dev/null)
if [[ "$UPDATED_VERSION" != "$VERSION" ]]; then
echo "ERROR: Version update failed!"
echo "Expected: $VERSION"
echo "Actual: $UPDATED_VERSION"
# Restore backup
mv "$BACKUP_PATH" "$PACKAGE_PATH"
exit 1
fi
# Clean up backup on success
rm -f "$BACKUP_PATH"
echo "SUCCESS: Updated package.json version to: $UPDATED_VERSION"
echo "updated_version=$UPDATED_VERSION" >> $GITHUB_OUTPUT
+48 -128
View File
@@ -1,12 +1,16 @@
name: Build & Push Docker to ECR
name: Build Cloud Deployment Images
# This workflow builds Formbricks Docker images for ECR deployment:
# - workflow_call: Used by releases with explicit SemVer versions
# - workflow_dispatch: Auto-detects version from current branch or uses override
on:
workflow_dispatch:
inputs:
image_tag:
description: "Image tag to push (e.g., v3.16.1, main)"
required: true
default: "v3.16.1"
version_override:
description: "Override version (SemVer only, e.g., 1.2.3). Leave empty to auto-detect from branch."
required: false
type: string
deploy_production:
description: "Tag image for production deployment"
required: false
@@ -17,6 +21,24 @@ on:
required: false
default: false
type: boolean
workflow_call:
inputs:
image_tag:
description: "Image tag to push (required for workflow_call)"
required: true
type: string
IS_PRERELEASE:
description: "Whether this is a prerelease (auto-tags for staging/production)"
required: false
type: boolean
default: false
outputs:
IMAGE_TAG:
description: "Normalized image tag used for the build"
value: ${{ jobs.build-and-push.outputs.IMAGE_TAG }}
TAGS:
description: "Newline-separated list of ECR tags pushed"
value: ${{ jobs.build-and-push.outputs.TAGS }}
permissions:
contents: read
@@ -27,14 +49,15 @@ env:
# ECR settings are sourced from repository/environment variables for portability across envs/forks
ECR_REGISTRY: ${{ vars.ECR_REGISTRY }}
ECR_REPOSITORY: ${{ vars.ECR_REPOSITORY }}
DOCKERFILE: apps/web/Dockerfile
CONTEXT: .
jobs:
build-and-push:
name: Build and Push
runs-on: ubuntu-latest
timeout-minutes: 45
outputs:
IMAGE_TAG: ${{ steps.build.outputs.image_tag }}
TAGS: ${{ steps.build.outputs.registry_tags }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
@@ -44,125 +67,22 @@ jobs:
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Validate image tag input
shell: bash
env:
IMAGE_TAG: ${{ inputs.image_tag }}
run: |
set -euo pipefail
if [[ -z "${IMAGE_TAG}" ]]; then
echo "❌ Image tag is required (non-empty)."
exit 1
fi
if (( ${#IMAGE_TAG} > 128 )); then
echo "❌ Image tag must be at most 128 characters."
exit 1
fi
if [[ ! "${IMAGE_TAG}" =~ ^[a-z0-9._-]+$ ]]; then
echo "❌ Image tag may only contain lowercase letters, digits, '.', '_' and '-'."
exit 1
fi
if [[ "${IMAGE_TAG}" =~ ^[.-] || "${IMAGE_TAG}" =~ [.-]$ ]]; then
echo "❌ Image tag must not start or end with '.' or '-'."
exit 1
fi
- name: Validate required variables
shell: bash
env:
ECR_REGISTRY: ${{ env.ECR_REGISTRY }}
ECR_REPOSITORY: ${{ env.ECR_REPOSITORY }}
ECR_REGION: ${{ env.ECR_REGION }}
run: |
set -euo pipefail
if [[ -z "${ECR_REGISTRY}" || -z "${ECR_REPOSITORY}" || -z "${ECR_REGION}" ]]; then
echo "ECR_REGION, ECR_REGISTRY and ECR_REPOSITORY must be set via repository or environment variables (Settings → Variables)."
exit 1
fi
- name: Update package.json version
shell: bash
env:
IMAGE_TAG: ${{ inputs.image_tag }}
run: |
set -euo pipefail
# Remove 'v' prefix if present (e.g., v3.16.1 -> 3.16.1)
VERSION="${IMAGE_TAG#v}"
# Validate SemVer format (major.minor.patch with optional prerelease and build metadata)
if [[ ! "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$ ]]; then
echo "❌ Error: Invalid version format after extraction. Must be SemVer (e.g., 1.2.3, 1.2.3-alpha, 1.2.3+build.1)"
echo "Original input: ${IMAGE_TAG}"
echo "Extracted version: ${VERSION}"
echo "Expected format: MAJOR.MINOR.PATCH[-PRERELEASE][+BUILDMETADATA]"
exit 1
fi
echo "✅ Valid SemVer format detected: ${VERSION}"
echo "Updating package.json version to: ${VERSION}"
sed -i "s/\"version\": \"0.0.0\"/\"version\": \"${VERSION}\"/" ./apps/web/package.json
cat ./apps/web/package.json | grep version
- name: Build tag list
id: tags
shell: bash
env:
IMAGE_TAG: ${{ inputs.image_tag }}
DEPLOY_PRODUCTION: ${{ inputs.deploy_production }}
DEPLOY_STAGING: ${{ inputs.deploy_staging }}
ECR_REGISTRY: ${{ env.ECR_REGISTRY }}
ECR_REPOSITORY: ${{ env.ECR_REPOSITORY }}
run: |
set -euo pipefail
# Start with the base image tag
TAGS="${ECR_REGISTRY}/${ECR_REPOSITORY}:${IMAGE_TAG}"
# Add production tag if requested
if [[ "${DEPLOY_PRODUCTION}" == "true" ]]; then
TAGS="${TAGS}\n${ECR_REGISTRY}/${ECR_REPOSITORY}:production"
fi
# Add staging tag if requested
if [[ "${DEPLOY_STAGING}" == "true" ]]; then
TAGS="${TAGS}\n${ECR_REGISTRY}/${ECR_REPOSITORY}:staging"
fi
# Output for debugging
echo "Generated tags:"
echo -e "${TAGS}"
# Set output for next step (escape newlines for GitHub Actions)
{
echo "tags<<EOF"
echo -e "${TAGS}"
echo "EOF"
} >> "${GITHUB_OUTPUT}"
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a
- name: Build and push cloud deployment image
id: build
uses: ./.github/actions/build-and-push-docker
with:
role-to-assume: ${{ secrets.AWS_ECR_PUSH_ROLE_ARN }}
aws-region: ${{ env.ECR_REGION }}
- name: Log in to Amazon ECR
uses: aws-actions/amazon-ecr-login@062b18b96a7aff071d4dc91bc00c4c1a7945b076
- name: Set up Depot CLI
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
- name: Build and push image (Depot remote builder)
uses: depot/build-push-action@636daae76684e38c301daa0c5eca1c095b24e780 # v1.14.0
with:
project: tw0fqmsx3c
token: ${{ secrets.DEPOT_PROJECT_TOKEN }}
context: ${{ env.CONTEXT }}
file: ${{ env.DOCKERFILE }}
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.tags.outputs.tags }}
secrets: |
database_url=${{ secrets.DUMMY_DATABASE_URL }}
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
sentry_auth_token=${{ secrets.SENTRY_AUTH_TOKEN }}
registry_type: "ecr"
ecr_registry: ${{ env.ECR_REGISTRY }}
ecr_repository: ${{ env.ECR_REPOSITORY }}
ecr_region: ${{ env.ECR_REGION }}
aws_role_arn: ${{ secrets.AWS_ECR_PUSH_ROLE_ARN }}
version: ${{ inputs.version_override || inputs.image_tag }}
deploy_production: ${{ inputs.deploy_production }}
deploy_staging: ${{ inputs.deploy_staging }}
is_prerelease: ${{ inputs.IS_PRERELEASE }}
env:
DEPOT_PROJECT_TOKEN: ${{ secrets.DEPOT_PROJECT_TOKEN }}
DUMMY_DATABASE_URL: ${{ secrets.DUMMY_DATABASE_URL }}
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
DUMMY_REDIS_URL: ${{ secrets.DUMMY_REDIS_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
@@ -4,7 +4,7 @@ on:
workflow_dispatch:
inputs:
VERSION:
description: "The version of the Docker image to release, full image tag if image tag is v0.0.0 enter v0.0.0."
description: "The version of the Docker image to release (clean SemVer, e.g., 1.2.3)"
required: true
type: string
REPOSITORY:
+31 -19
View File
@@ -33,7 +33,7 @@ jobs:
timeout-minutes: 60
services:
postgres:
image: pgvector/pgvector:pg17
image: pgvector/pgvector@sha256:9ae02a756ba16a2d69dd78058e25915e36e189bb36ddf01ceae86390d7ed786a
env:
POSTGRES_DB: postgres
POSTGRES_USER: postgres
@@ -41,7 +41,7 @@ jobs:
ports:
- 5432:5432
options: >-
--health-cmd="pg_isready -U testuser"
--health-cmd="pg_isready -U postgres"
--health-interval=10s
--health-timeout=5s
--health-retries=5
@@ -49,25 +49,15 @@ jobs:
image: valkey/valkey@sha256:12ba4f45a7c3e1d0f076acd616cb230834e75a77e8516dde382720af32832d6d
ports:
- 6379:6379
minio:
image: bitnami/minio:2025.7.23-debian-12-r5
env:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
ports:
- 9000:9000
options: >-
--health-cmd="curl -fsS http://localhost:9000/minio/health/live || exit 1"
--health-interval=10s
--health-timeout=5s
--health-retries=20
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: allow
egress-policy: audit
allowed-endpoints: |
ee.formbricks.com:443
registry-1.docker.io:443
docker.io:443
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: ./.github/actions/dangerous-git-checkout
@@ -101,8 +91,8 @@ jobs:
echo "S3_REGION=us-east-1" >> .env
echo "S3_BUCKET_NAME=formbricks-e2e" >> .env
echo "S3_ENDPOINT_URL=http://localhost:9000" >> .env
echo "S3_ACCESS_KEY=minioadmin" >> .env
echo "S3_SECRET_KEY=minioadmin" >> .env
echo "S3_ACCESS_KEY=devminio" >> .env
echo "S3_SECRET_KEY=devminio123" >> .env
echo "S3_FORCE_PATH_STYLE=1" >> .env
shell: bash
@@ -122,6 +112,22 @@ jobs:
chmod +x "${MC_BIN}"
sudo mv "${MC_BIN}" /usr/local/bin/mc
- name: Start MinIO Server
run: |
set -euo pipefail
# Start MinIO server in background
docker run -d \
--name minio-server \
-p 9000:9000 \
-p 9001:9001 \
-e MINIO_ROOT_USER=devminio \
-e MINIO_ROOT_PASSWORD=devminio123 \
minio/minio:RELEASE.2025-09-07T16-13-09Z \
server /data --console-address :9001
echo "MinIO server started"
- name: Wait for MinIO and create S3 bucket
run: |
set -euo pipefail
@@ -142,7 +148,7 @@ jobs:
exit 1
fi
mc alias set local http://localhost:9000 minioadmin minioadmin
mc alias set local http://localhost:9000 devminio devminio123
mc mb --ignore-existing local/formbricks-e2e
- name: Build App
@@ -160,6 +166,12 @@ jobs:
cd apps/web && pnpm vitest run modules/core/rate-limit/rate-limit-load.test.ts
shell: bash
- name: Run Cache Integration Tests
run: |
echo "Running cache integration tests with Redis/Valkey..."
cd packages/cache && pnpm vitest run src/cache-integration.test.ts
shell: bash
- name: Check for Enterprise License
run: |
LICENSE_KEY=$(grep '^ENTERPRISE_LICENSE_KEY=' .env | cut -d'=' -f2-)
+41 -18
View File
@@ -8,8 +8,8 @@ permissions:
contents: read
jobs:
docker-build:
name: Build & release docker image
docker-build-community:
name: Build & release community docker image
permissions:
contents: read
packages: write
@@ -19,6 +19,19 @@ jobs:
with:
IS_PRERELEASE: ${{ github.event.release.prerelease }}
docker-build-cloud:
name: Build & push Formbricks Cloud to ECR
permissions:
contents: read
id-token: write
uses: ./.github/workflows/build-and-push-ecr.yml
secrets: inherit
with:
image_tag: ${{ needs.docker-build-community.outputs.VERSION }}
IS_PRERELEASE: ${{ github.event.release.prerelease }}
needs:
- docker-build-community
helm-chart-release:
name: Release Helm Chart
permissions:
@@ -27,31 +40,41 @@ jobs:
uses: ./.github/workflows/release-helm-chart.yml
secrets: inherit
needs:
- docker-build
- docker-build-community
with:
VERSION: ${{ needs.docker-build.outputs.VERSION }}
VERSION: ${{ needs.docker-build-community.outputs.VERSION }}
deploy-formbricks-cloud:
name: Deploy Helm Chart to Formbricks Cloud
permissions:
contents: read
id-token: write
secrets: inherit
uses: ./.github/workflows/deploy-formbricks-cloud.yml
verify-cloud-build:
name: Verify Cloud Build Outputs
runs-on: ubuntu-latest
timeout-minutes: 5 # Simple verification should be quick
needs:
- docker-build
- helm-chart-release
with:
VERSION: v${{ needs.docker-build.outputs.VERSION }}
ENVIRONMENT: ${{ github.event.release.prerelease && 'staging' || 'production' }}
- docker-build-cloud
steps:
- name: Harden the runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Display ECR build outputs
env:
IMAGE_TAG: ${{ needs.docker-build-cloud.outputs.IMAGE_TAG }}
TAGS: ${{ needs.docker-build-cloud.outputs.TAGS }}
run: |
set -euo pipefail
echo "✅ ECR Build Completed Successfully"
echo "Image Tag: ${IMAGE_TAG}"
echo "ECR Tags:"
printf '%s\n' "${TAGS}"
move-stable-tag:
name: Move stable tag to release
permissions:
contents: read
contents: write # Required for tag push operations in called workflow
uses: ./.github/workflows/move-stable-tag.yml
needs:
- docker-build # Ensure release is successful first
- docker-build-community # Ensure release is successful first
with:
release_tag: ${{ github.event.release.tag_name }}
commit_sha: ${{ github.sha }}
+3 -3
View File
@@ -4,7 +4,7 @@ on:
workflow_call:
inputs:
release_tag:
description: "The release tag name (e.g., v1.2.3)"
description: "The release tag name (e.g., 1.2.3)"
required: true
type: string
commit_sha:
@@ -53,8 +53,8 @@ jobs:
set -euo pipefail
# Validate release tag format
if [[ ! "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$ ]]; then
echo "❌ Error: Invalid release tag format. Expected format: v1.2.3, v1.2.3-alpha"
if [[ ! "$RELEASE_TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$ ]]; then
echo "❌ Error: Invalid release tag format. Expected format: 1.2.3, 1.2.3-alpha"
echo "Provided: $RELEASE_TAG"
exit 1
fi
@@ -1,39 +1,31 @@
name: Docker Release to Github Experimental
name: Build Community Testing Images
# 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.
# This workflow builds experimental/testing versions of Formbricks for self-hosting customers
# to test fixes and features before official releases. Images are pushed to GHCR with
# timestamped experimental versions for easy identification and testing.
on:
workflow_dispatch:
env:
# Use docker.io for Docker Hub if empty
REGISTRY: ghcr.io
# github.repository as <account>/<repo>
IMAGE_NAME: ${{ github.repository }}-experimental
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
inputs:
version_override:
description: "Override version (SemVer only, e.g., 1.2.3-beta). Leave empty for auto-generated experimental version."
required: false
type: string
permissions:
contents: read
packages: write
id-token: write
jobs:
build:
build-community-testing:
name: Build Community Testing Image
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
# This is used to complete the identity challenge
# with sigstore/fulcio when running outside of PRs.
id-token: write
timeout-minutes: 45
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
@@ -42,110 +34,17 @@ jobs:
with:
fetch-depth: 0
- name: Generate SemVer version from branch or tag
id: generate_version
- name: Build and push community testing image
uses: ./.github/actions/build-and-push-docker
with:
registry_type: "ghcr"
ghcr_image_name: "${{ github.repository }}-experimental"
experimental_mode: "true"
version: ${{ inputs.version_override }}
env:
REF_NAME: ${{ github.ref_name }}
REF_TYPE: ${{ github.ref_type }}
run: |
# Get reference name and type from environment variables
echo "Reference type: $REF_TYPE"
echo "Reference name: $REF_NAME"
# Create unique timestamped version for testing sourcemap resolution
TIMESTAMP=$(date +%s)
if [[ "$REF_TYPE" == "tag" ]]; then
# If running from a tag, use the tag name + timestamp
if [[ "$REF_NAME" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+.*$ ]]; then
# Tag looks like a SemVer, use it directly (remove 'v' prefix if present)
BASE_VERSION=$(echo "$REF_NAME" | sed 's/^v//')
VERSION="${BASE_VERSION}-${TIMESTAMP}"
echo "Using SemVer tag with timestamp: $VERSION"
else
# Tag is not SemVer, treat as prerelease
SANITIZED_TAG=$(echo "$REF_NAME" | sed 's/[^a-zA-Z0-9.-]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g')
VERSION="0.0.0-${SANITIZED_TAG}-${TIMESTAMP}"
echo "Using tag as prerelease with timestamp: $VERSION"
fi
else
# Running from branch, use branch name as prerelease + timestamp
SANITIZED_BRANCH=$(echo "$REF_NAME" | sed 's/[^a-zA-Z0-9.-]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g')
VERSION="0.0.0-${SANITIZED_BRANCH}-${TIMESTAMP}"
echo "Using branch as prerelease with timestamp: $VERSION"
fi
echo "VERSION=$VERSION" >> $GITHUB_ENV
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
echo "Generated SemVer version: $VERSION"
- name: Update package.json version
run: |
sed -i "s/\"version\": \"0.0.0\"/\"version\": \"${{ env.VERSION }}\"/" ./apps/web/package.json
cat ./apps/web/package.json | grep version
- name: Set up Depot CLI
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
# Install the cosign tool except on PR
# https://github.com/sigstore/cosign-installer
- name: Install cosign
if: github.event_name != 'pull_request'
uses: sigstore/cosign-installer@3454372f43399081ed03b604cb2d021dabca52bb # v3.8.2
# Login against a Docker registry except on PR
# https://github.com/docker/login-action
- name: Log into registry ${{ env.REGISTRY }}
if: github.event_name != 'pull_request'
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Extract metadata (tags, labels) for Docker
# https://github.com/docker/metadata-action
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=raw,value=${{ env.VERSION }}
# Build and push Docker image with Buildx (don't push on PR)
# https://github.com/docker/build-push-action
- name: Build and push Docker image
id: build-and-push
uses: depot/build-push-action@636daae76684e38c301daa0c5eca1c095b24e780 # v1.14.0
with:
project: tw0fqmsx3c
token: ${{ secrets.DEPOT_PROJECT_TOKEN }}
context: .
file: ./apps/web/Dockerfile
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
secrets: |
database_url=${{ secrets.DUMMY_DATABASE_URL }}
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
sentry_auth_token=${{ secrets.SENTRY_AUTH_TOKEN }}
# Sign the resulting Docker image digest except on PRs.
# This will only write to the public Rekor transparency log when the Docker
# repository is public to avoid leaking data. If you would like to publish
# transparency data even for private images, pass --force to cosign below.
# https://github.com/sigstore/cosign
- name: Sign the published Docker image
if: ${{ github.event_name != 'pull_request' }}
env:
# https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable
TAGS: ${{ steps.meta.outputs.tags }}
DIGEST: ${{ steps.build-and-push.outputs.digest }}
# This step uses the identity token to provision an ephemeral certificate
# against the sigstore community Fulcio instance.
run: echo "${TAGS}" | xargs -I {} cosign sign --yes "{}@${DIGEST}"
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DEPOT_PROJECT_TOKEN: ${{ secrets.DEPOT_PROJECT_TOKEN }}
DUMMY_DATABASE_URL: ${{ secrets.DUMMY_DATABASE_URL }}
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
DUMMY_REDIS_URL: ${{ secrets.DUMMY_REDIS_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
+43 -87
View File
@@ -1,4 +1,4 @@
name: Docker Release to Github
name: Release Community Docker Images
# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
@@ -23,8 +23,6 @@ env:
REGISTRY: ghcr.io
# github.repository as <account>/<repo>
IMAGE_NAME: ${{ github.repository }}
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
permissions:
contents: read
@@ -32,6 +30,7 @@ permissions:
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 45
permissions:
contents: read
packages: write
@@ -44,103 +43,60 @@ jobs:
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Get Release Tag
- name: Extract release version from tag
id: extract_release_tag
run: |
# Extract version from tag (e.g., refs/tags/v1.2.3 -> 1.2.3)
TAG="$GITHUB_REF"
TAG=${TAG#refs/tags/v}
set -euo pipefail
# 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"
# Extract tag name with fallback logic for different trigger contexts
if [[ -n "${RELEASE_TAG:-}" ]]; then
TAG="$RELEASE_TAG"
echo "Using RELEASE_TAG override: $TAG"
elif [[ "$GITHUB_REF_NAME" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]] || [[ "$GITHUB_REF_NAME" =~ ^v[0-9] ]]; then
TAG="$GITHUB_REF_NAME"
echo "Using GITHUB_REF_NAME (looks like tag): $TAG"
else
# Fallback: extract from GITHUB_REF for direct tag triggers
TAG="${GITHUB_REF#refs/tags/}"
if [[ -z "$TAG" || "$TAG" == "$GITHUB_REF" ]]; then
TAG="$GITHUB_REF_NAME"
echo "Using GITHUB_REF_NAME as final fallback: $TAG"
else
echo "Extracted from GITHUB_REF: $TAG"
fi
fi
# Strip v-prefix if present (normalize to clean SemVer)
TAG=${TAG#[vV]}
# Validate SemVer format (supports prereleases like 4.0.0-rc.1)
if [[ ! "$TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]]; then
echo "ERROR: Invalid tag format '$TAG'. Expected SemVer (e.g., 1.2.3, 4.0.0-rc.1)"
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"
echo "Using version: $TAG"
- name: Update package.json version
run: |
sed -i "s/\"version\": \"0.0.0\"/\"version\": \"${{ env.RELEASE_TAG }}\"/" ./apps/web/package.json
cat ./apps/web/package.json | grep version
- name: Set up Depot CLI
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
# Install the cosign tool except on PR
# https://github.com/sigstore/cosign-installer
- name: Install cosign
if: github.event_name != 'pull_request'
uses: sigstore/cosign-installer@3454372f43399081ed03b604cb2d021dabca52bb # v3.8.2
# Login against a Docker registry except on PR
# https://github.com/docker/login-action
- name: Log into registry ${{ env.REGISTRY }}
if: github.event_name != 'pull_request'
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
- name: Build and push community release image
id: build
uses: ./.github/actions/build-and-push-docker
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Extract metadata (tags, labels) for Docker
# https://github.com/docker/metadata-action
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
# Default semver tags (version, major.minor, major)
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
# Only tag as 'latest' for stable releases (not prereleases)
type=raw,value=latest,enable=${{ !inputs.IS_PRERELEASE }}
# Build and push Docker image with Buildx (don't push on PR)
# https://github.com/docker/build-push-action
- name: Build and push Docker image
id: build-and-push
uses: depot/build-push-action@636daae76684e38c301daa0c5eca1c095b24e780 # v1.14.0
with:
project: tw0fqmsx3c
token: ${{ secrets.DEPOT_PROJECT_TOKEN }}
context: .
file: ./apps/web/Dockerfile
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
secrets: |
database_url=${{ secrets.DUMMY_DATABASE_URL }}
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
sentry_auth_token=${{ secrets.SENTRY_AUTH_TOKEN }}
# Sign the resulting Docker image digest except on PRs.
# This will only write to the public Rekor transparency log when the Docker
# repository is public to avoid leaking data. If you would like to publish
# transparency data even for private images, pass --force to cosign below.
# https://github.com/sigstore/cosign
- name: Sign the published Docker image
if: ${{ github.event_name != 'pull_request' }}
registry_type: "ghcr"
ghcr_image_name: ${{ env.IMAGE_NAME }}
version: ${{ steps.extract_release_tag.outputs.VERSION }}
is_prerelease: ${{ inputs.IS_PRERELEASE }}
env:
# https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable
TAGS: ${{ steps.meta.outputs.tags }}
DIGEST: ${{ steps.build-and-push.outputs.digest }}
# This step uses the identity token to provision an ephemeral certificate
# against the sigstore community Fulcio instance.
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DEPOT_PROJECT_TOKEN: ${{ secrets.DEPOT_PROJECT_TOKEN }}
DUMMY_DATABASE_URL: ${{ secrets.DUMMY_DATABASE_URL }}
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
DUMMY_REDIS_URL: ${{ secrets.DUMMY_REDIS_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
+25 -4
View File
@@ -19,7 +19,7 @@ jobs:
contents: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
@@ -59,14 +59,35 @@ jobs:
uses: dcarbone/install-yq-action@4075b4dca348d74bd83f2bf82d30f25d7c54539b # v1.3.1
- name: Update Chart.yaml with new version
env:
VERSION: ${{ env.VERSION }}
run: |
yq -i ".version = \"$VERSION\"" helm-chart/Chart.yaml
yq -i ".appVersion = \"v$VERSION\"" helm-chart/Chart.yaml
set -euo pipefail
echo "Updating Chart.yaml with version: ${VERSION}"
yq -i ".version = \"${VERSION}\"" helm-chart/Chart.yaml
yq -i ".appVersion = \"${VERSION}\"" helm-chart/Chart.yaml
echo "✅ Successfully updated Chart.yaml"
- name: Package Helm chart
env:
VERSION: ${{ env.VERSION }}
run: |
set -euo pipefail
echo "Packaging Helm chart version: ${VERSION}"
helm package ./helm-chart
echo "✅ Successfully packaged formbricks-${VERSION}.tgz"
- name: Push Helm chart to GitHub Container Registry
env:
VERSION: ${{ env.VERSION }}
run: |
helm push "formbricks-$VERSION.tgz" oci://ghcr.io/formbricks/helm-charts
set -euo pipefail
echo "Pushing Helm chart to registry: formbricks-${VERSION}.tgz"
helm push "formbricks-${VERSION}.tgz" oci://ghcr.io/formbricks/helm-charts
echo "✅ Successfully pushed Helm chart to registry"
@@ -31,6 +31,6 @@ describe("IntegrationsTip", () => {
const linkElement = screen.getByText("environments.settings.notifications.use_the_integration");
expect(linkElement).toBeInTheDocument();
expect(linkElement).toHaveAttribute("href", `/environments/${environmentId}/integrations`);
expect(linkElement).toHaveAttribute("href", `/environments/${environmentId}/project/integrations`);
});
});
@@ -16,7 +16,7 @@ export const IntegrationsTip = ({ environmentId }: IntegrationsTipProps) => {
<p className="text-sm">
{t("environments.settings.notifications.need_slack_or_discord_notifications")}?
<a
href={`/environments/${environmentId}/integrations`}
href={`/environments/${environmentId}/project/integrations`}
className="ml-1 cursor-pointer text-sm underline">
{t("environments.settings.notifications.use_the_integration")}
</a>
@@ -75,7 +75,7 @@ export const SuccessView: React.FC<SuccessViewProps> = ({
{t("environments.surveys.summary.configure_alerts")}
</Link>
<Link
href={`/environments/${environmentId}/integrations`}
href={`/environments/${environmentId}/project/integrations`}
className="flex flex-col items-center gap-3 rounded-lg border border-slate-100 bg-white p-4 text-center text-sm text-slate-900 hover:border-slate-200 md:p-8">
<BlocksIcon className="h-8 w-8 stroke-1 text-slate-900" />
{t("environments.surveys.summary.setup_integrations")}
@@ -357,7 +357,10 @@ const buildNotionPayloadProperties = (
// notion requires specific payload for each column type
// * TYPES NOT SUPPORTED BY NOTION API - rollup, created_by, created_time, last_edited_by, or last_edited_time
const getValue = (colType: string, value: string | string[] | Date | number | Record<string, string>) => {
const getValue = (
colType: string,
value: string | string[] | Date | number | Record<string, string> | undefined
) => {
try {
switch (colType) {
case "select":
@@ -62,9 +62,10 @@ export const GET = async (req: Request) => {
};
const result = await createOrUpdateIntegration(environmentId, googleSheetIntegration);
if (result) {
return Response.redirect(`${WEBAPP_URL}/environments/${environmentId}/integrations/google-sheets`);
return Response.redirect(
`${WEBAPP_URL}/environments/${environmentId}/project/integrations/google-sheets`
);
}
return responses.internalServerErrorResponse("Failed to create or update Google Sheets integration");
@@ -90,7 +90,9 @@ export const GET = withV1ApiWrapper({
};
await createOrUpdateIntegration(environmentId, airtableIntegrationInput);
return {
response: Response.redirect(`${WEBAPP_URL}/environments/${environmentId}/integrations/airtable`),
response: Response.redirect(
`${WEBAPP_URL}/environments/${environmentId}/project/integrations/airtable`
),
};
} catch (error) {
logger.error({ error, url: req.url }, "Error in GET /api/v1/integrations/airtable/callback");
@@ -86,13 +86,15 @@ export const GET = withV1ApiWrapper({
if (result) {
return {
response: Response.redirect(`${WEBAPP_URL}/environments/${environmentId}/integrations/notion`),
response: Response.redirect(
`${WEBAPP_URL}/environments/${environmentId}/project/integrations/notion`
),
};
}
} else if (error) {
return {
response: Response.redirect(
`${WEBAPP_URL}/environments/${environmentId}/integrations/notion?error=${error}`
`${WEBAPP_URL}/environments/${environmentId}/project/integrations/notion?error=${error}`
),
};
}
@@ -93,13 +93,15 @@ export const GET = withV1ApiWrapper({
if (result) {
return {
response: Response.redirect(`${WEBAPP_URL}/environments/${environmentId}/integrations/slack`),
response: Response.redirect(
`${WEBAPP_URL}/environments/${environmentId}/project/integrations/slack`
),
};
}
} else if (error) {
return {
response: Response.redirect(
`${WEBAPP_URL}/environments/${environmentId}/integrations/slack?error=${error}`
`${WEBAPP_URL}/environments/${environmentId}/project/integrations/slack?error=${error}`
),
};
}
+1
View File
@@ -0,0 +1 @@
export { GET } from "@/modules/api/v2/health/route";
+24 -9
View File
@@ -1,9 +1,9 @@
import { AuthenticationMethod } from "@/app/middleware/endpoint-validator";
import * as Sentry from "@sentry/nextjs";
import { NextRequest } from "next/server";
import { Mock, beforeEach, describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { AuthenticationMethod } from "@/app/middleware/endpoint-validator";
import { responses } from "./response";
// Mocks
@@ -14,6 +14,10 @@ vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
vi.mock("@sentry/nextjs", () => ({
captureException: vi.fn(),
withScope: vi.fn((callback) => {
callback(mockSentryScope);
return mockSentryScope;
}),
}));
// Define these outside the mock factory so they can be referenced in tests and reset by clearAllMocks.
@@ -21,6 +25,14 @@ const mockContextualLoggerError = vi.fn();
const mockContextualLoggerWarn = vi.fn();
const mockContextualLoggerInfo = vi.fn();
// Mock Sentry scope that can be referenced in tests
const mockSentryScope = {
setTag: vi.fn(),
setExtra: vi.fn(),
setContext: vi.fn(),
setLevel: vi.fn(),
};
vi.mock("@formbricks/logger", () => {
const mockWithContextInstance = vi.fn(() => ({
error: mockContextualLoggerError,
@@ -110,6 +122,12 @@ describe("withV1ApiWrapper", () => {
}));
vi.clearAllMocks();
// Reset mock Sentry scope calls
mockSentryScope.setTag.mockClear();
mockSentryScope.setExtra.mockClear();
mockSentryScope.setContext.mockClear();
mockSentryScope.setLevel.mockClear();
});
test("logs and audits on error response with API key authentication", async () => {
@@ -161,10 +179,9 @@ describe("withV1ApiWrapper", () => {
organizationId: "org-1",
})
);
expect(Sentry.captureException).toHaveBeenCalledWith(
expect.any(Error),
expect.objectContaining({ extra: expect.objectContaining({ correlationId: "abc-123" }) })
);
expect(Sentry.withScope).toHaveBeenCalled();
expect(mockSentryScope.setExtra).toHaveBeenCalledWith("originalError", undefined);
expect(Sentry.captureException).toHaveBeenCalledWith(expect.any(Error));
});
test("does not log Sentry if not 500", async () => {
@@ -269,10 +286,8 @@ describe("withV1ApiWrapper", () => {
organizationId: "org-1",
})
);
expect(Sentry.captureException).toHaveBeenCalledWith(
expect.any(Error),
expect.objectContaining({ extra: expect.objectContaining({ correlationId: "err-1" }) })
);
expect(Sentry.withScope).toHaveBeenCalled();
expect(Sentry.captureException).toHaveBeenCalledWith(expect.any(Error));
});
test("does not log on success response but still audits", async () => {
+20 -7
View File
@@ -1,3 +1,8 @@
import * as Sentry from "@sentry/nextjs";
import { Session, getServerSession } from "next-auth";
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { authenticateRequest } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import {
@@ -14,11 +19,6 @@ import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { TRateLimitConfig } from "@/modules/core/rate-limit/types/rate-limit";
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditAction, TAuditTarget, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import * as Sentry from "@sentry/nextjs";
import { Session, getServerSession } from "next-auth";
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
export type TApiAuditLog = Parameters<typeof queueAuditEvent>[0];
export type TApiV1Authentication = TAuthenticationApiKey | Session | null;
@@ -173,8 +173,21 @@ const logErrorDetails = (res: Response, req: NextRequest, correlationId: string,
logger.withContext(logContext).error("V1 API Error Details");
if (SENTRY_DSN && IS_PRODUCTION && res.status >= 500) {
const err = new Error(`API V1 error, id: ${correlationId}`);
Sentry.captureException(err, { extra: { error, correlationId } });
// Set correlation ID as a tag for easy filtering
Sentry.withScope((scope) => {
scope.setTag("correlationId", correlationId);
scope.setLevel("error");
// If we have an actual error, capture it with full stacktrace
// Otherwise, create a generic error with context
if (error instanceof Error) {
Sentry.captureException(error);
} else {
scope.setExtra("originalError", error);
const genericError = new Error(`API V1 error, id: ${correlationId}`);
Sentry.captureException(genericError);
}
});
}
};
-1
View File
@@ -1,6 +1,5 @@
import { describe, expect, test } from "vitest";
import { TShuffleOption, TSurveyLogic, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TTemplateRole } from "@formbricks/types/templates";
import {
buildCTAQuestion,
buildConsentQuestion,
+3 -1
View File
@@ -19,7 +19,7 @@ import {
TSurveyRatingQuestion,
TSurveyWelcomeCard,
} from "@formbricks/types/surveys/types";
import { TTemplate } from "@formbricks/types/templates";
import { TTemplate, TTemplateRole } from "@formbricks/types/templates";
const getDefaultButtonLabel = (label: string | undefined, t: TFnType) =>
createI18nString(label || t("common.next"), []);
@@ -391,6 +391,7 @@ export const buildSurvey = (
name: string;
industries: ("eCommerce" | "saas" | "other")[];
channels: ("link" | "app" | "website")[];
role: TTemplateRole;
description: string;
questions: TSurveyQuestion[];
endings?: TSurveyEnding[];
@@ -403,6 +404,7 @@ export const buildSurvey = (
name: config.name,
industries: config.industries,
channels: config.channels,
role: config.role,
description: config.description,
preset: {
...localSurvey,
+49
View File
@@ -24,6 +24,7 @@ const cartAbandonmentSurvey = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.card_abandonment_survey"),
role: "productManager",
industries: ["eCommerce"],
channels: ["app", "website", "link"],
description: t("templates.card_abandonment_survey_description"),
@@ -124,6 +125,7 @@ const siteAbandonmentSurvey = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.site_abandonment_survey"),
role: "productManager",
industries: ["eCommerce"],
channels: ["app", "website"],
description: t("templates.site_abandonment_survey_description"),
@@ -221,6 +223,7 @@ const productMarketFitSuperhuman = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.product_market_fit_superhuman"),
role: "productManager",
industries: ["saas"],
channels: ["app", "link"],
description: t("templates.product_market_fit_superhuman_description"),
@@ -295,6 +298,7 @@ const onboardingSegmentation = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.onboarding_segmentation"),
role: "productManager",
industries: ["saas"],
channels: ["app", "link"],
description: t("templates.onboarding_segmentation_description"),
@@ -358,6 +362,7 @@ const churnSurvey = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.churn_survey"),
role: "sales",
industries: ["saas", "eCommerce", "other"],
channels: ["app", "link"],
description: t("templates.churn_survey_description"),
@@ -447,6 +452,7 @@ const earnedAdvocacyScore = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.earned_advocacy_score_name"),
role: "customerSuccess",
industries: ["saas", "eCommerce", "other"],
channels: ["app", "link"],
description: t("templates.earned_advocacy_score_description"),
@@ -519,6 +525,7 @@ const usabilityScoreRatingSurvey = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.usability_score_name"),
role: "customerSuccess",
industries: ["saas"],
channels: ["app", "link"],
description: t("templates.usability_rating_description"),
@@ -644,6 +651,7 @@ const improveTrialConversion = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.improve_trial_conversion_name"),
role: "sales",
industries: ["saas"],
channels: ["link", "app"],
description: t("templates.improve_trial_conversion_description"),
@@ -745,6 +753,7 @@ const reviewPrompt = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.review_prompt_name"),
role: "marketing",
industries: ["saas", "eCommerce", "other"],
channels: ["link", "app"],
description: t("templates.review_prompt_description"),
@@ -823,6 +832,7 @@ const interviewPrompt = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.interview_prompt_name"),
role: "productManager",
industries: ["saas"],
channels: ["app"],
description: t("templates.interview_prompt_description"),
@@ -850,6 +860,7 @@ const improveActivationRate = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.improve_activation_rate_name"),
role: "productManager",
industries: ["saas"],
channels: ["link"],
description: t("templates.improve_activation_rate_description"),
@@ -940,6 +951,7 @@ const employeeSatisfaction = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.employee_satisfaction_name"),
role: "peopleManager",
industries: ["saas", "eCommerce", "other"],
channels: ["app", "link"],
description: t("templates.employee_satisfaction_description"),
@@ -1017,6 +1029,7 @@ const uncoverStrengthsAndWeaknesses = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.uncover_strengths_and_weaknesses_name"),
role: "productManager",
industries: ["saas", "other"],
channels: ["app", "link"],
description: t("templates.uncover_strengths_and_weaknesses_description"),
@@ -1070,6 +1083,7 @@ const productMarketFitShort = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.product_market_fit_short_name"),
role: "productManager",
industries: ["saas"],
channels: ["app", "link"],
description: t("templates.product_market_fit_short_description"),
@@ -1106,6 +1120,7 @@ const marketAttribution = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.market_attribution_name"),
role: "marketing",
industries: ["saas", "eCommerce"],
channels: ["website", "app", "link"],
description: t("templates.market_attribution_description"),
@@ -1136,6 +1151,7 @@ const changingSubscriptionExperience = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.changing_subscription_experience_name"),
role: "productManager",
industries: ["saas"],
channels: ["app"],
description: t("templates.changing_subscription_experience_description"),
@@ -1178,6 +1194,7 @@ const identifyCustomerGoals = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.identify_customer_goals_name"),
role: "productManager",
industries: ["saas", "other"],
channels: ["app", "website"],
description: t("templates.identify_customer_goals_description"),
@@ -1207,6 +1224,7 @@ const featureChaser = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.feature_chaser_name"),
role: "productManager",
industries: ["saas"],
channels: ["app"],
description: t("templates.feature_chaser_description"),
@@ -1245,6 +1263,7 @@ const fakeDoorFollowUp = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.fake_door_follow_up_name"),
role: "productManager",
industries: ["saas", "eCommerce"],
channels: ["app", "website"],
description: t("templates.fake_door_follow_up_description"),
@@ -1288,6 +1307,7 @@ const feedbackBox = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.feedback_box_name"),
role: "productManager",
industries: ["saas"],
channels: ["app"],
description: t("templates.feedback_box_description"),
@@ -1357,6 +1377,7 @@ const integrationSetupSurvey = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.integration_setup_survey_name"),
role: "productManager",
industries: ["saas"],
channels: ["app"],
description: t("templates.integration_setup_survey_description"),
@@ -1429,6 +1450,7 @@ const newIntegrationSurvey = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.new_integration_survey_name"),
role: "productManager",
industries: ["saas"],
channels: ["app"],
description: t("templates.new_integration_survey_description"),
@@ -1460,6 +1482,7 @@ const docsFeedback = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.docs_feedback_name"),
role: "productManager",
industries: ["saas"],
channels: ["app", "website", "link"],
description: t("templates.docs_feedback_description"),
@@ -1499,6 +1522,7 @@ const nps = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.nps_name"),
role: "customerSuccess",
industries: ["saas", "eCommerce", "other"],
channels: ["app", "link", "website"],
description: t("templates.nps_description"),
@@ -1539,6 +1563,7 @@ const customerSatisfactionScore = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.csat_name"),
role: "customerSuccess",
industries: ["saas", "eCommerce", "other"],
channels: ["app", "link", "website"],
description: t("templates.csat_description"),
@@ -1707,6 +1732,7 @@ const collectFeedback = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.collect_feedback_name"),
role: "productManager",
industries: ["other", "eCommerce"],
channels: ["website", "link"],
description: t("templates.collect_feedback_description"),
@@ -1853,6 +1879,7 @@ const identifyUpsellOpportunities = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.identify_upsell_opportunities_name"),
role: "sales",
industries: ["saas"],
channels: ["app", "link"],
description: t("templates.identify_upsell_opportunities_description"),
@@ -1882,6 +1909,7 @@ const prioritizeFeatures = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.prioritize_features_name"),
role: "productManager",
industries: ["saas"],
channels: ["app"],
description: t("templates.prioritize_features_description"),
@@ -1934,6 +1962,7 @@ const gaugeFeatureSatisfaction = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.gauge_feature_satisfaction_name"),
role: "productManager",
industries: ["saas"],
channels: ["app"],
description: t("templates.gauge_feature_satisfaction_description"),
@@ -1967,6 +1996,7 @@ const marketSiteClarity = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.market_site_clarity_name"),
role: "marketing",
industries: ["saas", "eCommerce", "other"],
channels: ["website"],
description: t("templates.market_site_clarity_description"),
@@ -2008,6 +2038,7 @@ const customerEffortScore = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.customer_effort_score_name"),
role: "productManager",
industries: ["saas"],
channels: ["app"],
description: t("templates.customer_effort_score_description"),
@@ -2039,6 +2070,7 @@ const careerDevelopmentSurvey = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.career_development_survey_name"),
role: "productManager",
industries: ["saas", "eCommerce", "other"],
channels: ["link"],
description: t("templates.career_development_survey_description"),
@@ -2125,6 +2157,7 @@ const professionalDevelopmentSurvey = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.professional_development_survey_name"),
role: "productManager",
industries: ["saas", "eCommerce", "other"],
channels: ["link"],
description: t("templates.professional_development_survey_description"),
@@ -2212,6 +2245,7 @@ const rateCheckoutExperience = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.rate_checkout_experience_name"),
role: "productManager",
industries: ["eCommerce"],
channels: ["website", "app"],
description: t("templates.rate_checkout_experience_description"),
@@ -2288,6 +2322,7 @@ const measureSearchExperience = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.measure_search_experience_name"),
role: "productManager",
industries: ["saas", "eCommerce"],
channels: ["app", "website"],
description: t("templates.measure_search_experience_description"),
@@ -2364,6 +2399,7 @@ const evaluateContentQuality = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.evaluate_content_quality_name"),
role: "marketing",
industries: ["other"],
channels: ["website"],
description: t("templates.evaluate_content_quality_description"),
@@ -2441,6 +2477,7 @@ const measureTaskAccomplishment = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.measure_task_accomplishment_name"),
role: "productManager",
industries: ["saas"],
channels: ["app", "website"],
description: t("templates.measure_task_accomplishment_description"),
@@ -2623,6 +2660,7 @@ const identifySignUpBarriers = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.identify_sign_up_barriers_name"),
role: "marketing",
industries: ["saas", "eCommerce", "other"],
channels: ["website"],
description: t("templates.identify_sign_up_barriers_description"),
@@ -2774,6 +2812,7 @@ const buildProductRoadmap = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.build_product_roadmap_name"),
role: "productManager",
industries: ["saas"],
channels: ["app", "link"],
description: t("templates.build_product_roadmap_description"),
@@ -2808,6 +2847,7 @@ const understandPurchaseIntention = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.understand_purchase_intention_name"),
role: "sales",
industries: ["eCommerce"],
channels: ["website", "link", "app"],
description: t("templates.understand_purchase_intention_description"),
@@ -2863,6 +2903,7 @@ const improveNewsletterContent = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.improve_newsletter_content_name"),
role: "marketing",
industries: ["eCommerce", "saas", "other"],
channels: ["link"],
description: t("templates.improve_newsletter_content_description"),
@@ -2953,6 +2994,7 @@ const evaluateAProductIdea = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.evaluate_a_product_idea_name"),
role: "productManager",
industries: ["saas", "other"],
channels: ["link", "app"],
description: t("templates.evaluate_a_product_idea_description"),
@@ -3055,6 +3097,7 @@ const understandLowEngagement = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.understand_low_engagement_name"),
role: "productManager",
industries: ["saas"],
channels: ["link"],
description: t("templates.understand_low_engagement_description"),
@@ -3140,6 +3183,7 @@ const employeeWellBeing = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.employee_well_being_name"),
role: "peopleManager",
industries: ["saas", "eCommerce", "other"],
channels: ["link"],
description: t("templates.employee_well_being_description"),
@@ -3189,6 +3233,7 @@ const longTermRetentionCheckIn = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.long_term_retention_check_in_name"),
role: "peopleManager",
industries: ["saas", "other"],
channels: ["app", "link"],
description: t("templates.long_term_retention_check_in_description"),
@@ -3297,6 +3342,7 @@ const professionalDevelopmentGrowth = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.professional_development_growth_survey_name"),
role: "peopleManager",
industries: ["saas", "eCommerce", "other"],
channels: ["link"],
description: t("templates.professional_development_growth_survey_description"),
@@ -3346,6 +3392,7 @@ const recognitionAndReward = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.recognition_and_reward_survey_name"),
role: "peopleManager",
industries: ["saas", "eCommerce", "other"],
channels: ["link"],
description: t("templates.recognition_and_reward_survey_description"),
@@ -3394,6 +3441,7 @@ const alignmentAndEngagement = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.alignment_and_engagement_survey_name"),
role: "peopleManager",
industries: ["saas", "eCommerce", "other"],
channels: ["link"],
description: t("templates.alignment_and_engagement_survey_description"),
@@ -3442,6 +3490,7 @@ const supportiveWorkCulture = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.supportive_work_culture_survey_name"),
role: "peopleManager",
industries: ["saas", "eCommerce", "other"],
channels: ["link"],
description: t("templates.supportive_work_culture_survey_description"),
+1 -1
View File
@@ -114,7 +114,7 @@ export const MAX_FILE_UPLOAD_SIZES = {
standard: 1024 * 1024 * 10, // 10MB
big: 1024 * 1024 * 1024, // 1GB
} as const;
export const IS_STORAGE_CONFIGURED = Boolean(S3_ACCESS_KEY && S3_SECRET_KEY && S3_REGION && S3_BUCKET_NAME);
export const IS_STORAGE_CONFIGURED = Boolean(S3_BUCKET_NAME);
// Colors for Survey Bg
export const SURVEY_BG_COLORS = [
+865 -28
View File
@@ -1,6 +1,7 @@
import { env } from "@/lib/env";
import jwt from "jsonwebtoken";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import * as crypto from "@/lib/crypto";
import {
createEmailChangeToken,
createEmailToken,
@@ -14,12 +15,69 @@ import {
verifyTokenForLinkSurvey,
} from "./jwt";
const TEST_ENCRYPTION_KEY = "0".repeat(32); // 32-byte key for AES-256-GCM
const TEST_NEXTAUTH_SECRET = "test-nextauth-secret";
const DIFFERENT_SECRET = "different-secret";
// Error message constants
const NEXTAUTH_SECRET_ERROR = "NEXTAUTH_SECRET is not set";
const ENCRYPTION_KEY_ERROR = "ENCRYPTION_KEY is not set";
// Helper function to test error cases for missing secrets/keys
const testMissingSecretsError = async (
testFn: (...args: any[]) => any,
args: any[],
options: {
testNextAuthSecret?: boolean;
testEncryptionKey?: boolean;
isAsync?: boolean;
} = {}
) => {
const { testNextAuthSecret = true, testEncryptionKey = true, isAsync = false } = options;
if (testNextAuthSecret) {
const constants = await import("@/lib/constants");
const originalSecret = (constants as any).NEXTAUTH_SECRET;
(constants as any).NEXTAUTH_SECRET = undefined;
if (isAsync) {
await expect(testFn(...args)).rejects.toThrow(NEXTAUTH_SECRET_ERROR);
} else {
expect(() => testFn(...args)).toThrow(NEXTAUTH_SECRET_ERROR);
}
// Restore
(constants as any).NEXTAUTH_SECRET = originalSecret;
}
if (testEncryptionKey) {
const constants = await import("@/lib/constants");
const originalKey = (constants as any).ENCRYPTION_KEY;
(constants as any).ENCRYPTION_KEY = undefined;
if (isAsync) {
await expect(testFn(...args)).rejects.toThrow(ENCRYPTION_KEY_ERROR);
} else {
expect(() => testFn(...args)).toThrow(ENCRYPTION_KEY_ERROR);
}
// Restore
(constants as any).ENCRYPTION_KEY = originalKey;
}
};
// Mock environment variables
vi.mock("@/lib/env", () => ({
env: {
ENCRYPTION_KEY: "0".repeat(32), // 32-byte key for AES-256-GCM
ENCRYPTION_KEY: "0".repeat(32),
NEXTAUTH_SECRET: "test-nextauth-secret",
} as typeof env,
},
}));
// Mock constants
vi.mock("@/lib/constants", () => ({
NEXTAUTH_SECRET: "test-nextauth-secret",
ENCRYPTION_KEY: "0".repeat(32),
}));
// Mock prisma
@@ -31,22 +89,65 @@ vi.mock("@formbricks/database", () => ({
},
}));
describe("JWT Functions", () => {
// Mock logger
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
},
}));
describe("JWT Functions - Comprehensive Security Tests", () => {
const mockUser = {
id: "test-user-id",
email: "test@example.com",
};
let mockSymmetricEncrypt: any;
let mockSymmetricDecrypt: any;
beforeEach(() => {
vi.clearAllMocks();
// Setup default crypto mocks
mockSymmetricEncrypt = vi
.spyOn(crypto, "symmetricEncrypt")
.mockImplementation((text: string) => `encrypted_${text}`);
mockSymmetricDecrypt = vi
.spyOn(crypto, "symmetricDecrypt")
.mockImplementation((encryptedText: string) => encryptedText.replace("encrypted_", ""));
(prisma.user.findUnique as any).mockResolvedValue(mockUser);
});
describe("createToken", () => {
test("should create a valid token", () => {
const token = createToken(mockUser.id, mockUser.email);
test("should create a valid token with encrypted user ID", () => {
const token = createToken(mockUser.id);
expect(token).toBeDefined();
expect(typeof token).toBe("string");
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(mockUser.id, TEST_ENCRYPTION_KEY);
});
test("should accept custom options", () => {
const customOptions = { expiresIn: "1h" };
const token = createToken(mockUser.id, customOptions);
expect(token).toBeDefined();
// Verify the token contains the expected expiration
const decoded = jwt.decode(token) as any;
expect(decoded.exp).toBeDefined();
expect(decoded.iat).toBeDefined();
// Should expire in approximately 1 hour (3600 seconds)
expect(decoded.exp - decoded.iat).toBe(3600);
});
test("should throw error if NEXTAUTH_SECRET is not set", async () => {
await testMissingSecretsError(createToken, [mockUser.id], {
testNextAuthSecret: true,
testEncryptionKey: false,
});
});
});
@@ -56,6 +157,18 @@ describe("JWT Functions", () => {
const token = createTokenForLinkSurvey(surveyId, mockUser.email);
expect(token).toBeDefined();
expect(typeof token).toBe("string");
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(mockUser.email, TEST_ENCRYPTION_KEY);
});
test("should include surveyId in payload", () => {
const surveyId = "test-survey-id";
const token = createTokenForLinkSurvey(surveyId, mockUser.email);
const decoded = jwt.decode(token) as any;
expect(decoded.surveyId).toBe(surveyId);
});
test("should throw error if NEXTAUTH_SECRET or ENCRYPTION_KEY is not set", async () => {
await testMissingSecretsError(createTokenForLinkSurvey, ["survey-id", mockUser.email]);
});
});
@@ -64,24 +177,30 @@ describe("JWT Functions", () => {
const token = createEmailToken(mockUser.email);
expect(token).toBeDefined();
expect(typeof token).toBe("string");
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(mockUser.email, TEST_ENCRYPTION_KEY);
});
test("should throw error if NEXTAUTH_SECRET is not set", () => {
const originalSecret = env.NEXTAUTH_SECRET;
try {
(env as any).NEXTAUTH_SECRET = undefined;
expect(() => createEmailToken(mockUser.email)).toThrow("NEXTAUTH_SECRET is not set");
} finally {
(env as any).NEXTAUTH_SECRET = originalSecret;
}
test("should throw error if NEXTAUTH_SECRET or ENCRYPTION_KEY is not set", async () => {
await testMissingSecretsError(createEmailToken, [mockUser.email]);
});
});
describe("getEmailFromEmailToken", () => {
test("should extract email from valid token", () => {
const token = createEmailToken(mockUser.email);
const extractedEmail = getEmailFromEmailToken(token);
expect(extractedEmail).toBe(mockUser.email);
describe("createEmailChangeToken", () => {
test("should create a valid email change token with 1 day expiration", () => {
const token = createEmailChangeToken(mockUser.id, mockUser.email);
expect(token).toBeDefined();
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(mockUser.id, TEST_ENCRYPTION_KEY);
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(mockUser.email, TEST_ENCRYPTION_KEY);
const decoded = jwt.decode(token) as any;
expect(decoded.exp).toBeDefined();
expect(decoded.iat).toBeDefined();
// Should expire in approximately 1 day (86400 seconds)
expect(decoded.exp - decoded.iat).toBe(86400);
});
test("should throw error if NEXTAUTH_SECRET or ENCRYPTION_KEY is not set", async () => {
await testMissingSecretsError(createEmailChangeToken, [mockUser.id, mockUser.email]);
});
});
@@ -91,6 +210,50 @@ describe("JWT Functions", () => {
const token = createInviteToken(inviteId, mockUser.email);
expect(token).toBeDefined();
expect(typeof token).toBe("string");
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(inviteId, TEST_ENCRYPTION_KEY);
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(mockUser.email, TEST_ENCRYPTION_KEY);
});
test("should accept custom options", () => {
const inviteId = "test-invite-id";
const customOptions = { expiresIn: "24h" };
const token = createInviteToken(inviteId, mockUser.email, customOptions);
expect(token).toBeDefined();
const decoded = jwt.decode(token) as any;
expect(decoded.exp).toBeDefined();
expect(decoded.iat).toBeDefined();
// Should expire in approximately 24 hours (86400 seconds)
expect(decoded.exp - decoded.iat).toBe(86400);
});
test("should throw error if NEXTAUTH_SECRET or ENCRYPTION_KEY is not set", async () => {
await testMissingSecretsError(createInviteToken, ["invite-id", mockUser.email]);
});
});
describe("getEmailFromEmailToken", () => {
test("should extract email from valid token", () => {
const token = createEmailToken(mockUser.email);
const extractedEmail = getEmailFromEmailToken(token);
expect(extractedEmail).toBe(mockUser.email);
expect(mockSymmetricDecrypt).toHaveBeenCalledWith(`encrypted_${mockUser.email}`, TEST_ENCRYPTION_KEY);
});
test("should fall back to original email if decryption fails", () => {
mockSymmetricDecrypt.mockImplementationOnce(() => {
throw new Error("Decryption failed");
});
// Create token manually with unencrypted email for legacy compatibility
const legacyToken = jwt.sign({ email: mockUser.email }, TEST_NEXTAUTH_SECRET);
const extractedEmail = getEmailFromEmailToken(legacyToken);
expect(extractedEmail).toBe(mockUser.email);
});
test("should throw error if NEXTAUTH_SECRET or ENCRYPTION_KEY is not set", async () => {
const token = jwt.sign({ email: "test@example.com" }, TEST_NEXTAUTH_SECRET);
await testMissingSecretsError(getEmailFromEmailToken, [token]);
});
});
@@ -106,23 +269,194 @@ describe("JWT Functions", () => {
const result = verifyTokenForLinkSurvey("invalid-token", "test-survey-id");
expect(result).toBeNull();
});
test("should return null if NEXTAUTH_SECRET is not set", async () => {
const constants = await import("@/lib/constants");
const originalSecret = (constants as any).NEXTAUTH_SECRET;
(constants as any).NEXTAUTH_SECRET = undefined;
const result = verifyTokenForLinkSurvey("any-token", "test-survey-id");
expect(result).toBeNull();
// Restore
(constants as any).NEXTAUTH_SECRET = originalSecret;
});
test("should return null if surveyId doesn't match", () => {
const surveyId = "test-survey-id";
const differentSurveyId = "different-survey-id";
const token = createTokenForLinkSurvey(surveyId, mockUser.email);
const result = verifyTokenForLinkSurvey(token, differentSurveyId);
expect(result).toBeNull();
});
test("should return null if email is missing from payload", () => {
const tokenWithoutEmail = jwt.sign({ surveyId: "test-survey-id" }, TEST_NEXTAUTH_SECRET);
const result = verifyTokenForLinkSurvey(tokenWithoutEmail, "test-survey-id");
expect(result).toBeNull();
});
test("should fall back to original email if decryption fails", () => {
mockSymmetricDecrypt.mockImplementationOnce(() => {
throw new Error("Decryption failed");
});
// Create legacy token with unencrypted email
const legacyToken = jwt.sign(
{
email: mockUser.email,
surveyId: "test-survey-id",
},
TEST_NEXTAUTH_SECRET
);
const result = verifyTokenForLinkSurvey(legacyToken, "test-survey-id");
expect(result).toBe(mockUser.email);
});
test("should fall back to original email if ENCRYPTION_KEY is not set", async () => {
const constants = await import("@/lib/constants");
const originalKey = (constants as any).ENCRYPTION_KEY;
(constants as any).ENCRYPTION_KEY = undefined;
// Create a token with unencrypted email (as it would be if ENCRYPTION_KEY was not set during creation)
const token = jwt.sign(
{
email: mockUser.email,
surveyId: "survey-id",
},
TEST_NEXTAUTH_SECRET
);
const result = verifyTokenForLinkSurvey(token, "survey-id");
expect(result).toBe(mockUser.email);
// Restore
(constants as any).ENCRYPTION_KEY = originalKey;
});
test("should verify legacy survey tokens with surveyId-based secret", async () => {
const surveyId = "test-survey-id";
// Create legacy token with old format (NEXTAUTH_SECRET + surveyId)
const legacyToken = jwt.sign({ email: `encrypted_${mockUser.email}` }, TEST_NEXTAUTH_SECRET + surveyId);
const result = verifyTokenForLinkSurvey(legacyToken, surveyId);
expect(result).toBe(mockUser.email);
});
test("should reject survey tokens that fail both new and legacy verification", async () => {
const surveyId = "test-survey-id";
const invalidToken = jwt.sign({ email: "encrypted_test@example.com" }, "wrong-secret");
const result = verifyTokenForLinkSurvey(invalidToken, surveyId);
expect(result).toBeNull();
// Verify error logging
const { logger } = await import("@formbricks/logger");
expect(logger.error).toHaveBeenCalledWith(expect.any(Error), "Survey link token verification failed");
});
test("should reject legacy survey tokens for wrong survey", () => {
const correctSurveyId = "correct-survey-id";
const wrongSurveyId = "wrong-survey-id";
// Create legacy token for one survey
const legacyToken = jwt.sign(
{ email: `encrypted_${mockUser.email}` },
TEST_NEXTAUTH_SECRET + correctSurveyId
);
// Try to verify with different survey ID
const result = verifyTokenForLinkSurvey(legacyToken, wrongSurveyId);
expect(result).toBeNull();
});
});
describe("verifyToken", () => {
test("should verify valid token", async () => {
const token = createToken(mockUser.id, mockUser.email);
const token = createToken(mockUser.id);
const verified = await verifyToken(token);
expect(verified).toEqual({
id: mockUser.id,
id: mockUser.id, // Returns the decrypted user ID
email: mockUser.email,
});
});
test("should throw error if user not found", async () => {
(prisma.user.findUnique as any).mockResolvedValue(null);
const token = createToken(mockUser.id, mockUser.email);
const token = createToken(mockUser.id);
await expect(verifyToken(token)).rejects.toThrow("User not found");
});
test("should throw error if NEXTAUTH_SECRET is not set", async () => {
await testMissingSecretsError(verifyToken, ["any-token"], {
testNextAuthSecret: true,
testEncryptionKey: false,
isAsync: true,
});
});
test("should throw error for invalid token signature", async () => {
const invalidToken = jwt.sign({ id: "test-id" }, DIFFERENT_SECRET);
await expect(verifyToken(invalidToken)).rejects.toThrow("Invalid token");
});
test("should throw error if token payload is missing id", async () => {
const tokenWithoutId = jwt.sign({ email: mockUser.email }, TEST_NEXTAUTH_SECRET);
await expect(verifyToken(tokenWithoutId)).rejects.toThrow("Invalid token");
});
test("should return raw id from payload", async () => {
// Create token with unencrypted id
const token = jwt.sign({ id: mockUser.id }, TEST_NEXTAUTH_SECRET);
const verified = await verifyToken(token);
expect(verified).toEqual({
id: mockUser.id, // Returns the raw ID from payload
email: mockUser.email,
});
});
test("should verify legacy tokens with email-based secret", async () => {
// Create legacy token with old format (NEXTAUTH_SECRET + userEmail)
const legacyToken = jwt.sign({ id: `encrypted_${mockUser.id}` }, TEST_NEXTAUTH_SECRET + mockUser.email);
const verified = await verifyToken(legacyToken);
expect(verified).toEqual({
id: mockUser.id, // Returns the decrypted user ID
email: mockUser.email,
});
});
test("should prioritize new tokens over legacy tokens", async () => {
// Create both new and legacy tokens for the same user
const newToken = createToken(mockUser.id);
const legacyToken = jwt.sign({ id: `encrypted_${mockUser.id}` }, TEST_NEXTAUTH_SECRET + mockUser.email);
// New token should verify without triggering legacy path
const verifiedNew = await verifyToken(newToken);
expect(verifiedNew.id).toBe(mockUser.id); // Returns decrypted user ID
// Legacy token should trigger legacy path
const verifiedLegacy = await verifyToken(legacyToken);
expect(verifiedLegacy.id).toBe(mockUser.id); // Returns decrypted user ID
});
test("should reject tokens that fail both new and legacy verification", async () => {
const invalidToken = jwt.sign({ id: "encrypted_test-id" }, "wrong-secret");
await expect(verifyToken(invalidToken)).rejects.toThrow("Invalid token");
// Verify both methods were attempted
const { logger } = await import("@formbricks/logger");
expect(logger.error).toHaveBeenCalledWith(
expect.any(Error),
"Token verification failed with new method"
);
expect(logger.error).toHaveBeenCalledWith(
expect.any(Error),
"Token verification failed with legacy method"
);
});
});
describe("verifyInviteToken", () => {
@@ -139,6 +473,53 @@ describe("JWT Functions", () => {
test("should throw error for invalid token", () => {
expect(() => verifyInviteToken("invalid-token")).toThrow("Invalid or expired invite token");
});
test("should throw error if NEXTAUTH_SECRET or ENCRYPTION_KEY is not set", async () => {
await testMissingSecretsError(verifyInviteToken, ["any-token"]);
});
test("should throw error if inviteId is missing", () => {
const tokenWithoutInviteId = jwt.sign({ email: mockUser.email }, TEST_NEXTAUTH_SECRET);
expect(() => verifyInviteToken(tokenWithoutInviteId)).toThrow("Invalid or expired invite token");
});
test("should throw error if email is missing", () => {
const tokenWithoutEmail = jwt.sign({ inviteId: "test-invite-id" }, TEST_NEXTAUTH_SECRET);
expect(() => verifyInviteToken(tokenWithoutEmail)).toThrow("Invalid or expired invite token");
});
test("should fall back to original values if decryption fails", () => {
mockSymmetricDecrypt.mockImplementation(() => {
throw new Error("Decryption failed");
});
const inviteId = "test-invite-id";
const legacyToken = jwt.sign(
{
inviteId,
email: mockUser.email,
},
TEST_NEXTAUTH_SECRET
);
const verified = verifyInviteToken(legacyToken);
expect(verified).toEqual({
inviteId,
email: mockUser.email,
});
});
test("should throw error for token with wrong signature", () => {
const invalidToken = jwt.sign(
{
inviteId: "test-invite-id",
email: mockUser.email,
},
DIFFERENT_SECRET
);
expect(() => verifyInviteToken(invalidToken)).toThrow("Invalid or expired invite token");
});
});
describe("verifyEmailChangeToken", () => {
@@ -150,22 +531,478 @@ describe("JWT Functions", () => {
expect(result).toEqual({ id: userId, email });
});
test("should throw error if NEXTAUTH_SECRET or ENCRYPTION_KEY is not set", async () => {
await testMissingSecretsError(verifyEmailChangeToken, ["any-token"], { isAsync: true });
});
test("should throw error if token is invalid or missing fields", async () => {
// Create a token with missing fields
const jwt = await import("jsonwebtoken");
const token = jwt.sign({ foo: "bar" }, env.NEXTAUTH_SECRET as string);
const token = jwt.sign({ foo: "bar" }, TEST_NEXTAUTH_SECRET);
await expect(verifyEmailChangeToken(token)).rejects.toThrow(
"Token is invalid or missing required fields"
);
});
test("should throw error if id is missing", async () => {
const token = jwt.sign({ email: "test@example.com" }, TEST_NEXTAUTH_SECRET);
await expect(verifyEmailChangeToken(token)).rejects.toThrow(
"Token is invalid or missing required fields"
);
});
test("should throw error if email is missing", async () => {
const token = jwt.sign({ id: "test-id" }, TEST_NEXTAUTH_SECRET);
await expect(verifyEmailChangeToken(token)).rejects.toThrow(
"Token is invalid or missing required fields"
);
});
test("should return original id/email if decryption fails", async () => {
// Create a token with non-encrypted id/email
const jwt = await import("jsonwebtoken");
mockSymmetricDecrypt.mockImplementation(() => {
throw new Error("Decryption failed");
});
const payload = { id: "plain-id", email: "plain@example.com" };
const token = jwt.sign(payload, env.NEXTAUTH_SECRET as string);
const token = jwt.sign(payload, TEST_NEXTAUTH_SECRET);
const result = await verifyEmailChangeToken(token);
expect(result).toEqual(payload);
});
test("should throw error for token with wrong signature", async () => {
const invalidToken = jwt.sign(
{
id: "test-id",
email: "test@example.com",
},
DIFFERENT_SECRET
);
await expect(verifyEmailChangeToken(invalidToken)).rejects.toThrow();
});
});
// SECURITY SCENARIO TESTS
describe("Security Scenarios", () => {
describe("Algorithm Confusion Attack Prevention", () => {
test("should reject 'none' algorithm tokens in verifyToken", async () => {
// Create malicious token with "none" algorithm
const maliciousToken =
Buffer.from(
JSON.stringify({
alg: "none",
typ: "JWT",
})
).toString("base64url") +
"." +
Buffer.from(
JSON.stringify({
id: "encrypted_malicious-id",
})
).toString("base64url") +
".";
await expect(verifyToken(maliciousToken)).rejects.toThrow("Invalid token");
});
test("should reject 'none' algorithm tokens in verifyTokenForLinkSurvey", () => {
const maliciousToken =
Buffer.from(
JSON.stringify({
alg: "none",
typ: "JWT",
})
).toString("base64url") +
"." +
Buffer.from(
JSON.stringify({
email: "encrypted_attacker@evil.com",
surveyId: "test-survey-id",
})
).toString("base64url") +
".";
const result = verifyTokenForLinkSurvey(maliciousToken, "test-survey-id");
expect(result).toBeNull();
});
test("should reject 'none' algorithm tokens in verifyInviteToken", () => {
const maliciousToken =
Buffer.from(
JSON.stringify({
alg: "none",
typ: "JWT",
})
).toString("base64url") +
"." +
Buffer.from(
JSON.stringify({
inviteId: "encrypted_malicious-invite",
email: "encrypted_attacker@evil.com",
})
).toString("base64url") +
".";
expect(() => verifyInviteToken(maliciousToken)).toThrow("Invalid or expired invite token");
});
test("should reject 'none' algorithm tokens in verifyEmailChangeToken", async () => {
const maliciousToken =
Buffer.from(
JSON.stringify({
alg: "none",
typ: "JWT",
})
).toString("base64url") +
"." +
Buffer.from(
JSON.stringify({
id: "encrypted_malicious-id",
email: "encrypted_attacker@evil.com",
})
).toString("base64url") +
".";
await expect(verifyEmailChangeToken(maliciousToken)).rejects.toThrow();
});
test("should reject RS256 algorithm tokens (HS256/RS256 confusion)", async () => {
// Create malicious token with RS256 algorithm header but HS256 signature
const maliciousHeader = Buffer.from(
JSON.stringify({
alg: "RS256",
typ: "JWT",
})
).toString("base64url");
const maliciousPayload = Buffer.from(
JSON.stringify({
id: "encrypted_malicious-id",
})
).toString("base64url");
// Create signature using HMAC (as if it were HS256)
const crypto = require("crypto");
const signature = crypto
.createHmac("sha256", TEST_NEXTAUTH_SECRET)
.update(`${maliciousHeader}.${maliciousPayload}`)
.digest("base64url");
const maliciousToken = `${maliciousHeader}.${maliciousPayload}.${signature}`;
await expect(verifyToken(maliciousToken)).rejects.toThrow("Invalid token");
});
test("should only accept HS256 algorithm", async () => {
// Test that other valid algorithms are rejected
const otherAlgorithms = ["HS384", "HS512", "RS256", "RS384", "RS512", "ES256", "ES384", "ES512"];
for (const alg of otherAlgorithms) {
const maliciousHeader = Buffer.from(
JSON.stringify({
alg,
typ: "JWT",
})
).toString("base64url");
const maliciousPayload = Buffer.from(
JSON.stringify({
id: "encrypted_test-id",
})
).toString("base64url");
const maliciousToken = `${maliciousHeader}.${maliciousPayload}.fake-signature`;
await expect(verifyToken(maliciousToken)).rejects.toThrow("Invalid token");
}
});
});
describe("Token Tampering", () => {
test("should reject tokens with modified payload", async () => {
const token = createToken(mockUser.id);
const [header, payload, signature] = token.split(".");
// Modify the payload
const decodedPayload = JSON.parse(Buffer.from(payload, "base64url").toString());
decodedPayload.id = "malicious-id";
const tamperedPayload = Buffer.from(JSON.stringify(decodedPayload)).toString("base64url");
const tamperedToken = `${header}.${tamperedPayload}.${signature}`;
await expect(verifyToken(tamperedToken)).rejects.toThrow("Invalid token");
});
test("should reject tokens with modified signature", async () => {
const token = createToken(mockUser.id);
const [header, payload] = token.split(".");
const tamperedToken = `${header}.${payload}.tamperedsignature`;
await expect(verifyToken(tamperedToken)).rejects.toThrow("Invalid token");
});
test("should reject malformed tokens", async () => {
const malformedTokens = [
"not.a.jwt",
"only.two.parts",
"too.many.parts.here.invalid",
"",
"invalid-base64",
];
for (const malformedToken of malformedTokens) {
await expect(verifyToken(malformedToken)).rejects.toThrow();
}
});
});
describe("Cross-Survey Token Reuse", () => {
test("should reject survey tokens used for different surveys", () => {
const surveyId1 = "survey-1";
const surveyId2 = "survey-2";
const token = createTokenForLinkSurvey(surveyId1, mockUser.email);
const result = verifyTokenForLinkSurvey(token, surveyId2);
expect(result).toBeNull();
});
});
describe("Expired Tokens", () => {
test("should reject expired tokens", async () => {
const expiredToken = jwt.sign(
{
id: "encrypted_test-id",
exp: Math.floor(Date.now() / 1000) - 3600, // Expired 1 hour ago
},
TEST_NEXTAUTH_SECRET
);
await expect(verifyToken(expiredToken)).rejects.toThrow("Invalid token");
});
test("should reject expired email change tokens", async () => {
const expiredToken = jwt.sign(
{
id: "encrypted_test-id",
email: "encrypted_test@example.com",
exp: Math.floor(Date.now() / 1000) - 3600, // Expired 1 hour ago
},
TEST_NEXTAUTH_SECRET
);
await expect(verifyEmailChangeToken(expiredToken)).rejects.toThrow();
});
});
describe("Encryption Key Attacks", () => {
test("should fail gracefully with wrong encryption key", async () => {
mockSymmetricDecrypt.mockImplementation(() => {
throw new Error("Authentication tag verification failed");
});
// Mock findUnique to only return user for correct decrypted ID, not ciphertext
(prisma.user.findUnique as any).mockImplementation(({ where }: { where: { id: string } }) => {
if (where.id === mockUser.id) {
return Promise.resolve(mockUser);
}
return Promise.resolve(null); // Return null for ciphertext IDs
});
const token = createToken(mockUser.id);
// Should fail because ciphertext passed as userId won't match any user in DB
await expect(verifyToken(token)).rejects.toThrow(/User not found/i);
});
test("should handle encryption key not set gracefully", async () => {
const constants = await import("@/lib/constants");
const originalKey = (constants as any).ENCRYPTION_KEY;
(constants as any).ENCRYPTION_KEY = undefined;
const token = jwt.sign(
{
email: "test@example.com",
surveyId: "test-survey-id",
},
TEST_NEXTAUTH_SECRET
);
const result = verifyTokenForLinkSurvey(token, "test-survey-id");
expect(result).toBe("test@example.com");
// Restore
(constants as any).ENCRYPTION_KEY = originalKey;
});
});
describe("SQL Injection Attempts", () => {
test("should safely handle malicious user IDs", async () => {
const maliciousIds = [
"'; DROP TABLE users; --",
"1' OR '1'='1",
"admin'/*",
"<script>alert('xss')</script>",
"../../etc/passwd",
];
for (const maliciousId of maliciousIds) {
mockSymmetricDecrypt.mockReturnValueOnce(maliciousId);
const token = jwt.sign({ id: "encrypted_malicious" }, TEST_NEXTAUTH_SECRET);
// The function should look up the user safely
await verifyToken(token);
expect(prisma.user.findUnique).toHaveBeenCalledWith({
where: { id: maliciousId },
});
}
});
});
describe("Token Reuse and Replay Attacks", () => {
test("should allow legitimate token reuse within validity period", async () => {
const token = createToken(mockUser.id);
// First use
const result1 = await verifyToken(token);
expect(result1.id).toBe(mockUser.id); // Returns decrypted user ID
// Second use (should still work)
const result2 = await verifyToken(token);
expect(result2.id).toBe(mockUser.id); // Returns decrypted user ID
});
});
describe("Legacy Token Compatibility", () => {
test("should handle legacy unencrypted tokens gracefully", async () => {
// Legacy token with plain text data
const legacyToken = jwt.sign({ id: mockUser.id }, TEST_NEXTAUTH_SECRET);
const result = await verifyToken(legacyToken);
expect(result.id).toBe(mockUser.id); // Returns raw ID from payload
expect(result.email).toBe(mockUser.email);
});
test("should handle mixed encrypted/unencrypted fields", async () => {
mockSymmetricDecrypt
.mockImplementationOnce(() => mockUser.id) // id decrypts successfully
.mockImplementationOnce(() => {
throw new Error("Email not encrypted");
}); // email fails
const token = jwt.sign(
{
id: "encrypted_test-id",
email: "plain-email@example.com",
},
TEST_NEXTAUTH_SECRET
);
const result = await verifyEmailChangeToken(token);
expect(result.id).toBe(mockUser.id);
expect(result.email).toBe("plain-email@example.com");
});
test("should verify old format user tokens with email-based secrets", async () => {
// Simulate old token format with per-user secret
const oldFormatToken = jwt.sign(
{ id: `encrypted_${mockUser.id}` },
TEST_NEXTAUTH_SECRET + mockUser.email
);
const result = await verifyToken(oldFormatToken);
expect(result.id).toBe(mockUser.id); // Returns decrypted user ID
expect(result.email).toBe(mockUser.email);
});
test("should verify old format survey tokens with survey-based secrets", () => {
const surveyId = "legacy-survey-id";
// Simulate old survey token format
const oldFormatSurveyToken = jwt.sign(
{ email: `encrypted_${mockUser.email}` },
TEST_NEXTAUTH_SECRET + surveyId
);
const result = verifyTokenForLinkSurvey(oldFormatSurveyToken, surveyId);
expect(result).toBe(mockUser.email);
});
test("should gracefully handle database errors during legacy verification", async () => {
// Create token that will fail new method
const legacyToken = jwt.sign(
{ id: `encrypted_${mockUser.id}` },
TEST_NEXTAUTH_SECRET + mockUser.email
);
// Make database lookup fail
(prisma.user.findUnique as any).mockRejectedValueOnce(new Error("DB connection lost"));
await expect(verifyToken(legacyToken)).rejects.toThrow("DB connection lost");
});
});
describe("Edge Cases and Error Handling", () => {
test("should handle database connection errors gracefully", async () => {
(prisma.user.findUnique as any).mockRejectedValue(new Error("Database connection failed"));
const token = createToken(mockUser.id);
await expect(verifyToken(token)).rejects.toThrow("Database connection failed");
});
test("should handle crypto module errors", () => {
mockSymmetricEncrypt.mockImplementation(() => {
throw new Error("Crypto module error");
});
expect(() => createToken(mockUser.id)).toThrow("Crypto module error");
});
test("should validate email format in tokens", () => {
const invalidEmails = ["", "not-an-email", "missing@", "@missing-local.com", "spaces in@email.com"];
invalidEmails.forEach((invalidEmail) => {
expect(() => createEmailToken(invalidEmail)).not.toThrow();
// Note: JWT functions don't validate email format, they just encrypt/decrypt
// Email validation should happen at a higher level
});
});
test("should handle extremely long inputs", () => {
const longString = "a".repeat(10000);
expect(() => createToken(longString)).not.toThrow();
expect(() => createEmailToken(longString)).not.toThrow();
});
test("should handle special characters in user data", () => {
const specialChars = "!@#$%^&*()_+-=[]{}|;:'\",.<>?/~`";
expect(() => createToken(specialChars)).not.toThrow();
expect(() => createEmailToken(specialChars)).not.toThrow();
});
});
describe("Performance and Resource Exhaustion", () => {
test("should handle rapid token creation without memory leaks", () => {
const tokens: string[] = [];
for (let i = 0; i < 1000; i++) {
tokens.push(createToken(`user-${i}`));
}
expect(tokens.length).toBe(1000);
expect(tokens.every((token) => typeof token === "string")).toBe(true);
});
test("should handle rapid token verification", async () => {
const token = createToken(mockUser.id);
const verifications: Promise<any>[] = [];
for (let i = 0; i < 100; i++) {
verifications.push(verifyToken(token));
}
const results = await Promise.all(verifications);
expect(results.length).toBe(100);
expect(results.every((result: any) => result.id === mockUser.id)).toBe(true); // Returns decrypted user ID
});
});
});
});
+214 -90
View File
@@ -1,43 +1,64 @@
import { symmetricDecrypt, symmetricEncrypt } from "@/lib/crypto";
import { env } from "@/lib/env";
import jwt, { JwtPayload } from "jsonwebtoken";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { ENCRYPTION_KEY, NEXTAUTH_SECRET } from "@/lib/constants";
import { symmetricDecrypt, symmetricEncrypt } from "@/lib/crypto";
export const createToken = (userId: string, userEmail: string, options = {}): string => {
const encryptedUserId = symmetricEncrypt(userId, env.ENCRYPTION_KEY);
return jwt.sign({ id: encryptedUserId }, env.NEXTAUTH_SECRET + userEmail, options);
};
export const createTokenForLinkSurvey = (surveyId: string, userEmail: string): string => {
const encryptedEmail = symmetricEncrypt(userEmail, env.ENCRYPTION_KEY);
return jwt.sign({ email: encryptedEmail }, env.NEXTAUTH_SECRET + surveyId);
// Helper function to decrypt with fallback to plain text
const decryptWithFallback = (encryptedText: string, key: string): string => {
try {
return symmetricDecrypt(encryptedText, key);
} catch {
return encryptedText; // Return as-is if decryption fails (legacy format)
}
};
export const verifyEmailChangeToken = async (token: string): Promise<{ id: string; email: string }> => {
if (!env.NEXTAUTH_SECRET) {
export const createToken = (userId: string, options = {}): string => {
if (!NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is not set");
}
const payload = jwt.verify(token, env.NEXTAUTH_SECRET) as { id: string; email: string };
if (!ENCRYPTION_KEY) {
throw new Error("ENCRYPTION_KEY is not set");
}
const encryptedUserId = symmetricEncrypt(userId, ENCRYPTION_KEY);
return jwt.sign({ id: encryptedUserId }, NEXTAUTH_SECRET, options);
};
export const createTokenForLinkSurvey = (surveyId: string, userEmail: string): string => {
if (!NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is not set");
}
if (!ENCRYPTION_KEY) {
throw new Error("ENCRYPTION_KEY is not set");
}
const encryptedEmail = symmetricEncrypt(userEmail, ENCRYPTION_KEY);
return jwt.sign({ email: encryptedEmail, surveyId }, NEXTAUTH_SECRET);
};
export const verifyEmailChangeToken = async (token: string): Promise<{ id: string; email: string }> => {
if (!NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is not set");
}
if (!ENCRYPTION_KEY) {
throw new Error("ENCRYPTION_KEY is not set");
}
const payload = jwt.verify(token, NEXTAUTH_SECRET, { algorithms: ["HS256"] }) as {
id: string;
email: string;
};
if (!payload?.id || !payload?.email) {
throw new Error("Token is invalid or missing required fields");
}
let decryptedId: string;
let decryptedEmail: string;
try {
decryptedId = symmetricDecrypt(payload.id, env.ENCRYPTION_KEY);
} catch {
decryptedId = payload.id;
}
try {
decryptedEmail = symmetricDecrypt(payload.email, env.ENCRYPTION_KEY);
} catch {
decryptedEmail = payload.email;
}
// Decrypt both fields with fallback
const decryptedId = decryptWithFallback(payload.id, ENCRYPTION_KEY);
const decryptedEmail = decryptWithFallback(payload.email, ENCRYPTION_KEY);
return {
id: decryptedId,
@@ -46,127 +67,230 @@ export const verifyEmailChangeToken = async (token: string): Promise<{ id: strin
};
export const createEmailChangeToken = (userId: string, email: string): string => {
const encryptedUserId = symmetricEncrypt(userId, env.ENCRYPTION_KEY);
const encryptedEmail = symmetricEncrypt(email, env.ENCRYPTION_KEY);
if (!NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is not set");
}
if (!ENCRYPTION_KEY) {
throw new Error("ENCRYPTION_KEY is not set");
}
const encryptedUserId = symmetricEncrypt(userId, ENCRYPTION_KEY);
const encryptedEmail = symmetricEncrypt(email, ENCRYPTION_KEY);
const payload = {
id: encryptedUserId,
email: encryptedEmail,
};
return jwt.sign(payload, env.NEXTAUTH_SECRET as string, {
return jwt.sign(payload, NEXTAUTH_SECRET, {
expiresIn: "1d",
});
};
export const createEmailToken = (email: string): string => {
if (!env.NEXTAUTH_SECRET) {
if (!NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is not set");
}
const encryptedEmail = symmetricEncrypt(email, env.ENCRYPTION_KEY);
return jwt.sign({ email: encryptedEmail }, env.NEXTAUTH_SECRET);
if (!ENCRYPTION_KEY) {
throw new Error("ENCRYPTION_KEY is not set");
}
const encryptedEmail = symmetricEncrypt(email, ENCRYPTION_KEY);
return jwt.sign({ email: encryptedEmail }, NEXTAUTH_SECRET);
};
export const getEmailFromEmailToken = (token: string): string => {
if (!env.NEXTAUTH_SECRET) {
if (!NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is not set");
}
const payload = jwt.verify(token, env.NEXTAUTH_SECRET) as JwtPayload;
try {
// Try to decrypt first (for newer tokens)
const decryptedEmail = symmetricDecrypt(payload.email, env.ENCRYPTION_KEY);
return decryptedEmail;
} catch {
// If decryption fails, return the original email (for older tokens)
return payload.email;
if (!ENCRYPTION_KEY) {
throw new Error("ENCRYPTION_KEY is not set");
}
const payload = jwt.verify(token, NEXTAUTH_SECRET, { algorithms: ["HS256"] }) as JwtPayload & {
email: string;
};
return decryptWithFallback(payload.email, ENCRYPTION_KEY);
};
export const createInviteToken = (inviteId: string, email: string, options = {}): string => {
if (!env.NEXTAUTH_SECRET) {
if (!NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is not set");
}
const encryptedInviteId = symmetricEncrypt(inviteId, env.ENCRYPTION_KEY);
const encryptedEmail = symmetricEncrypt(email, env.ENCRYPTION_KEY);
return jwt.sign({ inviteId: encryptedInviteId, email: encryptedEmail }, env.NEXTAUTH_SECRET, options);
if (!ENCRYPTION_KEY) {
throw new Error("ENCRYPTION_KEY is not set");
}
const encryptedInviteId = symmetricEncrypt(inviteId, ENCRYPTION_KEY);
const encryptedEmail = symmetricEncrypt(email, ENCRYPTION_KEY);
return jwt.sign({ inviteId: encryptedInviteId, email: encryptedEmail }, NEXTAUTH_SECRET, options);
};
export const verifyTokenForLinkSurvey = (token: string, surveyId: string): string | null => {
if (!NEXTAUTH_SECRET) {
return null;
}
try {
const { email } = jwt.verify(token, env.NEXTAUTH_SECRET + surveyId) as JwtPayload;
let payload: JwtPayload & { email: string; surveyId?: string };
// Try primary method first (consistent secret)
try {
// Try to decrypt first (for newer tokens)
if (!env.ENCRYPTION_KEY) {
throw new Error("ENCRYPTION_KEY is not set");
payload = jwt.verify(token, NEXTAUTH_SECRET, { algorithms: ["HS256"] }) as JwtPayload & {
email: string;
surveyId: string;
};
} catch (primaryError) {
logger.error(primaryError, "Token verification failed with primary method");
// Fallback to legacy method (surveyId-based secret)
try {
payload = jwt.verify(token, NEXTAUTH_SECRET + surveyId, { algorithms: ["HS256"] }) as JwtPayload & {
email: string;
};
} catch (legacyError) {
logger.error(legacyError, "Token verification failed with legacy method");
throw new Error("Invalid token");
}
const decryptedEmail = symmetricDecrypt(email, env.ENCRYPTION_KEY);
return decryptedEmail;
} catch {
// If decryption fails, return the original email (for older tokens)
return email;
}
} catch (err) {
// Verify the surveyId matches if present in payload (new format)
if (payload.surveyId && payload.surveyId !== surveyId) {
return null;
}
const { email } = payload;
if (!email) {
return null;
}
// Decrypt email with fallback to plain text
if (!ENCRYPTION_KEY) {
return email; // Return as-is if encryption key not set
}
return decryptWithFallback(email, ENCRYPTION_KEY);
} catch (error) {
logger.error(error, "Survey link token verification failed");
return null;
}
};
export const verifyToken = async (token: string): Promise<JwtPayload> => {
// First decode to get the ID
const decoded = jwt.decode(token);
const payload: JwtPayload = decoded as JwtPayload;
// Helper function to get user email for legacy verification
const getUserEmailForLegacyVerification = async (
token: string,
userId?: string
): Promise<{ userId: string; userEmail: string }> => {
if (!userId) {
const decoded = jwt.decode(token);
if (!payload) {
throw new Error("Token is invalid");
// Validate decoded token structure before using it
if (
!decoded ||
typeof decoded !== "object" ||
!decoded.id ||
typeof decoded.id !== "string" ||
decoded.id.trim() === ""
) {
logger.error("Invalid token: missing or invalid user ID");
throw new Error("Invalid token");
}
userId = decoded.id;
}
const { id } = payload;
if (!id) {
throw new Error("Token missing required field: id");
const decryptedId = decryptWithFallback(userId, ENCRYPTION_KEY);
// Validate decrypted ID before database query
if (!decryptedId || typeof decryptedId !== "string" || decryptedId.trim() === "") {
logger.error("Invalid token: missing or invalid user ID");
throw new Error("Invalid token");
}
// Try to decrypt the ID (for newer tokens), if it fails use the ID as-is (for older tokens)
let decryptedId: string;
try {
decryptedId = symmetricDecrypt(id, env.ENCRYPTION_KEY);
} catch {
decryptedId = id;
}
// If no email provided, look up the user
const foundUser = await prisma.user.findUnique({
where: { id: decryptedId },
});
if (!foundUser) {
throw new Error("User not found");
const errorMessage = "User not found";
logger.error(errorMessage);
throw new Error(errorMessage);
}
const userEmail = foundUser.email;
return { userId: decryptedId, userEmail: foundUser.email };
};
return { id: decryptedId, email: userEmail };
export const verifyToken = async (token: string): Promise<JwtPayload> => {
if (!NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is not set");
}
let payload: JwtPayload & { id: string };
let userData: { userId: string; userEmail: string } | null = null;
// Try new method first, with smart fallback to legacy
try {
payload = jwt.verify(token, NEXTAUTH_SECRET, { algorithms: ["HS256"] }) as JwtPayload & {
id: string;
};
} catch (newMethodError) {
logger.error(newMethodError, "Token verification failed with new method");
// Get user email for legacy verification
userData = await getUserEmailForLegacyVerification(token);
// Try legacy verification with email-based secret
try {
payload = jwt.verify(token, NEXTAUTH_SECRET + userData.userEmail, {
algorithms: ["HS256"],
}) as JwtPayload & {
id: string;
};
} catch (legacyMethodError) {
logger.error(legacyMethodError, "Token verification failed with legacy method");
throw new Error("Invalid token");
}
}
if (!payload?.id) {
throw new Error("Invalid token");
}
// Get user email if we don't have it yet
userData ??= await getUserEmailForLegacyVerification(token, payload.id);
return { id: userData.userId, email: userData.userEmail };
};
export const verifyInviteToken = (token: string): { inviteId: string; email: string } => {
if (!NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is not set");
}
if (!ENCRYPTION_KEY) {
throw new Error("ENCRYPTION_KEY is not set");
}
try {
const decoded = jwt.decode(token);
const payload: JwtPayload = decoded as JwtPayload;
const payload = jwt.verify(token, NEXTAUTH_SECRET, { algorithms: ["HS256"] }) as JwtPayload & {
inviteId: string;
email: string;
};
const { inviteId, email } = payload;
const { inviteId: encryptedInviteId, email: encryptedEmail } = payload;
let decryptedInviteId: string;
let decryptedEmail: string;
try {
// Try to decrypt first (for newer tokens)
decryptedInviteId = symmetricDecrypt(inviteId, env.ENCRYPTION_KEY);
decryptedEmail = symmetricDecrypt(email, env.ENCRYPTION_KEY);
} catch {
// If decryption fails, use original values (for older tokens)
decryptedInviteId = inviteId;
decryptedEmail = email;
if (!encryptedInviteId || !encryptedEmail) {
throw new Error("Invalid token");
}
// Decrypt both fields with fallback to original values
const decryptedInviteId = decryptWithFallback(encryptedInviteId, ENCRYPTION_KEY);
const decryptedEmail = decryptWithFallback(encryptedEmail, ENCRYPTION_KEY);
return {
inviteId: decryptedInviteId,
email: decryptedEmail,
+3 -5
View File
@@ -1,11 +1,11 @@
import { parseRecallInfo } from "@/lib/utils/recall";
import { TResponse } from "@formbricks/types/responses";
import { TResponse, TResponseDataValue } from "@formbricks/types/responses";
import { TSurvey, TSurveyQuestion, TSurveyQuestionType } from "@formbricks/types/surveys/types";
import { getLanguageCode, getLocalizedValue } from "./i18n/utils";
// function to convert response value of type string | number | string[] or Record<string, string> to string | string[]
export const convertResponseValue = (
answer: string | number | string[] | Record<string, string>,
answer: TResponseDataValue,
question: TSurveyQuestion
): string | string[] => {
switch (question.type) {
@@ -57,9 +57,7 @@ export const getQuestionResponseMapping = (
return questionResponseMapping;
};
export const processResponseData = (
responseData: string | number | string[] | Record<string, string>
): string => {
export const processResponseData = (responseData: TResponseDataValue): string => {
switch (typeof responseData) {
case "string":
return responseData;
+1 -1
View File
@@ -450,7 +450,7 @@ const evaluateSingleCondition = (
return (
Array.isArray(leftValue) &&
Array.isArray(rightValue) &&
rightValue.some((v) => !leftValue.includes(v))
!rightValue.some((v) => leftValue.includes(v))
);
case "isAccepted":
return leftValue === "accepted";
+8 -3
View File
@@ -1,5 +1,10 @@
import { RefObject, useEffect } from "react";
// Helper function to check if a value is a DOM element with contains method
const isDOMElement = (element: unknown): element is HTMLElement => {
return element instanceof HTMLElement;
};
// Improved version of https://usehooks.com/useOnClickOutside/
export const useClickOutside = (
ref: RefObject<HTMLElement | HTMLDivElement | null>,
@@ -13,14 +18,14 @@ export const useClickOutside = (
// Do nothing if `mousedown` or `touchstart` started inside ref element
if (startedInside || !startedWhenMounted) return;
// Do nothing if clicking ref's element or descendent elements
if (!ref.current || ref.current.contains(event.target as Node)) return;
if (!isDOMElement(ref.current) || ref.current.contains(event.target as Node)) return;
handler(event);
};
const validateEventStart = (event: MouseEvent | TouchEvent) => {
startedWhenMounted = ref.current !== null;
startedInside = ref.current !== null && ref.current.contains(event.target as Node);
startedWhenMounted = isDOMElement(ref.current);
startedInside = isDOMElement(ref.current) && ref.current.contains(event.target as Node);
};
document.addEventListener("mousedown", validateEventStart);
+28 -8
View File
@@ -262,7 +262,9 @@
"membership_not_found": "Mitgliedschaft nicht gefunden",
"metadata": "Metadaten",
"minimum": "Minimum",
"mobile_overlay_text": "Formbricks ist für Geräte mit kleineren Auflösungen nicht verfügbar.",
"mobile_overlay_app_works_best_on_desktop": "Formbricks funktioniert am besten auf einem größeren Bildschirm. Um Umfragen zu verwalten oder zu erstellen, wechsle zu einem anderen Gerät.",
"mobile_overlay_surveys_look_good": "Keine Sorge deine Umfragen sehen auf jedem Gerät und jeder Bildschirmgröße großartig aus!",
"mobile_overlay_title": "Oops, Bildschirm zu klein erkannt!",
"move_down": "Nach unten bewegen",
"move_up": "Nach oben bewegen",
"multiple_languages": "Mehrsprachigkeit",
@@ -277,6 +279,7 @@
"no_result_found": "Kein Ergebnis gefunden",
"no_results": "Keine Ergebnisse",
"no_surveys_found": "Keine Umfragen gefunden.",
"none_of_the_above": "Keine der oben genannten Optionen",
"not_authenticated": "Du bist nicht authentifiziert, um diese Aktion durchzuführen.",
"not_authorized": "Nicht berechtigt",
"not_connected": "Nicht verbunden",
@@ -750,7 +753,6 @@
},
"project": {
"api_keys": {
"access_control": "Zugriffskontrolle",
"add_api_key": "API-Schlüssel hinzufügen",
"api_key": "API-Schlüssel",
"api_key_copied_to_clipboard": "API-Schlüssel in die Zwischenablage kopiert",
@@ -1202,12 +1204,12 @@
"add_description": "Beschreibung hinzufügen",
"add_ending": "Abschluss hinzufügen",
"add_ending_below": "Abschluss unten hinzufügen",
"add_fallback": "Hinzufügen",
"add_fallback_placeholder": "Hinzufügen eines Platzhalters, der angezeigt wird, wenn die Frage übersprungen wird:",
"add_fallback_placeholder": "Platzhalter hinzufügen, falls kein Wert zur Verfügung steht.",
"add_hidden_field_id": "Verstecktes Feld ID hinzufügen",
"add_highlight_border": "Rahmen hinzufügen",
"add_highlight_border_description": "Füge deiner Umfragekarte einen äußeren Rahmen hinzu.",
"add_logic": "Logik hinzufügen",
"add_none_of_the_above": "Füge \"Keine der oben genannten Optionen\" hinzu",
"add_option": "Option hinzufügen",
"add_other": "Anderes hinzufügen",
"add_photo_or_video": "Foto oder Video hinzufügen",
@@ -1240,6 +1242,7 @@
"automatically_mark_the_survey_as_complete_after": "Umfrage automatisch als abgeschlossen markieren nach",
"back_button_label": "Zurück\"- Button ",
"background_styling": "Hintergründe",
"bold": "Fett",
"brand_color": "Markenfarbe",
"brightness": "Helligkeit",
"button_label": "Beschriftung",
@@ -1312,7 +1315,6 @@
"days_before_showing_this_survey_again": "Tage, bevor diese Umfrage erneut angezeigt wird.",
"decide_how_often_people_can_answer_this_survey": "Entscheide, wie oft Leute diese Umfrage beantworten können.",
"delete_choice": "Auswahl löschen",
"description": "Beschreibung",
"disable_the_visibility_of_survey_progress": "Deaktiviere die Sichtbarkeit des Umfragefortschritts.",
"display_an_estimate_of_completion_time_for_survey": "Zeige eine Schätzung der Fertigstellungszeit für die Umfrage an",
"display_number_of_responses_for_survey": "Anzahl der Antworten für Umfrage anzeigen",
@@ -1323,6 +1325,7 @@
"does_not_include_all_of": "Enthält nicht alle von",
"does_not_include_one_of": "Enthält nicht eines von",
"does_not_start_with": "Fängt nicht an mit",
"edit_link": "Bearbeitungslink",
"edit_recall": "Erinnerung bearbeiten",
"edit_translations": "{lang} -Übersetzungen bearbeiten",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Teilnehmer können die Umfragesprache jederzeit während der Umfrage ändern.",
@@ -1333,13 +1336,13 @@
"ending_card_used_in_logic": "Diese Abschlusskarte wird in der Logik der Frage {questionIndex} verwendet.",
"ending_used_in_quota": "Dieses Ende wird in der \"{quotaName}\" Quote verwendet",
"ends_with": "endet mit",
"enter_fallback_value": "Ersatzwert eingeben",
"equals": "Gleich",
"equals_one_of": "Entspricht einem von",
"error_publishing_survey": "Beim Veröffentlichen der Umfrage ist ein Fehler aufgetreten.",
"error_saving_changes": "Fehler beim Speichern der Änderungen",
"even_after_they_submitted_a_response_e_g_feedback_box": "Sogar nachdem sie eine Antwort eingereicht haben (z.B. Feedback-Box)",
"everyone": "Jeder",
"fallback_for": "Ersatz für",
"fallback_missing": "Fehlender Fallback",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} wird in der Logik der Frage {questionIndex} verwendet. Bitte entferne es zuerst aus der Logik.",
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "Verstecktes Feld \"{fieldId}\" wird in der \"{quotaName}\" Quote verwendet",
@@ -1374,8 +1377,9 @@
"follow_ups_modal_action_subject_placeholder": "Betreff der E-Mail",
"follow_ups_modal_action_to_description": "Empfänger-E-Mail-Adresse",
"follow_ups_modal_action_to_label": "An",
"follow_ups_modal_action_to_warning": "Kein E-Mail-Feld in der Umfrage gefunden.",
"follow_ups_modal_action_to_warning": "Keine gültigen Optionen für den Versand von E-Mails gefunden, bitte fügen Sie einige Freitext- / Kontaktinformationen-Fragen oder versteckte Felder hinzu",
"follow_ups_modal_create_heading": "Neues Follow-up erstellen",
"follow_ups_modal_created_successfull_toast": "Nachverfolgung erstellt und wird gespeichert, sobald du die Umfrage speicherst.",
"follow_ups_modal_edit_heading": "Follow-up bearbeiten",
"follow_ups_modal_edit_no_id": "Keine Survey Follow-up-ID angegeben, das Survey-Follow-up kann nicht aktualisiert werden",
"follow_ups_modal_name_label": "Name des Follow-ups",
@@ -1385,8 +1389,9 @@
"follow_ups_modal_trigger_label": "Auslöser",
"follow_ups_modal_trigger_type_ending": "Teilnehmer sieht einen bestimmten Abschluss",
"follow_ups_modal_trigger_type_ending_select": "Abschlüsse auswählen: ",
"follow_ups_modal_trigger_type_ending_warning": "Keine Abschlüsse in der Umfrage gefunden!",
"follow_ups_modal_trigger_type_ending_warning": "Bitte wähle mindestens ein Ende aus oder ändere den Auslöser-Typ",
"follow_ups_modal_trigger_type_response": "Teilnehmer schließt Umfrage ab",
"follow_ups_modal_updated_successfull_toast": "Nachverfolgung aktualisiert und wird gespeichert, sobald du die Umfrage speicherst.",
"follow_ups_new": "Neues Follow-up",
"follow_ups_upgrade_button_text": "Upgrade, um Follow-ups zu aktivieren",
"form_styling": "Umfrage Styling",
@@ -1394,6 +1399,9 @@
"four_points": "4 Punkte",
"heading": "Überschrift",
"hidden_field_added_successfully": "Verstecktes Feld erfolgreich hinzugefügt",
"hidden_field_used_in_recall": "Verstecktes Feld \"{hiddenField}\" wird in Frage {questionIndex} abgerufen.",
"hidden_field_used_in_recall_ending_card": "Verstecktes Feld \"{hiddenField}\" wird in der Abschlusskarte abgerufen.",
"hidden_field_used_in_recall_welcome": "Verstecktes Feld \"{hiddenField}\" wird in der Willkommenskarte abgerufen.",
"hide_advanced_settings": "Erweiterte Einstellungen ausblenden",
"hide_back_button": "'Zurück'-Button ausblenden",
"hide_back_button_description": "Den Zurück-Button in der Umfrage nicht anzeigen",
@@ -1412,6 +1420,7 @@
"inner_text": "Innerer Text",
"input_border_color": "Randfarbe des Eingabefelds",
"input_color": "Farbe des Eingabefelds",
"insert_link": "Link einfügen",
"invalid_targeting": "Ungültiges Targeting: Bitte überprüfe deine Zielgruppenfilter",
"invalid_video_url_warning": "Bitte gib eine gültige YouTube-, Vimeo- oder Loom-URL ein. Andere Video-Plattformen werden derzeit nicht unterstützt.",
"invalid_youtube_url": "Ungültige YouTube-URL",
@@ -1429,6 +1438,7 @@
"is_set": "Ist festgelegt",
"is_skipped": "Wird übersprungen",
"is_submitted": "Wird eingereicht",
"italic": "Kursiv",
"jump_to_question": "Zur Frage springen",
"keep_current_order": "Bestehende Anordnung beibehalten",
"keep_showing_while_conditions_match": "Zeige weiter, solange die Bedingungen übereinstimmen",
@@ -1455,6 +1465,7 @@
"no_images_found_for": "Keine Bilder gefunden für ''{query}\"",
"no_languages_found_add_first_one_to_get_started": "Keine Sprachen gefunden. Füge die erste hinzu, um loszulegen.",
"no_option_found": "Keine Option gefunden",
"no_recall_items_found": "Keine Erinnerungsstücke gefunden",
"no_variables_yet_add_first_one_below": "Noch keine Variablen. Füge die erste hinzu.",
"number": "Nummer",
"once_set_the_default_language_for_this_survey_can_only_be_changed_by_disabling_the_multi_language_option_and_deleting_all_translations": "Sobald die Standardsprache für diese Umfrage festgelegt ist, kann sie nur geändert werden, indem die Mehrsprachigkeitsoption deaktiviert und alle Übersetzungen gelöscht werden.",
@@ -1474,6 +1485,7 @@
"pin_can_only_contain_numbers": "PIN darf nur Zahlen enthalten.",
"pin_must_be_a_four_digit_number": "Die PIN muss eine vierstellige Zahl sein.",
"please_enter_a_file_extension": "Bitte gib eine Dateierweiterung ein.",
"please_enter_a_valid_url": "Bitte geben Sie eine gültige URL ein (z. B. https://beispiel.de)",
"please_set_a_survey_trigger": "Bitte richte einen Umfrage-Trigger ein",
"please_specify": "Bitte angeben",
"prevent_double_submission": "Doppeltes Anbschicken verhindern",
@@ -1488,6 +1500,8 @@
"question_id_updated": "Frage-ID aktualisiert",
"question_used_in_logic": "Diese Frage wird in der Logik der Frage {questionIndex} verwendet.",
"question_used_in_quota": "Diese Frage wird in der \"{quotaName}\" Quote verwendet",
"question_used_in_recall": "Diese Frage wird in Frage {questionIndex} abgerufen.",
"question_used_in_recall_ending_card": "Diese Frage wird in der Abschlusskarte abgerufen.",
"quotas": {
"add_quota": "Quote hinzufügen",
"change_quota_for_public_survey": "Quote für öffentliche Umfrage ändern?",
@@ -1522,6 +1536,8 @@
"randomize_all": "Alle Optionen zufällig anordnen",
"randomize_all_except_last": "Alle Optionen zufällig anordnen außer der letzten",
"range": "Reichweite",
"recall_data": "Daten abrufen",
"recall_information_from": "Information abrufen von ...",
"recontact_options": "Optionen zur erneuten Kontaktaufnahme",
"redirect_thank_you_card": "Weiterleitung anlegen",
"redirect_to_url": "Zu URL weiterleiten",
@@ -1599,6 +1615,7 @@
"trigger_survey_when_one_of_the_actions_is_fired": "Umfrage auslösen, wenn eine der Aktionen ausgeführt wird...",
"try_lollipop_or_mountain": "Versuch 'Lolli' oder 'Berge'...",
"type_field_id": "Feld-ID eingeben",
"underline": "Unterstreichen",
"unlock_targeting_description": "Spezifische Nutzergruppen basierend auf Attributen oder Geräteinformationen ansprechen",
"unlock_targeting_title": "Targeting mit einem höheren Plan freischalten",
"unsaved_changes_warning": "Du hast ungespeicherte Änderungen in deiner Umfrage. Möchtest Du sie speichern, bevor Du gehst?",
@@ -1615,6 +1632,9 @@
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variable \"{variableName}\" wird in der \"{quotaName}\" Quote verwendet",
"variable_name_is_already_taken_please_choose_another": "Variablenname ist bereits vergeben, bitte wähle einen anderen.",
"variable_name_must_start_with_a_letter": "Variablenname muss mit einem Buchstaben beginnen.",
"variable_used_in_recall": "Variable \"{variable}\" wird in Frage {questionIndex} abgerufen.",
"variable_used_in_recall_ending_card": "Variable \"{variable}\" wird in der Abschlusskarte abgerufen.",
"variable_used_in_recall_welcome": "Variable \"{variable}\" wird in der Willkommenskarte abgerufen.",
"verify_email_before_submission": "E-Mail vor dem Absenden überprüfen",
"verify_email_before_submission_description": "Lass nur Leute mit einer echten E-Mail antworten.",
"wait": "Warte",
+30 -10
View File
@@ -262,7 +262,9 @@
"membership_not_found": "Membership not found",
"metadata": "Metadata",
"minimum": "Minimum",
"mobile_overlay_text": "Formbricks is not available for devices with smaller resolutions.",
"mobile_overlay_app_works_best_on_desktop": "Formbricks works best on a bigger screen. To manage or build surveys, switch to another device.",
"mobile_overlay_surveys_look_good": "Don't worry your surveys look great on every device and screen size!",
"mobile_overlay_title": "Oops, tiny screen detected!",
"move_down": "Move down",
"move_up": "Move up",
"multiple_languages": "Multiple languages",
@@ -277,6 +279,7 @@
"no_result_found": "No result found",
"no_results": "No results",
"no_surveys_found": "No surveys found.",
"none_of_the_above": "None of the above",
"not_authenticated": "You are not authenticated to perform this action.",
"not_authorized": "Not authorized",
"not_connected": "Not Connected",
@@ -750,7 +753,6 @@
},
"project": {
"api_keys": {
"access_control": "Access Control",
"add_api_key": "Add API Key",
"api_key": "API Key",
"api_key_copied_to_clipboard": "API key copied to clipboard",
@@ -1202,12 +1204,12 @@
"add_description": "Add description",
"add_ending": "Add ending",
"add_ending_below": "Add ending below",
"add_fallback": "Add",
"add_fallback_placeholder": "Add a placeholder to show if the question gets skipped:",
"add_fallback_placeholder": "Add a placeholder to show if there is no value to recall.",
"add_hidden_field_id": "Add hidden field ID",
"add_highlight_border": "Add highlight border",
"add_highlight_border_description": "Add an outer border to your survey card.",
"add_logic": "Add logic",
"add_none_of_the_above": "Add \"None of the Above\"",
"add_option": "Add option",
"add_other": "Add \"Other\"",
"add_photo_or_video": "Add photo or video",
@@ -1240,6 +1242,7 @@
"automatically_mark_the_survey_as_complete_after": "Automatically mark the survey as complete after",
"back_button_label": "\"Back\" Button Label",
"background_styling": "Background Styling",
"bold": "Bold",
"brand_color": "Brand color",
"brightness": "Brightness",
"button_label": "Button Label",
@@ -1300,8 +1303,8 @@
"contains": "Contains",
"continue_to_settings": "Continue to Settings",
"control_which_file_types_can_be_uploaded": "Control which file types can be uploaded.",
"convert_to_multiple_choice": "Convert to Multiple Choice",
"convert_to_single_choice": "Convert to Single Choice",
"convert_to_multiple_choice": "Convert to Multi-select",
"convert_to_single_choice": "Convert to Single-select",
"country": "Country",
"create_group": "Create group",
"create_your_own_survey": "Create your own survey",
@@ -1312,7 +1315,6 @@
"days_before_showing_this_survey_again": "days before showing this survey again.",
"decide_how_often_people_can_answer_this_survey": "Decide how often people can answer this survey.",
"delete_choice": "Delete choice",
"description": "Description",
"disable_the_visibility_of_survey_progress": "Disable the visibility of survey progress.",
"display_an_estimate_of_completion_time_for_survey": "Display an estimate of completion time for survey",
"display_number_of_responses_for_survey": "Display number of responses for survey",
@@ -1323,6 +1325,7 @@
"does_not_include_all_of": "Does not include all of",
"does_not_include_one_of": "Does not include one of",
"does_not_start_with": "Does not start with",
"edit_link": "Edit link",
"edit_recall": "Edit Recall",
"edit_translations": "Edit {lang} translations",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Enable participants to switch the survey language at any point during the survey.",
@@ -1333,13 +1336,13 @@
"ending_card_used_in_logic": "This ending card is used in logic of question {questionIndex}.",
"ending_used_in_quota": "This ending is being used in \"{quotaName}\" quota",
"ends_with": "Ends with",
"enter_fallback_value": "Enter fallback value",
"equals": "Equals",
"equals_one_of": "Equals one of",
"error_publishing_survey": "An error occured while publishing the survey.",
"error_saving_changes": "Error saving changes",
"even_after_they_submitted_a_response_e_g_feedback_box": "Even after they submitted a response (e.g. Feedback Box)",
"everyone": "Everyone",
"fallback_for": "Fallback for ",
"fallback_missing": "Fallback missing",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} is used in logic of question {questionIndex}. Please remove it from logic first.",
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "Hidden field \"{fieldId}\" is being used in \"{quotaName}\" quota",
@@ -1374,8 +1377,9 @@
"follow_ups_modal_action_subject_placeholder": "Subject of the email",
"follow_ups_modal_action_to_description": "Email address to send the email to",
"follow_ups_modal_action_to_label": "To",
"follow_ups_modal_action_to_warning": "No email field detected in the survey",
"follow_ups_modal_action_to_warning": "No valid options found for sending emails, please add some open-text / contact-info questions or hidden fields",
"follow_ups_modal_create_heading": "Create a new follow-up",
"follow_ups_modal_created_successfull_toast": "Follow-up created and will be saved once you save the survey.",
"follow_ups_modal_edit_heading": "Edit this follow-up",
"follow_ups_modal_edit_no_id": "No survey follow up id provided, can't update the survey follow up",
"follow_ups_modal_name_label": "Follow-up name",
@@ -1385,8 +1389,9 @@
"follow_ups_modal_trigger_label": "Trigger",
"follow_ups_modal_trigger_type_ending": "Respondent sees a specific ending",
"follow_ups_modal_trigger_type_ending_select": "Select endings: ",
"follow_ups_modal_trigger_type_ending_warning": "No endings found in the survey!",
"follow_ups_modal_trigger_type_ending_warning": "Please select at least one ending or change the trigger type",
"follow_ups_modal_trigger_type_response": "Respondent completes survey",
"follow_ups_modal_updated_successfull_toast": "Follow-up updated and will be saved once you save the survey.",
"follow_ups_new": "New follow-up",
"follow_ups_upgrade_button_text": "Upgrade to enable follow-ups",
"form_styling": "Form styling",
@@ -1394,6 +1399,9 @@
"four_points": "4 points",
"heading": "Heading",
"hidden_field_added_successfully": "Hidden field added successfully",
"hidden_field_used_in_recall": "Hidden field \"{hiddenField}\" is being recalled in question {questionIndex}.",
"hidden_field_used_in_recall_ending_card": "Hidden field \"{hiddenField}\" is being recalled in Ending Card",
"hidden_field_used_in_recall_welcome": "Hidden field \"{hiddenField}\" is being recalled in Welcome card.",
"hide_advanced_settings": "Hide advanced settings",
"hide_back_button": "Hide 'Back' button",
"hide_back_button_description": "Do not display the back button in the survey",
@@ -1412,6 +1420,7 @@
"inner_text": "Inner Text",
"input_border_color": "Input border color",
"input_color": "Input color",
"insert_link": "Insert link",
"invalid_targeting": "Invalid targeting: Please check your audience filters",
"invalid_video_url_warning": "Please enter a valid YouTube, Vimeo, or Loom URL. We currently do not support other video hosting providers.",
"invalid_youtube_url": "Invalid YouTube URL",
@@ -1429,6 +1438,7 @@
"is_set": "Is set",
"is_skipped": "Is skipped",
"is_submitted": "Is submitted",
"italic": "Italic",
"jump_to_question": "Jump to question",
"keep_current_order": "Keep current order",
"keep_showing_while_conditions_match": "Keep showing while conditions match",
@@ -1455,6 +1465,7 @@
"no_images_found_for": "No images found for ''{query}\"",
"no_languages_found_add_first_one_to_get_started": "No languages found. Add the first one to get started.",
"no_option_found": "No option found",
"no_recall_items_found": "No recall items found ",
"no_variables_yet_add_first_one_below": "No variables yet. Add the first one below.",
"number": "Number",
"once_set_the_default_language_for_this_survey_can_only_be_changed_by_disabling_the_multi_language_option_and_deleting_all_translations": "Once set, the default language for this survey can only be changed by disabling the multi-language option and deleting all translations.",
@@ -1474,6 +1485,7 @@
"pin_can_only_contain_numbers": "PIN can only contain numbers.",
"pin_must_be_a_four_digit_number": "PIN must be a four digit number.",
"please_enter_a_file_extension": "Please enter a file extension.",
"please_enter_a_valid_url": "Please enter a valid URL (e.g., https://example.com)",
"please_set_a_survey_trigger": "Please set a survey trigger",
"please_specify": "Please specify",
"prevent_double_submission": "Prevent double submission",
@@ -1488,6 +1500,8 @@
"question_id_updated": "Question ID updated",
"question_used_in_logic": "This question is used in logic of question {questionIndex}.",
"question_used_in_quota": "This question is being used in \"{quotaName}\" quota",
"question_used_in_recall": "This question is being recalled in question {questionIndex}.",
"question_used_in_recall_ending_card": "This question is being recalled in Ending Card",
"quotas": {
"add_quota": "Add quota",
"change_quota_for_public_survey": "Change quota for public survey?",
@@ -1522,6 +1536,8 @@
"randomize_all": "Randomize all",
"randomize_all_except_last": "Randomize all except last",
"range": "Range",
"recall_data": "Recall data",
"recall_information_from": "Recall information from ...",
"recontact_options": "Recontact Options",
"redirect_thank_you_card": "Redirect thank you card",
"redirect_to_url": "Redirect to Url",
@@ -1599,6 +1615,7 @@
"trigger_survey_when_one_of_the_actions_is_fired": "Trigger survey when one of the actions is fired...",
"try_lollipop_or_mountain": "Try 'lollipop' or 'mountain'...",
"type_field_id": "Type field id",
"underline": "Underline",
"unlock_targeting_description": "Target specific user groups based on attributes or device information",
"unlock_targeting_title": "Unlock targeting with a higher plan",
"unsaved_changes_warning": "You have unsaved changes in your survey. Would you like to save them before leaving?",
@@ -1615,6 +1632,9 @@
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variable \"{variableName}\" is being used in \"{quotaName}\" quota",
"variable_name_is_already_taken_please_choose_another": "Variable name is already taken, please choose another.",
"variable_name_must_start_with_a_letter": "Variable name must start with a letter.",
"variable_used_in_recall": "Variable \"{variable}\" is being recalled in question {questionIndex}.",
"variable_used_in_recall_ending_card": "Variable {variable} is being recalled in Ending Card",
"variable_used_in_recall_welcome": "Variable \"{variable}\" is being recalled in Welcome Card.",
"verify_email_before_submission": "Verify email before submission",
"verify_email_before_submission_description": "Only let people with a real email respond.",
"wait": "Wait",
+28 -8
View File
@@ -262,7 +262,9 @@
"membership_not_found": "Abonnement non trouvé",
"metadata": "Métadonnées",
"minimum": "Min",
"mobile_overlay_text": "Formbricks n'est pas disponible pour les appareils avec des résolutions plus petites.",
"mobile_overlay_app_works_best_on_desktop": "Formbricks fonctionne mieux sur un écran plus grand. Pour gérer ou créer des sondages, passez à un autre appareil.",
"mobile_overlay_surveys_look_good": "Ne t'inquiète pas tes enquêtes sont superbes sur tous les appareils et tailles d'écran!",
"mobile_overlay_title": "Oups, écran minuscule détecté!",
"move_down": "Déplacer vers le bas",
"move_up": "Déplacer vers le haut",
"multiple_languages": "Plusieurs langues",
@@ -277,6 +279,7 @@
"no_result_found": "Aucun résultat trouvé",
"no_results": "Aucun résultat",
"no_surveys_found": "Aucun sondage trouvé.",
"none_of_the_above": "Aucun des éléments ci-dessus",
"not_authenticated": "Vous n'êtes pas authentifié pour effectuer cette action.",
"not_authorized": "Non autorisé",
"not_connected": "Non connecté",
@@ -750,7 +753,6 @@
},
"project": {
"api_keys": {
"access_control": "Contrôle d'accès",
"add_api_key": "Ajouter une clé API",
"api_key": "Clé API",
"api_key_copied_to_clipboard": "Clé API copiée dans le presse-papiers",
@@ -1202,12 +1204,12 @@
"add_description": "Ajouter une description",
"add_ending": "Ajouter une fin",
"add_ending_below": "Ajouter une fin ci-dessous",
"add_fallback": "Ajouter",
"add_fallback_placeholder": "Ajouter un espace réservé pour montrer si la question est ignorée :",
"add_fallback_placeholder": "Ajouter un espace réservé à afficher s'il n'y a pas de valeur à rappeler.",
"add_hidden_field_id": "Ajouter un champ caché ID",
"add_highlight_border": "Ajouter une bordure de surlignage",
"add_highlight_border_description": "Ajoutez une bordure extérieure à votre carte d'enquête.",
"add_logic": "Ajouter de la logique",
"add_none_of_the_above": "Ajouter \"Aucun des éléments ci-dessus\"",
"add_option": "Ajouter une option",
"add_other": "Ajouter \"Autre",
"add_photo_or_video": "Ajouter une photo ou une vidéo",
@@ -1240,6 +1242,7 @@
"automatically_mark_the_survey_as_complete_after": "Marquer automatiquement l'enquête comme terminée après",
"back_button_label": "Label du bouton \"Retour''",
"background_styling": "Style de fond",
"bold": "Gras",
"brand_color": "Couleur de marque",
"brightness": "Luminosité",
"button_label": "Label du bouton",
@@ -1312,7 +1315,6 @@
"days_before_showing_this_survey_again": "jours avant de montrer à nouveau cette enquête.",
"decide_how_often_people_can_answer_this_survey": "Décidez à quelle fréquence les gens peuvent répondre à cette enquête.",
"delete_choice": "Supprimer l'option",
"description": "Description",
"disable_the_visibility_of_survey_progress": "Désactiver la visibilité de la progression du sondage.",
"display_an_estimate_of_completion_time_for_survey": "Afficher une estimation du temps de complétion pour l'enquête.",
"display_number_of_responses_for_survey": "Afficher le nombre de réponses pour l'enquête",
@@ -1323,6 +1325,7 @@
"does_not_include_all_of": "n'inclut pas tout",
"does_not_include_one_of": "n'inclut pas un de",
"does_not_start_with": "Ne commence pas par",
"edit_link": "Modifier le lien",
"edit_recall": "Modifier le rappel",
"edit_translations": "Modifier les traductions {lang}",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permettre aux participants de changer la langue de l'enquête à tout moment pendant celle-ci.",
@@ -1333,13 +1336,13 @@
"ending_card_used_in_logic": "Cette carte de fin est utilisée dans la logique de la question '{'questionIndex'}'.",
"ending_used_in_quota": "Cette fin est utilisée dans le quota \"{quotaName}\"",
"ends_with": "Se termine par",
"enter_fallback_value": "Saisir une valeur de secours",
"equals": "Égal",
"equals_one_of": "Égal à l'un de",
"error_publishing_survey": "Une erreur est survenue lors de la publication de l'enquête.",
"error_saving_changes": "Erreur lors de l'enregistrement des modifications",
"even_after_they_submitted_a_response_e_g_feedback_box": "Même après avoir soumis une réponse (par exemple, la boîte de feedback)",
"everyone": "Tout le monde",
"fallback_for": "Solution de repli pour ",
"fallback_missing": "Fallback manquant",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} est utilisé dans la logique de la question {questionIndex}. Veuillez d'abord le supprimer de la logique.",
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "Le champ masqué \"{fieldId}\" est utilisé dans le quota \"{quotaName}\"",
@@ -1374,8 +1377,9 @@
"follow_ups_modal_action_subject_placeholder": "Objet de l'email",
"follow_ups_modal_action_to_description": "Adresse e-mail à laquelle envoyer l'e-mail",
"follow_ups_modal_action_to_label": "à",
"follow_ups_modal_action_to_warning": "Aucun champ d'email détecté dans l'enquête",
"follow_ups_modal_action_to_warning": "Aucune option valable trouvée pour l'envoi d'emails, veuillez ajouter des questions à texte libre / info-contact ou des champs cachés",
"follow_ups_modal_create_heading": "Créer un nouveau suivi",
"follow_ups_modal_created_successfull_toast": "\"Suivi créé et sera enregistré une fois que vous sauvegarderez le sondage.\"",
"follow_ups_modal_edit_heading": "Modifier ce suivi",
"follow_ups_modal_edit_no_id": "Aucun identifiant de suivi d'enquête fourni, impossible de mettre à jour le suivi de l'enquête.",
"follow_ups_modal_name_label": "Nom de suivi",
@@ -1385,8 +1389,9 @@
"follow_ups_modal_trigger_label": "Déclencheur",
"follow_ups_modal_trigger_type_ending": "Le répondant voit une fin spécifique",
"follow_ups_modal_trigger_type_ending_select": "Choisir des fins :",
"follow_ups_modal_trigger_type_ending_warning": "Aucune fin trouvée dans l'enquête !",
"follow_ups_modal_trigger_type_ending_warning": "Veuillez sélectionner au moins une fin ou changer le type de déclencheur.",
"follow_ups_modal_trigger_type_response": "Le répondant complète l'enquête",
"follow_ups_modal_updated_successfull_toast": "\"Suivi mis à jour et sera enregistré une fois que vous sauvegarderez le sondage.\"",
"follow_ups_new": "Nouveau suivi",
"follow_ups_upgrade_button_text": "Passez à la version supérieure pour activer les relances",
"form_styling": "Style de formulaire",
@@ -1394,6 +1399,9 @@
"four_points": "4 points",
"heading": "En-tête",
"hidden_field_added_successfully": "Champ caché ajouté avec succès",
"hidden_field_used_in_recall": "Le champ caché \"{hiddenField}\" est rappelé dans la question {questionIndex}.",
"hidden_field_used_in_recall_ending_card": "Le champ caché \"{hiddenField}\" est rappelé dans la carte de fin.",
"hidden_field_used_in_recall_welcome": "Le champ caché \"{hiddenField}\" est rappelé dans la carte de bienvenue.",
"hide_advanced_settings": "Cacher les paramètres avancés",
"hide_back_button": "Masquer le bouton 'Retour'",
"hide_back_button_description": "Ne pas afficher le bouton retour dans l'enquête",
@@ -1412,6 +1420,7 @@
"inner_text": "Texte interne",
"input_border_color": "Couleur de bordure d'entrée",
"input_color": "Couleur d'entrée",
"insert_link": "Insérer un lien",
"invalid_targeting": "Ciblage invalide : Veuillez vérifier vos filtres d'audience",
"invalid_video_url_warning": "Merci d'entrer une URL YouTube, Vimeo ou Loom valide. Les autres plateformes vidéo ne sont pas encore supportées.",
"invalid_youtube_url": "URL YouTube invalide",
@@ -1429,6 +1438,7 @@
"is_set": "Est défini",
"is_skipped": "Est ignoré",
"is_submitted": "Est soumis",
"italic": "Italique",
"jump_to_question": "Passer à la question",
"keep_current_order": "Conserver la commande actuelle",
"keep_showing_while_conditions_match": "Continuer à afficher tant que les conditions correspondent",
@@ -1455,6 +1465,7 @@
"no_images_found_for": "Aucune image trouvée pour ''{query}\"",
"no_languages_found_add_first_one_to_get_started": "Aucune langue trouvée. Ajoutez la première pour commencer.",
"no_option_found": "Aucune option trouvée",
"no_recall_items_found": "Aucun élément de rappel trouvé",
"no_variables_yet_add_first_one_below": "Aucune variable pour le moment. Ajoutez la première ci-dessous.",
"number": "Numéro",
"once_set_the_default_language_for_this_survey_can_only_be_changed_by_disabling_the_multi_language_option_and_deleting_all_translations": "Une fois défini, la langue par défaut de cette enquête ne peut être changée qu'en désactivant l'option multilingue et en supprimant toutes les traductions.",
@@ -1474,6 +1485,7 @@
"pin_can_only_contain_numbers": "Le code PIN ne peut contenir que des chiffres.",
"pin_must_be_a_four_digit_number": "Le code PIN doit être un numéro à quatre chiffres.",
"please_enter_a_file_extension": "Veuillez entrer une extension de fichier.",
"please_enter_a_valid_url": "Veuillez entrer une URL valide (par exemple, https://example.com)",
"please_set_a_survey_trigger": "Veuillez définir un déclencheur d'enquête.",
"please_specify": "Veuillez préciser",
"prevent_double_submission": "Empêcher la double soumission",
@@ -1488,6 +1500,8 @@
"question_id_updated": "ID de la question mis à jour",
"question_used_in_logic": "Cette question est utilisée dans la logique de la question '{'questionIndex'}'.",
"question_used_in_quota": "Cette question est utilisée dans le quota \"{quotaName}\"",
"question_used_in_recall": "Cette question est rappelée dans la question {questionIndex}.",
"question_used_in_recall_ending_card": "Cette question est rappelée dans la carte de fin.",
"quotas": {
"add_quota": "Ajouter un quota",
"change_quota_for_public_survey": "Changer le quota pour le sondage public ?",
@@ -1522,6 +1536,8 @@
"randomize_all": "Randomiser tout",
"randomize_all_except_last": "Randomiser tout sauf le dernier",
"range": "Plage",
"recall_data": "Rappel des données",
"recall_information_from": "Rappeler les informations de ...",
"recontact_options": "Options de recontact",
"redirect_thank_you_card": "Carte de remerciement de redirection",
"redirect_to_url": "Rediriger vers l'URL",
@@ -1599,6 +1615,7 @@
"trigger_survey_when_one_of_the_actions_is_fired": "Déclencher l'enquête lorsqu'une des actions est déclenchée...",
"try_lollipop_or_mountain": "Essayez 'sucette' ou 'montagne'...",
"type_field_id": "Identifiant de champ de type",
"underline": "Souligner",
"unlock_targeting_description": "Cibler des groupes d'utilisateurs spécifiques en fonction des attributs ou des informations sur l'appareil",
"unlock_targeting_title": "Débloquez le ciblage avec un plan supérieur.",
"unsaved_changes_warning": "Vous avez des modifications non enregistrées dans votre enquête. Souhaitez-vous les enregistrer avant de partir ?",
@@ -1615,6 +1632,9 @@
"variable_is_used_in_quota_please_remove_it_from_quota_first": "La variable \"{variableName}\" est utilisée dans le quota \"{quotaName}\"",
"variable_name_is_already_taken_please_choose_another": "Le nom de la variable est déjà pris, veuillez en choisir un autre.",
"variable_name_must_start_with_a_letter": "Le nom de la variable doit commencer par une lettre.",
"variable_used_in_recall": "La variable \"{variable}\" est rappelée dans la question {questionIndex}.",
"variable_used_in_recall_ending_card": "La variable {variable} est rappelée dans la carte de fin.",
"variable_used_in_recall_welcome": "La variable \"{variable}\" est rappelée dans la carte de bienvenue.",
"verify_email_before_submission": "Vérifiez l'email avant la soumission",
"verify_email_before_submission_description": "Ne laissez répondre que les personnes ayant une véritable adresse e-mail.",
"wait": "Attendre",
+27 -7
View File
@@ -262,7 +262,9 @@
"membership_not_found": "メンバーシップが見つかりません",
"metadata": "メタデータ",
"minimum": "最小",
"mobile_overlay_text": "Formbricksは、解像度の小さいデバイスでは利用できません。",
"mobile_overlay_app_works_best_on_desktop": "Formbricks は より 大きな 画面 で最適に 作動します。 フォーム を 管理または 構築する には、 別の デバイス に 切り替える 必要が あります。",
"mobile_overlay_surveys_look_good": "ご安心ください - お使い の デバイス や 画面 サイズ に 関係なく、 フォーム は 素晴らしく 見えます!",
"mobile_overlay_title": "おっと、 小さな 画面 が 検出されました!",
"move_down": "下に移動",
"move_up": "上に移動",
"multiple_languages": "多言語",
@@ -277,6 +279,7 @@
"no_result_found": "結果が見つかりません",
"no_results": "結果なし",
"no_surveys_found": "フォームが見つかりません。",
"none_of_the_above": "いずれも該当しません",
"not_authenticated": "このアクションを実行するための認証がされていません。",
"not_authorized": "権限がありません",
"not_connected": "未接続",
@@ -750,7 +753,6 @@
},
"project": {
"api_keys": {
"access_control": "アクセス制御",
"add_api_key": "APIキーを追加",
"api_key": "APIキー",
"api_key_copied_to_clipboard": "APIキーをクリップボードにコピーしました",
@@ -1202,12 +1204,12 @@
"add_description": "説明を追加",
"add_ending": "終了を追加",
"add_ending_below": "以下に終了を追加",
"add_fallback": "追加",
"add_fallback_placeholder": "質問がスキップされた場合に表示するプレースホルダーを追加:",
"add_hidden_field_id": "非表示フィールドIDを追加",
"add_highlight_border": "ハイライトボーダーを追加",
"add_highlight_border_description": "フォームカードに外側のボーダーを追加します。",
"add_logic": "ロジックを追加",
"add_none_of_the_above": "\"いずれも該当しません\" を追加",
"add_option": "オプションを追加",
"add_other": "「その他」を追加",
"add_photo_or_video": "写真または動画を追加",
@@ -1240,6 +1242,7 @@
"automatically_mark_the_survey_as_complete_after": "フォームを自動的に完了としてマークする",
"back_button_label": "「戻る」ボタンのラベル",
"background_styling": "背景のスタイル",
"bold": "太字",
"brand_color": "ブランドカラー",
"brightness": "明るさ",
"button_label": "ボタンのラベル",
@@ -1312,7 +1315,6 @@
"days_before_showing_this_survey_again": "日後にこのフォームを再度表示します。",
"decide_how_often_people_can_answer_this_survey": "このフォームに人々が何回回答できるかを決定します。",
"delete_choice": "選択肢を削除",
"description": "説明",
"disable_the_visibility_of_survey_progress": "フォームの進捗状況の表示を無効にする。",
"display_an_estimate_of_completion_time_for_survey": "フォームの完了時間の目安を表示",
"display_number_of_responses_for_survey": "フォームの回答数を表示",
@@ -1323,6 +1325,7 @@
"does_not_include_all_of": "のすべてを含まない",
"does_not_include_one_of": "のいずれも含まない",
"does_not_start_with": "で始まらない",
"edit_link": "編集 リンク",
"edit_recall": "リコールを編集",
"edit_translations": "{lang} 翻訳を編集",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "回答者がフォームの途中でいつでも言語を切り替えられるようにします。",
@@ -1333,13 +1336,13 @@
"ending_card_used_in_logic": "この終了カードは質問 {questionIndex} のロジックで使用されています。",
"ending_used_in_quota": "この 終了 は \"{quotaName}\" クォータ で使用されています",
"ends_with": "で終わる",
"enter_fallback_value": "フォールバック値を入力",
"equals": "と等しい",
"equals_one_of": "のいずれかと等しい",
"error_publishing_survey": "フォームの公開中にエラーが発生しました。",
"error_saving_changes": "変更の保存中にエラーが発生しました",
"even_after_they_submitted_a_response_e_g_feedback_box": "回答を送信した後でも(例:フィードバックボックス)",
"everyone": "全員",
"fallback_for": "のフォールバック",
"fallback_missing": "フォールバックがありません",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} は質問 {questionIndex} のロジックで使用されています。まず、ロジックから削除してください。",
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "隠しフィールド \"{fieldId}\" は \"{quotaName}\" クォータ で使用されています",
@@ -1374,8 +1377,9 @@
"follow_ups_modal_action_subject_placeholder": "メールの件名",
"follow_ups_modal_action_to_description": "メールを送信するメールアドレス",
"follow_ups_modal_action_to_label": "宛先",
"follow_ups_modal_action_to_warning": "フォームでメールアドレスのフィールドが検出されていません",
"follow_ups_modal_action_to_warning": "メールを送信するための有効な オプション が見つかりません 、いくつかの オープン テキスト / 連絡先 情報の質問 または 非表示 フィールドを追加してください",
"follow_ups_modal_create_heading": "新しいフォローアップを作成",
"follow_ups_modal_created_successfull_toast": "フォローアップ が 作成され、 アンケートを 保存すると保存されます。",
"follow_ups_modal_edit_heading": "このフォローアップを編集",
"follow_ups_modal_edit_no_id": "フォームのフォローアップIDが提供されていません。フォームのフォローアップを更新できません",
"follow_ups_modal_name_label": "フォローアップ名",
@@ -1385,8 +1389,9 @@
"follow_ups_modal_trigger_label": "トリガー",
"follow_ups_modal_trigger_type_ending": "回答者が特定の終了画面を見たとき",
"follow_ups_modal_trigger_type_ending_select": "終了を選択:",
"follow_ups_modal_trigger_type_ending_warning": "フォームに終了画面が見つかりません!",
"follow_ups_modal_trigger_type_ending_warning": "少なくとも1つの終了を選択するか、 トリガー タイプを変更してください",
"follow_ups_modal_trigger_type_response": "回答者がフォームを完了したとき",
"follow_ups_modal_updated_successfull_toast": "フォローアップ が 更新され、 アンケートを 保存すると保存されます。",
"follow_ups_new": "新しいフォローアップ",
"follow_ups_upgrade_button_text": "フォローアップを有効にするためにアップグレード",
"form_styling": "フォームのスタイル",
@@ -1394,6 +1399,9 @@
"four_points": "4点",
"heading": "見出し",
"hidden_field_added_successfully": "非表示フィールドを正常に追加しました",
"hidden_field_used_in_recall": "隠し フィールド \"{hiddenField}\" が 質問 {questionIndex} で 呼び出され て います 。",
"hidden_field_used_in_recall_ending_card": "隠し フィールド \"{hiddenField}\" が エンディング カード で 呼び出され て います。",
"hidden_field_used_in_recall_welcome": "隠し フィールド \"{hiddenField}\" が ウェルカム カード で 呼び出され て います。",
"hide_advanced_settings": "詳細設定を非表示",
"hide_back_button": "「戻る」ボタンを非表示",
"hide_back_button_description": "フォームに「戻る」ボタンを表示しない",
@@ -1412,6 +1420,7 @@
"inner_text": "内部テキスト",
"input_border_color": "入力の枠線の色",
"input_color": "入力の色",
"insert_link": "リンク を 挿入",
"invalid_targeting": "無効なターゲティング: オーディエンスフィルターを確認してください",
"invalid_video_url_warning": "有効なYouTube、Vimeo、またはLoomのURLを入力してください。現在、他の動画ホスティングプロバイダーはサポートしていません。",
"invalid_youtube_url": "無効なYouTube URL",
@@ -1429,6 +1438,7 @@
"is_set": "設定されている",
"is_skipped": "スキップ済み",
"is_submitted": "送信済み",
"italic": "イタリック",
"jump_to_question": "質問にジャンプ",
"keep_current_order": "現在の順序を維持",
"keep_showing_while_conditions_match": "条件が一致する間、表示し続ける",
@@ -1455,6 +1465,7 @@
"no_images_found_for": "''{query}'' の画像が見つかりません",
"no_languages_found_add_first_one_to_get_started": "言語が見つかりません。始めるには、最初のものを追加してください。",
"no_option_found": "オプションが見つかりません",
"no_recall_items_found": "リコールアイテムが見つかりません ",
"no_variables_yet_add_first_one_below": "まだ変数がありません。以下で最初のものを追加してください。",
"number": "数値",
"once_set_the_default_language_for_this_survey_can_only_be_changed_by_disabling_the_multi_language_option_and_deleting_all_translations": "一度設定すると、このフォームのデフォルト言語は、多言語オプションを無効にしてすべての翻訳を削除することによってのみ変更できます。",
@@ -1474,6 +1485,7 @@
"pin_can_only_contain_numbers": "PINは数字のみでなければなりません。",
"pin_must_be_a_four_digit_number": "PINは4桁の数字でなければなりません。",
"please_enter_a_file_extension": "ファイル拡張子を入力してください。",
"please_enter_a_valid_url": "有効な URL を入力してください (例:https://example.com)",
"please_set_a_survey_trigger": "フォームのトリガーを設定してください",
"please_specify": "具体的に指定してください",
"prevent_double_submission": "二重送信を防ぐ",
@@ -1488,6 +1500,8 @@
"question_id_updated": "質問IDを更新しました",
"question_used_in_logic": "この質問は質問 {questionIndex} のロジックで使用されています。",
"question_used_in_quota": "この 質問 は \"{quotaName}\" の クオータ に使用されています",
"question_used_in_recall": "この 質問 は 質問 {questionIndex} で 呼び出され て います 。",
"question_used_in_recall_ending_card": "この 質問 は エンディング カード で 呼び出され て います。",
"quotas": {
"add_quota": "クォータを追加",
"change_quota_for_public_survey": "パブリック フォームのクォータを変更しますか?",
@@ -1522,6 +1536,8 @@
"randomize_all": "すべてをランダム化",
"randomize_all_except_last": "最後を除くすべてをランダム化",
"range": "範囲",
"recall_data": "データを呼び出す",
"recall_information_from": "... からの情報を呼び戻す",
"recontact_options": "再接触オプション",
"redirect_thank_you_card": "サンクスクカードをリダイレクト",
"redirect_to_url": "URLにリダイレクト",
@@ -1599,6 +1615,7 @@
"trigger_survey_when_one_of_the_actions_is_fired": "以下のアクションのいずれかが発火したときにフォームをトリガーします...",
"try_lollipop_or_mountain": "「lollipop」や「mountain」を試してみてください...",
"type_field_id": "フィールドIDを入力",
"underline": "下線",
"unlock_targeting_description": "属性またはデバイス情報に基づいて、特定のユーザーグループをターゲットにします",
"unlock_targeting_title": "上位プランでターゲティングをアンロック",
"unsaved_changes_warning": "フォームに未保存の変更があります。離れる前に保存しますか?",
@@ -1615,6 +1632,9 @@
"variable_is_used_in_quota_please_remove_it_from_quota_first": "変数 \"{variableName}\" は \"{quotaName}\" クォータ で使用されています",
"variable_name_is_already_taken_please_choose_another": "変数名はすでに使用されています。別の名前を選択してください。",
"variable_name_must_start_with_a_letter": "変数名はアルファベットで始まらなければなりません。",
"variable_used_in_recall": "変数 \"{variable}\" が 質問 {questionIndex} で 呼び出され て います 。",
"variable_used_in_recall_ending_card": "変数 {variable} が エンディング カード で 呼び出され て います。",
"variable_used_in_recall_welcome": "変数 \"{variable}\" が ウェルカム カード で 呼び出され て います。",
"verify_email_before_submission": "送信前にメールアドレスを認証",
"verify_email_before_submission_description": "有効なメールアドレスを持つ人のみが回答できるようにする",
"wait": "待つ",
+27 -7
View File
@@ -262,7 +262,9 @@
"membership_not_found": "Assinatura não encontrada",
"metadata": "metadados",
"minimum": "Mínimo",
"mobile_overlay_text": "O Formbricks não está disponível para dispositivos com resoluções menores.",
"mobile_overlay_app_works_best_on_desktop": "Formbricks funciona melhor em uma tela maior. Para gerenciar ou criar pesquisas, mude para outro dispositivo.",
"mobile_overlay_surveys_look_good": "Não se preocupe suas pesquisas ficam ótimas em qualquer dispositivo e tamanho de tela!",
"mobile_overlay_title": "Eita, tela pequena detectada!",
"move_down": "Descer",
"move_up": "Subir",
"multiple_languages": "Vários idiomas",
@@ -277,6 +279,7 @@
"no_result_found": "Nenhum resultado encontrado",
"no_results": "Nenhum resultado",
"no_surveys_found": "Não foram encontradas pesquisas.",
"none_of_the_above": "Nenhuma das opções acima",
"not_authenticated": "Você não está autenticado para realizar essa ação.",
"not_authorized": "Não autorizado",
"not_connected": "Desconectado",
@@ -750,7 +753,6 @@
},
"project": {
"api_keys": {
"access_control": "Controle de Acesso",
"add_api_key": "Adicionar Chave API",
"api_key": "Chave de API",
"api_key_copied_to_clipboard": "Chave da API copiada para a área de transferência",
@@ -1202,12 +1204,12 @@
"add_description": "Adicionar Descrição",
"add_ending": "Adicionar final",
"add_ending_below": "Adicione o final abaixo",
"add_fallback": "Adicionar",
"add_fallback_placeholder": "Adicionar um texto padrão para mostrar se a pergunta for ignorada:",
"add_hidden_field_id": "Adicionar campo oculto ID",
"add_highlight_border": "Adicionar borda de destaque",
"add_highlight_border_description": "Adicione uma borda externa ao seu cartão de pesquisa.",
"add_logic": "Adicionar lógica",
"add_none_of_the_above": "Adicionar \"Nenhuma das opções acima\"",
"add_option": "Adicionar opção",
"add_other": "Adicionar \"Outro",
"add_photo_or_video": "Adicionar foto ou video",
@@ -1240,6 +1242,7 @@
"automatically_mark_the_survey_as_complete_after": "Marcar automaticamente a pesquisa como concluída após",
"back_button_label": "Voltar",
"background_styling": "Estilo de Fundo",
"bold": "Negrito",
"brand_color": "Cor da marca",
"brightness": "brilho",
"button_label": "Rótulo do Botão",
@@ -1312,7 +1315,6 @@
"days_before_showing_this_survey_again": "dias antes de mostrar essa pesquisa de novo.",
"decide_how_often_people_can_answer_this_survey": "Decida com que frequência as pessoas podem responder a essa pesquisa.",
"delete_choice": "Deletar opção",
"description": "Descrição",
"disable_the_visibility_of_survey_progress": "Desativar a visibilidade do progresso da pesquisa.",
"display_an_estimate_of_completion_time_for_survey": "Mostrar uma estimativa de tempo de conclusão da pesquisa",
"display_number_of_responses_for_survey": "Mostrar número de respostas da pesquisa",
@@ -1323,6 +1325,7 @@
"does_not_include_all_of": "Não inclui todos de",
"does_not_include_one_of": "Não inclui um de",
"does_not_start_with": "Não começa com",
"edit_link": "Editar link",
"edit_recall": "Editar Lembrete",
"edit_translations": "Editar traduções de {lang}",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permitir que os participantes mudem o idioma da pesquisa a qualquer momento durante a pesquisa.",
@@ -1333,13 +1336,13 @@
"ending_card_used_in_logic": "Esse cartão de encerramento é usado na lógica da pergunta {questionIndex}.",
"ending_used_in_quota": "Este final está sendo usado na cota \"{quotaName}\"",
"ends_with": "Termina com",
"enter_fallback_value": "Insira o valor de fallback",
"equals": "Igual",
"equals_one_of": "É igual a um de",
"error_publishing_survey": "Ocorreu um erro ao publicar a pesquisa.",
"error_saving_changes": "Erro ao salvar alterações",
"even_after_they_submitted_a_response_e_g_feedback_box": "Mesmo depois de eles enviarem uma resposta (por exemplo, Caixa de Feedback)",
"everyone": "Todo mundo",
"fallback_for": "Alternativa para",
"fallback_missing": "Faltando alternativa",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} é usado na lógica da pergunta {questionIndex}. Por favor, remova-o da lógica primeiro.",
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "Campo oculto \"{fieldId}\" está sendo usado na cota \"{quotaName}\"",
@@ -1374,8 +1377,9 @@
"follow_ups_modal_action_subject_placeholder": "Assunto do e-mail",
"follow_ups_modal_action_to_description": "Endereço de e-mail para enviar o e-mail para",
"follow_ups_modal_action_to_label": "Para",
"follow_ups_modal_action_to_warning": "Nenhum campo de e-mail detectado na pesquisa",
"follow_ups_modal_action_to_warning": "Nenhuma opção válida encontrada para envio de emails, por favor, adicione algumas perguntas de texto livre / informações de contato ou campos ocultos",
"follow_ups_modal_create_heading": "Criar um novo acompanhamento",
"follow_ups_modal_created_successfull_toast": "Acompanhamento criado e será salvo assim que você salvar a pesquisa.",
"follow_ups_modal_edit_heading": "Editar este acompanhamento",
"follow_ups_modal_edit_no_id": "Nenhum ID de acompanhamento da pesquisa fornecido, não é possível atualizar o acompanhamento da pesquisa",
"follow_ups_modal_name_label": "Nome do acompanhamento",
@@ -1385,8 +1389,9 @@
"follow_ups_modal_trigger_label": "Gatilho",
"follow_ups_modal_trigger_type_ending": "Respondente vê um final específico",
"follow_ups_modal_trigger_type_ending_select": "Selecione os finais: ",
"follow_ups_modal_trigger_type_ending_warning": "Nenhum final encontrado na pesquisa!",
"follow_ups_modal_trigger_type_ending_warning": "Por favor, selecione pelo menos um encerramento ou altere o tipo de gatilho",
"follow_ups_modal_trigger_type_response": "Respondente completa a pesquisa",
"follow_ups_modal_updated_successfull_toast": "Acompanhamento atualizado e será salvo assim que você salvar a pesquisa.",
"follow_ups_new": "Novo acompanhamento",
"follow_ups_upgrade_button_text": "Atualize para habilitar os Acompanhamentos",
"form_styling": "Estilização de Formulários",
@@ -1394,6 +1399,9 @@
"four_points": "4 pontos",
"heading": "Título",
"hidden_field_added_successfully": "Campo oculto adicionado com sucesso",
"hidden_field_used_in_recall": "Campo oculto \"{hiddenField}\" está sendo recordado na pergunta {questionIndex}.",
"hidden_field_used_in_recall_ending_card": "Campo oculto \"{hiddenField}\" está sendo recordado no card de Encerramento.",
"hidden_field_used_in_recall_welcome": "Campo oculto \"{hiddenField}\" está sendo recordado no card de Boas-Vindas.",
"hide_advanced_settings": "Ocultar configurações avançadas",
"hide_back_button": "Ocultar botão 'Voltar'",
"hide_back_button_description": "Não exibir o botão de voltar na pesquisa",
@@ -1412,6 +1420,7 @@
"inner_text": "Texto Interno",
"input_border_color": "Cor da borda de entrada",
"input_color": "Cor de entrada",
"insert_link": "Inserir link",
"invalid_targeting": "Segmentação inválida: Por favor, verifique os filtros do seu público",
"invalid_video_url_warning": "Por favor, insira uma URL válida do YouTube, Vimeo ou Loom. No momento, não suportamos outros provedores de vídeo.",
"invalid_youtube_url": "URL do YouTube inválida",
@@ -1429,6 +1438,7 @@
"is_set": "Está definido",
"is_skipped": "é pulado",
"is_submitted": "é submetido",
"italic": "Itálico",
"jump_to_question": "Pular para a pergunta",
"keep_current_order": "Manter pedido atual",
"keep_showing_while_conditions_match": "Continue mostrando enquanto as condições corresponderem",
@@ -1455,6 +1465,7 @@
"no_images_found_for": "Nenhuma imagem encontrada para ''{query}\"",
"no_languages_found_add_first_one_to_get_started": "Nenhum idioma encontrado. Adicione o primeiro para começar.",
"no_option_found": "Nenhuma opção encontrada",
"no_recall_items_found": "Nenhum item de recordação encontrado",
"no_variables_yet_add_first_one_below": "Ainda não há variáveis. Adicione a primeira abaixo.",
"number": "Número",
"once_set_the_default_language_for_this_survey_can_only_be_changed_by_disabling_the_multi_language_option_and_deleting_all_translations": "Depois de definido, o idioma padrão desta pesquisa só pode ser alterado desativando a opção de vários idiomas e excluindo todas as traduções.",
@@ -1474,6 +1485,7 @@
"pin_can_only_contain_numbers": "O PIN só pode conter números.",
"pin_must_be_a_four_digit_number": "O PIN deve ser um número de quatro dígitos.",
"please_enter_a_file_extension": "Por favor, insira uma extensão de arquivo.",
"please_enter_a_valid_url": "Por favor, insira uma URL válida (ex.: https://example.com)",
"please_set_a_survey_trigger": "Por favor, configure um gatilho para a pesquisa",
"please_specify": "Por favor, especifique",
"prevent_double_submission": "Evitar envio duplicado",
@@ -1488,6 +1500,8 @@
"question_id_updated": "ID da pergunta atualizado",
"question_used_in_logic": "Essa pergunta é usada na lógica da pergunta {questionIndex}.",
"question_used_in_quota": "Esta questão está sendo usada na cota \"{quotaName}\"",
"question_used_in_recall": "Esta pergunta está sendo recordada na pergunta {questionIndex}.",
"question_used_in_recall_ending_card": "Esta pergunta está sendo recordada no card de Encerramento",
"quotas": {
"add_quota": "Adicionar cota",
"change_quota_for_public_survey": "Alterar cota para pesquisa pública?",
@@ -1522,6 +1536,8 @@
"randomize_all": "Randomizar tudo",
"randomize_all_except_last": "Randomizar tudo, exceto o último",
"range": "alcance",
"recall_data": "Lembrar dados",
"recall_information_from": "Recuperar informações de ...",
"recontact_options": "Opções de Recontato",
"redirect_thank_you_card": "Redirecionar cartão de agradecimento",
"redirect_to_url": "Redirecionar para URL",
@@ -1599,6 +1615,7 @@
"trigger_survey_when_one_of_the_actions_is_fired": "Disparar pesquisa quando uma das ações for executada...",
"try_lollipop_or_mountain": "Tenta 'pirulito' ou 'montanha'...",
"type_field_id": "Digite o id do campo",
"underline": "Sublinhar",
"unlock_targeting_description": "Direcione grupos específicos de usuários com base em atributos ou informações do dispositivo",
"unlock_targeting_title": "Desbloqueie o direcionamento com um plano superior",
"unsaved_changes_warning": "Você tem alterações não salvas na sua pesquisa. Quer salvar antes de sair?",
@@ -1615,6 +1632,9 @@
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variável \"{variableName}\" está sendo usada na cota \"{quotaName}\"",
"variable_name_is_already_taken_please_choose_another": "O nome da variável já está em uso, por favor escolha outro.",
"variable_name_must_start_with_a_letter": "O nome da variável deve começar com uma letra.",
"variable_used_in_recall": "Variável \"{variable}\" está sendo recordada na pergunta {questionIndex}.",
"variable_used_in_recall_ending_card": "Variável {variable} está sendo recordada no card de Encerramento",
"variable_used_in_recall_welcome": "Variável \"{variable}\" está sendo recordada no Card de Boas-Vindas.",
"verify_email_before_submission": "Verifique o e-mail antes de enviar",
"verify_email_before_submission_description": "Deixe só quem tem um email real responder.",
"wait": "Espera",
+30 -10
View File
@@ -262,7 +262,9 @@
"membership_not_found": "Associação não encontrada",
"metadata": "Metadados",
"minimum": "Mínimo",
"mobile_overlay_text": "O Formbricks não está disponível para dispositivos com resoluções menores.",
"mobile_overlay_app_works_best_on_desktop": "Formbricks funciona melhor num ecrã maior. Para gerir ou criar inquéritos, mude de dispositivo.",
"mobile_overlay_surveys_look_good": "Não se preocupe os seus inquéritos têm uma ótima aparência em todos os dispositivos e tamanhos de ecrã!",
"mobile_overlay_title": "Oops, ecrã pequeno detectado!",
"move_down": "Mover para baixo",
"move_up": "Mover para cima",
"multiple_languages": "Várias línguas",
@@ -277,6 +279,7 @@
"no_result_found": "Nenhum resultado encontrado",
"no_results": "Nenhum resultado",
"no_surveys_found": "Nenhum inquérito encontrado.",
"none_of_the_above": "Nenhuma das opções acima",
"not_authenticated": "Não está autenticado para realizar esta ação.",
"not_authorized": "Não autorizado",
"not_connected": "Não Conectado",
@@ -750,7 +753,6 @@
},
"project": {
"api_keys": {
"access_control": "Controlo de Acesso",
"add_api_key": "Adicionar Chave API",
"api_key": "Chave API",
"api_key_copied_to_clipboard": "Chave API copiada para a área de transferência",
@@ -1202,12 +1204,12 @@
"add_description": "Adicionar descrição",
"add_ending": "Adicionar encerramento",
"add_ending_below": "Adicionar encerramento abaixo",
"add_fallback": "Adicionar",
"add_fallback_placeholder": "Adicionar um espaço reservado para mostrar se a pergunta for ignorada:",
"add_fallback_placeholder": "Adicionar um espaço reservado para mostrar se não houver valor para recordar.",
"add_hidden_field_id": "Adicionar ID do campo oculto",
"add_highlight_border": "Adicionar borda de destaque",
"add_highlight_border_description": "Adicione uma borda externa ao seu cartão de inquérito.",
"add_logic": "Adicionar lógica",
"add_none_of_the_above": "Adicionar \"Nenhuma das Opções Acima\"",
"add_option": "Adicionar opção",
"add_other": "Adicionar \"Outro\"",
"add_photo_or_video": "Adicionar foto ou vídeo",
@@ -1240,6 +1242,7 @@
"automatically_mark_the_survey_as_complete_after": "Marcar automaticamente o inquérito como concluído após",
"back_button_label": "Rótulo do botão \"Voltar\"",
"background_styling": "Estilo de Fundo",
"bold": "Negrito",
"brand_color": "Cor da marca",
"brightness": "Brilho",
"button_label": "Rótulo do botão",
@@ -1300,8 +1303,8 @@
"contains": "Contém",
"continue_to_settings": "Continuar para Definições",
"control_which_file_types_can_be_uploaded": "Controlar quais tipos de ficheiros podem ser carregados.",
"convert_to_multiple_choice": "Converter para Escolha Múltipla",
"convert_to_single_choice": "Converter para Escolha Única",
"convert_to_multiple_choice": "Converter para Seleção Múltipla",
"convert_to_single_choice": "Converter para Seleção Única",
"country": "País",
"create_group": "Criar grupo",
"create_your_own_survey": "Crie o seu próprio inquérito",
@@ -1312,7 +1315,6 @@
"days_before_showing_this_survey_again": "dias antes de mostrar este inquérito novamente.",
"decide_how_often_people_can_answer_this_survey": "Decida com que frequência as pessoas podem responder a este inquérito.",
"delete_choice": "Eliminar escolha",
"description": "Descrição",
"disable_the_visibility_of_survey_progress": "Desativar a visibilidade do progresso da pesquisa.",
"display_an_estimate_of_completion_time_for_survey": "Mostrar uma estimativa do tempo de conclusão do inquérito",
"display_number_of_responses_for_survey": "Mostrar número de respostas do inquérito",
@@ -1323,6 +1325,7 @@
"does_not_include_all_of": "Não inclui todos de",
"does_not_include_one_of": "Não inclui um de",
"does_not_start_with": "Não começa com",
"edit_link": "Editar link",
"edit_recall": "Editar Lembrete",
"edit_translations": "Editar traduções {lang}",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permitir aos participantes mudar a língua do inquérito a qualquer momento durante o inquérito.",
@@ -1333,13 +1336,13 @@
"ending_card_used_in_logic": "Este cartão final é usado na lógica da pergunta {questionIndex}.",
"ending_used_in_quota": "Este final está a ser usado na quota \"{quotaName}\"",
"ends_with": "Termina com",
"enter_fallback_value": "Inserir valor de substituição",
"equals": "Igual",
"equals_one_of": "Igual a um de",
"error_publishing_survey": "Ocorreu um erro ao publicar o questionário.",
"error_saving_changes": "Erro ao guardar alterações",
"even_after_they_submitted_a_response_e_g_feedback_box": "Mesmo depois de terem enviado uma resposta (por exemplo, Caixa de Feedback)",
"everyone": "Todos",
"fallback_for": "Alternativa para ",
"fallback_missing": "Substituição em falta",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} é usado na lógica da pergunta {questionIndex}. Por favor, remova-o da lógica primeiro.",
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "Campo oculto \"{fieldId}\" está a ser usado na quota \"{quotaName}\"",
@@ -1374,8 +1377,9 @@
"follow_ups_modal_action_subject_placeholder": "Assunto do email",
"follow_ups_modal_action_to_description": "Endereço de email para enviar o email",
"follow_ups_modal_action_to_label": "Para",
"follow_ups_modal_action_to_warning": "Nenhum campo de email detetado no inquérito",
"follow_ups_modal_action_to_warning": "Não foram encontradas opções válidas para envio de emails, por favor adicione algumas perguntas de texto livre / informações de contato ou campos escondidos",
"follow_ups_modal_create_heading": "Criar um novo acompanhamento",
"follow_ups_modal_created_successfull_toast": "Seguimento criado e será guardado assim que guardar o questionário.",
"follow_ups_modal_edit_heading": "Editar este acompanhamento",
"follow_ups_modal_edit_no_id": "Nenhum ID de acompanhamento do inquérito fornecido, não é possível atualizar o acompanhamento do inquérito",
"follow_ups_modal_name_label": "Nome do acompanhamento",
@@ -1385,8 +1389,9 @@
"follow_ups_modal_trigger_label": "Desencadeador",
"follow_ups_modal_trigger_type_ending": "O respondente vê um final específico",
"follow_ups_modal_trigger_type_ending_select": "Selecionar finais: ",
"follow_ups_modal_trigger_type_ending_warning": "Não foram encontrados finais no inquérito!",
"follow_ups_modal_trigger_type_ending_warning": "Por favor, selecione pelo menos um final ou mude o tipo de gatilho",
"follow_ups_modal_trigger_type_response": "Respondente conclui inquérito",
"follow_ups_modal_updated_successfull_toast": "Seguimento atualizado e será guardado assim que guardar o questionário.",
"follow_ups_new": "Novo acompanhamento",
"follow_ups_upgrade_button_text": "Atualize para ativar os acompanhamentos",
"form_styling": "Estilo do formulário",
@@ -1394,6 +1399,9 @@
"four_points": "4 pontos",
"heading": "Cabeçalho",
"hidden_field_added_successfully": "Campo oculto adicionado com sucesso",
"hidden_field_used_in_recall": "Campo oculto \"{hiddenField}\" está a ser recordado na pergunta {questionIndex}.",
"hidden_field_used_in_recall_ending_card": "Campo oculto \"{hiddenField}\" está a ser recordado no Cartão de Conclusão",
"hidden_field_used_in_recall_welcome": "Campo oculto \"{hiddenField}\" está a ser recordado no cartão de boas-vindas.",
"hide_advanced_settings": "Ocultar definições avançadas",
"hide_back_button": "Ocultar botão 'Retroceder'",
"hide_back_button_description": "Não mostrar o botão de retroceder no inquérito",
@@ -1412,6 +1420,7 @@
"inner_text": "Texto Interno",
"input_border_color": "Cor da borda do campo de entrada",
"input_color": "Cor do campo de entrada",
"insert_link": "Inserir ligação",
"invalid_targeting": "Segmentação inválida: Por favor, verifique os seus filtros de audiência",
"invalid_video_url_warning": "Por favor, insira um URL válido do YouTube, Vimeo ou Loom. Atualmente, não suportamos outros fornecedores de hospedagem de vídeo.",
"invalid_youtube_url": "URL do YouTube inválido",
@@ -1429,6 +1438,7 @@
"is_set": "Está definido",
"is_skipped": "É ignorado",
"is_submitted": "Está submetido",
"italic": "Itálico",
"jump_to_question": "Saltar para a pergunta",
"keep_current_order": "Manter ordem atual",
"keep_showing_while_conditions_match": "Continuar a mostrar enquanto as condições corresponderem",
@@ -1455,6 +1465,7 @@
"no_images_found_for": "Não foram encontradas imagens para ''{query}\"",
"no_languages_found_add_first_one_to_get_started": "Nenhuma língua encontrada. Adicione a primeira para começar.",
"no_option_found": "Nenhuma opção encontrada",
"no_recall_items_found": "Nenhum item de recordação encontrado",
"no_variables_yet_add_first_one_below": "Ainda não há variáveis. Adicione a primeira abaixo.",
"number": "Número",
"once_set_the_default_language_for_this_survey_can_only_be_changed_by_disabling_the_multi_language_option_and_deleting_all_translations": "Depois de definido, o idioma padrão desta pesquisa só pode ser alterado desativando a opção de vários idiomas e eliminando todas as traduções.",
@@ -1474,6 +1485,7 @@
"pin_can_only_contain_numbers": "O PIN só pode conter números.",
"pin_must_be_a_four_digit_number": "O PIN deve ser um número de quatro dígitos.",
"please_enter_a_file_extension": "Por favor, insira uma extensão de ficheiro.",
"please_enter_a_valid_url": "Por favor, insira um URL válido (por exemplo, https://example.com)",
"please_set_a_survey_trigger": "Por favor, defina um desencadeador de inquérito",
"please_specify": "Por favor, especifique",
"prevent_double_submission": "Impedir submissão dupla",
@@ -1488,6 +1500,8 @@
"question_id_updated": "ID da pergunta atualizado",
"question_used_in_logic": "Esta pergunta é usada na lógica da pergunta {questionIndex}.",
"question_used_in_quota": "Esta pergunta está a ser usada na quota \"{quotaName}\"",
"question_used_in_recall": "Esta pergunta está a ser recordada na pergunta {questionIndex}.",
"question_used_in_recall_ending_card": "Esta pergunta está a ser recordada no Cartão de Conclusão",
"quotas": {
"add_quota": "Adicionar quota",
"change_quota_for_public_survey": "Alterar quota para inquérito público?",
@@ -1522,6 +1536,8 @@
"randomize_all": "Aleatorizar todos",
"randomize_all_except_last": "Aleatorizar todos exceto o último",
"range": "Intervalo",
"recall_data": "Recuperar dados",
"recall_information_from": "Recordar informação de ...",
"recontact_options": "Opções de Recontacto",
"redirect_thank_you_card": "Redirecionar cartão de agradecimento",
"redirect_to_url": "Redirecionar para Url",
@@ -1599,6 +1615,7 @@
"trigger_survey_when_one_of_the_actions_is_fired": "Desencadear inquérito quando uma das ações for disparada...",
"try_lollipop_or_mountain": "Experimente 'lollipop' ou 'mountain'...",
"type_field_id": "Escreva o id do campo",
"underline": "Sublinhar",
"unlock_targeting_description": "Alvo de grupos de utilizadores específicos com base em atributos ou informações do dispositivo",
"unlock_targeting_title": "Desbloqueie a segmentação com um plano superior",
"unsaved_changes_warning": "Tem alterações não guardadas no seu inquérito. Gostaria de as guardar antes de sair?",
@@ -1615,6 +1632,9 @@
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variável \"{variableName}\" está a ser utilizada na quota \"{quotaName}\"",
"variable_name_is_already_taken_please_choose_another": "O nome da variável já está em uso, por favor escolha outro.",
"variable_name_must_start_with_a_letter": "O nome da variável deve começar com uma letra.",
"variable_used_in_recall": "Variável \"{variable}\" está a ser recordada na pergunta {questionIndex}.",
"variable_used_in_recall_ending_card": "Variável {variable} está a ser recordada no Cartão de Conclusão",
"variable_used_in_recall_welcome": "Variável \"{variable}\" está a ser recordada no cartão de boas-vindas.",
"verify_email_before_submission": "Verificar email antes da submissão",
"verify_email_before_submission_description": "Permitir apenas que pessoas com um email real respondam.",
"wait": "Aguardar",
+30 -10
View File
@@ -262,7 +262,9 @@
"membership_not_found": "Apartenența nu a fost găsită",
"metadata": "Metadate",
"minimum": "Minim",
"mobile_overlay_text": "Formbricks nu este disponibil pentru dispozitive cu rezoluții mai mici.",
"mobile_overlay_app_works_best_on_desktop": "Formbricks funcționează cel mai bine pe un ecran mai mare. Pentru a gestiona sau crea chestionare, treceți la un alt dispozitiv.",
"mobile_overlay_surveys_look_good": "Nu vă faceți griji chestionarele dumneavoastră arată grozav pe orice dispozitiv și dimensiune a ecranului!",
"mobile_overlay_title": "Ups, ecran mic detectat!",
"move_down": "Mută în jos",
"move_up": "Mută sus",
"multiple_languages": "Mai multe limbi",
@@ -277,6 +279,7 @@
"no_result_found": "Niciun rezultat găsit",
"no_results": "Nicio rezultat",
"no_surveys_found": "Nu au fost găsite sondaje.",
"none_of_the_above": "Niciuna dintre cele de mai sus",
"not_authenticated": "Nu sunteți autentificat pentru a efectua această acțiune.",
"not_authorized": "Neautorizat",
"not_connected": "Neconectat",
@@ -750,7 +753,6 @@
},
"project": {
"api_keys": {
"access_control": "Control acces",
"add_api_key": "Adaugă Cheie API",
"api_key": "Cheie API",
"api_key_copied_to_clipboard": "Cheia API a fost copiată în clipboard",
@@ -1202,12 +1204,12 @@
"add_description": "Adăugați descriere",
"add_ending": "Adaugă finalizare",
"add_ending_below": "Adaugă finalizare mai jos",
"add_fallback": "Adaugă",
"add_fallback_placeholder": "Adaugă un substituent pentru a afișa dacă întrebarea este omisă:",
"add_fallback_placeholder": "Adaugă un placeholder pentru a afișa dacă nu există valoare de reamintit",
"add_hidden_field_id": "Adăugați ID câmp ascuns",
"add_highlight_border": "Adaugă bordură evidențiată",
"add_highlight_border_description": "Adaugă o margine exterioară cardului tău de sondaj.",
"add_logic": "Adaugă logică",
"add_none_of_the_above": "Adăugați \"Niciuna dintre cele de mai sus\"",
"add_option": "Adăugați opțiune",
"add_other": "Adăugați \"Altele\"",
"add_photo_or_video": "Adaugă fotografie sau video",
@@ -1240,6 +1242,7 @@
"automatically_mark_the_survey_as_complete_after": "Marcați automat sondajul ca finalizat după",
"back_button_label": "Etichetă buton \"Înapoi\"",
"background_styling": "Stilizare fundal",
"bold": "Îngroșat",
"brand_color": "Culoarea brandului",
"brightness": "Luminozitate",
"button_label": "Etichetă buton",
@@ -1300,8 +1303,8 @@
"contains": "Conține",
"continue_to_settings": "Continuă către Setări",
"control_which_file_types_can_be_uploaded": "Controlează ce tipuri de fișiere pot fi încărcate.",
"convert_to_multiple_choice": "Convertiți la alegere multiplă",
"convert_to_single_choice": "Convertiți la alegere unică",
"convert_to_multiple_choice": "Convertiți la selectare multiplă",
"convert_to_single_choice": "Convertiți la selectare unică",
"country": "Țară",
"create_group": "Creează grup",
"create_your_own_survey": "Creează-ți propriul chestionar",
@@ -1312,7 +1315,6 @@
"days_before_showing_this_survey_again": "zile înainte de a afișa din nou acest sondaj.",
"decide_how_often_people_can_answer_this_survey": "Decide cât de des pot răspunde oamenii la acest sondaj",
"delete_choice": "Șterge alegerea",
"description": "Descriere",
"disable_the_visibility_of_survey_progress": "Dezactivați vizibilitatea progresului sondajului",
"display_an_estimate_of_completion_time_for_survey": "Afișează o estimare a timpului de finalizare pentru sondaj",
"display_number_of_responses_for_survey": "Afișează numărul de răspunsuri pentru sondaj",
@@ -1323,6 +1325,7 @@
"does_not_include_all_of": "Nu include toate",
"does_not_include_one_of": "Nu include una dintre",
"does_not_start_with": "Nu începe cu",
"edit_link": "Editare legătură",
"edit_recall": "Editează Referințele",
"edit_translations": "Editează traducerile {lang}",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permite participanților să schimbe limba sondajului în orice moment în timpul sondajului.",
@@ -1333,13 +1336,13 @@
"ending_card_used_in_logic": "Această carte de încheiere este folosită în logica întrebării {questionIndex}.",
"ending_used_in_quota": "Finalul acesta este folosit în cota \"{quotaName}\"",
"ends_with": "Se termină cu",
"enter_fallback_value": "Introduceți valoarea implicită",
"equals": "Egal",
"equals_one_of": "Egal unu dintre",
"error_publishing_survey": "A apărut o eroare în timpul publicării sondajului.",
"error_saving_changes": "Eroare la salvarea modificărilor",
"even_after_they_submitted_a_response_e_g_feedback_box": "Chiar și după ce au furnizat un răspuns (de ex. Cutia de Feedback)",
"everyone": "Toată lumea",
"fallback_for": "Varianta de rezervă pentru",
"fallback_missing": "Rezerva lipsă",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} este folosit în logică întrebării {questionIndex}. Vă rugăm să-l eliminați din logică mai întâi.",
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "Câmpul ascuns \"{fieldId}\" este folosit în cota \"{quotaName}\"",
@@ -1374,8 +1377,9 @@
"follow_ups_modal_action_subject_placeholder": "Subiectul emailului",
"follow_ups_modal_action_to_description": "Adresă de email către care se trimite emailul",
"follow_ups_modal_action_to_label": "Către",
"follow_ups_modal_action_to_warning": "Nu s-a detectat niciun câmp de e-mail în sondaj",
"follow_ups_modal_action_to_warning": "Nu s-au găsit opțiuni valide pentru trimiterea e-mailurilor, vă rugăm să adăugați întrebări de tip text deschis / informații de contact sau câmpuri ascunse",
"follow_ups_modal_create_heading": "Creați o nouă urmărire",
"follow_ups_modal_created_successfull_toast": "Urmărirea a fost creată și va fi salvată odată ce salvați sondajul.",
"follow_ups_modal_edit_heading": "Editează acest follow-up",
"follow_ups_modal_edit_no_id": "Nu a fost furnizat un ID de urmărire al chestionarului, nu pot actualiza urmărirea chestionarului",
"follow_ups_modal_name_label": "Numele ",
@@ -1385,8 +1389,9 @@
"follow_ups_modal_trigger_label": "Declanșator",
"follow_ups_modal_trigger_type_ending": "Respondentul vede un sfârșit specific",
"follow_ups_modal_trigger_type_ending_select": "Selectează finalurile:",
"follow_ups_modal_trigger_type_ending_warning": "Nu s-au găsit finalizări în sondaj!",
"follow_ups_modal_trigger_type_ending_warning": "Vă rugăm să selectați cel puțin un sfârșit sau să schimbați tipul declanșatorului",
"follow_ups_modal_trigger_type_response": "Respondent finalizează sondajul",
"follow_ups_modal_updated_successfull_toast": "Urmărirea a fost actualizată și va fi salvată odată ce salvați sondajul.",
"follow_ups_new": "Follow-up nou",
"follow_ups_upgrade_button_text": "Actualizați pentru a activa urmărările",
"form_styling": "Stilizare formular",
@@ -1394,6 +1399,9 @@
"four_points": "4 puncte",
"heading": "Titlu",
"hidden_field_added_successfully": "Câmp ascuns adăugat cu succes",
"hidden_field_used_in_recall": "Câmpul ascuns \"{hiddenField}\" este reamintit în întrebarea {questionIndex}.",
"hidden_field_used_in_recall_ending_card": "Câmpul ascuns \"{hiddenField}\" este reamintit în Cardul de Încheiere.",
"hidden_field_used_in_recall_welcome": "Câmpul ascuns \"{hiddenField}\" este reamintit în cardul de bun venit.",
"hide_advanced_settings": "Ascunde setări avansate",
"hide_back_button": "Ascunde butonul 'Înapoi'",
"hide_back_button_description": "Nu afișa butonul Înapoi în sondaj",
@@ -1412,6 +1420,7 @@
"inner_text": "Text Interior",
"input_border_color": "Culoarea graniței câmpului de introducere",
"input_color": "Culoarea câmpului de introducere",
"insert_link": "Inserează link",
"invalid_targeting": "\"Targetare nevalidă: Vă rugăm să verificați filtrele pentru audiență\"",
"invalid_video_url_warning": "Vă rugăm să introduceți un URL valid de YouTube, Vimeo sau Loom. În prezent nu susținem alți furnizori de găzduire video.",
"invalid_youtube_url": "URL YouTube invalid",
@@ -1429,6 +1438,7 @@
"is_set": "Este setat",
"is_skipped": "Este sărit",
"is_submitted": "Este trimis",
"italic": "Cursiv",
"jump_to_question": "Sări la întrebare",
"keep_current_order": "Păstrați ordinea actuală",
"keep_showing_while_conditions_match": "Continuă să afișezi cât timp condițiile se potrivesc",
@@ -1455,6 +1465,7 @@
"no_images_found_for": "Nicio imagine găsită pentru ''{query}\"",
"no_languages_found_add_first_one_to_get_started": "Nu s-au găsit limbi. Adaugă prima pentru a începe.",
"no_option_found": "Nicio opțiune găsită",
"no_recall_items_found": "Nu s-au găsit elemente de reamintire",
"no_variables_yet_add_first_one_below": "Nu există variabile încă. Adăugați prima mai jos.",
"number": "Număr",
"once_set_the_default_language_for_this_survey_can_only_be_changed_by_disabling_the_multi_language_option_and_deleting_all_translations": "Odată setată, limba implicită pentru acest sondaj poate fi schimbată doar dezactivând opțiunea multi-limbă și ștergând toate traducerile.",
@@ -1474,6 +1485,7 @@
"pin_can_only_contain_numbers": "PIN-ul poate conține doar numere.",
"pin_must_be_a_four_digit_number": "PIN-ul trebuie să fie un număr de patru cifre",
"please_enter_a_file_extension": "Vă rugăm să introduceți o extensie de fișier.",
"please_enter_a_valid_url": "Vă rugăm să introduceți un URL valid (de exemplu, https://example.com)",
"please_set_a_survey_trigger": "Vă rugăm să setați un declanșator sondaj",
"please_specify": "Vă rugăm să specificați",
"prevent_double_submission": "Prevenire trimitere dublă",
@@ -1488,6 +1500,8 @@
"question_id_updated": "ID întrebare actualizat",
"question_used_in_logic": "Această întrebare este folosită în logica întrebării {questionIndex}.",
"question_used_in_quota": "Întrebarea aceasta este folosită în cota \"{quotaName}\"",
"question_used_in_recall": "Această întrebare este reamintită în întrebarea {questionIndex}.",
"question_used_in_recall_ending_card": "Această întrebare este reamintită în Cardul de Încheiere.",
"quotas": {
"add_quota": "Adăugați cotă",
"change_quota_for_public_survey": "Schimbați cota pentru sondaj public?",
@@ -1522,6 +1536,8 @@
"randomize_all": "Randomizează tot",
"randomize_all_except_last": "Randomizează tot cu excepția ultimului",
"range": "Interval",
"recall_data": "Reamintiți datele",
"recall_information_from": "Reamintiți informațiile din ...",
"recontact_options": "Opțiuni de recontactare",
"redirect_thank_you_card": "Redirecționează cardul de mulțumire",
"redirect_to_url": "Redirecționează către URL",
@@ -1599,6 +1615,7 @@
"trigger_survey_when_one_of_the_actions_is_fired": "Declanșați sondajul atunci când una dintre acțiuni este realizată...",
"try_lollipop_or_mountain": "Încercați „lollipop” sau „mountain”...",
"type_field_id": "ID câmp tip",
"underline": "Subliniază",
"unlock_targeting_description": "Vizează grupuri specifice de utilizatori pe baza atributelor sau a informațiilor despre dispozitiv",
"unlock_targeting_title": "Deblocați țintirea cu un plan superior",
"unsaved_changes_warning": "Aveți modificări nesalvate în sondajul dumneavoastră. Doriți să le salvați înainte de a pleca?",
@@ -1615,6 +1632,9 @@
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variabila \"{variableName}\" este folosită în cota \"{quotaName}\"",
"variable_name_is_already_taken_please_choose_another": "Numele variabilei este deja utilizat, vă rugăm să alegeți altul.",
"variable_name_must_start_with_a_letter": "Numele variabilei trebuie să înceapă cu o literă.",
"variable_used_in_recall": "Variabila \"{variable}\" este reamintită în întrebarea {questionIndex}.",
"variable_used_in_recall_ending_card": "Variabila {variable} este reamintită în Cardul de Încheiere.",
"variable_used_in_recall_welcome": "Variabila \"{variable}\" este reamintită în cardul de bun venit.",
"verify_email_before_submission": "Verifică emailul înainte de trimitere",
"verify_email_before_submission_description": "Permite doar persoanelor cu un email real să răspundă.",
"wait": "Așteptați",
+30 -10
View File
@@ -262,7 +262,9 @@
"membership_not_found": "未找到会员资格",
"metadata": "元数据",
"minimum": "最低",
"mobile_overlay_text": "Formbricks 不 适用 于 分辨率 较小 的 设备",
"mobile_overlay_app_works_best_on_desktop": "Formbricks 在 更大 的 屏幕 上 效果 最佳。 若 需要 管理 或 构建 调查, 请 切换 到 其他 设备",
"mobile_overlay_surveys_look_good": "别 担心 – 您 的 调查 在 每 一 种 设备 和 屏幕 尺寸 上 看起来 都 很 棒!",
"mobile_overlay_title": "噢, 检测 到 小 屏幕!",
"move_down": "下移",
"move_up": "上移",
"multiple_languages": "多种 语言",
@@ -277,6 +279,7 @@
"no_result_found": "没有 结果",
"no_results": "没有 结果",
"no_surveys_found": "未找到 调查",
"none_of_the_above": "以上 都 不 是",
"not_authenticated": "您 未 认证 以 执行 该 操作。",
"not_authorized": "未授权",
"not_connected": "未连接",
@@ -750,7 +753,6 @@
},
"project": {
"api_keys": {
"access_control": "访问控制",
"add_api_key": "添加 API 密钥",
"api_key": "API Key",
"api_key_copied_to_clipboard": "API 密钥 已复制到 剪贴板",
@@ -1202,12 +1204,12 @@
"add_description": "添加 描述",
"add_ending": "添加结尾",
"add_ending_below": "在下方 添加 结尾",
"add_fallback": "添加",
"add_fallback_placeholder": "添加 一个 占位符,以显示该问题是否被跳过:",
"add_fallback_placeholder": "添加 占位符 显示 如果 没有 值以 回忆",
"add_hidden_field_id": "添加 隐藏 字段 ID",
"add_highlight_border": "添加 高亮 边框",
"add_highlight_border_description": "在 你的 调查 卡片 添加 外 边框。",
"add_logic": "添加逻辑",
"add_none_of_the_above": "添加 “以上 都 不 是”",
"add_option": "添加 选项",
"add_other": "添加 \"其他\"",
"add_photo_or_video": "添加 照片 或 视频",
@@ -1240,6 +1242,7 @@
"automatically_mark_the_survey_as_complete_after": "自动 标记 调查 为 完成 在",
"back_button_label": "\"返回\" 按钮标签",
"background_styling": "背景 样式",
"bold": "粗体",
"brand_color": "品牌 颜色",
"brightness": "亮度",
"button_label": "按钮标签",
@@ -1300,8 +1303,8 @@
"contains": "包含",
"continue_to_settings": "继续 到 设置",
"control_which_file_types_can_be_uploaded": "控制 可以 上传的 文件 类型",
"convert_to_multiple_choice": "转换为多选",
"convert_to_single_choice": "转换为单选",
"convert_to_multiple_choice": "转换为 多选",
"convert_to_single_choice": "转换为 单选",
"country": "国家",
"create_group": "创建 群组",
"create_your_own_survey": "创建 你 的 调查",
@@ -1312,7 +1315,6 @@
"days_before_showing_this_survey_again": "显示 此 调查 之前 的 天数。",
"decide_how_often_people_can_answer_this_survey": "决定 人 可以 回答 这份 调查 的 频率 。",
"delete_choice": "删除 选择",
"description": "描述",
"disable_the_visibility_of_survey_progress": "禁用问卷 进度 的可见性。",
"display_an_estimate_of_completion_time_for_survey": "显示 调查 预计 完成 时间",
"display_number_of_responses_for_survey": "显示 调查 响应 数量",
@@ -1323,6 +1325,7 @@
"does_not_include_all_of": "不包括所有 ",
"does_not_include_one_of": "不包括一 个",
"does_not_start_with": "不 以 开头",
"edit_link": "编辑 链接",
"edit_recall": "编辑 调用",
"edit_translations": "编辑 {lang} 翻译",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "启用 参与者 在 调查 过程中 的 任何 时间 点 切换 调查 语言。",
@@ -1333,13 +1336,13 @@
"ending_card_used_in_logic": "\"这个 结束卡片 在 问题 {questionIndex} 的 逻辑 中 使用。\"",
"ending_used_in_quota": "此 结尾 正在 被 \"{quotaName}\" 配额 使用",
"ends_with": "以...结束",
"enter_fallback_value": "输入 后备 值",
"equals": "等于",
"equals_one_of": "等于 其中 一个",
"error_publishing_survey": "发布调查时发生了错误",
"error_saving_changes": "保存 更改 时 出错",
"even_after_they_submitted_a_response_e_g_feedback_box": "即使 他们 提交 了 回复(例如 反馈框)",
"everyone": "所有 人",
"fallback_for": "后备 用于",
"fallback_missing": "备用 缺失",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "\"{fieldId} 在 问题 {questionIndex} 的 逻辑 中 使用。请 先 从 逻辑 中 删除 它。\"",
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "隐藏 字段 \"{fieldId}\" 正在 被 \"{quotaName}\" 配额 使用",
@@ -1374,8 +1377,9 @@
"follow_ups_modal_action_subject_placeholder": "电子邮件主题",
"follow_ups_modal_action_to_description": "发送邮件的电子邮箱地址",
"follow_ups_modal_action_to_label": "到",
"follow_ups_modal_action_to_warning": "调查中未 检测到 电子邮件 字段",
"follow_ups_modal_action_to_warning": "为 发送 邮件 找不到 有效 选项 ,请 增加 一些 开放文本 / 联系 信息 问题 或 隐藏 字段",
"follow_ups_modal_create_heading": "创建一个新的跟进",
"follow_ups_modal_created_successfull_toast": "后续 操作 已 创建, 并且 在 你 保存 调查 后 将 被 保存。",
"follow_ups_modal_edit_heading": "编辑此跟进",
"follow_ups_modal_edit_no_id": "未 提供 调查 跟进 id ,无法 更新 调查 跟进",
"follow_ups_modal_name_label": "跟进 名称",
@@ -1385,8 +1389,9 @@
"follow_ups_modal_trigger_label": "触发",
"follow_ups_modal_trigger_type_ending": "受访者 看到 一个 特定 的 结尾",
"follow_ups_modal_trigger_type_ending_select": "选择结尾:",
"follow_ups_modal_trigger_type_ending_warning": "在 调查 中 未找到 结尾 ",
"follow_ups_modal_trigger_type_ending_warning": "请选择至少 一个结束条件 或更改触发条件类型",
"follow_ups_modal_trigger_type_response": "受访者 完成 调查",
"follow_ups_modal_updated_successfull_toast": "后续 操作 已 更新, 并且 在 你 保存 调查 后 将 被 保存。",
"follow_ups_new": "新的跟进",
"follow_ups_upgrade_button_text": "升级 以启用 跟进",
"form_styling": "表单 样式",
@@ -1394,6 +1399,9 @@
"four_points": "4 分",
"heading": "标题",
"hidden_field_added_successfully": "隐藏字段 添加成功",
"hidden_field_used_in_recall": "隐藏 字段 \"{hiddenField}\" 正在召回于问题 {questionIndex}。",
"hidden_field_used_in_recall_ending_card": "隐藏 字段 \"{hiddenField}\" 正在召回于结束 卡",
"hidden_field_used_in_recall_welcome": "隐藏 字段 \"{hiddenField}\" 正在召回于欢迎 卡 。",
"hide_advanced_settings": "隐藏 高级设置",
"hide_back_button": "隐藏 \"返回\" 按钮",
"hide_back_button_description": "不 显示 调查 中 的 返回 按钮",
@@ -1412,6 +1420,7 @@
"inner_text": "内文",
"input_border_color": "输入 边框 颜色",
"input_color": "输入颜色",
"insert_link": "插入 链接",
"invalid_targeting": "无效的目标: 请检查 您 的受众过滤器",
"invalid_video_url_warning": "请输入有效的 YouTube、Vimeo 或 Loom URL 。我们目前不支持其他 视频 托管服务提供商。",
"invalid_youtube_url": "无效的 YouTube URL",
@@ -1429,6 +1438,7 @@
"is_set": "已设置",
"is_skipped": "已跳过",
"is_submitted": "已提交",
"italic": "斜体",
"jump_to_question": "跳 转 到 问题",
"keep_current_order": "保持 当前 顺序",
"keep_showing_while_conditions_match": "条件 符合 时 保持 显示",
@@ -1455,6 +1465,7 @@
"no_images_found_for": "未找到与 \"{query}\" 相关的图片",
"no_languages_found_add_first_one_to_get_started": "没有找到语言。添加第一个以开始。",
"no_option_found": "找不到选择",
"no_recall_items_found": "未 找到 召回 项目",
"no_variables_yet_add_first_one_below": "还没有变量。 在下面添加第一个。",
"number": "数字",
"once_set_the_default_language_for_this_survey_can_only_be_changed_by_disabling_the_multi_language_option_and_deleting_all_translations": "一旦设置,此调查的默认语言只能通过禁用多语言选项并删除所有翻译来更改。",
@@ -1474,6 +1485,7 @@
"pin_can_only_contain_numbers": "PIN 只能包含数字。",
"pin_must_be_a_four_digit_number": "PIN 必须是 四 位数字。",
"please_enter_a_file_extension": "请输入 文件 扩展名。",
"please_enter_a_valid_url": "请输入有效的 URL(例如, https://example.com ",
"please_set_a_survey_trigger": "请 设置 一个 调查 触发",
"please_specify": "请 指定",
"prevent_double_submission": "防止 重复 提交",
@@ -1488,6 +1500,8 @@
"question_id_updated": "问题 ID 更新",
"question_used_in_logic": "\"这个 问题 在 问题 {questionIndex} 的 逻辑 中 使用。\"",
"question_used_in_quota": "此 问题 正在 被 \"{quotaName}\" 配额 使用",
"question_used_in_recall": "此问题正在召回于问题 {questionIndex}。",
"question_used_in_recall_ending_card": "此 问题 正在召回于结束 卡片。",
"quotas": {
"add_quota": "添加 配额",
"change_quota_for_public_survey": "更改 公共调查 的配额?",
@@ -1522,6 +1536,8 @@
"randomize_all": "随机排列",
"randomize_all_except_last": "随机排列,最后一个除外",
"range": "范围",
"recall_data": "调用 数据",
"recall_information_from": "从 ... 召回信息",
"recontact_options": "重新 联系 选项",
"redirect_thank_you_card": "重定向感谢卡",
"redirect_to_url": "重定向到 URL",
@@ -1599,6 +1615,7 @@
"trigger_survey_when_one_of_the_actions_is_fired": "当 其中 一个 动作 被 触发 时 启动 调查…",
"try_lollipop_or_mountain": "尝试 'lollipop' 或 'mountain' ...",
"type_field_id": "类型 字段 ID",
"underline": "下划线",
"unlock_targeting_description": "根据 属性 或 设备信息 定位 特定 用户组",
"unlock_targeting_title": "通过 更 高级 划解锁 定位",
"unsaved_changes_warning": "您在调查中有未保存的更改。离开前是否要保存?",
@@ -1615,6 +1632,9 @@
"variable_is_used_in_quota_please_remove_it_from_quota_first": "变量 \"{variableName}\" 正在 被 \"{quotaName}\" 配额 使用",
"variable_name_is_already_taken_please_choose_another": "变量名已被占用,请选择其他。",
"variable_name_must_start_with_a_letter": "变量名 必须 以字母开头。",
"variable_used_in_recall": "变量 \"{variable}\" 正在召回于问题 {questionIndex}。",
"variable_used_in_recall_ending_card": "变量 {variable} 正在召回于结束 卡片",
"variable_used_in_recall_welcome": "变量 \"{variable}\" 正在召回于欢迎 卡 。",
"verify_email_before_submission": "提交 之前 验证电子邮件",
"verify_email_before_submission_description": "仅允许 拥有 有效 电子邮件 的 人 回应。",
"wait": "等待",
+28 -8
View File
@@ -262,7 +262,9 @@
"membership_not_found": "找不到成員資格",
"metadata": "元數據",
"minimum": "最小值",
"mobile_overlay_text": "Formbricks 不適用於較小解析度的裝置。",
"mobile_overlay_app_works_best_on_desktop": "Formbricks 適合在大螢幕上使用。若要管理或建立問卷,請切換到其他裝置。",
"mobile_overlay_surveys_look_good": "別擔心 -你的 問卷 在每個 裝置 和 螢幕尺寸 上 都 很出色!",
"mobile_overlay_title": "糟糕 ,偵測到小螢幕!",
"move_down": "下移",
"move_up": "上移",
"multiple_languages": "多種語言",
@@ -277,6 +279,7 @@
"no_result_found": "找不到結果",
"no_results": "沒有結果",
"no_surveys_found": "找不到問卷。",
"none_of_the_above": "以上皆非",
"not_authenticated": "您未經授權執行此操作。",
"not_authorized": "未授權",
"not_connected": "未連線",
@@ -750,7 +753,6 @@
},
"project": {
"api_keys": {
"access_control": "存取控制",
"add_api_key": "新增 API 金鑰",
"api_key": "API 金鑰",
"api_key_copied_to_clipboard": "API 金鑰已複製到剪貼簿",
@@ -1202,12 +1204,12 @@
"add_description": "新增描述",
"add_ending": "新增結尾",
"add_ending_below": "在下方新增結尾",
"add_fallback": "新增",
"add_fallback_placeholder": "新增用于顯示問題被跳過時的佔位符",
"add_fallback_placeholder": "新增 預設 以顯示是否沒 有 值 可 回憶 。",
"add_hidden_field_id": "新增隱藏欄位 ID",
"add_highlight_border": "新增醒目提示邊框",
"add_highlight_border_description": "在您的問卷卡片新增外邊框。",
"add_logic": "新增邏輯",
"add_none_of_the_above": "新增 \"以上皆非\"",
"add_option": "新增選項",
"add_other": "新增「其他」",
"add_photo_or_video": "新增照片或影片",
@@ -1240,6 +1242,7 @@
"automatically_mark_the_survey_as_complete_after": "在指定時間後自動將問卷標記為完成",
"back_button_label": "「返回」按鈕標籤",
"background_styling": "背景樣式設定",
"bold": "粗體",
"brand_color": "品牌顏色",
"brightness": "亮度",
"button_label": "按鈕標籤",
@@ -1312,7 +1315,6 @@
"days_before_showing_this_survey_again": "天後再次顯示此問卷。",
"decide_how_often_people_can_answer_this_survey": "決定人們可以回答此問卷的頻率。",
"delete_choice": "刪除選項",
"description": "描述",
"disable_the_visibility_of_survey_progress": "停用問卷進度的可見性。",
"display_an_estimate_of_completion_time_for_survey": "顯示問卷的估計完成時間",
"display_number_of_responses_for_survey": "顯示問卷的回應數",
@@ -1323,6 +1325,7 @@
"does_not_include_all_of": "不包含全部",
"does_not_include_one_of": "不包含其中之一",
"does_not_start_with": "不以...開頭",
"edit_link": "編輯 連結",
"edit_recall": "編輯回憶",
"edit_translations": "編輯 '{'language'}' 翻譯",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "允許參與者在問卷中的任何時間點切換問卷語言。",
@@ -1333,13 +1336,13 @@
"ending_card_used_in_logic": "此結尾卡片用於問題 '{'questionIndex'}' 的邏輯中。",
"ending_used_in_quota": "此 結尾 正被使用於 \"{quotaName}\" 配額中",
"ends_with": "結尾為",
"enter_fallback_value": "輸入 預設 值",
"equals": "等於",
"equals_one_of": "等於其中之一",
"error_publishing_survey": "發布問卷時發生錯誤。",
"error_saving_changes": "儲存變更時發生錯誤",
"even_after_they_submitted_a_response_e_g_feedback_box": "即使他們提交回應之後(例如,意見反應方塊)",
"everyone": "所有人",
"fallback_for": "備用 用於 ",
"fallback_missing": "遺失的回退",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "'{'fieldId'}' 用於問題 '{'questionIndex'}' 的邏輯中。請先從邏輯中移除。",
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "隱藏欄位 \"{fieldId}\" 正被使用於 \"{quotaName}\" 配額中",
@@ -1374,8 +1377,9 @@
"follow_ups_modal_action_subject_placeholder": "電子郵件主旨",
"follow_ups_modal_action_to_description": "傳送電子郵件的電子郵件地址",
"follow_ups_modal_action_to_label": "收件者",
"follow_ups_modal_action_to_warning": "問卷中未偵測到電子郵件欄位",
"follow_ups_modal_action_to_warning": "未找到 發送電子郵件 有效選項,請添加 一些 開放文本 / 聯絡資訊 問題或隱藏欄位",
"follow_ups_modal_create_heading": "建立新的後續追蹤",
"follow_ups_modal_created_successfull_toast": "後續 動作 已 建立 並 將 在 你 儲存 調查 後 儲存",
"follow_ups_modal_edit_heading": "編輯此後續追蹤",
"follow_ups_modal_edit_no_id": "未提供問卷後續追蹤 ID,無法更新問卷後續追蹤",
"follow_ups_modal_name_label": "後續追蹤名稱",
@@ -1385,8 +1389,9 @@
"follow_ups_modal_trigger_label": "觸發器",
"follow_ups_modal_trigger_type_ending": "回應者看到特定結尾",
"follow_ups_modal_trigger_type_ending_select": "選取結尾:",
"follow_ups_modal_trigger_type_ending_warning": "問卷中找不到結尾!",
"follow_ups_modal_trigger_type_ending_warning": "請選擇至少一個結尾或更改觸發類型",
"follow_ups_modal_trigger_type_response": "回應者完成問卷",
"follow_ups_modal_updated_successfull_toast": "後續 動作 已 更新 並 將 在 你 儲存 調查 後 儲存",
"follow_ups_new": "新增後續追蹤",
"follow_ups_upgrade_button_text": "升級以啟用後續追蹤",
"form_styling": "表單樣式設定",
@@ -1394,6 +1399,9 @@
"four_points": "4 分",
"heading": "標題",
"hidden_field_added_successfully": "隱藏欄位已成功新增",
"hidden_field_used_in_recall": "隱藏欄位 \"{hiddenField}\" 於問題 {questionIndex} 中被召回。",
"hidden_field_used_in_recall_ending_card": "隱藏欄位 \"{hiddenField}\" 於結束卡中被召回。",
"hidden_field_used_in_recall_welcome": "隱藏欄位 \"{hiddenField}\" 於歡迎卡中被召回。",
"hide_advanced_settings": "隱藏進階設定",
"hide_back_button": "隱藏「Back」按鈕",
"hide_back_button_description": "不要在問卷中顯示返回按鈕",
@@ -1412,6 +1420,7 @@
"inner_text": "內部文字",
"input_border_color": "輸入邊框顏色",
"input_color": "輸入顏色",
"insert_link": "插入 連結",
"invalid_targeting": "目標設定無效:請檢查您的受眾篩選器",
"invalid_video_url_warning": "請輸入有效的 YouTube、Vimeo 或 Loom 網址。我們目前不支援其他影片託管提供者。",
"invalid_youtube_url": "無效的 YouTube 網址",
@@ -1429,6 +1438,7 @@
"is_set": "已設定",
"is_skipped": "已跳過",
"is_submitted": "已提交",
"italic": "斜體",
"jump_to_question": "跳至問題",
"keep_current_order": "保留目前順序",
"keep_showing_while_conditions_match": "在條件符合時持續顯示",
@@ -1455,6 +1465,7 @@
"no_images_found_for": "找不到「'{'query'}'」的圖片",
"no_languages_found_add_first_one_to_get_started": "找不到語言。新增第一個語言以開始使用。",
"no_option_found": "找不到選項",
"no_recall_items_found": "找不到 召回 項目",
"no_variables_yet_add_first_one_below": "尚無變數。在下方新增第一個變數。",
"number": "數字",
"once_set_the_default_language_for_this_survey_can_only_be_changed_by_disabling_the_multi_language_option_and_deleting_all_translations": "設定後,此問卷的預設語言只能藉由停用多語言選項並刪除所有翻譯來變更。",
@@ -1474,6 +1485,7 @@
"pin_can_only_contain_numbers": "PIN 碼只能包含數字。",
"pin_must_be_a_four_digit_number": "PIN 碼必須是四位數的數字。",
"please_enter_a_file_extension": "請輸入檔案副檔名。",
"please_enter_a_valid_url": "請輸入有效的 URL(例如:https://example.com",
"please_set_a_survey_trigger": "請設定問卷觸發器",
"please_specify": "請指定",
"prevent_double_submission": "防止重複提交",
@@ -1488,6 +1500,8 @@
"question_id_updated": "問題 ID 已更新",
"question_used_in_logic": "此問題用於問題 '{'questionIndex'}' 的邏輯中。",
"question_used_in_quota": "此問題 正被使用於 \"{quotaName}\" 配額中",
"question_used_in_recall": "此問題於問題 {questionIndex} 中被召回。",
"question_used_in_recall_ending_card": "此問題於結尾卡中被召回。",
"quotas": {
"add_quota": "新增額度",
"change_quota_for_public_survey": "更改 公開 問卷 的 額度?",
@@ -1522,6 +1536,8 @@
"randomize_all": "全部隨機排序",
"randomize_all_except_last": "全部隨機排序(最後一項除外)",
"range": "範圍",
"recall_data": "回憶數據",
"recall_information_from": "從 ... 獲取 信息",
"recontact_options": "重新聯絡選項",
"redirect_thank_you_card": "重新導向感謝卡片",
"redirect_to_url": "重新導向至網址",
@@ -1599,6 +1615,7 @@
"trigger_survey_when_one_of_the_actions_is_fired": "當觸發其中一個操作時,觸發問卷...",
"try_lollipop_or_mountain": "嘗試「棒棒糖」或「山峰」...",
"type_field_id": "輸入欄位 ID",
"underline": "下 劃 線",
"unlock_targeting_description": "根據屬性或裝置資訊鎖定特定使用者群組",
"unlock_targeting_title": "使用更高等級的方案解鎖目標設定",
"unsaved_changes_warning": "您的問卷中有未儲存的變更。您要先儲存它們再離開嗎?",
@@ -1615,6 +1632,9 @@
"variable_is_used_in_quota_please_remove_it_from_quota_first": "變數 \"{variableName}\" 正被使用於 \"{quotaName}\" 配額中",
"variable_name_is_already_taken_please_choose_another": "已使用此變數名稱,請選擇另一個名稱。",
"variable_name_must_start_with_a_letter": "變數名稱必須以字母開頭。",
"variable_used_in_recall": "變數 \"{variable}\" 於問題 {questionIndex} 中被召回。",
"variable_used_in_recall_ending_card": "變數 {variable} 於 結束 卡 中被召回。",
"variable_used_in_recall_welcome": "變數 \"{variable}\" 於 歡迎 Card 中被召回。",
"verify_email_before_submission": "提交前驗證電子郵件",
"verify_email_before_submission_description": "僅允許擁有真實電子郵件的人員回應。",
"wait": "等待",
@@ -1,11 +1,11 @@
import { getEnabledLanguages } from "@/lib/i18n/utils";
import { useClickOutside } from "@/lib/utils/hooks/useClickOutside";
import { Button } from "@/modules/ui/components/button";
import { Languages } from "lucide-react";
import { useRef, useState } from "react";
import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { getEnabledLanguages } from "@/lib/i18n/utils";
import { useClickOutside } from "@/lib/utils/hooks/useClickOutside";
import { Button } from "@/modules/ui/components/button";
interface LanguageDropdownProps {
survey: TSurvey;
@@ -13,6 +13,7 @@ import { RatingResponse } from "@/modules/ui/components/rating-response";
import { ResponseBadges } from "@/modules/ui/components/response-badges";
import { CheckCheckIcon, MousePointerClickIcon, PhoneIcon } from "lucide-react";
import React from "react";
import { TResponseDataValue } from "@formbricks/types/responses";
import {
TSurvey,
TSurveyMatrixQuestion,
@@ -23,7 +24,7 @@ import {
} from "@formbricks/types/surveys/types";
interface RenderResponseProps {
responseData: string | number | string[] | Record<string, string>;
responseData: TResponseDataValue;
question: TSurveyQuestion;
survey: TSurvey;
language: string | null;
@@ -1,4 +1,6 @@
export const isValidValue = (value: string | number | Record<string, string> | string[]) => {
import { TResponseDataValue } from "@formbricks/types/responses";
export const isValidValue = (value: TResponseDataValue) => {
return (
(typeof value === "string" && value.trim() !== "") ||
(Array.isArray(value) && value.length > 0) ||
@@ -0,0 +1,101 @@
import { getCacheService } from "@formbricks/cache";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { Result, err, ok } from "@formbricks/types/error-handlers";
import { type OverallHealthStatus } from "@/modules/api/v2/health/types/health-status";
import { type ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
/**
* Check if the main database is reachable and responding
* @returns Promise<Result<boolean, ApiErrorResponseV2>> - Result of the database health check
*/
export const checkDatabaseHealth = async (): Promise<Result<boolean, ApiErrorResponseV2>> => {
try {
// Simple query to check if database is reachable
await prisma.$queryRaw`SELECT 1`;
return ok(true);
} catch (error) {
logger
.withContext({
component: "health_check",
check_type: "main_database",
error,
})
.error("Database health check failed");
return err({
type: "internal_server_error",
details: [{ field: "main_database", issue: "Database health check failed" }],
});
}
};
/**
* Check if the Redis cache is reachable and responding
* @returns Promise<Result<boolean, ApiErrorResponseV2>> - Result of the cache health check
*/
export const checkCacheHealth = async (): Promise<Result<boolean, ApiErrorResponseV2>> => {
try {
const cacheServiceResult = await getCacheService();
if (!cacheServiceResult.ok) {
return err({
type: "internal_server_error",
details: [{ field: "cache_database", issue: "Cache service not available" }],
});
}
const isAvailable = await cacheServiceResult.data.isRedisAvailable();
if (isAvailable) {
return ok(true);
}
return err({
type: "internal_server_error",
details: [{ field: "cache_database", issue: "Redis not available" }],
});
} catch (error) {
logger
.withContext({
component: "health_check",
check_type: "cache_database",
error,
})
.error("Redis health check failed");
return err({
type: "internal_server_error",
details: [{ field: "cache_database", issue: "Redis health check failed" }],
});
}
};
/**
* Perform all health checks and return the overall status
* Always returns ok() with health status unless the health check endpoint itself fails
* @returns Promise<Result<OverallHealthStatus, ApiErrorResponseV2>> - Overall health status of all dependencies
*/
export const performHealthChecks = async (): Promise<Result<OverallHealthStatus, ApiErrorResponseV2>> => {
try {
const [databaseResult, cacheResult] = await Promise.all([checkDatabaseHealth(), checkCacheHealth()]);
const healthStatus: OverallHealthStatus = {
main_database: databaseResult.ok ? databaseResult.data : false,
cache_database: cacheResult.ok ? cacheResult.data : false,
};
// Always return ok() with the health status - individual dependency failures
// are reflected in the boolean values
return ok(healthStatus);
} catch (error) {
// Only return err() if the health check endpoint itself fails
logger
.withContext({
component: "health_check",
error,
})
.error("Health check endpoint failed");
return err({
type: "internal_server_error",
details: [{ field: "health", issue: "Failed to perform health checks" }],
});
}
};
@@ -0,0 +1,29 @@
import { ZOverallHealthStatus } from "@/modules/api/v2/health/types/health-status";
import { makePartialSchema } from "@/modules/api/v2/types/openapi-response";
import { ZodOpenApiOperationObject } from "zod-openapi";
export const healthCheckEndpoint: ZodOpenApiOperationObject = {
tags: ["Health"],
summary: "Health Check",
description: "Check the health status of critical application dependencies including database and cache.",
requestParams: {},
operationId: "healthCheck",
security: [],
responses: {
"200": {
description:
"Health check completed successfully. Check individual dependency status in response data.",
content: {
"application/json": {
schema: makePartialSchema(ZOverallHealthStatus),
},
},
},
},
};
export const healthPaths = {
"/health": {
get: healthCheckEndpoint,
},
};
@@ -0,0 +1,288 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { ErrorCode, getCacheService } from "@formbricks/cache";
import { prisma } from "@formbricks/database";
import { err, ok } from "@formbricks/types/error-handlers";
import { checkCacheHealth, checkDatabaseHealth, performHealthChecks } from "../health-checks";
// Mock dependencies
vi.mock("@formbricks/database", () => ({
prisma: {
$queryRaw: vi.fn(),
},
}));
vi.mock("@formbricks/cache", () => ({
getCacheService: vi.fn(),
ErrorCode: {
RedisConnectionError: "redis_connection_error",
},
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
info: vi.fn(),
withContext: vi.fn(() => ({
error: vi.fn(),
info: vi.fn(),
})),
},
}));
describe("Health Checks", () => {
beforeEach(() => {
vi.clearAllMocks();
});
// Helper function to create a mock CacheService
const createMockCacheService = (isRedisAvailable: boolean = true) => ({
getRedisClient: vi.fn(),
withTimeout: vi.fn(),
get: vi.fn(),
exists: vi.fn(),
set: vi.fn(),
del: vi.fn(),
keys: vi.fn(),
withCache: vi.fn(),
flush: vi.fn(),
tryGetCachedValue: vi.fn(),
trySetCache: vi.fn(),
isRedisAvailable: vi.fn().mockResolvedValue(isRedisAvailable),
});
describe("checkDatabaseHealth", () => {
test("should return healthy when database query succeeds", async () => {
vi.mocked(prisma.$queryRaw).mockResolvedValue([{ "?column?": 1 }]);
const result = await checkDatabaseHealth();
expect(result).toEqual({ ok: true, data: true });
expect(prisma.$queryRaw).toHaveBeenCalledWith(["SELECT 1"]);
});
test("should return unhealthy when database query fails", async () => {
const dbError = new Error("Database connection failed");
vi.mocked(prisma.$queryRaw).mockRejectedValue(dbError);
const result = await checkDatabaseHealth();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
expect(result.error.details).toEqual([
{ field: "main_database", issue: "Database health check failed" },
]);
}
});
test("should handle different types of database errors", async () => {
const networkError = new Error("ECONNREFUSED");
vi.mocked(prisma.$queryRaw).mockRejectedValue(networkError);
const result = await checkDatabaseHealth();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
expect(result.error.details).toEqual([
{ field: "main_database", issue: "Database health check failed" },
]);
}
});
});
describe("checkCacheHealth", () => {
test("should return healthy when Redis is available", async () => {
const mockCacheService = createMockCacheService(true);
vi.mocked(getCacheService).mockResolvedValue(ok(mockCacheService as any));
const result = await checkCacheHealth();
expect(result).toEqual({ ok: true, data: true });
expect(getCacheService).toHaveBeenCalled();
expect(mockCacheService.isRedisAvailable).toHaveBeenCalled();
});
test("should return unhealthy when cache service fails to initialize", async () => {
const cacheError = { code: ErrorCode.RedisConnectionError };
vi.mocked(getCacheService).mockResolvedValue(err(cacheError));
const result = await checkCacheHealth();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
expect(result.error.details).toEqual([
{ field: "cache_database", issue: "Cache service not available" },
]);
}
});
test("should return unhealthy when Redis is not available", async () => {
const mockCacheService = createMockCacheService(false);
vi.mocked(getCacheService).mockResolvedValue(ok(mockCacheService as any));
const result = await checkCacheHealth();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
expect(result.error.details).toEqual([{ field: "cache_database", issue: "Redis not available" }]);
}
expect(mockCacheService.isRedisAvailable).toHaveBeenCalled();
});
test("should handle Redis availability check exceptions", async () => {
const mockCacheService = createMockCacheService(true);
mockCacheService.isRedisAvailable.mockRejectedValue(new Error("Redis ping failed"));
vi.mocked(getCacheService).mockResolvedValue(ok(mockCacheService as any));
const result = await checkCacheHealth();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
expect(result.error.details).toEqual([
{ field: "cache_database", issue: "Redis health check failed" },
]);
}
});
test("should handle cache service initialization exceptions", async () => {
const serviceException = new Error("Cache service unavailable");
vi.mocked(getCacheService).mockRejectedValue(serviceException);
const result = await checkCacheHealth();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
expect(result.error.details).toEqual([
{ field: "cache_database", issue: "Redis health check failed" },
]);
}
});
test("should verify isRedisAvailable is called asynchronously", async () => {
const mockCacheService = createMockCacheService(true);
vi.mocked(getCacheService).mockResolvedValue(ok(mockCacheService as any));
await checkCacheHealth();
// Verify the async method was called
expect(mockCacheService.isRedisAvailable).toHaveBeenCalledTimes(1);
expect(mockCacheService.isRedisAvailable).toReturnWith(Promise.resolve(true));
});
});
describe("performHealthChecks", () => {
test("should return all healthy when both checks pass", async () => {
// Mock successful database check
vi.mocked(prisma.$queryRaw).mockResolvedValue([{ "?column?": 1 }]);
// Mock successful cache check
const mockCacheService = createMockCacheService(true);
vi.mocked(getCacheService).mockResolvedValue(ok(mockCacheService as any));
const result = await performHealthChecks();
expect(result).toEqual({
ok: true,
data: {
main_database: true,
cache_database: true,
},
});
});
test("should return mixed results when only database is healthy", async () => {
// Mock successful database check
vi.mocked(prisma.$queryRaw).mockResolvedValue([{ "?column?": 1 }]);
// Mock failed cache check
vi.mocked(getCacheService).mockResolvedValue(err({ code: ErrorCode.RedisConnectionError }));
const result = await performHealthChecks();
expect(result).toEqual({
ok: true,
data: {
main_database: true,
cache_database: false,
},
});
});
test("should return mixed results when only cache is healthy", async () => {
// Mock failed database check
vi.mocked(prisma.$queryRaw).mockRejectedValue(new Error("DB Error"));
// Mock successful cache check
const mockCacheService = createMockCacheService(true);
vi.mocked(getCacheService).mockResolvedValue(ok(mockCacheService as any));
const result = await performHealthChecks();
expect(result).toEqual({
ok: true,
data: {
main_database: false,
cache_database: true,
},
});
});
test("should return all unhealthy when both checks fail", async () => {
// Mock failed database check
vi.mocked(prisma.$queryRaw).mockRejectedValue(new Error("DB Error"));
// Mock failed cache check
vi.mocked(getCacheService).mockResolvedValue(err({ code: ErrorCode.RedisConnectionError }));
const result = await performHealthChecks();
expect(result).toEqual({
ok: true,
data: {
main_database: false,
cache_database: false,
},
});
});
test("should run both checks in parallel", async () => {
const dbPromise = new Promise((resolve) => setTimeout(() => resolve([{ "?column?": 1 }]), 100));
const redisPromise = new Promise((resolve) => setTimeout(() => resolve(true), 100));
vi.mocked(prisma.$queryRaw).mockReturnValue(dbPromise as any);
const mockCacheService = createMockCacheService(true);
mockCacheService.isRedisAvailable.mockReturnValue(redisPromise as any);
vi.mocked(getCacheService).mockResolvedValue(ok(mockCacheService as any));
const startTime = Date.now();
await performHealthChecks();
const endTime = Date.now();
// Should complete in roughly 100ms (parallel) rather than 200ms (sequential)
expect(endTime - startTime).toBeLessThan(150);
});
test("should return error only on catastrophic failure (endpoint itself fails)", async () => {
// Mock a catastrophic failure in Promise.all itself
const originalPromiseAll = Promise.all;
vi.spyOn(Promise, "all").mockRejectedValue(new Error("Catastrophic system failure"));
const result = await performHealthChecks();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
expect(result.error.details).toEqual([{ field: "health", issue: "Failed to perform health checks" }]);
}
// Restore original Promise.all
Promise.all = originalPromiseAll;
});
});
});
+15
View File
@@ -0,0 +1,15 @@
import { responses } from "@/modules/api/v2/lib/response";
import { performHealthChecks } from "./lib/health-checks";
export const GET = async () => {
const healthStatusResult = await performHealthChecks();
if (!healthStatusResult.ok) {
return responses.serviceUnavailableResponse({
details: healthStatusResult.error.details,
});
}
return responses.successResponse({
data: healthStatusResult.data,
});
};
@@ -0,0 +1,22 @@
import { z } from "zod";
import { extendZodWithOpenApi } from "zod-openapi";
extendZodWithOpenApi(z);
export const ZOverallHealthStatus = z
.object({
main_database: z.boolean().openapi({
description: "Main database connection status - true if database is reachable and running",
example: true,
}),
cache_database: z.boolean().openapi({
description: "Cache database connection status - true if cache database is reachable and running",
example: true,
}),
})
.openapi({
title: "Health Check Response",
description: "Health check status for critical application dependencies",
});
export type OverallHealthStatus = z.infer<typeof ZOverallHealthStatus>;
+30
View File
@@ -232,6 +232,35 @@ const internalServerErrorResponse = ({
);
};
const serviceUnavailableResponse = ({
details = [],
cors = false,
cache = "private, no-store",
}: {
details?: ApiErrorDetails;
cors?: boolean;
cache?: string;
} = {}) => {
const headers = {
...(cors && corsHeaders),
"Cache-Control": cache,
};
return Response.json(
{
error: {
code: 503,
message: "Service Unavailable",
details,
},
},
{
status: 503,
headers,
}
);
};
const successResponse = ({
data,
meta,
@@ -325,6 +354,7 @@ export const responses = {
unprocessableEntityResponse,
tooManyRequestsResponse,
internalServerErrorResponse,
serviceUnavailableResponse,
successResponse,
createdResponse,
multiStatusResponse,
@@ -1,8 +1,8 @@
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import * as Sentry from "@sentry/nextjs";
import { describe, expect, test, vi } from "vitest";
import { ZodError } from "zod";
import { logger } from "@formbricks/logger";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { formatZodError, handleApiError, logApiError, logApiRequest } from "../utils";
const mockRequest = new Request("http://localhost");
@@ -12,6 +12,15 @@ mockRequest.headers.set("x-request-id", "123");
vi.mock("@sentry/nextjs", () => ({
captureException: vi.fn(),
withScope: vi.fn((callback: (scope: any) => void) => {
const mockScope = {
setTag: vi.fn(),
setContext: vi.fn(),
setLevel: vi.fn(),
setExtra: vi.fn(),
};
callback(mockScope);
}),
}));
// Mock SENTRY_DSN constant
@@ -232,7 +241,7 @@ describe("utils", () => {
});
// Verify error was called on the child logger
expect(errorMock).toHaveBeenCalledWith("API Error Details");
expect(errorMock).toHaveBeenCalledWith("API V2 Error Details");
// Restore the original method
logger.withContext = originalWithContext;
@@ -266,7 +275,7 @@ describe("utils", () => {
});
// Verify error was called on the child logger
expect(errorMock).toHaveBeenCalledWith("API Error Details");
expect(errorMock).toHaveBeenCalledWith("API V2 Error Details");
// Restore the original method
logger.withContext = originalWithContext;
@@ -303,7 +312,7 @@ describe("utils", () => {
});
// Verify error was called on the child logger
expect(errorMock).toHaveBeenCalledWith("API Error Details");
expect(errorMock).toHaveBeenCalledWith("API V2 Error Details");
// Verify Sentry.captureException was called
expect(Sentry.captureException).toHaveBeenCalled();
+10 -10
View File
@@ -1,8 +1,8 @@
// Function is this file can be used in edge runtime functions, like api routes.
import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import * as Sentry from "@sentry/nextjs";
import { logger } from "@formbricks/logger";
import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
export const logApiErrorEdge = (request: Request, error: ApiErrorResponseV2): void => {
const correlationId = request.headers.get("x-request-id") ?? "";
@@ -10,14 +10,14 @@ export const logApiErrorEdge = (request: Request, error: ApiErrorResponseV2): vo
// Send the error to Sentry if the DSN is set and the error type is internal_server_error
// This is useful for tracking down issues without overloading Sentry with errors
if (SENTRY_DSN && IS_PRODUCTION && error.type === "internal_server_error") {
const err = new Error(`API V2 error, id: ${correlationId}`);
// Use Sentry scope to add correlation ID as a tag for easy filtering
Sentry.withScope((scope) => {
scope.setTag("correlationId", correlationId);
scope.setLevel("error");
Sentry.captureException(err, {
extra: {
details: error.details,
type: error.type,
correlationId,
},
scope.setExtra("originalError", error);
const err = new Error(`API V2 error, id: ${correlationId}`);
Sentry.captureException(err);
});
}
@@ -26,5 +26,5 @@ export const logApiErrorEdge = (request: Request, error: ApiErrorResponseV2): vo
correlationId,
error,
})
.error("API Error Details");
.error("API V2 Error Details");
};
+7 -6
View File
@@ -1,17 +1,18 @@
// @ts-nocheck // We can remove this when we update the prisma client and the typescript version
// if we don't add this we get build errors with prisma due to type-nesting
import { ZodCustomIssue, ZodIssue } from "zod";
import { logger } from "@formbricks/logger";
import { TApiAuditLog } from "@/app/lib/api/with-api-logging";
import { AUDIT_LOG_ENABLED } from "@/lib/constants";
import { responses } from "@/modules/api/v2/lib/response";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
import { ZodCustomIssue, ZodIssue } from "zod";
import { logger } from "@formbricks/logger";
import { logApiErrorEdge } from "./utils-edge";
export const handleApiError = (
request: Request,
err: ApiErrorResponseV2,
auditLog?: ApiAuditLog
auditLog?: TApiAuditLog
): Response => {
logApiError(request, err, auditLog);
@@ -55,7 +56,7 @@ export const formatZodError = (error: { issues: (ZodIssue | ZodCustomIssue)[] })
});
};
export const logApiRequest = (request: Request, responseStatus: number, auditLog?: ApiAuditLog): void => {
export const logApiRequest = (request: Request, responseStatus: number, auditLog?: TApiAuditLog): void => {
const method = request.method;
const url = new URL(request.url);
const path = url.pathname;
@@ -82,13 +83,13 @@ export const logApiRequest = (request: Request, responseStatus: number, auditLog
logAuditLog(request, auditLog);
};
export const logApiError = (request: Request, error: ApiErrorResponseV2, auditLog?: ApiAuditLog): void => {
export const logApiError = (request: Request, error: ApiErrorResponseV2, auditLog?: TApiAuditLog): void => {
logApiErrorEdge(request, error);
logAuditLog(request, auditLog);
};
const logAuditLog = (request: Request, auditLog?: ApiAuditLog): void => {
const logAuditLog = (request: Request, auditLog?: TApiAuditLog): void => {
if (AUDIT_LOG_ENABLED && auditLog) {
const correlationId = request.headers.get("x-request-id") ?? "";
queueAuditEvent({
@@ -1,3 +1,5 @@
import { healthPaths } from "@/modules/api/v2/health/lib/openapi";
import { ZOverallHealthStatus } from "@/modules/api/v2/health/types/health-status";
import { contactAttributeKeyPaths } from "@/modules/api/v2/management/contact-attribute-keys/lib/openapi";
import { responsePaths } from "@/modules/api/v2/management/responses/lib/openapi";
import { surveyContactLinksBySegmentPaths } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/lib/openapi";
@@ -35,6 +37,7 @@ const document = createDocument({
version: "2.0.0",
},
paths: {
...healthPaths,
...rolePaths,
...mePaths,
...responsePaths,
@@ -55,6 +58,10 @@ const document = createDocument({
},
],
tags: [
{
name: "Health",
description: "Operations for checking critical application dependencies health status.",
},
{
name: "Roles",
description: "Operations for managing roles.",
@@ -114,6 +121,7 @@ const document = createDocument({
},
},
schemas: {
health: ZOverallHealthStatus,
role: ZRoles,
me: ZApiKeyData,
response: ZResponse,
+10 -10
View File
@@ -1,12 +1,12 @@
import { randomBytes } from "crypto";
import { Provider } from "next-auth/providers/index";
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { EMAIL_VERIFICATION_DISABLED } from "@/lib/constants";
import { createToken } from "@/lib/jwt";
// Import mocked rate limiting functions
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { randomBytes } from "crypto";
import { Provider } from "next-auth/providers/index";
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { authOptions } from "./authOptions";
import { mockUser } from "./mock-data";
import { hashPassword } from "./utils";
@@ -31,7 +31,7 @@ vi.mock("@/lib/constants", () => ({
SESSION_MAX_AGE: 86400,
NEXTAUTH_SECRET: "test-secret",
WEBAPP_URL: "http://localhost:3000",
ENCRYPTION_KEY: "test-encryption-key-32-chars-long",
ENCRYPTION_KEY: "12345678901234567890123456789012", // 32 bytes for AES-256
REDIS_URL: undefined,
AUDIT_LOG_ENABLED: false,
AUDIT_LOG_GET_USER_IP: false,
@@ -261,7 +261,7 @@ describe("authOptions", () => {
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
vi.spyOn(prisma.user, "findUnique").mockResolvedValue(mockUser as any);
const credentials = { token: createToken(mockUser.id, mockUser.email) };
const credentials = { token: createToken(mockUser.id) };
await expect(tokenProvider.options.authorize(credentials, {})).rejects.toThrow(
"Email already verified"
@@ -280,7 +280,7 @@ describe("authOptions", () => {
groupId: null,
} as any);
const credentials = { token: createToken(mockUserId, mockUser.email) };
const credentials = { token: createToken(mockUserId) };
const result = await tokenProvider.options.authorize(credentials, {});
expect(result.email).toBe(mockUser.email);
@@ -303,7 +303,7 @@ describe("authOptions", () => {
groupId: null,
} as any);
const credentials = { token: createToken(mockUserId, mockUser.email) };
const credentials = { token: createToken(mockUserId) };
await tokenProvider.options.authorize(credentials, {});
@@ -315,7 +315,7 @@ describe("authOptions", () => {
new Error("Maximum number of requests reached. Please try again later.")
);
const credentials = { token: createToken(mockUserId, mockUser.email) };
const credentials = { token: createToken(mockUserId) };
await expect(tokenProvider.options.authorize(credentials, {})).rejects.toThrow(
"Maximum number of requests reached. Please try again later."
@@ -339,7 +339,7 @@ describe("authOptions", () => {
groupId: null,
} as any);
const credentials = { token: createToken(mockUserId, mockUser.email) };
const credentials = { token: createToken(mockUserId) };
await tokenProvider.options.authorize(credentials, {});
@@ -1,13 +1,13 @@
"use client";
import { cn } from "@/lib/cn";
import { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button";
import { ConfirmationModal } from "@/modules/ui/components/confirmation-modal";
import { useTranslate } from "@tolgee/react";
import { CheckIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { TOrganization, TOrganizationBillingPeriod } from "@formbricks/types/organizations";
import { cn } from "@/lib/cn";
import { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button";
import { ConfirmationModal } from "@/modules/ui/components/confirmation-modal";
import { TPricingPlan } from "../api/lib/constants";
interface PricingCardProps {
@@ -170,14 +170,13 @@ export const PricingCard = ({
{plan.id !== projectFeatureKeys.FREE && isCurrentPlan && (
<Button
variant="secondary"
loading={loading}
onClick={async () => {
setLoading(true);
await onManageSubscription();
setLoading(false);
}}
className="flex justify-center">
className="flex justify-center bg-[#635bff]">
{t("environments.settings.billing.manage_subscription")}
</Button>
)}
@@ -2,6 +2,7 @@
import { Button } from "@/modules/ui/components/button";
import { Label } from "@/modules/ui/components/label";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
import { useTranslate } from "@tolgee/react";
import { CopyIcon, Trash2Icon } from "lucide-react";
import { TSurveyQuota } from "@formbricks/types/quota";
@@ -37,26 +38,30 @@ export const QuotaList = ({ quotas, onEdit, deleteQuota, duplicateQuota }: Quota
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
deleteQuota(quota);
}}
className="h-8 w-8 p-0 text-slate-500">
<Trash2Icon className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
duplicateQuota(quota);
}}
className="h-8 w-8 p-0 text-slate-500">
<CopyIcon className="h-4 w-4" />
</Button>
<TooltipRenderer tooltipContent={t("common.delete")}>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
deleteQuota(quota);
}}
className="h-8 w-8 p-0 text-slate-500">
<Trash2Icon className="h-4 w-4" />
</Button>
</TooltipRenderer>
<TooltipRenderer tooltipContent={t("common.duplicate")}>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
duplicateQuota(quota);
}}
className="h-8 w-8 p-0 text-slate-500">
<CopyIcon className="h-4 w-4" />
</Button>
</TooltipRenderer>
</div>
</div>
))}
+11 -11
View File
@@ -1,3 +1,12 @@
import { render } from "@react-email/render";
import { createTransport } from "nodemailer";
import type SMTPTransport from "nodemailer/lib/smtp-transport";
import { logger } from "@formbricks/logger";
import type { TLinkSurveyEmailData } from "@formbricks/types/email";
import { InvalidInputError } from "@formbricks/types/errors";
import type { TResponse } from "@formbricks/types/responses";
import type { TSurvey } from "@formbricks/types/surveys/types";
import { TUserEmail, TUserLocale } from "@formbricks/types/user";
import {
DEBUG,
MAIL_FROM,
@@ -17,15 +26,6 @@ import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import NewEmailVerification from "@/modules/email/emails/auth/new-email-verification";
import { EmailCustomizationPreviewEmail } from "@/modules/email/emails/general/email-customization-preview-email";
import { getTranslate } from "@/tolgee/server";
import { render } from "@react-email/render";
import { createTransport } from "nodemailer";
import type SMTPTransport from "nodemailer/lib/smtp-transport";
import { logger } from "@formbricks/logger";
import type { TLinkSurveyEmailData } from "@formbricks/types/email";
import { InvalidInputError } from "@formbricks/types/errors";
import type { TResponse } from "@formbricks/types/responses";
import type { TSurvey } from "@formbricks/types/surveys/types";
import { TUserEmail, TUserLocale } from "@formbricks/types/user";
import { ForgotPasswordEmail } from "./emails/auth/forgot-password-email";
import { PasswordResetNotifyEmail } from "./emails/auth/password-reset-notify-email";
import { VerificationEmail } from "./emails/auth/verification-email";
@@ -111,7 +111,7 @@ export const sendVerificationEmail = async ({
}): Promise<boolean> => {
try {
const t = await getTranslate();
const token = createToken(id, email, {
const token = createToken(id, {
expiresIn: "1d",
});
const verifyLink = `${WEBAPP_URL}/auth/verify?token=${encodeURIComponent(token)}`;
@@ -136,7 +136,7 @@ export const sendForgotPasswordEmail = async (user: {
locale: TUserLocale;
}): Promise<boolean> => {
const t = await getTranslate();
const token = createToken(user.id, user.email, {
const token = createToken(user.id, {
expiresIn: "1d",
});
const verifyLink = `${WEBAPP_URL}/auth/forgot-password/reset?token=${encodeURIComponent(token)}`;
@@ -1,6 +1,5 @@
"use client";
import { getOrganizationAccessKeyDisplayName } from "@/modules/organization/settings/api-keys/lib/utils";
import { TOrganizationProject } from "@/modules/organization/settings/api-keys/types/api-keys";
import { Alert, AlertTitle } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
@@ -24,7 +23,7 @@ import { Switch } from "@/modules/ui/components/switch";
import { ApiKeyPermission } from "@prisma/client";
import { useTranslate } from "@tolgee/react";
import { ChevronDownIcon, Trash2Icon } from "lucide-react";
import { Fragment, useState } from "react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "react-hot-toast";
import { TOrganizationAccess } from "@formbricks/types/api-key";
@@ -220,10 +219,10 @@ export const AddApiKeyModal = ({
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("environments.project.api_keys.add_api_key")}</DialogTitle>
<DialogTitle className="px-1">{t("environments.project.api_keys.add_api_key")}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(submitAPIKey)} className="contents">
<DialogBody className="space-y-4 overflow-y-auto py-4">
<DialogBody className="space-y-4 overflow-y-auto px-1 py-4">
<div className="space-y-2">
<Label>{t("environments.project.api_keys.api_key_label")}</Label>
<Input
@@ -348,43 +347,31 @@ export const AddApiKeyModal = ({
</div>
<div className="space-y-4">
<div>
<Label>{t("environments.project.api_keys.organization_access")}</Label>
<p className="text-sm text-slate-500">
{t("environments.project.api_keys.organization_access_description")}
</p>
</div>
<div className="space-y-2">
<div className="grid grid-cols-[auto_100px_100px] gap-4">
<div></div>
<span className="flex items-center justify-center text-sm font-medium">Read</span>
<span className="flex items-center justify-center text-sm font-medium">Write</span>
{Object.keys(selectedOrganizationAccess).map((key) => (
<Fragment key={key}>
<div className="py-1 text-sm">{getOrganizationAccessKeyDisplayName(key, t)}</div>
<div className="flex items-center justify-center py-1">
<Switch
data-testid={`organization-access-${key}-read`}
checked={selectedOrganizationAccess[key].read}
onCheckedChange={(newVal) =>
setSelectedOrganizationAccessValue(key, "read", newVal)
}
/>
</div>
<div className="flex items-center justify-center py-1">
<Switch
data-testid={`organization-access-${key}-write`}
checked={selectedOrganizationAccess[key].write}
onCheckedChange={(newVal) =>
setSelectedOrganizationAccessValue(key, "write", newVal)
}
/>
</div>
</Fragment>
))}
<Label>{t("environments.project.api_keys.organization_access")}</Label>
{Object.keys(selectedOrganizationAccess).map((key) => (
<div key={key} className="mt-2 flex items-center gap-6">
<div className="flex items-center gap-2">
<Label>Read</Label>
<Switch
data-testid={`organization-access-${key}-read`}
checked={selectedOrganizationAccess[key].read || selectedOrganizationAccess[key].write}
onCheckedChange={(newVal) => setSelectedOrganizationAccessValue(key, "read", newVal)}
disabled={selectedOrganizationAccess[key].write}
/>
</div>
<div className="flex items-center gap-2">
<Label>Write</Label>
<Switch
data-testid={`organization-access-${key}-write`}
checked={selectedOrganizationAccess[key].write}
onCheckedChange={(newVal) => setSelectedOrganizationAccessValue(key, "write", newVal)}
/>
</div>
</div>
</div>
))}
<p className="text-sm text-slate-500">
{t("environments.project.api_keys.organization_access_description")}
</p>
</div>
<Alert variant="warning">
<AlertTitle>{t("environments.project.api_keys.api_key_security_warning")}</AlertTitle>
@@ -1,7 +1,6 @@
import { ApiKeyPermission } from "@prisma/client";
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import React from "react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TProject } from "@formbricks/types/project";
import { TApiKeyWithEnvironmentPermission } from "../types/api-keys";
@@ -104,6 +103,8 @@ describe("ViewPermissionModal", () => {
setOpen: vi.fn(),
projects: mockProjects,
apiKey: mockApiKey,
onSubmit: vi.fn(),
isUpdating: false,
};
test("renders the modal with correct title", () => {
@@ -154,7 +155,7 @@ describe("ViewPermissionModal", () => {
expect(screen.getByTestId("organization-access-accessControl-read")).toBeDisabled();
expect(screen.getByTestId("organization-access-accessControl-write")).not.toBeChecked();
expect(screen.getByTestId("organization-access-accessControl-write")).toBeDisabled();
expect(screen.getByTestId("organization-access-otherAccess-read")).not.toBeChecked();
expect(screen.getByTestId("organization-access-otherAccess-read")).toBeChecked();
expect(screen.getByTestId("organization-access-otherAccess-write")).toBeChecked();
});
});
@@ -1,6 +1,5 @@
"use client";
import { getOrganizationAccessKeyDisplayName } from "@/modules/organization/settings/api-keys/lib/utils";
import {
TApiKeyUpdateInput,
TApiKeyWithEnvironmentPermission,
@@ -22,7 +21,7 @@ import { Label } from "@/modules/ui/components/label";
import { Switch } from "@/modules/ui/components/switch";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslate } from "@tolgee/react";
import { Fragment, useEffect } from "react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { TOrganizationAccess } from "@formbricks/types/api-key";
@@ -168,36 +167,28 @@ export const ViewPermissionModal = ({
})}
</div>
</div>
<div className="space-y-2">
<div className="space-y-4">
<Label>{t("environments.project.api_keys.organization_access")}</Label>
<div className="space-y-2">
<div className="grid grid-cols-[auto_100px_100px] gap-4">
<div></div>
<span className="flex items-center justify-center text-sm font-medium">Read</span>
<span className="flex items-center justify-center text-sm font-medium">Write</span>
{Object.keys(organizationAccess).map((key) => (
<Fragment key={key}>
<div className="py-1 text-sm">{getOrganizationAccessKeyDisplayName(key, t)}</div>
<div className="flex items-center justify-center py-1">
<Switch
disabled={true}
data-testid={`organization-access-${key}-read`}
checked={organizationAccess[key].read}
/>
</div>
<div className="flex items-center justify-center py-1">
<Switch
disabled={true}
data-testid={`organization-access-${key}-write`}
checked={organizationAccess[key].write}
/>
</div>
</Fragment>
))}
{Object.keys(organizationAccess).map((key) => (
<div key={key} className="mb-2 flex items-center gap-6">
<div className="flex items-center gap-2">
<Label className="text-sm font-medium">Read</Label>
<Switch
disabled={true}
data-testid={`organization-access-${key}-read`}
checked={organizationAccess[key].read || organizationAccess[key].write}
/>
</div>
<div className="flex items-center gap-2">
<Label className="text-sm font-medium">Write</Label>
<Switch
disabled={true}
data-testid={`organization-access-${key}-write`}
checked={organizationAccess[key].write}
/>
</div>
</div>
</div>
))}
</div>
</div>
</form>
@@ -1,6 +1,6 @@
import { describe, expect, test, vi } from "vitest";
import { describe, expect, test } from "vitest";
import { TAPIKeyEnvironmentPermission } from "@formbricks/types/auth";
import { getOrganizationAccessKeyDisplayName, hasPermission } from "./utils";
import { hasPermission } from "./utils";
describe("hasPermission", () => {
const envId = "env1";
@@ -83,17 +83,3 @@ describe("hasPermission", () => {
expect(hasPermission(permissions, "other", "GET")).toBe(false);
});
});
describe("getOrganizationAccessKeyDisplayName", () => {
test("returns tolgee string for accessControl", () => {
const t = vi.fn((k) => k);
expect(getOrganizationAccessKeyDisplayName("accessControl", t)).toBe(
"environments.project.api_keys.access_control"
);
expect(t).toHaveBeenCalledWith("environments.project.api_keys.access_control");
});
test("returns tolgee string for other keys", () => {
const t = vi.fn((k) => k);
expect(getOrganizationAccessKeyDisplayName("otherKey", t)).toBe("otherKey");
});
});
@@ -1,4 +1,3 @@
import { TFnType } from "@tolgee/react";
import { OrganizationAccessType } from "@formbricks/types/api-key";
import { TAPIKeyEnvironmentPermission, TAuthenticationApiKey } from "@formbricks/types/auth";
@@ -43,15 +42,6 @@ export const hasPermission = (
}
};
export const getOrganizationAccessKeyDisplayName = (key: string, t: TFnType) => {
switch (key) {
case "accessControl":
return t("environments.project.api_keys.access_control");
default:
return key;
}
};
export const hasOrganizationAccess = (
authentication: TAuthenticationApiKey,
accessType: OrganizationAccessType
@@ -32,13 +32,7 @@ describe("TemplateFilters", () => {
test("renders all filter categories and options", () => {
const setSelectedFilter = vi.fn();
render(
<TemplateFilters
selectedFilter={[null, null, null]}
setSelectedFilter={setSelectedFilter}
prefilledFilters={[null, null, null]}
/>
);
render(<TemplateFilters selectedFilter={[null, null, null]} setSelectedFilter={setSelectedFilter} />);
expect(screen.getByText("environments.surveys.templates.all_channels")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.templates.all_industries")).toBeInTheDocument();
@@ -54,13 +48,7 @@ describe("TemplateFilters", () => {
const setSelectedFilter = vi.fn();
const user = userEvent.setup();
render(
<TemplateFilters
selectedFilter={[null, null, null]}
setSelectedFilter={setSelectedFilter}
prefilledFilters={[null, null, null]}
/>
);
render(<TemplateFilters selectedFilter={[null, null, null]} setSelectedFilter={setSelectedFilter} />);
await user.click(screen.getByText("environments.surveys.templates.channel1"));
expect(setSelectedFilter).toHaveBeenCalledWith(["channel1", null, null]);
@@ -74,11 +62,7 @@ describe("TemplateFilters", () => {
const user = userEvent.setup();
render(
<TemplateFilters
selectedFilter={["link", "app", "website"]}
setSelectedFilter={setSelectedFilter}
prefilledFilters={[null, null, null]}
/>
<TemplateFilters selectedFilter={["link", "app", "website"]} setSelectedFilter={setSelectedFilter} />
);
await user.click(screen.getByText("environments.surveys.templates.all_channels"));
@@ -93,7 +77,6 @@ describe("TemplateFilters", () => {
selectedFilter={[null, null, null]}
setSelectedFilter={setSelectedFilter}
templateSearch="search term"
prefilledFilters={[null, null, null]}
/>
);
@@ -102,20 +85,4 @@ describe("TemplateFilters", () => {
expect(button).toBeDisabled();
});
});
test("does not render filter categories that are prefilled", () => {
const setSelectedFilter = vi.fn();
render(
<TemplateFilters
selectedFilter={["link", null, null]}
setSelectedFilter={setSelectedFilter}
prefilledFilters={["link", null, null]}
/>
);
expect(screen.queryByText("environments.surveys.templates.all_channels")).not.toBeInTheDocument();
expect(screen.getByText("environments.surveys.templates.all_industries")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.templates.all_roles")).toBeInTheDocument();
});
});
@@ -9,14 +9,12 @@ interface TemplateFiltersProps {
selectedFilter: TTemplateFilter[];
setSelectedFilter: (filter: TTemplateFilter[]) => void;
templateSearch?: string;
prefilledFilters: TTemplateFilter[];
}
export const TemplateFilters = ({
selectedFilter,
setSelectedFilter,
templateSearch,
prefilledFilters,
}: TemplateFiltersProps) => {
const { t } = useTranslate();
const handleFilterSelect = (filterValue: TTemplateFilter, index: number) => {
@@ -31,7 +29,6 @@ export const TemplateFilters = ({
return (
<div className="mb-6 gap-3">
{allFilters.map((filters, index) => {
if (prefilledFilters[index] !== null) return;
return (
<div key={filters[0]?.value || index} className="mt-2 flex flex-wrap gap-1 last:border-r-0">
<button
@@ -102,41 +102,20 @@ describe("TemplateList", () => {
});
test("renders correctly with default props", () => {
render(
<TemplateList
userId="user-id"
environmentId="env-id"
project={mockProject}
prefilledFilters={[null, null, null]}
/>
);
render(<TemplateList userId="user-id" environmentId="env-id" project={mockProject} />);
expect(screen.getByText("Start from scratch")).toBeInTheDocument();
});
test("renders filters when showFilters is true", () => {
render(
<TemplateList
userId="user-id"
environmentId="env-id"
project={mockProject}
prefilledFilters={[null, null, null]}
showFilters={true}
/>
);
render(<TemplateList userId="user-id" environmentId="env-id" project={mockProject} showFilters={true} />);
expect(screen.queryByTestId("template-filters")).toBeInTheDocument();
});
test("doesn't render filters when showFilters is false", () => {
render(
<TemplateList
userId="user-id"
environmentId="env-id"
project={mockProject}
prefilledFilters={[null, null, null]}
showFilters={false}
/>
<TemplateList userId="user-id" environmentId="env-id" project={mockProject} showFilters={false} />
);
expect(screen.queryByTestId("template-filters")).not.toBeInTheDocument();
@@ -150,7 +129,6 @@ describe("TemplateList", () => {
userId="user-id"
environmentId="env-id"
project={mockProject}
prefilledFilters={[null, null, null]}
templateSearch="Template 1"
/>
);
@@ -167,7 +145,6 @@ describe("TemplateList", () => {
userId="user-id"
environmentId="env-id"
project={mockProject}
prefilledFilters={[null, null, null]}
onTemplateClick={onTemplateClickMock}
noPreview={true}
/>
@@ -186,14 +163,7 @@ describe("TemplateList", () => {
const user = userEvent.setup();
render(
<TemplateList
userId="user-id"
environmentId="env-id"
project={mockProject}
prefilledFilters={[null, null, null]}
/>
);
render(<TemplateList userId="user-id" environmentId="env-id" project={mockProject} />);
// First select the template
const selectButton = screen.getAllByText("Select")[0];
@@ -220,14 +190,7 @@ describe("TemplateList", () => {
const user = userEvent.setup();
render(
<TemplateList
userId="user-id"
environmentId="env-id"
project={mockProject}
prefilledFilters={[null, null, null]}
/>
);
render(<TemplateList userId="user-id" environmentId="env-id" project={mockProject} />);
// First select the template
const selectButton = screen.getAllByText("Select")[0];
@@ -250,12 +213,7 @@ describe("TemplateList", () => {
};
const { rerender } = render(
<TemplateList
userId="user-id"
environmentId="env-id"
project={mobileProject as Project}
prefilledFilters={[null, null, null]}
/>
<TemplateList userId="user-id" environmentId="env-id" project={mobileProject as Project} />
);
// Test with no channel config
@@ -264,14 +222,7 @@ describe("TemplateList", () => {
config: {},
};
rerender(
<TemplateList
userId="user-id"
environmentId="env-id"
project={noChannelProject as Project}
prefilledFilters={[null, null, null]}
/>
);
rerender(<TemplateList userId="user-id" environmentId="env-id" project={noChannelProject as Project} />);
expect(screen.getByText("Template 1")).toBeInTheDocument();
});
@@ -279,14 +230,7 @@ describe("TemplateList", () => {
test("development mode shows templates correctly", () => {
vi.stubEnv("NODE_ENV", "development");
render(
<TemplateList
userId="user-id"
environmentId="env-id"
project={mockProject}
prefilledFilters={[null, null, null]}
/>
);
render(<TemplateList userId="user-id" environmentId="env-id" project={mockProject} />);
expect(screen.getByText("Template 1")).toBeInTheDocument();
expect(screen.getByText("Template 2")).toBeInTheDocument();
@@ -21,7 +21,6 @@ interface TemplateListProps {
project: Project;
templateSearch?: string;
showFilters?: boolean;
prefilledFilters: TTemplateFilter[];
onTemplateClick?: (template: TTemplate) => void;
noPreview?: boolean; // single click to create survey
}
@@ -32,7 +31,6 @@ export const TemplateList = ({
environmentId,
showFilters = true,
templateSearch,
prefilledFilters,
onTemplateClick = () => {},
noPreview,
}: TemplateListProps) => {
@@ -40,7 +38,7 @@ export const TemplateList = ({
const router = useRouter();
const [activeTemplate, setActiveTemplate] = useState<TTemplate | null>(null);
const [loading, setLoading] = useState(false);
const [selectedFilter, setSelectedFilter] = useState<TTemplateFilter[]>(prefilledFilters);
const [selectedFilter, setSelectedFilter] = useState<TTemplateFilter[]>([null, null, null]);
const surveyType: TSurveyType = useMemo(() => {
if (project.config.channel) {
if (project.config.channel === "website") {
@@ -111,7 +109,6 @@ export const TemplateList = ({
selectedFilter={selectedFilter}
setSelectedFilter={setSelectedFilter}
templateSearch={templateSearch}
prefilledFilters={prefilledFilters}
/>
)}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
@@ -241,4 +241,40 @@ describe("ConditionalLogic", () => {
expect(screen.getAllByTestId("logic-editor").length).toBe(2);
});
test("should clear logicFallback when logic array is empty and logicFallback exists (useEffect)", () => {
const mockUpdateQuestion = vi.fn();
const mockQuestion: TSurveyQuestion = {
id: "testQuestionId",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Test Question" },
required: false,
inputType: "text",
charLimit: { enabled: false },
logic: [], // Empty logic array
logicFallback: "someTarget", // Has logicFallback but no logic
};
const mockSurvey = {
id: "testSurveyId",
name: "Test Survey",
type: "link",
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "testEnvId",
status: "inProgress",
questions: [mockQuestion],
endings: [],
} as unknown as TSurvey;
render(
<ConditionalLogic
localSurvey={mockSurvey}
question={mockQuestion}
questionIdx={0}
updateQuestion={mockUpdateQuestion}
/>
);
expect(mockUpdateQuestion).toHaveBeenCalledWith(0, { logicFallback: undefined });
});
});
@@ -1,5 +1,19 @@
"use client";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { createId } from "@paralleldrive/cuid2";
import { useTranslate } from "@tolgee/react";
import {
ArrowDownIcon,
ArrowUpIcon,
CopyIcon,
EllipsisVerticalIcon,
PlusIcon,
SplitIcon,
TrashIcon,
} from "lucide-react";
import { useEffect, useMemo } from "react";
import { TSurvey, TSurveyLogic, TSurveyQuestion } from "@formbricks/types/surveys/types";
import { duplicateLogicItem } from "@/lib/surveyLogic/utils";
import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { LogicEditor } from "@/modules/survey/editor/components/logic-editor";
@@ -15,20 +29,6 @@ import {
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { Label } from "@/modules/ui/components/label";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { createId } from "@paralleldrive/cuid2";
import { useTranslate } from "@tolgee/react";
import {
ArrowDownIcon,
ArrowUpIcon,
CopyIcon,
EllipsisVerticalIcon,
PlusIcon,
SplitIcon,
TrashIcon,
} from "lucide-react";
import { useMemo } from "react";
import { TSurvey, TSurveyLogic, TSurveyQuestion } from "@formbricks/types/surveys/types";
interface ConditionalLogicProps {
localSurvey: TSurvey;
@@ -117,6 +117,12 @@ export function ConditionalLogic({
};
const [parent] = useAutoAnimate();
useEffect(() => {
if (question.logic?.length === 0 && question.logicFallback) {
updateQuestion(questionIdx, { logicFallback: undefined });
}
}, [question.logic, questionIdx, question.logicFallback, updateQuestion]);
return (
<div className="mt-4" ref={parent}>
<Label className="flex gap-2">
@@ -1,10 +1,10 @@
"use client";
import { useTranslate } from "@tolgee/react";
import { TConditionGroup, TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types";
import { createSharedConditionsFactory } from "@/modules/survey/editor/lib/shared-conditions-factory";
import { getDefaultOperatorForQuestion } from "@/modules/survey/editor/lib/utils";
import { ConditionsEditor } from "@/modules/ui/components/conditions-editor";
import { useTranslate } from "@tolgee/react";
import { TConditionGroup, TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types";
interface LogicEditorConditionsProps {
conditions: TConditionGroup;
@@ -1,5 +1,3 @@
import { LogicEditorActions } from "@/modules/survey/editor/components/logic-editor-actions";
import { LogicEditorConditions } from "@/modules/survey/editor/components/logic-editor-conditions";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import {
@@ -8,6 +6,8 @@ import {
TSurveyQuestion,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import { LogicEditorActions } from "@/modules/survey/editor/components/logic-editor-actions";
import { LogicEditorConditions } from "@/modules/survey/editor/components/logic-editor-conditions";
import { LogicEditor } from "./logic-editor";
// Mock the subcomponents to isolate the LogicEditor component
@@ -1,5 +1,9 @@
"use client";
import { useTranslate } from "@tolgee/react";
import { ArrowRightIcon } from "lucide-react";
import { ReactElement, useMemo } from "react";
import { TSurvey, TSurveyLogic, TSurveyQuestion } from "@formbricks/types/surveys/types";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { LogicEditorActions } from "@/modules/survey/editor/components/logic-editor-actions";
import { LogicEditorConditions } from "@/modules/survey/editor/components/logic-editor-conditions";
@@ -11,10 +15,6 @@ import {
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import { useTranslate } from "@tolgee/react";
import { ArrowRightIcon } from "lucide-react";
import { ReactElement, useMemo } from "react";
import { TSurvey, TSurveyLogic, TSurveyQuestion } from "@formbricks/types/surveys/types";
interface LogicEditorProps {
localSurvey: TSurvey;
@@ -8,7 +8,7 @@ import { ConfirmationModal } from "@/modules/ui/components/confirmation-modal";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
import { createId } from "@paralleldrive/cuid2";
import { useTranslate } from "@tolgee/react";
import { CopyPlusIcon, TrashIcon } from "lucide-react";
import { CopyIcon, Trash2Icon } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { TSurveyFollowUp } from "@formbricks/database/types/survey-follow-up";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
@@ -154,7 +154,7 @@ export const FollowUpItem = ({
setDeleteFollowUpModalOpen(true);
}}
aria-label={t("common.delete")}>
<TrashIcon className="h-4 w-4 text-slate-500" />
<Trash2Icon className="h-4 w-4 text-slate-500" />
</Button>
</TooltipRenderer>
@@ -167,7 +167,7 @@ export const FollowUpItem = ({
duplicateFollowUp();
}}
aria-label={t("common.duplicate")}>
<CopyPlusIcon className="h-4 w-4 text-slate-500" />
<CopyIcon className="h-4 w-4 text-slate-500" />
</Button>
</TooltipRenderer>
</div>
@@ -186,14 +186,12 @@ export const FollowUpModal = ({
const handleSubmit = (data: TCreateSurveyFollowUpForm) => {
if (data.triggerType === "endings" && data.endingIds?.length === 0) {
toast.error("Please select at least one ending or change the trigger type");
toast.error(t("environments.surveys.edit.follow_ups_modal_trigger_type_ending_warning"));
return;
}
if (!emailSendToOptions.length) {
toast.error(
"No valid options found for sending emails, please add some open-text / contact-info questions or hidden fields"
);
toast.error(t("environments.surveys.edit.follow_ups_modal_action_to_warning"));
return;
}
@@ -264,7 +262,7 @@ export const FollowUpModal = ({
},
};
toast.success("Survey follow up updated successfully");
toast.success(t("environments.surveys.edit.follow_ups_modal_updated_successfull_toast"));
setOpen(false);
setLocalSurvey((prev) => {
return {
@@ -311,7 +309,7 @@ export const FollowUpModal = ({
},
};
toast.success("Survey follow up created successfully");
toast.success(t("environments.surveys.edit.follow_ups_modal_created_successfull_toast"));
setOpen(false);
form.reset();
setLocalSurvey((prev) => {
@@ -1,3 +1,6 @@
import { type Response } from "@prisma/client";
import { notFound } from "next/navigation";
import { TSurvey } from "@formbricks/types/surveys/types";
import {
IMPRINT_URL,
IS_FORMBRICKS_CLOUD,
@@ -16,9 +19,6 @@ import { PinScreen } from "@/modules/survey/link/components/pin-screen";
import { SurveyInactive } from "@/modules/survey/link/components/survey-inactive";
import { getEmailVerificationDetails } from "@/modules/survey/link/lib/helper";
import { getProjectByEnvironmentId } from "@/modules/survey/link/lib/project";
import { type Response } from "@prisma/client";
import { notFound } from "next/navigation";
import { TSurvey } from "@formbricks/types/surveys/types";
interface SurveyRendererProps {
survey: TSurvey;
@@ -59,7 +59,7 @@ export const renderSurvey = async ({
const isSpamProtectionEnabled = Boolean(IS_RECAPTCHA_CONFIGURED && survey.recaptcha?.enabled);
if (survey.status !== "inProgress" && !isPreview) {
if (survey.status !== "inProgress") {
const project = await getProjectByEnvironmentId(survey.environmentId);
return (
<SurveyInactive
+20 -43
View File
@@ -5,7 +5,6 @@ import { Session } from "next-auth";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { TProject } from "@formbricks/types/project";
import { TTemplateRole } from "@formbricks/types/templates";
import { SurveysPage } from "./page";
// Mock all dependencies
@@ -53,19 +52,16 @@ vi.mock("@/modules/survey/list/lib/survey", () => ({
}));
vi.mock("@/modules/survey/templates/components/template-container", () => ({
TemplateContainerWithPreview: vi.fn(
({ userId, environment, project, prefilledFilters, isTemplatePage }) => (
<div
data-testid="template-container"
data-user-id={userId}
data-environment-id={environment.id}
data-project-id={project.id}
data-prefilled-filters={JSON.stringify(prefilledFilters)}
data-is-template-page={isTemplatePage}>
Template Container
</div>
)
),
TemplateContainerWithPreview: vi.fn(({ userId, environment, project, isTemplatePage }) => (
<div
data-testid="template-container"
data-user-id={userId}
data-environment-id={environment.id}
data-project-id={project.id}
data-is-template-page={isTemplatePage}>
Template Container
</div>
)),
}));
vi.mock("@/modules/ui/components/button", () => ({
@@ -207,9 +203,8 @@ describe("SurveysPage", () => {
mockTranslate.mockReturnValue("Project not found");
const params = Promise.resolve({ environmentId: "env-123" });
const searchParams = Promise.resolve({});
await expect(SurveysPage({ params, searchParams })).rejects.toThrow("Project not found");
await expect(SurveysPage({ params })).rejects.toThrow("Project not found");
expect(mockGetProjectWithTeamIdsByEnvironmentId).toHaveBeenCalledWith("env-123");
expect(mockTranslate).toHaveBeenCalledWith("common.project_not_found");
@@ -225,9 +220,8 @@ describe("SurveysPage", () => {
});
const params = Promise.resolve({ environmentId: "env-123" });
const searchParams = Promise.resolve({});
await SurveysPage({ params, searchParams });
await SurveysPage({ params });
expect(mockRedirect).toHaveBeenCalledWith("/environments/env-123/settings/billing");
});
@@ -236,9 +230,8 @@ describe("SurveysPage", () => {
mockGetSurveyCount.mockResolvedValue(0);
const params = Promise.resolve({ environmentId: "env-123" });
const searchParams = Promise.resolve({ role: "product_manager" as TTemplateRole });
const result = await SurveysPage({ params, searchParams });
const result = await SurveysPage({ params });
render(result);
expect(screen.getByTestId("template-container")).toBeInTheDocument();
@@ -246,20 +239,14 @@ describe("SurveysPage", () => {
expect(screen.getByTestId("template-container")).toHaveAttribute("data-environment-id", "env-123");
expect(screen.getByTestId("template-container")).toHaveAttribute("data-project-id", "project-123");
expect(screen.getByTestId("template-container")).toHaveAttribute("data-is-template-page", "false");
const prefilledFilters = JSON.parse(
screen.getByTestId("template-container").getAttribute("data-prefilled-filters") || "[]"
);
expect(prefilledFilters).toEqual(["website", "other", "product_manager"]);
});
test("renders surveys list when survey count is greater than 0", async () => {
mockGetSurveyCount.mockResolvedValue(5);
const params = Promise.resolve({ environmentId: "env-123" });
const searchParams = Promise.resolve({});
const result = await SurveysPage({ params, searchParams });
const result = await SurveysPage({ params });
render(result);
expect(screen.getByTestId("page-content-wrapper")).toBeInTheDocument();
@@ -289,9 +276,8 @@ describe("SurveysPage", () => {
mockGetSurveyCount.mockResolvedValue(5);
const params = Promise.resolve({ environmentId: "env-123" });
const searchParams = Promise.resolve({});
const result = await SurveysPage({ params, searchParams });
const result = await SurveysPage({ params });
render(result);
expect(screen.getByTestId("page-header")).toBeInTheDocument();
@@ -307,9 +293,8 @@ describe("SurveysPage", () => {
mockGetSurveyCount.mockResolvedValue(0);
const params = Promise.resolve({ environmentId: "env-123" });
const searchParams = Promise.resolve({});
const result = await SurveysPage({ params, searchParams });
const result = await SurveysPage({ params });
render(result);
// When survey count is 0, it should render TemplateContainer regardless of read-only status
@@ -330,16 +315,11 @@ describe("SurveysPage", () => {
mockGetSurveyCount.mockResolvedValue(0);
const params = Promise.resolve({ environmentId: "env-123" });
const searchParams = Promise.resolve({});
const result = await SurveysPage({ params, searchParams });
const result = await SurveysPage({ params });
render(result);
expect(screen.getByTestId("template-container")).toBeInTheDocument();
const prefilledFilters = JSON.parse(
screen.getByTestId("template-container").getAttribute("data-prefilled-filters") || "[]"
);
expect(prefilledFilters).toEqual([null, null, null]);
});
test("handles project with null styling", async () => {
@@ -351,9 +331,8 @@ describe("SurveysPage", () => {
mockGetSurveyCount.mockResolvedValue(0);
const params = Promise.resolve({ environmentId: "env-123" });
const searchParams = Promise.resolve({});
const result = await SurveysPage({ params, searchParams });
const result = await SurveysPage({ params });
render(result);
expect(screen.getByTestId("template-container")).toBeInTheDocument();
@@ -365,9 +344,8 @@ describe("SurveysPage", () => {
mockGetSurveyCount.mockResolvedValue(5);
const params = Promise.resolve({ environmentId: "env-123" });
const searchParams = Promise.resolve({});
const result = await SurveysPage({ params, searchParams });
const result = await SurveysPage({ params });
render(result);
expect(screen.getByTestId("surveys-list")).toHaveAttribute("data-locale", "en-US");
@@ -377,9 +355,8 @@ describe("SurveysPage", () => {
mockGetSurveyCount.mockResolvedValue(5);
const params = Promise.resolve({ environmentId: "env-123" });
const searchParams = Promise.resolve({});
const result = await SurveysPage({ params, searchParams });
const result = await SurveysPage({ params });
render(result);
expect(screen.getByTestId("link")).toHaveAttribute("href", "/environments/env-123/surveys/templates");
+1 -12
View File
@@ -14,7 +14,6 @@ import { PlusIcon } from "lucide-react";
import { Metadata } from "next";
import Link from "next/link";
import { redirect } from "next/navigation";
import { TTemplateRole } from "@formbricks/types/templates";
export const metadata: Metadata = {
title: "Your Surveys",
@@ -24,17 +23,10 @@ interface SurveyTemplateProps {
params: Promise<{
environmentId: string;
}>;
searchParams: Promise<{
role?: TTemplateRole;
}>;
}
export const SurveysPage = async ({
params: paramsProps,
searchParams: searchParamsProps,
}: SurveyTemplateProps) => {
export const SurveysPage = async ({ params: paramsProps }: SurveyTemplateProps) => {
const publicDomain = getPublicDomain();
const searchParams = await searchParamsProps;
const params = await paramsProps;
const t = await getTranslate();
@@ -46,8 +38,6 @@ export const SurveysPage = async ({
const { session, isBilling, environment, isReadOnly } = await getEnvironmentAuth(params.environmentId);
const prefilledFilters = [project?.config.channel, project.config.industry, searchParams.role ?? null];
if (isBilling) {
return redirect(`/environments/${params.environmentId}/settings/billing`);
}
@@ -79,7 +69,6 @@ export const SurveysPage = async ({
userId={session.user.id}
environment={environment}
project={projectWithRequiredProps}
prefilledFilters={prefilledFilters}
isTemplatePage={false}
/>
);
@@ -3,7 +3,6 @@ import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TProjectConfigChannel, TProjectConfigIndustry } from "@formbricks/types/project";
import { TTemplateRole } from "@formbricks/types/templates";
import { TemplateContainerWithPreview } from "./template-container";
// Mock dependencies
@@ -59,8 +58,6 @@ const mockEnvironment = {
appSetupCompleted: true,
};
const mockPrefilledFilters: (TProjectConfigChannel | TProjectConfigIndustry | TTemplateRole | null)[] = [];
describe("TemplateContainerWithPreview", () => {
afterEach(() => {
cleanup();
@@ -72,7 +69,6 @@ describe("TemplateContainerWithPreview", () => {
project={mockProject}
environment={mockEnvironment}
userId="user1"
prefilledFilters={mockPrefilledFilters}
isTemplatePage={true}
/>
);
@@ -86,7 +82,6 @@ describe("TemplateContainerWithPreview", () => {
project={mockProject}
environment={mockEnvironment}
userId="user1"
prefilledFilters={mockPrefilledFilters}
isTemplatePage={false}
/>
);
@@ -100,7 +95,6 @@ describe("TemplateContainerWithPreview", () => {
project={mockProject}
environment={mockEnvironment}
userId="user1"
prefilledFilters={mockPrefilledFilters}
isTemplatePage={true}
/>
);
@@ -114,7 +108,6 @@ describe("TemplateContainerWithPreview", () => {
project={mockProject}
environment={mockEnvironment}
userId="user1"
prefilledFilters={mockPrefilledFilters}
isTemplatePage={false}
/>
);
@@ -128,7 +121,6 @@ describe("TemplateContainerWithPreview", () => {
project={mockProject}
environment={mockEnvironment}
userId="user1"
prefilledFilters={mockPrefilledFilters}
isTemplatePage={true}
/>
);
@@ -144,7 +136,6 @@ describe("TemplateContainerWithPreview", () => {
project={mockProject}
environment={mockEnvironment}
userId="user1"
prefilledFilters={mockPrefilledFilters}
isTemplatePage={true}
/>
);
@@ -158,7 +149,6 @@ describe("TemplateContainerWithPreview", () => {
project={mockProject}
environment={mockEnvironment}
userId="user1"
prefilledFilters={mockPrefilledFilters}
isTemplatePage={true}
/>
);
@@ -172,7 +162,6 @@ describe("TemplateContainerWithPreview", () => {
project={mockProject}
environment={mockEnvironment}
userId="user1"
prefilledFilters={mockPrefilledFilters}
isTemplatePage={true}
/>
);
@@ -5,19 +5,16 @@ import { TemplateList } from "@/modules/survey/components/template-list";
import { MenuBar } from "@/modules/survey/templates/components/menu-bar";
import { PreviewSurvey } from "@/modules/ui/components/preview-survey";
import { SearchBar } from "@/modules/ui/components/search-bar";
import { Project } from "@prisma/client";
import { Environment } from "@prisma/client";
import type { Environment, Project } from "@prisma/client";
import { useTranslate } from "@tolgee/react";
import { useState } from "react";
import type { TProjectConfigChannel, TProjectConfigIndustry } from "@formbricks/types/project";
import type { TTemplate, TTemplateRole } from "@formbricks/types/templates";
import type { TTemplate } from "@formbricks/types/templates";
import { getMinimalSurvey } from "../lib/minimal-survey";
type TemplateContainerWithPreviewProps = {
project: Project;
environment: Pick<Environment, "id" | "appSetupCompleted">;
userId: string;
prefilledFilters: (TProjectConfigChannel | TProjectConfigIndustry | TTemplateRole | null)[];
isTemplatePage?: boolean;
};
@@ -25,7 +22,6 @@ export const TemplateContainerWithPreview = ({
project,
environment,
userId,
prefilledFilters,
isTemplatePage = true,
}: TemplateContainerWithPreviewProps) => {
const { t } = useTranslate();
@@ -63,7 +59,6 @@ export const TemplateContainerWithPreview = ({
setActiveQuestionId(template.preset.questions[0].id);
setActiveTemplate(template);
}}
prefilledFilters={prefilledFilters}
/>
</div>
<aside className="group hidden flex-1 flex-shrink-0 items-center justify-center overflow-hidden border-l border-slate-100 bg-slate-50 md:flex md:flex-col">
+1 -16
View File
@@ -2,23 +2,15 @@ import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { getProjectWithTeamIdsByEnvironmentId } from "@/modules/survey/lib/project";
import { getTranslate } from "@/tolgee/server";
import { redirect } from "next/navigation";
import { TProjectConfigChannel, TProjectConfigIndustry } from "@formbricks/types/project";
import { TTemplateRole } from "@formbricks/types/templates";
import { TemplateContainerWithPreview } from "./components/template-container";
interface SurveyTemplateProps {
params: Promise<{
environmentId: string;
}>;
searchParams: Promise<{
channel?: TProjectConfigChannel;
industry?: TProjectConfigIndustry;
role?: TTemplateRole;
}>;
}
export const SurveyTemplatesPage = async (props: SurveyTemplateProps) => {
const searchParams = await props.searchParams;
const t = await getTranslate();
const params = await props.params;
const environmentId = params.environmentId;
@@ -35,14 +27,7 @@ export const SurveyTemplatesPage = async (props: SurveyTemplateProps) => {
return redirect(`/environments/${environment.id}/surveys`);
}
const prefilledFilters = [project.config.channel, project.config.industry, searchParams.role ?? null];
return (
<TemplateContainerWithPreview
userId={session.user.id}
environment={environment}
project={project}
prefilledFilters={prefilledFilters}
/>
<TemplateContainerWithPreview userId={session.user.id} environment={environment} project={project} />
);
};
@@ -1,7 +1,7 @@
import { cn } from "@/modules/ui/lib/utils";
import { Slot } from "@radix-ui/react-slot";
import { ChevronRightIcon, EllipsisIcon } from "lucide-react";
import * as React from "react";
import { cn } from "@/modules/ui/lib/utils";
const Breadcrumb = React.forwardRef<
HTMLElement,
@@ -15,7 +15,10 @@ const BreadcrumbList = React.forwardRef<HTMLOListElement, React.ComponentPropsWi
({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn("flex flex-wrap items-center gap-1.5 break-words text-sm text-slate-500", className)}
className={cn(
"flex flex-wrap items-center gap-1.5 break-words text-sm text-slate-500 hover:text-slate-700",
className
)}
{...props}
/>
)
@@ -32,7 +35,7 @@ const BreadcrumbItem = React.forwardRef<
<li
ref={ref}
className={cn(
"inline-flex items-center gap-1.5 space-x-1 rounded-md px-1.5 py-1 hover:outline hover:outline-slate-300",
"inline-flex items-center gap-1.5 space-x-1 rounded-md px-1.5 py-1 hover:bg-white hover:outline hover:outline-slate-300",
isActive && "bg-slate-100 outline outline-slate-300",
isHighlighted && "bg-red-800 text-white outline hover:outline-red-800",
className
@@ -80,14 +83,15 @@ const BreadcrumbEllipsis = ({ className, ...props }: React.ComponentProps<"span"
<span className="sr-only">More</span>
</span>
);
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis";
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbEllipsis,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
};
@@ -1,10 +1,10 @@
"use client";
import { cn } from "@/lib/cn";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
import { useTranslate } from "@tolgee/react";
import { Check, Copy } from "lucide-react";
import React from "react";
import { cn } from "@/lib/cn";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
interface BadgeContentProps {
id: string | number;
@@ -59,8 +59,8 @@ export const BadgeContent: React.FC<BadgeContentProps> = ({
};
const content = (
<button
type="button"
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/prefer-tag-over-role, jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<div
role={isCopyEnabled ? "button" : undefined}
className={getButtonClasses()}
onClick={handleCopy}
@@ -69,7 +69,7 @@ export const BadgeContent: React.FC<BadgeContentProps> = ({
onMouseLeave={isCopyEnabled ? () => setIsHovered(false) : undefined}>
<span>{id}</span>
{renderIcon()}
</button>
</div>
);
const getTooltipContent = () => {
@@ -96,7 +96,7 @@ describe("IdBadge", () => {
test("removes interactive elements when copy is disabled", () => {
const { container } = render(<IdBadge id="1734" copyDisabled={true} />);
const badge = container.querySelector("button");
const badge = container.querySelector("div");
// Should not have cursor-pointer class
expect(badge).not.toHaveClass("cursor-pointer");
@@ -1,38 +1,51 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { afterEach, describe, expect, test } from "vitest";
import { NoMobileOverlay } from "./index";
// Mock the tolgee translation
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) =>
key === "common.mobile_overlay_text" ? "Please use desktop to access this section" : key,
}),
}));
describe("NoMobileOverlay", () => {
afterEach(() => {
cleanup();
});
test("renders overlay with correct text", () => {
test("renders title and paragraphs", () => {
render(<NoMobileOverlay />);
expect(screen.getByText("Please use desktop to access this section")).toBeInTheDocument();
expect(
screen.getByRole("heading", { level: 1, name: "common.mobile_overlay_title" })
).toBeInTheDocument();
expect(screen.getByText("common.mobile_overlay_app_works_best_on_desktop")).toBeInTheDocument();
expect(screen.getByText("common.mobile_overlay_surveys_look_good")).toBeInTheDocument();
});
test("has proper z-index for overlay", () => {
test("has proper overlay classes (z-index and responsive hide)", () => {
render(<NoMobileOverlay />);
const overlay = screen.getByText("Please use desktop to access this section").closest("div.fixed");
const overlay = document.querySelector("div.fixed");
expect(overlay).toBeInTheDocument();
expect(overlay).toHaveClass("z-[9999]");
});
test("has responsive layout with sm:hidden class", () => {
render(<NoMobileOverlay />);
const overlay = screen.getByText("Please use desktop to access this section").closest("div.fixed");
expect(overlay).toHaveClass("sm:hidden");
});
test("renders learn more link with correct href", () => {
render(<NoMobileOverlay />);
const link = screen.getByRole("link", { name: "common.learn_more" });
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute("href", "https://formbricks.com/docs/xm-and-surveys/overview");
});
test("stacks icons with maximize centered inside smartphone", () => {
const { container } = render(<NoMobileOverlay />);
const wrapper = container.querySelector("div.relative.h-16.w-16");
expect(wrapper).toBeInTheDocument();
const phoneSvg = wrapper?.querySelector("svg.h-16.w-16");
expect(phoneSvg).toBeInTheDocument();
const expandSvg = wrapper?.querySelector("svg.absolute");
expect(expandSvg).toBeInTheDocument();
expect(expandSvg).toHaveClass("left-1/2", "top-1/3", "-translate-x-1/2", "-translate-y-1/3");
});
});
@@ -1,20 +1,35 @@
"use client";
import { useTranslate } from "@tolgee/react";
import { SmartphoneIcon, XIcon } from "lucide-react";
import { ExternalLinkIcon, Maximize2Icon, SmartphoneIcon } from "lucide-react";
import { Button } from "@/modules/ui/components/button";
export const NoMobileOverlay = () => {
const { t } = useTranslate();
return (
<>
<div className="fixed inset-0 z-[9999] flex items-center justify-center sm:hidden">
<div className="relative h-full w-full bg-slate-50"></div>
<div className="bg-slate-850 absolute mx-8 flex flex-col items-center gap-6 rounded-lg px-8 py-10 text-center">
<XIcon className="absolute top-14 h-8 w-8 text-slate-500" />
<SmartphoneIcon className="h-16 w-16 text-slate-500" />
<p className="text-slate-500">{t("common.mobile_overlay_text")}</p>
<div className="fixed inset-0 z-[9999] sm:hidden">
<div className="absolute inset-0 bg-slate-50"></div>
<div className="relative mx-auto flex h-full max-w-xl flex-col items-center justify-center py-16 text-center">
<div className="relative h-16 w-16">
<SmartphoneIcon className="text-muted-foreground h-16 w-16" />
<Maximize2Icon className="text-muted-foreground absolute left-1/2 top-1/3 h-5 w-5 -translate-x-1/2 -translate-y-1/3" />
</div>
<h1 className="mt-2 text-2xl font-bold text-zinc-900 dark:text-white">
{t("common.mobile_overlay_title")}
</h1>
<p className="mt-2 text-base text-zinc-600 dark:text-zinc-400">
{t("common.mobile_overlay_app_works_best_on_desktop")}
</p>
<p className="mt-2 text-base text-zinc-600 dark:text-zinc-400">
{t("common.mobile_overlay_surveys_look_good")}
</p>
<Button variant="default" asChild className="mt-8">
<a href="https://formbricks.com/docs/xm-and-surveys/overview">
{t("common.learn_more")}
<ExternalLinkIcon />
</a>
</Button>
</div>
</>
</div>
);
};
+1 -1
View File
@@ -164,7 +164,7 @@ const nextConfig = {
},
{
key: "Content-Security-Policy",
value: `default-src 'self'; script-src 'self' 'unsafe-inline'${scriptSrcUnsafeEval} https://*.intercom.io https://*.intercomcdn.com https:; style-src 'self' 'unsafe-inline' https://*.intercomcdn.com https:; img-src 'self' blob: data: https://*.intercom.io https://*.intercomcdn.com https:; font-src 'self' data: https://*.intercomcdn.com https:; connect-src 'self' http://localhost:9000 https://*.intercom.io wss://*.intercom.io https://*.intercomcdn.com https:; frame-src 'self' https://*.intercom.io https://app.cal.com https:; media-src 'self' https:; object-src 'self' data: https:; base-uri 'self'; form-action 'self'`,
value: `default-src 'self'; script-src 'self' 'unsafe-inline'${scriptSrcUnsafeEval} https://*.intercom.io https://*.intercomcdn.com https:; style-src 'self' 'unsafe-inline' https://*.intercomcdn.com https:; img-src 'self' blob: data: http://localhost:9000 https://*.intercom.io https://*.intercomcdn.com https:; font-src 'self' data: https://*.intercomcdn.com https:; connect-src 'self' http://localhost:9000 https://*.intercom.io wss://*.intercom.io https://*.intercomcdn.com https:; frame-src 'self' https://*.intercom.io https://app.cal.com https:; media-src 'self' https:; object-src 'self' data: https:; base-uri 'self'; form-action 'self'`,
},
{
key: "Strict-Transport-Security",
+1
View File
@@ -3,6 +3,7 @@ export const SURVEYS_API_URL = `/api/v1/management/surveys`;
export const WEBHOOKS_API_URL = `/api/v2/management/webhooks`;
export const ROLES_API_URL = `/api/v2/roles`;
export const ME_API_URL = `/api/v2/me`;
export const HEALTH_API_URL = `/api/v2/health`;
export const TEAMS_API_URL = (organizationId: string) => `/api/v2/organizations/${organizationId}/teams`;
export const PROJECT_TEAMS_API_URL = (organizationId: string) =>
+135
View File
@@ -0,0 +1,135 @@
import { expect } from "@playwright/test";
import { logger } from "@formbricks/logger";
import { test } from "../lib/fixtures";
import { HEALTH_API_URL } from "./constants";
test.describe("API Tests for Health Endpoint", () => {
test("Health check returns 200 with dependency status", async ({ request }) => {
try {
// Make request to health endpoint (no authentication required)
const response = await request.get(HEALTH_API_URL);
// Should always return 200 if the health check endpoint can execute
expect(response.status()).toBe(200);
const responseBody = await response.json();
// Verify response structure
expect(responseBody).toHaveProperty("data");
expect(responseBody.data).toHaveProperty("main_database");
expect(responseBody.data).toHaveProperty("cache_database");
// Verify data types are boolean
expect(typeof responseBody.data.main_database).toBe("boolean");
expect(typeof responseBody.data.cache_database).toBe("boolean");
// Log the health status for debugging
logger.info(
{
main_database: responseBody.data.main_database,
cache_database: responseBody.data.cache_database,
},
"Health check status"
);
// In a healthy system, we expect both to be true
// But we don't fail the test if they're false - that's what the health check is for
if (!responseBody.data.main_database) {
logger.warn("Main database is unhealthy");
}
if (!responseBody.data.cache_database) {
logger.warn("Cache database is unhealthy");
}
} catch (error) {
logger.error(error, "Error during health check API test");
throw error;
}
});
test("Health check response time is reasonable", async ({ request }) => {
try {
const startTime = Date.now();
const response = await request.get(HEALTH_API_URL);
const endTime = Date.now();
const responseTime = endTime - startTime;
expect(response.status()).toBe(200);
// Health check should respond within 5 seconds
expect(responseTime).toBeLessThan(5000);
logger.info({ responseTime }, "Health check response time");
} catch (error) {
logger.error(error, "Error during health check performance test");
throw error;
}
});
test("Health check is accessible without authentication", async ({ request }) => {
try {
// Make request without any headers or authentication
const response = await request.get(HEALTH_API_URL, {
headers: {
// Explicitly no x-api-key or other auth headers
},
});
// Should be accessible without authentication
expect(response.status()).toBe(200);
const responseBody = await response.json();
expect(responseBody).toHaveProperty("data");
} catch (error) {
logger.error(error, "Error during unauthenticated health check test");
throw error;
}
});
test("Health check handles CORS properly", async ({ request }) => {
try {
// Test with OPTIONS request (preflight)
const optionsResponse = await request.fetch(HEALTH_API_URL, {
method: "OPTIONS",
});
// OPTIONS should succeed or at least not be a server error
expect(optionsResponse.status()).not.toBe(500);
// Test regular GET request
const getResponse = await request.get(HEALTH_API_URL);
expect(getResponse.status()).toBe(200);
} catch (error) {
logger.error(error, "Error during CORS health check test");
throw error;
}
});
test("Health check OpenAPI schema compliance", async ({ request }) => {
try {
const response = await request.get(HEALTH_API_URL);
expect(response.status()).toBe(200);
const responseBody = await response.json();
// Verify it matches our OpenAPI schema
expect(responseBody).toMatchObject({
data: {
main_database: expect.any(Boolean),
cache_database: expect.any(Boolean),
},
});
// Ensure no extra properties in the response data
const dataKeys = Object.keys(responseBody.data);
expect(dataKeys).toHaveLength(2);
expect(dataKeys).toContain("main_database");
expect(dataKeys).toContain("cache_database");
} catch (error) {
logger.error(error, "Error during OpenAPI schema compliance test");
throw error;
}
});
});
-3
View File
@@ -226,7 +226,6 @@ services:
ports:
- 3000:3000
volumes:
- uploads:/home/nextjs/apps/web/uploads/
- ./saml-connection:/home/nextjs/apps/web/saml-connection
<<: *environment
@@ -235,5 +234,3 @@ volumes:
driver: local
redis:
driver: local
uploads:
driver: local
+210 -73
View File
@@ -293,7 +293,7 @@ EOT
minio_service_user="formbricks-service-$(openssl rand -hex 4)"
minio_service_password=$(openssl rand -base64 20)
minio_bucket_name="formbricks-uploads"
minio_policy_name="formbricks-policy-$(openssl rand -hex 4)"
minio_policy_name="formbricks-policy"
echo "✅ MinIO will be configured with:"
echo " S3 Access Key (least privilege): $minio_service_user"
@@ -340,11 +340,11 @@ EOT
sed -i "s|# S3_BUCKET_NAME:|S3_BUCKET_NAME: \"$ext_s3_bucket\"|" docker-compose.yml
if [[ -n $ext_s3_endpoint ]]; then
sed -i "s|# S3_ENDPOINT_URL:|S3_ENDPOINT_URL: \"$ext_s3_endpoint\"|" docker-compose.yml
# Ensure S3_FORCE_PATH_STYLE is enabled for S3-compatible endpoints (match commented or uncommented)
sed -i 's|#\? *S3_FORCE_PATH_STYLE:.*|S3_FORCE_PATH_STYLE: 1|' docker-compose.yml
# Ensure S3_FORCE_PATH_STYLE is enabled for S3-compatible endpoints
sed -E -i 's|^([[:space:]]*)#?[[:space:]]*S3_FORCE_PATH_STYLE:[[:space:]]*.*$|\1S3_FORCE_PATH_STYLE: 1|' docker-compose.yml
else
# Comment out S3_FORCE_PATH_STYLE for native AWS S3 (match commented or uncommented)
sed -i 's|#\? *S3_FORCE_PATH_STYLE:.*|# S3_FORCE_PATH_STYLE:|' docker-compose.yml
# Comment out S3_FORCE_PATH_STYLE for native AWS S3
sed -E -i 's|^([[:space:]]*)#?[[:space:]]*S3_FORCE_PATH_STYLE:[[:space:]]*.*$|\1# S3_FORCE_PATH_STYLE:|' docker-compose.yml
fi
echo "🚗 External S3 configuration updated successfully!"
elif [[ $minio_storage == "y" ]]; then
@@ -358,8 +358,8 @@ EOT
else
sed -i "s|# S3_ENDPOINT_URL:|S3_ENDPOINT_URL: \"http://$files_domain\"|" docker-compose.yml
fi
# Ensure S3_FORCE_PATH_STYLE is enabled for MinIO (match commented or uncommented)
sed -i 's|#\? *S3_FORCE_PATH_STYLE:.*|S3_FORCE_PATH_STYLE: 1|' docker-compose.yml
# Ensure S3_FORCE_PATH_STYLE is enabled for MinIO
sed -E -i 's|^([[:space:]]*)#?[[:space:]]*S3_FORCE_PATH_STYLE:[[:space:]]*.*$|\1S3_FORCE_PATH_STYLE: 1|' docker-compose.yml
echo "🚗 MinIO S3 configuration updated successfully!"
fi
@@ -394,19 +394,50 @@ EOT
{ print }
' docker-compose.yml >tmp.yml && mv tmp.yml docker-compose.yml
# Step 2: Add minio-init dependency to formbricks if MinIO enabled
# Step 2: Ensure formbricks waits for minio-init to complete successfully (mapping depends_on)
if [[ $minio_storage == "y" ]]; then
sed -i '/formbricks:/,/depends_on:/{
/- postgres/a\ - minio-init
}' docker-compose.yml
# Remove any existing simple depends_on list and replace with mapping
awk '
BEGIN{in_fb=0; removing=0}
/^ formbricks:/ {in_fb=1}
in_fb && /^ depends_on:/ {removing=1; next}
in_fb && removing && /^ [A-Za-z0-9_-]+:/ {removing=0}
/^ [A-Za-z0-9_-]+:/ && !/^ formbricks:/ {in_fb=0}
{ if(!removing) print }
' docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml
awk '
BEGIN{in_fb=0; inserted=0}
/^ formbricks:/ {in_fb=1}
/^ [A-Za-z0-9_-]+:/ && !/^ formbricks:/ {in_fb=0}
{
print
if (in_fb && !inserted && $0 ~ /^ image:/) {
print " depends_on:"
print " postgres:"
print " condition: service_started"
print " minio-init:"
print " condition: service_completed_successfully"
inserted=1
}
}
' docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml
fi
# Step 3: Build service snippets and inject them BEFORE the volumes section (robust, no sed -i multiline)
# Step 3: Build service snippets and inject them BEFORE the volumes section (non-destructive: skip if service exists)
services_snippet_file="services_snippet.yml"
: > "$services_snippet_file"
insert_traefik="y"
if grep -q "^ traefik:" docker-compose.yml; then insert_traefik="n"; fi
if [[ $minio_storage == "y" ]]; then
cat > "$services_snippet_file" << EOF
insert_minio="y"; insert_minio_init="y"
if grep -q "^ minio:" docker-compose.yml; then insert_minio="n"; fi
if grep -q "^ minio-init:" docker-compose.yml; then insert_minio_init="n"; fi
if [[ $insert_minio == "y" ]]; then
cat >> "$services_snippet_file" << EOF
minio:
restart: always
@@ -417,11 +448,6 @@ EOT
MINIO_ROOT_PASSWORD: "$minio_root_password"
volumes:
- minio-data:/data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 30s
timeout: 20s
retries: 3
labels:
- "traefik.enable=true"
# S3 API on files subdomain
@@ -440,52 +466,29 @@ EOT
- "traefik.http.middlewares.minio-cors.headers.addvaryheader=true"
- "traefik.http.middlewares.minio-ratelimit.ratelimit.average=100"
- "traefik.http.middlewares.minio-ratelimit.ratelimit.burst=200"
EOF
fi
if [[ $insert_minio_init == "y" ]]; then
cat >> "$services_snippet_file" << EOF
minio-init:
image: minio/mc@sha256:95b5f3f7969a5c5a9f3a700ba72d5c84172819e13385aaf916e237cf111ab868
depends_on:
minio:
condition: service_healthy
- minio
environment:
MINIO_ROOT_USER: "$minio_root_user"
MINIO_ROOT_PASSWORD: "$minio_root_password"
MINIO_SERVICE_USER: "$minio_service_user"
MINIO_SERVICE_PASSWORD: "$minio_service_password"
MINIO_BUCKET_NAME: "$minio_bucket_name"
entrypoint:
- /bin/sh
- -c
- |
echo '🔗 Setting up MinIO alias...';
mc alias set minio http://minio:9000 "$minio_root_user" "$minio_root_password";
echo '🪣 Creating bucket (idempotent)...';
mc mb minio/$minio_bucket_name --ignore-existing;
echo '📄 Creating JSON policy file...';
printf '%s' "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"s3:DeleteObject\",\"s3:GetObject\",\"s3:PutObject\"],\"Resource\":[\"arn:aws:s3:::$minio_bucket_name/*\"]},{\"Effect\":\"Allow\",\"Action\":[\"s3:ListBucket\"],\"Resource\":[\"arn:aws:s3:::$minio_bucket_name\"]}]}" > /tmp/formbricks-policy.json
echo '🔒 Creating policy (idempotent)...';
if ! mc admin policy info minio $minio_policy_name >/dev/null 2>&1; then
mc admin policy create minio $minio_policy_name /tmp/formbricks-policy.json || mc admin policy add minio $minio_policy_name /tmp/formbricks-policy.json;
echo 'Policy created successfully.';
else
echo 'Policy already exists, skipping creation.';
fi
echo '👤 Creating service user (idempotent)...';
if ! mc admin user info minio "$minio_service_user" >/dev/null 2>&1; then
mc admin user add minio "$minio_service_user" "$minio_service_password";
echo 'User created successfully.';
else
echo 'User already exists, skipping creation.';
fi
echo '🔗 Attaching policy to user (idempotent)...';
mc admin policy attach minio $minio_policy_name --user "$minio_service_user" || echo 'Policy already attached or attachment failed (non-fatal).';
echo '✅ MinIO setup complete!';
exit 0;
entrypoint: ["/bin/sh", "/tmp/minio-init.sh"]
volumes:
- ./minio-init.sh:/tmp/minio-init.sh:ro
EOF
fi
if [[ $insert_traefik == "y" ]]; then
cat >> "$services_snippet_file" << EOF
traefik:
image: "traefik:v2.7"
restart: always
@@ -502,6 +505,7 @@ EOT
- ./acme.json:/acme.json
- /var/run/docker.sock:/var/run/docker.sock:ro
EOF
fi
# Downgrade MinIO router to plain HTTP when HTTPS is not configured
if [[ $https_setup != "y" ]]; then
@@ -511,7 +515,8 @@ EOF
sed -i "s|accesscontrolalloworiginlist=https://$domain_name|accesscontrolalloworiginlist=http://$domain_name|" "$services_snippet_file"
fi
else
cat > "$services_snippet_file" << EOF
if [[ $insert_traefik == "y" ]]; then
cat > "$services_snippet_file" << EOF
traefik:
image: "traefik:v2.7"
@@ -528,6 +533,9 @@ EOF
- ./acme.json:/acme.json
- /var/run/docker.sock:/var/run/docker.sock:ro
EOF
else
: > "$services_snippet_file"
fi
fi
awk '
@@ -543,30 +551,122 @@ EOF
rm -f "$services_snippet_file"
# Deterministically rewrite the volumes section to include required volumes
awk -v add_minio="$minio_storage" '
BEGIN { in_vol=0 }
/^volumes:/ {
print "volumes:";
print " postgres:";
print " driver: local";
print " uploads:";
print " driver: local";
if (add_minio == "y") {
print " minio-data:";
print " driver: local";
# Ensure required volumes exist without removing user-defined volumes
if grep -q '^volumes:' docker-compose.yml; then
# Ensure postgres
if ! awk '/^volumes:/{invol=1; next} invol && (/^[^[:space:]]/ || NF==0){invol=0} invol{ if($1=="postgres:") found=1 } END{ exit(found?0:1) }' docker-compose.yml; then
awk '
/^volumes:/ { print; invol=1; next }
invol && /^[^[:space:]]/ { if(!added){ print " postgres:"; print " driver: local"; added=1 } ; invol=0 }
{ print }
END { if (invol && !added) { print " postgres:"; print " driver: local" } }
' docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml
fi
# Ensure redis
if ! awk '/^volumes:/{invol=1; next} invol && (/^[^[:space:]]/ || NF==0){invol=0} invol{ if($1=="redis:") found=1 } END{ exit(found?0:1) }' docker-compose.yml; then
awk '
/^volumes:/ { print; invol=1; next }
invol && /^[^[:space:]]/ { if(!added){ print " redis:"; print " driver: local"; added=1 } ; invol=0 }
{ print }
END { if (invol && !added) { print " redis:"; print " driver: local" } }
' docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml
fi
# Ensure minio-data if needed
if [[ $minio_storage == "y" ]]; then
if ! awk '/^volumes:/{invol=1; next} invol && (/^[^[:space:]]/ || NF==0){invol=0} invol{ if($1=="minio-data:") found=1 } END{ exit(found?0:1) }' docker-compose.yml; then
awk '
/^volumes:/ { print; invol=1; next }
invol && /^[^[:space:]]/ { if(!added){ print " minio-data:"; print " driver: local"; added=1 } ; invol=0 }
{ print }
END { if (invol && !added) { print " minio-data:"; print " driver: local" } }
' docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml
fi
fi
else
{
echo ""
echo "volumes:"
echo " postgres:"
echo " driver: local"
echo " redis:"
echo " driver: local"
if [[ $minio_storage == "y" ]]; then
echo " minio-data:"
echo " driver: local"
fi
} >> docker-compose.yml
fi
# Create minio-init script outside heredoc to avoid variable expansion issues
if [[ $minio_storage == "y" ]]; then
cat > minio-init.sh << 'MINIO_SCRIPT_EOF'
#!/bin/sh
echo '⏳ Waiting for MinIO to be ready...'
attempts=0
max_attempts=30
until mc alias set minio http://minio:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" >/dev/null 2>&1 \
&& mc ls minio >/dev/null 2>&1; do
attempts=$((attempts + 1))
if [ $attempts -ge $max_attempts ]; then
printf '❌ Failed to connect to MinIO after %s attempts\n' $max_attempts
exit 1
fi
printf '...still waiting attempt %s/%s\n' $attempts $max_attempts
sleep 2
done
echo '🔗 MinIO reachable; alias configured.'
echo '🪣 Creating bucket (idempotent)...';
mc mb minio/$MINIO_BUCKET_NAME --ignore-existing;
echo '📄 Creating JSON policy file...';
cat > /tmp/formbricks-policy.json << EOF
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:DeleteObject", "s3:GetObject", "s3:PutObject"],
"Resource": ["arn:aws:s3:::$MINIO_BUCKET_NAME/*"]
},
{
"Effect": "Allow",
"Action": ["s3:ListBucket"],
"Resource": ["arn:aws:s3:::$MINIO_BUCKET_NAME"]
}
in_vol=1; skip=1; next
}
# Skip original volumes block lines until EOF (we already printed ours)
{ if (!skip) print }
' docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml
]
}
EOF
echo '🔒 Creating policy (idempotent)...';
if ! mc admin policy info minio formbricks-policy >/dev/null 2>&1; then
mc admin policy create minio formbricks-policy /tmp/formbricks-policy.json || mc admin policy add minio formbricks-policy /tmp/formbricks-policy.json;
echo 'Policy created successfully.';
else
echo 'Policy already exists, skipping creation.';
fi
echo '👤 Creating service user (idempotent)...';
if ! mc admin user info minio "$MINIO_SERVICE_USER" >/dev/null 2>&1; then
mc admin user add minio "$MINIO_SERVICE_USER" "$MINIO_SERVICE_PASSWORD";
echo 'User created successfully.';
else
echo 'User already exists, skipping creation.';
fi
echo '🔗 Attaching policy to user (idempotent)...';
mc admin policy attach minio formbricks-policy --user "$MINIO_SERVICE_USER" || echo 'Policy already attached or attachment failed (non-fatal).';
echo '✅ MinIO setup complete!';
exit 0;
MINIO_SCRIPT_EOF
chmod +x minio-init.sh
fi
newgrp docker <<END
docker compose up -d
echo "🔗 To edit more variables and deeper config, go to the formbricks/docker-compose.yml, edit the file, and restart the container!"
echo "🚨 Make sure you have set up the DNS records as well as inbound rules for the domain name and IP address of this instance."
@@ -630,6 +730,40 @@ get_logs() {
sudo docker compose logs
}
cleanup_minio_init() {
echo "🧹 Cleaning up MinIO init service and references..."
cd formbricks
# Remove minio-init service block from docker-compose.yml
awk '
BEGIN{skip=0}
/^services:[[:space:]]*$/ { print; next }
/^ minio-init:/ { skip=1; next }
/^ [A-Za-z0-9_-]+:/ { if (skip) skip=0 }
{ if (!skip) print }
' docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml
# Remove list-style "- minio-init" lines under depends_on (if any)
if sed --version >/dev/null 2>&1; then
sed -E -i '/^[[:space:]]*-[[:space:]]*minio-init[[:space:]]*$/d' docker-compose.yml
else
sed -E -i '' '/^[[:space:]]*-[[:space:]]*minio-init[[:space:]]*$/d' docker-compose.yml
fi
# Remove the minio-init mapping and its condition line (mapping style depends_on)
if sed --version >/dev/null 2>&1; then
sed -i '/^[[:space:]]*minio-init:[[:space:]]*$/,/^[[:space:]]*condition:[[:space:]]*service_completed_successfully[[:space:]]*$/d' docker-compose.yml
else
sed -i '' '/^[[:space:]]*minio-init:[[:space:]]*$/,/^[[:space:]]*condition:[[:space:]]*service_completed_successfully[[:space:]]*$/d' docker-compose.yml
fi
# Remove any stopped minio-init container and restart without orphans
docker compose rm -f -s minio-init >/dev/null 2>&1 || true
docker compose up -d --remove-orphans
echo "✅ MinIO init cleanup complete."
}
case "$1" in
install)
install_formbricks
@@ -646,6 +780,9 @@ restart)
logs)
get_logs
;;
cleanup-minio-init)
cleanup_minio_init
;;
uninstall)
uninstall_formbricks
;;
+1472
View File
File diff suppressed because it is too large Load Diff
+50 -5
View File
@@ -7,6 +7,8 @@ servers:
- url: https://app.formbricks.com/api/v2
description: Formbricks Cloud
tags:
- name: Health
description: Operations for checking critical application dependencies health status.
- name: Roles
description: Operations for managing roles.
- name: Me
@@ -391,6 +393,36 @@ paths:
servers:
- url: https://app.formbricks.com/api/v2
description: Formbricks API Server
/health:
get:
tags:
- Health
summary: Health Check
description: Check the health status of critical application dependencies
including database and cache.
operationId: healthCheck
security: []
responses:
"200":
description: Health check completed successfully. Check individual dependency
status in response data.
content:
application/json:
schema:
type: object
properties:
main_database:
type: boolean
description: Main database connection status - true if database is reachable and
running
example: true
cache_database:
type: boolean
description: Cache database connection status - true if cache database is
reachable and running
example: true
title: Health Check Response
description: Health check status for critical application dependencies
/roles:
get:
operationId: getRoles
@@ -3500,6 +3532,24 @@ components:
name: x-api-key
description: Use your Formbricks x-api-key to authenticate.
schemas:
health:
type: object
properties:
main_database:
type: boolean
description: Main database connection status - true if database is reachable and
running
example: true
cache_database:
type: boolean
description: Cache database connection status - true if cache database is
reachable and running
example: true
required:
- main_database
- cache_database
title: Health Check Response
description: Health check status for critical application dependencies
role:
type: object
properties:
@@ -3835,15 +3885,12 @@ components:
type: string
enum:
- link
- web
- website
- app
description: The type of the survey
status:
type: string
enum:
- draft
- scheduled
- inProgress
- paused
- completed
@@ -4347,7 +4394,6 @@ components:
- createdBy
- environmentId
- endings
- thankYouCard
- hiddenFields
- variables
- displayOption
@@ -4365,7 +4411,6 @@ components:
- isSingleResponsePerEmailEnabled
- inlineTriggers
- isBackButtonHidden
- verifyEmail
- recaptcha
- metadata
- displayPercentage
+5 -90
View File
@@ -5,6 +5,11 @@
"light": "#00C4B8",
"primary": "#00C4B8"
},
"errors": {
"404": {
"redirect": true
}
},
"favicon": "/images/favicon.svg",
"footer": {
"socials": {
@@ -69,7 +74,6 @@
"xm-and-surveys/surveys/general-features/multi-language-surveys",
"xm-and-surveys/surveys/general-features/partial-submissions",
"xm-and-surveys/surveys/general-features/recall",
"xm-and-surveys/surveys/general-features/schedule-start-end-dates",
"xm-and-surveys/surveys/general-features/metadata",
"xm-and-surveys/surveys/general-features/variables",
"xm-and-surveys/surveys/general-features/hide-back-button",
@@ -394,447 +398,358 @@
"redirects": [
{
"destination": "/docs/overview/what-is-formbricks",
"permanent": true,
"source": "/docs/introduction/what-is-formbricks"
},
{
"destination": "/docs/overview/open-source",
"permanent": true,
"source": "/docs/introduction/why-open-source"
},
{
"destination": "/docs/xm-and-surveys/overview",
"permanent": true,
"source": "/docs/introduction/how-it-works"
},
{
"destination": "/docs/xm-and-surveys/xm/best-practices/contact-form",
"permanent": true,
"source": "/docs/best-practices/contact-form"
},
{
"destination": "/docs/xm-and-surveys/xm/best-practices/docs-feedback",
"permanent": true,
"source": "/docs/best-practices/docs-feedback"
},
{
"destination": "/docs/xm-and-surveys/xm/best-practices/feature-chaser",
"permanent": true,
"source": "/docs/best-practices/feature-chaser"
},
{
"destination": "/docs/xm-and-surveys/xm/best-practices/feedback-box",
"permanent": true,
"source": "/docs/best-practices/feedback-box"
},
{
"destination": "/docs/xm-and-surveys/xm/best-practices/improve-email-content",
"permanent": true,
"source": "/docs/best-practices/improve-email-content"
},
{
"destination": "/docs/xm-and-surveys/xm/best-practices/interview-prompt",
"permanent": true,
"source": "/docs/best-practices/interview-prompt"
},
{
"destination": "/docs/xm-and-surveys/xm/best-practices/cancel-subscription",
"permanent": true,
"source": "/docs/best-practices/cancel-subscription"
},
{
"destination": "/docs/xm-and-surveys/xm/best-practices/pmf-survey",
"permanent": true,
"source": "/docs/best-practices/pmf-survey"
},
{
"destination": "/docs/xm-and-surveys/xm/best-practices/quiz-time",
"permanent": true,
"source": "/docs/best-practices/quiz-time"
},
{
"destination": "/docs/xm-and-surveys/xm/best-practices/improve-trial-cr",
"permanent": true,
"source": "/docs/best-practices/improve-trial-cr"
},
{
"destination": "/docs/xm-and-surveys/surveys/link-surveys/quickstart",
"permanent": true,
"source": "/docs/link-surveys/quickstart"
},
{
"destination": "/docs/xm-and-surveys/surveys/general-features/add-image-or-video-question",
"permanent": true,
"source": "/docs/link-surveys/global/add-image-or-video-question"
},
{
"destination": "/docs/xm-and-surveys/surveys/general-features/conditional-logic",
"permanent": true,
"source": "/docs/link-surveys/global/conditional-logic"
},
{
"destination": "/docs/xm-and-surveys/surveys/general-features/overwrite-styling",
"permanent": true,
"source": "/docs/link-surveys/global/overwrite-styling"
},
{
"destination": "/docs/xm-and-surveys/surveys/link-surveys/data-prefilling",
"permanent": true,
"source": "/docs/link-surveys/global/data-prefilling"
},
{
"destination": "/docs/xm-and-surveys/surveys/link-surveys/embed-surveys",
"permanent": true,
"source": "/docs/link-surveys/embed-surveys"
},
{
"destination": "/docs/xm-and-surveys/surveys/general-features/hidden-fields",
"permanent": true,
"source": "/docs/link-surveys/global/hidden-fields"
},
{
"destination": "/docs/xm-and-surveys/surveys/general-features/limit-submissions",
"permanent": true,
"source": "/docs/link-surveys/global/limit-submissions"
},
{
"destination": "/docs/xm-and-surveys/surveys/general-features/market-research-panel",
"permanent": true,
"source": "/docs/link-surveys/market-research-panel"
},
{
"destination": "/docs/xm-and-surveys/surveys/general-features/multi-language-surveys",
"permanent": true,
"source": "/docs/link-surveys/global/multi-language-surveys"
},
{
"destination": "/docs/xm-and-surveys/surveys/general-features/partial-submissions",
"permanent": true,
"source": "/docs/link-surveys/global/partial-submissions"
},
{
"destination": "/docs/xm-and-surveys/surveys/link-surveys/pin-protected-surveys",
"permanent": true,
"source": "/docs/link-surveys/pin-protected-surveys"
},
{
"destination": "/docs/xm-and-surveys/surveys/general-features/recall",
"permanent": true,
"source": "/docs/link-surveys/global/recall"
},
{
"destination": "/docs/xm-and-surveys/surveys/link-surveys/single-use-links",
"permanent": true,
"source": "/docs/link-surveys/single-use-links"
},
{
"destination": "/docs/xm-and-surveys/surveys/link-surveys/source-tracking",
"permanent": true,
"source": "/docs/link-surveys/source-tracking"
},
{
"destination": "/docs/xm-and-surveys/surveys/general-features/schedule-start-end-dates",
"permanent": true,
"source": "/docs/link-surveys/global/schedule-start-end-dates"
},
{
"destination": "/docs/xm-and-surveys/surveys/link-surveys/start-at-question",
"permanent": true,
"source": "/docs/link-surveys/start-at-question"
},
{
"destination": "/docs/xm-and-surveys/surveys/general-features/metadata",
"permanent": true,
"source": "/docs/link-surveys/global/metadata"
},
{
"destination": "/docs/xm-and-surveys/surveys/general-features/variables",
"permanent": true,
"source": "/docs/link-surveys/global/variables"
},
{
"destination": "/docs/xm-and-surveys/surveys/link-surveys/verify-email-before-survey",
"permanent": true,
"source": "/docs/link-surveys/verify-email-before-survey"
},
{
"destination": "/docs/xm-and-surveys/surveys/general-features/add-image-or-video-question",
"permanent": true,
"source": "/docs/app-surveys/global/add-image-or-video-question"
},
{
"destination": "/docs/xm-and-surveys/core-features/question-type/consent",
"permanent": true,
"source": "/docs/core-features/global/question-type/consent"
},
{
"destination": "/docs/xm-and-surveys/core-features/question-type/statement-cta",
"permanent": true,
"source": "/docs/core-features/global/question-type/statement-cta"
},
{
"destination": "/docs/xm-and-surveys/core-features/integrations/airtable",
"permanent": true,
"source": "/docs/developer-docs/integrations/airtable"
},
{
"destination": "/docs/xm-and-surveys/core-features/integrations/zapier",
"permanent": true,
"source": "/docs/developer-docs/integrations/zapier"
},
{
"destination": "/docs/xm-and-surveys/core-features/integrations/wordpress",
"permanent": true,
"source": "/docs/developer-docs/integrations/wordpress"
},
{
"destination": "/docs/xm-and-surveys/core-features/integrations/slack",
"permanent": true,
"source": "/docs/developer-docs/integrations/slack"
},
{
"destination": "/docs/xm-and-surveys/core-features/integrations/n8n",
"permanent": true,
"source": "/docs/developer-docs/integrations/n8n"
},
{
"destination": "/docs/xm-and-surveys/core-features/integrations/notion",
"permanent": true,
"source": "/docs/developer-docs/integrations/notion"
},
{
"destination": "/docs/xm-and-surveys/core-features/integrations/google-sheets",
"permanent": true,
"source": "/docs/developer-docs/integrations/google-sheets"
},
{
"destination": "/docs/xm-and-surveys/surveys/website-app-surveys/quickstart",
"permanent": true,
"source": "/docs/app-surveys/quickstart"
},
{
"destination": "/docs/xm-and-surveys/core-features/question-type/address",
"permanent": true,
"source": "/docs/core-features/global/question-type/address"
},
{
"destination": "/docs/xm-and-surveys/surveys/website-app-surveys/framework-guides",
"permanent": true,
"source": "/docs/app-surveys/framework-guides"
},
{
"destination": "/docs/xm-and-surveys/core-features/integrations/activepieces",
"permanent": true,
"source": "/docs/developer-docs/integrations/activepieces"
},
{
"destination": "/docs/xm-and-surveys/core-features/user-management",
"permanent": true,
"source": "/docs/core-features/global/access-roles"
},
{
"destination": "/docs/xm-and-surveys/core-features/styling-theme",
"permanent": true,
"source": "/docs/core-features/global/styling-theme"
},
{
"destination": "/docs/xm-and-surveys/core-features/email-customization",
"permanent": true,
"source": "/docs/core-features/global/email-customization"
},
{
"destination": "/docs/self-hosting/setup/one-click",
"permanent": true,
"source": "/docs/self-hosting/one-click"
},
{
"destination": "/docs/self-hosting/configuration/custom-ssl",
"permanent": true,
"source": "/docs/self-hosting/custom-ssl"
},
{
"destination": "/docs/self-hosting/setup/docker",
"permanent": true,
"source": "/docs/self-hosting/docker"
},
{
"destination": "/docs/self-hosting/setup/cluster-setup",
"permanent": true,
"source": "/docs/self-hosting/cluster-setup"
},
{
"destination": "/docs/self-hosting/advanced/migration",
"permanent": true,
"source": "/docs/self-hosting/migration-guide"
},
{
"destination": "/docs/self-hosting/configuration/integrations",
"permanent": true,
"source": "/docs/self-hosting/integrations"
},
{
"destination": "/docs/self-hosting/advanced/license",
"permanent": true,
"source": "/docs/self-hosting/license"
},
{
"destination": "/docs/self-hosting/advanced/rate-limiting",
"permanent": true,
"source": "/docs/self-hosting/rate-limiting"
},
{
"destination": "/docs/self-hosting/setup/cluster-setup",
"permanent": true,
"source": "/docs/self-hosting/kubernetes"
},
{
"destination": "/docs/development/overview",
"permanent": true,
"source": "/docs/developer-docs/overview"
},
{
"destination": "/docs/xm-and-surveys/surveys/website-app-surveys/framework-guides",
"permanent": true,
"source": "/docs/developer-docs/js-sdk"
},
{
"destination": "/docs/xm-and-surveys/surveys/website-app-surveys/framework-guides#react-native",
"permanent": true,
"source": "/docs/developer-docs/react-native-in-app-surveys"
},
{
"destination": "/docs/api-reference/rest-api",
"permanent": true,
"source": "/docs/developer-docs/rest-api"
},
{
"destination": "/docs/xm-and-surveys/core-features/integrations/webhooks",
"permanent": true,
"source": "/docs/developer-docs/webhooks"
},
{
"destination": "/docs/development/contribution/contribution",
"permanent": true,
"source": "/docs/developer-docs/contributing/get-started"
},
{
"destination": "/docs/xm-and-surveys/surveys/website-app-surveys/actions",
"permanent": true,
"source": "/docs/app-surveys/actions"
},
{
"destination": "/docs/xm-and-surveys/surveys/website-app-surveys/advanced-targeting",
"permanent": true,
"source": "/docs/app-surveys/advanced-targeting"
},
{
"destination": "/docs/xm-and-surveys/surveys/website-app-surveys/user-identification",
"permanent": true,
"source": "/docs/app-surveys/user-identification"
},
{
"destination": "/docs/xm-and-surveys/surveys/website-app-surveys/recontact",
"permanent": true,
"source": "/docs/app-surveys/recontact"
},
{
"destination": "/docs/xm-and-surveys/surveys/website-app-surveys/show-survey-to-percent-of-users",
"permanent": true,
"source": "/docs/app-surveys/global/show-survey-to-percent-of-users"
},
{
"destination": "/docs/xm-and-surveys/surveys/general-features/metadata",
"permanent": true,
"source": "/docs/app-surveys/global/metadata"
},
{
"destination": "/docs/api-reference",
"permanent": true,
"source": "/docs/api-docs"
},
{
"destination": "/docs/development/troubleshooting",
"permanent": true,
"source": "/docs/developer-docs/contributing/troubleshooting"
},
{
"destination": "/docs/xm-and-surveys/core-features/question-type/file-upload",
"permanent": true,
"source": "/docs/core-features/global/question-type/file-upload"
},
{
"destination": "/docs/xm-and-surveys/core-features/question-type/select-picture",
"permanent": true,
"source": "/docs/core-features/global/question-type/picture-selection"
},
{
"destination": "/docs/xm-and-surveys/core-features/question-type/rating",
"permanent": true,
"source": "/docs/core-features/global/question-type/rating"
},
{
"destination": "/docs/xm-and-surveys/core-features/question-type/date",
"permanent": true,
"source": "/docs/core-features/global/question-type/date"
},
{
"destination": "/docs/xm-and-surveys/core-features/question-type/schedule-a-meeting",
"permanent": true,
"source": "/docs/core-features/global/question-type/schedule"
},
{
"destination": "/docs/xm-and-surveys/core-features/question-type/free-text",
"permanent": true,
"source": "/docs/core-features/global/question-type/free-text"
},
{
"destination": "/docs/xm-and-surveys/core-features/question-type/select-single",
"permanent": true,
"source": "/docs/core-features/global/question-type/single-select"
},
{
"destination": "/docs/xm-and-surveys/core-features/question-type/select-multiple",
"permanent": true,
"source": "/docs/core-features/global/question-type/multiple-select"
},
{
"destination": "/docs/xm-and-surveys/core-features/question-type/matrix",
"permanent": true,
"source": "/docs/core-features/global/question-type/matrix"
},
{
"destination": "/docs/xm-and-surveys/core-features/integrations/make",
"permanent": true,
"source": "/docs/developer-docs/integrations/make"
},
{
"destination": "/docs/xm-and-surveys/core-features/integrations/overview",
"permanent": true,
"source": "/docs/developer-docs/integrations/overview"
},
{
"destination": "/docs/xm-and-surveys/surveys/general-features/hidden-fields",
"permanent": true,
"source": "/docs/app-surveys/global/hidden-fields"
},
{
"destination": "/docs/xm-and-surveys/surveys/general-features/limit-submissions",
"permanent": true,
"source": "/docs/app-surveys/global/limit-submissions"
},
{
"destination": "/docs/xm-and-surveys/core-features/question-type/net-promoter-score",
"permanent": true,
"source": "/docs/core-features/global/question-type/net-promoter-score"
},
{
"destination": "/docs/xm-and-surveys/surveys/link-surveys/data-prefilling",
"permanent": true,
"source": "/docs/link-surveys/data-prefilling"
},
{
"destination": "/docs/xm-and-surveys/surveys/general-features/multi-language-surveys",
"permanent": true,
"source": "/docs/app-surveys/global/multi-language-surveys"
}
],
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 101 KiB

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