mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-22 06:00:51 -06:00
Compare commits
16 Commits
add-cursor
...
release/4.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d49517be91 | ||
|
|
7aedb73378 | ||
|
|
4112722a88 | ||
|
|
0eddeb46c1 | ||
|
|
774f45b109 | ||
|
|
3c65c002bb | ||
|
|
65539e85df | ||
|
|
91dab12a81 | ||
|
|
1c5244e030 | ||
|
|
8b3c0f1547 | ||
|
|
07370ac765 | ||
|
|
0f699405bb | ||
|
|
422f05b386 | ||
|
|
bdfbc4b0f6 | ||
|
|
b1828a2f27 | ||
|
|
3ba6dd9ada |
312
.github/actions/build-and-push-docker/action.yml
vendored
Normal file
312
.github/actions/build-and-push-docker/action.yml
vendored
Normal file
@@ -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
|
||||||
106
.github/actions/docker-build-setup/action.yml
vendored
Normal file
106
.github/actions/docker-build-setup/action.yml
vendored
Normal file
@@ -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"
|
||||||
192
.github/actions/resolve-docker-version/action.yml
vendored
Normal file
192
.github/actions/resolve-docker-version/action.yml
vendored
Normal file
@@ -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
|
||||||
160
.github/actions/update-package-version/action.yml
vendored
Normal file
160
.github/actions/update-package-version/action.yml
vendored
Normal file
@@ -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
|
||||||
176
.github/workflows/build-and-push-ecr.yml
vendored
176
.github/workflows/build-and-push-ecr.yml
vendored
@@ -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:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
image_tag:
|
version_override:
|
||||||
description: "Image tag to push (e.g., v3.16.1, main)"
|
description: "Override version (SemVer only, e.g., 1.2.3). Leave empty to auto-detect from branch."
|
||||||
required: true
|
required: false
|
||||||
default: "v3.16.1"
|
type: string
|
||||||
deploy_production:
|
deploy_production:
|
||||||
description: "Tag image for production deployment"
|
description: "Tag image for production deployment"
|
||||||
required: false
|
required: false
|
||||||
@@ -17,6 +21,24 @@ on:
|
|||||||
required: false
|
required: false
|
||||||
default: false
|
default: false
|
||||||
type: boolean
|
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:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -27,14 +49,15 @@ env:
|
|||||||
# ECR settings are sourced from repository/environment variables for portability across envs/forks
|
# ECR settings are sourced from repository/environment variables for portability across envs/forks
|
||||||
ECR_REGISTRY: ${{ vars.ECR_REGISTRY }}
|
ECR_REGISTRY: ${{ vars.ECR_REGISTRY }}
|
||||||
ECR_REPOSITORY: ${{ vars.ECR_REPOSITORY }}
|
ECR_REPOSITORY: ${{ vars.ECR_REPOSITORY }}
|
||||||
DOCKERFILE: apps/web/Dockerfile
|
|
||||||
CONTEXT: .
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-push:
|
build-and-push:
|
||||||
name: Build and Push
|
name: Build and Push
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 45
|
timeout-minutes: 45
|
||||||
|
outputs:
|
||||||
|
IMAGE_TAG: ${{ steps.build.outputs.image_tag }}
|
||||||
|
TAGS: ${{ steps.build.outputs.registry_tags }}
|
||||||
steps:
|
steps:
|
||||||
- name: Harden the runner (Audit all outbound calls)
|
- name: Harden the runner (Audit all outbound calls)
|
||||||
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
||||||
@@ -44,125 +67,22 @@ jobs:
|
|||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
|
||||||
- name: Validate image tag input
|
- name: Build and push cloud deployment image
|
||||||
shell: bash
|
id: build
|
||||||
env:
|
uses: ./.github/actions/build-and-push-docker
|
||||||
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
|
|
||||||
with:
|
with:
|
||||||
role-to-assume: ${{ secrets.AWS_ECR_PUSH_ROLE_ARN }}
|
registry_type: "ecr"
|
||||||
aws-region: ${{ env.ECR_REGION }}
|
ecr_registry: ${{ env.ECR_REGISTRY }}
|
||||||
|
ecr_repository: ${{ env.ECR_REPOSITORY }}
|
||||||
- name: Log in to Amazon ECR
|
ecr_region: ${{ env.ECR_REGION }}
|
||||||
uses: aws-actions/amazon-ecr-login@062b18b96a7aff071d4dc91bc00c4c1a7945b076
|
aws_role_arn: ${{ secrets.AWS_ECR_PUSH_ROLE_ARN }}
|
||||||
|
version: ${{ inputs.version_override || inputs.image_tag }}
|
||||||
- name: Set up Depot CLI
|
deploy_production: ${{ inputs.deploy_production }}
|
||||||
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
|
deploy_staging: ${{ inputs.deploy_staging }}
|
||||||
|
is_prerelease: ${{ inputs.IS_PRERELEASE }}
|
||||||
- name: Build and push image (Depot remote builder)
|
env:
|
||||||
uses: depot/build-push-action@636daae76684e38c301daa0c5eca1c095b24e780 # v1.14.0
|
DEPOT_PROJECT_TOKEN: ${{ secrets.DEPOT_PROJECT_TOKEN }}
|
||||||
with:
|
DUMMY_DATABASE_URL: ${{ secrets.DUMMY_DATABASE_URL }}
|
||||||
project: tw0fqmsx3c
|
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
||||||
token: ${{ secrets.DEPOT_PROJECT_TOKEN }}
|
DUMMY_REDIS_URL: ${{ secrets.DUMMY_REDIS_URL }}
|
||||||
context: ${{ env.CONTEXT }}
|
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||||
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 }}
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
VERSION:
|
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
|
required: true
|
||||||
type: string
|
type: string
|
||||||
REPOSITORY:
|
REPOSITORY:
|
||||||
|
|||||||
44
.github/workflows/e2e.yml
vendored
44
.github/workflows/e2e.yml
vendored
@@ -41,7 +41,7 @@ jobs:
|
|||||||
ports:
|
ports:
|
||||||
- 5432:5432
|
- 5432:5432
|
||||||
options: >-
|
options: >-
|
||||||
--health-cmd="pg_isready -U testuser"
|
--health-cmd="pg_isready -U postgres"
|
||||||
--health-interval=10s
|
--health-interval=10s
|
||||||
--health-timeout=5s
|
--health-timeout=5s
|
||||||
--health-retries=5
|
--health-retries=5
|
||||||
@@ -49,25 +49,15 @@ jobs:
|
|||||||
image: valkey/valkey@sha256:12ba4f45a7c3e1d0f076acd616cb230834e75a77e8516dde382720af32832d6d
|
image: valkey/valkey@sha256:12ba4f45a7c3e1d0f076acd616cb230834e75a77e8516dde382720af32832d6d
|
||||||
ports:
|
ports:
|
||||||
- 6379:6379
|
- 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:
|
steps:
|
||||||
- name: Harden the runner (Audit all outbound calls)
|
- 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:
|
with:
|
||||||
egress-policy: allow
|
egress-policy: audit
|
||||||
allowed-endpoints: |
|
allowed-endpoints: |
|
||||||
ee.formbricks.com:443
|
ee.formbricks.com:443
|
||||||
|
registry-1.docker.io:443
|
||||||
|
docker.io:443
|
||||||
|
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
- uses: ./.github/actions/dangerous-git-checkout
|
- uses: ./.github/actions/dangerous-git-checkout
|
||||||
@@ -101,8 +91,8 @@ jobs:
|
|||||||
echo "S3_REGION=us-east-1" >> .env
|
echo "S3_REGION=us-east-1" >> .env
|
||||||
echo "S3_BUCKET_NAME=formbricks-e2e" >> .env
|
echo "S3_BUCKET_NAME=formbricks-e2e" >> .env
|
||||||
echo "S3_ENDPOINT_URL=http://localhost:9000" >> .env
|
echo "S3_ENDPOINT_URL=http://localhost:9000" >> .env
|
||||||
echo "S3_ACCESS_KEY=minioadmin" >> .env
|
echo "S3_ACCESS_KEY=devminio" >> .env
|
||||||
echo "S3_SECRET_KEY=minioadmin" >> .env
|
echo "S3_SECRET_KEY=devminio123" >> .env
|
||||||
echo "S3_FORCE_PATH_STYLE=1" >> .env
|
echo "S3_FORCE_PATH_STYLE=1" >> .env
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
@@ -122,6 +112,22 @@ jobs:
|
|||||||
chmod +x "${MC_BIN}"
|
chmod +x "${MC_BIN}"
|
||||||
sudo mv "${MC_BIN}" /usr/local/bin/mc
|
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
|
- name: Wait for MinIO and create S3 bucket
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -142,7 +148,7 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
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
|
mc mb --ignore-existing local/formbricks-e2e
|
||||||
|
|
||||||
- name: Build App
|
- name: Build App
|
||||||
@@ -233,4 +239,4 @@ jobs:
|
|||||||
|
|
||||||
- name: Output App Logs
|
- name: Output App Logs
|
||||||
if: failure()
|
if: failure()
|
||||||
run: cat app.log
|
run: cat app.log
|
||||||
67
.github/workflows/formbricks-release.yml
vendored
67
.github/workflows/formbricks-release.yml
vendored
@@ -8,8 +8,8 @@ permissions:
|
|||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
docker-build:
|
docker-build-community:
|
||||||
name: Build & release docker image
|
name: Build & release community docker image
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
@@ -19,6 +19,19 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
IS_PRERELEASE: ${{ github.event.release.prerelease }}
|
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:
|
helm-chart-release:
|
||||||
name: Release Helm Chart
|
name: Release Helm Chart
|
||||||
permissions:
|
permissions:
|
||||||
@@ -27,22 +40,42 @@ jobs:
|
|||||||
uses: ./.github/workflows/release-helm-chart.yml
|
uses: ./.github/workflows/release-helm-chart.yml
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
needs:
|
needs:
|
||||||
- docker-build
|
- docker-build-community
|
||||||
with:
|
with:
|
||||||
VERSION: ${{ needs.docker-build.outputs.VERSION }}
|
VERSION: ${{ needs.docker-build-community.outputs.VERSION }}
|
||||||
|
|
||||||
deploy-formbricks-cloud:
|
verify-cloud-build:
|
||||||
name: Deploy Helm Chart to Formbricks Cloud
|
name: Verify Cloud Build Outputs
|
||||||
permissions:
|
runs-on: ubuntu-latest
|
||||||
contents: read
|
timeout-minutes: 5 # Simple verification should be quick
|
||||||
id-token: write
|
|
||||||
secrets: inherit
|
|
||||||
uses: ./.github/workflows/deploy-formbricks-cloud.yml
|
|
||||||
needs:
|
needs:
|
||||||
- docker-build
|
- docker-build-cloud
|
||||||
- helm-chart-release
|
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: write # Required for tag push operations in called workflow
|
||||||
|
uses: ./.github/workflows/move-stable-tag.yml
|
||||||
|
needs:
|
||||||
|
- docker-build-community # Ensure release is successful first
|
||||||
with:
|
with:
|
||||||
VERSION: v${{ needs.docker-build.outputs.VERSION }}
|
release_tag: ${{ github.event.release.tag_name }}
|
||||||
ENVIRONMENT: ${{ github.event.release.prerelease && 'staging' || 'production' }}
|
commit_sha: ${{ github.sha }}
|
||||||
|
is_prerelease: ${{ github.event.release.prerelease }}
|
||||||
|
|
||||||
|
|||||||
96
.github/workflows/move-stable-tag.yml
vendored
Normal file
96
.github/workflows/move-stable-tag.yml
vendored
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
name: Move Stable Tag
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
release_tag:
|
||||||
|
description: "The release tag name (e.g., 1.2.3)"
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
commit_sha:
|
||||||
|
description: "The commit SHA to point the stable tag to"
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
is_prerelease:
|
||||||
|
description: "Whether this is a prerelease (stable tag won't be moved for prereleases)"
|
||||||
|
required: false
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
# Prevent concurrent stable tag operations to avoid race conditions
|
||||||
|
concurrency:
|
||||||
|
group: move-stable-tag-${{ github.repository }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
move-stable-tag:
|
||||||
|
name: Move stable tag to release
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10 # Prevent hung git operations
|
||||||
|
permissions:
|
||||||
|
contents: write # Required to push tags
|
||||||
|
# Only move stable tag for non-prerelease versions
|
||||||
|
if: ${{ !inputs.is_prerelease }}
|
||||||
|
steps:
|
||||||
|
- name: Harden the runner
|
||||||
|
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||||
|
with:
|
||||||
|
egress-policy: audit
|
||||||
|
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
fetch-depth: 0 # Full history needed for tag operations
|
||||||
|
|
||||||
|
- name: Validate inputs
|
||||||
|
env:
|
||||||
|
RELEASE_TAG: ${{ inputs.release_tag }}
|
||||||
|
COMMIT_SHA: ${{ inputs.commit_sha }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Validate release tag format
|
||||||
|
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
|
||||||
|
|
||||||
|
# Validate commit SHA format (40 character hex)
|
||||||
|
if [[ ! "$COMMIT_SHA" =~ ^[a-f0-9]{40}$ ]]; then
|
||||||
|
echo "❌ Error: Invalid commit SHA format. Expected 40 character hex string"
|
||||||
|
echo "Provided: $COMMIT_SHA"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✅ Input validation passed"
|
||||||
|
echo "Release tag: $RELEASE_TAG"
|
||||||
|
echo "Commit SHA: $COMMIT_SHA"
|
||||||
|
|
||||||
|
- name: Move stable tag
|
||||||
|
env:
|
||||||
|
RELEASE_TAG: ${{ inputs.release_tag }}
|
||||||
|
COMMIT_SHA: ${{ inputs.commit_sha }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Configure git
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
|
||||||
|
# Verify the commit exists
|
||||||
|
if ! git cat-file -e "$COMMIT_SHA"; then
|
||||||
|
echo "❌ Error: Commit $COMMIT_SHA does not exist in this repository"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Move stable tag to the release commit
|
||||||
|
echo "📌 Moving stable tag to commit: $COMMIT_SHA (release: $RELEASE_TAG)"
|
||||||
|
git tag -f stable "$COMMIT_SHA"
|
||||||
|
git push origin stable --force
|
||||||
|
|
||||||
|
echo "✅ Successfully moved stable tag to release $RELEASE_TAG"
|
||||||
|
echo "🔗 Stable tag now points to: https://github.com/${{ github.repository }}/commit/$COMMIT_SHA"
|
||||||
@@ -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.
|
# This workflow builds experimental/testing versions of Formbricks for self-hosting customers
|
||||||
# They are provided by a third-party and are governed by
|
# to test fixes and features before official releases. Images are pushed to GHCR with
|
||||||
# separate terms of service, privacy policy, and support
|
# timestamped experimental versions for easy identification and testing.
|
||||||
# documentation.
|
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
env:
|
version_override:
|
||||||
# Use docker.io for Docker Hub if empty
|
description: "Override version (SemVer only, e.g., 1.2.3-beta). Leave empty for auto-generated experimental version."
|
||||||
REGISTRY: ghcr.io
|
required: false
|
||||||
# github.repository as <account>/<repo>
|
type: string
|
||||||
IMAGE_NAME: ${{ github.repository }}-experimental
|
|
||||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
|
||||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
packages: write
|
||||||
|
id-token: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build-community-testing:
|
||||||
|
name: Build Community Testing Image
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
timeout-minutes: 45
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
# This is used to complete the identity challenge
|
|
||||||
# with sigstore/fulcio when running outside of PRs.
|
|
||||||
id-token: write
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Harden the runner (Audit all outbound calls)
|
- 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:
|
with:
|
||||||
egress-policy: audit
|
egress-policy: audit
|
||||||
|
|
||||||
@@ -42,110 +34,17 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Generate SemVer version from branch or tag
|
- name: Build and push community testing image
|
||||||
id: generate_version
|
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:
|
env:
|
||||||
REF_NAME: ${{ github.ref_name }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
REF_TYPE: ${{ github.ref_type }}
|
DEPOT_PROJECT_TOKEN: ${{ secrets.DEPOT_PROJECT_TOKEN }}
|
||||||
run: |
|
DUMMY_DATABASE_URL: ${{ secrets.DUMMY_DATABASE_URL }}
|
||||||
# Get reference name and type from environment variables
|
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
||||||
echo "Reference type: $REF_TYPE"
|
DUMMY_REDIS_URL: ${{ secrets.DUMMY_REDIS_URL }}
|
||||||
echo "Reference name: $REF_NAME"
|
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||||
|
|
||||||
# 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}"
|
|
||||||
|
|||||||
130
.github/workflows/release-docker-github.yml
vendored
130
.github/workflows/release-docker-github.yml
vendored
@@ -1,4 +1,4 @@
|
|||||||
name: Docker Release to Github
|
name: Release Community Docker Images
|
||||||
|
|
||||||
# This workflow uses actions that are not certified by GitHub.
|
# This workflow uses actions that are not certified by GitHub.
|
||||||
# They are provided by a third-party and are governed by
|
# They are provided by a third-party and are governed by
|
||||||
@@ -23,8 +23,6 @@ env:
|
|||||||
REGISTRY: ghcr.io
|
REGISTRY: ghcr.io
|
||||||
# github.repository as <account>/<repo>
|
# github.repository as <account>/<repo>
|
||||||
IMAGE_NAME: ${{ github.repository }}
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
|
||||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -32,6 +30,7 @@ permissions:
|
|||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 45
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
@@ -44,103 +43,60 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Harden the runner (Audit all outbound calls)
|
- 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:
|
with:
|
||||||
egress-policy: audit
|
egress-policy: audit
|
||||||
|
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
|
||||||
- name: Get Release Tag
|
- name: Extract release version from tag
|
||||||
id: extract_release_tag
|
id: extract_release_tag
|
||||||
run: |
|
run: |
|
||||||
# Extract version from tag (e.g., refs/tags/v1.2.3 -> 1.2.3)
|
set -euo pipefail
|
||||||
TAG="$GITHUB_REF"
|
|
||||||
TAG=${TAG#refs/tags/v}
|
|
||||||
|
|
||||||
# Validate the extracted tag format
|
# Extract tag name with fallback logic for different trigger contexts
|
||||||
if [[ ! "$TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$ ]]; then
|
if [[ -n "${RELEASE_TAG:-}" ]]; then
|
||||||
echo "❌ Error: Invalid release tag format after extraction. Must be semver (e.g., 1.2.3, 1.2.3-alpha)"
|
TAG="$RELEASE_TAG"
|
||||||
echo "Original ref: $GITHUB_REF"
|
echo "Using RELEASE_TAG override: $TAG"
|
||||||
echo "Extracted tag: $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
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Safely add to environment variables
|
|
||||||
echo "RELEASE_TAG=$TAG" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
echo "VERSION=$TAG" >> $GITHUB_OUTPUT
|
echo "VERSION=$TAG" >> $GITHUB_OUTPUT
|
||||||
echo "Using tag-based version: $TAG"
|
echo "Using version: $TAG"
|
||||||
|
|
||||||
- name: Update package.json version
|
- name: Build and push community release image
|
||||||
run: |
|
id: build
|
||||||
sed -i "s/\"version\": \"0.0.0\"/\"version\": \"${{ env.RELEASE_TAG }}\"/" ./apps/web/package.json
|
uses: ./.github/actions/build-and-push-docker
|
||||||
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:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry_type: "ghcr"
|
||||||
username: ${{ github.actor }}
|
ghcr_image_name: ${{ env.IMAGE_NAME }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
version: ${{ steps.extract_release_tag.outputs.VERSION }}
|
||||||
|
is_prerelease: ${{ inputs.IS_PRERELEASE }}
|
||||||
# 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' }}
|
|
||||||
env:
|
env:
|
||||||
# https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
TAGS: ${{ steps.meta.outputs.tags }}
|
DEPOT_PROJECT_TOKEN: ${{ secrets.DEPOT_PROJECT_TOKEN }}
|
||||||
DIGEST: ${{ steps.build-and-push.outputs.digest }}
|
DUMMY_DATABASE_URL: ${{ secrets.DUMMY_DATABASE_URL }}
|
||||||
# This step uses the identity token to provision an ephemeral certificate
|
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
||||||
# against the sigstore community Fulcio instance.
|
DUMMY_REDIS_URL: ${{ secrets.DUMMY_REDIS_URL }}
|
||||||
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
|
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||||
|
|||||||
29
.github/workflows/release-helm-chart.yml
vendored
29
.github/workflows/release-helm-chart.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Harden the runner (Audit all outbound calls)
|
- 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:
|
with:
|
||||||
egress-policy: audit
|
egress-policy: audit
|
||||||
|
|
||||||
@@ -59,14 +59,35 @@ jobs:
|
|||||||
uses: dcarbone/install-yq-action@4075b4dca348d74bd83f2bf82d30f25d7c54539b # v1.3.1
|
uses: dcarbone/install-yq-action@4075b4dca348d74bd83f2bf82d30f25d7c54539b # v1.3.1
|
||||||
|
|
||||||
- name: Update Chart.yaml with new version
|
- name: Update Chart.yaml with new version
|
||||||
|
env:
|
||||||
|
VERSION: ${{ env.VERSION }}
|
||||||
run: |
|
run: |
|
||||||
yq -i ".version = \"$VERSION\"" helm-chart/Chart.yaml
|
set -euo pipefail
|
||||||
yq -i ".appVersion = \"v$VERSION\"" helm-chart/Chart.yaml
|
|
||||||
|
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
|
- name: Package Helm chart
|
||||||
|
env:
|
||||||
|
VERSION: ${{ env.VERSION }}
|
||||||
run: |
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
echo "Packaging Helm chart version: ${VERSION}"
|
||||||
helm package ./helm-chart
|
helm package ./helm-chart
|
||||||
|
|
||||||
|
echo "✅ Successfully packaged formbricks-${VERSION}.tgz"
|
||||||
|
|
||||||
- name: Push Helm chart to GitHub Container Registry
|
- name: Push Helm chart to GitHub Container Registry
|
||||||
|
env:
|
||||||
|
VERSION: ${{ env.VERSION }}
|
||||||
run: |
|
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");
|
const linkElement = screen.getByText("environments.settings.notifications.use_the_integration");
|
||||||
expect(linkElement).toBeInTheDocument();
|
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">
|
<p className="text-sm">
|
||||||
{t("environments.settings.notifications.need_slack_or_discord_notifications")}?
|
{t("environments.settings.notifications.need_slack_or_discord_notifications")}?
|
||||||
<a
|
<a
|
||||||
href={`/environments/${environmentId}/integrations`}
|
href={`/environments/${environmentId}/project/integrations`}
|
||||||
className="ml-1 cursor-pointer text-sm underline">
|
className="ml-1 cursor-pointer text-sm underline">
|
||||||
{t("environments.settings.notifications.use_the_integration")}
|
{t("environments.settings.notifications.use_the_integration")}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export const SuccessView: React.FC<SuccessViewProps> = ({
|
|||||||
{t("environments.surveys.summary.configure_alerts")}
|
{t("environments.surveys.summary.configure_alerts")}
|
||||||
</Link>
|
</Link>
|
||||||
<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">
|
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" />
|
<BlocksIcon className="h-8 w-8 stroke-1 text-slate-900" />
|
||||||
{t("environments.surveys.summary.setup_integrations")}
|
{t("environments.surveys.summary.setup_integrations")}
|
||||||
|
|||||||
@@ -357,7 +357,10 @@ const buildNotionPayloadProperties = (
|
|||||||
|
|
||||||
// notion requires specific payload for each column type
|
// 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
|
// * 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 {
|
try {
|
||||||
switch (colType) {
|
switch (colType) {
|
||||||
case "select":
|
case "select":
|
||||||
|
|||||||
@@ -62,9 +62,10 @@ export const GET = async (req: Request) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const result = await createOrUpdateIntegration(environmentId, googleSheetIntegration);
|
const result = await createOrUpdateIntegration(environmentId, googleSheetIntegration);
|
||||||
|
|
||||||
if (result) {
|
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");
|
return responses.internalServerErrorResponse("Failed to create or update Google Sheets integration");
|
||||||
|
|||||||
@@ -90,7 +90,9 @@ export const GET = withV1ApiWrapper({
|
|||||||
};
|
};
|
||||||
await createOrUpdateIntegration(environmentId, airtableIntegrationInput);
|
await createOrUpdateIntegration(environmentId, airtableIntegrationInput);
|
||||||
return {
|
return {
|
||||||
response: Response.redirect(`${WEBAPP_URL}/environments/${environmentId}/integrations/airtable`),
|
response: Response.redirect(
|
||||||
|
`${WEBAPP_URL}/environments/${environmentId}/project/integrations/airtable`
|
||||||
|
),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ error, url: req.url }, "Error in GET /api/v1/integrations/airtable/callback");
|
logger.error({ error, url: req.url }, "Error in GET /api/v1/integrations/airtable/callback");
|
||||||
|
|||||||
@@ -86,13 +86,15 @@ export const GET = withV1ApiWrapper({
|
|||||||
|
|
||||||
if (result) {
|
if (result) {
|
||||||
return {
|
return {
|
||||||
response: Response.redirect(`${WEBAPP_URL}/environments/${environmentId}/integrations/notion`),
|
response: Response.redirect(
|
||||||
|
`${WEBAPP_URL}/environments/${environmentId}/project/integrations/notion`
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} else if (error) {
|
} else if (error) {
|
||||||
return {
|
return {
|
||||||
response: Response.redirect(
|
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) {
|
if (result) {
|
||||||
return {
|
return {
|
||||||
response: Response.redirect(`${WEBAPP_URL}/environments/${environmentId}/integrations/slack`),
|
response: Response.redirect(
|
||||||
|
`${WEBAPP_URL}/environments/${environmentId}/project/integrations/slack`
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} else if (error) {
|
} else if (error) {
|
||||||
return {
|
return {
|
||||||
response: Response.redirect(
|
response: Response.redirect(
|
||||||
`${WEBAPP_URL}/environments/${environmentId}/integrations/slack?error=${error}`
|
`${WEBAPP_URL}/environments/${environmentId}/project/integrations/slack?error=${error}`
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
import { TShuffleOption, TSurveyLogic, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
import { TShuffleOption, TSurveyLogic, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||||
import { TTemplateRole } from "@formbricks/types/templates";
|
|
||||||
import {
|
import {
|
||||||
buildCTAQuestion,
|
buildCTAQuestion,
|
||||||
buildConsentQuestion,
|
buildConsentQuestion,
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
TSurveyRatingQuestion,
|
TSurveyRatingQuestion,
|
||||||
TSurveyWelcomeCard,
|
TSurveyWelcomeCard,
|
||||||
} from "@formbricks/types/surveys/types";
|
} 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) =>
|
const getDefaultButtonLabel = (label: string | undefined, t: TFnType) =>
|
||||||
createI18nString(label || t("common.next"), []);
|
createI18nString(label || t("common.next"), []);
|
||||||
@@ -391,6 +391,7 @@ export const buildSurvey = (
|
|||||||
name: string;
|
name: string;
|
||||||
industries: ("eCommerce" | "saas" | "other")[];
|
industries: ("eCommerce" | "saas" | "other")[];
|
||||||
channels: ("link" | "app" | "website")[];
|
channels: ("link" | "app" | "website")[];
|
||||||
|
role: TTemplateRole;
|
||||||
description: string;
|
description: string;
|
||||||
questions: TSurveyQuestion[];
|
questions: TSurveyQuestion[];
|
||||||
endings?: TSurveyEnding[];
|
endings?: TSurveyEnding[];
|
||||||
@@ -403,6 +404,7 @@ export const buildSurvey = (
|
|||||||
name: config.name,
|
name: config.name,
|
||||||
industries: config.industries,
|
industries: config.industries,
|
||||||
channels: config.channels,
|
channels: config.channels,
|
||||||
|
role: config.role,
|
||||||
description: config.description,
|
description: config.description,
|
||||||
preset: {
|
preset: {
|
||||||
...localSurvey,
|
...localSurvey,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const cartAbandonmentSurvey = (t: TFnType): TTemplate => {
|
|||||||
return buildSurvey(
|
return buildSurvey(
|
||||||
{
|
{
|
||||||
name: t("templates.card_abandonment_survey"),
|
name: t("templates.card_abandonment_survey"),
|
||||||
|
role: "productManager",
|
||||||
industries: ["eCommerce"],
|
industries: ["eCommerce"],
|
||||||
channels: ["app", "website", "link"],
|
channels: ["app", "website", "link"],
|
||||||
description: t("templates.card_abandonment_survey_description"),
|
description: t("templates.card_abandonment_survey_description"),
|
||||||
@@ -124,6 +125,7 @@ const siteAbandonmentSurvey = (t: TFnType): TTemplate => {
|
|||||||
return buildSurvey(
|
return buildSurvey(
|
||||||
{
|
{
|
||||||
name: t("templates.site_abandonment_survey"),
|
name: t("templates.site_abandonment_survey"),
|
||||||
|
role: "productManager",
|
||||||
industries: ["eCommerce"],
|
industries: ["eCommerce"],
|
||||||
channels: ["app", "website"],
|
channels: ["app", "website"],
|
||||||
description: t("templates.site_abandonment_survey_description"),
|
description: t("templates.site_abandonment_survey_description"),
|
||||||
@@ -221,6 +223,7 @@ const productMarketFitSuperhuman = (t: TFnType): TTemplate => {
|
|||||||
return buildSurvey(
|
return buildSurvey(
|
||||||
{
|
{
|
||||||
name: t("templates.product_market_fit_superhuman"),
|
name: t("templates.product_market_fit_superhuman"),
|
||||||
|
role: "productManager",
|
||||||
industries: ["saas"],
|
industries: ["saas"],
|
||||||
channels: ["app", "link"],
|
channels: ["app", "link"],
|
||||||
description: t("templates.product_market_fit_superhuman_description"),
|
description: t("templates.product_market_fit_superhuman_description"),
|
||||||
@@ -295,6 +298,7 @@ const onboardingSegmentation = (t: TFnType): TTemplate => {
|
|||||||
return buildSurvey(
|
return buildSurvey(
|
||||||
{
|
{
|
||||||
name: t("templates.onboarding_segmentation"),
|
name: t("templates.onboarding_segmentation"),
|
||||||
|
role: "productManager",
|
||||||
industries: ["saas"],
|
industries: ["saas"],
|
||||||
channels: ["app", "link"],
|
channels: ["app", "link"],
|
||||||
description: t("templates.onboarding_segmentation_description"),
|
description: t("templates.onboarding_segmentation_description"),
|
||||||
@@ -358,6 +362,7 @@ const churnSurvey = (t: TFnType): TTemplate => {
|
|||||||
return buildSurvey(
|
return buildSurvey(
|
||||||
{
|
{
|
||||||
name: t("templates.churn_survey"),
|
name: t("templates.churn_survey"),
|
||||||
|
role: "sales",
|
||||||
industries: ["saas", "eCommerce", "other"],
|
industries: ["saas", "eCommerce", "other"],
|
||||||
channels: ["app", "link"],
|
channels: ["app", "link"],
|
||||||
description: t("templates.churn_survey_description"),
|
description: t("templates.churn_survey_description"),
|
||||||
@@ -447,6 +452,7 @@ const earnedAdvocacyScore = (t: TFnType): TTemplate => {
|
|||||||
return buildSurvey(
|
return buildSurvey(
|
||||||
{
|
{
|
||||||
name: t("templates.earned_advocacy_score_name"),
|
name: t("templates.earned_advocacy_score_name"),
|
||||||
|
role: "customerSuccess",
|
||||||
industries: ["saas", "eCommerce", "other"],
|
industries: ["saas", "eCommerce", "other"],
|
||||||
channels: ["app", "link"],
|
channels: ["app", "link"],
|
||||||
description: t("templates.earned_advocacy_score_description"),
|
description: t("templates.earned_advocacy_score_description"),
|
||||||
@@ -519,6 +525,7 @@ const usabilityScoreRatingSurvey = (t: TFnType): TTemplate => {
|
|||||||
return buildSurvey(
|
return buildSurvey(
|
||||||
{
|
{
|
||||||
name: t("templates.usability_score_name"),
|
name: t("templates.usability_score_name"),
|
||||||
|
role: "customerSuccess",
|
||||||
industries: ["saas"],
|
industries: ["saas"],
|
||||||
channels: ["app", "link"],
|
channels: ["app", "link"],
|
||||||
description: t("templates.usability_rating_description"),
|
description: t("templates.usability_rating_description"),
|
||||||
@@ -644,6 +651,7 @@ const improveTrialConversion = (t: TFnType): TTemplate => {
|
|||||||
return buildSurvey(
|
return buildSurvey(
|
||||||
{
|
{
|
||||||
name: t("templates.improve_trial_conversion_name"),
|
name: t("templates.improve_trial_conversion_name"),
|
||||||
|
role: "sales",
|
||||||
industries: ["saas"],
|
industries: ["saas"],
|
||||||
channels: ["link", "app"],
|
channels: ["link", "app"],
|
||||||
description: t("templates.improve_trial_conversion_description"),
|
description: t("templates.improve_trial_conversion_description"),
|
||||||
@@ -745,6 +753,7 @@ const reviewPrompt = (t: TFnType): TTemplate => {
|
|||||||
return buildSurvey(
|
return buildSurvey(
|
||||||
{
|
{
|
||||||
name: t("templates.review_prompt_name"),
|
name: t("templates.review_prompt_name"),
|
||||||
|
role: "marketing",
|
||||||
industries: ["saas", "eCommerce", "other"],
|
industries: ["saas", "eCommerce", "other"],
|
||||||
channels: ["link", "app"],
|
channels: ["link", "app"],
|
||||||
description: t("templates.review_prompt_description"),
|
description: t("templates.review_prompt_description"),
|
||||||
@@ -823,6 +832,7 @@ const interviewPrompt = (t: TFnType): TTemplate => {
|
|||||||
return buildSurvey(
|
return buildSurvey(
|
||||||
{
|
{
|
||||||
name: t("templates.interview_prompt_name"),
|
name: t("templates.interview_prompt_name"),
|
||||||
|
role: "productManager",
|
||||||
industries: ["saas"],
|
industries: ["saas"],
|
||||||
channels: ["app"],
|
channels: ["app"],
|
||||||
description: t("templates.interview_prompt_description"),
|
description: t("templates.interview_prompt_description"),
|
||||||
@@ -850,6 +860,7 @@ const improveActivationRate = (t: TFnType): TTemplate => {
|
|||||||
return buildSurvey(
|
return buildSurvey(
|
||||||
{
|
{
|
||||||
name: t("templates.improve_activation_rate_name"),
|
name: t("templates.improve_activation_rate_name"),
|
||||||
|
role: "productManager",
|
||||||
industries: ["saas"],
|
industries: ["saas"],
|
||||||
channels: ["link"],
|
channels: ["link"],
|
||||||
description: t("templates.improve_activation_rate_description"),
|
description: t("templates.improve_activation_rate_description"),
|
||||||
@@ -940,6 +951,7 @@ const employeeSatisfaction = (t: TFnType): TTemplate => {
|
|||||||
return buildSurvey(
|
return buildSurvey(
|
||||||
{
|
{
|
||||||
name: t("templates.employee_satisfaction_name"),
|
name: t("templates.employee_satisfaction_name"),
|
||||||
|
role: "peopleManager",
|
||||||
industries: ["saas", "eCommerce", "other"],
|
industries: ["saas", "eCommerce", "other"],
|
||||||
channels: ["app", "link"],
|
channels: ["app", "link"],
|
||||||
description: t("templates.employee_satisfaction_description"),
|
description: t("templates.employee_satisfaction_description"),
|
||||||
@@ -1017,6 +1029,7 @@ const uncoverStrengthsAndWeaknesses = (t: TFnType): TTemplate => {
|
|||||||
return buildSurvey(
|
return buildSurvey(
|
||||||
{
|
{
|
||||||
name: t("templates.uncover_strengths_and_weaknesses_name"),
|
name: t("templates.uncover_strengths_and_weaknesses_name"),
|
||||||
|
role: "productManager",
|
||||||
industries: ["saas", "other"],
|
industries: ["saas", "other"],
|
||||||
channels: ["app", "link"],
|
channels: ["app", "link"],
|
||||||
description: t("templates.uncover_strengths_and_weaknesses_description"),
|
description: t("templates.uncover_strengths_and_weaknesses_description"),
|
||||||
@@ -1070,6 +1083,7 @@ const productMarketFitShort = (t: TFnType): TTemplate => {
|
|||||||
return buildSurvey(
|
return buildSurvey(
|
||||||
{
|
{
|
||||||
name: t("templates.product_market_fit_short_name"),
|
name: t("templates.product_market_fit_short_name"),
|
||||||
|
role: "productManager",
|
||||||
industries: ["saas"],
|
industries: ["saas"],
|
||||||
channels: ["app", "link"],
|
channels: ["app", "link"],
|
||||||
description: t("templates.product_market_fit_short_description"),
|
description: t("templates.product_market_fit_short_description"),
|
||||||
@@ -1106,6 +1120,7 @@ const marketAttribution = (t: TFnType): TTemplate => {
|
|||||||
return buildSurvey(
|
return buildSurvey(
|
||||||
{
|
{
|
||||||
name: t("templates.market_attribution_name"),
|
name: t("templates.market_attribution_name"),
|
||||||
|
role: "marketing",
|
||||||
industries: ["saas", "eCommerce"],
|
industries: ["saas", "eCommerce"],
|
||||||
channels: ["website", "app", "link"],
|
channels: ["website", "app", "link"],
|
||||||
description: t("templates.market_attribution_description"),
|
description: t("templates.market_attribution_description"),
|
||||||
@@ -1136,6 +1151,7 @@ const changingSubscriptionExperience = (t: TFnType): TTemplate => {
|
|||||||
return buildSurvey(
|
return buildSurvey(
|
||||||
{
|
{
|
||||||
name: t("templates.changing_subscription_experience_name"),
|
name: t("templates.changing_subscription_experience_name"),
|
||||||
|
role: "productManager",
|
||||||
industries: ["saas"],
|
industries: ["saas"],
|
||||||
channels: ["app"],
|
channels: ["app"],
|
||||||
description: t("templates.changing_subscription_experience_description"),
|
description: t("templates.changing_subscription_experience_description"),
|
||||||
@@ -1178,6 +1194,7 @@ const identifyCustomerGoals = (t: TFnType): TTemplate => {
|
|||||||
return buildSurvey(
|
return buildSurvey(
|
||||||
{
|
{
|
||||||
name: t("templates.identify_customer_goals_name"),
|
name: t("templates.identify_customer_goals_name"),
|
||||||
|
role: "productManager",
|
||||||
industries: ["saas", "other"],
|
industries: ["saas", "other"],
|
||||||
channels: ["app", "website"],
|
channels: ["app", "website"],
|
||||||
description: t("templates.identify_customer_goals_description"),
|
description: t("templates.identify_customer_goals_description"),
|
||||||
@@ -1207,6 +1224,7 @@ const featureChaser = (t: TFnType): TTemplate => {
|
|||||||
return buildSurvey(
|
return buildSurvey(
|
||||||
{
|
{
|
||||||
name: t("templates.feature_chaser_name"),
|
name: t("templates.feature_chaser_name"),
|
||||||
|
role: "productManager",
|
||||||
industries: ["saas"],
|
industries: ["saas"],
|
||||||
channels: ["app"],
|
channels: ["app"],
|
||||||
description: t("templates.feature_chaser_description"),
|
description: t("templates.feature_chaser_description"),
|
||||||
@@ -1245,6 +1263,7 @@ const fakeDoorFollowUp = (t: TFnType): TTemplate => {
|
|||||||
return buildSurvey(
|
return buildSurvey(
|
||||||
{
|
{
|
||||||
name: t("templates.fake_door_follow_up_name"),
|
name: t("templates.fake_door_follow_up_name"),
|
||||||
|
role: "productManager",
|
||||||
industries: ["saas", "eCommerce"],
|
industries: ["saas", "eCommerce"],
|
||||||
channels: ["app", "website"],
|
channels: ["app", "website"],
|
||||||
description: t("templates.fake_door_follow_up_description"),
|
description: t("templates.fake_door_follow_up_description"),
|
||||||
@@ -1288,6 +1307,7 @@ const feedbackBox = (t: TFnType): TTemplate => {
|
|||||||
return buildSurvey(
|
return buildSurvey(
|
||||||
{
|
{
|
||||||
name: t("templates.feedback_box_name"),
|
name: t("templates.feedback_box_name"),
|
||||||
|
role: "productManager",
|
||||||
industries: ["saas"],
|
industries: ["saas"],
|
||||||
channels: ["app"],
|
channels: ["app"],
|
||||||
description: t("templates.feedback_box_description"),
|
description: t("templates.feedback_box_description"),
|
||||||
@@ -1357,6 +1377,7 @@ const integrationSetupSurvey = (t: TFnType): TTemplate => {
|
|||||||
return buildSurvey(
|
return buildSurvey(
|
||||||
{
|
{
|
||||||
name: t("templates.integration_setup_survey_name"),
|
name: t("templates.integration_setup_survey_name"),
|
||||||
|
role: "productManager",
|
||||||
industries: ["saas"],
|
industries: ["saas"],
|
||||||
channels: ["app"],
|
channels: ["app"],
|
||||||
description: t("templates.integration_setup_survey_description"),
|
description: t("templates.integration_setup_survey_description"),
|
||||||
@@ -1429,6 +1450,7 @@ const newIntegrationSurvey = (t: TFnType): TTemplate => {
|
|||||||
return buildSurvey(
|
return buildSurvey(
|
||||||
{
|
{
|
||||||
name: t("templates.new_integration_survey_name"),
|
name: t("templates.new_integration_survey_name"),
|
||||||
|
role: "productManager",
|
||||||
industries: ["saas"],
|
industries: ["saas"],
|
||||||
channels: ["app"],
|
channels: ["app"],
|
||||||
description: t("templates.new_integration_survey_description"),
|
description: t("templates.new_integration_survey_description"),
|
||||||
@@ -1460,6 +1482,7 @@ const docsFeedback = (t: TFnType): TTemplate => {
|
|||||||
return buildSurvey(
|
return buildSurvey(
|
||||||
{
|
{
|
||||||
name: t("templates.docs_feedback_name"),
|
name: t("templates.docs_feedback_name"),
|
||||||
|
role: "productManager",
|
||||||
industries: ["saas"],
|
industries: ["saas"],
|
||||||
channels: ["app", "website", "link"],
|
channels: ["app", "website", "link"],
|
||||||
description: t("templates.docs_feedback_description"),
|
description: t("templates.docs_feedback_description"),
|
||||||
@@ -1499,6 +1522,7 @@ const nps = (t: TFnType): TTemplate => {
|
|||||||
return buildSurvey(
|
return buildSurvey(
|
||||||
{
|
{
|
||||||
name: t("templates.nps_name"),
|
name: t("templates.nps_name"),
|
||||||
|
role: "customerSuccess",
|
||||||
industries: ["saas", "eCommerce", "other"],
|
industries: ["saas", "eCommerce", "other"],
|
||||||
channels: ["app", "link", "website"],
|
channels: ["app", "link", "website"],
|
||||||
description: t("templates.nps_description"),
|
description: t("templates.nps_description"),
|
||||||
@@ -1539,6 +1563,7 @@ const customerSatisfactionScore = (t: TFnType): TTemplate => {
|
|||||||
return buildSurvey(
|
return buildSurvey(
|
||||||
{
|
{
|
||||||
name: t("templates.csat_name"),
|
name: t("templates.csat_name"),
|
||||||
|
role: "customerSuccess",
|
||||||
industries: ["saas", "eCommerce", "other"],
|
industries: ["saas", "eCommerce", "other"],
|
||||||
channels: ["app", "link", "website"],
|
channels: ["app", "link", "website"],
|
||||||
description: t("templates.csat_description"),
|
description: t("templates.csat_description"),
|
||||||
@@ -1707,6 +1732,7 @@ const collectFeedback = (t: TFnType): TTemplate => {
|
|||||||
return buildSurvey(
|
return buildSurvey(
|
||||||
{
|
{
|
||||||
name: t("templates.collect_feedback_name"),
|
name: t("templates.collect_feedback_name"),
|
||||||
|
role: "productManager",
|
||||||
industries: ["other", "eCommerce"],
|
industries: ["other", "eCommerce"],
|
||||||
channels: ["website", "link"],
|
channels: ["website", "link"],
|
||||||
description: t("templates.collect_feedback_description"),
|
description: t("templates.collect_feedback_description"),
|
||||||
@@ -1853,6 +1879,7 @@ const identifyUpsellOpportunities = (t: TFnType): TTemplate => {
|
|||||||
return buildSurvey(
|
return buildSurvey(
|
||||||
{
|
{
|
||||||
name: t("templates.identify_upsell_opportunities_name"),
|
name: t("templates.identify_upsell_opportunities_name"),
|
||||||
|
role: "sales",
|
||||||
industries: ["saas"],
|
industries: ["saas"],
|
||||||
channels: ["app", "link"],
|
channels: ["app", "link"],
|
||||||
description: t("templates.identify_upsell_opportunities_description"),
|
description: t("templates.identify_upsell_opportunities_description"),
|
||||||
@@ -1882,6 +1909,7 @@ const prioritizeFeatures = (t: TFnType): TTemplate => {
|
|||||||
return buildSurvey(
|
return buildSurvey(
|
||||||
{
|
{
|
||||||
name: t("templates.prioritize_features_name"),
|
name: t("templates.prioritize_features_name"),
|
||||||
|
role: "productManager",
|
||||||
industries: ["saas"],
|
industries: ["saas"],
|
||||||
channels: ["app"],
|
channels: ["app"],
|
||||||
description: t("templates.prioritize_features_description"),
|
description: t("templates.prioritize_features_description"),
|
||||||
@@ -1934,6 +1962,7 @@ const gaugeFeatureSatisfaction = (t: TFnType): TTemplate => {
|
|||||||
return buildSurvey(
|
return buildSurvey(
|
||||||
{
|
{
|
||||||
name: t("templates.gauge_feature_satisfaction_name"),
|
name: t("templates.gauge_feature_satisfaction_name"),
|
||||||
|
role: "productManager",
|
||||||
industries: ["saas"],
|
industries: ["saas"],
|
||||||
channels: ["app"],
|
channels: ["app"],
|
||||||
description: t("templates.gauge_feature_satisfaction_description"),
|
description: t("templates.gauge_feature_satisfaction_description"),
|
||||||
@@ -1967,6 +1996,7 @@ const marketSiteClarity = (t: TFnType): TTemplate => {
|
|||||||
return buildSurvey(
|
return buildSurvey(
|
||||||
{
|
{
|
||||||
name: t("templates.market_site_clarity_name"),
|
name: t("templates.market_site_clarity_name"),
|
||||||
|
role: "marketing",
|
||||||
industries: ["saas", "eCommerce", "other"],
|
industries: ["saas", "eCommerce", "other"],
|
||||||
channels: ["website"],
|
channels: ["website"],
|
||||||
description: t("templates.market_site_clarity_description"),
|
description: t("templates.market_site_clarity_description"),
|
||||||
@@ -2008,6 +2038,7 @@ const customerEffortScore = (t: TFnType): TTemplate => {
|
|||||||
return buildSurvey(
|
return buildSurvey(
|
||||||
{
|
{
|
||||||
name: t("templates.customer_effort_score_name"),
|
name: t("templates.customer_effort_score_name"),
|
||||||
|
role: "productManager",
|
||||||
industries: ["saas"],
|
industries: ["saas"],
|
||||||
channels: ["app"],
|
channels: ["app"],
|
||||||
description: t("templates.customer_effort_score_description"),
|
description: t("templates.customer_effort_score_description"),
|
||||||
@@ -2039,6 +2070,7 @@ const careerDevelopmentSurvey = (t: TFnType): TTemplate => {
|
|||||||
return buildSurvey(
|
return buildSurvey(
|
||||||
{
|
{
|
||||||
name: t("templates.career_development_survey_name"),
|
name: t("templates.career_development_survey_name"),
|
||||||
|
role: "productManager",
|
||||||
industries: ["saas", "eCommerce", "other"],
|
industries: ["saas", "eCommerce", "other"],
|
||||||
channels: ["link"],
|
channels: ["link"],
|
||||||
description: t("templates.career_development_survey_description"),
|
description: t("templates.career_development_survey_description"),
|
||||||
@@ -2125,6 +2157,7 @@ const professionalDevelopmentSurvey = (t: TFnType): TTemplate => {
|
|||||||
return buildSurvey(
|
return buildSurvey(
|
||||||
{
|
{
|
||||||
name: t("templates.professional_development_survey_name"),
|
name: t("templates.professional_development_survey_name"),
|
||||||
|
role: "productManager",
|
||||||
industries: ["saas", "eCommerce", "other"],
|
industries: ["saas", "eCommerce", "other"],
|
||||||
channels: ["link"],
|
channels: ["link"],
|
||||||
description: t("templates.professional_development_survey_description"),
|
description: t("templates.professional_development_survey_description"),
|
||||||
@@ -2212,6 +2245,7 @@ const rateCheckoutExperience = (t: TFnType): TTemplate => {
|
|||||||
return buildSurvey(
|
return buildSurvey(
|
||||||
{
|
{
|
||||||
name: t("templates.rate_checkout_experience_name"),
|
name: t("templates.rate_checkout_experience_name"),
|
||||||
|
role: "productManager",
|
||||||
industries: ["eCommerce"],
|
industries: ["eCommerce"],
|
||||||
channels: ["website", "app"],
|
channels: ["website", "app"],
|
||||||
description: t("templates.rate_checkout_experience_description"),
|
description: t("templates.rate_checkout_experience_description"),
|
||||||
@@ -2288,6 +2322,7 @@ const measureSearchExperience = (t: TFnType): TTemplate => {
|
|||||||
return buildSurvey(
|
return buildSurvey(
|
||||||
{
|
{
|
||||||
name: t("templates.measure_search_experience_name"),
|
name: t("templates.measure_search_experience_name"),
|
||||||
|
role: "productManager",
|
||||||
industries: ["saas", "eCommerce"],
|
industries: ["saas", "eCommerce"],
|
||||||
channels: ["app", "website"],
|
channels: ["app", "website"],
|
||||||
description: t("templates.measure_search_experience_description"),
|
description: t("templates.measure_search_experience_description"),
|
||||||
@@ -2364,6 +2399,7 @@ const evaluateContentQuality = (t: TFnType): TTemplate => {
|
|||||||
return buildSurvey(
|
return buildSurvey(
|
||||||
{
|
{
|
||||||
name: t("templates.evaluate_content_quality_name"),
|
name: t("templates.evaluate_content_quality_name"),
|
||||||
|
role: "marketing",
|
||||||
industries: ["other"],
|
industries: ["other"],
|
||||||
channels: ["website"],
|
channels: ["website"],
|
||||||
description: t("templates.evaluate_content_quality_description"),
|
description: t("templates.evaluate_content_quality_description"),
|
||||||
@@ -2441,6 +2477,7 @@ const measureTaskAccomplishment = (t: TFnType): TTemplate => {
|
|||||||
return buildSurvey(
|
return buildSurvey(
|
||||||
{
|
{
|
||||||
name: t("templates.measure_task_accomplishment_name"),
|
name: t("templates.measure_task_accomplishment_name"),
|
||||||
|
role: "productManager",
|
||||||
industries: ["saas"],
|
industries: ["saas"],
|
||||||
channels: ["app", "website"],
|
channels: ["app", "website"],
|
||||||
description: t("templates.measure_task_accomplishment_description"),
|
description: t("templates.measure_task_accomplishment_description"),
|
||||||
@@ -2623,6 +2660,7 @@ const identifySignUpBarriers = (t: TFnType): TTemplate => {
|
|||||||
return buildSurvey(
|
return buildSurvey(
|
||||||
{
|
{
|
||||||
name: t("templates.identify_sign_up_barriers_name"),
|
name: t("templates.identify_sign_up_barriers_name"),
|
||||||
|
role: "marketing",
|
||||||
industries: ["saas", "eCommerce", "other"],
|
industries: ["saas", "eCommerce", "other"],
|
||||||
channels: ["website"],
|
channels: ["website"],
|
||||||
description: t("templates.identify_sign_up_barriers_description"),
|
description: t("templates.identify_sign_up_barriers_description"),
|
||||||
@@ -2774,6 +2812,7 @@ const buildProductRoadmap = (t: TFnType): TTemplate => {
|
|||||||
return buildSurvey(
|
return buildSurvey(
|
||||||
{
|
{
|
||||||
name: t("templates.build_product_roadmap_name"),
|
name: t("templates.build_product_roadmap_name"),
|
||||||
|
role: "productManager",
|
||||||
industries: ["saas"],
|
industries: ["saas"],
|
||||||
channels: ["app", "link"],
|
channels: ["app", "link"],
|
||||||
description: t("templates.build_product_roadmap_description"),
|
description: t("templates.build_product_roadmap_description"),
|
||||||
@@ -2808,6 +2847,7 @@ const understandPurchaseIntention = (t: TFnType): TTemplate => {
|
|||||||
return buildSurvey(
|
return buildSurvey(
|
||||||
{
|
{
|
||||||
name: t("templates.understand_purchase_intention_name"),
|
name: t("templates.understand_purchase_intention_name"),
|
||||||
|
role: "sales",
|
||||||
industries: ["eCommerce"],
|
industries: ["eCommerce"],
|
||||||
channels: ["website", "link", "app"],
|
channels: ["website", "link", "app"],
|
||||||
description: t("templates.understand_purchase_intention_description"),
|
description: t("templates.understand_purchase_intention_description"),
|
||||||
@@ -2863,6 +2903,7 @@ const improveNewsletterContent = (t: TFnType): TTemplate => {
|
|||||||
return buildSurvey(
|
return buildSurvey(
|
||||||
{
|
{
|
||||||
name: t("templates.improve_newsletter_content_name"),
|
name: t("templates.improve_newsletter_content_name"),
|
||||||
|
role: "marketing",
|
||||||
industries: ["eCommerce", "saas", "other"],
|
industries: ["eCommerce", "saas", "other"],
|
||||||
channels: ["link"],
|
channels: ["link"],
|
||||||
description: t("templates.improve_newsletter_content_description"),
|
description: t("templates.improve_newsletter_content_description"),
|
||||||
@@ -2953,6 +2994,7 @@ const evaluateAProductIdea = (t: TFnType): TTemplate => {
|
|||||||
return buildSurvey(
|
return buildSurvey(
|
||||||
{
|
{
|
||||||
name: t("templates.evaluate_a_product_idea_name"),
|
name: t("templates.evaluate_a_product_idea_name"),
|
||||||
|
role: "productManager",
|
||||||
industries: ["saas", "other"],
|
industries: ["saas", "other"],
|
||||||
channels: ["link", "app"],
|
channels: ["link", "app"],
|
||||||
description: t("templates.evaluate_a_product_idea_description"),
|
description: t("templates.evaluate_a_product_idea_description"),
|
||||||
@@ -3055,6 +3097,7 @@ const understandLowEngagement = (t: TFnType): TTemplate => {
|
|||||||
return buildSurvey(
|
return buildSurvey(
|
||||||
{
|
{
|
||||||
name: t("templates.understand_low_engagement_name"),
|
name: t("templates.understand_low_engagement_name"),
|
||||||
|
role: "productManager",
|
||||||
industries: ["saas"],
|
industries: ["saas"],
|
||||||
channels: ["link"],
|
channels: ["link"],
|
||||||
description: t("templates.understand_low_engagement_description"),
|
description: t("templates.understand_low_engagement_description"),
|
||||||
@@ -3140,6 +3183,7 @@ const employeeWellBeing = (t: TFnType): TTemplate => {
|
|||||||
return buildSurvey(
|
return buildSurvey(
|
||||||
{
|
{
|
||||||
name: t("templates.employee_well_being_name"),
|
name: t("templates.employee_well_being_name"),
|
||||||
|
role: "peopleManager",
|
||||||
industries: ["saas", "eCommerce", "other"],
|
industries: ["saas", "eCommerce", "other"],
|
||||||
channels: ["link"],
|
channels: ["link"],
|
||||||
description: t("templates.employee_well_being_description"),
|
description: t("templates.employee_well_being_description"),
|
||||||
@@ -3189,6 +3233,7 @@ const longTermRetentionCheckIn = (t: TFnType): TTemplate => {
|
|||||||
return buildSurvey(
|
return buildSurvey(
|
||||||
{
|
{
|
||||||
name: t("templates.long_term_retention_check_in_name"),
|
name: t("templates.long_term_retention_check_in_name"),
|
||||||
|
role: "peopleManager",
|
||||||
industries: ["saas", "other"],
|
industries: ["saas", "other"],
|
||||||
channels: ["app", "link"],
|
channels: ["app", "link"],
|
||||||
description: t("templates.long_term_retention_check_in_description"),
|
description: t("templates.long_term_retention_check_in_description"),
|
||||||
@@ -3297,6 +3342,7 @@ const professionalDevelopmentGrowth = (t: TFnType): TTemplate => {
|
|||||||
return buildSurvey(
|
return buildSurvey(
|
||||||
{
|
{
|
||||||
name: t("templates.professional_development_growth_survey_name"),
|
name: t("templates.professional_development_growth_survey_name"),
|
||||||
|
role: "peopleManager",
|
||||||
industries: ["saas", "eCommerce", "other"],
|
industries: ["saas", "eCommerce", "other"],
|
||||||
channels: ["link"],
|
channels: ["link"],
|
||||||
description: t("templates.professional_development_growth_survey_description"),
|
description: t("templates.professional_development_growth_survey_description"),
|
||||||
@@ -3346,6 +3392,7 @@ const recognitionAndReward = (t: TFnType): TTemplate => {
|
|||||||
return buildSurvey(
|
return buildSurvey(
|
||||||
{
|
{
|
||||||
name: t("templates.recognition_and_reward_survey_name"),
|
name: t("templates.recognition_and_reward_survey_name"),
|
||||||
|
role: "peopleManager",
|
||||||
industries: ["saas", "eCommerce", "other"],
|
industries: ["saas", "eCommerce", "other"],
|
||||||
channels: ["link"],
|
channels: ["link"],
|
||||||
description: t("templates.recognition_and_reward_survey_description"),
|
description: t("templates.recognition_and_reward_survey_description"),
|
||||||
@@ -3394,6 +3441,7 @@ const alignmentAndEngagement = (t: TFnType): TTemplate => {
|
|||||||
return buildSurvey(
|
return buildSurvey(
|
||||||
{
|
{
|
||||||
name: t("templates.alignment_and_engagement_survey_name"),
|
name: t("templates.alignment_and_engagement_survey_name"),
|
||||||
|
role: "peopleManager",
|
||||||
industries: ["saas", "eCommerce", "other"],
|
industries: ["saas", "eCommerce", "other"],
|
||||||
channels: ["link"],
|
channels: ["link"],
|
||||||
description: t("templates.alignment_and_engagement_survey_description"),
|
description: t("templates.alignment_and_engagement_survey_description"),
|
||||||
@@ -3442,6 +3490,7 @@ const supportiveWorkCulture = (t: TFnType): TTemplate => {
|
|||||||
return buildSurvey(
|
return buildSurvey(
|
||||||
{
|
{
|
||||||
name: t("templates.supportive_work_culture_survey_name"),
|
name: t("templates.supportive_work_culture_survey_name"),
|
||||||
|
role: "peopleManager",
|
||||||
industries: ["saas", "eCommerce", "other"],
|
industries: ["saas", "eCommerce", "other"],
|
||||||
channels: ["link"],
|
channels: ["link"],
|
||||||
description: t("templates.supportive_work_culture_survey_description"),
|
description: t("templates.supportive_work_culture_survey_description"),
|
||||||
|
|||||||
@@ -114,7 +114,10 @@ export const MAX_FILE_UPLOAD_SIZES = {
|
|||||||
standard: 1024 * 1024 * 10, // 10MB
|
standard: 1024 * 1024 * 10, // 10MB
|
||||||
big: 1024 * 1024 * 1024, // 1GB
|
big: 1024 * 1024 * 1024, // 1GB
|
||||||
} as const;
|
} as const;
|
||||||
export const IS_STORAGE_CONFIGURED = Boolean(S3_ACCESS_KEY && S3_SECRET_KEY && S3_REGION && S3_BUCKET_NAME);
|
// Storage is considered configured if we have the minimum required settings:
|
||||||
|
// - S3_REGION and S3_BUCKET_NAME are always required
|
||||||
|
// - S3_ACCESS_KEY and S3_SECRET_KEY are optional (for IAM role-based authentication)
|
||||||
|
export const IS_STORAGE_CONFIGURED = Boolean(S3_BUCKET_NAME);
|
||||||
|
|
||||||
// Colors for Survey Bg
|
// Colors for Survey Bg
|
||||||
export const SURVEY_BG_COLORS = [
|
export const SURVEY_BG_COLORS = [
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { env } from "@/lib/env";
|
import jwt from "jsonwebtoken";
|
||||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
import { prisma } from "@formbricks/database";
|
import { prisma } from "@formbricks/database";
|
||||||
|
import * as crypto from "@/lib/crypto";
|
||||||
import {
|
import {
|
||||||
createEmailChangeToken,
|
createEmailChangeToken,
|
||||||
createEmailToken,
|
createEmailToken,
|
||||||
@@ -14,12 +15,69 @@ import {
|
|||||||
verifyTokenForLinkSurvey,
|
verifyTokenForLinkSurvey,
|
||||||
} from "./jwt";
|
} 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
|
// Mock environment variables
|
||||||
vi.mock("@/lib/env", () => ({
|
vi.mock("@/lib/env", () => ({
|
||||||
env: {
|
env: {
|
||||||
ENCRYPTION_KEY: "0".repeat(32), // 32-byte key for AES-256-GCM
|
ENCRYPTION_KEY: "0".repeat(32),
|
||||||
NEXTAUTH_SECRET: "test-nextauth-secret",
|
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
|
// 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 = {
|
const mockUser = {
|
||||||
id: "test-user-id",
|
id: "test-user-id",
|
||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let mockSymmetricEncrypt: any;
|
||||||
|
let mockSymmetricDecrypt: any;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
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);
|
(prisma.user.findUnique as any).mockResolvedValue(mockUser);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("createToken", () => {
|
describe("createToken", () => {
|
||||||
test("should create a valid token", () => {
|
test("should create a valid token with encrypted user ID", () => {
|
||||||
const token = createToken(mockUser.id, mockUser.email);
|
const token = createToken(mockUser.id);
|
||||||
expect(token).toBeDefined();
|
expect(token).toBeDefined();
|
||||||
expect(typeof token).toBe("string");
|
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);
|
const token = createTokenForLinkSurvey(surveyId, mockUser.email);
|
||||||
expect(token).toBeDefined();
|
expect(token).toBeDefined();
|
||||||
expect(typeof token).toBe("string");
|
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);
|
const token = createEmailToken(mockUser.email);
|
||||||
expect(token).toBeDefined();
|
expect(token).toBeDefined();
|
||||||
expect(typeof token).toBe("string");
|
expect(typeof token).toBe("string");
|
||||||
|
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(mockUser.email, TEST_ENCRYPTION_KEY);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should throw error if NEXTAUTH_SECRET is not set", () => {
|
test("should throw error if NEXTAUTH_SECRET or ENCRYPTION_KEY is not set", async () => {
|
||||||
const originalSecret = env.NEXTAUTH_SECRET;
|
await testMissingSecretsError(createEmailToken, [mockUser.email]);
|
||||||
try {
|
|
||||||
(env as any).NEXTAUTH_SECRET = undefined;
|
|
||||||
expect(() => createEmailToken(mockUser.email)).toThrow("NEXTAUTH_SECRET is not set");
|
|
||||||
} finally {
|
|
||||||
(env as any).NEXTAUTH_SECRET = originalSecret;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getEmailFromEmailToken", () => {
|
describe("createEmailChangeToken", () => {
|
||||||
test("should extract email from valid token", () => {
|
test("should create a valid email change token with 1 day expiration", () => {
|
||||||
const token = createEmailToken(mockUser.email);
|
const token = createEmailChangeToken(mockUser.id, mockUser.email);
|
||||||
const extractedEmail = getEmailFromEmailToken(token);
|
expect(token).toBeDefined();
|
||||||
expect(extractedEmail).toBe(mockUser.email);
|
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);
|
const token = createInviteToken(inviteId, mockUser.email);
|
||||||
expect(token).toBeDefined();
|
expect(token).toBeDefined();
|
||||||
expect(typeof token).toBe("string");
|
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");
|
const result = verifyTokenForLinkSurvey("invalid-token", "test-survey-id");
|
||||||
expect(result).toBeNull();
|
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", () => {
|
describe("verifyToken", () => {
|
||||||
test("should verify valid token", async () => {
|
test("should verify valid token", async () => {
|
||||||
const token = createToken(mockUser.id, mockUser.email);
|
const token = createToken(mockUser.id);
|
||||||
const verified = await verifyToken(token);
|
const verified = await verifyToken(token);
|
||||||
expect(verified).toEqual({
|
expect(verified).toEqual({
|
||||||
id: mockUser.id,
|
id: mockUser.id, // Returns the decrypted user ID
|
||||||
email: mockUser.email,
|
email: mockUser.email,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should throw error if user not found", async () => {
|
test("should throw error if user not found", async () => {
|
||||||
(prisma.user.findUnique as any).mockResolvedValue(null);
|
(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");
|
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", () => {
|
describe("verifyInviteToken", () => {
|
||||||
@@ -139,6 +473,53 @@ describe("JWT Functions", () => {
|
|||||||
test("should throw error for invalid token", () => {
|
test("should throw error for invalid token", () => {
|
||||||
expect(() => verifyInviteToken("invalid-token")).toThrow("Invalid or expired invite 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", () => {
|
describe("verifyEmailChangeToken", () => {
|
||||||
@@ -150,22 +531,478 @@ describe("JWT Functions", () => {
|
|||||||
expect(result).toEqual({ id: userId, email });
|
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 () => {
|
test("should throw error if token is invalid or missing fields", async () => {
|
||||||
// Create a token with missing fields
|
const token = jwt.sign({ foo: "bar" }, TEST_NEXTAUTH_SECRET);
|
||||||
const jwt = await import("jsonwebtoken");
|
await expect(verifyEmailChangeToken(token)).rejects.toThrow(
|
||||||
const token = jwt.sign({ foo: "bar" }, env.NEXTAUTH_SECRET as string);
|
"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(
|
await expect(verifyEmailChangeToken(token)).rejects.toThrow(
|
||||||
"Token is invalid or missing required fields"
|
"Token is invalid or missing required fields"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should return original id/email if decryption fails", async () => {
|
test("should return original id/email if decryption fails", async () => {
|
||||||
// Create a token with non-encrypted id/email
|
mockSymmetricDecrypt.mockImplementation(() => {
|
||||||
const jwt = await import("jsonwebtoken");
|
throw new Error("Decryption failed");
|
||||||
|
});
|
||||||
|
|
||||||
const payload = { id: "plain-id", email: "plain@example.com" };
|
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);
|
const result = await verifyEmailChangeToken(token);
|
||||||
expect(result).toEqual(payload);
|
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
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,43 +1,64 @@
|
|||||||
import { symmetricDecrypt, symmetricEncrypt } from "@/lib/crypto";
|
|
||||||
import { env } from "@/lib/env";
|
|
||||||
import jwt, { JwtPayload } from "jsonwebtoken";
|
import jwt, { JwtPayload } from "jsonwebtoken";
|
||||||
import { prisma } from "@formbricks/database";
|
import { prisma } from "@formbricks/database";
|
||||||
import { logger } from "@formbricks/logger";
|
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 => {
|
// Helper function to decrypt with fallback to plain text
|
||||||
const encryptedUserId = symmetricEncrypt(userId, env.ENCRYPTION_KEY);
|
const decryptWithFallback = (encryptedText: string, key: string): string => {
|
||||||
return jwt.sign({ id: encryptedUserId }, env.NEXTAUTH_SECRET + userEmail, options);
|
try {
|
||||||
};
|
return symmetricDecrypt(encryptedText, key);
|
||||||
export const createTokenForLinkSurvey = (surveyId: string, userEmail: string): string => {
|
} catch {
|
||||||
const encryptedEmail = symmetricEncrypt(userEmail, env.ENCRYPTION_KEY);
|
return encryptedText; // Return as-is if decryption fails (legacy format)
|
||||||
return jwt.sign({ email: encryptedEmail }, env.NEXTAUTH_SECRET + surveyId);
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const verifyEmailChangeToken = async (token: string): Promise<{ id: string; email: string }> => {
|
export const createToken = (userId: string, options = {}): string => {
|
||||||
if (!env.NEXTAUTH_SECRET) {
|
if (!NEXTAUTH_SECRET) {
|
||||||
throw new Error("NEXTAUTH_SECRET is not set");
|
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) {
|
if (!payload?.id || !payload?.email) {
|
||||||
throw new Error("Token is invalid or missing required fields");
|
throw new Error("Token is invalid or missing required fields");
|
||||||
}
|
}
|
||||||
|
|
||||||
let decryptedId: string;
|
// Decrypt both fields with fallback
|
||||||
let decryptedEmail: string;
|
const decryptedId = decryptWithFallback(payload.id, ENCRYPTION_KEY);
|
||||||
|
const decryptedEmail = decryptWithFallback(payload.email, ENCRYPTION_KEY);
|
||||||
try {
|
|
||||||
decryptedId = symmetricDecrypt(payload.id, env.ENCRYPTION_KEY);
|
|
||||||
} catch {
|
|
||||||
decryptedId = payload.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
decryptedEmail = symmetricDecrypt(payload.email, env.ENCRYPTION_KEY);
|
|
||||||
} catch {
|
|
||||||
decryptedEmail = payload.email;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: decryptedId,
|
id: decryptedId,
|
||||||
@@ -46,127 +67,230 @@ export const verifyEmailChangeToken = async (token: string): Promise<{ id: strin
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const createEmailChangeToken = (userId: string, email: string): string => {
|
export const createEmailChangeToken = (userId: string, email: string): string => {
|
||||||
const encryptedUserId = symmetricEncrypt(userId, env.ENCRYPTION_KEY);
|
if (!NEXTAUTH_SECRET) {
|
||||||
const encryptedEmail = symmetricEncrypt(email, env.ENCRYPTION_KEY);
|
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 = {
|
const payload = {
|
||||||
id: encryptedUserId,
|
id: encryptedUserId,
|
||||||
email: encryptedEmail,
|
email: encryptedEmail,
|
||||||
};
|
};
|
||||||
|
|
||||||
return jwt.sign(payload, env.NEXTAUTH_SECRET as string, {
|
return jwt.sign(payload, NEXTAUTH_SECRET, {
|
||||||
expiresIn: "1d",
|
expiresIn: "1d",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createEmailToken = (email: string): string => {
|
export const createEmailToken = (email: string): string => {
|
||||||
if (!env.NEXTAUTH_SECRET) {
|
if (!NEXTAUTH_SECRET) {
|
||||||
throw new Error("NEXTAUTH_SECRET is not set");
|
throw new Error("NEXTAUTH_SECRET is not set");
|
||||||
}
|
}
|
||||||
|
|
||||||
const encryptedEmail = symmetricEncrypt(email, env.ENCRYPTION_KEY);
|
if (!ENCRYPTION_KEY) {
|
||||||
return jwt.sign({ email: encryptedEmail }, env.NEXTAUTH_SECRET);
|
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 => {
|
export const getEmailFromEmailToken = (token: string): string => {
|
||||||
if (!env.NEXTAUTH_SECRET) {
|
if (!NEXTAUTH_SECRET) {
|
||||||
throw new Error("NEXTAUTH_SECRET is not set");
|
throw new Error("NEXTAUTH_SECRET is not set");
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = jwt.verify(token, env.NEXTAUTH_SECRET) as JwtPayload;
|
if (!ENCRYPTION_KEY) {
|
||||||
try {
|
throw new Error("ENCRYPTION_KEY is not set");
|
||||||
// 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 => {
|
export const createInviteToken = (inviteId: string, email: string, options = {}): string => {
|
||||||
if (!env.NEXTAUTH_SECRET) {
|
if (!NEXTAUTH_SECRET) {
|
||||||
throw new Error("NEXTAUTH_SECRET is not set");
|
throw new Error("NEXTAUTH_SECRET is not set");
|
||||||
}
|
}
|
||||||
const encryptedInviteId = symmetricEncrypt(inviteId, env.ENCRYPTION_KEY);
|
|
||||||
const encryptedEmail = symmetricEncrypt(email, env.ENCRYPTION_KEY);
|
if (!ENCRYPTION_KEY) {
|
||||||
return jwt.sign({ inviteId: encryptedInviteId, email: encryptedEmail }, env.NEXTAUTH_SECRET, options);
|
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 => {
|
export const verifyTokenForLinkSurvey = (token: string, surveyId: string): string | null => {
|
||||||
|
if (!NEXTAUTH_SECRET) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
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 {
|
||||||
// Try to decrypt first (for newer tokens)
|
payload = jwt.verify(token, NEXTAUTH_SECRET, { algorithms: ["HS256"] }) as JwtPayload & {
|
||||||
if (!env.ENCRYPTION_KEY) {
|
email: string;
|
||||||
throw new Error("ENCRYPTION_KEY is not set");
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const verifyToken = async (token: string): Promise<JwtPayload> => {
|
// Helper function to get user email for legacy verification
|
||||||
// First decode to get the ID
|
const getUserEmailForLegacyVerification = async (
|
||||||
const decoded = jwt.decode(token);
|
token: string,
|
||||||
const payload: JwtPayload = decoded as JwtPayload;
|
userId?: string
|
||||||
|
): Promise<{ userId: string; userEmail: string }> => {
|
||||||
|
if (!userId) {
|
||||||
|
const decoded = jwt.decode(token);
|
||||||
|
|
||||||
if (!payload) {
|
// Validate decoded token structure before using it
|
||||||
throw new Error("Token is invalid");
|
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;
|
const decryptedId = decryptWithFallback(userId, ENCRYPTION_KEY);
|
||||||
if (!id) {
|
|
||||||
throw new Error("Token missing required field: id");
|
// 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({
|
const foundUser = await prisma.user.findUnique({
|
||||||
where: { id: decryptedId },
|
where: { id: decryptedId },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!foundUser) {
|
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 } => {
|
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 {
|
try {
|
||||||
const decoded = jwt.decode(token);
|
const payload = jwt.verify(token, NEXTAUTH_SECRET, { algorithms: ["HS256"] }) as JwtPayload & {
|
||||||
const payload: JwtPayload = decoded as JwtPayload;
|
inviteId: string;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
|
||||||
const { inviteId, email } = payload;
|
const { inviteId: encryptedInviteId, email: encryptedEmail } = payload;
|
||||||
|
|
||||||
let decryptedInviteId: string;
|
if (!encryptedInviteId || !encryptedEmail) {
|
||||||
let decryptedEmail: string;
|
throw new Error("Invalid token");
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Decrypt both fields with fallback to original values
|
||||||
|
const decryptedInviteId = decryptWithFallback(encryptedInviteId, ENCRYPTION_KEY);
|
||||||
|
const decryptedEmail = decryptWithFallback(encryptedEmail, ENCRYPTION_KEY);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
inviteId: decryptedInviteId,
|
inviteId: decryptedInviteId,
|
||||||
email: decryptedEmail,
|
email: decryptedEmail,
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { parseRecallInfo } from "@/lib/utils/recall";
|
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 { TSurvey, TSurveyQuestion, TSurveyQuestionType } from "@formbricks/types/surveys/types";
|
||||||
import { getLanguageCode, getLocalizedValue } from "./i18n/utils";
|
import { getLanguageCode, getLocalizedValue } from "./i18n/utils";
|
||||||
|
|
||||||
// function to convert response value of type string | number | string[] or Record<string, string> to string | string[]
|
// function to convert response value of type string | number | string[] or Record<string, string> to string | string[]
|
||||||
export const convertResponseValue = (
|
export const convertResponseValue = (
|
||||||
answer: string | number | string[] | Record<string, string>,
|
answer: TResponseDataValue,
|
||||||
question: TSurveyQuestion
|
question: TSurveyQuestion
|
||||||
): string | string[] => {
|
): string | string[] => {
|
||||||
switch (question.type) {
|
switch (question.type) {
|
||||||
@@ -57,9 +57,7 @@ export const getQuestionResponseMapping = (
|
|||||||
return questionResponseMapping;
|
return questionResponseMapping;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const processResponseData = (
|
export const processResponseData = (responseData: TResponseDataValue): string => {
|
||||||
responseData: string | number | string[] | Record<string, string>
|
|
||||||
): string => {
|
|
||||||
switch (typeof responseData) {
|
switch (typeof responseData) {
|
||||||
case "string":
|
case "string":
|
||||||
return responseData;
|
return responseData;
|
||||||
|
|||||||
@@ -450,7 +450,7 @@ const evaluateSingleCondition = (
|
|||||||
return (
|
return (
|
||||||
Array.isArray(leftValue) &&
|
Array.isArray(leftValue) &&
|
||||||
Array.isArray(rightValue) &&
|
Array.isArray(rightValue) &&
|
||||||
rightValue.some((v) => !leftValue.includes(v))
|
!rightValue.some((v) => leftValue.includes(v))
|
||||||
);
|
);
|
||||||
case "isAccepted":
|
case "isAccepted":
|
||||||
return leftValue === "accepted";
|
return leftValue === "accepted";
|
||||||
|
|||||||
@@ -169,11 +169,14 @@
|
|||||||
"connect_formbricks": "Formbricks verbinden",
|
"connect_formbricks": "Formbricks verbinden",
|
||||||
"connected": "Verbunden",
|
"connected": "Verbunden",
|
||||||
"contacts": "Kontakte",
|
"contacts": "Kontakte",
|
||||||
|
"continue": "Weitermachen",
|
||||||
"copied": "Kopiert",
|
"copied": "Kopiert",
|
||||||
"copied_to_clipboard": "In die Zwischenablage kopiert",
|
"copied_to_clipboard": "In die Zwischenablage kopiert",
|
||||||
"copy": "Kopieren",
|
"copy": "Kopieren",
|
||||||
"copy_code": "Code kopieren",
|
"copy_code": "Code kopieren",
|
||||||
"copy_link": "Link kopieren",
|
"copy_link": "Link kopieren",
|
||||||
|
"count_contacts": "{value, plural, other {'{'value, plural,\none '{{#}' Kontakt'}'\nother '{{#}' Kontakte'}'\n'}'}}",
|
||||||
|
"count_responses": "{value, plural, other {{count} Antworten}}",
|
||||||
"create_new_organization": "Neue Organisation erstellen",
|
"create_new_organization": "Neue Organisation erstellen",
|
||||||
"create_project": "Projekt erstellen",
|
"create_project": "Projekt erstellen",
|
||||||
"create_segment": "Segment erstellen",
|
"create_segment": "Segment erstellen",
|
||||||
@@ -201,6 +204,7 @@
|
|||||||
"e_commerce": "E-Commerce",
|
"e_commerce": "E-Commerce",
|
||||||
"edit": "Bearbeiten",
|
"edit": "Bearbeiten",
|
||||||
"email": "E-Mail",
|
"email": "E-Mail",
|
||||||
|
"ending_card": "Abschluss-Karte",
|
||||||
"enterprise_license": "Enterprise Lizenz",
|
"enterprise_license": "Enterprise Lizenz",
|
||||||
"environment_not_found": "Umgebung nicht gefunden",
|
"environment_not_found": "Umgebung nicht gefunden",
|
||||||
"environment_notice": "Du befindest dich derzeit in der {environment}-Umgebung.",
|
"environment_notice": "Du befindest dich derzeit in der {environment}-Umgebung.",
|
||||||
@@ -269,6 +273,7 @@
|
|||||||
"no_background_image_found": "Kein Hintergrundbild gefunden.",
|
"no_background_image_found": "Kein Hintergrundbild gefunden.",
|
||||||
"no_code": "No Code",
|
"no_code": "No Code",
|
||||||
"no_files_uploaded": "Keine Dateien hochgeladen",
|
"no_files_uploaded": "Keine Dateien hochgeladen",
|
||||||
|
"no_quotas_found": "Keine Kontingente gefunden",
|
||||||
"no_result_found": "Kein Ergebnis gefunden",
|
"no_result_found": "Kein Ergebnis gefunden",
|
||||||
"no_results": "Keine Ergebnisse",
|
"no_results": "Keine Ergebnisse",
|
||||||
"no_surveys_found": "Keine Umfragen gefunden.",
|
"no_surveys_found": "Keine Umfragen gefunden.",
|
||||||
@@ -312,6 +317,7 @@
|
|||||||
"product_manager": "Produktmanager",
|
"product_manager": "Produktmanager",
|
||||||
"profile": "Profil",
|
"profile": "Profil",
|
||||||
"profile_id": "Profil-ID",
|
"profile_id": "Profil-ID",
|
||||||
|
"progress": "Fortschritt",
|
||||||
"project_configuration": "Projekteinstellungen",
|
"project_configuration": "Projekteinstellungen",
|
||||||
"project_creation_description": "Organisieren Sie Umfragen in Projekten für eine bessere Zugriffskontrolle.",
|
"project_creation_description": "Organisieren Sie Umfragen in Projekten für eine bessere Zugriffskontrolle.",
|
||||||
"project_id": "Projekt-ID",
|
"project_id": "Projekt-ID",
|
||||||
@@ -323,6 +329,9 @@
|
|||||||
"question": "Frage",
|
"question": "Frage",
|
||||||
"question_id": "Frage-ID",
|
"question_id": "Frage-ID",
|
||||||
"questions": "Fragen",
|
"questions": "Fragen",
|
||||||
|
"quota": "Kontingent",
|
||||||
|
"quotas": "Quoten",
|
||||||
|
"quotas_description": "Begrenze die Anzahl der Antworten, die du von Teilnehmern erhältst, die bestimmte Kriterien erfüllen.",
|
||||||
"read_docs": "Dokumentation lesen",
|
"read_docs": "Dokumentation lesen",
|
||||||
"recipients": "Empfänger",
|
"recipients": "Empfänger",
|
||||||
"remove": "Entfernen",
|
"remove": "Entfernen",
|
||||||
@@ -370,6 +379,7 @@
|
|||||||
"start_free_trial": "Kostenlos starten",
|
"start_free_trial": "Kostenlos starten",
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
"step_by_step_manual": "Schritt-für-Schritt-Anleitung",
|
"step_by_step_manual": "Schritt-für-Schritt-Anleitung",
|
||||||
|
"storage_not_configured": "Dateispeicher nicht eingerichtet, Uploads werden wahrscheinlich fehlschlagen",
|
||||||
"styling": "Styling",
|
"styling": "Styling",
|
||||||
"submit": "Abschicken",
|
"submit": "Abschicken",
|
||||||
"summary": "Zusammenfassung",
|
"summary": "Zusammenfassung",
|
||||||
@@ -579,6 +589,7 @@
|
|||||||
"contacts_table_refresh": "Kontakte aktualisieren",
|
"contacts_table_refresh": "Kontakte aktualisieren",
|
||||||
"contacts_table_refresh_success": "Kontakte erfolgreich aktualisiert",
|
"contacts_table_refresh_success": "Kontakte erfolgreich aktualisiert",
|
||||||
"delete_contact_confirmation": "Dies wird alle Umfrageantworten und Kontaktattribute löschen, die mit diesem Kontakt verbunden sind. Jegliche zielgerichtete Kommunikation und Personalisierung basierend auf den Daten dieses Kontakts gehen verloren.",
|
"delete_contact_confirmation": "Dies wird alle Umfrageantworten und Kontaktattribute löschen, die mit diesem Kontakt verbunden sind. Jegliche zielgerichtete Kommunikation und Personalisierung basierend auf den Daten dieses Kontakts gehen verloren.",
|
||||||
|
"delete_contact_confirmation_with_quotas": "{value, plural, other {Dies wird alle Umfrageantworten und Kontaktattribute löschen, die mit diesem Kontakt verbunden sind. Jegliche zielgerichtete Kommunikation und Personalisierung basierend auf den Daten dieses Kontakts gehen verloren. Wenn dieser Kontakt Antworten hat, die zu den Umfragequoten zählen, werden die Quotenstände reduziert, aber die Quotenlimits bleiben unverändert.}}",
|
||||||
"no_responses_found": "Keine Antworten gefunden",
|
"no_responses_found": "Keine Antworten gefunden",
|
||||||
"not_provided": "Nicht angegeben",
|
"not_provided": "Nicht angegeben",
|
||||||
"search_contact": "Kontakt suchen",
|
"search_contact": "Kontakt suchen",
|
||||||
@@ -1280,7 +1291,7 @@
|
|||||||
"columns": "Spalten",
|
"columns": "Spalten",
|
||||||
"company": "Firma",
|
"company": "Firma",
|
||||||
"company_logo": "Firmenlogo",
|
"company_logo": "Firmenlogo",
|
||||||
"completed_responses": "unvollständige oder vollständige Antworten.",
|
"completed_responses": "Abgeschlossene Antworten.",
|
||||||
"concat": "Verketten +",
|
"concat": "Verketten +",
|
||||||
"conditional_logic": "Bedingte Logik",
|
"conditional_logic": "Bedingte Logik",
|
||||||
"confirm_default_language": "Standardsprache bestätigen",
|
"confirm_default_language": "Standardsprache bestätigen",
|
||||||
@@ -1320,6 +1331,7 @@
|
|||||||
"end_screen_card": "Abschluss-Karte",
|
"end_screen_card": "Abschluss-Karte",
|
||||||
"ending_card": "Abschluss-Karte",
|
"ending_card": "Abschluss-Karte",
|
||||||
"ending_card_used_in_logic": "Diese Abschlusskarte wird in der Logik der Frage {questionIndex} verwendet.",
|
"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",
|
"ends_with": "endet mit",
|
||||||
"equals": "Gleich",
|
"equals": "Gleich",
|
||||||
"equals_one_of": "Entspricht einem von",
|
"equals_one_of": "Entspricht einem von",
|
||||||
@@ -1330,6 +1342,7 @@
|
|||||||
"fallback_for": "Ersatz für",
|
"fallback_for": "Ersatz für",
|
||||||
"fallback_missing": "Fehlender Fallback",
|
"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_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",
|
||||||
"field_name_eg_score_price": "Feldname z.B. Punktzahl, Preis",
|
"field_name_eg_score_price": "Feldname z.B. Punktzahl, Preis",
|
||||||
"first_name": "Vorname",
|
"first_name": "Vorname",
|
||||||
"five_points_recommended": "5 Punkte (empfohlen)",
|
"five_points_recommended": "5 Punkte (empfohlen)",
|
||||||
@@ -1361,8 +1374,9 @@
|
|||||||
"follow_ups_modal_action_subject_placeholder": "Betreff der E-Mail",
|
"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_description": "Empfänger-E-Mail-Adresse",
|
||||||
"follow_ups_modal_action_to_label": "An",
|
"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_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_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_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",
|
"follow_ups_modal_name_label": "Name des Follow-ups",
|
||||||
@@ -1372,8 +1386,9 @@
|
|||||||
"follow_ups_modal_trigger_label": "Auslöser",
|
"follow_ups_modal_trigger_label": "Auslöser",
|
||||||
"follow_ups_modal_trigger_type_ending": "Teilnehmer sieht einen bestimmten Abschluss",
|
"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_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_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_new": "Neues Follow-up",
|
||||||
"follow_ups_upgrade_button_text": "Upgrade, um Follow-ups zu aktivieren",
|
"follow_ups_upgrade_button_text": "Upgrade, um Follow-ups zu aktivieren",
|
||||||
"form_styling": "Umfrage Styling",
|
"form_styling": "Umfrage Styling",
|
||||||
@@ -1474,6 +1489,38 @@
|
|||||||
"question_duplicated": "Frage dupliziert.",
|
"question_duplicated": "Frage dupliziert.",
|
||||||
"question_id_updated": "Frage-ID aktualisiert",
|
"question_id_updated": "Frage-ID aktualisiert",
|
||||||
"question_used_in_logic": "Diese Frage wird in der Logik der Frage {questionIndex} verwendet.",
|
"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",
|
||||||
|
"quotas": {
|
||||||
|
"add_quota": "Quote hinzufügen",
|
||||||
|
"change_quota_for_public_survey": "Quote für öffentliche Umfrage ändern?",
|
||||||
|
"confirm_quota_changes": "Änderungen der Quoten bestätigen",
|
||||||
|
"confirm_quota_changes_body": "Du hast ungespeicherte Änderungen in deinem Kontingent. Möchtest Du sie speichern, bevor Du gehst?",
|
||||||
|
"continue_survey_normally": "Umfrage normal fortsetzen",
|
||||||
|
"count_partial_submissions": "Teilweise Abgaben zählen",
|
||||||
|
"count_partial_submissions_description": "Einschließlich Teilnehmer, die die Quotenanforderungen erfüllen, aber die Umfrage nicht abgeschlossen haben",
|
||||||
|
"create_quota_for_public_survey": "Quote für öffentliche Umfrage erstellen?",
|
||||||
|
"create_quota_for_public_survey_description": "Nur zukünftige Antworten werden für das Kontingent berücksichtigt",
|
||||||
|
"create_quota_for_public_survey_text": "Diese Umfrage ist bereits öffentlich. Bestehende Antworten werden für die neue Quote nicht berücksichtigt.",
|
||||||
|
"delete_quota_confirmation_text": "Dies wird die Quote {quotaName} dauerhaft löschen.",
|
||||||
|
"duplicate_quota": "Duplizieren der Quote",
|
||||||
|
"edit_quota": "Bearbeite Quote",
|
||||||
|
"end_survey_for_matching_participants": "Umfrage für passende Teilnehmer beenden",
|
||||||
|
"inclusion_criteria": "Einschlusskriterien",
|
||||||
|
"limit_must_be_greater_than_or_equal_to_the_number_of_responses": "{value, plural, other {Limit muss größer oder gleich der Anzahl der Antworten sein}}",
|
||||||
|
"limited_to_x_responses": "Begrenzt auf {limit}",
|
||||||
|
"new_quota": "Neues Kontingent",
|
||||||
|
"quota_created_successfull_toast": "Kontingent erfolgreich erstellt",
|
||||||
|
"quota_deleted_successfull_toast": "Kontingent erfolgreich gelöscht",
|
||||||
|
"quota_duplicated_successfull_toast": "Kontingent erfolgreich dupliziert",
|
||||||
|
"quota_name_placeholder": "z.B., Teilnehmende im Alter von 18-25",
|
||||||
|
"quota_updated_successfull_toast": "Kontingent erfolgreich aktualisiert",
|
||||||
|
"response_limit": "Grenzen",
|
||||||
|
"save_changes_confirmation_body": "Jegliche Änderungen an den Einschlusskriterien betreffen nur zukünftige Antworten.\nWir empfehlen, entweder ein bestehendes Kontingent zu duplizieren oder ein neues zu erstellen.",
|
||||||
|
"save_changes_confirmation_text": "Vorhandene Antworten bleiben im Kontingent",
|
||||||
|
"select_ending_card": "Abschlusskarte auswählen",
|
||||||
|
"upgrade_prompt_title": "Verwende Quoten mit einem höheren Plan",
|
||||||
|
"when_quota_has_been_reached": "Wenn das Kontingent erreicht ist"
|
||||||
|
},
|
||||||
"randomize_all": "Alle Optionen zufällig anordnen",
|
"randomize_all": "Alle Optionen zufällig anordnen",
|
||||||
"randomize_all_except_last": "Alle Optionen zufällig anordnen außer der letzten",
|
"randomize_all_except_last": "Alle Optionen zufällig anordnen außer der letzten",
|
||||||
"range": "Reichweite",
|
"range": "Reichweite",
|
||||||
@@ -1567,6 +1614,7 @@
|
|||||||
"url_not_supported": "URL nicht unterstützt",
|
"url_not_supported": "URL nicht unterstützt",
|
||||||
"use_with_caution": "Mit Vorsicht verwenden",
|
"use_with_caution": "Mit Vorsicht verwenden",
|
||||||
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} wird in der Logik der Frage {questionIndex} verwendet. Bitte entferne es zuerst aus der Logik.",
|
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} wird in der Logik der Frage {questionIndex} verwendet. Bitte entferne es zuerst aus der Logik.",
|
||||||
|
"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_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_name_must_start_with_a_letter": "Variablenname muss mit einem Buchstaben beginnen.",
|
||||||
"verify_email_before_submission": "E-Mail vor dem Absenden überprüfen",
|
"verify_email_before_submission": "E-Mail vor dem Absenden überprüfen",
|
||||||
@@ -1601,11 +1649,14 @@
|
|||||||
"address_line_2": "Adresszeile 2",
|
"address_line_2": "Adresszeile 2",
|
||||||
"an_error_occurred_deleting_the_tag": "Beim Löschen des Tags ist ein Fehler aufgetreten",
|
"an_error_occurred_deleting_the_tag": "Beim Löschen des Tags ist ein Fehler aufgetreten",
|
||||||
"browser": "Browser",
|
"browser": "Browser",
|
||||||
|
"bulk_delete_response_quotas": "Die Antworten sind Teil der Quoten für diese Umfrage. Wie möchten Sie die Quoten verwalten?",
|
||||||
"city": "Stadt",
|
"city": "Stadt",
|
||||||
"company": "Firma",
|
"company": "Firma",
|
||||||
"completed": "Erledigt ✅",
|
"completed": "Erledigt ✅",
|
||||||
"country": "Land",
|
"country": "Land",
|
||||||
|
"decrement_quotas": "Alle Grenzwerte der Kontingente einschließlich dieser Antwort verringern",
|
||||||
"delete_response_confirmation": "Dies wird die Umfrageantwort einschließlich aller Antworten, Tags, angehängter Dokumente und Antwort-Metadaten löschen.",
|
"delete_response_confirmation": "Dies wird die Umfrageantwort einschließlich aller Antworten, Tags, angehängter Dokumente und Antwort-Metadaten löschen.",
|
||||||
|
"delete_response_quotas": "Die Antwort ist Teil der Quoten für diese Umfrage. Wie möchten Sie die Quoten verwalten?",
|
||||||
"device": "Gerät",
|
"device": "Gerät",
|
||||||
"device_info": "Geräteinfo",
|
"device_info": "Geräteinfo",
|
||||||
"email": "E-Mail",
|
"email": "E-Mail",
|
||||||
@@ -1737,6 +1788,7 @@
|
|||||||
"configure_alerts": "Benachrichtigungen konfigurieren",
|
"configure_alerts": "Benachrichtigungen konfigurieren",
|
||||||
"congrats": "Glückwunsch! Deine Umfrage ist jetzt live.",
|
"congrats": "Glückwunsch! Deine Umfrage ist jetzt live.",
|
||||||
"connect_your_website_or_app_with_formbricks_to_get_started": "Verbinde deine Website oder App mit Formbricks, um loszulegen.",
|
"connect_your_website_or_app_with_formbricks_to_get_started": "Verbinde deine Website oder App mit Formbricks, um loszulegen.",
|
||||||
|
"current_count": "Aktuelle Anzahl",
|
||||||
"custom_range": "Benutzerdefinierter Bereich...",
|
"custom_range": "Benutzerdefinierter Bereich...",
|
||||||
"delete_all_existing_responses_and_displays": "Alle bestehenden Antworten und Anzeigen löschen",
|
"delete_all_existing_responses_and_displays": "Alle bestehenden Antworten und Anzeigen löschen",
|
||||||
"download_qr_code": "QR Code herunterladen",
|
"download_qr_code": "QR Code herunterladen",
|
||||||
@@ -1790,6 +1842,7 @@
|
|||||||
"last_month": "Letztes Monat",
|
"last_month": "Letztes Monat",
|
||||||
"last_quarter": "Letztes Quartal",
|
"last_quarter": "Letztes Quartal",
|
||||||
"last_year": "Letztes Jahr",
|
"last_year": "Letztes Jahr",
|
||||||
|
"limit": "Limit",
|
||||||
"no_responses_found": "Keine Antworten gefunden",
|
"no_responses_found": "Keine Antworten gefunden",
|
||||||
"other_values_found": "Andere Werte gefunden",
|
"other_values_found": "Andere Werte gefunden",
|
||||||
"overall": "Insgesamt",
|
"overall": "Insgesamt",
|
||||||
@@ -1798,6 +1851,8 @@
|
|||||||
"qr_code_download_failed": "QR-Code-Download fehlgeschlagen",
|
"qr_code_download_failed": "QR-Code-Download fehlgeschlagen",
|
||||||
"qr_code_download_with_start_soon": "QR Code-Download startet bald",
|
"qr_code_download_with_start_soon": "QR Code-Download startet bald",
|
||||||
"qr_code_generation_failed": "Es gab ein Problem beim Laden des QR-Codes für die Umfrage. Bitte versuchen Sie es erneut.",
|
"qr_code_generation_failed": "Es gab ein Problem beim Laden des QR-Codes für die Umfrage. Bitte versuchen Sie es erneut.",
|
||||||
|
"quotas_completed": "Kontingente abgeschlossen",
|
||||||
|
"quotas_completed_tooltip": "Die Anzahl der von den Befragten abgeschlossenen Quoten.",
|
||||||
"reset_survey": "Umfrage zurücksetzen",
|
"reset_survey": "Umfrage zurücksetzen",
|
||||||
"reset_survey_warning": "Das Zurücksetzen einer Umfrage entfernt alle Antworten und Anzeigen, die mit dieser Umfrage verbunden sind. Dies kann nicht rückgängig gemacht werden.",
|
"reset_survey_warning": "Das Zurücksetzen einer Umfrage entfernt alle Antworten und Anzeigen, die mit dieser Umfrage verbunden sind. Dies kann nicht rückgängig gemacht werden.",
|
||||||
"selected_responses_csv": "Ausgewählte Antworten (CSV)",
|
"selected_responses_csv": "Ausgewählte Antworten (CSV)",
|
||||||
@@ -2832,4 +2887,4 @@
|
|||||||
"usability_rating_description": "Bewerte die wahrgenommene Benutzerfreundlichkeit, indem du die Nutzer bittest, ihre Erfahrung mit deinem Produkt mittels eines standardisierten 10-Fragen-Fragebogens zu bewerten.",
|
"usability_rating_description": "Bewerte die wahrgenommene Benutzerfreundlichkeit, indem du die Nutzer bittest, ihre Erfahrung mit deinem Produkt mittels eines standardisierten 10-Fragen-Fragebogens zu bewerten.",
|
||||||
"usability_score_name": "System Usability Score Survey (SUS)"
|
"usability_score_name": "System Usability Score Survey (SUS)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -169,11 +169,14 @@
|
|||||||
"connect_formbricks": "Connect Formbricks",
|
"connect_formbricks": "Connect Formbricks",
|
||||||
"connected": "Connected",
|
"connected": "Connected",
|
||||||
"contacts": "Contacts",
|
"contacts": "Contacts",
|
||||||
|
"continue": "Continue",
|
||||||
"copied": "Copied",
|
"copied": "Copied",
|
||||||
"copied_to_clipboard": "Copied to clipboard",
|
"copied_to_clipboard": "Copied to clipboard",
|
||||||
"copy": "Copy",
|
"copy": "Copy",
|
||||||
"copy_code": "Copy code",
|
"copy_code": "Copy code",
|
||||||
"copy_link": "Copy Link",
|
"copy_link": "Copy Link",
|
||||||
|
"count_contacts": "{value, plural, one {{value} contact} other {{value} contacts}}",
|
||||||
|
"count_responses": "{value, plural, one {{value} response} other {{value} responses}}",
|
||||||
"create_new_organization": "Create new organization",
|
"create_new_organization": "Create new organization",
|
||||||
"create_project": "Create project",
|
"create_project": "Create project",
|
||||||
"create_segment": "Create segment",
|
"create_segment": "Create segment",
|
||||||
@@ -201,6 +204,7 @@
|
|||||||
"e_commerce": "E-Commerce",
|
"e_commerce": "E-Commerce",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"email": "Email",
|
"email": "Email",
|
||||||
|
"ending_card": "Ending card",
|
||||||
"enterprise_license": "Enterprise License",
|
"enterprise_license": "Enterprise License",
|
||||||
"environment_not_found": "Environment not found",
|
"environment_not_found": "Environment not found",
|
||||||
"environment_notice": "You're currently in the {environment} environment.",
|
"environment_notice": "You're currently in the {environment} environment.",
|
||||||
@@ -269,6 +273,7 @@
|
|||||||
"no_background_image_found": "No background image found.",
|
"no_background_image_found": "No background image found.",
|
||||||
"no_code": "No code",
|
"no_code": "No code",
|
||||||
"no_files_uploaded": "No files were uploaded",
|
"no_files_uploaded": "No files were uploaded",
|
||||||
|
"no_quotas_found": "No quotas found",
|
||||||
"no_result_found": "No result found",
|
"no_result_found": "No result found",
|
||||||
"no_results": "No results",
|
"no_results": "No results",
|
||||||
"no_surveys_found": "No surveys found.",
|
"no_surveys_found": "No surveys found.",
|
||||||
@@ -312,6 +317,7 @@
|
|||||||
"product_manager": "Product Manager",
|
"product_manager": "Product Manager",
|
||||||
"profile": "Profile",
|
"profile": "Profile",
|
||||||
"profile_id": "Profile ID",
|
"profile_id": "Profile ID",
|
||||||
|
"progress": "Progress",
|
||||||
"project_configuration": "Project Configuration",
|
"project_configuration": "Project Configuration",
|
||||||
"project_creation_description": "Organize surveys in projects for better access control.",
|
"project_creation_description": "Organize surveys in projects for better access control.",
|
||||||
"project_id": "Project ID",
|
"project_id": "Project ID",
|
||||||
@@ -323,6 +329,9 @@
|
|||||||
"question": "Question",
|
"question": "Question",
|
||||||
"question_id": "Question ID",
|
"question_id": "Question ID",
|
||||||
"questions": "Questions",
|
"questions": "Questions",
|
||||||
|
"quota": "Quota",
|
||||||
|
"quotas": "Quotas",
|
||||||
|
"quotas_description": "Limit the amount of responses you receive from participants who meet certain criteria.",
|
||||||
"read_docs": "Read Docs",
|
"read_docs": "Read Docs",
|
||||||
"recipients": "Recipients",
|
"recipients": "Recipients",
|
||||||
"remove": "Remove",
|
"remove": "Remove",
|
||||||
@@ -370,6 +379,7 @@
|
|||||||
"start_free_trial": "Start Free Trial",
|
"start_free_trial": "Start Free Trial",
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
"step_by_step_manual": "Step by step manual",
|
"step_by_step_manual": "Step by step manual",
|
||||||
|
"storage_not_configured": "File storage not set up, uploads will likely fail",
|
||||||
"styling": "Styling",
|
"styling": "Styling",
|
||||||
"submit": "Submit",
|
"submit": "Submit",
|
||||||
"summary": "Summary",
|
"summary": "Summary",
|
||||||
@@ -579,6 +589,7 @@
|
|||||||
"contacts_table_refresh": "Refresh contacts",
|
"contacts_table_refresh": "Refresh contacts",
|
||||||
"contacts_table_refresh_success": "Contacts refreshed successfully",
|
"contacts_table_refresh_success": "Contacts refreshed successfully",
|
||||||
"delete_contact_confirmation": "This will delete all survey responses and contact attributes associated with this contact. Any targeting and personalization based on this contact's data will be lost.",
|
"delete_contact_confirmation": "This will delete all survey responses and contact attributes associated with this contact. Any targeting and personalization based on this contact's data will be lost.",
|
||||||
|
"delete_contact_confirmation_with_quotas": "{value, plural, one {This will delete all survey responses and contact attributes associated with this contact. Any targeting and personalization based on this contact's data will be lost. If this contact has responses that count towards survey quotas, the quota counts will be reduced but the quota limits will remain unchanged.} other {This will delete all survey responses and contact attributes associated with these contacts. Any targeting and personalization based on these contacts' data will be lost. If these contacts have responses that count towards survey quotas, the quota counts will be reduced but the quota limits will remain unchanged.}}",
|
||||||
"no_responses_found": "No responses found",
|
"no_responses_found": "No responses found",
|
||||||
"not_provided": "Not provided",
|
"not_provided": "Not provided",
|
||||||
"search_contact": "Search contact",
|
"search_contact": "Search contact",
|
||||||
@@ -1280,7 +1291,7 @@
|
|||||||
"columns": "Columns",
|
"columns": "Columns",
|
||||||
"company": "Company",
|
"company": "Company",
|
||||||
"company_logo": "Company logo",
|
"company_logo": "Company logo",
|
||||||
"completed_responses": "partial or completed responses.",
|
"completed_responses": "completed responses.",
|
||||||
"concat": "Concat +",
|
"concat": "Concat +",
|
||||||
"conditional_logic": "Conditional Logic",
|
"conditional_logic": "Conditional Logic",
|
||||||
"confirm_default_language": "Confirm default language",
|
"confirm_default_language": "Confirm default language",
|
||||||
@@ -1320,6 +1331,7 @@
|
|||||||
"end_screen_card": "End screen card",
|
"end_screen_card": "End screen card",
|
||||||
"ending_card": "Ending card",
|
"ending_card": "Ending card",
|
||||||
"ending_card_used_in_logic": "This ending card is used in logic of question {questionIndex}.",
|
"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",
|
"ends_with": "Ends with",
|
||||||
"equals": "Equals",
|
"equals": "Equals",
|
||||||
"equals_one_of": "Equals one of",
|
"equals_one_of": "Equals one of",
|
||||||
@@ -1330,6 +1342,7 @@
|
|||||||
"fallback_for": "Fallback for ",
|
"fallback_for": "Fallback for ",
|
||||||
"fallback_missing": "Fallback missing",
|
"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_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",
|
||||||
"field_name_eg_score_price": "Field name e.g, score, price",
|
"field_name_eg_score_price": "Field name e.g, score, price",
|
||||||
"first_name": "First Name",
|
"first_name": "First Name",
|
||||||
"five_points_recommended": "5 points (recommended)",
|
"five_points_recommended": "5 points (recommended)",
|
||||||
@@ -1361,8 +1374,9 @@
|
|||||||
"follow_ups_modal_action_subject_placeholder": "Subject of the email",
|
"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_description": "Email address to send the email to",
|
||||||
"follow_ups_modal_action_to_label": "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_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_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_edit_no_id": "No survey follow up id provided, can't update the survey follow up",
|
||||||
"follow_ups_modal_name_label": "Follow-up name",
|
"follow_ups_modal_name_label": "Follow-up name",
|
||||||
@@ -1372,8 +1386,9 @@
|
|||||||
"follow_ups_modal_trigger_label": "Trigger",
|
"follow_ups_modal_trigger_label": "Trigger",
|
||||||
"follow_ups_modal_trigger_type_ending": "Respondent sees a specific ending",
|
"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_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_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_new": "New follow-up",
|
||||||
"follow_ups_upgrade_button_text": "Upgrade to enable follow-ups",
|
"follow_ups_upgrade_button_text": "Upgrade to enable follow-ups",
|
||||||
"form_styling": "Form styling",
|
"form_styling": "Form styling",
|
||||||
@@ -1474,6 +1489,38 @@
|
|||||||
"question_duplicated": "Question duplicated.",
|
"question_duplicated": "Question duplicated.",
|
||||||
"question_id_updated": "Question ID updated",
|
"question_id_updated": "Question ID updated",
|
||||||
"question_used_in_logic": "This question is used in logic of question {questionIndex}.",
|
"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",
|
||||||
|
"quotas": {
|
||||||
|
"add_quota": "Add quota",
|
||||||
|
"change_quota_for_public_survey": "Change quota for public survey?",
|
||||||
|
"confirm_quota_changes": "Confirm quota changes",
|
||||||
|
"confirm_quota_changes_body": "You have unsaved changes in your quota. Would you like to save them before leaving?",
|
||||||
|
"continue_survey_normally": "Continue survey normally",
|
||||||
|
"count_partial_submissions": "Count partial submissions",
|
||||||
|
"count_partial_submissions_description": "Include respondents that match the quota criteria but did not complete the survey",
|
||||||
|
"create_quota_for_public_survey": "Create quota for public survey?",
|
||||||
|
"create_quota_for_public_survey_description": "Only future answers will be screened into quota",
|
||||||
|
"create_quota_for_public_survey_text": "This survey is already public. Existing responses will not be taken into account for the new quota.",
|
||||||
|
"delete_quota_confirmation_text": "This will permanently delete the quota {quotaName}.",
|
||||||
|
"duplicate_quota": "Duplicate quota",
|
||||||
|
"edit_quota": "Edit quota",
|
||||||
|
"end_survey_for_matching_participants": "End survey for matching participants",
|
||||||
|
"inclusion_criteria": "Inclusion Criteria",
|
||||||
|
"limit_must_be_greater_than_or_equal_to_the_number_of_responses": "{value, plural, one {You already have {value} response for this quota, so the limit must be greater than {value}.} other {You already have {value} responses for this quota, so the limit must be greater than {value}.}}",
|
||||||
|
"limited_to_x_responses": "Limited to {limit}",
|
||||||
|
"new_quota": "New Quota",
|
||||||
|
"quota_created_successfull_toast": "Quota created successfully",
|
||||||
|
"quota_deleted_successfull_toast": "Quota deleted successfully",
|
||||||
|
"quota_duplicated_successfull_toast": "Quota duplicated successfully",
|
||||||
|
"quota_name_placeholder": "e.g., Age 18-25 participants",
|
||||||
|
"quota_updated_successfull_toast": "Quota updated successfully",
|
||||||
|
"response_limit": "Limits",
|
||||||
|
"save_changes_confirmation_body": "Any changes to the inclusion criteria only affect future responses. \nWe recommend to either duplicate an existing or create a new quota.",
|
||||||
|
"save_changes_confirmation_text": "Existing responses stay in the quota",
|
||||||
|
"select_ending_card": "Select ending card",
|
||||||
|
"upgrade_prompt_title": "Use quotas with a higher plan",
|
||||||
|
"when_quota_has_been_reached": "When quota has been reached"
|
||||||
|
},
|
||||||
"randomize_all": "Randomize all",
|
"randomize_all": "Randomize all",
|
||||||
"randomize_all_except_last": "Randomize all except last",
|
"randomize_all_except_last": "Randomize all except last",
|
||||||
"range": "Range",
|
"range": "Range",
|
||||||
@@ -1567,6 +1614,7 @@
|
|||||||
"url_not_supported": "URL not supported",
|
"url_not_supported": "URL not supported",
|
||||||
"use_with_caution": "Use with caution",
|
"use_with_caution": "Use with caution",
|
||||||
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} is used in logic of question {questionIndex}. Please remove it from logic first.",
|
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} is used in logic of question {questionIndex}. Please remove it from logic first.",
|
||||||
|
"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_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_name_must_start_with_a_letter": "Variable name must start with a letter.",
|
||||||
"verify_email_before_submission": "Verify email before submission",
|
"verify_email_before_submission": "Verify email before submission",
|
||||||
@@ -1601,11 +1649,14 @@
|
|||||||
"address_line_2": "Address Line 2",
|
"address_line_2": "Address Line 2",
|
||||||
"an_error_occurred_deleting_the_tag": "An error occurred deleting the tag",
|
"an_error_occurred_deleting_the_tag": "An error occurred deleting the tag",
|
||||||
"browser": "Browser",
|
"browser": "Browser",
|
||||||
|
"bulk_delete_response_quotas": "The responses are part of quotas for this survey. How do you want to handle the quotas?",
|
||||||
"city": "City",
|
"city": "City",
|
||||||
"company": "Company",
|
"company": "Company",
|
||||||
"completed": "Completed ✅",
|
"completed": "Completed ✅",
|
||||||
"country": "Country",
|
"country": "Country",
|
||||||
|
"decrement_quotas": "Decrement all limits of quotas including this response",
|
||||||
"delete_response_confirmation": "This will delete the survey response, including all answers, tags, attached documents, and response metadata.",
|
"delete_response_confirmation": "This will delete the survey response, including all answers, tags, attached documents, and response metadata.",
|
||||||
|
"delete_response_quotas": "The response is part of quotas for this survey. How do you want to handle the quotas?",
|
||||||
"device": "Device",
|
"device": "Device",
|
||||||
"device_info": "Device info",
|
"device_info": "Device info",
|
||||||
"email": "Email",
|
"email": "Email",
|
||||||
@@ -1737,6 +1788,7 @@
|
|||||||
"configure_alerts": "Configure alerts",
|
"configure_alerts": "Configure alerts",
|
||||||
"congrats": "Congrats! Your survey is live.",
|
"congrats": "Congrats! Your survey is live.",
|
||||||
"connect_your_website_or_app_with_formbricks_to_get_started": "Connect your website or app with Formbricks to get started.",
|
"connect_your_website_or_app_with_formbricks_to_get_started": "Connect your website or app with Formbricks to get started.",
|
||||||
|
"current_count": "Current count",
|
||||||
"custom_range": "Custom range...",
|
"custom_range": "Custom range...",
|
||||||
"delete_all_existing_responses_and_displays": "Delete all existing responses and displays",
|
"delete_all_existing_responses_and_displays": "Delete all existing responses and displays",
|
||||||
"download_qr_code": "Download QR code",
|
"download_qr_code": "Download QR code",
|
||||||
@@ -1790,6 +1842,7 @@
|
|||||||
"last_month": "Last month",
|
"last_month": "Last month",
|
||||||
"last_quarter": "Last quarter",
|
"last_quarter": "Last quarter",
|
||||||
"last_year": "Last year",
|
"last_year": "Last year",
|
||||||
|
"limit": "Limit",
|
||||||
"no_responses_found": "No responses found",
|
"no_responses_found": "No responses found",
|
||||||
"other_values_found": "Other values found",
|
"other_values_found": "Other values found",
|
||||||
"overall": "Overall",
|
"overall": "Overall",
|
||||||
@@ -1798,6 +1851,8 @@
|
|||||||
"qr_code_download_failed": "QR code download failed",
|
"qr_code_download_failed": "QR code download failed",
|
||||||
"qr_code_download_with_start_soon": "QR code download will start soon",
|
"qr_code_download_with_start_soon": "QR code download will start soon",
|
||||||
"qr_code_generation_failed": "There was a problem, loading the survey QR Code. Please try again.",
|
"qr_code_generation_failed": "There was a problem, loading the survey QR Code. Please try again.",
|
||||||
|
"quotas_completed": "Quotas completed",
|
||||||
|
"quotas_completed_tooltip": "The number of quotas completed by the respondents.",
|
||||||
"reset_survey": "Reset survey",
|
"reset_survey": "Reset survey",
|
||||||
"reset_survey_warning": "Resetting a survey removes all responses and displays associated with this survey. This cannot be undone.",
|
"reset_survey_warning": "Resetting a survey removes all responses and displays associated with this survey. This cannot be undone.",
|
||||||
"selected_responses_csv": "Selected responses (CSV)",
|
"selected_responses_csv": "Selected responses (CSV)",
|
||||||
@@ -2832,4 +2887,4 @@
|
|||||||
"usability_rating_description": "Measure perceived usability by asking users to rate their experience with your product using a standardized 10-question survey.",
|
"usability_rating_description": "Measure perceived usability by asking users to rate their experience with your product using a standardized 10-question survey.",
|
||||||
"usability_score_name": "System Usability Score (SUS)"
|
"usability_score_name": "System Usability Score (SUS)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -169,11 +169,14 @@
|
|||||||
"connect_formbricks": "Connecter Formbricks",
|
"connect_formbricks": "Connecter Formbricks",
|
||||||
"connected": "Connecté",
|
"connected": "Connecté",
|
||||||
"contacts": "Contacts",
|
"contacts": "Contacts",
|
||||||
|
"continue": "Continuer",
|
||||||
"copied": "Copié",
|
"copied": "Copié",
|
||||||
"copied_to_clipboard": "Copié dans le presse-papiers",
|
"copied_to_clipboard": "Copié dans le presse-papiers",
|
||||||
"copy": "Copier",
|
"copy": "Copier",
|
||||||
"copy_code": "Copier le code",
|
"copy_code": "Copier le code",
|
||||||
"copy_link": "Copier le lien",
|
"copy_link": "Copier le lien",
|
||||||
|
"count_contacts": "{value, plural, one {# contact} other {# contacts} }",
|
||||||
|
"count_responses": "{value, plural, other {# réponses}}",
|
||||||
"create_new_organization": "Créer une nouvelle organisation",
|
"create_new_organization": "Créer une nouvelle organisation",
|
||||||
"create_project": "Créer un projet",
|
"create_project": "Créer un projet",
|
||||||
"create_segment": "Créer un segment",
|
"create_segment": "Créer un segment",
|
||||||
@@ -201,6 +204,7 @@
|
|||||||
"e_commerce": "E-commerce",
|
"e_commerce": "E-commerce",
|
||||||
"edit": "Modifier",
|
"edit": "Modifier",
|
||||||
"email": "Email",
|
"email": "Email",
|
||||||
|
"ending_card": "Carte de fin",
|
||||||
"enterprise_license": "Licence d'entreprise",
|
"enterprise_license": "Licence d'entreprise",
|
||||||
"environment_not_found": "Environnement non trouvé",
|
"environment_not_found": "Environnement non trouvé",
|
||||||
"environment_notice": "Vous êtes actuellement dans l'environnement {environment}.",
|
"environment_notice": "Vous êtes actuellement dans l'environnement {environment}.",
|
||||||
@@ -269,6 +273,7 @@
|
|||||||
"no_background_image_found": "Aucune image de fond trouvée.",
|
"no_background_image_found": "Aucune image de fond trouvée.",
|
||||||
"no_code": "Pas de code",
|
"no_code": "Pas de code",
|
||||||
"no_files_uploaded": "Aucun fichier n'a été téléchargé.",
|
"no_files_uploaded": "Aucun fichier n'a été téléchargé.",
|
||||||
|
"no_quotas_found": "Aucun quota trouvé",
|
||||||
"no_result_found": "Aucun résultat trouvé",
|
"no_result_found": "Aucun résultat trouvé",
|
||||||
"no_results": "Aucun résultat",
|
"no_results": "Aucun résultat",
|
||||||
"no_surveys_found": "Aucun sondage trouvé.",
|
"no_surveys_found": "Aucun sondage trouvé.",
|
||||||
@@ -312,6 +317,7 @@
|
|||||||
"product_manager": "Chef de produit",
|
"product_manager": "Chef de produit",
|
||||||
"profile": "Profil",
|
"profile": "Profil",
|
||||||
"profile_id": "Identifiant de profil",
|
"profile_id": "Identifiant de profil",
|
||||||
|
"progress": "Progression",
|
||||||
"project_configuration": "Configuration du projet",
|
"project_configuration": "Configuration du projet",
|
||||||
"project_creation_description": "Organisez les enquêtes en projets pour un meilleur contrôle d'accès.",
|
"project_creation_description": "Organisez les enquêtes en projets pour un meilleur contrôle d'accès.",
|
||||||
"project_id": "ID de projet",
|
"project_id": "ID de projet",
|
||||||
@@ -323,6 +329,9 @@
|
|||||||
"question": "Question",
|
"question": "Question",
|
||||||
"question_id": "ID de la question",
|
"question_id": "ID de la question",
|
||||||
"questions": "Questions",
|
"questions": "Questions",
|
||||||
|
"quota": "Quota",
|
||||||
|
"quotas": "Quotas",
|
||||||
|
"quotas_description": "Limitez le nombre de réponses que vous recevez de la part des participants répondant à certains critères.",
|
||||||
"read_docs": "Lire les documents",
|
"read_docs": "Lire les documents",
|
||||||
"recipients": "Destinataires",
|
"recipients": "Destinataires",
|
||||||
"remove": "Retirer",
|
"remove": "Retirer",
|
||||||
@@ -370,6 +379,7 @@
|
|||||||
"start_free_trial": "Commencer l'essai gratuit",
|
"start_free_trial": "Commencer l'essai gratuit",
|
||||||
"status": "Statut",
|
"status": "Statut",
|
||||||
"step_by_step_manual": "Manuel étape par étape",
|
"step_by_step_manual": "Manuel étape par étape",
|
||||||
|
"storage_not_configured": "Stockage de fichiers non configuré, les téléchargements risquent d'échouer",
|
||||||
"styling": "Style",
|
"styling": "Style",
|
||||||
"submit": "Soumettre",
|
"submit": "Soumettre",
|
||||||
"summary": "Résumé",
|
"summary": "Résumé",
|
||||||
@@ -579,6 +589,7 @@
|
|||||||
"contacts_table_refresh": "Rafraîchir les contacts",
|
"contacts_table_refresh": "Rafraîchir les contacts",
|
||||||
"contacts_table_refresh_success": "Contacts rafraîchis avec succès",
|
"contacts_table_refresh_success": "Contacts rafraîchis avec succès",
|
||||||
"delete_contact_confirmation": "Cela supprimera toutes les réponses aux enquêtes et les attributs de contact associés à ce contact. Toute la personnalisation et le ciblage basés sur les données de ce contact seront perdus.",
|
"delete_contact_confirmation": "Cela supprimera toutes les réponses aux enquêtes et les attributs de contact associés à ce contact. Toute la personnalisation et le ciblage basés sur les données de ce contact seront perdus.",
|
||||||
|
"delete_contact_confirmation_with_quotas": "{value, plural, other {Cela supprimera toutes les réponses aux enquêtes et les attributs de contact associés à ce contact. Toute la personnalisation et le ciblage basés sur les données de ce contact seront perdus. Si ce contact a des réponses qui comptent dans les quotas de l'enquête, les comptes de quotas seront réduits mais les limites de quota resteront inchangées.}}",
|
||||||
"no_responses_found": "Aucune réponse trouvée",
|
"no_responses_found": "Aucune réponse trouvée",
|
||||||
"not_provided": "Non fourni",
|
"not_provided": "Non fourni",
|
||||||
"search_contact": "Rechercher un contact",
|
"search_contact": "Rechercher un contact",
|
||||||
@@ -1280,7 +1291,7 @@
|
|||||||
"columns": "Colonnes",
|
"columns": "Colonnes",
|
||||||
"company": "Société",
|
"company": "Société",
|
||||||
"company_logo": "Logo de l'entreprise",
|
"company_logo": "Logo de l'entreprise",
|
||||||
"completed_responses": "des réponses partielles ou complètes.",
|
"completed_responses": "Réponses terminées",
|
||||||
"concat": "Concat +",
|
"concat": "Concat +",
|
||||||
"conditional_logic": "Logique conditionnelle",
|
"conditional_logic": "Logique conditionnelle",
|
||||||
"confirm_default_language": "Confirmer la langue par défaut",
|
"confirm_default_language": "Confirmer la langue par défaut",
|
||||||
@@ -1320,6 +1331,7 @@
|
|||||||
"end_screen_card": "Carte de fin d'écran",
|
"end_screen_card": "Carte de fin d'écran",
|
||||||
"ending_card": "Carte de fin",
|
"ending_card": "Carte de fin",
|
||||||
"ending_card_used_in_logic": "Cette carte de fin est utilisée dans la logique de la question '{'questionIndex'}'.",
|
"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",
|
"ends_with": "Se termine par",
|
||||||
"equals": "Égal",
|
"equals": "Égal",
|
||||||
"equals_one_of": "Égal à l'un de",
|
"equals_one_of": "Égal à l'un de",
|
||||||
@@ -1330,6 +1342,7 @@
|
|||||||
"fallback_for": "Solution de repli pour ",
|
"fallback_for": "Solution de repli pour ",
|
||||||
"fallback_missing": "Fallback manquant",
|
"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_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}\"",
|
||||||
"field_name_eg_score_price": "Nom du champ par exemple, score, prix",
|
"field_name_eg_score_price": "Nom du champ par exemple, score, prix",
|
||||||
"first_name": "Prénom",
|
"first_name": "Prénom",
|
||||||
"five_points_recommended": "5 points (recommandé)",
|
"five_points_recommended": "5 points (recommandé)",
|
||||||
@@ -1361,8 +1374,9 @@
|
|||||||
"follow_ups_modal_action_subject_placeholder": "Objet de l'email",
|
"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_description": "Adresse e-mail à laquelle envoyer l'e-mail",
|
||||||
"follow_ups_modal_action_to_label": "à",
|
"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_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_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_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",
|
"follow_ups_modal_name_label": "Nom de suivi",
|
||||||
@@ -1372,8 +1386,9 @@
|
|||||||
"follow_ups_modal_trigger_label": "Déclencheur",
|
"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": "Le répondant voit une fin spécifique",
|
||||||
"follow_ups_modal_trigger_type_ending_select": "Choisir des fins :",
|
"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_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_new": "Nouveau suivi",
|
||||||
"follow_ups_upgrade_button_text": "Passez à la version supérieure pour activer les relances",
|
"follow_ups_upgrade_button_text": "Passez à la version supérieure pour activer les relances",
|
||||||
"form_styling": "Style de formulaire",
|
"form_styling": "Style de formulaire",
|
||||||
@@ -1474,6 +1489,38 @@
|
|||||||
"question_duplicated": "Question dupliquée.",
|
"question_duplicated": "Question dupliquée.",
|
||||||
"question_id_updated": "ID de la question mis à jour",
|
"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_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}\"",
|
||||||
|
"quotas": {
|
||||||
|
"add_quota": "Ajouter un quota",
|
||||||
|
"change_quota_for_public_survey": "Changer le quota pour le sondage public ?",
|
||||||
|
"confirm_quota_changes": "Confirmer les modifications de quotas",
|
||||||
|
"confirm_quota_changes_body": "Vous avez des modifications non enregistrées dans votre quota. Souhaitez-vous les enregistrer avant de partir ?",
|
||||||
|
"continue_survey_normally": "Continuer le sondage normalement",
|
||||||
|
"count_partial_submissions": "Compter les soumissions partielles",
|
||||||
|
"count_partial_submissions_description": "Inclure les répondants qui correspondent aux critères de quota mais n'ont pas terminé le sondage",
|
||||||
|
"create_quota_for_public_survey": "Créer un quota pour le sondage public ?",
|
||||||
|
"create_quota_for_public_survey_description": "Seules les réponses futures seront filtrées dans le quota",
|
||||||
|
"create_quota_for_public_survey_text": "Ce sondage est déjà public. Les réponses existantes ne seront pas prises en compte pour le nouveau quota.",
|
||||||
|
"delete_quota_confirmation_text": "Cela supprimera définitivement le quota {quotaName}.",
|
||||||
|
"duplicate_quota": "Dupliquer le quota",
|
||||||
|
"edit_quota": "Modifier le quota",
|
||||||
|
"end_survey_for_matching_participants": "Terminer l'enquête pour les participants correspondants",
|
||||||
|
"inclusion_criteria": "Critères d'inclusion",
|
||||||
|
"limit_must_be_greater_than_or_equal_to_the_number_of_responses": "{value, plural, other {La limite doit être supérieure ou égale au nombre de réponses}}",
|
||||||
|
"limited_to_x_responses": "Limité à {limit}",
|
||||||
|
"new_quota": "Nouveau Quota",
|
||||||
|
"quota_created_successfull_toast": "Quota créé avec succès",
|
||||||
|
"quota_deleted_successfull_toast": "Quota supprimé avec succès",
|
||||||
|
"quota_duplicated_successfull_toast": "Quota dupliqué avec succès",
|
||||||
|
"quota_name_placeholder": "par ex., Participants âgés de 18 à 25 ans",
|
||||||
|
"quota_updated_successfull_toast": "Quota mis à jour avec succès",
|
||||||
|
"response_limit": "Limites",
|
||||||
|
"save_changes_confirmation_body": "Les modifications apportées aux critères d'inclusion n'affectent que les réponses futures. \nNous vous recommandons soit de dupliquer un quota existant, soit d'en créer un nouveau.",
|
||||||
|
"save_changes_confirmation_text": "\"Les réponses existantes restent dans le quota\"",
|
||||||
|
"select_ending_card": "Sélectionner la carte de fin",
|
||||||
|
"upgrade_prompt_title": "Utilisez des quotas avec un plan supérieur",
|
||||||
|
"when_quota_has_been_reached": "Quand le quota est atteint"
|
||||||
|
},
|
||||||
"randomize_all": "Randomiser tout",
|
"randomize_all": "Randomiser tout",
|
||||||
"randomize_all_except_last": "Randomiser tout sauf le dernier",
|
"randomize_all_except_last": "Randomiser tout sauf le dernier",
|
||||||
"range": "Plage",
|
"range": "Plage",
|
||||||
@@ -1567,6 +1614,7 @@
|
|||||||
"url_not_supported": "URL non supportée",
|
"url_not_supported": "URL non supportée",
|
||||||
"use_with_caution": "À utiliser avec précaution",
|
"use_with_caution": "À utiliser avec précaution",
|
||||||
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} est utilisé dans la logique de la question {questionIndex}. Veuillez d'abord le supprimer de la logique.",
|
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} est utilisé dans la logique de la question {questionIndex}. Veuillez d'abord le supprimer de la logique.",
|
||||||
|
"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_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_name_must_start_with_a_letter": "Le nom de la variable doit commencer par une lettre.",
|
||||||
"verify_email_before_submission": "Vérifiez l'email avant la soumission",
|
"verify_email_before_submission": "Vérifiez l'email avant la soumission",
|
||||||
@@ -1601,11 +1649,14 @@
|
|||||||
"address_line_2": "Ligne d'adresse 2",
|
"address_line_2": "Ligne d'adresse 2",
|
||||||
"an_error_occurred_deleting_the_tag": "Une erreur est survenue lors de la suppression de l'étiquette.",
|
"an_error_occurred_deleting_the_tag": "Une erreur est survenue lors de la suppression de l'étiquette.",
|
||||||
"browser": "Navigateur",
|
"browser": "Navigateur",
|
||||||
|
"bulk_delete_response_quotas": "Les réponses font partie des quotas pour ce sondage. Comment voulez-vous gérer les quotas ?",
|
||||||
"city": "Ville",
|
"city": "Ville",
|
||||||
"company": "Société",
|
"company": "Société",
|
||||||
"completed": "Terminé ✅",
|
"completed": "Terminé ✅",
|
||||||
"country": "Pays",
|
"country": "Pays",
|
||||||
|
"decrement_quotas": "Décrémentez toutes les limites des quotas y compris cette réponse",
|
||||||
"delete_response_confirmation": "Cela supprimera la réponse au sondage, y compris toutes les réponses, les étiquettes, les documents joints et les métadonnées de réponse.",
|
"delete_response_confirmation": "Cela supprimera la réponse au sondage, y compris toutes les réponses, les étiquettes, les documents joints et les métadonnées de réponse.",
|
||||||
|
"delete_response_quotas": "La réponse fait partie des quotas pour ce sondage. Comment voulez-vous gérer les quotas ?",
|
||||||
"device": "Dispositif",
|
"device": "Dispositif",
|
||||||
"device_info": "Informations sur l'appareil",
|
"device_info": "Informations sur l'appareil",
|
||||||
"email": "Email",
|
"email": "Email",
|
||||||
@@ -1737,6 +1788,7 @@
|
|||||||
"configure_alerts": "Configurer les alertes",
|
"configure_alerts": "Configurer les alertes",
|
||||||
"congrats": "Félicitations ! Votre enquête est en ligne.",
|
"congrats": "Félicitations ! Votre enquête est en ligne.",
|
||||||
"connect_your_website_or_app_with_formbricks_to_get_started": "Connectez votre site web ou votre application à Formbricks pour commencer.",
|
"connect_your_website_or_app_with_formbricks_to_get_started": "Connectez votre site web ou votre application à Formbricks pour commencer.",
|
||||||
|
"current_count": "Nombre actuel",
|
||||||
"custom_range": "Plage personnalisée...",
|
"custom_range": "Plage personnalisée...",
|
||||||
"delete_all_existing_responses_and_displays": "Supprimer toutes les réponses existantes et les affichages",
|
"delete_all_existing_responses_and_displays": "Supprimer toutes les réponses existantes et les affichages",
|
||||||
"download_qr_code": "Télécharger code QR",
|
"download_qr_code": "Télécharger code QR",
|
||||||
@@ -1790,6 +1842,7 @@
|
|||||||
"last_month": "Le mois dernier",
|
"last_month": "Le mois dernier",
|
||||||
"last_quarter": "dernier trimestre",
|
"last_quarter": "dernier trimestre",
|
||||||
"last_year": "l'année dernière",
|
"last_year": "l'année dernière",
|
||||||
|
"limit": "Limite",
|
||||||
"no_responses_found": "Aucune réponse trouvée",
|
"no_responses_found": "Aucune réponse trouvée",
|
||||||
"other_values_found": "D'autres valeurs trouvées",
|
"other_values_found": "D'autres valeurs trouvées",
|
||||||
"overall": "Globalement",
|
"overall": "Globalement",
|
||||||
@@ -1798,6 +1851,8 @@
|
|||||||
"qr_code_download_failed": "Échec du téléchargement du code QR",
|
"qr_code_download_failed": "Échec du téléchargement du code QR",
|
||||||
"qr_code_download_with_start_soon": "Le téléchargement du code QR débutera bientôt",
|
"qr_code_download_with_start_soon": "Le téléchargement du code QR débutera bientôt",
|
||||||
"qr_code_generation_failed": "\"Un problème est survenu lors du chargement du code QR du sondage. Veuillez réessayer.\"",
|
"qr_code_generation_failed": "\"Un problème est survenu lors du chargement du code QR du sondage. Veuillez réessayer.\"",
|
||||||
|
"quotas_completed": "Quotas terminés",
|
||||||
|
"quotas_completed_tooltip": "Le nombre de quotas complétés par les répondants.",
|
||||||
"reset_survey": "Réinitialiser l'enquête",
|
"reset_survey": "Réinitialiser l'enquête",
|
||||||
"reset_survey_warning": "Réinitialiser un sondage supprime toutes les réponses et les affichages associés à ce sondage. Cela ne peut pas être annulé.",
|
"reset_survey_warning": "Réinitialiser un sondage supprime toutes les réponses et les affichages associés à ce sondage. Cela ne peut pas être annulé.",
|
||||||
"selected_responses_csv": "Réponses sélectionnées (CSV)",
|
"selected_responses_csv": "Réponses sélectionnées (CSV)",
|
||||||
@@ -2832,4 +2887,4 @@
|
|||||||
"usability_rating_description": "Mesurez la convivialité perçue en demandant aux utilisateurs d'évaluer leur expérience avec votre produit via un sondage standardisé de 10 questions.",
|
"usability_rating_description": "Mesurez la convivialité perçue en demandant aux utilisateurs d'évaluer leur expérience avec votre produit via un sondage standardisé de 10 questions.",
|
||||||
"usability_score_name": "Score d'Utilisabilité du Système (SUS)"
|
"usability_score_name": "Score d'Utilisabilité du Système (SUS)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -169,11 +169,14 @@
|
|||||||
"connect_formbricks": "Formbricksを接続",
|
"connect_formbricks": "Formbricksを接続",
|
||||||
"connected": "接続済み",
|
"connected": "接続済み",
|
||||||
"contacts": "連絡先",
|
"contacts": "連絡先",
|
||||||
|
"continue": "続行",
|
||||||
"copied": "コピーしました",
|
"copied": "コピーしました",
|
||||||
"copied_to_clipboard": "クリップボードにコピーしました",
|
"copied_to_clipboard": "クリップボードにコピーしました",
|
||||||
"copy": "コピー",
|
"copy": "コピー",
|
||||||
"copy_code": "コードをコピー",
|
"copy_code": "コードをコピー",
|
||||||
"copy_link": "リンクをコピー",
|
"copy_link": "リンクをコピー",
|
||||||
|
"count_contacts": "{count, plural, other {# 件の連絡先}}",
|
||||||
|
"count_responses": "{count, plural, other {# 件の回答}}",
|
||||||
"create_new_organization": "新しい組織を作成",
|
"create_new_organization": "新しい組織を作成",
|
||||||
"create_project": "プロジェクトを作成",
|
"create_project": "プロジェクトを作成",
|
||||||
"create_segment": "セグメントを作成",
|
"create_segment": "セグメントを作成",
|
||||||
@@ -201,6 +204,7 @@
|
|||||||
"e_commerce": "Eコマース",
|
"e_commerce": "Eコマース",
|
||||||
"edit": "編集",
|
"edit": "編集",
|
||||||
"email": "メールアドレス",
|
"email": "メールアドレス",
|
||||||
|
"ending_card": "終了カード",
|
||||||
"enterprise_license": "エンタープライズライセンス",
|
"enterprise_license": "エンタープライズライセンス",
|
||||||
"environment_not_found": "環境が見つかりません",
|
"environment_not_found": "環境が見つかりません",
|
||||||
"environment_notice": "現在、{environment} 環境にいます。",
|
"environment_notice": "現在、{environment} 環境にいます。",
|
||||||
@@ -269,6 +273,7 @@
|
|||||||
"no_background_image_found": "背景画像が見つかりません。",
|
"no_background_image_found": "背景画像が見つかりません。",
|
||||||
"no_code": "ノーコード",
|
"no_code": "ノーコード",
|
||||||
"no_files_uploaded": "ファイルがアップロードされていません",
|
"no_files_uploaded": "ファイルがアップロードされていません",
|
||||||
|
"no_quotas_found": "クォータが見つかりません",
|
||||||
"no_result_found": "結果が見つかりません",
|
"no_result_found": "結果が見つかりません",
|
||||||
"no_results": "結果なし",
|
"no_results": "結果なし",
|
||||||
"no_surveys_found": "フォームが見つかりません。",
|
"no_surveys_found": "フォームが見つかりません。",
|
||||||
@@ -312,6 +317,7 @@
|
|||||||
"product_manager": "プロダクトマネージャー",
|
"product_manager": "プロダクトマネージャー",
|
||||||
"profile": "プロフィール",
|
"profile": "プロフィール",
|
||||||
"profile_id": "プロフィールID",
|
"profile_id": "プロフィールID",
|
||||||
|
"progress": "進捗",
|
||||||
"project_configuration": "プロジェクト設定",
|
"project_configuration": "プロジェクト設定",
|
||||||
"project_creation_description": "より良いアクセス制御のために、フォームをプロジェクトで整理します。",
|
"project_creation_description": "より良いアクセス制御のために、フォームをプロジェクトで整理します。",
|
||||||
"project_id": "プロジェクトID",
|
"project_id": "プロジェクトID",
|
||||||
@@ -323,6 +329,9 @@
|
|||||||
"question": "質問",
|
"question": "質問",
|
||||||
"question_id": "質問ID",
|
"question_id": "質問ID",
|
||||||
"questions": "質問",
|
"questions": "質問",
|
||||||
|
"quota": "クォータ",
|
||||||
|
"quotas": "クォータ",
|
||||||
|
"quotas_description": "特定の基準を満たす参加者からの回答数を制限する",
|
||||||
"read_docs": "ドキュメントを読む",
|
"read_docs": "ドキュメントを読む",
|
||||||
"recipients": "受信者",
|
"recipients": "受信者",
|
||||||
"remove": "削除",
|
"remove": "削除",
|
||||||
@@ -370,6 +379,7 @@
|
|||||||
"start_free_trial": "無料トライアルを開始",
|
"start_free_trial": "無料トライアルを開始",
|
||||||
"status": "ステータス",
|
"status": "ステータス",
|
||||||
"step_by_step_manual": "ステップバイステップマニュアル",
|
"step_by_step_manual": "ステップバイステップマニュアル",
|
||||||
|
"storage_not_configured": "ファイルストレージが設定されていないため、アップロードは失敗する可能性があります",
|
||||||
"styling": "スタイル",
|
"styling": "スタイル",
|
||||||
"submit": "送信",
|
"submit": "送信",
|
||||||
"summary": "概要",
|
"summary": "概要",
|
||||||
@@ -579,6 +589,7 @@
|
|||||||
"contacts_table_refresh": "連絡先を更新",
|
"contacts_table_refresh": "連絡先を更新",
|
||||||
"contacts_table_refresh_success": "連絡先を正常に更新しました",
|
"contacts_table_refresh_success": "連絡先を正常に更新しました",
|
||||||
"delete_contact_confirmation": "これにより、この連絡先に関連付けられているすべてのフォーム回答と連絡先属性が削除されます。この連絡先のデータに基づいたターゲティングとパーソナライゼーションはすべて失われます。",
|
"delete_contact_confirmation": "これにより、この連絡先に関連付けられているすべてのフォーム回答と連絡先属性が削除されます。この連絡先のデータに基づいたターゲティングとパーソナライゼーションはすべて失われます。",
|
||||||
|
"delete_contact_confirmation_with_quotas": "{value, plural, one {これにより この連絡先に関連するすべてのアンケート応答と連絡先属性が削除されます。この連絡先のデータに基づくターゲティングとパーソナライゼーションが失われます。この連絡先がアンケートの割当量を考慮した回答を持っている場合、割当量カウントは減少しますが、割当量の制限は変更されません。} other {これにより これらの連絡先に関連するすべてのアンケート応答と連絡先属性が削除されます。これらの連絡先のデータに基づくターゲティングとパーソナライゼーションが失われます。これらの連絡先がアンケートの割当量を考慮した回答を持っている場合、割当量カウントは減少しますが、割当量の制限は変更されません。}}",
|
||||||
"no_responses_found": "回答が見つかりません",
|
"no_responses_found": "回答が見つかりません",
|
||||||
"not_provided": "提供されていません",
|
"not_provided": "提供されていません",
|
||||||
"search_contact": "連絡先を検索",
|
"search_contact": "連絡先を検索",
|
||||||
@@ -1280,7 +1291,7 @@
|
|||||||
"columns": "列",
|
"columns": "列",
|
||||||
"company": "会社",
|
"company": "会社",
|
||||||
"company_logo": "会社のロゴ",
|
"company_logo": "会社のロゴ",
|
||||||
"completed_responses": "部分的または完了した回答。",
|
"completed_responses": "完了した回答",
|
||||||
"concat": "連結 +",
|
"concat": "連結 +",
|
||||||
"conditional_logic": "条件付きロジック",
|
"conditional_logic": "条件付きロジック",
|
||||||
"confirm_default_language": "デフォルト言語を確認",
|
"confirm_default_language": "デフォルト言語を確認",
|
||||||
@@ -1320,6 +1331,7 @@
|
|||||||
"end_screen_card": "終了画面カード",
|
"end_screen_card": "終了画面カード",
|
||||||
"ending_card": "終了カード",
|
"ending_card": "終了カード",
|
||||||
"ending_card_used_in_logic": "この終了カードは質問 {questionIndex} のロジックで使用されています。",
|
"ending_card_used_in_logic": "この終了カードは質問 {questionIndex} のロジックで使用されています。",
|
||||||
|
"ending_used_in_quota": "この 終了 は \"{quotaName}\" クォータ で使用されています",
|
||||||
"ends_with": "で終わる",
|
"ends_with": "で終わる",
|
||||||
"equals": "と等しい",
|
"equals": "と等しい",
|
||||||
"equals_one_of": "のいずれかと等しい",
|
"equals_one_of": "のいずれかと等しい",
|
||||||
@@ -1330,6 +1342,7 @@
|
|||||||
"fallback_for": "のフォールバック",
|
"fallback_for": "のフォールバック",
|
||||||
"fallback_missing": "フォールバックがありません",
|
"fallback_missing": "フォールバックがありません",
|
||||||
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} は質問 {questionIndex} のロジックで使用されています。まず、ロジックから削除してください。",
|
"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}\" クォータ で使用されています",
|
||||||
"field_name_eg_score_price": "フィールド名、例:score、price",
|
"field_name_eg_score_price": "フィールド名、例:score、price",
|
||||||
"first_name": "名",
|
"first_name": "名",
|
||||||
"five_points_recommended": "5点(推奨)",
|
"five_points_recommended": "5点(推奨)",
|
||||||
@@ -1361,8 +1374,9 @@
|
|||||||
"follow_ups_modal_action_subject_placeholder": "メールの件名",
|
"follow_ups_modal_action_subject_placeholder": "メールの件名",
|
||||||
"follow_ups_modal_action_to_description": "メールを送信するメールアドレス",
|
"follow_ups_modal_action_to_description": "メールを送信するメールアドレス",
|
||||||
"follow_ups_modal_action_to_label": "宛先",
|
"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_create_heading": "新しいフォローアップを作成",
|
||||||
|
"follow_ups_modal_created_successfull_toast": "フォローアップ が 作成され、 アンケートを 保存すると保存されます。",
|
||||||
"follow_ups_modal_edit_heading": "このフォローアップを編集",
|
"follow_ups_modal_edit_heading": "このフォローアップを編集",
|
||||||
"follow_ups_modal_edit_no_id": "フォームのフォローアップIDが提供されていません。フォームのフォローアップを更新できません",
|
"follow_ups_modal_edit_no_id": "フォームのフォローアップIDが提供されていません。フォームのフォローアップを更新できません",
|
||||||
"follow_ups_modal_name_label": "フォローアップ名",
|
"follow_ups_modal_name_label": "フォローアップ名",
|
||||||
@@ -1372,8 +1386,9 @@
|
|||||||
"follow_ups_modal_trigger_label": "トリガー",
|
"follow_ups_modal_trigger_label": "トリガー",
|
||||||
"follow_ups_modal_trigger_type_ending": "回答者が特定の終了画面を見たとき",
|
"follow_ups_modal_trigger_type_ending": "回答者が特定の終了画面を見たとき",
|
||||||
"follow_ups_modal_trigger_type_ending_select": "終了を選択:",
|
"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_trigger_type_response": "回答者がフォームを完了したとき",
|
||||||
|
"follow_ups_modal_updated_successfull_toast": "フォローアップ が 更新され、 アンケートを 保存すると保存されます。",
|
||||||
"follow_ups_new": "新しいフォローアップ",
|
"follow_ups_new": "新しいフォローアップ",
|
||||||
"follow_ups_upgrade_button_text": "フォローアップを有効にするためにアップグレード",
|
"follow_ups_upgrade_button_text": "フォローアップを有効にするためにアップグレード",
|
||||||
"form_styling": "フォームのスタイル",
|
"form_styling": "フォームのスタイル",
|
||||||
@@ -1474,6 +1489,38 @@
|
|||||||
"question_duplicated": "質問を複製しました。",
|
"question_duplicated": "質問を複製しました。",
|
||||||
"question_id_updated": "質問IDを更新しました",
|
"question_id_updated": "質問IDを更新しました",
|
||||||
"question_used_in_logic": "この質問は質問 {questionIndex} のロジックで使用されています。",
|
"question_used_in_logic": "この質問は質問 {questionIndex} のロジックで使用されています。",
|
||||||
|
"question_used_in_quota": "この 質問 は \"{quotaName}\" の クオータ に使用されています",
|
||||||
|
"quotas": {
|
||||||
|
"add_quota": "クォータを追加",
|
||||||
|
"change_quota_for_public_survey": "パブリック フォームのクォータを変更しますか?",
|
||||||
|
"confirm_quota_changes": "配分の変更を確認",
|
||||||
|
"confirm_quota_changes_body": "クォータに未保存の変更があります。離れる前に保存しますか?",
|
||||||
|
"continue_survey_normally": "アンケートを通常通り続行",
|
||||||
|
"count_partial_submissions": "部分的な提出の数を数える",
|
||||||
|
"count_partial_submissions_description": "クォータ基準を満たしているものの、調査を完了しなかった回答者を含める",
|
||||||
|
"create_quota_for_public_survey": "パブリック フォームのクォータを作成しますか?",
|
||||||
|
"create_quota_for_public_survey_description": "今後の回答のみがクォータにスクリーニングされます",
|
||||||
|
"create_quota_for_public_survey_text": "この調査はすでに公開されています。既存の回答は、新しい割当には考慮されません。",
|
||||||
|
"delete_quota_confirmation_text": "これは永久にクォータ {quotaName} を削除します。",
|
||||||
|
"duplicate_quota": "割り当ての複製",
|
||||||
|
"edit_quota": "クオータを編集",
|
||||||
|
"end_survey_for_matching_participants": "一致する参加者に対してアンケートを終了",
|
||||||
|
"inclusion_criteria": "選定基準",
|
||||||
|
"limit_must_be_greater_than_or_equal_to_the_number_of_responses": "{value, plural, other { この クオータ では すでに {value} 件 の回答があります ので、制限は {value} より大きくする必要があります。} }",
|
||||||
|
"limited_to_x_responses": "{limit} 回に制限",
|
||||||
|
"new_quota": "新しい クォータ",
|
||||||
|
"quota_created_successfull_toast": "クオータを正常に作成しました",
|
||||||
|
"quota_deleted_successfull_toast": "クオータを正常に削除しました",
|
||||||
|
"quota_duplicated_successfull_toast": "クオータを正常に複製しました",
|
||||||
|
"quota_name_placeholder": "例: 年齢 18 から 25 歳 の 参加者",
|
||||||
|
"quota_updated_successfull_toast": "クオータを更新しました",
|
||||||
|
"response_limit": "制限",
|
||||||
|
"save_changes_confirmation_body": "今後の回答のみに影響します。\\n 既存のクォータを複製するか、新しいクォータを作成することをお勧めします。",
|
||||||
|
"save_changes_confirmation_text": "既存の応答 は クォータ に とどまります",
|
||||||
|
"select_ending_card": "終了カードを選択",
|
||||||
|
"upgrade_prompt_title": "上位プランで クォータ を使用",
|
||||||
|
"when_quota_has_been_reached": "クオータが達成されたとき"
|
||||||
|
},
|
||||||
"randomize_all": "すべてをランダム化",
|
"randomize_all": "すべてをランダム化",
|
||||||
"randomize_all_except_last": "最後を除くすべてをランダム化",
|
"randomize_all_except_last": "最後を除くすべてをランダム化",
|
||||||
"range": "範囲",
|
"range": "範囲",
|
||||||
@@ -1567,6 +1614,7 @@
|
|||||||
"url_not_supported": "URLはサポートされていません",
|
"url_not_supported": "URLはサポートされていません",
|
||||||
"use_with_caution": "注意して使用",
|
"use_with_caution": "注意して使用",
|
||||||
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} は質問 {questionIndex} のロジックで使用されています。まず、ロジックから削除してください。",
|
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} は質問 {questionIndex} のロジックで使用されています。まず、ロジックから削除してください。",
|
||||||
|
"variable_is_used_in_quota_please_remove_it_from_quota_first": "変数 \"{variableName}\" は \"{quotaName}\" クォータ で使用されています",
|
||||||
"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": "変数名はアルファベットで始まらなければなりません。",
|
||||||
"verify_email_before_submission": "送信前にメールアドレスを認証",
|
"verify_email_before_submission": "送信前にメールアドレスを認証",
|
||||||
@@ -1601,11 +1649,14 @@
|
|||||||
"address_line_2": "住所2",
|
"address_line_2": "住所2",
|
||||||
"an_error_occurred_deleting_the_tag": "タグの削除中にエラーが発生しました",
|
"an_error_occurred_deleting_the_tag": "タグの削除中にエラーが発生しました",
|
||||||
"browser": "ブラウザ",
|
"browser": "ブラウザ",
|
||||||
|
"bulk_delete_response_quotas": "この回答は、このアンケートの割り当ての一部です。 割り当てをどのように処理しますか?",
|
||||||
"city": "市区町村",
|
"city": "市区町村",
|
||||||
"company": "会社",
|
"company": "会社",
|
||||||
"completed": "完了 ✅",
|
"completed": "完了 ✅",
|
||||||
"country": "国",
|
"country": "国",
|
||||||
|
"decrement_quotas": "すべて の 制限 を 減少 し、 この 回答 を 含む しきい値",
|
||||||
"delete_response_confirmation": "これにより、すべての回答、タグ、添付されたドキュメント、および回答メタデータを含むフォームの回答が削除されます。",
|
"delete_response_confirmation": "これにより、すべての回答、タグ、添付されたドキュメント、および回答メタデータを含むフォームの回答が削除されます。",
|
||||||
|
"delete_response_quotas": "この回答は、このアンケートの割り当ての一部です。 割り当てをどのように処理しますか?",
|
||||||
"device": "デバイス",
|
"device": "デバイス",
|
||||||
"device_info": "デバイス情報",
|
"device_info": "デバイス情報",
|
||||||
"email": "メールアドレス",
|
"email": "メールアドレス",
|
||||||
@@ -1737,6 +1788,7 @@
|
|||||||
"configure_alerts": "アラートを設定",
|
"configure_alerts": "アラートを設定",
|
||||||
"congrats": "おめでとうございます!フォームが公開されました。",
|
"congrats": "おめでとうございます!フォームが公開されました。",
|
||||||
"connect_your_website_or_app_with_formbricks_to_get_started": "始めるには、ウェブサイトやアプリをFormbricksに接続してください。",
|
"connect_your_website_or_app_with_formbricks_to_get_started": "始めるには、ウェブサイトやアプリをFormbricksに接続してください。",
|
||||||
|
"current_count": "現在の件数",
|
||||||
"custom_range": "カスタム範囲...",
|
"custom_range": "カスタム範囲...",
|
||||||
"delete_all_existing_responses_and_displays": "既存のすべての回答と表示を削除",
|
"delete_all_existing_responses_and_displays": "既存のすべての回答と表示を削除",
|
||||||
"download_qr_code": "QRコードをダウンロード",
|
"download_qr_code": "QRコードをダウンロード",
|
||||||
@@ -1790,6 +1842,7 @@
|
|||||||
"last_month": "先月",
|
"last_month": "先月",
|
||||||
"last_quarter": "前四半期",
|
"last_quarter": "前四半期",
|
||||||
"last_year": "昨年",
|
"last_year": "昨年",
|
||||||
|
"limit": "制限",
|
||||||
"no_responses_found": "回答が見つかりません",
|
"no_responses_found": "回答が見つかりません",
|
||||||
"other_values_found": "他の値が見つかりました",
|
"other_values_found": "他の値が見つかりました",
|
||||||
"overall": "全体",
|
"overall": "全体",
|
||||||
@@ -1798,6 +1851,8 @@
|
|||||||
"qr_code_download_failed": "QRコードのダウンロードに失敗しました",
|
"qr_code_download_failed": "QRコードのダウンロードに失敗しました",
|
||||||
"qr_code_download_with_start_soon": "QRコードのダウンロードがまもなく開始されます",
|
"qr_code_download_with_start_soon": "QRコードのダウンロードがまもなく開始されます",
|
||||||
"qr_code_generation_failed": "フォームのQRコードの読み込み中に問題が発生しました。もう一度お試しください。",
|
"qr_code_generation_failed": "フォームのQRコードの読み込み中に問題が発生しました。もう一度お試しください。",
|
||||||
|
"quotas_completed": "クォータ完了",
|
||||||
|
"quotas_completed_tooltip": "回答者 によって 完了 した 定員 の 数。",
|
||||||
"reset_survey": "フォームをリセット",
|
"reset_survey": "フォームをリセット",
|
||||||
"reset_survey_warning": "フォームをリセットすると、このフォームに関連付けられているすべての回答と表示が削除されます。この操作は元に戻せません。",
|
"reset_survey_warning": "フォームをリセットすると、このフォームに関連付けられているすべての回答と表示が削除されます。この操作は元に戻せません。",
|
||||||
"selected_responses_csv": "選択した回答 (CSV)",
|
"selected_responses_csv": "選択した回答 (CSV)",
|
||||||
@@ -2832,4 +2887,4 @@
|
|||||||
"usability_rating_description": "標準化された10の質問アンケートを使用して、製品に対するユーザーの体験を評価し、知覚された使いやすさを測定する。",
|
"usability_rating_description": "標準化された10の質問アンケートを使用して、製品に対するユーザーの体験を評価し、知覚された使いやすさを測定する。",
|
||||||
"usability_score_name": "システムユーザビリティスコア(SUS)"
|
"usability_score_name": "システムユーザビリティスコア(SUS)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -169,11 +169,14 @@
|
|||||||
"connect_formbricks": "Conectar Formbricks",
|
"connect_formbricks": "Conectar Formbricks",
|
||||||
"connected": "conectado",
|
"connected": "conectado",
|
||||||
"contacts": "Contatos",
|
"contacts": "Contatos",
|
||||||
|
"continue": "Continuar",
|
||||||
"copied": "Copiado",
|
"copied": "Copiado",
|
||||||
"copied_to_clipboard": "Copiado para a área de transferência",
|
"copied_to_clipboard": "Copiado para a área de transferência",
|
||||||
"copy": "Copiar",
|
"copy": "Copiar",
|
||||||
"copy_code": "Copiar código",
|
"copy_code": "Copiar código",
|
||||||
"copy_link": "Copiar Link",
|
"copy_link": "Copiar Link",
|
||||||
|
"count_contacts": "{value, plural, one {# contato} other {# contatos} }",
|
||||||
|
"count_responses": "{value, plural, other {# respostas}}",
|
||||||
"create_new_organization": "Criar nova organização",
|
"create_new_organization": "Criar nova organização",
|
||||||
"create_project": "Criar projeto",
|
"create_project": "Criar projeto",
|
||||||
"create_segment": "Criar segmento",
|
"create_segment": "Criar segmento",
|
||||||
@@ -201,6 +204,7 @@
|
|||||||
"e_commerce": "comércio eletrônico",
|
"e_commerce": "comércio eletrônico",
|
||||||
"edit": "Editar",
|
"edit": "Editar",
|
||||||
"email": "Email",
|
"email": "Email",
|
||||||
|
"ending_card": "Cartão de encerramento",
|
||||||
"enterprise_license": "Licença Empresarial",
|
"enterprise_license": "Licença Empresarial",
|
||||||
"environment_not_found": "Ambiente não encontrado",
|
"environment_not_found": "Ambiente não encontrado",
|
||||||
"environment_notice": "Você está atualmente no ambiente {environment}.",
|
"environment_notice": "Você está atualmente no ambiente {environment}.",
|
||||||
@@ -269,6 +273,7 @@
|
|||||||
"no_background_image_found": "Imagem de fundo não encontrada.",
|
"no_background_image_found": "Imagem de fundo não encontrada.",
|
||||||
"no_code": "Sem código",
|
"no_code": "Sem código",
|
||||||
"no_files_uploaded": "Nenhum arquivo foi enviado",
|
"no_files_uploaded": "Nenhum arquivo foi enviado",
|
||||||
|
"no_quotas_found": "Nenhuma cota encontrada",
|
||||||
"no_result_found": "Nenhum resultado encontrado",
|
"no_result_found": "Nenhum resultado encontrado",
|
||||||
"no_results": "Nenhum resultado",
|
"no_results": "Nenhum resultado",
|
||||||
"no_surveys_found": "Não foram encontradas pesquisas.",
|
"no_surveys_found": "Não foram encontradas pesquisas.",
|
||||||
@@ -312,6 +317,7 @@
|
|||||||
"product_manager": "Gerente de Produto",
|
"product_manager": "Gerente de Produto",
|
||||||
"profile": "Perfil",
|
"profile": "Perfil",
|
||||||
"profile_id": "ID de Perfil",
|
"profile_id": "ID de Perfil",
|
||||||
|
"progress": "Progresso",
|
||||||
"project_configuration": "Configuração do Projeto",
|
"project_configuration": "Configuração do Projeto",
|
||||||
"project_creation_description": "Organize pesquisas em projetos para melhor controle de acesso.",
|
"project_creation_description": "Organize pesquisas em projetos para melhor controle de acesso.",
|
||||||
"project_id": "ID do Projeto",
|
"project_id": "ID do Projeto",
|
||||||
@@ -323,6 +329,9 @@
|
|||||||
"question": "Pergunta",
|
"question": "Pergunta",
|
||||||
"question_id": "ID da Pergunta",
|
"question_id": "ID da Pergunta",
|
||||||
"questions": "Perguntas",
|
"questions": "Perguntas",
|
||||||
|
"quota": "Cota",
|
||||||
|
"quotas": "Cotas",
|
||||||
|
"quotas_description": "Limite a quantidade de respostas que você recebe de participantes que atendem a determinados critérios.",
|
||||||
"read_docs": "Ler Documentação",
|
"read_docs": "Ler Documentação",
|
||||||
"recipients": "Destinatários",
|
"recipients": "Destinatários",
|
||||||
"remove": "remover",
|
"remove": "remover",
|
||||||
@@ -370,6 +379,7 @@
|
|||||||
"start_free_trial": "Iniciar Teste Grátis",
|
"start_free_trial": "Iniciar Teste Grátis",
|
||||||
"status": "status",
|
"status": "status",
|
||||||
"step_by_step_manual": "Manual passo a passo",
|
"step_by_step_manual": "Manual passo a passo",
|
||||||
|
"storage_not_configured": "Armazenamento de arquivos não configurado, uploads provavelmente falharão",
|
||||||
"styling": "Estilização",
|
"styling": "Estilização",
|
||||||
"submit": "Enviar",
|
"submit": "Enviar",
|
||||||
"summary": "Resumo",
|
"summary": "Resumo",
|
||||||
@@ -579,6 +589,7 @@
|
|||||||
"contacts_table_refresh": "Atualizar contatos",
|
"contacts_table_refresh": "Atualizar contatos",
|
||||||
"contacts_table_refresh_success": "Contatos atualizados com sucesso",
|
"contacts_table_refresh_success": "Contatos atualizados com sucesso",
|
||||||
"delete_contact_confirmation": "Isso irá apagar todas as respostas da pesquisa e atributos de contato associados a este contato. Qualquer direcionamento e personalização baseados nos dados deste contato serão perdidos.",
|
"delete_contact_confirmation": "Isso irá apagar todas as respostas da pesquisa e atributos de contato associados a este contato. Qualquer direcionamento e personalização baseados nos dados deste contato serão perdidos.",
|
||||||
|
"delete_contact_confirmation_with_quotas": "{value, plural, other {Isso irá apagar todas as respostas da pesquisa e atributos de contato associados a este contato. Qualquer direcionamento e personalização baseados nos dados deste contato serão perdidos. Se este contato tiver respostas que contam para cotas da pesquisa, as contagens das cotas serão reduzidas, mas os limites das cotas permanecerão inalterados.}}",
|
||||||
"no_responses_found": "Nenhuma resposta encontrada",
|
"no_responses_found": "Nenhuma resposta encontrada",
|
||||||
"not_provided": "Não fornecido",
|
"not_provided": "Não fornecido",
|
||||||
"search_contact": "Buscar contato",
|
"search_contact": "Buscar contato",
|
||||||
@@ -1280,7 +1291,7 @@
|
|||||||
"columns": "colunas",
|
"columns": "colunas",
|
||||||
"company": "empresa",
|
"company": "empresa",
|
||||||
"company_logo": "Logo da empresa",
|
"company_logo": "Logo da empresa",
|
||||||
"completed_responses": "respostas parciais ou completas.",
|
"completed_responses": "Respostas concluídas.",
|
||||||
"concat": "Concatenar +",
|
"concat": "Concatenar +",
|
||||||
"conditional_logic": "Lógica Condicional",
|
"conditional_logic": "Lógica Condicional",
|
||||||
"confirm_default_language": "Confirmar idioma padrão",
|
"confirm_default_language": "Confirmar idioma padrão",
|
||||||
@@ -1320,6 +1331,7 @@
|
|||||||
"end_screen_card": "cartão de tela final",
|
"end_screen_card": "cartão de tela final",
|
||||||
"ending_card": "Cartão de encerramento",
|
"ending_card": "Cartão de encerramento",
|
||||||
"ending_card_used_in_logic": "Esse cartão de encerramento é usado na lógica da pergunta {questionIndex}.",
|
"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",
|
"ends_with": "Termina com",
|
||||||
"equals": "Igual",
|
"equals": "Igual",
|
||||||
"equals_one_of": "É igual a um de",
|
"equals_one_of": "É igual a um de",
|
||||||
@@ -1330,6 +1342,7 @@
|
|||||||
"fallback_for": "Alternativa para",
|
"fallback_for": "Alternativa para",
|
||||||
"fallback_missing": "Faltando alternativa",
|
"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_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}\"",
|
||||||
"field_name_eg_score_price": "Nome do campo, por exemplo, pontuação, preço",
|
"field_name_eg_score_price": "Nome do campo, por exemplo, pontuação, preço",
|
||||||
"first_name": "Primeiro Nome",
|
"first_name": "Primeiro Nome",
|
||||||
"five_points_recommended": "5 pontos (recomendado)",
|
"five_points_recommended": "5 pontos (recomendado)",
|
||||||
@@ -1361,8 +1374,9 @@
|
|||||||
"follow_ups_modal_action_subject_placeholder": "Assunto do e-mail",
|
"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_description": "Endereço de e-mail para enviar o e-mail para",
|
||||||
"follow_ups_modal_action_to_label": "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_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_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_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",
|
"follow_ups_modal_name_label": "Nome do acompanhamento",
|
||||||
@@ -1372,8 +1386,9 @@
|
|||||||
"follow_ups_modal_trigger_label": "Gatilho",
|
"follow_ups_modal_trigger_label": "Gatilho",
|
||||||
"follow_ups_modal_trigger_type_ending": "Respondente vê um final específico",
|
"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_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_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_new": "Novo acompanhamento",
|
||||||
"follow_ups_upgrade_button_text": "Atualize para habilitar os Acompanhamentos",
|
"follow_ups_upgrade_button_text": "Atualize para habilitar os Acompanhamentos",
|
||||||
"form_styling": "Estilização de Formulários",
|
"form_styling": "Estilização de Formulários",
|
||||||
@@ -1474,6 +1489,38 @@
|
|||||||
"question_duplicated": "Pergunta duplicada.",
|
"question_duplicated": "Pergunta duplicada.",
|
||||||
"question_id_updated": "ID da pergunta atualizado",
|
"question_id_updated": "ID da pergunta atualizado",
|
||||||
"question_used_in_logic": "Essa pergunta é usada na lógica da pergunta {questionIndex}.",
|
"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}\"",
|
||||||
|
"quotas": {
|
||||||
|
"add_quota": "Adicionar cota",
|
||||||
|
"change_quota_for_public_survey": "Alterar cota para pesquisa pública?",
|
||||||
|
"confirm_quota_changes": "Confirmar Alterações nas Cotas",
|
||||||
|
"confirm_quota_changes_body": "Você tem alterações não salvas na sua cota. Quer salvar antes de sair?",
|
||||||
|
"continue_survey_normally": "Continuar pesquisa normalmente",
|
||||||
|
"count_partial_submissions": "Contar respostas parciais",
|
||||||
|
"count_partial_submissions_description": "Incluir respondentes que atendem aos critérios de cota, mas não completaram a pesquisa",
|
||||||
|
"create_quota_for_public_survey": "Criar cota para pesquisa pública?",
|
||||||
|
"create_quota_for_public_survey_description": "Apenas respostas futuras serão filtradas para a cota",
|
||||||
|
"create_quota_for_public_survey_text": "Esta pesquisa já é pública. Respostas existentes não serão consideradas para a nova cota.",
|
||||||
|
"delete_quota_confirmation_text": "Isso irá apagar permanentemente a cota {quotaName}.",
|
||||||
|
"duplicate_quota": "Duplicar cota",
|
||||||
|
"edit_quota": "Editar cota",
|
||||||
|
"end_survey_for_matching_participants": "Encerrar a pesquisa para participantes correspondentes",
|
||||||
|
"inclusion_criteria": "Critérios de Inclusão",
|
||||||
|
"limit_must_be_greater_than_or_equal_to_the_number_of_responses": "{value, plural, other {O limite deve ser maior ou igual ao número de respostas}}",
|
||||||
|
"limited_to_x_responses": "Limitado a {limit}",
|
||||||
|
"new_quota": "Nova Cota",
|
||||||
|
"quota_created_successfull_toast": "Cota criada com sucesso",
|
||||||
|
"quota_deleted_successfull_toast": "Cota deletada com sucesso",
|
||||||
|
"quota_duplicated_successfull_toast": "Cota duplicada com sucesso",
|
||||||
|
"quota_name_placeholder": "ex.: Participantes de 18-25 anos",
|
||||||
|
"quota_updated_successfull_toast": "Cota atualizada com sucesso",
|
||||||
|
"response_limit": "Limites",
|
||||||
|
"save_changes_confirmation_body": "Quaisquer alterações nos critérios de inclusão afetam apenas respostas futuras. \nRecomendamos duplicar uma cota existente ou criar uma nova.",
|
||||||
|
"save_changes_confirmation_text": "Respostas existentes permanecem na cota",
|
||||||
|
"select_ending_card": "Selecione cartão de final",
|
||||||
|
"upgrade_prompt_title": "Use cotas com um plano superior",
|
||||||
|
"when_quota_has_been_reached": "Quando a cota for atingida"
|
||||||
|
},
|
||||||
"randomize_all": "Randomizar tudo",
|
"randomize_all": "Randomizar tudo",
|
||||||
"randomize_all_except_last": "Randomizar tudo, exceto o último",
|
"randomize_all_except_last": "Randomizar tudo, exceto o último",
|
||||||
"range": "alcance",
|
"range": "alcance",
|
||||||
@@ -1567,6 +1614,7 @@
|
|||||||
"url_not_supported": "URL não suportada",
|
"url_not_supported": "URL não suportada",
|
||||||
"use_with_caution": "Use com cuidado",
|
"use_with_caution": "Use com cuidado",
|
||||||
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} está sendo usado na lógica da pergunta {questionIndex}. Por favor, remova-o da lógica primeiro.",
|
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} está sendo usado na lógica da pergunta {questionIndex}. Por favor, remova-o da lógica primeiro.",
|
||||||
|
"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_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_name_must_start_with_a_letter": "O nome da variável deve começar com uma letra.",
|
||||||
"verify_email_before_submission": "Verifique o e-mail antes de enviar",
|
"verify_email_before_submission": "Verifique o e-mail antes de enviar",
|
||||||
@@ -1601,11 +1649,14 @@
|
|||||||
"address_line_2": "Complemento",
|
"address_line_2": "Complemento",
|
||||||
"an_error_occurred_deleting_the_tag": "Ocorreu um erro ao deletar a tag",
|
"an_error_occurred_deleting_the_tag": "Ocorreu um erro ao deletar a tag",
|
||||||
"browser": "navegador",
|
"browser": "navegador",
|
||||||
|
"bulk_delete_response_quotas": "As respostas fazem parte das cotas desta pesquisa. Como você quer gerenciar as cotas?",
|
||||||
"city": "Cidade",
|
"city": "Cidade",
|
||||||
"company": "empresa",
|
"company": "empresa",
|
||||||
"completed": "Concluído ✅",
|
"completed": "Concluído ✅",
|
||||||
"country": "País",
|
"country": "País",
|
||||||
|
"decrement_quotas": "Diminua todos os limites de cotas, incluindo esta resposta",
|
||||||
"delete_response_confirmation": "Isso irá excluir a resposta da pesquisa, incluindo todas as respostas, etiquetas, documentos anexados e metadados da resposta.",
|
"delete_response_confirmation": "Isso irá excluir a resposta da pesquisa, incluindo todas as respostas, etiquetas, documentos anexados e metadados da resposta.",
|
||||||
|
"delete_response_quotas": "A resposta faz parte das cotas desta pesquisa. Como você quer gerenciar as cotas?",
|
||||||
"device": "dispositivo",
|
"device": "dispositivo",
|
||||||
"device_info": "Informações do dispositivo",
|
"device_info": "Informações do dispositivo",
|
||||||
"email": "Email",
|
"email": "Email",
|
||||||
@@ -1737,6 +1788,7 @@
|
|||||||
"configure_alerts": "Configurar alertas",
|
"configure_alerts": "Configurar alertas",
|
||||||
"congrats": "Parabéns! Sua pesquisa está no ar.",
|
"congrats": "Parabéns! Sua pesquisa está no ar.",
|
||||||
"connect_your_website_or_app_with_formbricks_to_get_started": "Conecte seu site ou app com o Formbricks para começar.",
|
"connect_your_website_or_app_with_formbricks_to_get_started": "Conecte seu site ou app com o Formbricks para começar.",
|
||||||
|
"current_count": "Contagem Atual",
|
||||||
"custom_range": "Intervalo personalizado...",
|
"custom_range": "Intervalo personalizado...",
|
||||||
"delete_all_existing_responses_and_displays": "Excluir todas as respostas e exibições existentes",
|
"delete_all_existing_responses_and_displays": "Excluir todas as respostas e exibições existentes",
|
||||||
"download_qr_code": "baixar código QR",
|
"download_qr_code": "baixar código QR",
|
||||||
@@ -1790,6 +1842,7 @@
|
|||||||
"last_month": "Último mês",
|
"last_month": "Último mês",
|
||||||
"last_quarter": "Último trimestre",
|
"last_quarter": "Último trimestre",
|
||||||
"last_year": "Último ano",
|
"last_year": "Último ano",
|
||||||
|
"limit": "Limite",
|
||||||
"no_responses_found": "Nenhuma resposta encontrada",
|
"no_responses_found": "Nenhuma resposta encontrada",
|
||||||
"other_values_found": "Outros valores encontrados",
|
"other_values_found": "Outros valores encontrados",
|
||||||
"overall": "No geral",
|
"overall": "No geral",
|
||||||
@@ -1798,6 +1851,8 @@
|
|||||||
"qr_code_download_failed": "falha no download do código QR",
|
"qr_code_download_failed": "falha no download do código QR",
|
||||||
"qr_code_download_with_start_soon": "O download do código QR começará em breve",
|
"qr_code_download_with_start_soon": "O download do código QR começará em breve",
|
||||||
"qr_code_generation_failed": "Houve um problema ao carregar o Código QR do questionário. Por favor, tente novamente.",
|
"qr_code_generation_failed": "Houve um problema ao carregar o Código QR do questionário. Por favor, tente novamente.",
|
||||||
|
"quotas_completed": "Cotas concluídas",
|
||||||
|
"quotas_completed_tooltip": "Número de cotas preenchidas pelos respondentes.",
|
||||||
"reset_survey": "Redefinir pesquisa",
|
"reset_survey": "Redefinir pesquisa",
|
||||||
"reset_survey_warning": "Redefinir uma pesquisa remove todas as respostas e exibições associadas a esta pesquisa. Isto não pode ser desfeito.",
|
"reset_survey_warning": "Redefinir uma pesquisa remove todas as respostas e exibições associadas a esta pesquisa. Isto não pode ser desfeito.",
|
||||||
"selected_responses_csv": "Respostas selecionadas (CSV)",
|
"selected_responses_csv": "Respostas selecionadas (CSV)",
|
||||||
@@ -2832,4 +2887,4 @@
|
|||||||
"usability_rating_description": "Meça a usabilidade percebida perguntando aos usuários para avaliar sua experiência com seu produto usando uma pesquisa padronizada de 10 perguntas.",
|
"usability_rating_description": "Meça a usabilidade percebida perguntando aos usuários para avaliar sua experiência com seu produto usando uma pesquisa padronizada de 10 perguntas.",
|
||||||
"usability_score_name": "Pontuação de Usabilidade do Sistema (SUS)"
|
"usability_score_name": "Pontuação de Usabilidade do Sistema (SUS)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -169,11 +169,14 @@
|
|||||||
"connect_formbricks": "Ligar Formbricks",
|
"connect_formbricks": "Ligar Formbricks",
|
||||||
"connected": "Conectado",
|
"connected": "Conectado",
|
||||||
"contacts": "Contactos",
|
"contacts": "Contactos",
|
||||||
|
"continue": "Continuar",
|
||||||
"copied": "Copiado",
|
"copied": "Copiado",
|
||||||
"copied_to_clipboard": "Copiado para a área de transferência",
|
"copied_to_clipboard": "Copiado para a área de transferência",
|
||||||
"copy": "Copiar",
|
"copy": "Copiar",
|
||||||
"copy_code": "Copiar código",
|
"copy_code": "Copiar código",
|
||||||
"copy_link": "Copiar Link",
|
"copy_link": "Copiar Link",
|
||||||
|
"count_contacts": "{value, plural, one {# contacto} other {# contactos} }",
|
||||||
|
"count_responses": "{value, plural, other {# respostas}}",
|
||||||
"create_new_organization": "Criar nova organização",
|
"create_new_organization": "Criar nova organização",
|
||||||
"create_project": "Criar projeto",
|
"create_project": "Criar projeto",
|
||||||
"create_segment": "Criar segmento",
|
"create_segment": "Criar segmento",
|
||||||
@@ -201,6 +204,7 @@
|
|||||||
"e_commerce": "Comércio Eletrónico",
|
"e_commerce": "Comércio Eletrónico",
|
||||||
"edit": "Editar",
|
"edit": "Editar",
|
||||||
"email": "Email",
|
"email": "Email",
|
||||||
|
"ending_card": "Cartão de encerramento",
|
||||||
"enterprise_license": "Licença Enterprise",
|
"enterprise_license": "Licença Enterprise",
|
||||||
"environment_not_found": "Ambiente não encontrado",
|
"environment_not_found": "Ambiente não encontrado",
|
||||||
"environment_notice": "Está atualmente no ambiente {environment}.",
|
"environment_notice": "Está atualmente no ambiente {environment}.",
|
||||||
@@ -269,6 +273,7 @@
|
|||||||
"no_background_image_found": "Nenhuma imagem de fundo encontrada.",
|
"no_background_image_found": "Nenhuma imagem de fundo encontrada.",
|
||||||
"no_code": "Sem código",
|
"no_code": "Sem código",
|
||||||
"no_files_uploaded": "Nenhum ficheiro foi carregado",
|
"no_files_uploaded": "Nenhum ficheiro foi carregado",
|
||||||
|
"no_quotas_found": "Nenhum quota encontrado",
|
||||||
"no_result_found": "Nenhum resultado encontrado",
|
"no_result_found": "Nenhum resultado encontrado",
|
||||||
"no_results": "Nenhum resultado",
|
"no_results": "Nenhum resultado",
|
||||||
"no_surveys_found": "Nenhum inquérito encontrado.",
|
"no_surveys_found": "Nenhum inquérito encontrado.",
|
||||||
@@ -312,6 +317,7 @@
|
|||||||
"product_manager": "Gestor de Produto",
|
"product_manager": "Gestor de Produto",
|
||||||
"profile": "Perfil",
|
"profile": "Perfil",
|
||||||
"profile_id": "ID do Perfil",
|
"profile_id": "ID do Perfil",
|
||||||
|
"progress": "Progresso",
|
||||||
"project_configuration": "Configuração do Projeto",
|
"project_configuration": "Configuração do Projeto",
|
||||||
"project_creation_description": "Organize questionários em projetos para um melhor controlo de acesso.",
|
"project_creation_description": "Organize questionários em projetos para um melhor controlo de acesso.",
|
||||||
"project_id": "ID do Projeto",
|
"project_id": "ID do Projeto",
|
||||||
@@ -323,6 +329,9 @@
|
|||||||
"question": "Pergunta",
|
"question": "Pergunta",
|
||||||
"question_id": "ID da pergunta",
|
"question_id": "ID da pergunta",
|
||||||
"questions": "Perguntas",
|
"questions": "Perguntas",
|
||||||
|
"quota": "Quota",
|
||||||
|
"quotas": "Quotas",
|
||||||
|
"quotas_description": "Limitar a quantidade de respostas recebidas de participantes que atendem a certos critérios.",
|
||||||
"read_docs": "Ler Documentos",
|
"read_docs": "Ler Documentos",
|
||||||
"recipients": "Destinatários",
|
"recipients": "Destinatários",
|
||||||
"remove": "Remover",
|
"remove": "Remover",
|
||||||
@@ -370,6 +379,7 @@
|
|||||||
"start_free_trial": "Iniciar Teste Grátis",
|
"start_free_trial": "Iniciar Teste Grátis",
|
||||||
"status": "Estado",
|
"status": "Estado",
|
||||||
"step_by_step_manual": "Manual passo a passo",
|
"step_by_step_manual": "Manual passo a passo",
|
||||||
|
"storage_not_configured": "Armazenamento de ficheiros não configurado, uploads provavelmente falharão",
|
||||||
"styling": "Estilo",
|
"styling": "Estilo",
|
||||||
"submit": "Submeter",
|
"submit": "Submeter",
|
||||||
"summary": "Resumo",
|
"summary": "Resumo",
|
||||||
@@ -579,6 +589,7 @@
|
|||||||
"contacts_table_refresh": "Atualizar contactos",
|
"contacts_table_refresh": "Atualizar contactos",
|
||||||
"contacts_table_refresh_success": "Contactos atualizados com sucesso",
|
"contacts_table_refresh_success": "Contactos atualizados com sucesso",
|
||||||
"delete_contact_confirmation": "Isto irá eliminar todas as respostas das pesquisas e os atributos de contato associados a este contato. Qualquer direcionamento e personalização baseados nos dados deste contato serão perdidos.",
|
"delete_contact_confirmation": "Isto irá eliminar todas as respostas das pesquisas e os atributos de contato associados a este contato. Qualquer direcionamento e personalização baseados nos dados deste contato serão perdidos.",
|
||||||
|
"delete_contact_confirmation_with_quotas": "{value, plural, other {Isto irá eliminar todas as respostas das pesquisas e os atributos de contacto associados a este contacto. Qualquer segmentação e personalização baseados nos dados deste contacto serão perdidos. Se este contacto tiver respostas que contribuam para as quotas das pesquisas, as contagens de quotas serão reduzidas, mas os limites das quotas permanecerão inalterados.}}",
|
||||||
"no_responses_found": "Nenhuma resposta encontrada",
|
"no_responses_found": "Nenhuma resposta encontrada",
|
||||||
"not_provided": "Não fornecido",
|
"not_provided": "Não fornecido",
|
||||||
"search_contact": "Procurar contacto",
|
"search_contact": "Procurar contacto",
|
||||||
@@ -1280,7 +1291,7 @@
|
|||||||
"columns": "Colunas",
|
"columns": "Colunas",
|
||||||
"company": "Empresa",
|
"company": "Empresa",
|
||||||
"company_logo": "Logotipo da empresa",
|
"company_logo": "Logotipo da empresa",
|
||||||
"completed_responses": "respostas parciais ou completas",
|
"completed_responses": "Respostas concluídas",
|
||||||
"concat": "Concatenar +",
|
"concat": "Concatenar +",
|
||||||
"conditional_logic": "Lógica Condicional",
|
"conditional_logic": "Lógica Condicional",
|
||||||
"confirm_default_language": "Confirmar idioma padrão",
|
"confirm_default_language": "Confirmar idioma padrão",
|
||||||
@@ -1320,6 +1331,7 @@
|
|||||||
"end_screen_card": "Cartão de ecrã final",
|
"end_screen_card": "Cartão de ecrã final",
|
||||||
"ending_card": "Cartão de encerramento",
|
"ending_card": "Cartão de encerramento",
|
||||||
"ending_card_used_in_logic": "Este cartão final é usado na lógica da pergunta {questionIndex}.",
|
"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",
|
"ends_with": "Termina com",
|
||||||
"equals": "Igual",
|
"equals": "Igual",
|
||||||
"equals_one_of": "Igual a um de",
|
"equals_one_of": "Igual a um de",
|
||||||
@@ -1330,6 +1342,7 @@
|
|||||||
"fallback_for": "Alternativa para ",
|
"fallback_for": "Alternativa para ",
|
||||||
"fallback_missing": "Substituição em falta",
|
"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_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}\"",
|
||||||
"field_name_eg_score_price": "Nome do campo, por exemplo, pontuação, preço",
|
"field_name_eg_score_price": "Nome do campo, por exemplo, pontuação, preço",
|
||||||
"first_name": "Primeiro Nome",
|
"first_name": "Primeiro Nome",
|
||||||
"five_points_recommended": "5 pontos (recomendado)",
|
"five_points_recommended": "5 pontos (recomendado)",
|
||||||
@@ -1361,8 +1374,9 @@
|
|||||||
"follow_ups_modal_action_subject_placeholder": "Assunto do email",
|
"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_description": "Endereço de email para enviar o email",
|
||||||
"follow_ups_modal_action_to_label": "Para",
|
"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_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_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_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",
|
"follow_ups_modal_name_label": "Nome do acompanhamento",
|
||||||
@@ -1372,8 +1386,9 @@
|
|||||||
"follow_ups_modal_trigger_label": "Desencadeador",
|
"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": "O respondente vê um final específico",
|
||||||
"follow_ups_modal_trigger_type_ending_select": "Selecionar finais: ",
|
"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_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_new": "Novo acompanhamento",
|
||||||
"follow_ups_upgrade_button_text": "Atualize para ativar os acompanhamentos",
|
"follow_ups_upgrade_button_text": "Atualize para ativar os acompanhamentos",
|
||||||
"form_styling": "Estilo do formulário",
|
"form_styling": "Estilo do formulário",
|
||||||
@@ -1474,6 +1489,38 @@
|
|||||||
"question_duplicated": "Pergunta duplicada.",
|
"question_duplicated": "Pergunta duplicada.",
|
||||||
"question_id_updated": "ID da pergunta atualizado",
|
"question_id_updated": "ID da pergunta atualizado",
|
||||||
"question_used_in_logic": "Esta pergunta é usada na lógica da pergunta {questionIndex}.",
|
"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}\"",
|
||||||
|
"quotas": {
|
||||||
|
"add_quota": "Adicionar quota",
|
||||||
|
"change_quota_for_public_survey": "Alterar quota para inquérito público?",
|
||||||
|
"confirm_quota_changes": "Confirmar Alterações das Quotas",
|
||||||
|
"confirm_quota_changes_body": "Tem alterações não guardadas na sua cota. Gostaria de as guardar antes de sair?",
|
||||||
|
"continue_survey_normally": "Continua a pesquisa normalmente",
|
||||||
|
"count_partial_submissions": "Contar submissões parciais",
|
||||||
|
"count_partial_submissions_description": "Incluir respondentes que correspondem aos critérios de quota mas não completaram o inquérito",
|
||||||
|
"create_quota_for_public_survey": "Criar quota para inquérito público?",
|
||||||
|
"create_quota_for_public_survey_description": "Apenas respostas futuras serão controladas no limite",
|
||||||
|
"create_quota_for_public_survey_text": "Este questionário já é público. As respostas existentes não serão consideradas na nova quota.",
|
||||||
|
"delete_quota_confirmation_text": "Isto irá apagar permanentemente a quota {quotaName}.",
|
||||||
|
"duplicate_quota": "Duplicar quota",
|
||||||
|
"edit_quota": "Editar cota",
|
||||||
|
"end_survey_for_matching_participants": "Encerrar inquérito para participantes correspondentes",
|
||||||
|
"inclusion_criteria": "Critérios de Inclusão",
|
||||||
|
"limit_must_be_greater_than_or_equal_to_the_number_of_responses": "{value, plural, other {Limite deve ser maior ou igual ao número de respostas}}",
|
||||||
|
"limited_to_x_responses": "Limitado a {limit}",
|
||||||
|
"new_quota": "Nova Cota",
|
||||||
|
"quota_created_successfull_toast": "Quota criada com sucesso",
|
||||||
|
"quota_deleted_successfull_toast": "Quota eliminada com sucesso",
|
||||||
|
"quota_duplicated_successfull_toast": "Quota duplicada com sucesso",
|
||||||
|
"quota_name_placeholder": "por exemplo, Participantes Idade 18-25",
|
||||||
|
"quota_updated_successfull_toast": "Quota atualizada com sucesso",
|
||||||
|
"response_limit": "Limites",
|
||||||
|
"save_changes_confirmation_body": "Quaisquer alterações aos critérios de inclusão afetam apenas respostas futuras. \nRecomendamos duplicar uma cota existente ou criar uma nova.",
|
||||||
|
"save_changes_confirmation_text": "As respostas existentes permanecem na cota",
|
||||||
|
"select_ending_card": "Selecionar cartão de encerramento",
|
||||||
|
"upgrade_prompt_title": "Utilize quotas com um plano superior",
|
||||||
|
"when_quota_has_been_reached": "Quando a quota foi atingida"
|
||||||
|
},
|
||||||
"randomize_all": "Aleatorizar todos",
|
"randomize_all": "Aleatorizar todos",
|
||||||
"randomize_all_except_last": "Aleatorizar todos exceto o último",
|
"randomize_all_except_last": "Aleatorizar todos exceto o último",
|
||||||
"range": "Intervalo",
|
"range": "Intervalo",
|
||||||
@@ -1567,6 +1614,7 @@
|
|||||||
"url_not_supported": "URL não suportado",
|
"url_not_supported": "URL não suportado",
|
||||||
"use_with_caution": "Usar com cautela",
|
"use_with_caution": "Usar com cautela",
|
||||||
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} é usada na lógica da pergunta {questionIndex}. Por favor, remova-a da lógica primeiro.",
|
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} é usada na lógica da pergunta {questionIndex}. Por favor, remova-a da lógica primeiro.",
|
||||||
|
"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_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_name_must_start_with_a_letter": "O nome da variável deve começar com uma letra.",
|
||||||
"verify_email_before_submission": "Verificar email antes da submissão",
|
"verify_email_before_submission": "Verificar email antes da submissão",
|
||||||
@@ -1601,11 +1649,14 @@
|
|||||||
"address_line_2": "Endereço Linha 2",
|
"address_line_2": "Endereço Linha 2",
|
||||||
"an_error_occurred_deleting_the_tag": "Ocorreu um erro ao eliminar a etiqueta",
|
"an_error_occurred_deleting_the_tag": "Ocorreu um erro ao eliminar a etiqueta",
|
||||||
"browser": "Navegador",
|
"browser": "Navegador",
|
||||||
|
"bulk_delete_response_quotas": "As respostas são parte das quotas deste inquérito. Como deseja gerir as quotas?",
|
||||||
"city": "Cidade",
|
"city": "Cidade",
|
||||||
"company": "Empresa",
|
"company": "Empresa",
|
||||||
"completed": "Concluído ✅",
|
"completed": "Concluído ✅",
|
||||||
"country": "País",
|
"country": "País",
|
||||||
|
"decrement_quotas": "Decrementar todos os limites das cotas incluindo esta resposta",
|
||||||
"delete_response_confirmation": "Isto irá apagar a resposta do inquérito, incluindo todas as respostas, etiquetas, documentos anexos e metadados da resposta.",
|
"delete_response_confirmation": "Isto irá apagar a resposta do inquérito, incluindo todas as respostas, etiquetas, documentos anexos e metadados da resposta.",
|
||||||
|
"delete_response_quotas": "A resposta faz parte das quotas deste inquérito. Como deseja gerir as quotas?",
|
||||||
"device": "Dispositivo",
|
"device": "Dispositivo",
|
||||||
"device_info": "Informações do dispositivo",
|
"device_info": "Informações do dispositivo",
|
||||||
"email": "Email",
|
"email": "Email",
|
||||||
@@ -1737,6 +1788,7 @@
|
|||||||
"configure_alerts": "Configurar alertas",
|
"configure_alerts": "Configurar alertas",
|
||||||
"congrats": "Parabéns! O seu inquérito está ativo.",
|
"congrats": "Parabéns! O seu inquérito está ativo.",
|
||||||
"connect_your_website_or_app_with_formbricks_to_get_started": "Ligue o seu website ou aplicação ao Formbricks para começar.",
|
"connect_your_website_or_app_with_formbricks_to_get_started": "Ligue o seu website ou aplicação ao Formbricks para começar.",
|
||||||
|
"current_count": "Contagem atual",
|
||||||
"custom_range": "Intervalo personalizado...",
|
"custom_range": "Intervalo personalizado...",
|
||||||
"delete_all_existing_responses_and_displays": "Excluir todas as respostas existentes e exibições",
|
"delete_all_existing_responses_and_displays": "Excluir todas as respostas existentes e exibições",
|
||||||
"download_qr_code": "Transferir código QR",
|
"download_qr_code": "Transferir código QR",
|
||||||
@@ -1790,6 +1842,7 @@
|
|||||||
"last_month": "Último mês",
|
"last_month": "Último mês",
|
||||||
"last_quarter": "Último trimestre",
|
"last_quarter": "Último trimestre",
|
||||||
"last_year": "Ano passado",
|
"last_year": "Ano passado",
|
||||||
|
"limit": "Limite",
|
||||||
"no_responses_found": "Nenhuma resposta encontrada",
|
"no_responses_found": "Nenhuma resposta encontrada",
|
||||||
"other_values_found": "Outros valores encontrados",
|
"other_values_found": "Outros valores encontrados",
|
||||||
"overall": "Geral",
|
"overall": "Geral",
|
||||||
@@ -1798,6 +1851,8 @@
|
|||||||
"qr_code_download_failed": "Falha ao transferir o código QR",
|
"qr_code_download_failed": "Falha ao transferir o código QR",
|
||||||
"qr_code_download_with_start_soon": "O download do código QR começará em breve",
|
"qr_code_download_with_start_soon": "O download do código QR começará em breve",
|
||||||
"qr_code_generation_failed": "Ocorreu um problema ao carregar o Código QR do questionário. Por favor, tente novamente.",
|
"qr_code_generation_failed": "Ocorreu um problema ao carregar o Código QR do questionário. Por favor, tente novamente.",
|
||||||
|
"quotas_completed": "Quotas concluídas",
|
||||||
|
"quotas_completed_tooltip": "O número de quotas concluídas pelos respondentes.",
|
||||||
"reset_survey": "Reiniciar inquérito",
|
"reset_survey": "Reiniciar inquérito",
|
||||||
"reset_survey_warning": "Repor um inquérito remove todas as respostas e visualizações associadas a este inquérito. Isto não pode ser desfeito.",
|
"reset_survey_warning": "Repor um inquérito remove todas as respostas e visualizações associadas a este inquérito. Isto não pode ser desfeito.",
|
||||||
"selected_responses_csv": "Respostas selecionadas (CSV)",
|
"selected_responses_csv": "Respostas selecionadas (CSV)",
|
||||||
@@ -2832,4 +2887,4 @@
|
|||||||
"usability_rating_description": "Meça a usabilidade percebida ao solicitar que os utilizadores avaliem a sua experiência com o seu produto usando um questionário padronizado de 10 perguntas.",
|
"usability_rating_description": "Meça a usabilidade percebida ao solicitar que os utilizadores avaliem a sua experiência com o seu produto usando um questionário padronizado de 10 perguntas.",
|
||||||
"usability_score_name": "Pontuação de Usabilidade do Sistema (SUS)"
|
"usability_score_name": "Pontuação de Usabilidade do Sistema (SUS)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -169,11 +169,14 @@
|
|||||||
"connect_formbricks": "Conectează Formbricks",
|
"connect_formbricks": "Conectează Formbricks",
|
||||||
"connected": "Conectat",
|
"connected": "Conectat",
|
||||||
"contacts": "Contacte",
|
"contacts": "Contacte",
|
||||||
|
"continue": "Continuă",
|
||||||
"copied": "Copiat",
|
"copied": "Copiat",
|
||||||
"copied_to_clipboard": "Copiat în clipboard",
|
"copied_to_clipboard": "Copiat în clipboard",
|
||||||
"copy": "Copiază",
|
"copy": "Copiază",
|
||||||
"copy_code": "Copiază codul",
|
"copy_code": "Copiază codul",
|
||||||
"copy_link": "Copiază legătura",
|
"copy_link": "Copiază legătura",
|
||||||
|
"count_contacts": "{value, plural, one {# contact} other {# contacte} }",
|
||||||
|
"count_responses": "{value, plural, one {# răspuns} other {# răspunsuri} }",
|
||||||
"create_new_organization": "Creează organizație nouă",
|
"create_new_organization": "Creează organizație nouă",
|
||||||
"create_project": "Creează proiect",
|
"create_project": "Creează proiect",
|
||||||
"create_segment": "Creați segment",
|
"create_segment": "Creați segment",
|
||||||
@@ -201,6 +204,7 @@
|
|||||||
"e_commerce": "Comerț electronic",
|
"e_commerce": "Comerț electronic",
|
||||||
"edit": "Editare",
|
"edit": "Editare",
|
||||||
"email": "Email",
|
"email": "Email",
|
||||||
|
"ending_card": "Cardul de finalizare",
|
||||||
"enterprise_license": "Licență Întreprindere",
|
"enterprise_license": "Licență Întreprindere",
|
||||||
"environment_not_found": "Mediul nu a fost găsit",
|
"environment_not_found": "Mediul nu a fost găsit",
|
||||||
"environment_notice": "Te afli în prezent în mediul {environment}",
|
"environment_notice": "Te afli în prezent în mediul {environment}",
|
||||||
@@ -269,6 +273,7 @@
|
|||||||
"no_background_image_found": "Nu a fost găsită nicio imagine de fundal.",
|
"no_background_image_found": "Nu a fost găsită nicio imagine de fundal.",
|
||||||
"no_code": "Fără Cod",
|
"no_code": "Fără Cod",
|
||||||
"no_files_uploaded": "Nu au fost încărcate fișiere",
|
"no_files_uploaded": "Nu au fost încărcate fișiere",
|
||||||
|
"no_quotas_found": "Nicio cotă găsită",
|
||||||
"no_result_found": "Niciun rezultat găsit",
|
"no_result_found": "Niciun rezultat găsit",
|
||||||
"no_results": "Nicio rezultat",
|
"no_results": "Nicio rezultat",
|
||||||
"no_surveys_found": "Nu au fost găsite sondaje.",
|
"no_surveys_found": "Nu au fost găsite sondaje.",
|
||||||
@@ -312,6 +317,7 @@
|
|||||||
"product_manager": "Manager de Produs",
|
"product_manager": "Manager de Produs",
|
||||||
"profile": "Profil",
|
"profile": "Profil",
|
||||||
"profile_id": "ID Profil",
|
"profile_id": "ID Profil",
|
||||||
|
"progress": "Progres",
|
||||||
"project_configuration": "Configurare proiect",
|
"project_configuration": "Configurare proiect",
|
||||||
"project_creation_description": "Organizați sondajele în proiecte pentru un control mai bun al accesului.",
|
"project_creation_description": "Organizați sondajele în proiecte pentru un control mai bun al accesului.",
|
||||||
"project_id": "ID proiect",
|
"project_id": "ID proiect",
|
||||||
@@ -323,6 +329,9 @@
|
|||||||
"question": "Întrebare",
|
"question": "Întrebare",
|
||||||
"question_id": "ID întrebare",
|
"question_id": "ID întrebare",
|
||||||
"questions": "Întrebări",
|
"questions": "Întrebări",
|
||||||
|
"quota": "Cotă",
|
||||||
|
"quotas": "Cote",
|
||||||
|
"quotas_description": "Limitați numărul de răspunsuri primite de la participanții care îndeplinesc anumite criterii.",
|
||||||
"read_docs": "Citește documentația",
|
"read_docs": "Citește documentația",
|
||||||
"recipients": "Destinatari",
|
"recipients": "Destinatari",
|
||||||
"remove": "Șterge",
|
"remove": "Șterge",
|
||||||
@@ -370,6 +379,7 @@
|
|||||||
"start_free_trial": "Începe perioada de testare gratuită",
|
"start_free_trial": "Începe perioada de testare gratuită",
|
||||||
"status": "Stare",
|
"status": "Stare",
|
||||||
"step_by_step_manual": "Manual pas cu pas",
|
"step_by_step_manual": "Manual pas cu pas",
|
||||||
|
"storage_not_configured": "Stocarea fișierelor neconfigurată, upload-urile vor eșua probabil",
|
||||||
"styling": "Stilizare",
|
"styling": "Stilizare",
|
||||||
"submit": "Trimite",
|
"submit": "Trimite",
|
||||||
"summary": "Sumar",
|
"summary": "Sumar",
|
||||||
@@ -579,6 +589,7 @@
|
|||||||
"contacts_table_refresh": "Reîmprospătare contacte",
|
"contacts_table_refresh": "Reîmprospătare contacte",
|
||||||
"contacts_table_refresh_success": "Contactele au fost actualizate cu succes",
|
"contacts_table_refresh_success": "Contactele au fost actualizate cu succes",
|
||||||
"delete_contact_confirmation": "Acest lucru va șterge toate răspunsurile la sondaj și atributele de contact asociate cu acest contact. Orice țintire și personalizare bazată pe datele acestui contact vor fi pierdute.",
|
"delete_contact_confirmation": "Acest lucru va șterge toate răspunsurile la sondaj și atributele de contact asociate cu acest contact. Orice țintire și personalizare bazată pe datele acestui contact vor fi pierdute.",
|
||||||
|
"delete_contact_confirmation_with_quotas": "{value, plural, one {Această acțiune va șterge toate răspunsurile chestionarului și atributele de contact asociate cu acest contact. Orice țintire și personalizare bazată pe datele acestui contact vor fi pierdute. Dacă acest contact are răspunsuri care contează pentru cotele chestionarului, numărul cotelor va fi redus, dar limitele cotelor vor rămâne neschimbate.} other {Aceste acțiuni vor șterge toate răspunsurile chestionarului și atributele de contact asociate cu acești contacți. Orice țintire și personalizare bazată pe datele acestor contacți vor fi pierdute. Dacă acești contacți au răspunsuri care contează pentru cotele chestionarului, numărul cotelor va fi redus, dar limitele cotelor vor rămâne neschimbate.} }",
|
||||||
"no_responses_found": "Nu s-au găsit răspunsuri",
|
"no_responses_found": "Nu s-au găsit răspunsuri",
|
||||||
"not_provided": "Nu a fost furnizat",
|
"not_provided": "Nu a fost furnizat",
|
||||||
"search_contact": "Căutați contact",
|
"search_contact": "Căutați contact",
|
||||||
@@ -1280,7 +1291,7 @@
|
|||||||
"columns": "Coloane",
|
"columns": "Coloane",
|
||||||
"company": "Companie",
|
"company": "Companie",
|
||||||
"company_logo": "Sigla companiei",
|
"company_logo": "Sigla companiei",
|
||||||
"completed_responses": "răspunsuri parțiale sau finalizate",
|
"completed_responses": "Răspunsuri completate",
|
||||||
"concat": "Concat +",
|
"concat": "Concat +",
|
||||||
"conditional_logic": "Logică condițională",
|
"conditional_logic": "Logică condițională",
|
||||||
"confirm_default_language": "Confirmați limba implicită",
|
"confirm_default_language": "Confirmați limba implicită",
|
||||||
@@ -1320,6 +1331,7 @@
|
|||||||
"end_screen_card": "Ecran final card",
|
"end_screen_card": "Ecran final card",
|
||||||
"ending_card": "Cardul de finalizare",
|
"ending_card": "Cardul de finalizare",
|
||||||
"ending_card_used_in_logic": "Această carte de încheiere este folosită în logica întrebării {questionIndex}.",
|
"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",
|
"ends_with": "Se termină cu",
|
||||||
"equals": "Egal",
|
"equals": "Egal",
|
||||||
"equals_one_of": "Egal unu dintre",
|
"equals_one_of": "Egal unu dintre",
|
||||||
@@ -1330,6 +1342,7 @@
|
|||||||
"fallback_for": "Varianta de rezervă pentru",
|
"fallback_for": "Varianta de rezervă pentru",
|
||||||
"fallback_missing": "Rezerva lipsă",
|
"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_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}\"",
|
||||||
"field_name_eg_score_price": "Nume câmp, de exemplu, scor, preț",
|
"field_name_eg_score_price": "Nume câmp, de exemplu, scor, preț",
|
||||||
"first_name": "Prenume",
|
"first_name": "Prenume",
|
||||||
"five_points_recommended": "5 puncte (recomandat)",
|
"five_points_recommended": "5 puncte (recomandat)",
|
||||||
@@ -1361,8 +1374,9 @@
|
|||||||
"follow_ups_modal_action_subject_placeholder": "Subiectul emailului",
|
"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_description": "Adresă de email către care se trimite emailul",
|
||||||
"follow_ups_modal_action_to_label": "Către",
|
"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_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_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_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 ",
|
"follow_ups_modal_name_label": "Numele ",
|
||||||
@@ -1372,8 +1386,9 @@
|
|||||||
"follow_ups_modal_trigger_label": "Declanșator",
|
"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": "Respondentul vede un sfârșit specific",
|
||||||
"follow_ups_modal_trigger_type_ending_select": "Selectează finalurile:",
|
"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_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_new": "Follow-up nou",
|
||||||
"follow_ups_upgrade_button_text": "Actualizați pentru a activa urmărările",
|
"follow_ups_upgrade_button_text": "Actualizați pentru a activa urmărările",
|
||||||
"form_styling": "Stilizare formular",
|
"form_styling": "Stilizare formular",
|
||||||
@@ -1474,6 +1489,38 @@
|
|||||||
"question_duplicated": "Întrebare duplicată.",
|
"question_duplicated": "Întrebare duplicată.",
|
||||||
"question_id_updated": "ID întrebare actualizat",
|
"question_id_updated": "ID întrebare actualizat",
|
||||||
"question_used_in_logic": "Această întrebare este folosită în logica întrebării {questionIndex}.",
|
"question_used_in_logic": "Această întrebare este folosită în logica întrebării {questionIndex}.",
|
||||||
|
"question_used_in_quota": "Întrebarea aceasta este folosită în cota \"{quotaName}\"",
|
||||||
|
"quotas": {
|
||||||
|
"add_quota": "Adăugați cotă",
|
||||||
|
"change_quota_for_public_survey": "Schimbați cota pentru sondaj public?",
|
||||||
|
"confirm_quota_changes": "Confirmă modificările cotelor",
|
||||||
|
"confirm_quota_changes_body": "Aveți modificări nesalvate în quota dumneavoastră. Doriți să le salvați înainte de a pleca?",
|
||||||
|
"continue_survey_normally": "Continuă chestionarul în mod normal",
|
||||||
|
"count_partial_submissions": "Număr contestații parțiale",
|
||||||
|
"count_partial_submissions_description": "Includeți respondenții care îndeplinesc criteriile de cotă dar nu au completat sondajul",
|
||||||
|
"create_quota_for_public_survey": "Creați cotă pentru sondaj public?",
|
||||||
|
"create_quota_for_public_survey_description": "Doar răspunsurile viitoare vor fi încorporate în cotă",
|
||||||
|
"create_quota_for_public_survey_text": "Acest sondaj este deja public. Răspunsurile actuale nu vor fi luate în considerare pentru noua cotă.",
|
||||||
|
"delete_quota_confirmation_text": "Acest lucru va șterge definitiv cota {quotaName}.",
|
||||||
|
"duplicate_quota": "Duplicare cotă",
|
||||||
|
"edit_quota": "Editează cota",
|
||||||
|
"end_survey_for_matching_participants": "Încheiere sondaj pentru participanții eligibili",
|
||||||
|
"inclusion_criteria": "Criterii de includere",
|
||||||
|
"limit_must_be_greater_than_or_equal_to_the_number_of_responses": "{value, plural, one {Deja aveți {value} răspuns pentru această cotă, astfel încât limita trebuie să fie mai mare decât {value}.} other {Deja aveți {value} răspunsuri pentru această cotă, astfel încât limita trebuie să fie mai mare decât {value}.} }",
|
||||||
|
"limited_to_x_responses": "Limitat la {limit}",
|
||||||
|
"new_quota": "Contingent Nou",
|
||||||
|
"quota_created_successfull_toast": "\"Cota creată cu succes!\"",
|
||||||
|
"quota_deleted_successfull_toast": "\"Cota ștearsă cu succes!\"",
|
||||||
|
"quota_duplicated_successfull_toast": "\"Cota duplicată cu succes!\"",
|
||||||
|
"quota_name_placeholder": "de exemplu, Participanți cu vârsta 18-25 ani",
|
||||||
|
"quota_updated_successfull_toast": "\"Cota actualizată cu succes!\"",
|
||||||
|
"response_limit": "Limitări",
|
||||||
|
"save_changes_confirmation_body": "Orice modificări ale criteriilor de includere afectează doar răspunsurile viitoare. \nRecomandăm fie să duplicați un existent, fie să creați o nouă cotă.",
|
||||||
|
"save_changes_confirmation_text": "Răspunsurile existente rămân în cotă",
|
||||||
|
"select_ending_card": "Selectează cardul de finalizare",
|
||||||
|
"upgrade_prompt_title": "Folosește cote cu un plan superior",
|
||||||
|
"when_quota_has_been_reached": "Când cota a fost atinsă"
|
||||||
|
},
|
||||||
"randomize_all": "Randomizează tot",
|
"randomize_all": "Randomizează tot",
|
||||||
"randomize_all_except_last": "Randomizează tot cu excepția ultimului",
|
"randomize_all_except_last": "Randomizează tot cu excepția ultimului",
|
||||||
"range": "Interval",
|
"range": "Interval",
|
||||||
@@ -1567,6 +1614,7 @@
|
|||||||
"url_not_supported": "URL nesuportat",
|
"url_not_supported": "URL nesuportat",
|
||||||
"use_with_caution": "Folosește cu precauție",
|
"use_with_caution": "Folosește cu precauție",
|
||||||
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} este folosit în logica întrebării {questionIndex}. Vă rugăm să-l eliminați din logică mai întâi.",
|
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} este folosit în logica întrebării {questionIndex}. Vă rugăm să-l eliminați din logică mai întâi.",
|
||||||
|
"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_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_name_must_start_with_a_letter": "Numele variabilei trebuie să înceapă cu o literă.",
|
||||||
"verify_email_before_submission": "Verifică emailul înainte de trimitere",
|
"verify_email_before_submission": "Verifică emailul înainte de trimitere",
|
||||||
@@ -1601,11 +1649,14 @@
|
|||||||
"address_line_2": "Adresă Linie 2",
|
"address_line_2": "Adresă Linie 2",
|
||||||
"an_error_occurred_deleting_the_tag": "A apărut o eroare la ștergerea etichetei",
|
"an_error_occurred_deleting_the_tag": "A apărut o eroare la ștergerea etichetei",
|
||||||
"browser": "Browser",
|
"browser": "Browser",
|
||||||
|
"bulk_delete_response_quotas": "Răspunsurile fac parte din cotele pentru acest sondaj. Cum doriți să gestionați cotele?",
|
||||||
"city": "Oraș",
|
"city": "Oraș",
|
||||||
"company": "Companie",
|
"company": "Companie",
|
||||||
"completed": "Finalizat ✅",
|
"completed": "Finalizat ✅",
|
||||||
"country": "Țară",
|
"country": "Țară",
|
||||||
|
"decrement_quotas": "Decrementați toate limitele cotelor, inclusiv acest răspuns",
|
||||||
"delete_response_confirmation": "Aceasta va șterge răspunsul la sondaj, inclusiv toate răspunsurile, etichetele, documentele atașate și metadatele răspunsului.",
|
"delete_response_confirmation": "Aceasta va șterge răspunsul la sondaj, inclusiv toate răspunsurile, etichetele, documentele atașate și metadatele răspunsului.",
|
||||||
|
"delete_response_quotas": "Răspunsul face parte din cotele pentru acest sondaj. Cum doriți să gestionați cotele?",
|
||||||
"device": "Dispozitiv",
|
"device": "Dispozitiv",
|
||||||
"device_info": "Informații despre dispozitiv",
|
"device_info": "Informații despre dispozitiv",
|
||||||
"email": "Email",
|
"email": "Email",
|
||||||
@@ -1737,6 +1788,7 @@
|
|||||||
"configure_alerts": "Configurează alertele",
|
"configure_alerts": "Configurează alertele",
|
||||||
"congrats": "Felicitări! Sondajul dumneavoastră este activ.",
|
"congrats": "Felicitări! Sondajul dumneavoastră este activ.",
|
||||||
"connect_your_website_or_app_with_formbricks_to_get_started": "Conectează-ți site-ul sau aplicația cu Formbricks pentru a începe.",
|
"connect_your_website_or_app_with_formbricks_to_get_started": "Conectează-ți site-ul sau aplicația cu Formbricks pentru a începe.",
|
||||||
|
"current_count": "Număr curent",
|
||||||
"custom_range": "Interval personalizat...",
|
"custom_range": "Interval personalizat...",
|
||||||
"delete_all_existing_responses_and_displays": "Șterge toate răspunsurile și afișările existente",
|
"delete_all_existing_responses_and_displays": "Șterge toate răspunsurile și afișările existente",
|
||||||
"download_qr_code": "Descărcare cod QR",
|
"download_qr_code": "Descărcare cod QR",
|
||||||
@@ -1790,6 +1842,7 @@
|
|||||||
"last_month": "Ultima lună",
|
"last_month": "Ultima lună",
|
||||||
"last_quarter": "Ultimul trimestru",
|
"last_quarter": "Ultimul trimestru",
|
||||||
"last_year": "Anul trecut",
|
"last_year": "Anul trecut",
|
||||||
|
"limit": "Limită",
|
||||||
"no_responses_found": "Nu s-au găsit răspunsuri",
|
"no_responses_found": "Nu s-au găsit răspunsuri",
|
||||||
"other_values_found": "Alte valori găsite",
|
"other_values_found": "Alte valori găsite",
|
||||||
"overall": "General",
|
"overall": "General",
|
||||||
@@ -1798,6 +1851,8 @@
|
|||||||
"qr_code_download_failed": "Descărcarea codului QR a eșuat",
|
"qr_code_download_failed": "Descărcarea codului QR a eșuat",
|
||||||
"qr_code_download_with_start_soon": "Descărcarea codului QR va începe în curând",
|
"qr_code_download_with_start_soon": "Descărcarea codului QR va începe în curând",
|
||||||
"qr_code_generation_failed": "A apărut o problemă la încărcarea codului QR al chestionarului. Vă rugăm să încercați din nou.",
|
"qr_code_generation_failed": "A apărut o problemă la încărcarea codului QR al chestionarului. Vă rugăm să încercați din nou.",
|
||||||
|
"quotas_completed": "Cote completate",
|
||||||
|
"quotas_completed_tooltip": "Numărul de cote completate de respondenți.",
|
||||||
"reset_survey": "Resetează chestionarul",
|
"reset_survey": "Resetează chestionarul",
|
||||||
"reset_survey_warning": "Resetarea unui sondaj elimină toate răspunsurile și afișajele asociate cu acest sondaj. Aceasta nu poate fi anulată.",
|
"reset_survey_warning": "Resetarea unui sondaj elimină toate răspunsurile și afișajele asociate cu acest sondaj. Aceasta nu poate fi anulată.",
|
||||||
"selected_responses_csv": "Răspunsuri selectate (CSV)",
|
"selected_responses_csv": "Răspunsuri selectate (CSV)",
|
||||||
@@ -2832,4 +2887,4 @@
|
|||||||
"usability_rating_description": "Măsurați uzabilitatea percepută cerând utilizatorilor să își evalueze experiența cu produsul dumneavoastră folosind un chestionar standardizat din 10 întrebări.",
|
"usability_rating_description": "Măsurați uzabilitatea percepută cerând utilizatorilor să își evalueze experiența cu produsul dumneavoastră folosind un chestionar standardizat din 10 întrebări.",
|
||||||
"usability_score_name": "Scor de Uzabilitate al Sistemului (SUS)"
|
"usability_score_name": "Scor de Uzabilitate al Sistemului (SUS)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -169,11 +169,14 @@
|
|||||||
"connect_formbricks": "连接 Formbricks",
|
"connect_formbricks": "连接 Formbricks",
|
||||||
"connected": "已连接",
|
"connected": "已连接",
|
||||||
"contacts": "联系人",
|
"contacts": "联系人",
|
||||||
|
"continue": "继续",
|
||||||
"copied": "已复制",
|
"copied": "已复制",
|
||||||
"copied_to_clipboard": "已 复制到 剪贴板",
|
"copied_to_clipboard": "已 复制到 剪贴板",
|
||||||
"copy": "复制",
|
"copy": "复制",
|
||||||
"copy_code": "复制 代码",
|
"copy_code": "复制 代码",
|
||||||
"copy_link": "复制 链接",
|
"copy_link": "复制 链接",
|
||||||
|
"count_contacts": "{value, plural, other {{value} 联系人} }",
|
||||||
|
"count_responses": "{value, plural, other {{value} 回复} }",
|
||||||
"create_new_organization": "创建 新的 组织",
|
"create_new_organization": "创建 新的 组织",
|
||||||
"create_project": "创建 项目",
|
"create_project": "创建 项目",
|
||||||
"create_segment": "创建 细分",
|
"create_segment": "创建 细分",
|
||||||
@@ -201,6 +204,7 @@
|
|||||||
"e_commerce": "电子商务",
|
"e_commerce": "电子商务",
|
||||||
"edit": "编辑",
|
"edit": "编辑",
|
||||||
"email": "邮箱",
|
"email": "邮箱",
|
||||||
|
"ending_card": "结尾卡片",
|
||||||
"enterprise_license": "企业 许可证",
|
"enterprise_license": "企业 许可证",
|
||||||
"environment_not_found": "环境 未找到",
|
"environment_not_found": "环境 未找到",
|
||||||
"environment_notice": "你 目前 位于 {environment} 环境。",
|
"environment_notice": "你 目前 位于 {environment} 环境。",
|
||||||
@@ -269,6 +273,7 @@
|
|||||||
"no_background_image_found": "未找到 背景 图片。",
|
"no_background_image_found": "未找到 背景 图片。",
|
||||||
"no_code": "无代码",
|
"no_code": "无代码",
|
||||||
"no_files_uploaded": "没有 文件 被 上传",
|
"no_files_uploaded": "没有 文件 被 上传",
|
||||||
|
"no_quotas_found": "未找到配额",
|
||||||
"no_result_found": "没有 结果",
|
"no_result_found": "没有 结果",
|
||||||
"no_results": "没有 结果",
|
"no_results": "没有 结果",
|
||||||
"no_surveys_found": "未找到 调查",
|
"no_surveys_found": "未找到 调查",
|
||||||
@@ -312,6 +317,7 @@
|
|||||||
"product_manager": "产品经理",
|
"product_manager": "产品经理",
|
||||||
"profile": "资料",
|
"profile": "资料",
|
||||||
"profile_id": "资料 ID",
|
"profile_id": "资料 ID",
|
||||||
|
"progress": "进度",
|
||||||
"project_configuration": "项目 配置",
|
"project_configuration": "项目 配置",
|
||||||
"project_creation_description": "将 调查 组织 在 项目 中 以 便于 更好 的 访问 控制。",
|
"project_creation_description": "将 调查 组织 在 项目 中 以 便于 更好 的 访问 控制。",
|
||||||
"project_id": "项目 ID",
|
"project_id": "项目 ID",
|
||||||
@@ -323,6 +329,9 @@
|
|||||||
"question": "问题",
|
"question": "问题",
|
||||||
"question_id": "问题 ID",
|
"question_id": "问题 ID",
|
||||||
"questions": "问题",
|
"questions": "问题",
|
||||||
|
"quota": "配额",
|
||||||
|
"quotas": "配额",
|
||||||
|
"quotas_description": "限制 符合 特定 条件 的 参与者 的 响应 数量 。",
|
||||||
"read_docs": "阅读 文档",
|
"read_docs": "阅读 文档",
|
||||||
"recipients": "收件人",
|
"recipients": "收件人",
|
||||||
"remove": "移除",
|
"remove": "移除",
|
||||||
@@ -370,6 +379,7 @@
|
|||||||
"start_free_trial": "开始 免费试用",
|
"start_free_trial": "开始 免费试用",
|
||||||
"status": "状态",
|
"status": "状态",
|
||||||
"step_by_step_manual": "分步 手册",
|
"step_by_step_manual": "分步 手册",
|
||||||
|
"storage_not_configured": "文件存储 未设置,上传 可能 失败",
|
||||||
"styling": "样式",
|
"styling": "样式",
|
||||||
"submit": "提交",
|
"submit": "提交",
|
||||||
"summary": "概要",
|
"summary": "概要",
|
||||||
@@ -579,6 +589,7 @@
|
|||||||
"contacts_table_refresh": "刷新 联系人",
|
"contacts_table_refresh": "刷新 联系人",
|
||||||
"contacts_table_refresh_success": "联系人 已成功刷新",
|
"contacts_table_refresh_success": "联系人 已成功刷新",
|
||||||
"delete_contact_confirmation": "这将删除与此联系人相关的所有调查问卷回复和联系人属性。基于此联系人数据的任何定位和个性化将会丢失。",
|
"delete_contact_confirmation": "这将删除与此联系人相关的所有调查问卷回复和联系人属性。基于此联系人数据的任何定位和个性化将会丢失。",
|
||||||
|
"delete_contact_confirmation_with_quotas": "{value, plural, one {这将删除与此联系人相关的所有调查回复和联系人属性。基于此联系人数据的任何定位和个性化将丢失。如果此联系人有影响调查配额的回复,配额数量将减少,但配额限制将保持不变。} other {这将删除与这些联系人相关的所有调查回复和联系人属性。基于这些联系人数据的任何定位和个性化将丢失。如果这些联系人有影响调查配额的回复,配额数量将减少,但配额限制将保持不变。}}",
|
||||||
"no_responses_found": "未找到 响应",
|
"no_responses_found": "未找到 响应",
|
||||||
"not_provided": "未提供",
|
"not_provided": "未提供",
|
||||||
"search_contact": "搜索 联系人",
|
"search_contact": "搜索 联系人",
|
||||||
@@ -1280,7 +1291,7 @@
|
|||||||
"columns": "列",
|
"columns": "列",
|
||||||
"company": "公司",
|
"company": "公司",
|
||||||
"company_logo": "公司 徽标",
|
"company_logo": "公司 徽标",
|
||||||
"completed_responses": "部分 或 完成 的 反馈",
|
"completed_responses": "完成反馈。",
|
||||||
"concat": "拼接 +",
|
"concat": "拼接 +",
|
||||||
"conditional_logic": "条件逻辑",
|
"conditional_logic": "条件逻辑",
|
||||||
"confirm_default_language": "确认 默认 语言",
|
"confirm_default_language": "确认 默认 语言",
|
||||||
@@ -1320,6 +1331,7 @@
|
|||||||
"end_screen_card": "结束 屏幕 卡片",
|
"end_screen_card": "结束 屏幕 卡片",
|
||||||
"ending_card": "结尾卡片",
|
"ending_card": "结尾卡片",
|
||||||
"ending_card_used_in_logic": "\"这个 结束卡片 在 问题 {questionIndex} 的 逻辑 中 使用。\"",
|
"ending_card_used_in_logic": "\"这个 结束卡片 在 问题 {questionIndex} 的 逻辑 中 使用。\"",
|
||||||
|
"ending_used_in_quota": "此 结尾 正在 被 \"{quotaName}\" 配额 使用",
|
||||||
"ends_with": "以...结束",
|
"ends_with": "以...结束",
|
||||||
"equals": "等于",
|
"equals": "等于",
|
||||||
"equals_one_of": "等于 其中 一个",
|
"equals_one_of": "等于 其中 一个",
|
||||||
@@ -1330,6 +1342,7 @@
|
|||||||
"fallback_for": "后备 用于",
|
"fallback_for": "后备 用于",
|
||||||
"fallback_missing": "备用 缺失",
|
"fallback_missing": "备用 缺失",
|
||||||
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "\"{fieldId} 在 问题 {questionIndex} 的 逻辑 中 使用。请 先 从 逻辑 中 删除 它。\"",
|
"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}\" 配额 使用",
|
||||||
"field_name_eg_score_price": "字段 名称 例如 评分 ,价格",
|
"field_name_eg_score_price": "字段 名称 例如 评分 ,价格",
|
||||||
"first_name": "名字",
|
"first_name": "名字",
|
||||||
"five_points_recommended": "5 点 (推荐)",
|
"five_points_recommended": "5 点 (推荐)",
|
||||||
@@ -1361,8 +1374,9 @@
|
|||||||
"follow_ups_modal_action_subject_placeholder": "电子邮件主题",
|
"follow_ups_modal_action_subject_placeholder": "电子邮件主题",
|
||||||
"follow_ups_modal_action_to_description": "发送邮件的电子邮箱地址",
|
"follow_ups_modal_action_to_description": "发送邮件的电子邮箱地址",
|
||||||
"follow_ups_modal_action_to_label": "到",
|
"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_create_heading": "创建一个新的跟进",
|
||||||
|
"follow_ups_modal_created_successfull_toast": "后续 操作 已 创建, 并且 在 你 保存 调查 后 将 被 保存。",
|
||||||
"follow_ups_modal_edit_heading": "编辑此跟进",
|
"follow_ups_modal_edit_heading": "编辑此跟进",
|
||||||
"follow_ups_modal_edit_no_id": "未 提供 调查 跟进 id ,无法 更新 调查 跟进",
|
"follow_ups_modal_edit_no_id": "未 提供 调查 跟进 id ,无法 更新 调查 跟进",
|
||||||
"follow_ups_modal_name_label": "跟进 名称",
|
"follow_ups_modal_name_label": "跟进 名称",
|
||||||
@@ -1372,8 +1386,9 @@
|
|||||||
"follow_ups_modal_trigger_label": "触发",
|
"follow_ups_modal_trigger_label": "触发",
|
||||||
"follow_ups_modal_trigger_type_ending": "受访者 看到 一个 特定 的 结尾",
|
"follow_ups_modal_trigger_type_ending": "受访者 看到 一个 特定 的 结尾",
|
||||||
"follow_ups_modal_trigger_type_ending_select": "选择结尾:",
|
"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_trigger_type_response": "受访者 完成 调查",
|
||||||
|
"follow_ups_modal_updated_successfull_toast": "后续 操作 已 更新, 并且 在 你 保存 调查 后 将 被 保存。",
|
||||||
"follow_ups_new": "新的跟进",
|
"follow_ups_new": "新的跟进",
|
||||||
"follow_ups_upgrade_button_text": "升级 以启用 跟进",
|
"follow_ups_upgrade_button_text": "升级 以启用 跟进",
|
||||||
"form_styling": "表单 样式",
|
"form_styling": "表单 样式",
|
||||||
@@ -1474,6 +1489,38 @@
|
|||||||
"question_duplicated": "问题重复。",
|
"question_duplicated": "问题重复。",
|
||||||
"question_id_updated": "问题 ID 更新",
|
"question_id_updated": "问题 ID 更新",
|
||||||
"question_used_in_logic": "\"这个 问题 在 问题 {questionIndex} 的 逻辑 中 使用。\"",
|
"question_used_in_logic": "\"这个 问题 在 问题 {questionIndex} 的 逻辑 中 使用。\"",
|
||||||
|
"question_used_in_quota": "此 问题 正在 被 \"{quotaName}\" 配额 使用",
|
||||||
|
"quotas": {
|
||||||
|
"add_quota": "添加 配额",
|
||||||
|
"change_quota_for_public_survey": "更改 公共调查 的配额?",
|
||||||
|
"confirm_quota_changes": "确认配额变更",
|
||||||
|
"confirm_quota_changes_body": "您在配额中有未保存的更改。离开前是否要保存?",
|
||||||
|
"continue_survey_normally": "正常 继续 调查",
|
||||||
|
"count_partial_submissions": "统计 部分 提交",
|
||||||
|
"count_partial_submissions_description": "包含 符合 配额 标准 但 未 完成 调查 的 受访者",
|
||||||
|
"create_quota_for_public_survey": "为公共调查 创建 配额?",
|
||||||
|
"create_quota_for_public_survey_description": "只有未来的答案将纳入配额",
|
||||||
|
"create_quota_for_public_survey_text": "此 调查 已经 是 公开 的 。现有 的 回复 将 不 会 考虑 在 新 配额 中 。",
|
||||||
|
"delete_quota_confirmation_text": "这将永久删除配额 {quotaName}。",
|
||||||
|
"duplicate_quota": "复制 配额",
|
||||||
|
"edit_quota": "编辑 配额",
|
||||||
|
"end_survey_for_matching_participants": "为 符合 条件 的 参与者 结束 调查",
|
||||||
|
"inclusion_criteria": "纳入标准",
|
||||||
|
"limit_must_be_greater_than_or_equal_to_the_number_of_responses": "{value, plural, other {你已为此配额收到 {value} 个回复, 所以限额必须大于 {value}.} }",
|
||||||
|
"limited_to_x_responses": "限制 为 {limit}",
|
||||||
|
"new_quota": "新 配额",
|
||||||
|
"quota_created_successfull_toast": "配额 创建 成功",
|
||||||
|
"quota_deleted_successfull_toast": "配额 删除 成功",
|
||||||
|
"quota_duplicated_successfull_toast": "配额 复制 成功",
|
||||||
|
"quota_name_placeholder": "例如, 年龄 18-25 岁 参与者",
|
||||||
|
"quota_updated_successfull_toast": "配额 更新 成功",
|
||||||
|
"response_limit": "限额",
|
||||||
|
"save_changes_confirmation_body": "任何 对 包含 条件 的 更改 仅 影响 将来 的 响应。\n我们 建议 复制 一个 现有 的 或 创建 一个 新 的 配额。",
|
||||||
|
"save_changes_confirmation_text": "现有 的 响应 保留 在 配额 中",
|
||||||
|
"select_ending_card": "选择结尾卡片",
|
||||||
|
"upgrade_prompt_title": "在更高的计划中使用配额",
|
||||||
|
"when_quota_has_been_reached": "达到 配额 时"
|
||||||
|
},
|
||||||
"randomize_all": "随机排列",
|
"randomize_all": "随机排列",
|
||||||
"randomize_all_except_last": "随机排列,最后一个除外",
|
"randomize_all_except_last": "随机排列,最后一个除外",
|
||||||
"range": "范围",
|
"range": "范围",
|
||||||
@@ -1567,6 +1614,7 @@
|
|||||||
"url_not_supported": "URL 不支持",
|
"url_not_supported": "URL 不支持",
|
||||||
"use_with_caution": "谨慎 使用",
|
"use_with_caution": "谨慎 使用",
|
||||||
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "\"{variable} 在 问题 {questionIndex} 的 逻辑 中 使用。请 先 从 逻辑 中 删除 它。\"",
|
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "\"{variable} 在 问题 {questionIndex} 的 逻辑 中 使用。请 先 从 逻辑 中 删除 它。\"",
|
||||||
|
"variable_is_used_in_quota_please_remove_it_from_quota_first": "变量 \"{variableName}\" 正在 被 \"{quotaName}\" 配额 使用",
|
||||||
"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": "变量名 必须 以字母开头。",
|
||||||
"verify_email_before_submission": "提交 之前 验证电子邮件",
|
"verify_email_before_submission": "提交 之前 验证电子邮件",
|
||||||
@@ -1601,11 +1649,14 @@
|
|||||||
"address_line_2": "地址 第2行",
|
"address_line_2": "地址 第2行",
|
||||||
"an_error_occurred_deleting_the_tag": "删除 标签 时发生错误",
|
"an_error_occurred_deleting_the_tag": "删除 标签 时发生错误",
|
||||||
"browser": "浏览器",
|
"browser": "浏览器",
|
||||||
|
"bulk_delete_response_quotas": "这些 响应是 此 调查配额 的一部分。 您 希望 如何 处理 这些 配额?",
|
||||||
"city": "城市",
|
"city": "城市",
|
||||||
"company": "公司",
|
"company": "公司",
|
||||||
"completed": "完成 ✅",
|
"completed": "完成 ✅",
|
||||||
"country": "国家",
|
"country": "国家",
|
||||||
|
"decrement_quotas": "减少所有配额限制,包括此回应",
|
||||||
"delete_response_confirmation": "这 将 删除 调查 回应, 包括 所有 答案、 标签、 附件文档 和 回应元数据。",
|
"delete_response_confirmation": "这 将 删除 调查 回应, 包括 所有 答案、 标签、 附件文档 和 回应元数据。",
|
||||||
|
"delete_response_quotas": "该响应是 此 调查配额 的一部分。 您 希望 如何 处理 这些 配额?",
|
||||||
"device": "设备",
|
"device": "设备",
|
||||||
"device_info": "设备信息",
|
"device_info": "设备信息",
|
||||||
"email": "邮件",
|
"email": "邮件",
|
||||||
@@ -1737,6 +1788,7 @@
|
|||||||
"configure_alerts": "配置 警报",
|
"configure_alerts": "配置 警报",
|
||||||
"congrats": "恭喜!您的调查已上线。",
|
"congrats": "恭喜!您的调查已上线。",
|
||||||
"connect_your_website_or_app_with_formbricks_to_get_started": "将您 的网站 或应用 与 Formbricks 连接 , 以开始 使用。",
|
"connect_your_website_or_app_with_formbricks_to_get_started": "将您 的网站 或应用 与 Formbricks 连接 , 以开始 使用。",
|
||||||
|
"current_count": "当前数量",
|
||||||
"custom_range": "自定义 范围...",
|
"custom_range": "自定义 范围...",
|
||||||
"delete_all_existing_responses_and_displays": "删除 所有 现有 的 回复 和 显示",
|
"delete_all_existing_responses_and_displays": "删除 所有 现有 的 回复 和 显示",
|
||||||
"download_qr_code": "下载 二维码",
|
"download_qr_code": "下载 二维码",
|
||||||
@@ -1790,6 +1842,7 @@
|
|||||||
"last_month": "上个月",
|
"last_month": "上个月",
|
||||||
"last_quarter": "上季度",
|
"last_quarter": "上季度",
|
||||||
"last_year": "去年",
|
"last_year": "去年",
|
||||||
|
"limit": "限额",
|
||||||
"no_responses_found": "未找到响应",
|
"no_responses_found": "未找到响应",
|
||||||
"other_values_found": "找到其他值",
|
"other_values_found": "找到其他值",
|
||||||
"overall": "整体",
|
"overall": "整体",
|
||||||
@@ -1798,6 +1851,8 @@
|
|||||||
"qr_code_download_failed": "二维码下载失败",
|
"qr_code_download_failed": "二维码下载失败",
|
||||||
"qr_code_download_with_start_soon": "二维码下载将很快开始",
|
"qr_code_download_with_start_soon": "二维码下载将很快开始",
|
||||||
"qr_code_generation_failed": "加载 调查 QR 码 时出现问题。 请重试。",
|
"qr_code_generation_failed": "加载 调查 QR 码 时出现问题。 请重试。",
|
||||||
|
"quotas_completed": "配额完成",
|
||||||
|
"quotas_completed_tooltip": "受访者完成的配额数量。",
|
||||||
"reset_survey": "重置 调查",
|
"reset_survey": "重置 调查",
|
||||||
"reset_survey_warning": "重置 一个调查 会移除与 此调查 相关 的 所有响应 和 展示 。此操作 不能 撤销 。",
|
"reset_survey_warning": "重置 一个调查 会移除与 此调查 相关 的 所有响应 和 展示 。此操作 不能 撤销 。",
|
||||||
"selected_responses_csv": "选定 反馈 (CSV)",
|
"selected_responses_csv": "选定 反馈 (CSV)",
|
||||||
@@ -2832,4 +2887,4 @@
|
|||||||
"usability_rating_description": "通过要求用户使用标准化的 10 问 调查 来 评价 他们对您产品的体验,以 测量 感知 的 可用性。",
|
"usability_rating_description": "通过要求用户使用标准化的 10 问 调查 来 评价 他们对您产品的体验,以 测量 感知 的 可用性。",
|
||||||
"usability_score_name": "系统 可用性 得分 ( SUS )"
|
"usability_score_name": "系统 可用性 得分 ( SUS )"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -169,11 +169,14 @@
|
|||||||
"connect_formbricks": "連線 Formbricks",
|
"connect_formbricks": "連線 Formbricks",
|
||||||
"connected": "已連線",
|
"connected": "已連線",
|
||||||
"contacts": "聯絡人",
|
"contacts": "聯絡人",
|
||||||
|
"continue": "繼續",
|
||||||
"copied": "已 複製",
|
"copied": "已 複製",
|
||||||
"copied_to_clipboard": "已複製到剪貼簿",
|
"copied_to_clipboard": "已複製到剪貼簿",
|
||||||
"copy": "複製",
|
"copy": "複製",
|
||||||
"copy_code": "複製程式碼",
|
"copy_code": "複製程式碼",
|
||||||
"copy_link": "複製連結",
|
"copy_link": "複製連結",
|
||||||
|
"count_contacts": "{value, plural, other {{value} 聯絡人} }",
|
||||||
|
"count_responses": "{value, plural, other {{value} 回應} }",
|
||||||
"create_new_organization": "建立新組織",
|
"create_new_organization": "建立新組織",
|
||||||
"create_project": "建立專案",
|
"create_project": "建立專案",
|
||||||
"create_segment": "建立區隔",
|
"create_segment": "建立區隔",
|
||||||
@@ -201,6 +204,7 @@
|
|||||||
"e_commerce": "電子商務",
|
"e_commerce": "電子商務",
|
||||||
"edit": "編輯",
|
"edit": "編輯",
|
||||||
"email": "電子郵件",
|
"email": "電子郵件",
|
||||||
|
"ending_card": "結尾卡片",
|
||||||
"enterprise_license": "企業授權",
|
"enterprise_license": "企業授權",
|
||||||
"environment_not_found": "找不到環境",
|
"environment_not_found": "找不到環境",
|
||||||
"environment_notice": "您目前在 '{'environment'}' 環境中。",
|
"environment_notice": "您目前在 '{'environment'}' 環境中。",
|
||||||
@@ -269,6 +273,7 @@
|
|||||||
"no_background_image_found": "找不到背景圖片。",
|
"no_background_image_found": "找不到背景圖片。",
|
||||||
"no_code": "無程式碼",
|
"no_code": "無程式碼",
|
||||||
"no_files_uploaded": "沒有上傳任何檔案",
|
"no_files_uploaded": "沒有上傳任何檔案",
|
||||||
|
"no_quotas_found": "找不到 配額",
|
||||||
"no_result_found": "找不到結果",
|
"no_result_found": "找不到結果",
|
||||||
"no_results": "沒有結果",
|
"no_results": "沒有結果",
|
||||||
"no_surveys_found": "找不到問卷。",
|
"no_surveys_found": "找不到問卷。",
|
||||||
@@ -312,6 +317,7 @@
|
|||||||
"product_manager": "產品經理",
|
"product_manager": "產品經理",
|
||||||
"profile": "個人資料",
|
"profile": "個人資料",
|
||||||
"profile_id": "個人資料 ID",
|
"profile_id": "個人資料 ID",
|
||||||
|
"progress": "進度",
|
||||||
"project_configuration": "專案組態",
|
"project_configuration": "專案組態",
|
||||||
"project_creation_description": "組織調查 在 專案中以便更好地存取控制。",
|
"project_creation_description": "組織調查 在 專案中以便更好地存取控制。",
|
||||||
"project_id": "專案 ID",
|
"project_id": "專案 ID",
|
||||||
@@ -323,6 +329,9 @@
|
|||||||
"question": "問題",
|
"question": "問題",
|
||||||
"question_id": "問題 ID",
|
"question_id": "問題 ID",
|
||||||
"questions": "問題",
|
"questions": "問題",
|
||||||
|
"quota": "配額",
|
||||||
|
"quotas": "額度",
|
||||||
|
"quotas_description": "限制 擁有 特定 條件 的 參與者 所 提供 的 回應 數量。",
|
||||||
"read_docs": "閱讀文件",
|
"read_docs": "閱讀文件",
|
||||||
"recipients": "收件者",
|
"recipients": "收件者",
|
||||||
"remove": "移除",
|
"remove": "移除",
|
||||||
@@ -370,6 +379,7 @@
|
|||||||
"start_free_trial": "開始免費試用",
|
"start_free_trial": "開始免費試用",
|
||||||
"status": "狀態",
|
"status": "狀態",
|
||||||
"step_by_step_manual": "逐步手冊",
|
"step_by_step_manual": "逐步手冊",
|
||||||
|
"storage_not_configured": "檔案儲存未設定,上傳可能會失敗",
|
||||||
"styling": "樣式設定",
|
"styling": "樣式設定",
|
||||||
"submit": "提交",
|
"submit": "提交",
|
||||||
"summary": "摘要",
|
"summary": "摘要",
|
||||||
@@ -579,6 +589,7 @@
|
|||||||
"contacts_table_refresh": "重新整理聯絡人",
|
"contacts_table_refresh": "重新整理聯絡人",
|
||||||
"contacts_table_refresh_success": "聯絡人已成功重新整理",
|
"contacts_table_refresh_success": "聯絡人已成功重新整理",
|
||||||
"delete_contact_confirmation": "這將刪除與此聯繫人相關的所有調查回應和聯繫屬性。任何基於此聯繫人數據的定位和個性化將會丟失。",
|
"delete_contact_confirmation": "這將刪除與此聯繫人相關的所有調查回應和聯繫屬性。任何基於此聯繫人數據的定位和個性化將會丟失。",
|
||||||
|
"delete_contact_confirmation_with_quotas": "{value, plural, one {這將刪除與這個 contact 相關的所有調查響應和聯繫人屬性。基於這個 contact 數據的任何定向和個性化功能將會丟失。如果這個 contact 有作為調查配額依據的響應,配額計數將會減少,但配額限制將保持不變。} other {這將刪除與這些 contacts 相關的所有調查響應和聯繫人屬性。基於這些 contacts 數據的任何定向和個性化功能將會丟失。如果這些 contacts 有作為調查配額依據的響應,配額計數將會減少,但配額限制將保持不變。}}",
|
||||||
"no_responses_found": "找不到回應",
|
"no_responses_found": "找不到回應",
|
||||||
"not_provided": "未提供",
|
"not_provided": "未提供",
|
||||||
"search_contact": "搜尋聯絡人",
|
"search_contact": "搜尋聯絡人",
|
||||||
@@ -1280,7 +1291,7 @@
|
|||||||
"columns": "欄位",
|
"columns": "欄位",
|
||||||
"company": "公司",
|
"company": "公司",
|
||||||
"company_logo": "公司標誌",
|
"company_logo": "公司標誌",
|
||||||
"completed_responses": "部分或完整答复。",
|
"completed_responses": "完成 回應",
|
||||||
"concat": "串連 +",
|
"concat": "串連 +",
|
||||||
"conditional_logic": "條件邏輯",
|
"conditional_logic": "條件邏輯",
|
||||||
"confirm_default_language": "確認預設語言",
|
"confirm_default_language": "確認預設語言",
|
||||||
@@ -1320,6 +1331,7 @@
|
|||||||
"end_screen_card": "結束畫面卡片",
|
"end_screen_card": "結束畫面卡片",
|
||||||
"ending_card": "結尾卡片",
|
"ending_card": "結尾卡片",
|
||||||
"ending_card_used_in_logic": "此結尾卡片用於問題 '{'questionIndex'}' 的邏輯中。",
|
"ending_card_used_in_logic": "此結尾卡片用於問題 '{'questionIndex'}' 的邏輯中。",
|
||||||
|
"ending_used_in_quota": "此 結尾 正被使用於 \"{quotaName}\" 配額中",
|
||||||
"ends_with": "結尾為",
|
"ends_with": "結尾為",
|
||||||
"equals": "等於",
|
"equals": "等於",
|
||||||
"equals_one_of": "等於其中之一",
|
"equals_one_of": "等於其中之一",
|
||||||
@@ -1330,6 +1342,7 @@
|
|||||||
"fallback_for": "備用 用於 ",
|
"fallback_for": "備用 用於 ",
|
||||||
"fallback_missing": "遺失的回退",
|
"fallback_missing": "遺失的回退",
|
||||||
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "'{'fieldId'}' 用於問題 '{'questionIndex'}' 的邏輯中。請先從邏輯中移除。",
|
"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}\" 配額中",
|
||||||
"field_name_eg_score_price": "欄位名稱,例如:分數、價格",
|
"field_name_eg_score_price": "欄位名稱,例如:分數、價格",
|
||||||
"first_name": "名字",
|
"first_name": "名字",
|
||||||
"five_points_recommended": "5 分(建議)",
|
"five_points_recommended": "5 分(建議)",
|
||||||
@@ -1361,8 +1374,9 @@
|
|||||||
"follow_ups_modal_action_subject_placeholder": "電子郵件主旨",
|
"follow_ups_modal_action_subject_placeholder": "電子郵件主旨",
|
||||||
"follow_ups_modal_action_to_description": "傳送電子郵件的電子郵件地址",
|
"follow_ups_modal_action_to_description": "傳送電子郵件的電子郵件地址",
|
||||||
"follow_ups_modal_action_to_label": "收件者",
|
"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_create_heading": "建立新的後續追蹤",
|
||||||
|
"follow_ups_modal_created_successfull_toast": "後續 動作 已 建立 並 將 在 你 儲存 調查 後 儲存",
|
||||||
"follow_ups_modal_edit_heading": "編輯此後續追蹤",
|
"follow_ups_modal_edit_heading": "編輯此後續追蹤",
|
||||||
"follow_ups_modal_edit_no_id": "未提供問卷後續追蹤 ID,無法更新問卷後續追蹤",
|
"follow_ups_modal_edit_no_id": "未提供問卷後續追蹤 ID,無法更新問卷後續追蹤",
|
||||||
"follow_ups_modal_name_label": "後續追蹤名稱",
|
"follow_ups_modal_name_label": "後續追蹤名稱",
|
||||||
@@ -1372,8 +1386,9 @@
|
|||||||
"follow_ups_modal_trigger_label": "觸發器",
|
"follow_ups_modal_trigger_label": "觸發器",
|
||||||
"follow_ups_modal_trigger_type_ending": "回應者看到特定結尾",
|
"follow_ups_modal_trigger_type_ending": "回應者看到特定結尾",
|
||||||
"follow_ups_modal_trigger_type_ending_select": "選取結尾:",
|
"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_trigger_type_response": "回應者完成問卷",
|
||||||
|
"follow_ups_modal_updated_successfull_toast": "後續 動作 已 更新 並 將 在 你 儲存 調查 後 儲存",
|
||||||
"follow_ups_new": "新增後續追蹤",
|
"follow_ups_new": "新增後續追蹤",
|
||||||
"follow_ups_upgrade_button_text": "升級以啟用後續追蹤",
|
"follow_ups_upgrade_button_text": "升級以啟用後續追蹤",
|
||||||
"form_styling": "表單樣式設定",
|
"form_styling": "表單樣式設定",
|
||||||
@@ -1474,6 +1489,38 @@
|
|||||||
"question_duplicated": "問題已複製。",
|
"question_duplicated": "問題已複製。",
|
||||||
"question_id_updated": "問題 ID 已更新",
|
"question_id_updated": "問題 ID 已更新",
|
||||||
"question_used_in_logic": "此問題用於問題 '{'questionIndex'}' 的邏輯中。",
|
"question_used_in_logic": "此問題用於問題 '{'questionIndex'}' 的邏輯中。",
|
||||||
|
"question_used_in_quota": "此問題 正被使用於 \"{quotaName}\" 配額中",
|
||||||
|
"quotas": {
|
||||||
|
"add_quota": "新增額度",
|
||||||
|
"change_quota_for_public_survey": "更改 公開 問卷 的 額度?",
|
||||||
|
"confirm_quota_changes": "確認配額變更",
|
||||||
|
"confirm_quota_changes_body": "您的 配額 中有 未儲存 的 變更。您 要 先 儲存 它們 再 離開 嗎?",
|
||||||
|
"continue_survey_normally": "正常 繼續 問卷",
|
||||||
|
"count_partial_submissions": "計算 部分提交",
|
||||||
|
"count_partial_submissions_description": "包括符合配額標準但未完成問卷的受訪者",
|
||||||
|
"create_quota_for_public_survey": "為 公開 問卷 建立 額度?",
|
||||||
|
"create_quota_for_public_survey_description": "只有 未來 的 答案 會 被 篩選 進 配額",
|
||||||
|
"create_quota_for_public_survey_text": "這個 調查 已經 是 公開 的。 現有 的 回應 將 不會 被 納入 新 額度 的 考量。",
|
||||||
|
"delete_quota_confirmation_text": "這將永久刪除配額 {quotaName}。",
|
||||||
|
"duplicate_quota": "複製 配額",
|
||||||
|
"edit_quota": "編輯 配額",
|
||||||
|
"end_survey_for_matching_participants": "結束問卷調查 對於 符合條件的參加者",
|
||||||
|
"inclusion_criteria": "納入 條件",
|
||||||
|
"limit_must_be_greater_than_or_equal_to_the_number_of_responses": "{value, plural, other {您已經有 {value} 個 回應 對於 此 配額,因此 限制 必須大於 {value}。} }",
|
||||||
|
"limited_to_x_responses": "限制為 {limit}",
|
||||||
|
"new_quota": "新 配額",
|
||||||
|
"quota_created_successfull_toast": "配額已成功建立。",
|
||||||
|
"quota_deleted_successfull_toast": "配額已成功刪除。",
|
||||||
|
"quota_duplicated_successfull_toast": "配額已成功複製。",
|
||||||
|
"quota_name_placeholder": "例如, 年齡 18-25 參與者",
|
||||||
|
"quota_updated_successfull_toast": "配額已成功更新",
|
||||||
|
"response_limit": "限制",
|
||||||
|
"save_changes_confirmation_body": "任何 變更 包括 條件 只 影響 未來 的 回覆。\n 我們 推薦 複製 現有 的 配額 或 創建 新 的 配額。",
|
||||||
|
"save_changes_confirmation_text": "現有 回應 留在 配額 內",
|
||||||
|
"select_ending_card": "選取結尾卡片",
|
||||||
|
"upgrade_prompt_title": "使用 額度 與 更高 的 計劃",
|
||||||
|
"when_quota_has_been_reached": "當 配額 已達"
|
||||||
|
},
|
||||||
"randomize_all": "全部隨機排序",
|
"randomize_all": "全部隨機排序",
|
||||||
"randomize_all_except_last": "全部隨機排序(最後一項除外)",
|
"randomize_all_except_last": "全部隨機排序(最後一項除外)",
|
||||||
"range": "範圍",
|
"range": "範圍",
|
||||||
@@ -1567,6 +1614,7 @@
|
|||||||
"url_not_supported": "不支援網址",
|
"url_not_supported": "不支援網址",
|
||||||
"use_with_caution": "謹慎使用",
|
"use_with_caution": "謹慎使用",
|
||||||
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "'{'variable'}' 用於問題 '{'questionIndex'}' 的邏輯中。請先從邏輯中移除。",
|
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "'{'variable'}' 用於問題 '{'questionIndex'}' 的邏輯中。請先從邏輯中移除。",
|
||||||
|
"variable_is_used_in_quota_please_remove_it_from_quota_first": "變數 \"{variableName}\" 正被使用於 \"{quotaName}\" 配額中",
|
||||||
"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": "變數名稱必須以字母開頭。",
|
||||||
"verify_email_before_submission": "提交前驗證電子郵件",
|
"verify_email_before_submission": "提交前驗證電子郵件",
|
||||||
@@ -1601,11 +1649,14 @@
|
|||||||
"address_line_2": "地址 2",
|
"address_line_2": "地址 2",
|
||||||
"an_error_occurred_deleting_the_tag": "刪除標籤時發生錯誤",
|
"an_error_occurred_deleting_the_tag": "刪除標籤時發生錯誤",
|
||||||
"browser": "瀏覽器",
|
"browser": "瀏覽器",
|
||||||
|
"bulk_delete_response_quotas": "回應 屬於 此 調查 的 配額 一部分 . 你 想 如何 處理 配額?",
|
||||||
"city": "城市",
|
"city": "城市",
|
||||||
"company": "公司",
|
"company": "公司",
|
||||||
"completed": "已完成 ✅",
|
"completed": "已完成 ✅",
|
||||||
"country": "國家/地區",
|
"country": "國家/地區",
|
||||||
|
"decrement_quotas": "減少所有配額限制,包括此回應",
|
||||||
"delete_response_confirmation": "這將刪除調查響應,包括所有回答、標籤、附件文件以及響應元數據。",
|
"delete_response_confirmation": "這將刪除調查響應,包括所有回答、標籤、附件文件以及響應元數據。",
|
||||||
|
"delete_response_quotas": "回應 屬於 此 調查 的 配額 一部分 . 你 想 如何 處理 配額?",
|
||||||
"device": "裝置",
|
"device": "裝置",
|
||||||
"device_info": "裝置資訊",
|
"device_info": "裝置資訊",
|
||||||
"email": "電子郵件",
|
"email": "電子郵件",
|
||||||
@@ -1737,6 +1788,7 @@
|
|||||||
"configure_alerts": "設定警示",
|
"configure_alerts": "設定警示",
|
||||||
"congrats": "恭喜!您的問卷已上線。",
|
"congrats": "恭喜!您的問卷已上線。",
|
||||||
"connect_your_website_or_app_with_formbricks_to_get_started": "將您的網站或應用程式與 Formbricks 連線以開始使用。",
|
"connect_your_website_or_app_with_formbricks_to_get_started": "將您的網站或應用程式與 Formbricks 連線以開始使用。",
|
||||||
|
"current_count": "目前計數",
|
||||||
"custom_range": "自訂範圍...",
|
"custom_range": "自訂範圍...",
|
||||||
"delete_all_existing_responses_and_displays": "刪除 所有 現有 回應 和 顯示",
|
"delete_all_existing_responses_and_displays": "刪除 所有 現有 回應 和 顯示",
|
||||||
"download_qr_code": "下載 QR code",
|
"download_qr_code": "下載 QR code",
|
||||||
@@ -1790,6 +1842,7 @@
|
|||||||
"last_month": "上個月",
|
"last_month": "上個月",
|
||||||
"last_quarter": "上一季",
|
"last_quarter": "上一季",
|
||||||
"last_year": "去年",
|
"last_year": "去年",
|
||||||
|
"limit": "限制",
|
||||||
"no_responses_found": "找不到回應",
|
"no_responses_found": "找不到回應",
|
||||||
"other_values_found": "找到其他值",
|
"other_values_found": "找到其他值",
|
||||||
"overall": "整體",
|
"overall": "整體",
|
||||||
@@ -1798,6 +1851,8 @@
|
|||||||
"qr_code_download_failed": "QR code 下載失敗",
|
"qr_code_download_failed": "QR code 下載失敗",
|
||||||
"qr_code_download_with_start_soon": "QR code 下載即將開始",
|
"qr_code_download_with_start_soon": "QR code 下載即將開始",
|
||||||
"qr_code_generation_failed": "載入調查 QR Code 時發生問題。請再試一次。",
|
"qr_code_generation_failed": "載入調查 QR Code 時發生問題。請再試一次。",
|
||||||
|
"quotas_completed": "配額 已完成",
|
||||||
|
"quotas_completed_tooltip": "受訪者完成的 配額 數量。",
|
||||||
"reset_survey": "重設問卷",
|
"reset_survey": "重設問卷",
|
||||||
"reset_survey_warning": "重置 調查 會 移除 與 此 調查 相關 的 所有 回應 和 顯示 。 這 是 不可 撤銷 的 。",
|
"reset_survey_warning": "重置 調查 會 移除 與 此 調查 相關 的 所有 回應 和 顯示 。 這 是 不可 撤銷 的 。",
|
||||||
"selected_responses_csv": "選擇的回應 (CSV)",
|
"selected_responses_csv": "選擇的回應 (CSV)",
|
||||||
@@ -2832,4 +2887,4 @@
|
|||||||
"usability_rating_description": "透過使用標準化的 十個問題 問卷,要求使用者評估他們對 您 產品的使用體驗,來衡量感知的 可用性。",
|
"usability_rating_description": "透過使用標準化的 十個問題 問卷,要求使用者評估他們對 您 產品的使用體驗,來衡量感知的 可用性。",
|
||||||
"usability_score_name": "系統 可用性 分數 (SUS)"
|
"usability_score_name": "系統 可用性 分數 (SUS)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -13,6 +13,7 @@ import { RatingResponse } from "@/modules/ui/components/rating-response";
|
|||||||
import { ResponseBadges } from "@/modules/ui/components/response-badges";
|
import { ResponseBadges } from "@/modules/ui/components/response-badges";
|
||||||
import { CheckCheckIcon, MousePointerClickIcon, PhoneIcon } from "lucide-react";
|
import { CheckCheckIcon, MousePointerClickIcon, PhoneIcon } from "lucide-react";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { TResponseDataValue } from "@formbricks/types/responses";
|
||||||
import {
|
import {
|
||||||
TSurvey,
|
TSurvey,
|
||||||
TSurveyMatrixQuestion,
|
TSurveyMatrixQuestion,
|
||||||
@@ -23,7 +24,7 @@ import {
|
|||||||
} from "@formbricks/types/surveys/types";
|
} from "@formbricks/types/surveys/types";
|
||||||
|
|
||||||
interface RenderResponseProps {
|
interface RenderResponseProps {
|
||||||
responseData: string | number | string[] | Record<string, string>;
|
responseData: TResponseDataValue;
|
||||||
question: TSurveyQuestion;
|
question: TSurveyQuestion;
|
||||||
survey: TSurvey;
|
survey: TSurvey;
|
||||||
language: string | null;
|
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 (
|
return (
|
||||||
(typeof value === "string" && value.trim() !== "") ||
|
(typeof value === "string" && value.trim() !== "") ||
|
||||||
(Array.isArray(value) && value.length > 0) ||
|
(Array.isArray(value) && value.length > 0) ||
|
||||||
|
|||||||
@@ -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 { EMAIL_VERIFICATION_DISABLED } from "@/lib/constants";
|
||||||
import { createToken } from "@/lib/jwt";
|
import { createToken } from "@/lib/jwt";
|
||||||
// Import mocked rate limiting functions
|
// Import mocked rate limiting functions
|
||||||
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
|
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
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 { authOptions } from "./authOptions";
|
||||||
import { mockUser } from "./mock-data";
|
import { mockUser } from "./mock-data";
|
||||||
import { hashPassword } from "./utils";
|
import { hashPassword } from "./utils";
|
||||||
@@ -31,7 +31,7 @@ vi.mock("@/lib/constants", () => ({
|
|||||||
SESSION_MAX_AGE: 86400,
|
SESSION_MAX_AGE: 86400,
|
||||||
NEXTAUTH_SECRET: "test-secret",
|
NEXTAUTH_SECRET: "test-secret",
|
||||||
WEBAPP_URL: "http://localhost:3000",
|
WEBAPP_URL: "http://localhost:3000",
|
||||||
ENCRYPTION_KEY: "test-encryption-key-32-chars-long",
|
ENCRYPTION_KEY: "12345678901234567890123456789012", // 32 bytes for AES-256
|
||||||
REDIS_URL: undefined,
|
REDIS_URL: undefined,
|
||||||
AUDIT_LOG_ENABLED: false,
|
AUDIT_LOG_ENABLED: false,
|
||||||
AUDIT_LOG_GET_USER_IP: false,
|
AUDIT_LOG_GET_USER_IP: false,
|
||||||
@@ -261,7 +261,7 @@ describe("authOptions", () => {
|
|||||||
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
|
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
|
||||||
vi.spyOn(prisma.user, "findUnique").mockResolvedValue(mockUser as any);
|
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(
|
await expect(tokenProvider.options.authorize(credentials, {})).rejects.toThrow(
|
||||||
"Email already verified"
|
"Email already verified"
|
||||||
@@ -280,7 +280,7 @@ describe("authOptions", () => {
|
|||||||
groupId: null,
|
groupId: null,
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
const credentials = { token: createToken(mockUserId, mockUser.email) };
|
const credentials = { token: createToken(mockUserId) };
|
||||||
|
|
||||||
const result = await tokenProvider.options.authorize(credentials, {});
|
const result = await tokenProvider.options.authorize(credentials, {});
|
||||||
expect(result.email).toBe(mockUser.email);
|
expect(result.email).toBe(mockUser.email);
|
||||||
@@ -303,7 +303,7 @@ describe("authOptions", () => {
|
|||||||
groupId: null,
|
groupId: null,
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
const credentials = { token: createToken(mockUserId, mockUser.email) };
|
const credentials = { token: createToken(mockUserId) };
|
||||||
|
|
||||||
await tokenProvider.options.authorize(credentials, {});
|
await tokenProvider.options.authorize(credentials, {});
|
||||||
|
|
||||||
@@ -315,7 +315,7 @@ describe("authOptions", () => {
|
|||||||
new Error("Maximum number of requests reached. Please try again later.")
|
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(
|
await expect(tokenProvider.options.authorize(credentials, {})).rejects.toThrow(
|
||||||
"Maximum number of requests reached. Please try again later."
|
"Maximum number of requests reached. Please try again later."
|
||||||
@@ -339,7 +339,7 @@ describe("authOptions", () => {
|
|||||||
groupId: null,
|
groupId: null,
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
const credentials = { token: createToken(mockUserId, mockUser.email) };
|
const credentials = { token: createToken(mockUserId) };
|
||||||
|
|
||||||
await tokenProvider.options.authorize(credentials, {});
|
await tokenProvider.options.authorize(credentials, {});
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
import {
|
||||||
DEBUG,
|
DEBUG,
|
||||||
MAIL_FROM,
|
MAIL_FROM,
|
||||||
@@ -17,15 +26,6 @@ import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
|||||||
import NewEmailVerification from "@/modules/email/emails/auth/new-email-verification";
|
import NewEmailVerification from "@/modules/email/emails/auth/new-email-verification";
|
||||||
import { EmailCustomizationPreviewEmail } from "@/modules/email/emails/general/email-customization-preview-email";
|
import { EmailCustomizationPreviewEmail } from "@/modules/email/emails/general/email-customization-preview-email";
|
||||||
import { getTranslate } from "@/tolgee/server";
|
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 { ForgotPasswordEmail } from "./emails/auth/forgot-password-email";
|
||||||
import { PasswordResetNotifyEmail } from "./emails/auth/password-reset-notify-email";
|
import { PasswordResetNotifyEmail } from "./emails/auth/password-reset-notify-email";
|
||||||
import { VerificationEmail } from "./emails/auth/verification-email";
|
import { VerificationEmail } from "./emails/auth/verification-email";
|
||||||
@@ -111,7 +111,7 @@ export const sendVerificationEmail = async ({
|
|||||||
}): Promise<boolean> => {
|
}): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
const token = createToken(id, email, {
|
const token = createToken(id, {
|
||||||
expiresIn: "1d",
|
expiresIn: "1d",
|
||||||
});
|
});
|
||||||
const verifyLink = `${WEBAPP_URL}/auth/verify?token=${encodeURIComponent(token)}`;
|
const verifyLink = `${WEBAPP_URL}/auth/verify?token=${encodeURIComponent(token)}`;
|
||||||
@@ -136,7 +136,7 @@ export const sendForgotPasswordEmail = async (user: {
|
|||||||
locale: TUserLocale;
|
locale: TUserLocale;
|
||||||
}): Promise<boolean> => {
|
}): Promise<boolean> => {
|
||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
const token = createToken(user.id, user.email, {
|
const token = createToken(user.id, {
|
||||||
expiresIn: "1d",
|
expiresIn: "1d",
|
||||||
});
|
});
|
||||||
const verifyLink = `${WEBAPP_URL}/auth/forgot-password/reset?token=${encodeURIComponent(token)}`;
|
const verifyLink = `${WEBAPP_URL}/auth/forgot-password/reset?token=${encodeURIComponent(token)}`;
|
||||||
|
|||||||
@@ -32,13 +32,7 @@ describe("TemplateFilters", () => {
|
|||||||
|
|
||||||
test("renders all filter categories and options", () => {
|
test("renders all filter categories and options", () => {
|
||||||
const setSelectedFilter = vi.fn();
|
const setSelectedFilter = vi.fn();
|
||||||
render(
|
render(<TemplateFilters selectedFilter={[null, null, null]} setSelectedFilter={setSelectedFilter} />);
|
||||||
<TemplateFilters
|
|
||||||
selectedFilter={[null, null, null]}
|
|
||||||
setSelectedFilter={setSelectedFilter}
|
|
||||||
prefilledFilters={[null, null, null]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText("environments.surveys.templates.all_channels")).toBeInTheDocument();
|
expect(screen.getByText("environments.surveys.templates.all_channels")).toBeInTheDocument();
|
||||||
expect(screen.getByText("environments.surveys.templates.all_industries")).toBeInTheDocument();
|
expect(screen.getByText("environments.surveys.templates.all_industries")).toBeInTheDocument();
|
||||||
@@ -54,13 +48,7 @@ describe("TemplateFilters", () => {
|
|||||||
const setSelectedFilter = vi.fn();
|
const setSelectedFilter = vi.fn();
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
render(
|
render(<TemplateFilters selectedFilter={[null, null, null]} setSelectedFilter={setSelectedFilter} />);
|
||||||
<TemplateFilters
|
|
||||||
selectedFilter={[null, null, null]}
|
|
||||||
setSelectedFilter={setSelectedFilter}
|
|
||||||
prefilledFilters={[null, null, null]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
await user.click(screen.getByText("environments.surveys.templates.channel1"));
|
await user.click(screen.getByText("environments.surveys.templates.channel1"));
|
||||||
expect(setSelectedFilter).toHaveBeenCalledWith(["channel1", null, null]);
|
expect(setSelectedFilter).toHaveBeenCalledWith(["channel1", null, null]);
|
||||||
@@ -74,11 +62,7 @@ describe("TemplateFilters", () => {
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<TemplateFilters
|
<TemplateFilters selectedFilter={["link", "app", "website"]} setSelectedFilter={setSelectedFilter} />
|
||||||
selectedFilter={["link", "app", "website"]}
|
|
||||||
setSelectedFilter={setSelectedFilter}
|
|
||||||
prefilledFilters={[null, null, null]}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
await user.click(screen.getByText("environments.surveys.templates.all_channels"));
|
await user.click(screen.getByText("environments.surveys.templates.all_channels"));
|
||||||
@@ -93,7 +77,6 @@ describe("TemplateFilters", () => {
|
|||||||
selectedFilter={[null, null, null]}
|
selectedFilter={[null, null, null]}
|
||||||
setSelectedFilter={setSelectedFilter}
|
setSelectedFilter={setSelectedFilter}
|
||||||
templateSearch="search term"
|
templateSearch="search term"
|
||||||
prefilledFilters={[null, null, null]}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -102,20 +85,4 @@ describe("TemplateFilters", () => {
|
|||||||
expect(button).toBeDisabled();
|
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[];
|
selectedFilter: TTemplateFilter[];
|
||||||
setSelectedFilter: (filter: TTemplateFilter[]) => void;
|
setSelectedFilter: (filter: TTemplateFilter[]) => void;
|
||||||
templateSearch?: string;
|
templateSearch?: string;
|
||||||
prefilledFilters: TTemplateFilter[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TemplateFilters = ({
|
export const TemplateFilters = ({
|
||||||
selectedFilter,
|
selectedFilter,
|
||||||
setSelectedFilter,
|
setSelectedFilter,
|
||||||
templateSearch,
|
templateSearch,
|
||||||
prefilledFilters,
|
|
||||||
}: TemplateFiltersProps) => {
|
}: TemplateFiltersProps) => {
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
const handleFilterSelect = (filterValue: TTemplateFilter, index: number) => {
|
const handleFilterSelect = (filterValue: TTemplateFilter, index: number) => {
|
||||||
@@ -31,7 +29,6 @@ export const TemplateFilters = ({
|
|||||||
return (
|
return (
|
||||||
<div className="mb-6 gap-3">
|
<div className="mb-6 gap-3">
|
||||||
{allFilters.map((filters, index) => {
|
{allFilters.map((filters, index) => {
|
||||||
if (prefilledFilters[index] !== null) return;
|
|
||||||
return (
|
return (
|
||||||
<div key={filters[0]?.value || index} className="mt-2 flex flex-wrap gap-1 last:border-r-0">
|
<div key={filters[0]?.value || index} className="mt-2 flex flex-wrap gap-1 last:border-r-0">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -102,41 +102,20 @@ describe("TemplateList", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("renders correctly with default props", () => {
|
test("renders correctly with default props", () => {
|
||||||
render(
|
render(<TemplateList userId="user-id" environmentId="env-id" project={mockProject} />);
|
||||||
<TemplateList
|
|
||||||
userId="user-id"
|
|
||||||
environmentId="env-id"
|
|
||||||
project={mockProject}
|
|
||||||
prefilledFilters={[null, null, null]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText("Start from scratch")).toBeInTheDocument();
|
expect(screen.getByText("Start from scratch")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("renders filters when showFilters is true", () => {
|
test("renders filters when showFilters is true", () => {
|
||||||
render(
|
render(<TemplateList userId="user-id" environmentId="env-id" project={mockProject} showFilters={true} />);
|
||||||
<TemplateList
|
|
||||||
userId="user-id"
|
|
||||||
environmentId="env-id"
|
|
||||||
project={mockProject}
|
|
||||||
prefilledFilters={[null, null, null]}
|
|
||||||
showFilters={true}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.queryByTestId("template-filters")).toBeInTheDocument();
|
expect(screen.queryByTestId("template-filters")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("doesn't render filters when showFilters is false", () => {
|
test("doesn't render filters when showFilters is false", () => {
|
||||||
render(
|
render(
|
||||||
<TemplateList
|
<TemplateList userId="user-id" environmentId="env-id" project={mockProject} showFilters={false} />
|
||||||
userId="user-id"
|
|
||||||
environmentId="env-id"
|
|
||||||
project={mockProject}
|
|
||||||
prefilledFilters={[null, null, null]}
|
|
||||||
showFilters={false}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.queryByTestId("template-filters")).not.toBeInTheDocument();
|
expect(screen.queryByTestId("template-filters")).not.toBeInTheDocument();
|
||||||
@@ -150,7 +129,6 @@ describe("TemplateList", () => {
|
|||||||
userId="user-id"
|
userId="user-id"
|
||||||
environmentId="env-id"
|
environmentId="env-id"
|
||||||
project={mockProject}
|
project={mockProject}
|
||||||
prefilledFilters={[null, null, null]}
|
|
||||||
templateSearch="Template 1"
|
templateSearch="Template 1"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -167,7 +145,6 @@ describe("TemplateList", () => {
|
|||||||
userId="user-id"
|
userId="user-id"
|
||||||
environmentId="env-id"
|
environmentId="env-id"
|
||||||
project={mockProject}
|
project={mockProject}
|
||||||
prefilledFilters={[null, null, null]}
|
|
||||||
onTemplateClick={onTemplateClickMock}
|
onTemplateClick={onTemplateClickMock}
|
||||||
noPreview={true}
|
noPreview={true}
|
||||||
/>
|
/>
|
||||||
@@ -186,14 +163,7 @@ describe("TemplateList", () => {
|
|||||||
|
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
render(
|
render(<TemplateList userId="user-id" environmentId="env-id" project={mockProject} />);
|
||||||
<TemplateList
|
|
||||||
userId="user-id"
|
|
||||||
environmentId="env-id"
|
|
||||||
project={mockProject}
|
|
||||||
prefilledFilters={[null, null, null]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
// First select the template
|
// First select the template
|
||||||
const selectButton = screen.getAllByText("Select")[0];
|
const selectButton = screen.getAllByText("Select")[0];
|
||||||
@@ -220,14 +190,7 @@ describe("TemplateList", () => {
|
|||||||
|
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
render(
|
render(<TemplateList userId="user-id" environmentId="env-id" project={mockProject} />);
|
||||||
<TemplateList
|
|
||||||
userId="user-id"
|
|
||||||
environmentId="env-id"
|
|
||||||
project={mockProject}
|
|
||||||
prefilledFilters={[null, null, null]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
// First select the template
|
// First select the template
|
||||||
const selectButton = screen.getAllByText("Select")[0];
|
const selectButton = screen.getAllByText("Select")[0];
|
||||||
@@ -250,12 +213,7 @@ describe("TemplateList", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const { rerender } = render(
|
const { rerender } = render(
|
||||||
<TemplateList
|
<TemplateList userId="user-id" environmentId="env-id" project={mobileProject as Project} />
|
||||||
userId="user-id"
|
|
||||||
environmentId="env-id"
|
|
||||||
project={mobileProject as Project}
|
|
||||||
prefilledFilters={[null, null, null]}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Test with no channel config
|
// Test with no channel config
|
||||||
@@ -264,14 +222,7 @@ describe("TemplateList", () => {
|
|||||||
config: {},
|
config: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
rerender(
|
rerender(<TemplateList userId="user-id" environmentId="env-id" project={noChannelProject as Project} />);
|
||||||
<TemplateList
|
|
||||||
userId="user-id"
|
|
||||||
environmentId="env-id"
|
|
||||||
project={noChannelProject as Project}
|
|
||||||
prefilledFilters={[null, null, null]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText("Template 1")).toBeInTheDocument();
|
expect(screen.getByText("Template 1")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -279,14 +230,7 @@ describe("TemplateList", () => {
|
|||||||
test("development mode shows templates correctly", () => {
|
test("development mode shows templates correctly", () => {
|
||||||
vi.stubEnv("NODE_ENV", "development");
|
vi.stubEnv("NODE_ENV", "development");
|
||||||
|
|
||||||
render(
|
render(<TemplateList userId="user-id" environmentId="env-id" project={mockProject} />);
|
||||||
<TemplateList
|
|
||||||
userId="user-id"
|
|
||||||
environmentId="env-id"
|
|
||||||
project={mockProject}
|
|
||||||
prefilledFilters={[null, null, null]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText("Template 1")).toBeInTheDocument();
|
expect(screen.getByText("Template 1")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Template 2")).toBeInTheDocument();
|
expect(screen.getByText("Template 2")).toBeInTheDocument();
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ interface TemplateListProps {
|
|||||||
project: Project;
|
project: Project;
|
||||||
templateSearch?: string;
|
templateSearch?: string;
|
||||||
showFilters?: boolean;
|
showFilters?: boolean;
|
||||||
prefilledFilters: TTemplateFilter[];
|
|
||||||
onTemplateClick?: (template: TTemplate) => void;
|
onTemplateClick?: (template: TTemplate) => void;
|
||||||
noPreview?: boolean; // single click to create survey
|
noPreview?: boolean; // single click to create survey
|
||||||
}
|
}
|
||||||
@@ -32,7 +31,6 @@ export const TemplateList = ({
|
|||||||
environmentId,
|
environmentId,
|
||||||
showFilters = true,
|
showFilters = true,
|
||||||
templateSearch,
|
templateSearch,
|
||||||
prefilledFilters,
|
|
||||||
onTemplateClick = () => {},
|
onTemplateClick = () => {},
|
||||||
noPreview,
|
noPreview,
|
||||||
}: TemplateListProps) => {
|
}: TemplateListProps) => {
|
||||||
@@ -40,7 +38,7 @@ export const TemplateList = ({
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [activeTemplate, setActiveTemplate] = useState<TTemplate | null>(null);
|
const [activeTemplate, setActiveTemplate] = useState<TTemplate | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [selectedFilter, setSelectedFilter] = useState<TTemplateFilter[]>(prefilledFilters);
|
const [selectedFilter, setSelectedFilter] = useState<TTemplateFilter[]>([null, null, null]);
|
||||||
const surveyType: TSurveyType = useMemo(() => {
|
const surveyType: TSurveyType = useMemo(() => {
|
||||||
if (project.config.channel) {
|
if (project.config.channel) {
|
||||||
if (project.config.channel === "website") {
|
if (project.config.channel === "website") {
|
||||||
@@ -111,7 +109,6 @@ export const TemplateList = ({
|
|||||||
selectedFilter={selectedFilter}
|
selectedFilter={selectedFilter}
|
||||||
setSelectedFilter={setSelectedFilter}
|
setSelectedFilter={setSelectedFilter}
|
||||||
templateSearch={templateSearch}
|
templateSearch={templateSearch}
|
||||||
prefilledFilters={prefilledFilters}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<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);
|
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 });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import {
|
|||||||
SplitIcon,
|
SplitIcon,
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useMemo } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import { TSurvey, TSurveyLogic, TSurveyQuestion } from "@formbricks/types/surveys/types";
|
import { TSurvey, TSurveyLogic, TSurveyQuestion } from "@formbricks/types/surveys/types";
|
||||||
|
|
||||||
interface ConditionalLogicProps {
|
interface ConditionalLogicProps {
|
||||||
@@ -117,6 +117,12 @@ export function ConditionalLogic({
|
|||||||
};
|
};
|
||||||
const [parent] = useAutoAnimate();
|
const [parent] = useAutoAnimate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (question.logic?.length === 0 && question.logicFallback) {
|
||||||
|
updateQuestion(questionIdx, { logicFallback: undefined });
|
||||||
|
}
|
||||||
|
}, [question.logic, questionIdx, question.logicFallback, updateQuestion]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-4" ref={parent}>
|
<div className="mt-4" ref={parent}>
|
||||||
<Label className="flex gap-2">
|
<Label className="flex gap-2">
|
||||||
|
|||||||
@@ -186,14 +186,12 @@ export const FollowUpModal = ({
|
|||||||
|
|
||||||
const handleSubmit = (data: TCreateSurveyFollowUpForm) => {
|
const handleSubmit = (data: TCreateSurveyFollowUpForm) => {
|
||||||
if (data.triggerType === "endings" && data.endingIds?.length === 0) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!emailSendToOptions.length) {
|
if (!emailSendToOptions.length) {
|
||||||
toast.error(
|
toast.error(t("environments.surveys.edit.follow_ups_modal_action_to_warning"));
|
||||||
"No valid options found for sending emails, please add some open-text / contact-info questions or hidden fields"
|
|
||||||
);
|
|
||||||
|
|
||||||
return;
|
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);
|
setOpen(false);
|
||||||
setLocalSurvey((prev) => {
|
setLocalSurvey((prev) => {
|
||||||
return {
|
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);
|
setOpen(false);
|
||||||
form.reset();
|
form.reset();
|
||||||
setLocalSurvey((prev) => {
|
setLocalSurvey((prev) => {
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { Session } from "next-auth";
|
|||||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
import { TEnvironment } from "@formbricks/types/environment";
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
import { TProject } from "@formbricks/types/project";
|
import { TProject } from "@formbricks/types/project";
|
||||||
import { TTemplateRole } from "@formbricks/types/templates";
|
|
||||||
import { SurveysPage } from "./page";
|
import { SurveysPage } from "./page";
|
||||||
|
|
||||||
// Mock all dependencies
|
// Mock all dependencies
|
||||||
@@ -53,19 +52,16 @@ vi.mock("@/modules/survey/list/lib/survey", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/modules/survey/templates/components/template-container", () => ({
|
vi.mock("@/modules/survey/templates/components/template-container", () => ({
|
||||||
TemplateContainerWithPreview: vi.fn(
|
TemplateContainerWithPreview: vi.fn(({ userId, environment, project, isTemplatePage }) => (
|
||||||
({ userId, environment, project, prefilledFilters, isTemplatePage }) => (
|
<div
|
||||||
<div
|
data-testid="template-container"
|
||||||
data-testid="template-container"
|
data-user-id={userId}
|
||||||
data-user-id={userId}
|
data-environment-id={environment.id}
|
||||||
data-environment-id={environment.id}
|
data-project-id={project.id}
|
||||||
data-project-id={project.id}
|
data-is-template-page={isTemplatePage}>
|
||||||
data-prefilled-filters={JSON.stringify(prefilledFilters)}
|
Template Container
|
||||||
data-is-template-page={isTemplatePage}>
|
</div>
|
||||||
Template Container
|
)),
|
||||||
</div>
|
|
||||||
)
|
|
||||||
),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/modules/ui/components/button", () => ({
|
vi.mock("@/modules/ui/components/button", () => ({
|
||||||
@@ -207,9 +203,8 @@ describe("SurveysPage", () => {
|
|||||||
mockTranslate.mockReturnValue("Project not found");
|
mockTranslate.mockReturnValue("Project not found");
|
||||||
|
|
||||||
const params = Promise.resolve({ environmentId: "env-123" });
|
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(mockGetProjectWithTeamIdsByEnvironmentId).toHaveBeenCalledWith("env-123");
|
||||||
expect(mockTranslate).toHaveBeenCalledWith("common.project_not_found");
|
expect(mockTranslate).toHaveBeenCalledWith("common.project_not_found");
|
||||||
@@ -225,9 +220,8 @@ describe("SurveysPage", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const params = Promise.resolve({ environmentId: "env-123" });
|
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");
|
expect(mockRedirect).toHaveBeenCalledWith("/environments/env-123/settings/billing");
|
||||||
});
|
});
|
||||||
@@ -236,9 +230,8 @@ describe("SurveysPage", () => {
|
|||||||
mockGetSurveyCount.mockResolvedValue(0);
|
mockGetSurveyCount.mockResolvedValue(0);
|
||||||
|
|
||||||
const params = Promise.resolve({ environmentId: "env-123" });
|
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);
|
render(result);
|
||||||
|
|
||||||
expect(screen.getByTestId("template-container")).toBeInTheDocument();
|
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-environment-id", "env-123");
|
||||||
expect(screen.getByTestId("template-container")).toHaveAttribute("data-project-id", "project-123");
|
expect(screen.getByTestId("template-container")).toHaveAttribute("data-project-id", "project-123");
|
||||||
expect(screen.getByTestId("template-container")).toHaveAttribute("data-is-template-page", "false");
|
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 () => {
|
test("renders surveys list when survey count is greater than 0", async () => {
|
||||||
mockGetSurveyCount.mockResolvedValue(5);
|
mockGetSurveyCount.mockResolvedValue(5);
|
||||||
|
|
||||||
const params = Promise.resolve({ environmentId: "env-123" });
|
const params = Promise.resolve({ environmentId: "env-123" });
|
||||||
const searchParams = Promise.resolve({});
|
|
||||||
|
|
||||||
const result = await SurveysPage({ params, searchParams });
|
const result = await SurveysPage({ params });
|
||||||
render(result);
|
render(result);
|
||||||
|
|
||||||
expect(screen.getByTestId("page-content-wrapper")).toBeInTheDocument();
|
expect(screen.getByTestId("page-content-wrapper")).toBeInTheDocument();
|
||||||
@@ -289,9 +276,8 @@ describe("SurveysPage", () => {
|
|||||||
mockGetSurveyCount.mockResolvedValue(5);
|
mockGetSurveyCount.mockResolvedValue(5);
|
||||||
|
|
||||||
const params = Promise.resolve({ environmentId: "env-123" });
|
const params = Promise.resolve({ environmentId: "env-123" });
|
||||||
const searchParams = Promise.resolve({});
|
|
||||||
|
|
||||||
const result = await SurveysPage({ params, searchParams });
|
const result = await SurveysPage({ params });
|
||||||
render(result);
|
render(result);
|
||||||
|
|
||||||
expect(screen.getByTestId("page-header")).toBeInTheDocument();
|
expect(screen.getByTestId("page-header")).toBeInTheDocument();
|
||||||
@@ -307,9 +293,8 @@ describe("SurveysPage", () => {
|
|||||||
mockGetSurveyCount.mockResolvedValue(0);
|
mockGetSurveyCount.mockResolvedValue(0);
|
||||||
|
|
||||||
const params = Promise.resolve({ environmentId: "env-123" });
|
const params = Promise.resolve({ environmentId: "env-123" });
|
||||||
const searchParams = Promise.resolve({});
|
|
||||||
|
|
||||||
const result = await SurveysPage({ params, searchParams });
|
const result = await SurveysPage({ params });
|
||||||
render(result);
|
render(result);
|
||||||
|
|
||||||
// When survey count is 0, it should render TemplateContainer regardless of read-only status
|
// When survey count is 0, it should render TemplateContainer regardless of read-only status
|
||||||
@@ -330,16 +315,11 @@ describe("SurveysPage", () => {
|
|||||||
mockGetSurveyCount.mockResolvedValue(0);
|
mockGetSurveyCount.mockResolvedValue(0);
|
||||||
|
|
||||||
const params = Promise.resolve({ environmentId: "env-123" });
|
const params = Promise.resolve({ environmentId: "env-123" });
|
||||||
const searchParams = Promise.resolve({});
|
|
||||||
|
|
||||||
const result = await SurveysPage({ params, searchParams });
|
const result = await SurveysPage({ params });
|
||||||
render(result);
|
render(result);
|
||||||
|
|
||||||
expect(screen.getByTestId("template-container")).toBeInTheDocument();
|
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 () => {
|
test("handles project with null styling", async () => {
|
||||||
@@ -351,9 +331,8 @@ describe("SurveysPage", () => {
|
|||||||
mockGetSurveyCount.mockResolvedValue(0);
|
mockGetSurveyCount.mockResolvedValue(0);
|
||||||
|
|
||||||
const params = Promise.resolve({ environmentId: "env-123" });
|
const params = Promise.resolve({ environmentId: "env-123" });
|
||||||
const searchParams = Promise.resolve({});
|
|
||||||
|
|
||||||
const result = await SurveysPage({ params, searchParams });
|
const result = await SurveysPage({ params });
|
||||||
render(result);
|
render(result);
|
||||||
|
|
||||||
expect(screen.getByTestId("template-container")).toBeInTheDocument();
|
expect(screen.getByTestId("template-container")).toBeInTheDocument();
|
||||||
@@ -365,9 +344,8 @@ describe("SurveysPage", () => {
|
|||||||
mockGetSurveyCount.mockResolvedValue(5);
|
mockGetSurveyCount.mockResolvedValue(5);
|
||||||
|
|
||||||
const params = Promise.resolve({ environmentId: "env-123" });
|
const params = Promise.resolve({ environmentId: "env-123" });
|
||||||
const searchParams = Promise.resolve({});
|
|
||||||
|
|
||||||
const result = await SurveysPage({ params, searchParams });
|
const result = await SurveysPage({ params });
|
||||||
render(result);
|
render(result);
|
||||||
|
|
||||||
expect(screen.getByTestId("surveys-list")).toHaveAttribute("data-locale", "en-US");
|
expect(screen.getByTestId("surveys-list")).toHaveAttribute("data-locale", "en-US");
|
||||||
@@ -377,9 +355,8 @@ describe("SurveysPage", () => {
|
|||||||
mockGetSurveyCount.mockResolvedValue(5);
|
mockGetSurveyCount.mockResolvedValue(5);
|
||||||
|
|
||||||
const params = Promise.resolve({ environmentId: "env-123" });
|
const params = Promise.resolve({ environmentId: "env-123" });
|
||||||
const searchParams = Promise.resolve({});
|
|
||||||
|
|
||||||
const result = await SurveysPage({ params, searchParams });
|
const result = await SurveysPage({ params });
|
||||||
render(result);
|
render(result);
|
||||||
|
|
||||||
expect(screen.getByTestId("link")).toHaveAttribute("href", "/environments/env-123/surveys/templates");
|
expect(screen.getByTestId("link")).toHaveAttribute("href", "/environments/env-123/surveys/templates");
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import { PlusIcon } from "lucide-react";
|
|||||||
import { Metadata } from "next";
|
import { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { TTemplateRole } from "@formbricks/types/templates";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Your Surveys",
|
title: "Your Surveys",
|
||||||
@@ -24,17 +23,10 @@ interface SurveyTemplateProps {
|
|||||||
params: Promise<{
|
params: Promise<{
|
||||||
environmentId: string;
|
environmentId: string;
|
||||||
}>;
|
}>;
|
||||||
searchParams: Promise<{
|
|
||||||
role?: TTemplateRole;
|
|
||||||
}>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SurveysPage = async ({
|
export const SurveysPage = async ({ params: paramsProps }: SurveyTemplateProps) => {
|
||||||
params: paramsProps,
|
|
||||||
searchParams: searchParamsProps,
|
|
||||||
}: SurveyTemplateProps) => {
|
|
||||||
const publicDomain = getPublicDomain();
|
const publicDomain = getPublicDomain();
|
||||||
const searchParams = await searchParamsProps;
|
|
||||||
const params = await paramsProps;
|
const params = await paramsProps;
|
||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
|
|
||||||
@@ -46,8 +38,6 @@ export const SurveysPage = async ({
|
|||||||
|
|
||||||
const { session, isBilling, environment, isReadOnly } = await getEnvironmentAuth(params.environmentId);
|
const { session, isBilling, environment, isReadOnly } = await getEnvironmentAuth(params.environmentId);
|
||||||
|
|
||||||
const prefilledFilters = [project?.config.channel, project.config.industry, searchParams.role ?? null];
|
|
||||||
|
|
||||||
if (isBilling) {
|
if (isBilling) {
|
||||||
return redirect(`/environments/${params.environmentId}/settings/billing`);
|
return redirect(`/environments/${params.environmentId}/settings/billing`);
|
||||||
}
|
}
|
||||||
@@ -79,7 +69,6 @@ export const SurveysPage = async ({
|
|||||||
userId={session.user.id}
|
userId={session.user.id}
|
||||||
environment={environment}
|
environment={environment}
|
||||||
project={projectWithRequiredProps}
|
project={projectWithRequiredProps}
|
||||||
prefilledFilters={prefilledFilters}
|
|
||||||
isTemplatePage={false}
|
isTemplatePage={false}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import "@testing-library/jest-dom/vitest";
|
|||||||
import { cleanup, render, screen } from "@testing-library/react";
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||||
import { TProjectConfigChannel, TProjectConfigIndustry } from "@formbricks/types/project";
|
import { TProjectConfigChannel, TProjectConfigIndustry } from "@formbricks/types/project";
|
||||||
import { TTemplateRole } from "@formbricks/types/templates";
|
|
||||||
import { TemplateContainerWithPreview } from "./template-container";
|
import { TemplateContainerWithPreview } from "./template-container";
|
||||||
|
|
||||||
// Mock dependencies
|
// Mock dependencies
|
||||||
@@ -59,8 +58,6 @@ const mockEnvironment = {
|
|||||||
appSetupCompleted: true,
|
appSetupCompleted: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockPrefilledFilters: (TProjectConfigChannel | TProjectConfigIndustry | TTemplateRole | null)[] = [];
|
|
||||||
|
|
||||||
describe("TemplateContainerWithPreview", () => {
|
describe("TemplateContainerWithPreview", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
@@ -72,7 +69,6 @@ describe("TemplateContainerWithPreview", () => {
|
|||||||
project={mockProject}
|
project={mockProject}
|
||||||
environment={mockEnvironment}
|
environment={mockEnvironment}
|
||||||
userId="user1"
|
userId="user1"
|
||||||
prefilledFilters={mockPrefilledFilters}
|
|
||||||
isTemplatePage={true}
|
isTemplatePage={true}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -86,7 +82,6 @@ describe("TemplateContainerWithPreview", () => {
|
|||||||
project={mockProject}
|
project={mockProject}
|
||||||
environment={mockEnvironment}
|
environment={mockEnvironment}
|
||||||
userId="user1"
|
userId="user1"
|
||||||
prefilledFilters={mockPrefilledFilters}
|
|
||||||
isTemplatePage={false}
|
isTemplatePage={false}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -100,7 +95,6 @@ describe("TemplateContainerWithPreview", () => {
|
|||||||
project={mockProject}
|
project={mockProject}
|
||||||
environment={mockEnvironment}
|
environment={mockEnvironment}
|
||||||
userId="user1"
|
userId="user1"
|
||||||
prefilledFilters={mockPrefilledFilters}
|
|
||||||
isTemplatePage={true}
|
isTemplatePage={true}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -114,7 +108,6 @@ describe("TemplateContainerWithPreview", () => {
|
|||||||
project={mockProject}
|
project={mockProject}
|
||||||
environment={mockEnvironment}
|
environment={mockEnvironment}
|
||||||
userId="user1"
|
userId="user1"
|
||||||
prefilledFilters={mockPrefilledFilters}
|
|
||||||
isTemplatePage={false}
|
isTemplatePage={false}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -128,7 +121,6 @@ describe("TemplateContainerWithPreview", () => {
|
|||||||
project={mockProject}
|
project={mockProject}
|
||||||
environment={mockEnvironment}
|
environment={mockEnvironment}
|
||||||
userId="user1"
|
userId="user1"
|
||||||
prefilledFilters={mockPrefilledFilters}
|
|
||||||
isTemplatePage={true}
|
isTemplatePage={true}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -144,7 +136,6 @@ describe("TemplateContainerWithPreview", () => {
|
|||||||
project={mockProject}
|
project={mockProject}
|
||||||
environment={mockEnvironment}
|
environment={mockEnvironment}
|
||||||
userId="user1"
|
userId="user1"
|
||||||
prefilledFilters={mockPrefilledFilters}
|
|
||||||
isTemplatePage={true}
|
isTemplatePage={true}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -158,7 +149,6 @@ describe("TemplateContainerWithPreview", () => {
|
|||||||
project={mockProject}
|
project={mockProject}
|
||||||
environment={mockEnvironment}
|
environment={mockEnvironment}
|
||||||
userId="user1"
|
userId="user1"
|
||||||
prefilledFilters={mockPrefilledFilters}
|
|
||||||
isTemplatePage={true}
|
isTemplatePage={true}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -172,7 +162,6 @@ describe("TemplateContainerWithPreview", () => {
|
|||||||
project={mockProject}
|
project={mockProject}
|
||||||
environment={mockEnvironment}
|
environment={mockEnvironment}
|
||||||
userId="user1"
|
userId="user1"
|
||||||
prefilledFilters={mockPrefilledFilters}
|
|
||||||
isTemplatePage={true}
|
isTemplatePage={true}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,19 +5,16 @@ import { TemplateList } from "@/modules/survey/components/template-list";
|
|||||||
import { MenuBar } from "@/modules/survey/templates/components/menu-bar";
|
import { MenuBar } from "@/modules/survey/templates/components/menu-bar";
|
||||||
import { PreviewSurvey } from "@/modules/ui/components/preview-survey";
|
import { PreviewSurvey } from "@/modules/ui/components/preview-survey";
|
||||||
import { SearchBar } from "@/modules/ui/components/search-bar";
|
import { SearchBar } from "@/modules/ui/components/search-bar";
|
||||||
import { Project } from "@prisma/client";
|
import type { Environment, Project } from "@prisma/client";
|
||||||
import { Environment } from "@prisma/client";
|
|
||||||
import { useTranslate } from "@tolgee/react";
|
import { useTranslate } from "@tolgee/react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import type { TProjectConfigChannel, TProjectConfigIndustry } from "@formbricks/types/project";
|
import type { TTemplate } from "@formbricks/types/templates";
|
||||||
import type { TTemplate, TTemplateRole } from "@formbricks/types/templates";
|
|
||||||
import { getMinimalSurvey } from "../lib/minimal-survey";
|
import { getMinimalSurvey } from "../lib/minimal-survey";
|
||||||
|
|
||||||
type TemplateContainerWithPreviewProps = {
|
type TemplateContainerWithPreviewProps = {
|
||||||
project: Project;
|
project: Project;
|
||||||
environment: Pick<Environment, "id" | "appSetupCompleted">;
|
environment: Pick<Environment, "id" | "appSetupCompleted">;
|
||||||
userId: string;
|
userId: string;
|
||||||
prefilledFilters: (TProjectConfigChannel | TProjectConfigIndustry | TTemplateRole | null)[];
|
|
||||||
isTemplatePage?: boolean;
|
isTemplatePage?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -25,7 +22,6 @@ export const TemplateContainerWithPreview = ({
|
|||||||
project,
|
project,
|
||||||
environment,
|
environment,
|
||||||
userId,
|
userId,
|
||||||
prefilledFilters,
|
|
||||||
isTemplatePage = true,
|
isTemplatePage = true,
|
||||||
}: TemplateContainerWithPreviewProps) => {
|
}: TemplateContainerWithPreviewProps) => {
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
@@ -63,7 +59,6 @@ export const TemplateContainerWithPreview = ({
|
|||||||
setActiveQuestionId(template.preset.questions[0].id);
|
setActiveQuestionId(template.preset.questions[0].id);
|
||||||
setActiveTemplate(template);
|
setActiveTemplate(template);
|
||||||
}}
|
}}
|
||||||
prefilledFilters={prefilledFilters}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<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">
|
||||||
|
|||||||
@@ -2,23 +2,15 @@ import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
|||||||
import { getProjectWithTeamIdsByEnvironmentId } from "@/modules/survey/lib/project";
|
import { getProjectWithTeamIdsByEnvironmentId } from "@/modules/survey/lib/project";
|
||||||
import { getTranslate } from "@/tolgee/server";
|
import { getTranslate } from "@/tolgee/server";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { TProjectConfigChannel, TProjectConfigIndustry } from "@formbricks/types/project";
|
|
||||||
import { TTemplateRole } from "@formbricks/types/templates";
|
|
||||||
import { TemplateContainerWithPreview } from "./components/template-container";
|
import { TemplateContainerWithPreview } from "./components/template-container";
|
||||||
|
|
||||||
interface SurveyTemplateProps {
|
interface SurveyTemplateProps {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
environmentId: string;
|
environmentId: string;
|
||||||
}>;
|
}>;
|
||||||
searchParams: Promise<{
|
|
||||||
channel?: TProjectConfigChannel;
|
|
||||||
industry?: TProjectConfigIndustry;
|
|
||||||
role?: TTemplateRole;
|
|
||||||
}>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SurveyTemplatesPage = async (props: SurveyTemplateProps) => {
|
export const SurveyTemplatesPage = async (props: SurveyTemplateProps) => {
|
||||||
const searchParams = await props.searchParams;
|
|
||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
const environmentId = params.environmentId;
|
const environmentId = params.environmentId;
|
||||||
@@ -35,14 +27,7 @@ export const SurveyTemplatesPage = async (props: SurveyTemplateProps) => {
|
|||||||
return redirect(`/environments/${environment.id}/surveys`);
|
return redirect(`/environments/${environment.id}/surveys`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const prefilledFilters = [project.config.channel, project.config.industry, searchParams.role ?? null];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TemplateContainerWithPreview
|
<TemplateContainerWithPreview userId={session.user.id} environment={environment} project={project} />
|
||||||
userId={session.user.id}
|
|
||||||
environment={environment}
|
|
||||||
project={project}
|
|
||||||
prefilledFilters={prefilledFilters}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -59,8 +59,8 @@ export const BadgeContent: React.FC<BadgeContentProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<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
|
||||||
type="button"
|
<div
|
||||||
role={isCopyEnabled ? "button" : undefined}
|
role={isCopyEnabled ? "button" : undefined}
|
||||||
className={getButtonClasses()}
|
className={getButtonClasses()}
|
||||||
onClick={handleCopy}
|
onClick={handleCopy}
|
||||||
@@ -69,7 +69,7 @@ export const BadgeContent: React.FC<BadgeContentProps> = ({
|
|||||||
onMouseLeave={isCopyEnabled ? () => setIsHovered(false) : undefined}>
|
onMouseLeave={isCopyEnabled ? () => setIsHovered(false) : undefined}>
|
||||||
<span>{id}</span>
|
<span>{id}</span>
|
||||||
{renderIcon()}
|
{renderIcon()}
|
||||||
</button>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const getTooltipContent = () => {
|
const getTooltipContent = () => {
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ describe("IdBadge", () => {
|
|||||||
test("removes interactive elements when copy is disabled", () => {
|
test("removes interactive elements when copy is disabled", () => {
|
||||||
const { container } = render(<IdBadge id="1734" copyDisabled={true} />);
|
const { container } = render(<IdBadge id="1734" copyDisabled={true} />);
|
||||||
|
|
||||||
const badge = container.querySelector("button");
|
const badge = container.querySelector("div");
|
||||||
|
|
||||||
// Should not have cursor-pointer class
|
// Should not have cursor-pointer class
|
||||||
expect(badge).not.toHaveClass("cursor-pointer");
|
expect(badge).not.toHaveClass("cursor-pointer");
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ const nextConfig = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "Content-Security-Policy",
|
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",
|
key: "Strict-Transport-Security",
|
||||||
|
|||||||
@@ -229,12 +229,8 @@ services:
|
|||||||
- ./saml-connection:/home/nextjs/apps/web/saml-connection
|
- ./saml-connection:/home/nextjs/apps/web/saml-connection
|
||||||
<<: *environment
|
<<: *environment
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres:
|
postgres:
|
||||||
driver: local
|
driver: local
|
||||||
redis:
|
redis:
|
||||||
driver: local
|
driver: local
|
||||||
uploads:
|
|
||||||
driver: local
|
|
||||||
|
|||||||
@@ -293,7 +293,7 @@ EOT
|
|||||||
minio_service_user="formbricks-service-$(openssl rand -hex 4)"
|
minio_service_user="formbricks-service-$(openssl rand -hex 4)"
|
||||||
minio_service_password=$(openssl rand -base64 20)
|
minio_service_password=$(openssl rand -base64 20)
|
||||||
minio_bucket_name="formbricks-uploads"
|
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 "✅ MinIO will be configured with:"
|
||||||
echo " S3 Access Key (least privilege): $minio_service_user"
|
echo " S3 Access Key (least privilege): $minio_service_user"
|
||||||
@@ -306,7 +306,7 @@ EOT
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo "📥 Downloading docker-compose.yml from Formbricks GitHub repository..."
|
echo "📥 Downloading docker-compose.yml from Formbricks GitHub repository..."
|
||||||
curl -fsSL -o docker-compose.yml https://raw.githubusercontent.com/formbricks/formbricks/main/docker/docker-compose.yml
|
curl -fsSL -o docker-compose.yml https://raw.githubusercontent.com/formbricks/formbricks/stable/docker/docker-compose.yml
|
||||||
|
|
||||||
echo "🚙 Updating docker-compose.yml with your custom inputs..."
|
echo "🚙 Updating docker-compose.yml with your custom inputs..."
|
||||||
sed -i "/WEBAPP_URL:/s|WEBAPP_URL:.*|WEBAPP_URL: \"https://$domain_name\"|" docker-compose.yml
|
sed -i "/WEBAPP_URL:/s|WEBAPP_URL:.*|WEBAPP_URL: \"https://$domain_name\"|" docker-compose.yml
|
||||||
@@ -340,9 +340,11 @@ EOT
|
|||||||
sed -i "s|# S3_BUCKET_NAME:|S3_BUCKET_NAME: \"$ext_s3_bucket\"|" docker-compose.yml
|
sed -i "s|# S3_BUCKET_NAME:|S3_BUCKET_NAME: \"$ext_s3_bucket\"|" docker-compose.yml
|
||||||
if [[ -n $ext_s3_endpoint ]]; then
|
if [[ -n $ext_s3_endpoint ]]; then
|
||||||
sed -i "s|# S3_ENDPOINT_URL:|S3_ENDPOINT_URL: \"$ext_s3_endpoint\"|" docker-compose.yml
|
sed -i "s|# S3_ENDPOINT_URL:|S3_ENDPOINT_URL: \"$ext_s3_endpoint\"|" docker-compose.yml
|
||||||
sed -i "s|S3_FORCE_PATH_STYLE: 0|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
|
else
|
||||||
sed -i "s|S3_FORCE_PATH_STYLE: 0|# 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
|
fi
|
||||||
echo "🚗 External S3 configuration updated successfully!"
|
echo "🚗 External S3 configuration updated successfully!"
|
||||||
elif [[ $minio_storage == "y" ]]; then
|
elif [[ $minio_storage == "y" ]]; then
|
||||||
@@ -356,7 +358,8 @@ EOT
|
|||||||
else
|
else
|
||||||
sed -i "s|# S3_ENDPOINT_URL:|S3_ENDPOINT_URL: \"http://$files_domain\"|" docker-compose.yml
|
sed -i "s|# S3_ENDPOINT_URL:|S3_ENDPOINT_URL: \"http://$files_domain\"|" docker-compose.yml
|
||||||
fi
|
fi
|
||||||
sed -i "s|S3_FORCE_PATH_STYLE: 0|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!"
|
echo "🚗 MinIO S3 configuration updated successfully!"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -391,34 +394,60 @@ EOT
|
|||||||
{ print }
|
{ print }
|
||||||
' docker-compose.yml >tmp.yml && mv tmp.yml docker-compose.yml
|
' 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
|
if [[ $minio_storage == "y" ]]; then
|
||||||
sed -i '/formbricks:/,/depends_on:/{
|
# Remove any existing simple depends_on list and replace with mapping
|
||||||
/- postgres/a\ - minio-init
|
awk '
|
||||||
}' docker-compose.yml
|
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
|
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="services_snippet.yml"
|
||||||
: > "$services_snippet_file"
|
: > "$services_snippet_file"
|
||||||
|
|
||||||
|
insert_traefik="y"
|
||||||
|
if grep -q "^ traefik:" docker-compose.yml; then insert_traefik="n"; fi
|
||||||
|
|
||||||
if [[ $minio_storage == "y" ]]; then
|
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:
|
minio:
|
||||||
restart: always
|
restart: always
|
||||||
image: minio/minio:RELEASE.2025-09-07T16-13-09Z
|
image: minio/minio@sha256:13582eff79c6605a2d315bdd0e70164142ea7e98fc8411e9e10d089502a6d883
|
||||||
command: server /data
|
command: server /data
|
||||||
environment:
|
environment:
|
||||||
MINIO_ROOT_USER: "$minio_root_user"
|
MINIO_ROOT_USER: "$minio_root_user"
|
||||||
MINIO_ROOT_PASSWORD: "$minio_root_password"
|
MINIO_ROOT_PASSWORD: "$minio_root_password"
|
||||||
volumes:
|
volumes:
|
||||||
- minio-data:/data
|
- minio-data:/data
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 20s
|
|
||||||
retries: 3
|
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
# S3 API on files subdomain
|
# S3 API on files subdomain
|
||||||
@@ -437,52 +466,29 @@ EOT
|
|||||||
- "traefik.http.middlewares.minio-cors.headers.addvaryheader=true"
|
- "traefik.http.middlewares.minio-cors.headers.addvaryheader=true"
|
||||||
- "traefik.http.middlewares.minio-ratelimit.ratelimit.average=100"
|
- "traefik.http.middlewares.minio-ratelimit.ratelimit.average=100"
|
||||||
- "traefik.http.middlewares.minio-ratelimit.ratelimit.burst=200"
|
- "traefik.http.middlewares.minio-ratelimit.ratelimit.burst=200"
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ $insert_minio_init == "y" ]]; then
|
||||||
|
cat >> "$services_snippet_file" << EOF
|
||||||
minio-init:
|
minio-init:
|
||||||
image: minio/mc:latest
|
image: minio/mc@sha256:95b5f3f7969a5c5a9f3a700ba72d5c84172819e13385aaf916e237cf111ab868
|
||||||
depends_on:
|
depends_on:
|
||||||
minio:
|
- minio
|
||||||
condition: service_healthy
|
|
||||||
environment:
|
environment:
|
||||||
MINIO_ROOT_USER: "$minio_root_user"
|
MINIO_ROOT_USER: "$minio_root_user"
|
||||||
MINIO_ROOT_PASSWORD: "$minio_root_password"
|
MINIO_ROOT_PASSWORD: "$minio_root_password"
|
||||||
MINIO_SERVICE_USER: "$minio_service_user"
|
MINIO_SERVICE_USER: "$minio_service_user"
|
||||||
MINIO_SERVICE_PASSWORD: "$minio_service_password"
|
MINIO_SERVICE_PASSWORD: "$minio_service_password"
|
||||||
MINIO_BUCKET_NAME: "$minio_bucket_name"
|
MINIO_BUCKET_NAME: "$minio_bucket_name"
|
||||||
entrypoint:
|
entrypoint: ["/bin/sh", "/tmp/minio-init.sh"]
|
||||||
- /bin/sh
|
volumes:
|
||||||
- -c
|
- ./minio-init.sh:/tmp/minio-init.sh:ro
|
||||||
- |
|
EOF
|
||||||
echo '🔗 Setting up MinIO alias...';
|
fi
|
||||||
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;
|
|
||||||
|
|
||||||
|
if [[ $insert_traefik == "y" ]]; then
|
||||||
|
cat >> "$services_snippet_file" << EOF
|
||||||
traefik:
|
traefik:
|
||||||
image: "traefik:v2.7"
|
image: "traefik:v2.7"
|
||||||
restart: always
|
restart: always
|
||||||
@@ -499,6 +505,7 @@ EOT
|
|||||||
- ./acme.json:/acme.json
|
- ./acme.json:/acme.json
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
EOF
|
EOF
|
||||||
|
fi
|
||||||
|
|
||||||
# Downgrade MinIO router to plain HTTP when HTTPS is not configured
|
# Downgrade MinIO router to plain HTTP when HTTPS is not configured
|
||||||
if [[ $https_setup != "y" ]]; then
|
if [[ $https_setup != "y" ]]; then
|
||||||
@@ -508,7 +515,8 @@ EOF
|
|||||||
sed -i "s|accesscontrolalloworiginlist=https://$domain_name|accesscontrolalloworiginlist=http://$domain_name|" "$services_snippet_file"
|
sed -i "s|accesscontrolalloworiginlist=https://$domain_name|accesscontrolalloworiginlist=http://$domain_name|" "$services_snippet_file"
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
cat > "$services_snippet_file" << EOF
|
if [[ $insert_traefik == "y" ]]; then
|
||||||
|
cat > "$services_snippet_file" << EOF
|
||||||
|
|
||||||
traefik:
|
traefik:
|
||||||
image: "traefik:v2.7"
|
image: "traefik:v2.7"
|
||||||
@@ -525,6 +533,9 @@ EOF
|
|||||||
- ./acme.json:/acme.json
|
- ./acme.json:/acme.json
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
EOF
|
EOF
|
||||||
|
else
|
||||||
|
: > "$services_snippet_file"
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
awk '
|
awk '
|
||||||
@@ -540,30 +551,122 @@ EOF
|
|||||||
|
|
||||||
rm -f "$services_snippet_file"
|
rm -f "$services_snippet_file"
|
||||||
|
|
||||||
# Deterministically rewrite the volumes section to include required volumes
|
# Ensure required volumes exist without removing user-defined volumes
|
||||||
awk -v add_minio="$minio_storage" '
|
if grep -q '^volumes:' docker-compose.yml; then
|
||||||
BEGIN { in_vol=0 }
|
# Ensure postgres
|
||||||
/^volumes:/ {
|
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
|
||||||
print "volumes:";
|
awk '
|
||||||
print " postgres:";
|
/^volumes:/ { print; invol=1; next }
|
||||||
print " driver: local";
|
invol && /^[^[:space:]]/ { if(!added){ print " postgres:"; print " driver: local"; added=1 } ; invol=0 }
|
||||||
print " uploads:";
|
{ print }
|
||||||
print " driver: local";
|
END { if (invol && !added) { print " postgres:"; print " driver: local" } }
|
||||||
if (add_minio == "y") {
|
' docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml
|
||||||
print " minio-data:";
|
fi
|
||||||
print " driver: local";
|
# 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)
|
EOF
|
||||||
{ if (!skip) print }
|
|
||||||
' docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml
|
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
|
newgrp docker <<END
|
||||||
|
|
||||||
docker compose up -d
|
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 "🔗 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."
|
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."
|
||||||
@@ -627,6 +730,40 @@ get_logs() {
|
|||||||
sudo docker compose 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
|
case "$1" in
|
||||||
install)
|
install)
|
||||||
install_formbricks
|
install_formbricks
|
||||||
@@ -643,6 +780,9 @@ restart)
|
|||||||
logs)
|
logs)
|
||||||
get_logs
|
get_logs
|
||||||
;;
|
;;
|
||||||
|
cleanup-minio-init)
|
||||||
|
cleanup_minio_init
|
||||||
|
;;
|
||||||
uninstall)
|
uninstall)
|
||||||
uninstall_formbricks
|
uninstall_formbricks
|
||||||
;;
|
;;
|
||||||
|
|||||||
1472
docker/migrate-to-v4.sh
Normal file
1472
docker/migrate-to-v4.sh
Normal file
File diff suppressed because it is too large
Load Diff
@@ -133,11 +133,13 @@ REDIS_URL=redis://your-redis-host:6379
|
|||||||
Configure S3 storage by adding the following environment variables to your instances:
|
Configure S3 storage by adding the following environment variables to your instances:
|
||||||
|
|
||||||
```sh env
|
```sh env
|
||||||
# Required for file uploads in serverless environments
|
# Required
|
||||||
|
S3_BUCKET_NAME=your-bucket-name
|
||||||
|
|
||||||
|
# Optional - if not provided, AWS SDK will use defaults (us-east-1) or auto-detect
|
||||||
S3_ACCESS_KEY=your-access-key
|
S3_ACCESS_KEY=your-access-key
|
||||||
S3_SECRET_KEY=your-secret-key
|
S3_SECRET_KEY=your-secret-key
|
||||||
S3_REGION=your-region
|
S3_REGION=your-region
|
||||||
S3_BUCKET_NAME=your-bucket-name
|
|
||||||
|
|
||||||
# For S3-compatible storage (e.g., StorJ, MinIO)
|
# For S3-compatible storage (e.g., StorJ, MinIO)
|
||||||
# Leave empty for Amazon S3
|
# Leave empty for Amazon S3
|
||||||
|
|||||||
@@ -310,6 +310,18 @@ To restart Formbricks, simply run the following command:
|
|||||||
|
|
||||||
The script will automatically restart all the Formbricks related containers and brings the entire stack up with the previous configuration.
|
The script will automatically restart all the Formbricks related containers and brings the entire stack up with the previous configuration.
|
||||||
|
|
||||||
|
## Cleanup MinIO init (optional)
|
||||||
|
|
||||||
|
During the one-click setup, a temporary `minio-init` service configures MinIO (bucket, policy, service user). It is idempotent and safe to leave in place; it will do nothing on subsequent starts once configuration exists.
|
||||||
|
|
||||||
|
If you prefer to remove the `minio-init` service and its references after a successful setup, run:
|
||||||
|
|
||||||
|
```
|
||||||
|
./formbricks.sh cleanup-minio-init
|
||||||
|
```
|
||||||
|
|
||||||
|
This only removes the init job and its Compose references; it does not delete any data or affect your MinIO configuration.
|
||||||
|
|
||||||
## Uninstall
|
## Uninstall
|
||||||
|
|
||||||
To uninstall Formbricks, simply run the following command, but keep in mind that this will delete all your data!
|
To uninstall Formbricks, simply run the following command, but keep in mind that this will delete all your data!
|
||||||
|
|||||||
@@ -82,8 +82,8 @@ describe("client.ts", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should return error when access key is missing", async () => {
|
test("should create S3 client without credentials (IAM role authentication)", async () => {
|
||||||
// Mock constants with missing access key
|
// Mock constants with missing access key (IAM role scenario)
|
||||||
vi.doMock("./constants", () => ({
|
vi.doMock("./constants", () => ({
|
||||||
...mockConstants,
|
...mockConstants,
|
||||||
S3_ACCESS_KEY: undefined,
|
S3_ACCESS_KEY: undefined,
|
||||||
@@ -93,14 +93,20 @@ describe("client.ts", () => {
|
|||||||
|
|
||||||
const result = createS3ClientFromEnv();
|
const result = createS3ClientFromEnv();
|
||||||
|
|
||||||
expect(result.ok).toBe(false);
|
expect(mockS3Client).toHaveBeenCalledWith({
|
||||||
if (!result.ok) {
|
region: mockConstants.S3_REGION,
|
||||||
expect(result.error.code).toBe("s3_credentials_error");
|
endpoint: mockConstants.S3_ENDPOINT_URL,
|
||||||
|
forcePathStyle: mockConstants.S3_FORCE_PATH_STYLE,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (result.ok) {
|
||||||
|
expect(result.data).toBeDefined();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should return error when secret key is missing", async () => {
|
test("should create S3 client without secret key (IAM role authentication)", async () => {
|
||||||
// Mock constants with missing secret key
|
// Mock constants with missing secret key (IAM role scenario)
|
||||||
vi.doMock("./constants", () => ({
|
vi.doMock("./constants", () => ({
|
||||||
...mockConstants,
|
...mockConstants,
|
||||||
S3_SECRET_KEY: undefined,
|
S3_SECRET_KEY: undefined,
|
||||||
@@ -110,14 +116,20 @@ describe("client.ts", () => {
|
|||||||
|
|
||||||
const result = createS3ClientFromEnv();
|
const result = createS3ClientFromEnv();
|
||||||
|
|
||||||
expect(result.ok).toBe(false);
|
expect(mockS3Client).toHaveBeenCalledWith({
|
||||||
if (!result.ok) {
|
region: mockConstants.S3_REGION,
|
||||||
expect(result.error.code).toBe("s3_credentials_error");
|
endpoint: mockConstants.S3_ENDPOINT_URL,
|
||||||
|
forcePathStyle: mockConstants.S3_FORCE_PATH_STYLE,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (result.ok) {
|
||||||
|
expect(result.data).toBeDefined();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should return error when both credentials are missing", async () => {
|
test("should create S3 client without any credentials (IAM role authentication)", async () => {
|
||||||
// Mock constants with no credentials
|
// Mock constants with no credentials (full IAM role scenario)
|
||||||
vi.doMock("./constants", () => ({
|
vi.doMock("./constants", () => ({
|
||||||
...mockConstants,
|
...mockConstants,
|
||||||
S3_ACCESS_KEY: undefined,
|
S3_ACCESS_KEY: undefined,
|
||||||
@@ -128,14 +140,20 @@ describe("client.ts", () => {
|
|||||||
|
|
||||||
const result = createS3ClientFromEnv();
|
const result = createS3ClientFromEnv();
|
||||||
|
|
||||||
expect(result.ok).toBe(false);
|
expect(mockS3Client).toHaveBeenCalledWith({
|
||||||
if (!result.ok) {
|
region: mockConstants.S3_REGION,
|
||||||
expect(result.error.code).toBe("s3_credentials_error");
|
endpoint: mockConstants.S3_ENDPOINT_URL,
|
||||||
|
forcePathStyle: mockConstants.S3_FORCE_PATH_STYLE,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (result.ok) {
|
||||||
|
expect(result.data).toBeDefined();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should return error when credentials are empty strings", async () => {
|
test("should create S3 client with empty string credentials (IAM role authentication)", async () => {
|
||||||
// Mock constants with empty string credentials
|
// Mock constants with empty string credentials (treated as undefined)
|
||||||
vi.doMock("./constants", () => ({
|
vi.doMock("./constants", () => ({
|
||||||
...mockConstants,
|
...mockConstants,
|
||||||
S3_ACCESS_KEY: "",
|
S3_ACCESS_KEY: "",
|
||||||
@@ -146,14 +164,20 @@ describe("client.ts", () => {
|
|||||||
|
|
||||||
const result = createS3ClientFromEnv();
|
const result = createS3ClientFromEnv();
|
||||||
|
|
||||||
expect(result.ok).toBe(false);
|
expect(mockS3Client).toHaveBeenCalledWith({
|
||||||
if (!result.ok) {
|
region: mockConstants.S3_REGION,
|
||||||
expect(result.error.code).toBe("s3_credentials_error");
|
endpoint: mockConstants.S3_ENDPOINT_URL,
|
||||||
|
forcePathStyle: mockConstants.S3_FORCE_PATH_STYLE,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (result.ok) {
|
||||||
|
expect(result.data).toBeDefined();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should return error when mixed empty and undefined credentials", async () => {
|
test("should create S3 client with mixed empty and undefined credentials (IAM role authentication)", async () => {
|
||||||
// Mock constants with mixed empty and undefined
|
// Mock constants with mixed empty and undefined (both treated as missing)
|
||||||
vi.doMock("./constants", () => ({
|
vi.doMock("./constants", () => ({
|
||||||
...mockConstants,
|
...mockConstants,
|
||||||
S3_ACCESS_KEY: "",
|
S3_ACCESS_KEY: "",
|
||||||
@@ -164,9 +188,15 @@ describe("client.ts", () => {
|
|||||||
|
|
||||||
const result = createS3ClientFromEnv();
|
const result = createS3ClientFromEnv();
|
||||||
|
|
||||||
expect(result.ok).toBe(false);
|
expect(mockS3Client).toHaveBeenCalledWith({
|
||||||
if (!result.ok) {
|
region: mockConstants.S3_REGION,
|
||||||
expect(result.error.code).toBe("s3_credentials_error");
|
endpoint: mockConstants.S3_ENDPOINT_URL,
|
||||||
|
forcePathStyle: mockConstants.S3_FORCE_PATH_STYLE,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (result.ok) {
|
||||||
|
expect(result.data).toBeDefined();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -197,6 +227,75 @@ describe("client.ts", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("should create S3 client when region is missing (uses AWS SDK defaults)", async () => {
|
||||||
|
// Mock constants with missing region - should still work
|
||||||
|
vi.doMock("./constants", () => ({
|
||||||
|
...mockConstants,
|
||||||
|
S3_REGION: undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { createS3ClientFromEnv } = await import("./client");
|
||||||
|
|
||||||
|
const result = createS3ClientFromEnv();
|
||||||
|
|
||||||
|
expect(mockS3Client).toHaveBeenCalledWith({
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: mockConstants.S3_ACCESS_KEY,
|
||||||
|
secretAccessKey: mockConstants.S3_SECRET_KEY,
|
||||||
|
},
|
||||||
|
endpoint: mockConstants.S3_ENDPOINT_URL,
|
||||||
|
forcePathStyle: mockConstants.S3_FORCE_PATH_STYLE,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (result.ok) {
|
||||||
|
expect(result.data).toBeDefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should create S3 client with only bucket name (minimal config for IAM roles)", async () => {
|
||||||
|
// Mock constants with only bucket name - minimal required config
|
||||||
|
vi.doMock("./constants", () => ({
|
||||||
|
S3_ACCESS_KEY: undefined,
|
||||||
|
S3_SECRET_KEY: undefined,
|
||||||
|
S3_REGION: undefined,
|
||||||
|
S3_BUCKET_NAME: "test-bucket",
|
||||||
|
S3_ENDPOINT_URL: undefined,
|
||||||
|
S3_FORCE_PATH_STYLE: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { createS3ClientFromEnv } = await import("./client");
|
||||||
|
|
||||||
|
const result = createS3ClientFromEnv();
|
||||||
|
|
||||||
|
expect(mockS3Client).toHaveBeenCalledWith({
|
||||||
|
endpoint: undefined,
|
||||||
|
forcePathStyle: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (result.ok) {
|
||||||
|
expect(result.data).toBeDefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return error when bucket name is missing", async () => {
|
||||||
|
// Mock constants with missing bucket name
|
||||||
|
vi.doMock("./constants", () => ({
|
||||||
|
...mockConstants,
|
||||||
|
S3_BUCKET_NAME: undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { createS3ClientFromEnv } = await import("./client");
|
||||||
|
|
||||||
|
const result = createS3ClientFromEnv();
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.error.code).toBe("s3_credentials_error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("should return unknown error when S3Client constructor throws", async () => {
|
test("should return unknown error when S3Client constructor throws", async () => {
|
||||||
// Provide valid credentials so we reach the constructor path
|
// Provide valid credentials so we reach the constructor path
|
||||||
vi.doMock("./constants", () => ({
|
vi.doMock("./constants", () => ({
|
||||||
@@ -254,11 +353,10 @@ describe("client.ts", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("should return undefined when creating from env fails and no client provided", async () => {
|
test("should return undefined when creating from env fails and no client provided", async () => {
|
||||||
// Mock constants with missing credentials
|
// Mock constants with missing required field (bucket name only)
|
||||||
vi.doMock("./constants", () => ({
|
vi.doMock("./constants", () => ({
|
||||||
...mockConstants,
|
...mockConstants,
|
||||||
S3_ACCESS_KEY: undefined,
|
S3_BUCKET_NAME: undefined,
|
||||||
S3_SECRET_KEY: undefined,
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { createS3Client } = await import("./client");
|
const { createS3Client } = await import("./client");
|
||||||
@@ -290,8 +388,7 @@ describe("client.ts", () => {
|
|||||||
test("returns undefined when env is invalid and does not construct client", async () => {
|
test("returns undefined when env is invalid and does not construct client", async () => {
|
||||||
vi.doMock("./constants", () => ({
|
vi.doMock("./constants", () => ({
|
||||||
...mockConstants,
|
...mockConstants,
|
||||||
S3_ACCESS_KEY: undefined,
|
S3_BUCKET_NAME: undefined,
|
||||||
S3_SECRET_KEY: undefined,
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { getCachedS3Client } = await import("./client");
|
const { getCachedS3Client } = await import("./client");
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { S3Client } from "@aws-sdk/client-s3";
|
import { S3Client, type S3ClientConfig } from "@aws-sdk/client-s3";
|
||||||
import { logger } from "@formbricks/logger";
|
import { logger } from "@formbricks/logger";
|
||||||
import { type Result, type StorageError, StorageErrorCode, err, ok } from "../types/error";
|
import { type Result, type StorageError, StorageErrorCode, err, ok } from "../types/error";
|
||||||
import {
|
import {
|
||||||
@@ -19,19 +19,35 @@ let cachedS3Client: S3Client | undefined;
|
|||||||
*/
|
*/
|
||||||
export const createS3ClientFromEnv = (): Result<S3Client, StorageError> => {
|
export const createS3ClientFromEnv = (): Result<S3Client, StorageError> => {
|
||||||
try {
|
try {
|
||||||
if (!S3_ACCESS_KEY || !S3_SECRET_KEY || !S3_BUCKET_NAME || !S3_REGION) {
|
// Only S3_BUCKET_NAME is required - S3_REGION is optional and will default to AWS SDK defaults
|
||||||
logger.error("S3 Client: S3 credentials are not set");
|
if (!S3_BUCKET_NAME) {
|
||||||
|
logger.error("S3 Client: S3_BUCKET_NAME is required");
|
||||||
return err({
|
return err({
|
||||||
code: StorageErrorCode.S3CredentialsError,
|
code: StorageErrorCode.S3CredentialsError,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const s3ClientInstance = new S3Client({
|
// Build S3 client configuration
|
||||||
credentials: { accessKeyId: S3_ACCESS_KEY, secretAccessKey: S3_SECRET_KEY },
|
const s3Config: S3ClientConfig = {
|
||||||
region: S3_REGION,
|
|
||||||
endpoint: S3_ENDPOINT_URL,
|
endpoint: S3_ENDPOINT_URL,
|
||||||
forcePathStyle: S3_FORCE_PATH_STYLE,
|
forcePathStyle: S3_FORCE_PATH_STYLE,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
// Only set region if it's provided, otherwise let AWS SDK use its defaults
|
||||||
|
if (S3_REGION) {
|
||||||
|
s3Config.region = S3_REGION;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only add credentials if both access key and secret key are provided
|
||||||
|
// This allows the AWS SDK to use IAM roles, instance profiles, or other credential providers
|
||||||
|
if (S3_ACCESS_KEY && S3_SECRET_KEY) {
|
||||||
|
s3Config.credentials = {
|
||||||
|
accessKeyId: S3_ACCESS_KEY,
|
||||||
|
secretAccessKey: S3_SECRET_KEY,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const s3ClientInstance = new S3Client(s3Config);
|
||||||
|
|
||||||
return ok(s3ClientInstance);
|
return ok(s3ClientInstance);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import {
|
|||||||
|
|
||||||
interface QuestionConditionalProps {
|
interface QuestionConditionalProps {
|
||||||
question: TSurveyQuestion;
|
question: TSurveyQuestion;
|
||||||
value: string | number | string[] | Record<string, string>;
|
value: TResponseDataValue;
|
||||||
onChange: (responseData: TResponseData) => void;
|
onChange: (responseData: TResponseData) => void;
|
||||||
onSubmit: (data: TResponseData, ttc: TResponseTtc) => void;
|
onSubmit: (data: TResponseData, ttc: TResponseTtc) => void;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ export function MultipleChoiceSingleQuestion({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
|
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
|
||||||
setTtc(updatedTtcObj);
|
setTtc(updatedTtcObj);
|
||||||
onSubmit({ [question.id]: value ?? "" }, updatedTtcObj);
|
onSubmit({ [question.id]: value }, updatedTtcObj);
|
||||||
}}
|
}}
|
||||||
className="fb-w-full">
|
className="fb-w-full">
|
||||||
{isMediaAvailable ? <QuestionMedia imgUrl={question.imageUrl} videoUrl={question.videoUrl} /> : null}
|
{isMediaAvailable ? <QuestionMedia imgUrl={question.imageUrl} videoUrl={question.videoUrl} /> : null}
|
||||||
@@ -208,9 +208,13 @@ export function MultipleChoiceSingleQuestion({
|
|||||||
value={getLocalizedValue(otherOption.label, languageCode)}
|
value={getLocalizedValue(otherOption.label, languageCode)}
|
||||||
className="fb-border-brand fb-text-brand fb-h-4 fb-w-4 fb-flex-shrink-0 fb-border focus:fb-ring-0 focus:fb-ring-offset-0"
|
className="fb-border-brand fb-text-brand fb-h-4 fb-w-4 fb-flex-shrink-0 fb-border focus:fb-ring-0 focus:fb-ring-offset-0"
|
||||||
aria-labelledby={`${otherOption.id}-label`}
|
aria-labelledby={`${otherOption.id}-label`}
|
||||||
onChange={() => {
|
onClick={() => {
|
||||||
setOtherSelected(!otherSelected);
|
if (otherSelected) {
|
||||||
onChange({ [question.id]: "" });
|
onChange({ [question.id]: undefined });
|
||||||
|
} else {
|
||||||
|
setOtherSelected(!otherSelected);
|
||||||
|
onChange({ [question.id]: "" });
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
checked={otherSelected}
|
checked={otherSelected}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -407,7 +407,7 @@ const evaluateSingleCondition = (
|
|||||||
return (
|
return (
|
||||||
Array.isArray(leftValue) &&
|
Array.isArray(leftValue) &&
|
||||||
Array.isArray(rightValue) &&
|
Array.isArray(rightValue) &&
|
||||||
rightValue.some((v) => !leftValue.includes(v))
|
!rightValue.some((v) => leftValue.includes(v))
|
||||||
);
|
);
|
||||||
case "isAccepted":
|
case "isAccepted":
|
||||||
return leftValue === "accepted";
|
return leftValue === "accepted";
|
||||||
|
|||||||
@@ -42,14 +42,34 @@ export const safeUrlRefinement = (url: string, ctx: z.RefinementCtx): void => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allow localhost for easy recall testing on self-hosted environments
|
// Allow localhost for easy recall testing on self-hosted environments and mailto links
|
||||||
if (!url.startsWith("https://") && !url.startsWith("http://localhost")) {
|
if (!url.startsWith("https://") && !url.startsWith("http://localhost") && !url.startsWith("mailto:")) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
message: "URL must start with https://",
|
message: "URL must start with https:// or mailto:",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Skip further validation for mailto URLs as they have different structure
|
||||||
|
if (url.startsWith("mailto:")) {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
if (parsed.protocol !== "mailto:") {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Invalid mailto URL format",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Invalid mailto URL format",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const urlObj = new URL(url);
|
const urlObj = new URL(url);
|
||||||
const hostname = urlObj.hostname;
|
const hostname = urlObj.hostname;
|
||||||
|
|||||||
@@ -4,12 +4,9 @@ import { ZSurveyQuota } from "./quota";
|
|||||||
import { ZSurvey } from "./surveys/types";
|
import { ZSurvey } from "./surveys/types";
|
||||||
import { ZTag } from "./tags";
|
import { ZTag } from "./tags";
|
||||||
|
|
||||||
export const ZResponseDataValue = z.union([
|
export const ZResponseDataValue = z
|
||||||
z.string(),
|
.union([z.string(), z.number(), z.array(z.string()), z.record(z.string())])
|
||||||
z.number(),
|
.optional();
|
||||||
z.array(z.string()),
|
|
||||||
z.record(z.string()),
|
|
||||||
]);
|
|
||||||
|
|
||||||
export const ZResponseFilterCondition = z.enum([
|
export const ZResponseFilterCondition = z.enum([
|
||||||
"accepted",
|
"accepted",
|
||||||
|
|||||||
Reference in New Issue
Block a user