mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-24 07:00:34 -06:00
Compare commits
46 Commits
release/4.
...
fix-tar-fs
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42b5528c1d | ||
|
|
3f122ed9ee | ||
|
|
bdad80d6d1 | ||
|
|
d9ea00d86e | ||
|
|
4a3c2fccba | ||
|
|
3a09af674a | ||
|
|
1ced76c44d | ||
|
|
fa1663d858 | ||
|
|
ebf591a7e0 | ||
|
|
5c9795cd23 | ||
|
|
b67177ba55 | ||
|
|
6cf1f49c8e | ||
|
|
4afb95b92a | ||
|
|
38089241b4 | ||
|
|
07487d4871 | ||
|
|
fa0879e3a0 | ||
|
|
3733c22a6f | ||
|
|
5e5baa76ab | ||
|
|
2153d2aa16 | ||
|
|
7fa4862fd9 | ||
|
|
411e9a26ee | ||
|
|
eb1349f205 | ||
|
|
5c25f25212 | ||
|
|
6af81e46ee | ||
|
|
7423fc9472 | ||
|
|
1557ffcca1 | ||
|
|
5d53ed76ed | ||
|
|
ebd399e611 | ||
|
|
843110b0d6 | ||
|
|
51babf2f98 | ||
|
|
6bc5f1e168 | ||
|
|
c9016802e7 | ||
|
|
6a49fb4700 | ||
|
|
646921cd37 | ||
|
|
34d3145fcd | ||
|
|
c3c06eb309 | ||
|
|
bf4c6238d5 | ||
|
|
8972ef0fef | ||
|
|
4e59924a5a | ||
|
|
8b28353b79 | ||
|
|
abbc7a065b | ||
|
|
00e8ee27a2 | ||
|
|
379aeba71a | ||
|
|
717adddeae | ||
|
|
41798266a0 | ||
|
|
a93fa8ec76 |
179
.cursor/rules/review-and-refine.mdc
Normal file
179
.cursor/rules/review-and-refine.mdc
Normal file
@@ -0,0 +1,179 @@
|
||||
---
|
||||
description: Apply these quality standards before finalizing code changes to ensure DRY principles, React best practices, TypeScript conventions, and maintainable code.
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Review & Refine
|
||||
|
||||
Before finalizing any code changes, review your implementation against these quality standards:
|
||||
|
||||
## Core Principles
|
||||
|
||||
### DRY (Don't Repeat Yourself)
|
||||
|
||||
- Extract duplicated logic into reusable functions or hooks
|
||||
- If the same code appears in multiple places, consolidate it
|
||||
- Create helper functions at appropriate scope (component-level, module-level, or utility files)
|
||||
- Avoid copy-pasting code blocks
|
||||
|
||||
### Code Reduction
|
||||
|
||||
- Remove unnecessary code, comments, and abstractions
|
||||
- Prefer built-in solutions over custom implementations
|
||||
- Consolidate similar logic
|
||||
- Remove dead code and unused imports
|
||||
- Question if every line of code is truly needed
|
||||
|
||||
## React Best Practices
|
||||
|
||||
### Component Design
|
||||
|
||||
- Keep components focused on a single responsibility
|
||||
- Extract complex logic into custom hooks
|
||||
- Prefer composition over prop drilling
|
||||
- Use children props and render props when appropriate
|
||||
- Keep component files under 300 lines when possible
|
||||
|
||||
### Hooks Usage
|
||||
|
||||
- Follow Rules of Hooks (only call at top level, only in React functions)
|
||||
- Extract complex `useEffect` logic into custom hooks
|
||||
- Use `useMemo` and `useCallback` only when you have a measured performance issue
|
||||
- Declare dependencies arrays correctly - don't ignore exhaustive-deps warnings
|
||||
- Keep `useEffect` focused on a single concern
|
||||
|
||||
### State Management
|
||||
|
||||
- Colocate state as close as possible to where it's used
|
||||
- Lift state only when necessary
|
||||
- Use `useReducer` for complex state logic with multiple sub-values
|
||||
- Avoid derived state - compute values during render instead
|
||||
- Don't store values in state that can be computed from props
|
||||
|
||||
### Event Handlers
|
||||
|
||||
- Name event handlers with `handle` prefix (e.g., `handleClick`, `handleSubmit`)
|
||||
- Extract complex event handler logic into separate functions
|
||||
- Avoid inline arrow functions in JSX when they contain complex logic
|
||||
|
||||
## TypeScript Best Practices
|
||||
|
||||
### Type Safety
|
||||
|
||||
- Prefer type inference over explicit types when possible
|
||||
- Use `const` assertions for literal types
|
||||
- Avoid `any` - use `unknown` if type is truly unknown
|
||||
- Use discriminated unions for complex conditional logic
|
||||
- Leverage type guards and narrowing
|
||||
|
||||
### Interface & Type Usage
|
||||
|
||||
- Use existing types from `@formbricks/types` - don't recreate them
|
||||
- Prefer `interface` for object shapes that might be extended
|
||||
- Prefer `type` for unions, intersections, and mapped types
|
||||
- Define types close to where they're used unless they're shared
|
||||
- Export types from index files for shared types
|
||||
|
||||
### Type Assertions
|
||||
|
||||
- Avoid type assertions (`as`) when possible
|
||||
- Use type guards instead of assertions
|
||||
- Only assert when you have more information than TypeScript
|
||||
|
||||
## Code Organization
|
||||
|
||||
### Separation of Concerns
|
||||
|
||||
- Separate business logic from UI rendering
|
||||
- Extract API calls into separate functions or modules
|
||||
- Keep data transformation separate from component logic
|
||||
- Use custom hooks for stateful logic that doesn't render UI
|
||||
|
||||
### Function Clarity
|
||||
|
||||
- Functions should do one thing well
|
||||
- Name functions clearly and descriptively
|
||||
- Keep functions small (aim for under 20 lines)
|
||||
- Extract complex conditionals into named boolean variables or functions
|
||||
- Avoid deep nesting (max 3 levels)
|
||||
|
||||
### File Structure
|
||||
|
||||
- Group related functions together
|
||||
- Order declarations logically (types → hooks → helpers → component)
|
||||
- Keep imports organized (external → internal → relative)
|
||||
- Consider splitting large files by concern
|
||||
|
||||
## Additional Quality Checks
|
||||
|
||||
### Performance
|
||||
|
||||
- Don't optimize prematurely - measure first
|
||||
- Avoid creating new objects/arrays/functions in render unnecessarily
|
||||
- Use keys properly in lists (stable, unique identifiers)
|
||||
- Lazy load heavy components when appropriate
|
||||
|
||||
### Accessibility
|
||||
|
||||
- Use semantic HTML elements
|
||||
- Include ARIA labels where needed
|
||||
- Ensure keyboard navigation works
|
||||
- Check color contrast and focus states
|
||||
|
||||
### Error Handling
|
||||
|
||||
- Handle error states in components
|
||||
- Provide user feedback for failed operations
|
||||
- Use error boundaries for component errors
|
||||
- Log errors appropriately (avoid swallowing errors silently)
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
- Use descriptive names (avoid abbreviations unless very common)
|
||||
- Boolean variables/props should sound like yes/no questions (`isLoading`, `hasError`, `canEdit`)
|
||||
- Arrays should be plural (`users`, `choices`, `items`)
|
||||
- Event handlers: `handleX` in components, `onX` for props
|
||||
- Constants in UPPER_SNAKE_CASE only for true constants
|
||||
|
||||
### Code Readability
|
||||
|
||||
- Prefer early returns to reduce nesting
|
||||
- Use destructuring to make code clearer
|
||||
- Break complex expressions into named variables
|
||||
- Add comments only when code can't be made self-explanatory
|
||||
- Use whitespace to group related code
|
||||
|
||||
### Testing Considerations
|
||||
|
||||
- Write code that's easy to test (pure functions, clear inputs/outputs)
|
||||
- Avoid hard-to-mock dependencies when possible
|
||||
- Keep side effects at the edges of your code
|
||||
|
||||
## Review Checklist
|
||||
|
||||
Before submitting your changes, ask yourself:
|
||||
|
||||
1. **DRY**: Is there any duplicated logic I can extract?
|
||||
2. **Clarity**: Would another developer understand this code easily?
|
||||
3. **Simplicity**: Is this the simplest solution that works?
|
||||
4. **Types**: Am I using TypeScript effectively?
|
||||
5. **React**: Am I following React idioms and best practices?
|
||||
6. **Performance**: Are there obvious performance issues?
|
||||
7. **Separation**: Are concerns properly separated?
|
||||
8. **Testing**: Is this code testable?
|
||||
9. **Maintenance**: Will this be easy to change in 6 months?
|
||||
10. **Deletion**: Can I remove any code and still accomplish the goal?
|
||||
|
||||
## When to Apply This Rule
|
||||
|
||||
Apply this rule:
|
||||
|
||||
- After implementing a feature but before marking it complete
|
||||
- When you notice your code feels "messy" or complex
|
||||
- Before requesting code review
|
||||
- When you see yourself copy-pasting code
|
||||
- After receiving feedback about code quality
|
||||
|
||||
Don't let perfect be the enemy of good, but always strive for:
|
||||
**Simple, readable, maintainable code that does one thing well.**
|
||||
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:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
image_tag:
|
||||
description: "Image tag to push (e.g., v3.16.1, main)"
|
||||
required: true
|
||||
default: "v3.16.1"
|
||||
version_override:
|
||||
description: "Override version (SemVer only, e.g., 1.2.3). Leave empty to auto-detect from branch."
|
||||
required: false
|
||||
type: string
|
||||
deploy_production:
|
||||
description: "Tag image for production deployment"
|
||||
required: false
|
||||
@@ -17,6 +21,24 @@ on:
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
workflow_call:
|
||||
inputs:
|
||||
image_tag:
|
||||
description: "Image tag to push (required for workflow_call)"
|
||||
required: true
|
||||
type: string
|
||||
IS_PRERELEASE:
|
||||
description: "Whether this is a prerelease (auto-tags for staging/production)"
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
outputs:
|
||||
IMAGE_TAG:
|
||||
description: "Normalized image tag used for the build"
|
||||
value: ${{ jobs.build-and-push.outputs.IMAGE_TAG }}
|
||||
TAGS:
|
||||
description: "Newline-separated list of ECR tags pushed"
|
||||
value: ${{ jobs.build-and-push.outputs.TAGS }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -27,14 +49,15 @@ env:
|
||||
# ECR settings are sourced from repository/environment variables for portability across envs/forks
|
||||
ECR_REGISTRY: ${{ vars.ECR_REGISTRY }}
|
||||
ECR_REPOSITORY: ${{ vars.ECR_REPOSITORY }}
|
||||
DOCKERFILE: apps/web/Dockerfile
|
||||
CONTEXT: .
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
name: Build and Push
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
outputs:
|
||||
IMAGE_TAG: ${{ steps.build.outputs.image_tag }}
|
||||
TAGS: ${{ steps.build.outputs.registry_tags }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
||||
@@ -44,125 +67,22 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Validate image tag input
|
||||
shell: bash
|
||||
env:
|
||||
IMAGE_TAG: ${{ inputs.image_tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${IMAGE_TAG}" ]]; then
|
||||
echo "❌ Image tag is required (non-empty)."
|
||||
exit 1
|
||||
fi
|
||||
if (( ${#IMAGE_TAG} > 128 )); then
|
||||
echo "❌ Image tag must be at most 128 characters."
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! "${IMAGE_TAG}" =~ ^[a-z0-9._-]+$ ]]; then
|
||||
echo "❌ Image tag may only contain lowercase letters, digits, '.', '_' and '-'."
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${IMAGE_TAG}" =~ ^[.-] || "${IMAGE_TAG}" =~ [.-]$ ]]; then
|
||||
echo "❌ Image tag must not start or end with '.' or '-'."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Validate required variables
|
||||
shell: bash
|
||||
env:
|
||||
ECR_REGISTRY: ${{ env.ECR_REGISTRY }}
|
||||
ECR_REPOSITORY: ${{ env.ECR_REPOSITORY }}
|
||||
ECR_REGION: ${{ env.ECR_REGION }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${ECR_REGISTRY}" || -z "${ECR_REPOSITORY}" || -z "${ECR_REGION}" ]]; then
|
||||
echo "ECR_REGION, ECR_REGISTRY and ECR_REPOSITORY must be set via repository or environment variables (Settings → Variables)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Update package.json version
|
||||
shell: bash
|
||||
env:
|
||||
IMAGE_TAG: ${{ inputs.image_tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Remove 'v' prefix if present (e.g., v3.16.1 -> 3.16.1)
|
||||
VERSION="${IMAGE_TAG#v}"
|
||||
|
||||
# Validate SemVer format (major.minor.patch with optional prerelease and build metadata)
|
||||
if [[ ! "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$ ]]; then
|
||||
echo "❌ Error: Invalid version format after extraction. Must be SemVer (e.g., 1.2.3, 1.2.3-alpha, 1.2.3+build.1)"
|
||||
echo "Original input: ${IMAGE_TAG}"
|
||||
echo "Extracted version: ${VERSION}"
|
||||
echo "Expected format: MAJOR.MINOR.PATCH[-PRERELEASE][+BUILDMETADATA]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Valid SemVer format detected: ${VERSION}"
|
||||
echo "Updating package.json version to: ${VERSION}"
|
||||
sed -i "s/\"version\": \"0.0.0\"/\"version\": \"${VERSION}\"/" ./apps/web/package.json
|
||||
cat ./apps/web/package.json | grep version
|
||||
|
||||
- name: Build tag list
|
||||
id: tags
|
||||
shell: bash
|
||||
env:
|
||||
IMAGE_TAG: ${{ inputs.image_tag }}
|
||||
DEPLOY_PRODUCTION: ${{ inputs.deploy_production }}
|
||||
DEPLOY_STAGING: ${{ inputs.deploy_staging }}
|
||||
ECR_REGISTRY: ${{ env.ECR_REGISTRY }}
|
||||
ECR_REPOSITORY: ${{ env.ECR_REPOSITORY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Start with the base image tag
|
||||
TAGS="${ECR_REGISTRY}/${ECR_REPOSITORY}:${IMAGE_TAG}"
|
||||
|
||||
# Add production tag if requested
|
||||
if [[ "${DEPLOY_PRODUCTION}" == "true" ]]; then
|
||||
TAGS="${TAGS}\n${ECR_REGISTRY}/${ECR_REPOSITORY}:production"
|
||||
fi
|
||||
|
||||
# Add staging tag if requested
|
||||
if [[ "${DEPLOY_STAGING}" == "true" ]]; then
|
||||
TAGS="${TAGS}\n${ECR_REGISTRY}/${ECR_REPOSITORY}:staging"
|
||||
fi
|
||||
|
||||
# Output for debugging
|
||||
echo "Generated tags:"
|
||||
echo -e "${TAGS}"
|
||||
|
||||
# Set output for next step (escape newlines for GitHub Actions)
|
||||
{
|
||||
echo "tags<<EOF"
|
||||
echo -e "${TAGS}"
|
||||
echo "EOF"
|
||||
} >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Configure AWS credentials (OIDC)
|
||||
uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a
|
||||
- name: Build and push cloud deployment image
|
||||
id: build
|
||||
uses: ./.github/actions/build-and-push-docker
|
||||
with:
|
||||
role-to-assume: ${{ secrets.AWS_ECR_PUSH_ROLE_ARN }}
|
||||
aws-region: ${{ env.ECR_REGION }}
|
||||
|
||||
- name: Log in to Amazon ECR
|
||||
uses: aws-actions/amazon-ecr-login@062b18b96a7aff071d4dc91bc00c4c1a7945b076
|
||||
|
||||
- name: Set up Depot CLI
|
||||
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
|
||||
|
||||
- name: Build and push image (Depot remote builder)
|
||||
uses: depot/build-push-action@636daae76684e38c301daa0c5eca1c095b24e780 # v1.14.0
|
||||
with:
|
||||
project: tw0fqmsx3c
|
||||
token: ${{ secrets.DEPOT_PROJECT_TOKEN }}
|
||||
context: ${{ env.CONTEXT }}
|
||||
file: ${{ env.DOCKERFILE }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.tags.outputs.tags }}
|
||||
secrets: |
|
||||
database_url=${{ secrets.DUMMY_DATABASE_URL }}
|
||||
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
||||
sentry_auth_token=${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
registry_type: "ecr"
|
||||
ecr_registry: ${{ env.ECR_REGISTRY }}
|
||||
ecr_repository: ${{ env.ECR_REPOSITORY }}
|
||||
ecr_region: ${{ env.ECR_REGION }}
|
||||
aws_role_arn: ${{ secrets.AWS_ECR_PUSH_ROLE_ARN }}
|
||||
version: ${{ inputs.version_override || inputs.image_tag }}
|
||||
deploy_production: ${{ inputs.deploy_production }}
|
||||
deploy_staging: ${{ inputs.deploy_staging }}
|
||||
is_prerelease: ${{ inputs.IS_PRERELEASE }}
|
||||
env:
|
||||
DEPOT_PROJECT_TOKEN: ${{ secrets.DEPOT_PROJECT_TOKEN }}
|
||||
DUMMY_DATABASE_URL: ${{ secrets.DUMMY_DATABASE_URL }}
|
||||
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
||||
DUMMY_REDIS_URL: ${{ secrets.DUMMY_REDIS_URL }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
|
||||
@@ -4,7 +4,7 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
VERSION:
|
||||
description: "The version of the Docker image to release, full image tag if image tag is v0.0.0 enter v0.0.0."
|
||||
description: "The version of the Docker image to release (clean SemVer, e.g., 1.2.3)"
|
||||
required: true
|
||||
type: string
|
||||
REPOSITORY:
|
||||
|
||||
59
.github/workflows/e2e.yml
vendored
59
.github/workflows/e2e.yml
vendored
@@ -33,7 +33,7 @@ jobs:
|
||||
timeout-minutes: 60
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg17
|
||||
image: pgvector/pgvector@sha256:9ae02a756ba16a2d69dd78058e25915e36e189bb36ddf01ceae86390d7ed786a
|
||||
env:
|
||||
POSTGRES_DB: postgres
|
||||
POSTGRES_USER: postgres
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd="pg_isready -U testuser"
|
||||
--health-cmd="pg_isready -U postgres"
|
||||
--health-interval=10s
|
||||
--health-timeout=5s
|
||||
--health-retries=5
|
||||
@@ -49,25 +49,15 @@ jobs:
|
||||
image: valkey/valkey@sha256:12ba4f45a7c3e1d0f076acd616cb230834e75a77e8516dde382720af32832d6d
|
||||
ports:
|
||||
- 6379:6379
|
||||
minio:
|
||||
image: bitnami/minio:2025.7.23-debian-12-r5
|
||||
env:
|
||||
MINIO_ROOT_USER: minioadmin
|
||||
MINIO_ROOT_PASSWORD: minioadmin
|
||||
ports:
|
||||
- 9000:9000
|
||||
options: >-
|
||||
--health-cmd="curl -fsS http://localhost:9000/minio/health/live || exit 1"
|
||||
--health-interval=10s
|
||||
--health-timeout=5s
|
||||
--health-retries=20
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
||||
with:
|
||||
egress-policy: allow
|
||||
egress-policy: audit
|
||||
allowed-endpoints: |
|
||||
ee.formbricks.com:443
|
||||
registry-1.docker.io:443
|
||||
docker.io:443
|
||||
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
@@ -101,8 +91,8 @@ jobs:
|
||||
echo "S3_REGION=us-east-1" >> .env
|
||||
echo "S3_BUCKET_NAME=formbricks-e2e" >> .env
|
||||
echo "S3_ENDPOINT_URL=http://localhost:9000" >> .env
|
||||
echo "S3_ACCESS_KEY=minioadmin" >> .env
|
||||
echo "S3_SECRET_KEY=minioadmin" >> .env
|
||||
echo "S3_ACCESS_KEY=devminio" >> .env
|
||||
echo "S3_SECRET_KEY=devminio123" >> .env
|
||||
echo "S3_FORCE_PATH_STYLE=1" >> .env
|
||||
shell: bash
|
||||
|
||||
@@ -122,6 +112,22 @@ jobs:
|
||||
chmod +x "${MC_BIN}"
|
||||
sudo mv "${MC_BIN}" /usr/local/bin/mc
|
||||
|
||||
- name: Start MinIO Server
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Start MinIO server in background
|
||||
docker run -d \
|
||||
--name minio-server \
|
||||
-p 9000:9000 \
|
||||
-p 9001:9001 \
|
||||
-e MINIO_ROOT_USER=devminio \
|
||||
-e MINIO_ROOT_PASSWORD=devminio123 \
|
||||
minio/minio:RELEASE.2025-09-07T16-13-09Z \
|
||||
server /data --console-address :9001
|
||||
|
||||
echo "MinIO server started"
|
||||
|
||||
- name: Wait for MinIO and create S3 bucket
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -142,7 +148,7 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mc alias set local http://localhost:9000 minioadmin minioadmin
|
||||
mc alias set local http://localhost:9000 devminio devminio123
|
||||
mc mb --ignore-existing local/formbricks-e2e
|
||||
|
||||
- name: Build App
|
||||
@@ -160,6 +166,12 @@ jobs:
|
||||
cd apps/web && pnpm vitest run modules/core/rate-limit/rate-limit-load.test.ts
|
||||
shell: bash
|
||||
|
||||
- name: Run Cache Integration Tests
|
||||
run: |
|
||||
echo "Running cache integration tests with Redis/Valkey..."
|
||||
cd packages/cache && pnpm vitest run src/cache-integration.test.ts
|
||||
shell: bash
|
||||
|
||||
- name: Check for Enterprise License
|
||||
run: |
|
||||
LICENSE_KEY=$(grep '^ENTERPRISE_LICENSE_KEY=' .env | cut -d'=' -f2-)
|
||||
@@ -169,6 +181,12 @@ jobs:
|
||||
fi
|
||||
echo "License key length: ${#LICENSE_KEY}"
|
||||
|
||||
- name: Disable rate limiting for E2E tests
|
||||
run: |
|
||||
echo "RATE_LIMITING_DISABLED=1" >> .env
|
||||
echo "Rate limiting disabled for E2E tests"
|
||||
shell: bash
|
||||
|
||||
- name: Run App
|
||||
run: |
|
||||
echo "Starting app with enterprise license..."
|
||||
@@ -210,11 +228,14 @@ jobs:
|
||||
if: env.AZURE_ENABLED == 'true'
|
||||
env:
|
||||
PLAYWRIGHT_SERVICE_URL: ${{ secrets.PLAYWRIGHT_SERVICE_URL }}
|
||||
CI: true
|
||||
run: |
|
||||
pnpm test-e2e:azure
|
||||
|
||||
- name: Run E2E Tests (Local)
|
||||
if: env.AZURE_ENABLED == 'false'
|
||||
env:
|
||||
CI: true
|
||||
run: |
|
||||
pnpm test:e2e
|
||||
|
||||
|
||||
67
.github/workflows/formbricks-release.yml
vendored
67
.github/workflows/formbricks-release.yml
vendored
@@ -8,8 +8,8 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
docker-build:
|
||||
name: Build & release docker image
|
||||
docker-build-community:
|
||||
name: Build & release community docker image
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
@@ -19,6 +19,19 @@ jobs:
|
||||
with:
|
||||
IS_PRERELEASE: ${{ github.event.release.prerelease }}
|
||||
|
||||
docker-build-cloud:
|
||||
name: Build & push Formbricks Cloud to ECR
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
uses: ./.github/workflows/build-and-push-ecr.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
image_tag: ${{ needs.docker-build-community.outputs.VERSION }}
|
||||
IS_PRERELEASE: ${{ github.event.release.prerelease }}
|
||||
needs:
|
||||
- docker-build-community
|
||||
|
||||
helm-chart-release:
|
||||
name: Release Helm Chart
|
||||
permissions:
|
||||
@@ -27,22 +40,42 @@ jobs:
|
||||
uses: ./.github/workflows/release-helm-chart.yml
|
||||
secrets: inherit
|
||||
needs:
|
||||
- docker-build
|
||||
- docker-build-community
|
||||
with:
|
||||
VERSION: ${{ needs.docker-build.outputs.VERSION }}
|
||||
VERSION: ${{ needs.docker-build-community.outputs.VERSION }}
|
||||
|
||||
deploy-formbricks-cloud:
|
||||
name: Deploy Helm Chart to Formbricks Cloud
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
secrets: inherit
|
||||
uses: ./.github/workflows/deploy-formbricks-cloud.yml
|
||||
verify-cloud-build:
|
||||
name: Verify Cloud Build Outputs
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5 # Simple verification should be quick
|
||||
needs:
|
||||
- docker-build
|
||||
- helm-chart-release
|
||||
- docker-build-cloud
|
||||
steps:
|
||||
- name: Harden the runner
|
||||
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Display ECR build outputs
|
||||
env:
|
||||
IMAGE_TAG: ${{ needs.docker-build-cloud.outputs.IMAGE_TAG }}
|
||||
TAGS: ${{ needs.docker-build-cloud.outputs.TAGS }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
echo "✅ ECR Build Completed Successfully"
|
||||
echo "Image Tag: ${IMAGE_TAG}"
|
||||
echo "ECR Tags:"
|
||||
printf '%s\n' "${TAGS}"
|
||||
|
||||
move-stable-tag:
|
||||
name: Move stable tag to release
|
||||
permissions:
|
||||
contents: 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:
|
||||
VERSION: v${{ needs.docker-build.outputs.VERSION }}
|
||||
ENVIRONMENT: ${{ github.event.release.prerelease && 'staging' || 'production' }}
|
||||
|
||||
|
||||
release_tag: ${{ github.event.release.tag_name }}
|
||||
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.
|
||||
# They are provided by a third-party and are governed by
|
||||
# separate terms of service, privacy policy, and support
|
||||
# documentation.
|
||||
# This workflow builds experimental/testing versions of Formbricks for self-hosting customers
|
||||
# to test fixes and features before official releases. Images are pushed to GHCR with
|
||||
# timestamped experimental versions for easy identification and testing.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
# Use docker.io for Docker Hub if empty
|
||||
REGISTRY: ghcr.io
|
||||
# github.repository as <account>/<repo>
|
||||
IMAGE_NAME: ${{ github.repository }}-experimental
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||
inputs:
|
||||
version_override:
|
||||
description: "Override version (SemVer only, e.g., 1.2.3-beta). Leave empty for auto-generated experimental version."
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
build-community-testing:
|
||||
name: Build Community Testing Image
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
# This is used to complete the identity challenge
|
||||
# with sigstore/fulcio when running outside of PRs.
|
||||
id-token: write
|
||||
|
||||
|
||||
timeout-minutes: 45
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -42,110 +34,17 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Generate SemVer version from branch or tag
|
||||
id: generate_version
|
||||
- name: Build and push community testing image
|
||||
uses: ./.github/actions/build-and-push-docker
|
||||
with:
|
||||
registry_type: "ghcr"
|
||||
ghcr_image_name: "${{ github.repository }}-experimental"
|
||||
experimental_mode: "true"
|
||||
version: ${{ inputs.version_override }}
|
||||
env:
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
REF_TYPE: ${{ github.ref_type }}
|
||||
run: |
|
||||
# Get reference name and type from environment variables
|
||||
echo "Reference type: $REF_TYPE"
|
||||
echo "Reference name: $REF_NAME"
|
||||
|
||||
# Create unique timestamped version for testing sourcemap resolution
|
||||
TIMESTAMP=$(date +%s)
|
||||
|
||||
if [[ "$REF_TYPE" == "tag" ]]; then
|
||||
# If running from a tag, use the tag name + timestamp
|
||||
if [[ "$REF_NAME" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+.*$ ]]; then
|
||||
# Tag looks like a SemVer, use it directly (remove 'v' prefix if present)
|
||||
BASE_VERSION=$(echo "$REF_NAME" | sed 's/^v//')
|
||||
VERSION="${BASE_VERSION}-${TIMESTAMP}"
|
||||
echo "Using SemVer tag with timestamp: $VERSION"
|
||||
else
|
||||
# Tag is not SemVer, treat as prerelease
|
||||
SANITIZED_TAG=$(echo "$REF_NAME" | sed 's/[^a-zA-Z0-9.-]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g')
|
||||
VERSION="0.0.0-${SANITIZED_TAG}-${TIMESTAMP}"
|
||||
echo "Using tag as prerelease with timestamp: $VERSION"
|
||||
fi
|
||||
else
|
||||
# Running from branch, use branch name as prerelease + timestamp
|
||||
SANITIZED_BRANCH=$(echo "$REF_NAME" | sed 's/[^a-zA-Z0-9.-]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g')
|
||||
VERSION="0.0.0-${SANITIZED_BRANCH}-${TIMESTAMP}"
|
||||
echo "Using branch as prerelease with timestamp: $VERSION"
|
||||
fi
|
||||
|
||||
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
||||
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Generated SemVer version: $VERSION"
|
||||
|
||||
- name: Update package.json version
|
||||
run: |
|
||||
sed -i "s/\"version\": \"0.0.0\"/\"version\": \"${{ env.VERSION }}\"/" ./apps/web/package.json
|
||||
cat ./apps/web/package.json | grep version
|
||||
|
||||
- name: Set up Depot CLI
|
||||
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
|
||||
|
||||
# Install the cosign tool except on PR
|
||||
# https://github.com/sigstore/cosign-installer
|
||||
- name: Install cosign
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: sigstore/cosign-installer@3454372f43399081ed03b604cb2d021dabca52bb # v3.8.2
|
||||
|
||||
# Login against a Docker registry except on PR
|
||||
# https://github.com/docker/login-action
|
||||
- name: Log into registry ${{ env.REGISTRY }}
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Extract metadata (tags, labels) for Docker
|
||||
# https://github.com/docker/metadata-action
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=raw,value=${{ env.VERSION }}
|
||||
|
||||
# Build and push Docker image with Buildx (don't push on PR)
|
||||
# https://github.com/docker/build-push-action
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: depot/build-push-action@636daae76684e38c301daa0c5eca1c095b24e780 # v1.14.0
|
||||
with:
|
||||
project: tw0fqmsx3c
|
||||
token: ${{ secrets.DEPOT_PROJECT_TOKEN }}
|
||||
context: .
|
||||
file: ./apps/web/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
secrets: |
|
||||
database_url=${{ secrets.DUMMY_DATABASE_URL }}
|
||||
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
||||
sentry_auth_token=${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
|
||||
|
||||
|
||||
# Sign the resulting Docker image digest except on PRs.
|
||||
# This will only write to the public Rekor transparency log when the Docker
|
||||
# repository is public to avoid leaking data. If you would like to publish
|
||||
# transparency data even for private images, pass --force to cosign below.
|
||||
# https://github.com/sigstore/cosign
|
||||
- name: Sign the published Docker image
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
env:
|
||||
# https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable
|
||||
TAGS: ${{ steps.meta.outputs.tags }}
|
||||
DIGEST: ${{ steps.build-and-push.outputs.digest }}
|
||||
# This step uses the identity token to provision an ephemeral certificate
|
||||
# against the sigstore community Fulcio instance.
|
||||
run: echo "${TAGS}" | xargs -I {} cosign sign --yes "{}@${DIGEST}"
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
DEPOT_PROJECT_TOKEN: ${{ secrets.DEPOT_PROJECT_TOKEN }}
|
||||
DUMMY_DATABASE_URL: ${{ secrets.DUMMY_DATABASE_URL }}
|
||||
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
||||
DUMMY_REDIS_URL: ${{ secrets.DUMMY_REDIS_URL }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
|
||||
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.
|
||||
# They are provided by a third-party and are governed by
|
||||
@@ -23,8 +23,6 @@ env:
|
||||
REGISTRY: ghcr.io
|
||||
# github.repository as <account>/<repo>
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -32,6 +30,7 @@ permissions:
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
@@ -44,103 +43,60 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Get Release Tag
|
||||
- name: Extract release version from tag
|
||||
id: extract_release_tag
|
||||
run: |
|
||||
# Extract version from tag (e.g., refs/tags/v1.2.3 -> 1.2.3)
|
||||
TAG="$GITHUB_REF"
|
||||
TAG=${TAG#refs/tags/v}
|
||||
set -euo pipefail
|
||||
|
||||
# Validate the extracted tag format
|
||||
if [[ ! "$TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$ ]]; then
|
||||
echo "❌ Error: Invalid release tag format after extraction. Must be semver (e.g., 1.2.3, 1.2.3-alpha)"
|
||||
echo "Original ref: $GITHUB_REF"
|
||||
echo "Extracted tag: $TAG"
|
||||
# Extract tag name with fallback logic for different trigger contexts
|
||||
if [[ -n "${RELEASE_TAG:-}" ]]; then
|
||||
TAG="$RELEASE_TAG"
|
||||
echo "Using RELEASE_TAG override: $TAG"
|
||||
elif [[ "$GITHUB_REF_NAME" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]] || [[ "$GITHUB_REF_NAME" =~ ^v[0-9] ]]; then
|
||||
TAG="$GITHUB_REF_NAME"
|
||||
echo "Using GITHUB_REF_NAME (looks like tag): $TAG"
|
||||
else
|
||||
# Fallback: extract from GITHUB_REF for direct tag triggers
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
if [[ -z "$TAG" || "$TAG" == "$GITHUB_REF" ]]; then
|
||||
TAG="$GITHUB_REF_NAME"
|
||||
echo "Using GITHUB_REF_NAME as final fallback: $TAG"
|
||||
else
|
||||
echo "Extracted from GITHUB_REF: $TAG"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Strip v-prefix if present (normalize to clean SemVer)
|
||||
TAG=${TAG#[vV]}
|
||||
|
||||
# Validate SemVer format (supports prereleases like 4.0.0-rc.1)
|
||||
if [[ ! "$TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]]; then
|
||||
echo "ERROR: Invalid tag format '$TAG'. Expected SemVer (e.g., 1.2.3, 4.0.0-rc.1)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Safely add to environment variables
|
||||
echo "RELEASE_TAG=$TAG" >> $GITHUB_ENV
|
||||
|
||||
echo "VERSION=$TAG" >> $GITHUB_OUTPUT
|
||||
echo "Using tag-based version: $TAG"
|
||||
echo "Using version: $TAG"
|
||||
|
||||
- name: Update package.json version
|
||||
run: |
|
||||
sed -i "s/\"version\": \"0.0.0\"/\"version\": \"${{ env.RELEASE_TAG }}\"/" ./apps/web/package.json
|
||||
cat ./apps/web/package.json | grep version
|
||||
|
||||
- name: Set up Depot CLI
|
||||
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
|
||||
|
||||
# Install the cosign tool except on PR
|
||||
# https://github.com/sigstore/cosign-installer
|
||||
- name: Install cosign
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: sigstore/cosign-installer@3454372f43399081ed03b604cb2d021dabca52bb # v3.8.2
|
||||
|
||||
# Login against a Docker registry except on PR
|
||||
# https://github.com/docker/login-action
|
||||
- name: Log into registry ${{ env.REGISTRY }}
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
||||
- name: Build and push community release image
|
||||
id: build
|
||||
uses: ./.github/actions/build-and-push-docker
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Extract metadata (tags, labels) for Docker
|
||||
# https://github.com/docker/metadata-action
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
# Default semver tags (version, major.minor, major)
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
# Only tag as 'latest' for stable releases (not prereleases)
|
||||
type=raw,value=latest,enable=${{ !inputs.IS_PRERELEASE }}
|
||||
|
||||
# Build and push Docker image with Buildx (don't push on PR)
|
||||
# https://github.com/docker/build-push-action
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: depot/build-push-action@636daae76684e38c301daa0c5eca1c095b24e780 # v1.14.0
|
||||
with:
|
||||
project: tw0fqmsx3c
|
||||
token: ${{ secrets.DEPOT_PROJECT_TOKEN }}
|
||||
context: .
|
||||
file: ./apps/web/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
secrets: |
|
||||
database_url=${{ secrets.DUMMY_DATABASE_URL }}
|
||||
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
||||
sentry_auth_token=${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
|
||||
# Sign the resulting Docker image digest except on PRs.
|
||||
# This will only write to the public Rekor transparency log when the Docker
|
||||
# repository is public to avoid leaking data. If you would like to publish
|
||||
# transparency data even for private images, pass --force to cosign below.
|
||||
# https://github.com/sigstore/cosign
|
||||
- name: Sign the published Docker image
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
registry_type: "ghcr"
|
||||
ghcr_image_name: ${{ env.IMAGE_NAME }}
|
||||
version: ${{ steps.extract_release_tag.outputs.VERSION }}
|
||||
is_prerelease: ${{ inputs.IS_PRERELEASE }}
|
||||
env:
|
||||
# https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable
|
||||
TAGS: ${{ steps.meta.outputs.tags }}
|
||||
DIGEST: ${{ steps.build-and-push.outputs.digest }}
|
||||
# This step uses the identity token to provision an ephemeral certificate
|
||||
# against the sigstore community Fulcio instance.
|
||||
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
DEPOT_PROJECT_TOKEN: ${{ secrets.DEPOT_PROJECT_TOKEN }}
|
||||
DUMMY_DATABASE_URL: ${{ secrets.DUMMY_DATABASE_URL }}
|
||||
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
||||
DUMMY_REDIS_URL: ${{ secrets.DUMMY_REDIS_URL }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
|
||||
29
.github/workflows/release-helm-chart.yml
vendored
29
.github/workflows/release-helm-chart.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -59,14 +59,35 @@ jobs:
|
||||
uses: dcarbone/install-yq-action@4075b4dca348d74bd83f2bf82d30f25d7c54539b # v1.3.1
|
||||
|
||||
- name: Update Chart.yaml with new version
|
||||
env:
|
||||
VERSION: ${{ env.VERSION }}
|
||||
run: |
|
||||
yq -i ".version = \"$VERSION\"" helm-chart/Chart.yaml
|
||||
yq -i ".appVersion = \"v$VERSION\"" helm-chart/Chart.yaml
|
||||
set -euo pipefail
|
||||
|
||||
echo "Updating Chart.yaml with version: ${VERSION}"
|
||||
yq -i ".version = \"${VERSION}\"" helm-chart/Chart.yaml
|
||||
yq -i ".appVersion = \"${VERSION}\"" helm-chart/Chart.yaml
|
||||
|
||||
echo "✅ Successfully updated Chart.yaml"
|
||||
|
||||
- name: Package Helm chart
|
||||
env:
|
||||
VERSION: ${{ env.VERSION }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
echo "Packaging Helm chart version: ${VERSION}"
|
||||
helm package ./helm-chart
|
||||
|
||||
echo "✅ Successfully packaged formbricks-${VERSION}.tgz"
|
||||
|
||||
- name: Push Helm chart to GitHub Container Registry
|
||||
env:
|
||||
VERSION: ${{ env.VERSION }}
|
||||
run: |
|
||||
helm push "formbricks-$VERSION.tgz" oci://ghcr.io/formbricks/helm-charts
|
||||
set -euo pipefail
|
||||
|
||||
echo "Pushing Helm chart to registry: formbricks-${VERSION}.tgz"
|
||||
helm push "formbricks-${VERSION}.tgz" oci://ghcr.io/formbricks/helm-charts
|
||||
|
||||
echo "✅ Successfully pushed Helm chart to registry"
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { ArrowUpRightIcon, ChevronRightIcon, LogOutIcon } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import FBLogo from "@/images/formbricks-wordmark.svg";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { capitalizeFirstLetter } from "@/lib/utils/strings";
|
||||
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
||||
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
|
||||
import { ProfileAvatar } from "@/modules/ui/components/avatars";
|
||||
@@ -12,13 +18,6 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { ArrowUpRightIcon, ChevronRightIcon, LogOutIcon } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
|
||||
interface LandingSidebarProps {
|
||||
user: TUser;
|
||||
@@ -66,10 +65,8 @@ export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
|
||||
)}>
|
||||
{user?.name ? <span>{user?.name}</span> : <span>{user?.email}</span>}
|
||||
</p>
|
||||
<p
|
||||
title={capitalizeFirstLetter(organization?.name)}
|
||||
className="truncate text-sm text-slate-500">
|
||||
{capitalizeFirstLetter(organization?.name)}
|
||||
<p title={organization?.name} className="truncate text-sm text-slate-500">
|
||||
{organization?.name}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRightIcon className={cn("h-5 w-5 shrink-0 text-slate-700 hover:text-slate-500")} />
|
||||
|
||||
@@ -31,6 +31,6 @@ describe("IntegrationsTip", () => {
|
||||
|
||||
const linkElement = screen.getByText("environments.settings.notifications.use_the_integration");
|
||||
expect(linkElement).toBeInTheDocument();
|
||||
expect(linkElement).toHaveAttribute("href", `/environments/${environmentId}/integrations`);
|
||||
expect(linkElement).toHaveAttribute("href", `/environments/${environmentId}/project/integrations`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@ export const IntegrationsTip = ({ environmentId }: IntegrationsTipProps) => {
|
||||
<p className="text-sm">
|
||||
{t("environments.settings.notifications.need_slack_or_discord_notifications")}?
|
||||
<a
|
||||
href={`/environments/${environmentId}/integrations`}
|
||||
href={`/environments/${environmentId}/project/integrations`}
|
||||
className="ml-1 cursor-pointer text-sm underline">
|
||||
{t("environments.settings.notifications.use_the_integration")}
|
||||
</a>
|
||||
|
||||
@@ -120,7 +120,7 @@ describe("PasswordConfirmationModal", () => {
|
||||
const confirmButton = screen.getByText("common.confirm");
|
||||
await user.click(confirmButton);
|
||||
|
||||
expect(screen.getByText("String must contain at least 8 character(s)")).toBeInTheDocument();
|
||||
expect(screen.getByText("Password must be at least 8 characters long")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("handles cancel button click and resets form", async () => {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { H4, Small } from "@/modules/ui/components/typography";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
|
||||
interface ButtonInfo {
|
||||
text: string;
|
||||
@@ -41,7 +41,7 @@ export const SettingsCard = ({
|
||||
id={title}>
|
||||
<div className="flex justify-between border-b border-slate-200 px-4 pb-4">
|
||||
<div>
|
||||
<H4 className="font-medium capitalize tracking-normal">{title}</H4>
|
||||
<H4 className="font-medium tracking-normal">{title}</H4>
|
||||
<div className="ml-2">
|
||||
{beta && <Badge size="normal" type="warning" text="Beta" />}
|
||||
{soon && (
|
||||
|
||||
@@ -75,7 +75,7 @@ export const SuccessView: React.FC<SuccessViewProps> = ({
|
||||
{t("environments.surveys.summary.configure_alerts")}
|
||||
</Link>
|
||||
<Link
|
||||
href={`/environments/${environmentId}/integrations`}
|
||||
href={`/environments/${environmentId}/project/integrations`}
|
||||
className="flex flex-col items-center gap-3 rounded-lg border border-slate-100 bg-white p-4 text-center text-sm text-slate-900 hover:border-slate-200 md:p-8">
|
||||
<BlocksIcon className="h-8 w-8 stroke-1 text-slate-900" />
|
||||
{t("environments.surveys.summary.setup_integrations")}
|
||||
|
||||
@@ -357,7 +357,10 @@ const buildNotionPayloadProperties = (
|
||||
|
||||
// notion requires specific payload for each column type
|
||||
// * TYPES NOT SUPPORTED BY NOTION API - rollup, created_by, created_time, last_edited_by, or last_edited_time
|
||||
const getValue = (colType: string, value: string | string[] | Date | number | Record<string, string>) => {
|
||||
const getValue = (
|
||||
colType: string,
|
||||
value: string | string[] | Date | number | Record<string, string> | undefined
|
||||
) => {
|
||||
try {
|
||||
switch (colType) {
|
||||
case "select":
|
||||
|
||||
@@ -62,9 +62,10 @@ export const GET = async (req: Request) => {
|
||||
};
|
||||
|
||||
const result = await createOrUpdateIntegration(environmentId, googleSheetIntegration);
|
||||
|
||||
if (result) {
|
||||
return Response.redirect(`${WEBAPP_URL}/environments/${environmentId}/integrations/google-sheets`);
|
||||
return Response.redirect(
|
||||
`${WEBAPP_URL}/environments/${environmentId}/project/integrations/google-sheets`
|
||||
);
|
||||
}
|
||||
|
||||
return responses.internalServerErrorResponse("Failed to create or update Google Sheets integration");
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
import { cache } from "@/lib/cache";
|
||||
import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service";
|
||||
import {
|
||||
capturePosthogEnvironmentEvent,
|
||||
sendPlanLimitsReachedEventToPosthogWeekly,
|
||||
} from "@/lib/posthogServer";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
@@ -12,6 +6,12 @@ import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TJsEnvironmentState, TJsEnvironmentStateProject } from "@formbricks/types/js";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { cache } from "@/lib/cache";
|
||||
import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service";
|
||||
import {
|
||||
capturePosthogEnvironmentEvent,
|
||||
sendPlanLimitsReachedEventToPosthogWeekly,
|
||||
} from "@/lib/posthogServer";
|
||||
import { EnvironmentStateData, getEnvironmentStateData } from "./data";
|
||||
import { getEnvironmentState } from "./environmentState";
|
||||
|
||||
@@ -285,7 +285,7 @@ describe("getEnvironmentState", () => {
|
||||
expect(cache.withCache).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
"fb:env:test-environment-id:state",
|
||||
5 * 60 * 1000 // 5 minutes in milliseconds
|
||||
60 * 1000 // 1 minutes in milliseconds
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import "server-only";
|
||||
import { createCacheKey } from "@formbricks/cache";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TJsEnvironmentState } from "@formbricks/types/js";
|
||||
import { cache } from "@/lib/cache";
|
||||
import { IS_FORMBRICKS_CLOUD, IS_RECAPTCHA_CONFIGURED, RECAPTCHA_SITE_KEY } from "@/lib/constants";
|
||||
import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service";
|
||||
@@ -6,10 +10,6 @@ import {
|
||||
capturePosthogEnvironmentEvent,
|
||||
sendPlanLimitsReachedEventToPosthogWeekly,
|
||||
} from "@/lib/posthogServer";
|
||||
import { createCacheKey } from "@formbricks/cache";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TJsEnvironmentState } from "@formbricks/types/js";
|
||||
import { getEnvironmentStateData } from "./data";
|
||||
|
||||
/**
|
||||
@@ -80,6 +80,6 @@ export const getEnvironmentState = async (
|
||||
return { data };
|
||||
},
|
||||
createCacheKey.environment.state(environmentId),
|
||||
5 * 60 * 1000 // 5 minutes in milliseconds
|
||||
60 * 1000 // 1 minutes in milliseconds
|
||||
);
|
||||
};
|
||||
|
||||
@@ -90,7 +90,9 @@ export const GET = withV1ApiWrapper({
|
||||
};
|
||||
await createOrUpdateIntegration(environmentId, airtableIntegrationInput);
|
||||
return {
|
||||
response: Response.redirect(`${WEBAPP_URL}/environments/${environmentId}/integrations/airtable`),
|
||||
response: Response.redirect(
|
||||
`${WEBAPP_URL}/environments/${environmentId}/project/integrations/airtable`
|
||||
),
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error({ error, url: req.url }, "Error in GET /api/v1/integrations/airtable/callback");
|
||||
|
||||
@@ -86,13 +86,15 @@ export const GET = withV1ApiWrapper({
|
||||
|
||||
if (result) {
|
||||
return {
|
||||
response: Response.redirect(`${WEBAPP_URL}/environments/${environmentId}/integrations/notion`),
|
||||
response: Response.redirect(
|
||||
`${WEBAPP_URL}/environments/${environmentId}/project/integrations/notion`
|
||||
),
|
||||
};
|
||||
}
|
||||
} else if (error) {
|
||||
return {
|
||||
response: Response.redirect(
|
||||
`${WEBAPP_URL}/environments/${environmentId}/integrations/notion?error=${error}`
|
||||
`${WEBAPP_URL}/environments/${environmentId}/project/integrations/notion?error=${error}`
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -93,13 +93,15 @@ export const GET = withV1ApiWrapper({
|
||||
|
||||
if (result) {
|
||||
return {
|
||||
response: Response.redirect(`${WEBAPP_URL}/environments/${environmentId}/integrations/slack`),
|
||||
response: Response.redirect(
|
||||
`${WEBAPP_URL}/environments/${environmentId}/project/integrations/slack`
|
||||
),
|
||||
};
|
||||
}
|
||||
} else if (error) {
|
||||
return {
|
||||
response: Response.redirect(
|
||||
`${WEBAPP_URL}/environments/${environmentId}/integrations/slack?error=${error}`
|
||||
`${WEBAPP_URL}/environments/${environmentId}/project/integrations/slack?error=${error}`
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
1
apps/web/app/api/v2/health/route.ts
Normal file
1
apps/web/app/api/v2/health/route.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { GET } from "@/modules/api/v2/health/route";
|
||||
@@ -1,9 +1,9 @@
|
||||
import { AuthenticationMethod } from "@/app/middleware/endpoint-validator";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { NextRequest } from "next/server";
|
||||
import { Mock, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { AuthenticationMethod } from "@/app/middleware/endpoint-validator";
|
||||
import { responses } from "./response";
|
||||
|
||||
// Mocks
|
||||
@@ -14,6 +14,10 @@ vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
|
||||
|
||||
vi.mock("@sentry/nextjs", () => ({
|
||||
captureException: vi.fn(),
|
||||
withScope: vi.fn((callback) => {
|
||||
callback(mockSentryScope);
|
||||
return mockSentryScope;
|
||||
}),
|
||||
}));
|
||||
|
||||
// Define these outside the mock factory so they can be referenced in tests and reset by clearAllMocks.
|
||||
@@ -21,6 +25,14 @@ const mockContextualLoggerError = vi.fn();
|
||||
const mockContextualLoggerWarn = vi.fn();
|
||||
const mockContextualLoggerInfo = vi.fn();
|
||||
|
||||
// Mock Sentry scope that can be referenced in tests
|
||||
const mockSentryScope = {
|
||||
setTag: vi.fn(),
|
||||
setExtra: vi.fn(),
|
||||
setContext: vi.fn(),
|
||||
setLevel: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock("@formbricks/logger", () => {
|
||||
const mockWithContextInstance = vi.fn(() => ({
|
||||
error: mockContextualLoggerError,
|
||||
@@ -110,6 +122,12 @@ describe("withV1ApiWrapper", () => {
|
||||
}));
|
||||
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Reset mock Sentry scope calls
|
||||
mockSentryScope.setTag.mockClear();
|
||||
mockSentryScope.setExtra.mockClear();
|
||||
mockSentryScope.setContext.mockClear();
|
||||
mockSentryScope.setLevel.mockClear();
|
||||
});
|
||||
|
||||
test("logs and audits on error response with API key authentication", async () => {
|
||||
@@ -161,10 +179,9 @@ describe("withV1ApiWrapper", () => {
|
||||
organizationId: "org-1",
|
||||
})
|
||||
);
|
||||
expect(Sentry.captureException).toHaveBeenCalledWith(
|
||||
expect.any(Error),
|
||||
expect.objectContaining({ extra: expect.objectContaining({ correlationId: "abc-123" }) })
|
||||
);
|
||||
expect(Sentry.withScope).toHaveBeenCalled();
|
||||
expect(mockSentryScope.setExtra).toHaveBeenCalledWith("originalError", undefined);
|
||||
expect(Sentry.captureException).toHaveBeenCalledWith(expect.any(Error));
|
||||
});
|
||||
|
||||
test("does not log Sentry if not 500", async () => {
|
||||
@@ -269,10 +286,8 @@ describe("withV1ApiWrapper", () => {
|
||||
organizationId: "org-1",
|
||||
})
|
||||
);
|
||||
expect(Sentry.captureException).toHaveBeenCalledWith(
|
||||
expect.any(Error),
|
||||
expect.objectContaining({ extra: expect.objectContaining({ correlationId: "err-1" }) })
|
||||
);
|
||||
expect(Sentry.withScope).toHaveBeenCalled();
|
||||
expect(Sentry.captureException).toHaveBeenCalledWith(expect.any(Error));
|
||||
});
|
||||
|
||||
test("does not log on success response but still audits", async () => {
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { Session, getServerSession } from "next-auth";
|
||||
import { NextRequest } from "next/server";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { authenticateRequest } from "@/app/api/v1/auth";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import {
|
||||
@@ -14,11 +19,6 @@ import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import { TRateLimitConfig } from "@/modules/core/rate-limit/types/rate-limit";
|
||||
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { TAuditAction, TAuditTarget, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { Session, getServerSession } from "next-auth";
|
||||
import { NextRequest } from "next/server";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
|
||||
export type TApiAuditLog = Parameters<typeof queueAuditEvent>[0];
|
||||
export type TApiV1Authentication = TAuthenticationApiKey | Session | null;
|
||||
@@ -173,8 +173,21 @@ const logErrorDetails = (res: Response, req: NextRequest, correlationId: string,
|
||||
logger.withContext(logContext).error("V1 API Error Details");
|
||||
|
||||
if (SENTRY_DSN && IS_PRODUCTION && res.status >= 500) {
|
||||
const err = new Error(`API V1 error, id: ${correlationId}`);
|
||||
Sentry.captureException(err, { extra: { error, correlationId } });
|
||||
// Set correlation ID as a tag for easy filtering
|
||||
Sentry.withScope((scope) => {
|
||||
scope.setTag("correlationId", correlationId);
|
||||
scope.setLevel("error");
|
||||
|
||||
// If we have an actual error, capture it with full stacktrace
|
||||
// Otherwise, create a generic error with context
|
||||
if (error instanceof Error) {
|
||||
Sentry.captureException(error);
|
||||
} else {
|
||||
scope.setExtra("originalError", error);
|
||||
const genericError = new Error(`API V1 error, id: ${correlationId}`);
|
||||
Sentry.captureException(genericError);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { TShuffleOption, TSurveyLogic, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { TTemplateRole } from "@formbricks/types/templates";
|
||||
import {
|
||||
buildCTAQuestion,
|
||||
buildConsentQuestion,
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
TSurveyRatingQuestion,
|
||||
TSurveyWelcomeCard,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { TTemplate } from "@formbricks/types/templates";
|
||||
import { TTemplate, TTemplateRole } from "@formbricks/types/templates";
|
||||
|
||||
const getDefaultButtonLabel = (label: string | undefined, t: TFnType) =>
|
||||
createI18nString(label || t("common.next"), []);
|
||||
@@ -391,6 +391,7 @@ export const buildSurvey = (
|
||||
name: string;
|
||||
industries: ("eCommerce" | "saas" | "other")[];
|
||||
channels: ("link" | "app" | "website")[];
|
||||
role: TTemplateRole;
|
||||
description: string;
|
||||
questions: TSurveyQuestion[];
|
||||
endings?: TSurveyEnding[];
|
||||
@@ -403,6 +404,7 @@ export const buildSurvey = (
|
||||
name: config.name,
|
||||
industries: config.industries,
|
||||
channels: config.channels,
|
||||
role: config.role,
|
||||
description: config.description,
|
||||
preset: {
|
||||
...localSurvey,
|
||||
|
||||
@@ -24,6 +24,7 @@ const cartAbandonmentSurvey = (t: TFnType): TTemplate => {
|
||||
return buildSurvey(
|
||||
{
|
||||
name: t("templates.card_abandonment_survey"),
|
||||
role: "productManager",
|
||||
industries: ["eCommerce"],
|
||||
channels: ["app", "website", "link"],
|
||||
description: t("templates.card_abandonment_survey_description"),
|
||||
@@ -124,6 +125,7 @@ const siteAbandonmentSurvey = (t: TFnType): TTemplate => {
|
||||
return buildSurvey(
|
||||
{
|
||||
name: t("templates.site_abandonment_survey"),
|
||||
role: "productManager",
|
||||
industries: ["eCommerce"],
|
||||
channels: ["app", "website"],
|
||||
description: t("templates.site_abandonment_survey_description"),
|
||||
@@ -221,6 +223,7 @@ const productMarketFitSuperhuman = (t: TFnType): TTemplate => {
|
||||
return buildSurvey(
|
||||
{
|
||||
name: t("templates.product_market_fit_superhuman"),
|
||||
role: "productManager",
|
||||
industries: ["saas"],
|
||||
channels: ["app", "link"],
|
||||
description: t("templates.product_market_fit_superhuman_description"),
|
||||
@@ -295,6 +298,7 @@ const onboardingSegmentation = (t: TFnType): TTemplate => {
|
||||
return buildSurvey(
|
||||
{
|
||||
name: t("templates.onboarding_segmentation"),
|
||||
role: "productManager",
|
||||
industries: ["saas"],
|
||||
channels: ["app", "link"],
|
||||
description: t("templates.onboarding_segmentation_description"),
|
||||
@@ -358,6 +362,7 @@ const churnSurvey = (t: TFnType): TTemplate => {
|
||||
return buildSurvey(
|
||||
{
|
||||
name: t("templates.churn_survey"),
|
||||
role: "sales",
|
||||
industries: ["saas", "eCommerce", "other"],
|
||||
channels: ["app", "link"],
|
||||
description: t("templates.churn_survey_description"),
|
||||
@@ -447,6 +452,7 @@ const earnedAdvocacyScore = (t: TFnType): TTemplate => {
|
||||
return buildSurvey(
|
||||
{
|
||||
name: t("templates.earned_advocacy_score_name"),
|
||||
role: "customerSuccess",
|
||||
industries: ["saas", "eCommerce", "other"],
|
||||
channels: ["app", "link"],
|
||||
description: t("templates.earned_advocacy_score_description"),
|
||||
@@ -519,6 +525,7 @@ const usabilityScoreRatingSurvey = (t: TFnType): TTemplate => {
|
||||
return buildSurvey(
|
||||
{
|
||||
name: t("templates.usability_score_name"),
|
||||
role: "customerSuccess",
|
||||
industries: ["saas"],
|
||||
channels: ["app", "link"],
|
||||
description: t("templates.usability_rating_description"),
|
||||
@@ -644,6 +651,7 @@ const improveTrialConversion = (t: TFnType): TTemplate => {
|
||||
return buildSurvey(
|
||||
{
|
||||
name: t("templates.improve_trial_conversion_name"),
|
||||
role: "sales",
|
||||
industries: ["saas"],
|
||||
channels: ["link", "app"],
|
||||
description: t("templates.improve_trial_conversion_description"),
|
||||
@@ -745,6 +753,7 @@ const reviewPrompt = (t: TFnType): TTemplate => {
|
||||
return buildSurvey(
|
||||
{
|
||||
name: t("templates.review_prompt_name"),
|
||||
role: "marketing",
|
||||
industries: ["saas", "eCommerce", "other"],
|
||||
channels: ["link", "app"],
|
||||
description: t("templates.review_prompt_description"),
|
||||
@@ -823,6 +832,7 @@ const interviewPrompt = (t: TFnType): TTemplate => {
|
||||
return buildSurvey(
|
||||
{
|
||||
name: t("templates.interview_prompt_name"),
|
||||
role: "productManager",
|
||||
industries: ["saas"],
|
||||
channels: ["app"],
|
||||
description: t("templates.interview_prompt_description"),
|
||||
@@ -850,6 +860,7 @@ const improveActivationRate = (t: TFnType): TTemplate => {
|
||||
return buildSurvey(
|
||||
{
|
||||
name: t("templates.improve_activation_rate_name"),
|
||||
role: "productManager",
|
||||
industries: ["saas"],
|
||||
channels: ["link"],
|
||||
description: t("templates.improve_activation_rate_description"),
|
||||
@@ -940,6 +951,7 @@ const employeeSatisfaction = (t: TFnType): TTemplate => {
|
||||
return buildSurvey(
|
||||
{
|
||||
name: t("templates.employee_satisfaction_name"),
|
||||
role: "peopleManager",
|
||||
industries: ["saas", "eCommerce", "other"],
|
||||
channels: ["app", "link"],
|
||||
description: t("templates.employee_satisfaction_description"),
|
||||
@@ -1017,6 +1029,7 @@ const uncoverStrengthsAndWeaknesses = (t: TFnType): TTemplate => {
|
||||
return buildSurvey(
|
||||
{
|
||||
name: t("templates.uncover_strengths_and_weaknesses_name"),
|
||||
role: "productManager",
|
||||
industries: ["saas", "other"],
|
||||
channels: ["app", "link"],
|
||||
description: t("templates.uncover_strengths_and_weaknesses_description"),
|
||||
@@ -1070,6 +1083,7 @@ const productMarketFitShort = (t: TFnType): TTemplate => {
|
||||
return buildSurvey(
|
||||
{
|
||||
name: t("templates.product_market_fit_short_name"),
|
||||
role: "productManager",
|
||||
industries: ["saas"],
|
||||
channels: ["app", "link"],
|
||||
description: t("templates.product_market_fit_short_description"),
|
||||
@@ -1106,6 +1120,7 @@ const marketAttribution = (t: TFnType): TTemplate => {
|
||||
return buildSurvey(
|
||||
{
|
||||
name: t("templates.market_attribution_name"),
|
||||
role: "marketing",
|
||||
industries: ["saas", "eCommerce"],
|
||||
channels: ["website", "app", "link"],
|
||||
description: t("templates.market_attribution_description"),
|
||||
@@ -1136,6 +1151,7 @@ const changingSubscriptionExperience = (t: TFnType): TTemplate => {
|
||||
return buildSurvey(
|
||||
{
|
||||
name: t("templates.changing_subscription_experience_name"),
|
||||
role: "productManager",
|
||||
industries: ["saas"],
|
||||
channels: ["app"],
|
||||
description: t("templates.changing_subscription_experience_description"),
|
||||
@@ -1178,6 +1194,7 @@ const identifyCustomerGoals = (t: TFnType): TTemplate => {
|
||||
return buildSurvey(
|
||||
{
|
||||
name: t("templates.identify_customer_goals_name"),
|
||||
role: "productManager",
|
||||
industries: ["saas", "other"],
|
||||
channels: ["app", "website"],
|
||||
description: t("templates.identify_customer_goals_description"),
|
||||
@@ -1207,6 +1224,7 @@ const featureChaser = (t: TFnType): TTemplate => {
|
||||
return buildSurvey(
|
||||
{
|
||||
name: t("templates.feature_chaser_name"),
|
||||
role: "productManager",
|
||||
industries: ["saas"],
|
||||
channels: ["app"],
|
||||
description: t("templates.feature_chaser_description"),
|
||||
@@ -1245,6 +1263,7 @@ const fakeDoorFollowUp = (t: TFnType): TTemplate => {
|
||||
return buildSurvey(
|
||||
{
|
||||
name: t("templates.fake_door_follow_up_name"),
|
||||
role: "productManager",
|
||||
industries: ["saas", "eCommerce"],
|
||||
channels: ["app", "website"],
|
||||
description: t("templates.fake_door_follow_up_description"),
|
||||
@@ -1288,6 +1307,7 @@ const feedbackBox = (t: TFnType): TTemplate => {
|
||||
return buildSurvey(
|
||||
{
|
||||
name: t("templates.feedback_box_name"),
|
||||
role: "productManager",
|
||||
industries: ["saas"],
|
||||
channels: ["app"],
|
||||
description: t("templates.feedback_box_description"),
|
||||
@@ -1357,6 +1377,7 @@ const integrationSetupSurvey = (t: TFnType): TTemplate => {
|
||||
return buildSurvey(
|
||||
{
|
||||
name: t("templates.integration_setup_survey_name"),
|
||||
role: "productManager",
|
||||
industries: ["saas"],
|
||||
channels: ["app"],
|
||||
description: t("templates.integration_setup_survey_description"),
|
||||
@@ -1429,6 +1450,7 @@ const newIntegrationSurvey = (t: TFnType): TTemplate => {
|
||||
return buildSurvey(
|
||||
{
|
||||
name: t("templates.new_integration_survey_name"),
|
||||
role: "productManager",
|
||||
industries: ["saas"],
|
||||
channels: ["app"],
|
||||
description: t("templates.new_integration_survey_description"),
|
||||
@@ -1460,6 +1482,7 @@ const docsFeedback = (t: TFnType): TTemplate => {
|
||||
return buildSurvey(
|
||||
{
|
||||
name: t("templates.docs_feedback_name"),
|
||||
role: "productManager",
|
||||
industries: ["saas"],
|
||||
channels: ["app", "website", "link"],
|
||||
description: t("templates.docs_feedback_description"),
|
||||
@@ -1499,6 +1522,7 @@ const nps = (t: TFnType): TTemplate => {
|
||||
return buildSurvey(
|
||||
{
|
||||
name: t("templates.nps_name"),
|
||||
role: "customerSuccess",
|
||||
industries: ["saas", "eCommerce", "other"],
|
||||
channels: ["app", "link", "website"],
|
||||
description: t("templates.nps_description"),
|
||||
@@ -1539,6 +1563,7 @@ const customerSatisfactionScore = (t: TFnType): TTemplate => {
|
||||
return buildSurvey(
|
||||
{
|
||||
name: t("templates.csat_name"),
|
||||
role: "customerSuccess",
|
||||
industries: ["saas", "eCommerce", "other"],
|
||||
channels: ["app", "link", "website"],
|
||||
description: t("templates.csat_description"),
|
||||
@@ -1707,6 +1732,7 @@ const collectFeedback = (t: TFnType): TTemplate => {
|
||||
return buildSurvey(
|
||||
{
|
||||
name: t("templates.collect_feedback_name"),
|
||||
role: "productManager",
|
||||
industries: ["other", "eCommerce"],
|
||||
channels: ["website", "link"],
|
||||
description: t("templates.collect_feedback_description"),
|
||||
@@ -1853,6 +1879,7 @@ const identifyUpsellOpportunities = (t: TFnType): TTemplate => {
|
||||
return buildSurvey(
|
||||
{
|
||||
name: t("templates.identify_upsell_opportunities_name"),
|
||||
role: "sales",
|
||||
industries: ["saas"],
|
||||
channels: ["app", "link"],
|
||||
description: t("templates.identify_upsell_opportunities_description"),
|
||||
@@ -1882,6 +1909,7 @@ const prioritizeFeatures = (t: TFnType): TTemplate => {
|
||||
return buildSurvey(
|
||||
{
|
||||
name: t("templates.prioritize_features_name"),
|
||||
role: "productManager",
|
||||
industries: ["saas"],
|
||||
channels: ["app"],
|
||||
description: t("templates.prioritize_features_description"),
|
||||
@@ -1934,6 +1962,7 @@ const gaugeFeatureSatisfaction = (t: TFnType): TTemplate => {
|
||||
return buildSurvey(
|
||||
{
|
||||
name: t("templates.gauge_feature_satisfaction_name"),
|
||||
role: "productManager",
|
||||
industries: ["saas"],
|
||||
channels: ["app"],
|
||||
description: t("templates.gauge_feature_satisfaction_description"),
|
||||
@@ -1967,6 +1996,7 @@ const marketSiteClarity = (t: TFnType): TTemplate => {
|
||||
return buildSurvey(
|
||||
{
|
||||
name: t("templates.market_site_clarity_name"),
|
||||
role: "marketing",
|
||||
industries: ["saas", "eCommerce", "other"],
|
||||
channels: ["website"],
|
||||
description: t("templates.market_site_clarity_description"),
|
||||
@@ -2008,6 +2038,7 @@ const customerEffortScore = (t: TFnType): TTemplate => {
|
||||
return buildSurvey(
|
||||
{
|
||||
name: t("templates.customer_effort_score_name"),
|
||||
role: "productManager",
|
||||
industries: ["saas"],
|
||||
channels: ["app"],
|
||||
description: t("templates.customer_effort_score_description"),
|
||||
@@ -2039,6 +2070,7 @@ const careerDevelopmentSurvey = (t: TFnType): TTemplate => {
|
||||
return buildSurvey(
|
||||
{
|
||||
name: t("templates.career_development_survey_name"),
|
||||
role: "productManager",
|
||||
industries: ["saas", "eCommerce", "other"],
|
||||
channels: ["link"],
|
||||
description: t("templates.career_development_survey_description"),
|
||||
@@ -2125,6 +2157,7 @@ const professionalDevelopmentSurvey = (t: TFnType): TTemplate => {
|
||||
return buildSurvey(
|
||||
{
|
||||
name: t("templates.professional_development_survey_name"),
|
||||
role: "productManager",
|
||||
industries: ["saas", "eCommerce", "other"],
|
||||
channels: ["link"],
|
||||
description: t("templates.professional_development_survey_description"),
|
||||
@@ -2212,6 +2245,7 @@ const rateCheckoutExperience = (t: TFnType): TTemplate => {
|
||||
return buildSurvey(
|
||||
{
|
||||
name: t("templates.rate_checkout_experience_name"),
|
||||
role: "productManager",
|
||||
industries: ["eCommerce"],
|
||||
channels: ["website", "app"],
|
||||
description: t("templates.rate_checkout_experience_description"),
|
||||
@@ -2288,6 +2322,7 @@ const measureSearchExperience = (t: TFnType): TTemplate => {
|
||||
return buildSurvey(
|
||||
{
|
||||
name: t("templates.measure_search_experience_name"),
|
||||
role: "productManager",
|
||||
industries: ["saas", "eCommerce"],
|
||||
channels: ["app", "website"],
|
||||
description: t("templates.measure_search_experience_description"),
|
||||
@@ -2364,6 +2399,7 @@ const evaluateContentQuality = (t: TFnType): TTemplate => {
|
||||
return buildSurvey(
|
||||
{
|
||||
name: t("templates.evaluate_content_quality_name"),
|
||||
role: "marketing",
|
||||
industries: ["other"],
|
||||
channels: ["website"],
|
||||
description: t("templates.evaluate_content_quality_description"),
|
||||
@@ -2441,6 +2477,7 @@ const measureTaskAccomplishment = (t: TFnType): TTemplate => {
|
||||
return buildSurvey(
|
||||
{
|
||||
name: t("templates.measure_task_accomplishment_name"),
|
||||
role: "productManager",
|
||||
industries: ["saas"],
|
||||
channels: ["app", "website"],
|
||||
description: t("templates.measure_task_accomplishment_description"),
|
||||
@@ -2623,6 +2660,7 @@ const identifySignUpBarriers = (t: TFnType): TTemplate => {
|
||||
return buildSurvey(
|
||||
{
|
||||
name: t("templates.identify_sign_up_barriers_name"),
|
||||
role: "marketing",
|
||||
industries: ["saas", "eCommerce", "other"],
|
||||
channels: ["website"],
|
||||
description: t("templates.identify_sign_up_barriers_description"),
|
||||
@@ -2774,6 +2812,7 @@ const buildProductRoadmap = (t: TFnType): TTemplate => {
|
||||
return buildSurvey(
|
||||
{
|
||||
name: t("templates.build_product_roadmap_name"),
|
||||
role: "productManager",
|
||||
industries: ["saas"],
|
||||
channels: ["app", "link"],
|
||||
description: t("templates.build_product_roadmap_description"),
|
||||
@@ -2808,6 +2847,7 @@ const understandPurchaseIntention = (t: TFnType): TTemplate => {
|
||||
return buildSurvey(
|
||||
{
|
||||
name: t("templates.understand_purchase_intention_name"),
|
||||
role: "sales",
|
||||
industries: ["eCommerce"],
|
||||
channels: ["website", "link", "app"],
|
||||
description: t("templates.understand_purchase_intention_description"),
|
||||
@@ -2863,6 +2903,7 @@ const improveNewsletterContent = (t: TFnType): TTemplate => {
|
||||
return buildSurvey(
|
||||
{
|
||||
name: t("templates.improve_newsletter_content_name"),
|
||||
role: "marketing",
|
||||
industries: ["eCommerce", "saas", "other"],
|
||||
channels: ["link"],
|
||||
description: t("templates.improve_newsletter_content_description"),
|
||||
@@ -2953,6 +2994,7 @@ const evaluateAProductIdea = (t: TFnType): TTemplate => {
|
||||
return buildSurvey(
|
||||
{
|
||||
name: t("templates.evaluate_a_product_idea_name"),
|
||||
role: "productManager",
|
||||
industries: ["saas", "other"],
|
||||
channels: ["link", "app"],
|
||||
description: t("templates.evaluate_a_product_idea_description"),
|
||||
@@ -3055,6 +3097,7 @@ const understandLowEngagement = (t: TFnType): TTemplate => {
|
||||
return buildSurvey(
|
||||
{
|
||||
name: t("templates.understand_low_engagement_name"),
|
||||
role: "productManager",
|
||||
industries: ["saas"],
|
||||
channels: ["link"],
|
||||
description: t("templates.understand_low_engagement_description"),
|
||||
@@ -3140,6 +3183,7 @@ const employeeWellBeing = (t: TFnType): TTemplate => {
|
||||
return buildSurvey(
|
||||
{
|
||||
name: t("templates.employee_well_being_name"),
|
||||
role: "peopleManager",
|
||||
industries: ["saas", "eCommerce", "other"],
|
||||
channels: ["link"],
|
||||
description: t("templates.employee_well_being_description"),
|
||||
@@ -3189,6 +3233,7 @@ const longTermRetentionCheckIn = (t: TFnType): TTemplate => {
|
||||
return buildSurvey(
|
||||
{
|
||||
name: t("templates.long_term_retention_check_in_name"),
|
||||
role: "peopleManager",
|
||||
industries: ["saas", "other"],
|
||||
channels: ["app", "link"],
|
||||
description: t("templates.long_term_retention_check_in_description"),
|
||||
@@ -3297,6 +3342,7 @@ const professionalDevelopmentGrowth = (t: TFnType): TTemplate => {
|
||||
return buildSurvey(
|
||||
{
|
||||
name: t("templates.professional_development_growth_survey_name"),
|
||||
role: "peopleManager",
|
||||
industries: ["saas", "eCommerce", "other"],
|
||||
channels: ["link"],
|
||||
description: t("templates.professional_development_growth_survey_description"),
|
||||
@@ -3346,6 +3392,7 @@ const recognitionAndReward = (t: TFnType): TTemplate => {
|
||||
return buildSurvey(
|
||||
{
|
||||
name: t("templates.recognition_and_reward_survey_name"),
|
||||
role: "peopleManager",
|
||||
industries: ["saas", "eCommerce", "other"],
|
||||
channels: ["link"],
|
||||
description: t("templates.recognition_and_reward_survey_description"),
|
||||
@@ -3394,6 +3441,7 @@ const alignmentAndEngagement = (t: TFnType): TTemplate => {
|
||||
return buildSurvey(
|
||||
{
|
||||
name: t("templates.alignment_and_engagement_survey_name"),
|
||||
role: "peopleManager",
|
||||
industries: ["saas", "eCommerce", "other"],
|
||||
channels: ["link"],
|
||||
description: t("templates.alignment_and_engagement_survey_description"),
|
||||
@@ -3442,6 +3490,7 @@ const supportiveWorkCulture = (t: TFnType): TTemplate => {
|
||||
return buildSurvey(
|
||||
{
|
||||
name: t("templates.supportive_work_culture_survey_name"),
|
||||
role: "peopleManager",
|
||||
industries: ["saas", "eCommerce", "other"],
|
||||
channels: ["link"],
|
||||
description: t("templates.supportive_work_culture_survey_description"),
|
||||
|
||||
@@ -114,7 +114,7 @@ export const MAX_FILE_UPLOAD_SIZES = {
|
||||
standard: 1024 * 1024 * 10, // 10MB
|
||||
big: 1024 * 1024 * 1024, // 1GB
|
||||
} as const;
|
||||
export const IS_STORAGE_CONFIGURED = Boolean(S3_ACCESS_KEY && S3_SECRET_KEY && S3_REGION && S3_BUCKET_NAME);
|
||||
export const IS_STORAGE_CONFIGURED = Boolean(S3_BUCKET_NAME);
|
||||
|
||||
// Colors for Survey Bg
|
||||
export const SURVEY_BG_COLORS = [
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { env } from "@/lib/env";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import * as crypto from "@/lib/crypto";
|
||||
import {
|
||||
createEmailChangeToken,
|
||||
createEmailToken,
|
||||
@@ -14,12 +15,69 @@ import {
|
||||
verifyTokenForLinkSurvey,
|
||||
} from "./jwt";
|
||||
|
||||
const TEST_ENCRYPTION_KEY = "0".repeat(32); // 32-byte key for AES-256-GCM
|
||||
const TEST_NEXTAUTH_SECRET = "test-nextauth-secret";
|
||||
const DIFFERENT_SECRET = "different-secret";
|
||||
|
||||
// Error message constants
|
||||
const NEXTAUTH_SECRET_ERROR = "NEXTAUTH_SECRET is not set";
|
||||
const ENCRYPTION_KEY_ERROR = "ENCRYPTION_KEY is not set";
|
||||
|
||||
// Helper function to test error cases for missing secrets/keys
|
||||
const testMissingSecretsError = async (
|
||||
testFn: (...args: any[]) => any,
|
||||
args: any[],
|
||||
options: {
|
||||
testNextAuthSecret?: boolean;
|
||||
testEncryptionKey?: boolean;
|
||||
isAsync?: boolean;
|
||||
} = {}
|
||||
) => {
|
||||
const { testNextAuthSecret = true, testEncryptionKey = true, isAsync = false } = options;
|
||||
|
||||
if (testNextAuthSecret) {
|
||||
const constants = await import("@/lib/constants");
|
||||
const originalSecret = (constants as any).NEXTAUTH_SECRET;
|
||||
(constants as any).NEXTAUTH_SECRET = undefined;
|
||||
|
||||
if (isAsync) {
|
||||
await expect(testFn(...args)).rejects.toThrow(NEXTAUTH_SECRET_ERROR);
|
||||
} else {
|
||||
expect(() => testFn(...args)).toThrow(NEXTAUTH_SECRET_ERROR);
|
||||
}
|
||||
|
||||
// Restore
|
||||
(constants as any).NEXTAUTH_SECRET = originalSecret;
|
||||
}
|
||||
|
||||
if (testEncryptionKey) {
|
||||
const constants = await import("@/lib/constants");
|
||||
const originalKey = (constants as any).ENCRYPTION_KEY;
|
||||
(constants as any).ENCRYPTION_KEY = undefined;
|
||||
|
||||
if (isAsync) {
|
||||
await expect(testFn(...args)).rejects.toThrow(ENCRYPTION_KEY_ERROR);
|
||||
} else {
|
||||
expect(() => testFn(...args)).toThrow(ENCRYPTION_KEY_ERROR);
|
||||
}
|
||||
|
||||
// Restore
|
||||
(constants as any).ENCRYPTION_KEY = originalKey;
|
||||
}
|
||||
};
|
||||
|
||||
// Mock environment variables
|
||||
vi.mock("@/lib/env", () => ({
|
||||
env: {
|
||||
ENCRYPTION_KEY: "0".repeat(32), // 32-byte key for AES-256-GCM
|
||||
ENCRYPTION_KEY: "0".repeat(32),
|
||||
NEXTAUTH_SECRET: "test-nextauth-secret",
|
||||
} as typeof env,
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock constants
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
NEXTAUTH_SECRET: "test-nextauth-secret",
|
||||
ENCRYPTION_KEY: "0".repeat(32),
|
||||
}));
|
||||
|
||||
// Mock prisma
|
||||
@@ -31,22 +89,65 @@ vi.mock("@formbricks/database", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
describe("JWT Functions", () => {
|
||||
// Mock logger
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("JWT Functions - Comprehensive Security Tests", () => {
|
||||
const mockUser = {
|
||||
id: "test-user-id",
|
||||
email: "test@example.com",
|
||||
};
|
||||
|
||||
let mockSymmetricEncrypt: any;
|
||||
let mockSymmetricDecrypt: any;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Setup default crypto mocks
|
||||
mockSymmetricEncrypt = vi
|
||||
.spyOn(crypto, "symmetricEncrypt")
|
||||
.mockImplementation((text: string) => `encrypted_${text}`);
|
||||
|
||||
mockSymmetricDecrypt = vi
|
||||
.spyOn(crypto, "symmetricDecrypt")
|
||||
.mockImplementation((encryptedText: string) => encryptedText.replace("encrypted_", ""));
|
||||
|
||||
(prisma.user.findUnique as any).mockResolvedValue(mockUser);
|
||||
});
|
||||
|
||||
describe("createToken", () => {
|
||||
test("should create a valid token", () => {
|
||||
const token = createToken(mockUser.id, mockUser.email);
|
||||
test("should create a valid token with encrypted user ID", () => {
|
||||
const token = createToken(mockUser.id);
|
||||
expect(token).toBeDefined();
|
||||
expect(typeof token).toBe("string");
|
||||
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(mockUser.id, TEST_ENCRYPTION_KEY);
|
||||
});
|
||||
|
||||
test("should accept custom options", () => {
|
||||
const customOptions = { expiresIn: "1h" };
|
||||
const token = createToken(mockUser.id, customOptions);
|
||||
expect(token).toBeDefined();
|
||||
|
||||
// Verify the token contains the expected expiration
|
||||
const decoded = jwt.decode(token) as any;
|
||||
expect(decoded.exp).toBeDefined();
|
||||
expect(decoded.iat).toBeDefined();
|
||||
// Should expire in approximately 1 hour (3600 seconds)
|
||||
expect(decoded.exp - decoded.iat).toBe(3600);
|
||||
});
|
||||
|
||||
test("should throw error if NEXTAUTH_SECRET is not set", async () => {
|
||||
await testMissingSecretsError(createToken, [mockUser.id], {
|
||||
testNextAuthSecret: true,
|
||||
testEncryptionKey: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -56,6 +157,18 @@ describe("JWT Functions", () => {
|
||||
const token = createTokenForLinkSurvey(surveyId, mockUser.email);
|
||||
expect(token).toBeDefined();
|
||||
expect(typeof token).toBe("string");
|
||||
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(mockUser.email, TEST_ENCRYPTION_KEY);
|
||||
});
|
||||
|
||||
test("should include surveyId in payload", () => {
|
||||
const surveyId = "test-survey-id";
|
||||
const token = createTokenForLinkSurvey(surveyId, mockUser.email);
|
||||
const decoded = jwt.decode(token) as any;
|
||||
expect(decoded.surveyId).toBe(surveyId);
|
||||
});
|
||||
|
||||
test("should throw error if NEXTAUTH_SECRET or ENCRYPTION_KEY is not set", async () => {
|
||||
await testMissingSecretsError(createTokenForLinkSurvey, ["survey-id", mockUser.email]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -64,24 +177,30 @@ describe("JWT Functions", () => {
|
||||
const token = createEmailToken(mockUser.email);
|
||||
expect(token).toBeDefined();
|
||||
expect(typeof token).toBe("string");
|
||||
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(mockUser.email, TEST_ENCRYPTION_KEY);
|
||||
});
|
||||
|
||||
test("should throw error if NEXTAUTH_SECRET is not set", () => {
|
||||
const originalSecret = env.NEXTAUTH_SECRET;
|
||||
try {
|
||||
(env as any).NEXTAUTH_SECRET = undefined;
|
||||
expect(() => createEmailToken(mockUser.email)).toThrow("NEXTAUTH_SECRET is not set");
|
||||
} finally {
|
||||
(env as any).NEXTAUTH_SECRET = originalSecret;
|
||||
}
|
||||
test("should throw error if NEXTAUTH_SECRET or ENCRYPTION_KEY is not set", async () => {
|
||||
await testMissingSecretsError(createEmailToken, [mockUser.email]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEmailFromEmailToken", () => {
|
||||
test("should extract email from valid token", () => {
|
||||
const token = createEmailToken(mockUser.email);
|
||||
const extractedEmail = getEmailFromEmailToken(token);
|
||||
expect(extractedEmail).toBe(mockUser.email);
|
||||
describe("createEmailChangeToken", () => {
|
||||
test("should create a valid email change token with 1 day expiration", () => {
|
||||
const token = createEmailChangeToken(mockUser.id, mockUser.email);
|
||||
expect(token).toBeDefined();
|
||||
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(mockUser.id, TEST_ENCRYPTION_KEY);
|
||||
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(mockUser.email, TEST_ENCRYPTION_KEY);
|
||||
|
||||
const decoded = jwt.decode(token) as any;
|
||||
expect(decoded.exp).toBeDefined();
|
||||
expect(decoded.iat).toBeDefined();
|
||||
// Should expire in approximately 1 day (86400 seconds)
|
||||
expect(decoded.exp - decoded.iat).toBe(86400);
|
||||
});
|
||||
|
||||
test("should throw error if NEXTAUTH_SECRET or ENCRYPTION_KEY is not set", async () => {
|
||||
await testMissingSecretsError(createEmailChangeToken, [mockUser.id, mockUser.email]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -91,6 +210,50 @@ describe("JWT Functions", () => {
|
||||
const token = createInviteToken(inviteId, mockUser.email);
|
||||
expect(token).toBeDefined();
|
||||
expect(typeof token).toBe("string");
|
||||
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(inviteId, TEST_ENCRYPTION_KEY);
|
||||
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(mockUser.email, TEST_ENCRYPTION_KEY);
|
||||
});
|
||||
|
||||
test("should accept custom options", () => {
|
||||
const inviteId = "test-invite-id";
|
||||
const customOptions = { expiresIn: "24h" };
|
||||
const token = createInviteToken(inviteId, mockUser.email, customOptions);
|
||||
expect(token).toBeDefined();
|
||||
|
||||
const decoded = jwt.decode(token) as any;
|
||||
expect(decoded.exp).toBeDefined();
|
||||
expect(decoded.iat).toBeDefined();
|
||||
// Should expire in approximately 24 hours (86400 seconds)
|
||||
expect(decoded.exp - decoded.iat).toBe(86400);
|
||||
});
|
||||
|
||||
test("should throw error if NEXTAUTH_SECRET or ENCRYPTION_KEY is not set", async () => {
|
||||
await testMissingSecretsError(createInviteToken, ["invite-id", mockUser.email]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEmailFromEmailToken", () => {
|
||||
test("should extract email from valid token", () => {
|
||||
const token = createEmailToken(mockUser.email);
|
||||
const extractedEmail = getEmailFromEmailToken(token);
|
||||
expect(extractedEmail).toBe(mockUser.email);
|
||||
expect(mockSymmetricDecrypt).toHaveBeenCalledWith(`encrypted_${mockUser.email}`, TEST_ENCRYPTION_KEY);
|
||||
});
|
||||
|
||||
test("should fall back to original email if decryption fails", () => {
|
||||
mockSymmetricDecrypt.mockImplementationOnce(() => {
|
||||
throw new Error("Decryption failed");
|
||||
});
|
||||
|
||||
// Create token manually with unencrypted email for legacy compatibility
|
||||
const legacyToken = jwt.sign({ email: mockUser.email }, TEST_NEXTAUTH_SECRET);
|
||||
const extractedEmail = getEmailFromEmailToken(legacyToken);
|
||||
expect(extractedEmail).toBe(mockUser.email);
|
||||
});
|
||||
|
||||
test("should throw error if NEXTAUTH_SECRET or ENCRYPTION_KEY is not set", async () => {
|
||||
const token = jwt.sign({ email: "test@example.com" }, TEST_NEXTAUTH_SECRET);
|
||||
await testMissingSecretsError(getEmailFromEmailToken, [token]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -106,23 +269,194 @@ describe("JWT Functions", () => {
|
||||
const result = verifyTokenForLinkSurvey("invalid-token", "test-survey-id");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("should return null if NEXTAUTH_SECRET is not set", async () => {
|
||||
const constants = await import("@/lib/constants");
|
||||
const originalSecret = (constants as any).NEXTAUTH_SECRET;
|
||||
(constants as any).NEXTAUTH_SECRET = undefined;
|
||||
|
||||
const result = verifyTokenForLinkSurvey("any-token", "test-survey-id");
|
||||
expect(result).toBeNull();
|
||||
|
||||
// Restore
|
||||
(constants as any).NEXTAUTH_SECRET = originalSecret;
|
||||
});
|
||||
|
||||
test("should return null if surveyId doesn't match", () => {
|
||||
const surveyId = "test-survey-id";
|
||||
const differentSurveyId = "different-survey-id";
|
||||
const token = createTokenForLinkSurvey(surveyId, mockUser.email);
|
||||
const result = verifyTokenForLinkSurvey(token, differentSurveyId);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("should return null if email is missing from payload", () => {
|
||||
const tokenWithoutEmail = jwt.sign({ surveyId: "test-survey-id" }, TEST_NEXTAUTH_SECRET);
|
||||
const result = verifyTokenForLinkSurvey(tokenWithoutEmail, "test-survey-id");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("should fall back to original email if decryption fails", () => {
|
||||
mockSymmetricDecrypt.mockImplementationOnce(() => {
|
||||
throw new Error("Decryption failed");
|
||||
});
|
||||
|
||||
// Create legacy token with unencrypted email
|
||||
const legacyToken = jwt.sign(
|
||||
{
|
||||
email: mockUser.email,
|
||||
surveyId: "test-survey-id",
|
||||
},
|
||||
TEST_NEXTAUTH_SECRET
|
||||
);
|
||||
|
||||
const result = verifyTokenForLinkSurvey(legacyToken, "test-survey-id");
|
||||
expect(result).toBe(mockUser.email);
|
||||
});
|
||||
|
||||
test("should fall back to original email if ENCRYPTION_KEY is not set", async () => {
|
||||
const constants = await import("@/lib/constants");
|
||||
const originalKey = (constants as any).ENCRYPTION_KEY;
|
||||
(constants as any).ENCRYPTION_KEY = undefined;
|
||||
|
||||
// Create a token with unencrypted email (as it would be if ENCRYPTION_KEY was not set during creation)
|
||||
const token = jwt.sign(
|
||||
{
|
||||
email: mockUser.email,
|
||||
surveyId: "survey-id",
|
||||
},
|
||||
TEST_NEXTAUTH_SECRET
|
||||
);
|
||||
|
||||
const result = verifyTokenForLinkSurvey(token, "survey-id");
|
||||
expect(result).toBe(mockUser.email);
|
||||
|
||||
// Restore
|
||||
(constants as any).ENCRYPTION_KEY = originalKey;
|
||||
});
|
||||
|
||||
test("should verify legacy survey tokens with surveyId-based secret", async () => {
|
||||
const surveyId = "test-survey-id";
|
||||
|
||||
// Create legacy token with old format (NEXTAUTH_SECRET + surveyId)
|
||||
const legacyToken = jwt.sign({ email: `encrypted_${mockUser.email}` }, TEST_NEXTAUTH_SECRET + surveyId);
|
||||
|
||||
const result = verifyTokenForLinkSurvey(legacyToken, surveyId);
|
||||
expect(result).toBe(mockUser.email);
|
||||
});
|
||||
|
||||
test("should reject survey tokens that fail both new and legacy verification", async () => {
|
||||
const surveyId = "test-survey-id";
|
||||
const invalidToken = jwt.sign({ email: "encrypted_test@example.com" }, "wrong-secret");
|
||||
|
||||
const result = verifyTokenForLinkSurvey(invalidToken, surveyId);
|
||||
expect(result).toBeNull();
|
||||
|
||||
// Verify error logging
|
||||
const { logger } = await import("@formbricks/logger");
|
||||
expect(logger.error).toHaveBeenCalledWith(expect.any(Error), "Survey link token verification failed");
|
||||
});
|
||||
|
||||
test("should reject legacy survey tokens for wrong survey", () => {
|
||||
const correctSurveyId = "correct-survey-id";
|
||||
const wrongSurveyId = "wrong-survey-id";
|
||||
|
||||
// Create legacy token for one survey
|
||||
const legacyToken = jwt.sign(
|
||||
{ email: `encrypted_${mockUser.email}` },
|
||||
TEST_NEXTAUTH_SECRET + correctSurveyId
|
||||
);
|
||||
|
||||
// Try to verify with different survey ID
|
||||
const result = verifyTokenForLinkSurvey(legacyToken, wrongSurveyId);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyToken", () => {
|
||||
test("should verify valid token", async () => {
|
||||
const token = createToken(mockUser.id, mockUser.email);
|
||||
const token = createToken(mockUser.id);
|
||||
const verified = await verifyToken(token);
|
||||
expect(verified).toEqual({
|
||||
id: mockUser.id,
|
||||
id: mockUser.id, // Returns the decrypted user ID
|
||||
email: mockUser.email,
|
||||
});
|
||||
});
|
||||
|
||||
test("should throw error if user not found", async () => {
|
||||
(prisma.user.findUnique as any).mockResolvedValue(null);
|
||||
const token = createToken(mockUser.id, mockUser.email);
|
||||
const token = createToken(mockUser.id);
|
||||
await expect(verifyToken(token)).rejects.toThrow("User not found");
|
||||
});
|
||||
|
||||
test("should throw error if NEXTAUTH_SECRET is not set", async () => {
|
||||
await testMissingSecretsError(verifyToken, ["any-token"], {
|
||||
testNextAuthSecret: true,
|
||||
testEncryptionKey: false,
|
||||
isAsync: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("should throw error for invalid token signature", async () => {
|
||||
const invalidToken = jwt.sign({ id: "test-id" }, DIFFERENT_SECRET);
|
||||
await expect(verifyToken(invalidToken)).rejects.toThrow("Invalid token");
|
||||
});
|
||||
|
||||
test("should throw error if token payload is missing id", async () => {
|
||||
const tokenWithoutId = jwt.sign({ email: mockUser.email }, TEST_NEXTAUTH_SECRET);
|
||||
await expect(verifyToken(tokenWithoutId)).rejects.toThrow("Invalid token");
|
||||
});
|
||||
|
||||
test("should return raw id from payload", async () => {
|
||||
// Create token with unencrypted id
|
||||
const token = jwt.sign({ id: mockUser.id }, TEST_NEXTAUTH_SECRET);
|
||||
const verified = await verifyToken(token);
|
||||
expect(verified).toEqual({
|
||||
id: mockUser.id, // Returns the raw ID from payload
|
||||
email: mockUser.email,
|
||||
});
|
||||
});
|
||||
|
||||
test("should verify legacy tokens with email-based secret", async () => {
|
||||
// Create legacy token with old format (NEXTAUTH_SECRET + userEmail)
|
||||
const legacyToken = jwt.sign({ id: `encrypted_${mockUser.id}` }, TEST_NEXTAUTH_SECRET + mockUser.email);
|
||||
|
||||
const verified = await verifyToken(legacyToken);
|
||||
expect(verified).toEqual({
|
||||
id: mockUser.id, // Returns the decrypted user ID
|
||||
email: mockUser.email,
|
||||
});
|
||||
});
|
||||
|
||||
test("should prioritize new tokens over legacy tokens", async () => {
|
||||
// Create both new and legacy tokens for the same user
|
||||
const newToken = createToken(mockUser.id);
|
||||
const legacyToken = jwt.sign({ id: `encrypted_${mockUser.id}` }, TEST_NEXTAUTH_SECRET + mockUser.email);
|
||||
|
||||
// New token should verify without triggering legacy path
|
||||
const verifiedNew = await verifyToken(newToken);
|
||||
expect(verifiedNew.id).toBe(mockUser.id); // Returns decrypted user ID
|
||||
|
||||
// Legacy token should trigger legacy path
|
||||
const verifiedLegacy = await verifyToken(legacyToken);
|
||||
expect(verifiedLegacy.id).toBe(mockUser.id); // Returns decrypted user ID
|
||||
});
|
||||
|
||||
test("should reject tokens that fail both new and legacy verification", async () => {
|
||||
const invalidToken = jwt.sign({ id: "encrypted_test-id" }, "wrong-secret");
|
||||
await expect(verifyToken(invalidToken)).rejects.toThrow("Invalid token");
|
||||
|
||||
// Verify both methods were attempted
|
||||
const { logger } = await import("@formbricks/logger");
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.any(Error),
|
||||
"Token verification failed with new method"
|
||||
);
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.any(Error),
|
||||
"Token verification failed with legacy method"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyInviteToken", () => {
|
||||
@@ -139,6 +473,53 @@ describe("JWT Functions", () => {
|
||||
test("should throw error for invalid token", () => {
|
||||
expect(() => verifyInviteToken("invalid-token")).toThrow("Invalid or expired invite token");
|
||||
});
|
||||
|
||||
test("should throw error if NEXTAUTH_SECRET or ENCRYPTION_KEY is not set", async () => {
|
||||
await testMissingSecretsError(verifyInviteToken, ["any-token"]);
|
||||
});
|
||||
|
||||
test("should throw error if inviteId is missing", () => {
|
||||
const tokenWithoutInviteId = jwt.sign({ email: mockUser.email }, TEST_NEXTAUTH_SECRET);
|
||||
expect(() => verifyInviteToken(tokenWithoutInviteId)).toThrow("Invalid or expired invite token");
|
||||
});
|
||||
|
||||
test("should throw error if email is missing", () => {
|
||||
const tokenWithoutEmail = jwt.sign({ inviteId: "test-invite-id" }, TEST_NEXTAUTH_SECRET);
|
||||
expect(() => verifyInviteToken(tokenWithoutEmail)).toThrow("Invalid or expired invite token");
|
||||
});
|
||||
|
||||
test("should fall back to original values if decryption fails", () => {
|
||||
mockSymmetricDecrypt.mockImplementation(() => {
|
||||
throw new Error("Decryption failed");
|
||||
});
|
||||
|
||||
const inviteId = "test-invite-id";
|
||||
const legacyToken = jwt.sign(
|
||||
{
|
||||
inviteId,
|
||||
email: mockUser.email,
|
||||
},
|
||||
TEST_NEXTAUTH_SECRET
|
||||
);
|
||||
|
||||
const verified = verifyInviteToken(legacyToken);
|
||||
expect(verified).toEqual({
|
||||
inviteId,
|
||||
email: mockUser.email,
|
||||
});
|
||||
});
|
||||
|
||||
test("should throw error for token with wrong signature", () => {
|
||||
const invalidToken = jwt.sign(
|
||||
{
|
||||
inviteId: "test-invite-id",
|
||||
email: mockUser.email,
|
||||
},
|
||||
DIFFERENT_SECRET
|
||||
);
|
||||
|
||||
expect(() => verifyInviteToken(invalidToken)).toThrow("Invalid or expired invite token");
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyEmailChangeToken", () => {
|
||||
@@ -150,22 +531,478 @@ describe("JWT Functions", () => {
|
||||
expect(result).toEqual({ id: userId, email });
|
||||
});
|
||||
|
||||
test("should throw error if NEXTAUTH_SECRET or ENCRYPTION_KEY is not set", async () => {
|
||||
await testMissingSecretsError(verifyEmailChangeToken, ["any-token"], { isAsync: true });
|
||||
});
|
||||
|
||||
test("should throw error if token is invalid or missing fields", async () => {
|
||||
// Create a token with missing fields
|
||||
const jwt = await import("jsonwebtoken");
|
||||
const token = jwt.sign({ foo: "bar" }, env.NEXTAUTH_SECRET as string);
|
||||
const token = jwt.sign({ foo: "bar" }, TEST_NEXTAUTH_SECRET);
|
||||
await expect(verifyEmailChangeToken(token)).rejects.toThrow(
|
||||
"Token is invalid or missing required fields"
|
||||
);
|
||||
});
|
||||
|
||||
test("should throw error if id is missing", async () => {
|
||||
const token = jwt.sign({ email: "test@example.com" }, TEST_NEXTAUTH_SECRET);
|
||||
await expect(verifyEmailChangeToken(token)).rejects.toThrow(
|
||||
"Token is invalid or missing required fields"
|
||||
);
|
||||
});
|
||||
|
||||
test("should throw error if email is missing", async () => {
|
||||
const token = jwt.sign({ id: "test-id" }, TEST_NEXTAUTH_SECRET);
|
||||
await expect(verifyEmailChangeToken(token)).rejects.toThrow(
|
||||
"Token is invalid or missing required fields"
|
||||
);
|
||||
});
|
||||
|
||||
test("should return original id/email if decryption fails", async () => {
|
||||
// Create a token with non-encrypted id/email
|
||||
const jwt = await import("jsonwebtoken");
|
||||
mockSymmetricDecrypt.mockImplementation(() => {
|
||||
throw new Error("Decryption failed");
|
||||
});
|
||||
|
||||
const payload = { id: "plain-id", email: "plain@example.com" };
|
||||
const token = jwt.sign(payload, env.NEXTAUTH_SECRET as string);
|
||||
const token = jwt.sign(payload, TEST_NEXTAUTH_SECRET);
|
||||
const result = await verifyEmailChangeToken(token);
|
||||
expect(result).toEqual(payload);
|
||||
});
|
||||
|
||||
test("should throw error for token with wrong signature", async () => {
|
||||
const invalidToken = jwt.sign(
|
||||
{
|
||||
id: "test-id",
|
||||
email: "test@example.com",
|
||||
},
|
||||
DIFFERENT_SECRET
|
||||
);
|
||||
|
||||
await expect(verifyEmailChangeToken(invalidToken)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// SECURITY SCENARIO TESTS
|
||||
describe("Security Scenarios", () => {
|
||||
describe("Algorithm Confusion Attack Prevention", () => {
|
||||
test("should reject 'none' algorithm tokens in verifyToken", async () => {
|
||||
// Create malicious token with "none" algorithm
|
||||
const maliciousToken =
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
alg: "none",
|
||||
typ: "JWT",
|
||||
})
|
||||
).toString("base64url") +
|
||||
"." +
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
id: "encrypted_malicious-id",
|
||||
})
|
||||
).toString("base64url") +
|
||||
".";
|
||||
|
||||
await expect(verifyToken(maliciousToken)).rejects.toThrow("Invalid token");
|
||||
});
|
||||
|
||||
test("should reject 'none' algorithm tokens in verifyTokenForLinkSurvey", () => {
|
||||
const maliciousToken =
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
alg: "none",
|
||||
typ: "JWT",
|
||||
})
|
||||
).toString("base64url") +
|
||||
"." +
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
email: "encrypted_attacker@evil.com",
|
||||
surveyId: "test-survey-id",
|
||||
})
|
||||
).toString("base64url") +
|
||||
".";
|
||||
|
||||
const result = verifyTokenForLinkSurvey(maliciousToken, "test-survey-id");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("should reject 'none' algorithm tokens in verifyInviteToken", () => {
|
||||
const maliciousToken =
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
alg: "none",
|
||||
typ: "JWT",
|
||||
})
|
||||
).toString("base64url") +
|
||||
"." +
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
inviteId: "encrypted_malicious-invite",
|
||||
email: "encrypted_attacker@evil.com",
|
||||
})
|
||||
).toString("base64url") +
|
||||
".";
|
||||
|
||||
expect(() => verifyInviteToken(maliciousToken)).toThrow("Invalid or expired invite token");
|
||||
});
|
||||
|
||||
test("should reject 'none' algorithm tokens in verifyEmailChangeToken", async () => {
|
||||
const maliciousToken =
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
alg: "none",
|
||||
typ: "JWT",
|
||||
})
|
||||
).toString("base64url") +
|
||||
"." +
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
id: "encrypted_malicious-id",
|
||||
email: "encrypted_attacker@evil.com",
|
||||
})
|
||||
).toString("base64url") +
|
||||
".";
|
||||
|
||||
await expect(verifyEmailChangeToken(maliciousToken)).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("should reject RS256 algorithm tokens (HS256/RS256 confusion)", async () => {
|
||||
// Create malicious token with RS256 algorithm header but HS256 signature
|
||||
const maliciousHeader = Buffer.from(
|
||||
JSON.stringify({
|
||||
alg: "RS256",
|
||||
typ: "JWT",
|
||||
})
|
||||
).toString("base64url");
|
||||
|
||||
const maliciousPayload = Buffer.from(
|
||||
JSON.stringify({
|
||||
id: "encrypted_malicious-id",
|
||||
})
|
||||
).toString("base64url");
|
||||
|
||||
// Create signature using HMAC (as if it were HS256)
|
||||
const crypto = require("crypto");
|
||||
const signature = crypto
|
||||
.createHmac("sha256", TEST_NEXTAUTH_SECRET)
|
||||
.update(`${maliciousHeader}.${maliciousPayload}`)
|
||||
.digest("base64url");
|
||||
|
||||
const maliciousToken = `${maliciousHeader}.${maliciousPayload}.${signature}`;
|
||||
|
||||
await expect(verifyToken(maliciousToken)).rejects.toThrow("Invalid token");
|
||||
});
|
||||
|
||||
test("should only accept HS256 algorithm", async () => {
|
||||
// Test that other valid algorithms are rejected
|
||||
const otherAlgorithms = ["HS384", "HS512", "RS256", "RS384", "RS512", "ES256", "ES384", "ES512"];
|
||||
|
||||
for (const alg of otherAlgorithms) {
|
||||
const maliciousHeader = Buffer.from(
|
||||
JSON.stringify({
|
||||
alg,
|
||||
typ: "JWT",
|
||||
})
|
||||
).toString("base64url");
|
||||
|
||||
const maliciousPayload = Buffer.from(
|
||||
JSON.stringify({
|
||||
id: "encrypted_test-id",
|
||||
})
|
||||
).toString("base64url");
|
||||
|
||||
const maliciousToken = `${maliciousHeader}.${maliciousPayload}.fake-signature`;
|
||||
|
||||
await expect(verifyToken(maliciousToken)).rejects.toThrow("Invalid token");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Token Tampering", () => {
|
||||
test("should reject tokens with modified payload", async () => {
|
||||
const token = createToken(mockUser.id);
|
||||
const [header, payload, signature] = token.split(".");
|
||||
|
||||
// Modify the payload
|
||||
const decodedPayload = JSON.parse(Buffer.from(payload, "base64url").toString());
|
||||
decodedPayload.id = "malicious-id";
|
||||
const tamperedPayload = Buffer.from(JSON.stringify(decodedPayload)).toString("base64url");
|
||||
const tamperedToken = `${header}.${tamperedPayload}.${signature}`;
|
||||
|
||||
await expect(verifyToken(tamperedToken)).rejects.toThrow("Invalid token");
|
||||
});
|
||||
|
||||
test("should reject tokens with modified signature", async () => {
|
||||
const token = createToken(mockUser.id);
|
||||
const [header, payload] = token.split(".");
|
||||
const tamperedToken = `${header}.${payload}.tamperedsignature`;
|
||||
|
||||
await expect(verifyToken(tamperedToken)).rejects.toThrow("Invalid token");
|
||||
});
|
||||
|
||||
test("should reject malformed tokens", async () => {
|
||||
const malformedTokens = [
|
||||
"not.a.jwt",
|
||||
"only.two.parts",
|
||||
"too.many.parts.here.invalid",
|
||||
"",
|
||||
"invalid-base64",
|
||||
];
|
||||
|
||||
for (const malformedToken of malformedTokens) {
|
||||
await expect(verifyToken(malformedToken)).rejects.toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Cross-Survey Token Reuse", () => {
|
||||
test("should reject survey tokens used for different surveys", () => {
|
||||
const surveyId1 = "survey-1";
|
||||
const surveyId2 = "survey-2";
|
||||
|
||||
const token = createTokenForLinkSurvey(surveyId1, mockUser.email);
|
||||
const result = verifyTokenForLinkSurvey(token, surveyId2);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Expired Tokens", () => {
|
||||
test("should reject expired tokens", async () => {
|
||||
const expiredToken = jwt.sign(
|
||||
{
|
||||
id: "encrypted_test-id",
|
||||
exp: Math.floor(Date.now() / 1000) - 3600, // Expired 1 hour ago
|
||||
},
|
||||
TEST_NEXTAUTH_SECRET
|
||||
);
|
||||
|
||||
await expect(verifyToken(expiredToken)).rejects.toThrow("Invalid token");
|
||||
});
|
||||
|
||||
test("should reject expired email change tokens", async () => {
|
||||
const expiredToken = jwt.sign(
|
||||
{
|
||||
id: "encrypted_test-id",
|
||||
email: "encrypted_test@example.com",
|
||||
exp: Math.floor(Date.now() / 1000) - 3600, // Expired 1 hour ago
|
||||
},
|
||||
TEST_NEXTAUTH_SECRET
|
||||
);
|
||||
|
||||
await expect(verifyEmailChangeToken(expiredToken)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Encryption Key Attacks", () => {
|
||||
test("should fail gracefully with wrong encryption key", async () => {
|
||||
mockSymmetricDecrypt.mockImplementation(() => {
|
||||
throw new Error("Authentication tag verification failed");
|
||||
});
|
||||
|
||||
// Mock findUnique to only return user for correct decrypted ID, not ciphertext
|
||||
(prisma.user.findUnique as any).mockImplementation(({ where }: { where: { id: string } }) => {
|
||||
if (where.id === mockUser.id) {
|
||||
return Promise.resolve(mockUser);
|
||||
}
|
||||
return Promise.resolve(null); // Return null for ciphertext IDs
|
||||
});
|
||||
|
||||
const token = createToken(mockUser.id);
|
||||
// Should fail because ciphertext passed as userId won't match any user in DB
|
||||
await expect(verifyToken(token)).rejects.toThrow(/User not found/i);
|
||||
});
|
||||
|
||||
test("should handle encryption key not set gracefully", async () => {
|
||||
const constants = await import("@/lib/constants");
|
||||
const originalKey = (constants as any).ENCRYPTION_KEY;
|
||||
(constants as any).ENCRYPTION_KEY = undefined;
|
||||
|
||||
const token = jwt.sign(
|
||||
{
|
||||
email: "test@example.com",
|
||||
surveyId: "test-survey-id",
|
||||
},
|
||||
TEST_NEXTAUTH_SECRET
|
||||
);
|
||||
|
||||
const result = verifyTokenForLinkSurvey(token, "test-survey-id");
|
||||
expect(result).toBe("test@example.com");
|
||||
|
||||
// Restore
|
||||
(constants as any).ENCRYPTION_KEY = originalKey;
|
||||
});
|
||||
});
|
||||
|
||||
describe("SQL Injection Attempts", () => {
|
||||
test("should safely handle malicious user IDs", async () => {
|
||||
const maliciousIds = [
|
||||
"'; DROP TABLE users; --",
|
||||
"1' OR '1'='1",
|
||||
"admin'/*",
|
||||
"<script>alert('xss')</script>",
|
||||
"../../etc/passwd",
|
||||
];
|
||||
|
||||
for (const maliciousId of maliciousIds) {
|
||||
mockSymmetricDecrypt.mockReturnValueOnce(maliciousId);
|
||||
|
||||
const token = jwt.sign({ id: "encrypted_malicious" }, TEST_NEXTAUTH_SECRET);
|
||||
|
||||
// The function should look up the user safely
|
||||
await verifyToken(token);
|
||||
expect(prisma.user.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: maliciousId },
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Token Reuse and Replay Attacks", () => {
|
||||
test("should allow legitimate token reuse within validity period", async () => {
|
||||
const token = createToken(mockUser.id);
|
||||
|
||||
// First use
|
||||
const result1 = await verifyToken(token);
|
||||
expect(result1.id).toBe(mockUser.id); // Returns decrypted user ID
|
||||
|
||||
// Second use (should still work)
|
||||
const result2 = await verifyToken(token);
|
||||
expect(result2.id).toBe(mockUser.id); // Returns decrypted user ID
|
||||
});
|
||||
});
|
||||
|
||||
describe("Legacy Token Compatibility", () => {
|
||||
test("should handle legacy unencrypted tokens gracefully", async () => {
|
||||
// Legacy token with plain text data
|
||||
const legacyToken = jwt.sign({ id: mockUser.id }, TEST_NEXTAUTH_SECRET);
|
||||
const result = await verifyToken(legacyToken);
|
||||
|
||||
expect(result.id).toBe(mockUser.id); // Returns raw ID from payload
|
||||
expect(result.email).toBe(mockUser.email);
|
||||
});
|
||||
|
||||
test("should handle mixed encrypted/unencrypted fields", async () => {
|
||||
mockSymmetricDecrypt
|
||||
.mockImplementationOnce(() => mockUser.id) // id decrypts successfully
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error("Email not encrypted");
|
||||
}); // email fails
|
||||
|
||||
const token = jwt.sign(
|
||||
{
|
||||
id: "encrypted_test-id",
|
||||
email: "plain-email@example.com",
|
||||
},
|
||||
TEST_NEXTAUTH_SECRET
|
||||
);
|
||||
|
||||
const result = await verifyEmailChangeToken(token);
|
||||
expect(result.id).toBe(mockUser.id);
|
||||
expect(result.email).toBe("plain-email@example.com");
|
||||
});
|
||||
|
||||
test("should verify old format user tokens with email-based secrets", async () => {
|
||||
// Simulate old token format with per-user secret
|
||||
const oldFormatToken = jwt.sign(
|
||||
{ id: `encrypted_${mockUser.id}` },
|
||||
TEST_NEXTAUTH_SECRET + mockUser.email
|
||||
);
|
||||
|
||||
const result = await verifyToken(oldFormatToken);
|
||||
expect(result.id).toBe(mockUser.id); // Returns decrypted user ID
|
||||
expect(result.email).toBe(mockUser.email);
|
||||
});
|
||||
|
||||
test("should verify old format survey tokens with survey-based secrets", () => {
|
||||
const surveyId = "legacy-survey-id";
|
||||
|
||||
// Simulate old survey token format
|
||||
const oldFormatSurveyToken = jwt.sign(
|
||||
{ email: `encrypted_${mockUser.email}` },
|
||||
TEST_NEXTAUTH_SECRET + surveyId
|
||||
);
|
||||
|
||||
const result = verifyTokenForLinkSurvey(oldFormatSurveyToken, surveyId);
|
||||
expect(result).toBe(mockUser.email);
|
||||
});
|
||||
|
||||
test("should gracefully handle database errors during legacy verification", async () => {
|
||||
// Create token that will fail new method
|
||||
const legacyToken = jwt.sign(
|
||||
{ id: `encrypted_${mockUser.id}` },
|
||||
TEST_NEXTAUTH_SECRET + mockUser.email
|
||||
);
|
||||
|
||||
// Make database lookup fail
|
||||
(prisma.user.findUnique as any).mockRejectedValueOnce(new Error("DB connection lost"));
|
||||
|
||||
await expect(verifyToken(legacyToken)).rejects.toThrow("DB connection lost");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge Cases and Error Handling", () => {
|
||||
test("should handle database connection errors gracefully", async () => {
|
||||
(prisma.user.findUnique as any).mockRejectedValue(new Error("Database connection failed"));
|
||||
|
||||
const token = createToken(mockUser.id);
|
||||
await expect(verifyToken(token)).rejects.toThrow("Database connection failed");
|
||||
});
|
||||
|
||||
test("should handle crypto module errors", () => {
|
||||
mockSymmetricEncrypt.mockImplementation(() => {
|
||||
throw new Error("Crypto module error");
|
||||
});
|
||||
|
||||
expect(() => createToken(mockUser.id)).toThrow("Crypto module error");
|
||||
});
|
||||
|
||||
test("should validate email format in tokens", () => {
|
||||
const invalidEmails = ["", "not-an-email", "missing@", "@missing-local.com", "spaces in@email.com"];
|
||||
|
||||
invalidEmails.forEach((invalidEmail) => {
|
||||
expect(() => createEmailToken(invalidEmail)).not.toThrow();
|
||||
// Note: JWT functions don't validate email format, they just encrypt/decrypt
|
||||
// Email validation should happen at a higher level
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle extremely long inputs", () => {
|
||||
const longString = "a".repeat(10000);
|
||||
|
||||
expect(() => createToken(longString)).not.toThrow();
|
||||
expect(() => createEmailToken(longString)).not.toThrow();
|
||||
});
|
||||
|
||||
test("should handle special characters in user data", () => {
|
||||
const specialChars = "!@#$%^&*()_+-=[]{}|;:'\",.<>?/~`";
|
||||
|
||||
expect(() => createToken(specialChars)).not.toThrow();
|
||||
expect(() => createEmailToken(specialChars)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Performance and Resource Exhaustion", () => {
|
||||
test("should handle rapid token creation without memory leaks", () => {
|
||||
const tokens: string[] = [];
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
tokens.push(createToken(`user-${i}`));
|
||||
}
|
||||
|
||||
expect(tokens.length).toBe(1000);
|
||||
expect(tokens.every((token) => typeof token === "string")).toBe(true);
|
||||
});
|
||||
|
||||
test("should handle rapid token verification", async () => {
|
||||
const token = createToken(mockUser.id);
|
||||
|
||||
const verifications: Promise<any>[] = [];
|
||||
for (let i = 0; i < 100; i++) {
|
||||
verifications.push(verifyToken(token));
|
||||
}
|
||||
|
||||
const results = await Promise.all(verifications);
|
||||
expect(results.length).toBe(100);
|
||||
expect(results.every((result: any) => result.id === mockUser.id)).toBe(true); // Returns decrypted user ID
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,43 +1,64 @@
|
||||
import { symmetricDecrypt, symmetricEncrypt } from "@/lib/crypto";
|
||||
import { env } from "@/lib/env";
|
||||
import jwt, { JwtPayload } from "jsonwebtoken";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ENCRYPTION_KEY, NEXTAUTH_SECRET } from "@/lib/constants";
|
||||
import { symmetricDecrypt, symmetricEncrypt } from "@/lib/crypto";
|
||||
|
||||
export const createToken = (userId: string, userEmail: string, options = {}): string => {
|
||||
const encryptedUserId = symmetricEncrypt(userId, env.ENCRYPTION_KEY);
|
||||
return jwt.sign({ id: encryptedUserId }, env.NEXTAUTH_SECRET + userEmail, options);
|
||||
};
|
||||
export const createTokenForLinkSurvey = (surveyId: string, userEmail: string): string => {
|
||||
const encryptedEmail = symmetricEncrypt(userEmail, env.ENCRYPTION_KEY);
|
||||
return jwt.sign({ email: encryptedEmail }, env.NEXTAUTH_SECRET + surveyId);
|
||||
// Helper function to decrypt with fallback to plain text
|
||||
const decryptWithFallback = (encryptedText: string, key: string): string => {
|
||||
try {
|
||||
return symmetricDecrypt(encryptedText, key);
|
||||
} catch {
|
||||
return encryptedText; // Return as-is if decryption fails (legacy format)
|
||||
}
|
||||
};
|
||||
|
||||
export const verifyEmailChangeToken = async (token: string): Promise<{ id: string; email: string }> => {
|
||||
if (!env.NEXTAUTH_SECRET) {
|
||||
export const createToken = (userId: string, options = {}): string => {
|
||||
if (!NEXTAUTH_SECRET) {
|
||||
throw new Error("NEXTAUTH_SECRET is not set");
|
||||
}
|
||||
|
||||
const payload = jwt.verify(token, env.NEXTAUTH_SECRET) as { id: string; email: string };
|
||||
if (!ENCRYPTION_KEY) {
|
||||
throw new Error("ENCRYPTION_KEY is not set");
|
||||
}
|
||||
|
||||
const encryptedUserId = symmetricEncrypt(userId, ENCRYPTION_KEY);
|
||||
return jwt.sign({ id: encryptedUserId }, NEXTAUTH_SECRET, options);
|
||||
};
|
||||
export const createTokenForLinkSurvey = (surveyId: string, userEmail: string): string => {
|
||||
if (!NEXTAUTH_SECRET) {
|
||||
throw new Error("NEXTAUTH_SECRET is not set");
|
||||
}
|
||||
|
||||
if (!ENCRYPTION_KEY) {
|
||||
throw new Error("ENCRYPTION_KEY is not set");
|
||||
}
|
||||
|
||||
const encryptedEmail = symmetricEncrypt(userEmail, ENCRYPTION_KEY);
|
||||
return jwt.sign({ email: encryptedEmail, surveyId }, NEXTAUTH_SECRET);
|
||||
};
|
||||
|
||||
export const verifyEmailChangeToken = async (token: string): Promise<{ id: string; email: string }> => {
|
||||
if (!NEXTAUTH_SECRET) {
|
||||
throw new Error("NEXTAUTH_SECRET is not set");
|
||||
}
|
||||
|
||||
if (!ENCRYPTION_KEY) {
|
||||
throw new Error("ENCRYPTION_KEY is not set");
|
||||
}
|
||||
|
||||
const payload = jwt.verify(token, NEXTAUTH_SECRET, { algorithms: ["HS256"] }) as {
|
||||
id: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
if (!payload?.id || !payload?.email) {
|
||||
throw new Error("Token is invalid or missing required fields");
|
||||
}
|
||||
|
||||
let decryptedId: string;
|
||||
let decryptedEmail: string;
|
||||
|
||||
try {
|
||||
decryptedId = symmetricDecrypt(payload.id, env.ENCRYPTION_KEY);
|
||||
} catch {
|
||||
decryptedId = payload.id;
|
||||
}
|
||||
|
||||
try {
|
||||
decryptedEmail = symmetricDecrypt(payload.email, env.ENCRYPTION_KEY);
|
||||
} catch {
|
||||
decryptedEmail = payload.email;
|
||||
}
|
||||
// Decrypt both fields with fallback
|
||||
const decryptedId = decryptWithFallback(payload.id, ENCRYPTION_KEY);
|
||||
const decryptedEmail = decryptWithFallback(payload.email, ENCRYPTION_KEY);
|
||||
|
||||
return {
|
||||
id: decryptedId,
|
||||
@@ -46,127 +67,230 @@ export const verifyEmailChangeToken = async (token: string): Promise<{ id: strin
|
||||
};
|
||||
|
||||
export const createEmailChangeToken = (userId: string, email: string): string => {
|
||||
const encryptedUserId = symmetricEncrypt(userId, env.ENCRYPTION_KEY);
|
||||
const encryptedEmail = symmetricEncrypt(email, env.ENCRYPTION_KEY);
|
||||
if (!NEXTAUTH_SECRET) {
|
||||
throw new Error("NEXTAUTH_SECRET is not set");
|
||||
}
|
||||
|
||||
if (!ENCRYPTION_KEY) {
|
||||
throw new Error("ENCRYPTION_KEY is not set");
|
||||
}
|
||||
|
||||
const encryptedUserId = symmetricEncrypt(userId, ENCRYPTION_KEY);
|
||||
const encryptedEmail = symmetricEncrypt(email, ENCRYPTION_KEY);
|
||||
|
||||
const payload = {
|
||||
id: encryptedUserId,
|
||||
email: encryptedEmail,
|
||||
};
|
||||
|
||||
return jwt.sign(payload, env.NEXTAUTH_SECRET as string, {
|
||||
return jwt.sign(payload, NEXTAUTH_SECRET, {
|
||||
expiresIn: "1d",
|
||||
});
|
||||
};
|
||||
|
||||
export const createEmailToken = (email: string): string => {
|
||||
if (!env.NEXTAUTH_SECRET) {
|
||||
if (!NEXTAUTH_SECRET) {
|
||||
throw new Error("NEXTAUTH_SECRET is not set");
|
||||
}
|
||||
|
||||
const encryptedEmail = symmetricEncrypt(email, env.ENCRYPTION_KEY);
|
||||
return jwt.sign({ email: encryptedEmail }, env.NEXTAUTH_SECRET);
|
||||
if (!ENCRYPTION_KEY) {
|
||||
throw new Error("ENCRYPTION_KEY is not set");
|
||||
}
|
||||
|
||||
const encryptedEmail = symmetricEncrypt(email, ENCRYPTION_KEY);
|
||||
return jwt.sign({ email: encryptedEmail }, NEXTAUTH_SECRET);
|
||||
};
|
||||
|
||||
export const getEmailFromEmailToken = (token: string): string => {
|
||||
if (!env.NEXTAUTH_SECRET) {
|
||||
if (!NEXTAUTH_SECRET) {
|
||||
throw new Error("NEXTAUTH_SECRET is not set");
|
||||
}
|
||||
|
||||
const payload = jwt.verify(token, env.NEXTAUTH_SECRET) as JwtPayload;
|
||||
try {
|
||||
// Try to decrypt first (for newer tokens)
|
||||
const decryptedEmail = symmetricDecrypt(payload.email, env.ENCRYPTION_KEY);
|
||||
return decryptedEmail;
|
||||
} catch {
|
||||
// If decryption fails, return the original email (for older tokens)
|
||||
return payload.email;
|
||||
if (!ENCRYPTION_KEY) {
|
||||
throw new Error("ENCRYPTION_KEY is not set");
|
||||
}
|
||||
|
||||
const payload = jwt.verify(token, NEXTAUTH_SECRET, { algorithms: ["HS256"] }) as JwtPayload & {
|
||||
email: string;
|
||||
};
|
||||
return decryptWithFallback(payload.email, ENCRYPTION_KEY);
|
||||
};
|
||||
|
||||
export const createInviteToken = (inviteId: string, email: string, options = {}): string => {
|
||||
if (!env.NEXTAUTH_SECRET) {
|
||||
if (!NEXTAUTH_SECRET) {
|
||||
throw new Error("NEXTAUTH_SECRET is not set");
|
||||
}
|
||||
const encryptedInviteId = symmetricEncrypt(inviteId, env.ENCRYPTION_KEY);
|
||||
const encryptedEmail = symmetricEncrypt(email, env.ENCRYPTION_KEY);
|
||||
return jwt.sign({ inviteId: encryptedInviteId, email: encryptedEmail }, env.NEXTAUTH_SECRET, options);
|
||||
|
||||
if (!ENCRYPTION_KEY) {
|
||||
throw new Error("ENCRYPTION_KEY is not set");
|
||||
}
|
||||
|
||||
const encryptedInviteId = symmetricEncrypt(inviteId, ENCRYPTION_KEY);
|
||||
const encryptedEmail = symmetricEncrypt(email, ENCRYPTION_KEY);
|
||||
return jwt.sign({ inviteId: encryptedInviteId, email: encryptedEmail }, NEXTAUTH_SECRET, options);
|
||||
};
|
||||
|
||||
export const verifyTokenForLinkSurvey = (token: string, surveyId: string): string | null => {
|
||||
if (!NEXTAUTH_SECRET) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const { email } = jwt.verify(token, env.NEXTAUTH_SECRET + surveyId) as JwtPayload;
|
||||
let payload: JwtPayload & { email: string; surveyId?: string };
|
||||
|
||||
// Try primary method first (consistent secret)
|
||||
try {
|
||||
// Try to decrypt first (for newer tokens)
|
||||
if (!env.ENCRYPTION_KEY) {
|
||||
throw new Error("ENCRYPTION_KEY is not set");
|
||||
payload = jwt.verify(token, NEXTAUTH_SECRET, { algorithms: ["HS256"] }) as JwtPayload & {
|
||||
email: string;
|
||||
surveyId: string;
|
||||
};
|
||||
} catch (primaryError) {
|
||||
logger.error(primaryError, "Token verification failed with primary method");
|
||||
|
||||
// Fallback to legacy method (surveyId-based secret)
|
||||
try {
|
||||
payload = jwt.verify(token, NEXTAUTH_SECRET + surveyId, { algorithms: ["HS256"] }) as JwtPayload & {
|
||||
email: string;
|
||||
};
|
||||
} catch (legacyError) {
|
||||
logger.error(legacyError, "Token verification failed with legacy method");
|
||||
throw new Error("Invalid token");
|
||||
}
|
||||
const decryptedEmail = symmetricDecrypt(email, env.ENCRYPTION_KEY);
|
||||
return decryptedEmail;
|
||||
} catch {
|
||||
// If decryption fails, return the original email (for older tokens)
|
||||
return email;
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
// Verify the surveyId matches if present in payload (new format)
|
||||
if (payload.surveyId && payload.surveyId !== surveyId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { email } = payload;
|
||||
if (!email) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Decrypt email with fallback to plain text
|
||||
if (!ENCRYPTION_KEY) {
|
||||
return email; // Return as-is if encryption key not set
|
||||
}
|
||||
|
||||
return decryptWithFallback(email, ENCRYPTION_KEY);
|
||||
} catch (error) {
|
||||
logger.error(error, "Survey link token verification failed");
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const verifyToken = async (token: string): Promise<JwtPayload> => {
|
||||
// First decode to get the ID
|
||||
const decoded = jwt.decode(token);
|
||||
const payload: JwtPayload = decoded as JwtPayload;
|
||||
// Helper function to get user email for legacy verification
|
||||
const getUserEmailForLegacyVerification = async (
|
||||
token: string,
|
||||
userId?: string
|
||||
): Promise<{ userId: string; userEmail: string }> => {
|
||||
if (!userId) {
|
||||
const decoded = jwt.decode(token);
|
||||
|
||||
if (!payload) {
|
||||
throw new Error("Token is invalid");
|
||||
// Validate decoded token structure before using it
|
||||
if (
|
||||
!decoded ||
|
||||
typeof decoded !== "object" ||
|
||||
!decoded.id ||
|
||||
typeof decoded.id !== "string" ||
|
||||
decoded.id.trim() === ""
|
||||
) {
|
||||
logger.error("Invalid token: missing or invalid user ID");
|
||||
throw new Error("Invalid token");
|
||||
}
|
||||
|
||||
userId = decoded.id;
|
||||
}
|
||||
|
||||
const { id } = payload;
|
||||
if (!id) {
|
||||
throw new Error("Token missing required field: id");
|
||||
const decryptedId = decryptWithFallback(userId, ENCRYPTION_KEY);
|
||||
|
||||
// Validate decrypted ID before database query
|
||||
if (!decryptedId || typeof decryptedId !== "string" || decryptedId.trim() === "") {
|
||||
logger.error("Invalid token: missing or invalid user ID");
|
||||
throw new Error("Invalid token");
|
||||
}
|
||||
|
||||
// Try to decrypt the ID (for newer tokens), if it fails use the ID as-is (for older tokens)
|
||||
let decryptedId: string;
|
||||
try {
|
||||
decryptedId = symmetricDecrypt(id, env.ENCRYPTION_KEY);
|
||||
} catch {
|
||||
decryptedId = id;
|
||||
}
|
||||
|
||||
// If no email provided, look up the user
|
||||
const foundUser = await prisma.user.findUnique({
|
||||
where: { id: decryptedId },
|
||||
});
|
||||
|
||||
if (!foundUser) {
|
||||
throw new Error("User not found");
|
||||
const errorMessage = "User not found";
|
||||
logger.error(errorMessage);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const userEmail = foundUser.email;
|
||||
return { userId: decryptedId, userEmail: foundUser.email };
|
||||
};
|
||||
|
||||
return { id: decryptedId, email: userEmail };
|
||||
export const verifyToken = async (token: string): Promise<JwtPayload> => {
|
||||
if (!NEXTAUTH_SECRET) {
|
||||
throw new Error("NEXTAUTH_SECRET is not set");
|
||||
}
|
||||
|
||||
let payload: JwtPayload & { id: string };
|
||||
let userData: { userId: string; userEmail: string } | null = null;
|
||||
|
||||
// Try new method first, with smart fallback to legacy
|
||||
try {
|
||||
payload = jwt.verify(token, NEXTAUTH_SECRET, { algorithms: ["HS256"] }) as JwtPayload & {
|
||||
id: string;
|
||||
};
|
||||
} catch (newMethodError) {
|
||||
logger.error(newMethodError, "Token verification failed with new method");
|
||||
|
||||
// Get user email for legacy verification
|
||||
userData = await getUserEmailForLegacyVerification(token);
|
||||
|
||||
// Try legacy verification with email-based secret
|
||||
try {
|
||||
payload = jwt.verify(token, NEXTAUTH_SECRET + userData.userEmail, {
|
||||
algorithms: ["HS256"],
|
||||
}) as JwtPayload & {
|
||||
id: string;
|
||||
};
|
||||
} catch (legacyMethodError) {
|
||||
logger.error(legacyMethodError, "Token verification failed with legacy method");
|
||||
throw new Error("Invalid token");
|
||||
}
|
||||
}
|
||||
|
||||
if (!payload?.id) {
|
||||
throw new Error("Invalid token");
|
||||
}
|
||||
|
||||
// Get user email if we don't have it yet
|
||||
userData ??= await getUserEmailForLegacyVerification(token, payload.id);
|
||||
|
||||
return { id: userData.userId, email: userData.userEmail };
|
||||
};
|
||||
|
||||
export const verifyInviteToken = (token: string): { inviteId: string; email: string } => {
|
||||
if (!NEXTAUTH_SECRET) {
|
||||
throw new Error("NEXTAUTH_SECRET is not set");
|
||||
}
|
||||
|
||||
if (!ENCRYPTION_KEY) {
|
||||
throw new Error("ENCRYPTION_KEY is not set");
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwt.decode(token);
|
||||
const payload: JwtPayload = decoded as JwtPayload;
|
||||
const payload = jwt.verify(token, NEXTAUTH_SECRET, { algorithms: ["HS256"] }) as JwtPayload & {
|
||||
inviteId: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
const { inviteId, email } = payload;
|
||||
const { inviteId: encryptedInviteId, email: encryptedEmail } = payload;
|
||||
|
||||
let decryptedInviteId: string;
|
||||
let decryptedEmail: string;
|
||||
|
||||
try {
|
||||
// Try to decrypt first (for newer tokens)
|
||||
decryptedInviteId = symmetricDecrypt(inviteId, env.ENCRYPTION_KEY);
|
||||
decryptedEmail = symmetricDecrypt(email, env.ENCRYPTION_KEY);
|
||||
} catch {
|
||||
// If decryption fails, use original values (for older tokens)
|
||||
decryptedInviteId = inviteId;
|
||||
decryptedEmail = email;
|
||||
if (!encryptedInviteId || !encryptedEmail) {
|
||||
throw new Error("Invalid token");
|
||||
}
|
||||
|
||||
// Decrypt both fields with fallback to original values
|
||||
const decryptedInviteId = decryptWithFallback(encryptedInviteId, ENCRYPTION_KEY);
|
||||
const decryptedEmail = decryptWithFallback(encryptedEmail, ENCRYPTION_KEY);
|
||||
|
||||
return {
|
||||
inviteId: decryptedInviteId,
|
||||
email: decryptedEmail,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { parseRecallInfo } from "@/lib/utils/recall";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
import { TResponse, TResponseDataValue } from "@formbricks/types/responses";
|
||||
import { TSurvey, TSurveyQuestion, TSurveyQuestionType } from "@formbricks/types/surveys/types";
|
||||
import { getLanguageCode, getLocalizedValue } from "./i18n/utils";
|
||||
|
||||
// function to convert response value of type string | number | string[] or Record<string, string> to string | string[]
|
||||
export const convertResponseValue = (
|
||||
answer: string | number | string[] | Record<string, string>,
|
||||
answer: TResponseDataValue,
|
||||
question: TSurveyQuestion
|
||||
): string | string[] => {
|
||||
switch (question.type) {
|
||||
@@ -57,9 +57,7 @@ export const getQuestionResponseMapping = (
|
||||
return questionResponseMapping;
|
||||
};
|
||||
|
||||
export const processResponseData = (
|
||||
responseData: string | number | string[] | Record<string, string>
|
||||
): string => {
|
||||
export const processResponseData = (responseData: TResponseDataValue): string => {
|
||||
switch (typeof responseData) {
|
||||
case "string":
|
||||
return responseData;
|
||||
|
||||
@@ -450,7 +450,7 @@ const evaluateSingleCondition = (
|
||||
return (
|
||||
Array.isArray(leftValue) &&
|
||||
Array.isArray(rightValue) &&
|
||||
rightValue.some((v) => !leftValue.includes(v))
|
||||
!rightValue.some((v) => leftValue.includes(v))
|
||||
);
|
||||
case "isAccepted":
|
||||
return leftValue === "accepted";
|
||||
|
||||
@@ -1,36 +1,7 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
capitalizeFirstLetter,
|
||||
isCapitalized,
|
||||
sanitizeString,
|
||||
startsWithVowel,
|
||||
truncate,
|
||||
truncateText,
|
||||
} from "./strings";
|
||||
import { isCapitalized, sanitizeString, startsWithVowel, truncate, truncateText } from "./strings";
|
||||
|
||||
describe("String Utilities", () => {
|
||||
describe("capitalizeFirstLetter", () => {
|
||||
test("capitalizes the first letter of a string", () => {
|
||||
expect(capitalizeFirstLetter("hello")).toBe("Hello");
|
||||
});
|
||||
|
||||
test("returns empty string if input is null", () => {
|
||||
expect(capitalizeFirstLetter(null)).toBe("");
|
||||
});
|
||||
|
||||
test("returns empty string if input is empty string", () => {
|
||||
expect(capitalizeFirstLetter("")).toBe("");
|
||||
});
|
||||
|
||||
test("doesn't change already capitalized string", () => {
|
||||
expect(capitalizeFirstLetter("Hello")).toBe("Hello");
|
||||
});
|
||||
|
||||
test("handles single character string", () => {
|
||||
expect(capitalizeFirstLetter("a")).toBe("A");
|
||||
});
|
||||
});
|
||||
|
||||
describe("truncate", () => {
|
||||
test("returns the string as is if length is less than the specified length", () => {
|
||||
expect(truncate("hello", 10)).toBe("hello");
|
||||
|
||||
@@ -1,10 +1,3 @@
|
||||
export const capitalizeFirstLetter = (string: string | null = "") => {
|
||||
if (string === null) {
|
||||
return "";
|
||||
}
|
||||
return string.charAt(0).toUpperCase() + string.slice(1);
|
||||
};
|
||||
|
||||
// write a function that takes a string and truncates it to the specified length
|
||||
export const truncate = (str: string, length: number) => {
|
||||
if (!str) return "";
|
||||
|
||||
@@ -169,11 +169,14 @@
|
||||
"connect_formbricks": "Formbricks verbinden",
|
||||
"connected": "Verbunden",
|
||||
"contacts": "Kontakte",
|
||||
"continue": "Weitermachen",
|
||||
"copied": "Kopiert",
|
||||
"copied_to_clipboard": "In die Zwischenablage kopiert",
|
||||
"copy": "Kopieren",
|
||||
"copy_code": "Code 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_project": "Projekt erstellen",
|
||||
"create_segment": "Segment erstellen",
|
||||
@@ -201,6 +204,7 @@
|
||||
"e_commerce": "E-Commerce",
|
||||
"edit": "Bearbeiten",
|
||||
"email": "E-Mail",
|
||||
"ending_card": "Abschluss-Karte",
|
||||
"enterprise_license": "Enterprise Lizenz",
|
||||
"environment_not_found": "Umgebung nicht gefunden",
|
||||
"environment_notice": "Du befindest dich derzeit in der {environment}-Umgebung.",
|
||||
@@ -258,7 +262,9 @@
|
||||
"membership_not_found": "Mitgliedschaft nicht gefunden",
|
||||
"metadata": "Metadaten",
|
||||
"minimum": "Minimum",
|
||||
"mobile_overlay_text": "Formbricks ist für Geräte mit kleineren Auflösungen nicht verfügbar.",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks funktioniert am besten auf einem größeren Bildschirm. Um Umfragen zu verwalten oder zu erstellen, wechsle zu einem anderen Gerät.",
|
||||
"mobile_overlay_surveys_look_good": "Keine Sorge – deine Umfragen sehen auf jedem Gerät und jeder Bildschirmgröße großartig aus!",
|
||||
"mobile_overlay_title": "Oops, Bildschirm zu klein erkannt!",
|
||||
"move_down": "Nach unten bewegen",
|
||||
"move_up": "Nach oben bewegen",
|
||||
"multiple_languages": "Mehrsprachigkeit",
|
||||
@@ -269,6 +275,7 @@
|
||||
"no_background_image_found": "Kein Hintergrundbild gefunden.",
|
||||
"no_code": "No Code",
|
||||
"no_files_uploaded": "Keine Dateien hochgeladen",
|
||||
"no_quotas_found": "Keine Kontingente gefunden",
|
||||
"no_result_found": "Kein Ergebnis gefunden",
|
||||
"no_results": "Keine Ergebnisse",
|
||||
"no_surveys_found": "Keine Umfragen gefunden.",
|
||||
@@ -312,6 +319,7 @@
|
||||
"product_manager": "Produktmanager",
|
||||
"profile": "Profil",
|
||||
"profile_id": "Profil-ID",
|
||||
"progress": "Fortschritt",
|
||||
"project_configuration": "Projekteinstellungen",
|
||||
"project_creation_description": "Organisieren Sie Umfragen in Projekten für eine bessere Zugriffskontrolle.",
|
||||
"project_id": "Projekt-ID",
|
||||
@@ -323,6 +331,9 @@
|
||||
"question": "Frage",
|
||||
"question_id": "Frage-ID",
|
||||
"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",
|
||||
"recipients": "Empfänger",
|
||||
"remove": "Entfernen",
|
||||
@@ -370,6 +381,7 @@
|
||||
"start_free_trial": "Kostenlos starten",
|
||||
"status": "Status",
|
||||
"step_by_step_manual": "Schritt-für-Schritt-Anleitung",
|
||||
"storage_not_configured": "Dateispeicher nicht eingerichtet, Uploads werden wahrscheinlich fehlschlagen",
|
||||
"styling": "Styling",
|
||||
"submit": "Abschicken",
|
||||
"summary": "Zusammenfassung",
|
||||
@@ -579,6 +591,7 @@
|
||||
"contacts_table_refresh": "Kontakte aktualisieren",
|
||||
"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_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",
|
||||
"not_provided": "Nicht angegeben",
|
||||
"search_contact": "Kontakt suchen",
|
||||
@@ -739,7 +752,6 @@
|
||||
},
|
||||
"project": {
|
||||
"api_keys": {
|
||||
"access_control": "Zugriffskontrolle",
|
||||
"add_api_key": "API-Schlüssel hinzufügen",
|
||||
"api_key": "API-Schlüssel",
|
||||
"api_key_copied_to_clipboard": "API-Schlüssel in die Zwischenablage kopiert",
|
||||
@@ -1280,7 +1292,7 @@
|
||||
"columns": "Spalten",
|
||||
"company": "Firma",
|
||||
"company_logo": "Firmenlogo",
|
||||
"completed_responses": "unvollständige oder vollständige Antworten.",
|
||||
"completed_responses": "Abgeschlossene Antworten.",
|
||||
"concat": "Verketten +",
|
||||
"conditional_logic": "Bedingte Logik",
|
||||
"confirm_default_language": "Standardsprache bestätigen",
|
||||
@@ -1320,6 +1332,7 @@
|
||||
"end_screen_card": "Abschluss-Karte",
|
||||
"ending_card": "Abschluss-Karte",
|
||||
"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",
|
||||
"equals": "Gleich",
|
||||
"equals_one_of": "Entspricht einem von",
|
||||
@@ -1330,6 +1343,7 @@
|
||||
"fallback_for": "Ersatz für",
|
||||
"fallback_missing": "Fehlender Fallback",
|
||||
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} wird in der Logik der Frage {questionIndex} verwendet. Bitte entferne es zuerst aus der Logik.",
|
||||
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "Verstecktes Feld \"{fieldId}\" wird in der \"{quotaName}\" Quote verwendet",
|
||||
"field_name_eg_score_price": "Feldname z.B. Punktzahl, Preis",
|
||||
"first_name": "Vorname",
|
||||
"five_points_recommended": "5 Punkte (empfohlen)",
|
||||
@@ -1361,8 +1375,9 @@
|
||||
"follow_ups_modal_action_subject_placeholder": "Betreff der E-Mail",
|
||||
"follow_ups_modal_action_to_description": "Empfänger-E-Mail-Adresse",
|
||||
"follow_ups_modal_action_to_label": "An",
|
||||
"follow_ups_modal_action_to_warning": "Kein E-Mail-Feld in der Umfrage gefunden.",
|
||||
"follow_ups_modal_action_to_warning": "Keine gültigen Optionen für den Versand von E-Mails gefunden, bitte fügen Sie einige Freitext- / Kontaktinformationen-Fragen oder versteckte Felder hinzu",
|
||||
"follow_ups_modal_create_heading": "Neues Follow-up erstellen",
|
||||
"follow_ups_modal_created_successfull_toast": "Nachverfolgung erstellt und wird gespeichert, sobald du die Umfrage speicherst.",
|
||||
"follow_ups_modal_edit_heading": "Follow-up bearbeiten",
|
||||
"follow_ups_modal_edit_no_id": "Keine Survey Follow-up-ID angegeben, das Survey-Follow-up kann nicht aktualisiert werden",
|
||||
"follow_ups_modal_name_label": "Name des Follow-ups",
|
||||
@@ -1372,8 +1387,9 @@
|
||||
"follow_ups_modal_trigger_label": "Auslöser",
|
||||
"follow_ups_modal_trigger_type_ending": "Teilnehmer sieht einen bestimmten Abschluss",
|
||||
"follow_ups_modal_trigger_type_ending_select": "Abschlüsse auswählen: ",
|
||||
"follow_ups_modal_trigger_type_ending_warning": "Keine Abschlüsse in der Umfrage gefunden!",
|
||||
"follow_ups_modal_trigger_type_ending_warning": "Bitte wähle mindestens ein Ende aus oder ändere den Auslöser-Typ",
|
||||
"follow_ups_modal_trigger_type_response": "Teilnehmer schließt Umfrage ab",
|
||||
"follow_ups_modal_updated_successfull_toast": "Nachverfolgung aktualisiert und wird gespeichert, sobald du die Umfrage speicherst.",
|
||||
"follow_ups_new": "Neues Follow-up",
|
||||
"follow_ups_upgrade_button_text": "Upgrade, um Follow-ups zu aktivieren",
|
||||
"form_styling": "Umfrage Styling",
|
||||
@@ -1474,6 +1490,38 @@
|
||||
"question_duplicated": "Frage dupliziert.",
|
||||
"question_id_updated": "Frage-ID aktualisiert",
|
||||
"question_used_in_logic": "Diese Frage wird in der Logik der Frage {questionIndex} verwendet.",
|
||||
"question_used_in_quota": "Diese Frage wird in der \"{quotaName}\" Quote verwendet",
|
||||
"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_except_last": "Alle Optionen zufällig anordnen außer der letzten",
|
||||
"range": "Reichweite",
|
||||
@@ -1567,6 +1615,7 @@
|
||||
"url_not_supported": "URL nicht unterstützt",
|
||||
"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_quota_please_remove_it_from_quota_first": "Variable \"{variableName}\" wird in der \"{quotaName}\" Quote verwendet",
|
||||
"variable_name_is_already_taken_please_choose_another": "Variablenname ist bereits vergeben, bitte wähle einen anderen.",
|
||||
"variable_name_must_start_with_a_letter": "Variablenname muss mit einem Buchstaben beginnen.",
|
||||
"verify_email_before_submission": "E-Mail vor dem Absenden überprüfen",
|
||||
@@ -1601,11 +1650,14 @@
|
||||
"address_line_2": "Adresszeile 2",
|
||||
"an_error_occurred_deleting_the_tag": "Beim Löschen des Tags ist ein Fehler aufgetreten",
|
||||
"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",
|
||||
"company": "Firma",
|
||||
"completed": "Erledigt ✅",
|
||||
"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_quotas": "Die Antwort ist Teil der Quoten für diese Umfrage. Wie möchten Sie die Quoten verwalten?",
|
||||
"device": "Gerät",
|
||||
"device_info": "Geräteinfo",
|
||||
"email": "E-Mail",
|
||||
@@ -1737,6 +1789,7 @@
|
||||
"configure_alerts": "Benachrichtigungen konfigurieren",
|
||||
"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.",
|
||||
"current_count": "Aktuelle Anzahl",
|
||||
"custom_range": "Benutzerdefinierter Bereich...",
|
||||
"delete_all_existing_responses_and_displays": "Alle bestehenden Antworten und Anzeigen löschen",
|
||||
"download_qr_code": "QR Code herunterladen",
|
||||
@@ -1790,6 +1843,7 @@
|
||||
"last_month": "Letztes Monat",
|
||||
"last_quarter": "Letztes Quartal",
|
||||
"last_year": "Letztes Jahr",
|
||||
"limit": "Limit",
|
||||
"no_responses_found": "Keine Antworten gefunden",
|
||||
"other_values_found": "Andere Werte gefunden",
|
||||
"overall": "Insgesamt",
|
||||
@@ -1798,6 +1852,8 @@
|
||||
"qr_code_download_failed": "QR-Code-Download fehlgeschlagen",
|
||||
"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.",
|
||||
"quotas_completed": "Kontingente abgeschlossen",
|
||||
"quotas_completed_tooltip": "Die Anzahl der von den Befragten abgeschlossenen Quoten.",
|
||||
"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.",
|
||||
"selected_responses_csv": "Ausgewählte Antworten (CSV)",
|
||||
|
||||
@@ -169,11 +169,14 @@
|
||||
"connect_formbricks": "Connect Formbricks",
|
||||
"connected": "Connected",
|
||||
"contacts": "Contacts",
|
||||
"continue": "Continue",
|
||||
"copied": "Copied",
|
||||
"copied_to_clipboard": "Copied to clipboard",
|
||||
"copy": "Copy",
|
||||
"copy_code": "Copy code",
|
||||
"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_project": "Create project",
|
||||
"create_segment": "Create segment",
|
||||
@@ -201,6 +204,7 @@
|
||||
"e_commerce": "E-Commerce",
|
||||
"edit": "Edit",
|
||||
"email": "Email",
|
||||
"ending_card": "Ending card",
|
||||
"enterprise_license": "Enterprise License",
|
||||
"environment_not_found": "Environment not found",
|
||||
"environment_notice": "You're currently in the {environment} environment.",
|
||||
@@ -258,7 +262,9 @@
|
||||
"membership_not_found": "Membership not found",
|
||||
"metadata": "Metadata",
|
||||
"minimum": "Minimum",
|
||||
"mobile_overlay_text": "Formbricks is not available for devices with smaller resolutions.",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks works best on a bigger screen. To manage or build surveys, switch to another device.",
|
||||
"mobile_overlay_surveys_look_good": "Don't worry – your surveys look great on every device and screen size!",
|
||||
"mobile_overlay_title": "Oops, tiny screen detected!",
|
||||
"move_down": "Move down",
|
||||
"move_up": "Move up",
|
||||
"multiple_languages": "Multiple languages",
|
||||
@@ -269,6 +275,7 @@
|
||||
"no_background_image_found": "No background image found.",
|
||||
"no_code": "No code",
|
||||
"no_files_uploaded": "No files were uploaded",
|
||||
"no_quotas_found": "No quotas found",
|
||||
"no_result_found": "No result found",
|
||||
"no_results": "No results",
|
||||
"no_surveys_found": "No surveys found.",
|
||||
@@ -312,6 +319,7 @@
|
||||
"product_manager": "Product Manager",
|
||||
"profile": "Profile",
|
||||
"profile_id": "Profile ID",
|
||||
"progress": "Progress",
|
||||
"project_configuration": "Project Configuration",
|
||||
"project_creation_description": "Organize surveys in projects for better access control.",
|
||||
"project_id": "Project ID",
|
||||
@@ -323,6 +331,9 @@
|
||||
"question": "Question",
|
||||
"question_id": "Question ID",
|
||||
"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",
|
||||
"recipients": "Recipients",
|
||||
"remove": "Remove",
|
||||
@@ -370,6 +381,7 @@
|
||||
"start_free_trial": "Start Free Trial",
|
||||
"status": "Status",
|
||||
"step_by_step_manual": "Step by step manual",
|
||||
"storage_not_configured": "File storage not set up, uploads will likely fail",
|
||||
"styling": "Styling",
|
||||
"submit": "Submit",
|
||||
"summary": "Summary",
|
||||
@@ -579,6 +591,7 @@
|
||||
"contacts_table_refresh": "Refresh contacts",
|
||||
"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_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",
|
||||
"not_provided": "Not provided",
|
||||
"search_contact": "Search contact",
|
||||
@@ -739,7 +752,6 @@
|
||||
},
|
||||
"project": {
|
||||
"api_keys": {
|
||||
"access_control": "Access Control",
|
||||
"add_api_key": "Add API Key",
|
||||
"api_key": "API Key",
|
||||
"api_key_copied_to_clipboard": "API key copied to clipboard",
|
||||
@@ -1280,7 +1292,7 @@
|
||||
"columns": "Columns",
|
||||
"company": "Company",
|
||||
"company_logo": "Company logo",
|
||||
"completed_responses": "partial or completed responses.",
|
||||
"completed_responses": "completed responses.",
|
||||
"concat": "Concat +",
|
||||
"conditional_logic": "Conditional Logic",
|
||||
"confirm_default_language": "Confirm default language",
|
||||
@@ -1289,8 +1301,8 @@
|
||||
"contains": "Contains",
|
||||
"continue_to_settings": "Continue to Settings",
|
||||
"control_which_file_types_can_be_uploaded": "Control which file types can be uploaded.",
|
||||
"convert_to_multiple_choice": "Convert to Multiple Choice",
|
||||
"convert_to_single_choice": "Convert to Single Choice",
|
||||
"convert_to_multiple_choice": "Convert to Multi-select",
|
||||
"convert_to_single_choice": "Convert to Single-select",
|
||||
"country": "Country",
|
||||
"create_group": "Create group",
|
||||
"create_your_own_survey": "Create your own survey",
|
||||
@@ -1320,6 +1332,7 @@
|
||||
"end_screen_card": "End screen card",
|
||||
"ending_card": "Ending card",
|
||||
"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",
|
||||
"equals": "Equals",
|
||||
"equals_one_of": "Equals one of",
|
||||
@@ -1330,6 +1343,7 @@
|
||||
"fallback_for": "Fallback for ",
|
||||
"fallback_missing": "Fallback missing",
|
||||
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} is used in logic of question {questionIndex}. Please remove it from logic first.",
|
||||
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "Hidden field \"{fieldId}\" is being used in \"{quotaName}\" quota",
|
||||
"field_name_eg_score_price": "Field name e.g, score, price",
|
||||
"first_name": "First Name",
|
||||
"five_points_recommended": "5 points (recommended)",
|
||||
@@ -1361,8 +1375,9 @@
|
||||
"follow_ups_modal_action_subject_placeholder": "Subject of the email",
|
||||
"follow_ups_modal_action_to_description": "Email address to send the email to",
|
||||
"follow_ups_modal_action_to_label": "To",
|
||||
"follow_ups_modal_action_to_warning": "No email field detected in the survey",
|
||||
"follow_ups_modal_action_to_warning": "No valid options found for sending emails, please add some open-text / contact-info questions or hidden fields",
|
||||
"follow_ups_modal_create_heading": "Create a new follow-up",
|
||||
"follow_ups_modal_created_successfull_toast": "Follow-up created and will be saved once you save the survey.",
|
||||
"follow_ups_modal_edit_heading": "Edit this follow-up",
|
||||
"follow_ups_modal_edit_no_id": "No survey follow up id provided, can't update the survey follow up",
|
||||
"follow_ups_modal_name_label": "Follow-up name",
|
||||
@@ -1372,8 +1387,9 @@
|
||||
"follow_ups_modal_trigger_label": "Trigger",
|
||||
"follow_ups_modal_trigger_type_ending": "Respondent sees a specific ending",
|
||||
"follow_ups_modal_trigger_type_ending_select": "Select endings: ",
|
||||
"follow_ups_modal_trigger_type_ending_warning": "No endings found in the survey!",
|
||||
"follow_ups_modal_trigger_type_ending_warning": "Please select at least one ending or change the trigger type",
|
||||
"follow_ups_modal_trigger_type_response": "Respondent completes survey",
|
||||
"follow_ups_modal_updated_successfull_toast": "Follow-up updated and will be saved once you save the survey.",
|
||||
"follow_ups_new": "New follow-up",
|
||||
"follow_ups_upgrade_button_text": "Upgrade to enable follow-ups",
|
||||
"form_styling": "Form styling",
|
||||
@@ -1474,6 +1490,38 @@
|
||||
"question_duplicated": "Question duplicated.",
|
||||
"question_id_updated": "Question ID updated",
|
||||
"question_used_in_logic": "This question is used in logic of question {questionIndex}.",
|
||||
"question_used_in_quota": "This question is being used in \"{quotaName}\" quota",
|
||||
"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_except_last": "Randomize all except last",
|
||||
"range": "Range",
|
||||
@@ -1567,6 +1615,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} 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_must_start_with_a_letter": "Variable name must start with a letter.",
|
||||
"verify_email_before_submission": "Verify email before submission",
|
||||
@@ -1601,11 +1650,14 @@
|
||||
"address_line_2": "Address Line 2",
|
||||
"an_error_occurred_deleting_the_tag": "An error occurred deleting the tag",
|
||||
"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",
|
||||
"company": "Company",
|
||||
"completed": "Completed ✅",
|
||||
"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_quotas": "The response is part of quotas for this survey. How do you want to handle the quotas?",
|
||||
"device": "Device",
|
||||
"device_info": "Device info",
|
||||
"email": "Email",
|
||||
@@ -1737,6 +1789,7 @@
|
||||
"configure_alerts": "Configure alerts",
|
||||
"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.",
|
||||
"current_count": "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 +1843,7 @@
|
||||
"last_month": "Last month",
|
||||
"last_quarter": "Last quarter",
|
||||
"last_year": "Last year",
|
||||
"limit": "Limit",
|
||||
"no_responses_found": "No responses found",
|
||||
"other_values_found": "Other values found",
|
||||
"overall": "Overall",
|
||||
@@ -1798,6 +1852,8 @@
|
||||
"qr_code_download_failed": "QR code download failed",
|
||||
"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.",
|
||||
"quotas_completed": "Quotas completed",
|
||||
"quotas_completed_tooltip": "The number of quotas completed by the respondents.",
|
||||
"reset_survey": "Reset survey",
|
||||
"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)",
|
||||
|
||||
@@ -169,11 +169,14 @@
|
||||
"connect_formbricks": "Connecter Formbricks",
|
||||
"connected": "Connecté",
|
||||
"contacts": "Contacts",
|
||||
"continue": "Continuer",
|
||||
"copied": "Copié",
|
||||
"copied_to_clipboard": "Copié dans le presse-papiers",
|
||||
"copy": "Copier",
|
||||
"copy_code": "Copier le code",
|
||||
"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_project": "Créer un projet",
|
||||
"create_segment": "Créer un segment",
|
||||
@@ -201,6 +204,7 @@
|
||||
"e_commerce": "E-commerce",
|
||||
"edit": "Modifier",
|
||||
"email": "Email",
|
||||
"ending_card": "Carte de fin",
|
||||
"enterprise_license": "Licence d'entreprise",
|
||||
"environment_not_found": "Environnement non trouvé",
|
||||
"environment_notice": "Vous êtes actuellement dans l'environnement {environment}.",
|
||||
@@ -258,7 +262,9 @@
|
||||
"membership_not_found": "Abonnement non trouvé",
|
||||
"metadata": "Métadonnées",
|
||||
"minimum": "Min",
|
||||
"mobile_overlay_text": "Formbricks n'est pas disponible pour les appareils avec des résolutions plus petites.",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks fonctionne mieux sur un écran plus grand. Pour gérer ou créer des sondages, passez à un autre appareil.",
|
||||
"mobile_overlay_surveys_look_good": "Ne t'inquiète pas – tes enquêtes sont superbes sur tous les appareils et tailles d'écran!",
|
||||
"mobile_overlay_title": "Oups, écran minuscule détecté!",
|
||||
"move_down": "Déplacer vers le bas",
|
||||
"move_up": "Déplacer vers le haut",
|
||||
"multiple_languages": "Plusieurs langues",
|
||||
@@ -269,6 +275,7 @@
|
||||
"no_background_image_found": "Aucune image de fond trouvée.",
|
||||
"no_code": "Pas de code",
|
||||
"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_results": "Aucun résultat",
|
||||
"no_surveys_found": "Aucun sondage trouvé.",
|
||||
@@ -312,6 +319,7 @@
|
||||
"product_manager": "Chef de produit",
|
||||
"profile": "Profil",
|
||||
"profile_id": "Identifiant de profil",
|
||||
"progress": "Progression",
|
||||
"project_configuration": "Configuration du projet",
|
||||
"project_creation_description": "Organisez les enquêtes en projets pour un meilleur contrôle d'accès.",
|
||||
"project_id": "ID de projet",
|
||||
@@ -323,6 +331,9 @@
|
||||
"question": "Question",
|
||||
"question_id": "ID de la question",
|
||||
"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",
|
||||
"recipients": "Destinataires",
|
||||
"remove": "Retirer",
|
||||
@@ -370,6 +381,7 @@
|
||||
"start_free_trial": "Commencer l'essai gratuit",
|
||||
"status": "Statut",
|
||||
"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",
|
||||
"submit": "Soumettre",
|
||||
"summary": "Résumé",
|
||||
@@ -579,6 +591,7 @@
|
||||
"contacts_table_refresh": "Rafraîchir les contacts",
|
||||
"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_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",
|
||||
"not_provided": "Non fourni",
|
||||
"search_contact": "Rechercher un contact",
|
||||
@@ -739,7 +752,6 @@
|
||||
},
|
||||
"project": {
|
||||
"api_keys": {
|
||||
"access_control": "Contrôle d'accès",
|
||||
"add_api_key": "Ajouter une clé API",
|
||||
"api_key": "Clé API",
|
||||
"api_key_copied_to_clipboard": "Clé API copiée dans le presse-papiers",
|
||||
@@ -1280,7 +1292,7 @@
|
||||
"columns": "Colonnes",
|
||||
"company": "Société",
|
||||
"company_logo": "Logo de l'entreprise",
|
||||
"completed_responses": "des réponses partielles ou complètes.",
|
||||
"completed_responses": "Réponses terminées",
|
||||
"concat": "Concat +",
|
||||
"conditional_logic": "Logique conditionnelle",
|
||||
"confirm_default_language": "Confirmer la langue par défaut",
|
||||
@@ -1320,6 +1332,7 @@
|
||||
"end_screen_card": "Carte de fin d'écran",
|
||||
"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_used_in_quota": "Cette fin est utilisée dans le quota \"{quotaName}\"",
|
||||
"ends_with": "Se termine par",
|
||||
"equals": "Égal",
|
||||
"equals_one_of": "Égal à l'un de",
|
||||
@@ -1330,6 +1343,7 @@
|
||||
"fallback_for": "Solution de repli pour ",
|
||||
"fallback_missing": "Fallback manquant",
|
||||
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} est utilisé dans la logique de la question {questionIndex}. Veuillez d'abord le supprimer de la logique.",
|
||||
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "Le champ masqué \"{fieldId}\" est utilisé dans le quota \"{quotaName}\"",
|
||||
"field_name_eg_score_price": "Nom du champ par exemple, score, prix",
|
||||
"first_name": "Prénom",
|
||||
"five_points_recommended": "5 points (recommandé)",
|
||||
@@ -1361,8 +1375,9 @@
|
||||
"follow_ups_modal_action_subject_placeholder": "Objet de l'email",
|
||||
"follow_ups_modal_action_to_description": "Adresse e-mail à laquelle envoyer l'e-mail",
|
||||
"follow_ups_modal_action_to_label": "à",
|
||||
"follow_ups_modal_action_to_warning": "Aucun champ d'email détecté dans l'enquête",
|
||||
"follow_ups_modal_action_to_warning": "Aucune option valable trouvée pour l'envoi d'emails, veuillez ajouter des questions à texte libre / info-contact ou des champs cachés",
|
||||
"follow_ups_modal_create_heading": "Créer un nouveau suivi",
|
||||
"follow_ups_modal_created_successfull_toast": "\"Suivi créé et sera enregistré une fois que vous sauvegarderez le sondage.\"",
|
||||
"follow_ups_modal_edit_heading": "Modifier ce suivi",
|
||||
"follow_ups_modal_edit_no_id": "Aucun identifiant de suivi d'enquête fourni, impossible de mettre à jour le suivi de l'enquête.",
|
||||
"follow_ups_modal_name_label": "Nom de suivi",
|
||||
@@ -1372,8 +1387,9 @@
|
||||
"follow_ups_modal_trigger_label": "Déclencheur",
|
||||
"follow_ups_modal_trigger_type_ending": "Le répondant voit une fin spécifique",
|
||||
"follow_ups_modal_trigger_type_ending_select": "Choisir des fins :",
|
||||
"follow_ups_modal_trigger_type_ending_warning": "Aucune fin trouvée dans l'enquête !",
|
||||
"follow_ups_modal_trigger_type_ending_warning": "Veuillez sélectionner au moins une fin ou changer le type de déclencheur.",
|
||||
"follow_ups_modal_trigger_type_response": "Le répondant complète l'enquête",
|
||||
"follow_ups_modal_updated_successfull_toast": "\"Suivi mis à jour et sera enregistré une fois que vous sauvegarderez le sondage.\"",
|
||||
"follow_ups_new": "Nouveau suivi",
|
||||
"follow_ups_upgrade_button_text": "Passez à la version supérieure pour activer les relances",
|
||||
"form_styling": "Style de formulaire",
|
||||
@@ -1474,6 +1490,38 @@
|
||||
"question_duplicated": "Question dupliquée.",
|
||||
"question_id_updated": "ID de la question mis à jour",
|
||||
"question_used_in_logic": "Cette question est utilisée dans la logique de la question '{'questionIndex'}'.",
|
||||
"question_used_in_quota": "Cette question est utilisée dans le quota \"{quotaName}\"",
|
||||
"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_except_last": "Randomiser tout sauf le dernier",
|
||||
"range": "Plage",
|
||||
@@ -1567,6 +1615,7 @@
|
||||
"url_not_supported": "URL non supportée",
|
||||
"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_quota_please_remove_it_from_quota_first": "La variable \"{variableName}\" est utilisée dans le quota \"{quotaName}\"",
|
||||
"variable_name_is_already_taken_please_choose_another": "Le nom de la variable est déjà pris, veuillez en choisir un autre.",
|
||||
"variable_name_must_start_with_a_letter": "Le nom de la variable doit commencer par une lettre.",
|
||||
"verify_email_before_submission": "Vérifiez l'email avant la soumission",
|
||||
@@ -1601,11 +1650,14 @@
|
||||
"address_line_2": "Ligne d'adresse 2",
|
||||
"an_error_occurred_deleting_the_tag": "Une erreur est survenue lors de la suppression de l'étiquette.",
|
||||
"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",
|
||||
"company": "Société",
|
||||
"completed": "Terminé ✅",
|
||||
"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_quotas": "La réponse fait partie des quotas pour ce sondage. Comment voulez-vous gérer les quotas ?",
|
||||
"device": "Dispositif",
|
||||
"device_info": "Informations sur l'appareil",
|
||||
"email": "Email",
|
||||
@@ -1737,6 +1789,7 @@
|
||||
"configure_alerts": "Configurer les alertes",
|
||||
"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.",
|
||||
"current_count": "Nombre actuel",
|
||||
"custom_range": "Plage personnalisée...",
|
||||
"delete_all_existing_responses_and_displays": "Supprimer toutes les réponses existantes et les affichages",
|
||||
"download_qr_code": "Télécharger code QR",
|
||||
@@ -1790,6 +1843,7 @@
|
||||
"last_month": "Le mois dernier",
|
||||
"last_quarter": "dernier trimestre",
|
||||
"last_year": "l'année dernière",
|
||||
"limit": "Limite",
|
||||
"no_responses_found": "Aucune réponse trouvée",
|
||||
"other_values_found": "D'autres valeurs trouvées",
|
||||
"overall": "Globalement",
|
||||
@@ -1798,6 +1852,8 @@
|
||||
"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_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_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)",
|
||||
|
||||
@@ -169,11 +169,14 @@
|
||||
"connect_formbricks": "Formbricksを接続",
|
||||
"connected": "接続済み",
|
||||
"contacts": "連絡先",
|
||||
"continue": "続行",
|
||||
"copied": "コピーしました",
|
||||
"copied_to_clipboard": "クリップボードにコピーしました",
|
||||
"copy": "コピー",
|
||||
"copy_code": "コードをコピー",
|
||||
"copy_link": "リンクをコピー",
|
||||
"count_contacts": "{count, plural, other {# 件の連絡先}}",
|
||||
"count_responses": "{count, plural, other {# 件の回答}}",
|
||||
"create_new_organization": "新しい組織を作成",
|
||||
"create_project": "プロジェクトを作成",
|
||||
"create_segment": "セグメントを作成",
|
||||
@@ -201,6 +204,7 @@
|
||||
"e_commerce": "Eコマース",
|
||||
"edit": "編集",
|
||||
"email": "メールアドレス",
|
||||
"ending_card": "終了カード",
|
||||
"enterprise_license": "エンタープライズライセンス",
|
||||
"environment_not_found": "環境が見つかりません",
|
||||
"environment_notice": "現在、{environment} 環境にいます。",
|
||||
@@ -258,7 +262,9 @@
|
||||
"membership_not_found": "メンバーシップが見つかりません",
|
||||
"metadata": "メタデータ",
|
||||
"minimum": "最小",
|
||||
"mobile_overlay_text": "Formbricksは、解像度の小さいデバイスでは利用できません。",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks は より 大きな 画面 で最適に 作動します。 フォーム を 管理または 構築する には、 別の デバイス に 切り替える 必要が あります。",
|
||||
"mobile_overlay_surveys_look_good": "ご安心ください - お使い の デバイス や 画面 サイズ に 関係なく、 フォーム は 素晴らしく 見えます!",
|
||||
"mobile_overlay_title": "おっと、 小さな 画面 が 検出されました!",
|
||||
"move_down": "下に移動",
|
||||
"move_up": "上に移動",
|
||||
"multiple_languages": "多言語",
|
||||
@@ -269,6 +275,7 @@
|
||||
"no_background_image_found": "背景画像が見つかりません。",
|
||||
"no_code": "ノーコード",
|
||||
"no_files_uploaded": "ファイルがアップロードされていません",
|
||||
"no_quotas_found": "クォータが見つかりません",
|
||||
"no_result_found": "結果が見つかりません",
|
||||
"no_results": "結果なし",
|
||||
"no_surveys_found": "フォームが見つかりません。",
|
||||
@@ -312,6 +319,7 @@
|
||||
"product_manager": "プロダクトマネージャー",
|
||||
"profile": "プロフィール",
|
||||
"profile_id": "プロフィールID",
|
||||
"progress": "進捗",
|
||||
"project_configuration": "プロジェクト設定",
|
||||
"project_creation_description": "より良いアクセス制御のために、フォームをプロジェクトで整理します。",
|
||||
"project_id": "プロジェクトID",
|
||||
@@ -323,6 +331,9 @@
|
||||
"question": "質問",
|
||||
"question_id": "質問ID",
|
||||
"questions": "質問",
|
||||
"quota": "クォータ",
|
||||
"quotas": "クォータ",
|
||||
"quotas_description": "特定の基準を満たす参加者からの回答数を制限する",
|
||||
"read_docs": "ドキュメントを読む",
|
||||
"recipients": "受信者",
|
||||
"remove": "削除",
|
||||
@@ -370,6 +381,7 @@
|
||||
"start_free_trial": "無料トライアルを開始",
|
||||
"status": "ステータス",
|
||||
"step_by_step_manual": "ステップバイステップマニュアル",
|
||||
"storage_not_configured": "ファイルストレージが設定されていないため、アップロードは失敗する可能性があります",
|
||||
"styling": "スタイル",
|
||||
"submit": "送信",
|
||||
"summary": "概要",
|
||||
@@ -579,6 +591,7 @@
|
||||
"contacts_table_refresh": "連絡先を更新",
|
||||
"contacts_table_refresh_success": "連絡先を正常に更新しました",
|
||||
"delete_contact_confirmation": "これにより、この連絡先に関連付けられているすべてのフォーム回答と連絡先属性が削除されます。この連絡先のデータに基づいたターゲティングとパーソナライゼーションはすべて失われます。",
|
||||
"delete_contact_confirmation_with_quotas": "{value, plural, one {これにより この連絡先に関連するすべてのアンケート応答と連絡先属性が削除されます。この連絡先のデータに基づくターゲティングとパーソナライゼーションが失われます。この連絡先がアンケートの割当量を考慮した回答を持っている場合、割当量カウントは減少しますが、割当量の制限は変更されません。} other {これにより これらの連絡先に関連するすべてのアンケート応答と連絡先属性が削除されます。これらの連絡先のデータに基づくターゲティングとパーソナライゼーションが失われます。これらの連絡先がアンケートの割当量を考慮した回答を持っている場合、割当量カウントは減少しますが、割当量の制限は変更されません。}}",
|
||||
"no_responses_found": "回答が見つかりません",
|
||||
"not_provided": "提供されていません",
|
||||
"search_contact": "連絡先を検索",
|
||||
@@ -739,7 +752,6 @@
|
||||
},
|
||||
"project": {
|
||||
"api_keys": {
|
||||
"access_control": "アクセス制御",
|
||||
"add_api_key": "APIキーを追加",
|
||||
"api_key": "APIキー",
|
||||
"api_key_copied_to_clipboard": "APIキーをクリップボードにコピーしました",
|
||||
@@ -1280,7 +1292,7 @@
|
||||
"columns": "列",
|
||||
"company": "会社",
|
||||
"company_logo": "会社のロゴ",
|
||||
"completed_responses": "部分的または完了した回答。",
|
||||
"completed_responses": "完了した回答",
|
||||
"concat": "連結 +",
|
||||
"conditional_logic": "条件付きロジック",
|
||||
"confirm_default_language": "デフォルト言語を確認",
|
||||
@@ -1320,6 +1332,7 @@
|
||||
"end_screen_card": "終了画面カード",
|
||||
"ending_card": "終了カード",
|
||||
"ending_card_used_in_logic": "この終了カードは質問 {questionIndex} のロジックで使用されています。",
|
||||
"ending_used_in_quota": "この 終了 は \"{quotaName}\" クォータ で使用されています",
|
||||
"ends_with": "で終わる",
|
||||
"equals": "と等しい",
|
||||
"equals_one_of": "のいずれかと等しい",
|
||||
@@ -1330,6 +1343,7 @@
|
||||
"fallback_for": "のフォールバック",
|
||||
"fallback_missing": "フォールバックがありません",
|
||||
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} は質問 {questionIndex} のロジックで使用されています。まず、ロジックから削除してください。",
|
||||
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "隠しフィールド \"{fieldId}\" は \"{quotaName}\" クォータ で使用されています",
|
||||
"field_name_eg_score_price": "フィールド名、例:score、price",
|
||||
"first_name": "名",
|
||||
"five_points_recommended": "5点(推奨)",
|
||||
@@ -1361,8 +1375,9 @@
|
||||
"follow_ups_modal_action_subject_placeholder": "メールの件名",
|
||||
"follow_ups_modal_action_to_description": "メールを送信するメールアドレス",
|
||||
"follow_ups_modal_action_to_label": "宛先",
|
||||
"follow_ups_modal_action_to_warning": "フォームでメールアドレスのフィールドが検出されていません",
|
||||
"follow_ups_modal_action_to_warning": "メールを送信するための有効な オプション が見つかりません 、いくつかの オープン テキスト / 連絡先 情報の質問 または 非表示 フィールドを追加してください",
|
||||
"follow_ups_modal_create_heading": "新しいフォローアップを作成",
|
||||
"follow_ups_modal_created_successfull_toast": "フォローアップ が 作成され、 アンケートを 保存すると保存されます。",
|
||||
"follow_ups_modal_edit_heading": "このフォローアップを編集",
|
||||
"follow_ups_modal_edit_no_id": "フォームのフォローアップIDが提供されていません。フォームのフォローアップを更新できません",
|
||||
"follow_ups_modal_name_label": "フォローアップ名",
|
||||
@@ -1372,8 +1387,9 @@
|
||||
"follow_ups_modal_trigger_label": "トリガー",
|
||||
"follow_ups_modal_trigger_type_ending": "回答者が特定の終了画面を見たとき",
|
||||
"follow_ups_modal_trigger_type_ending_select": "終了を選択:",
|
||||
"follow_ups_modal_trigger_type_ending_warning": "フォームに終了画面が見つかりません!",
|
||||
"follow_ups_modal_trigger_type_ending_warning": "少なくとも1つの終了を選択するか、 トリガー タイプを変更してください",
|
||||
"follow_ups_modal_trigger_type_response": "回答者がフォームを完了したとき",
|
||||
"follow_ups_modal_updated_successfull_toast": "フォローアップ が 更新され、 アンケートを 保存すると保存されます。",
|
||||
"follow_ups_new": "新しいフォローアップ",
|
||||
"follow_ups_upgrade_button_text": "フォローアップを有効にするためにアップグレード",
|
||||
"form_styling": "フォームのスタイル",
|
||||
@@ -1474,6 +1490,38 @@
|
||||
"question_duplicated": "質問を複製しました。",
|
||||
"question_id_updated": "質問IDを更新しました",
|
||||
"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_except_last": "最後を除くすべてをランダム化",
|
||||
"range": "範囲",
|
||||
@@ -1567,6 +1615,7 @@
|
||||
"url_not_supported": "URLはサポートされていません",
|
||||
"use_with_caution": "注意して使用",
|
||||
"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_must_start_with_a_letter": "変数名はアルファベットで始まらなければなりません。",
|
||||
"verify_email_before_submission": "送信前にメールアドレスを認証",
|
||||
@@ -1601,11 +1650,14 @@
|
||||
"address_line_2": "住所2",
|
||||
"an_error_occurred_deleting_the_tag": "タグの削除中にエラーが発生しました",
|
||||
"browser": "ブラウザ",
|
||||
"bulk_delete_response_quotas": "この回答は、このアンケートの割り当ての一部です。 割り当てをどのように処理しますか?",
|
||||
"city": "市区町村",
|
||||
"company": "会社",
|
||||
"completed": "完了 ✅",
|
||||
"country": "国",
|
||||
"decrement_quotas": "すべて の 制限 を 減少 し、 この 回答 を 含む しきい値",
|
||||
"delete_response_confirmation": "これにより、すべての回答、タグ、添付されたドキュメント、および回答メタデータを含むフォームの回答が削除されます。",
|
||||
"delete_response_quotas": "この回答は、このアンケートの割り当ての一部です。 割り当てをどのように処理しますか?",
|
||||
"device": "デバイス",
|
||||
"device_info": "デバイス情報",
|
||||
"email": "メールアドレス",
|
||||
@@ -1737,6 +1789,7 @@
|
||||
"configure_alerts": "アラートを設定",
|
||||
"congrats": "おめでとうございます!フォームが公開されました。",
|
||||
"connect_your_website_or_app_with_formbricks_to_get_started": "始めるには、ウェブサイトやアプリをFormbricksに接続してください。",
|
||||
"current_count": "現在の件数",
|
||||
"custom_range": "カスタム範囲...",
|
||||
"delete_all_existing_responses_and_displays": "既存のすべての回答と表示を削除",
|
||||
"download_qr_code": "QRコードをダウンロード",
|
||||
@@ -1790,6 +1843,7 @@
|
||||
"last_month": "先月",
|
||||
"last_quarter": "前四半期",
|
||||
"last_year": "昨年",
|
||||
"limit": "制限",
|
||||
"no_responses_found": "回答が見つかりません",
|
||||
"other_values_found": "他の値が見つかりました",
|
||||
"overall": "全体",
|
||||
@@ -1798,6 +1852,8 @@
|
||||
"qr_code_download_failed": "QRコードのダウンロードに失敗しました",
|
||||
"qr_code_download_with_start_soon": "QRコードのダウンロードがまもなく開始されます",
|
||||
"qr_code_generation_failed": "フォームのQRコードの読み込み中に問題が発生しました。もう一度お試しください。",
|
||||
"quotas_completed": "クォータ完了",
|
||||
"quotas_completed_tooltip": "回答者 によって 完了 した 定員 の 数。",
|
||||
"reset_survey": "フォームをリセット",
|
||||
"reset_survey_warning": "フォームをリセットすると、このフォームに関連付けられているすべての回答と表示が削除されます。この操作は元に戻せません。",
|
||||
"selected_responses_csv": "選択した回答 (CSV)",
|
||||
|
||||
@@ -169,11 +169,14 @@
|
||||
"connect_formbricks": "Conectar Formbricks",
|
||||
"connected": "conectado",
|
||||
"contacts": "Contatos",
|
||||
"continue": "Continuar",
|
||||
"copied": "Copiado",
|
||||
"copied_to_clipboard": "Copiado para a área de transferência",
|
||||
"copy": "Copiar",
|
||||
"copy_code": "Copiar código",
|
||||
"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_project": "Criar projeto",
|
||||
"create_segment": "Criar segmento",
|
||||
@@ -201,6 +204,7 @@
|
||||
"e_commerce": "comércio eletrônico",
|
||||
"edit": "Editar",
|
||||
"email": "Email",
|
||||
"ending_card": "Cartão de encerramento",
|
||||
"enterprise_license": "Licença Empresarial",
|
||||
"environment_not_found": "Ambiente não encontrado",
|
||||
"environment_notice": "Você está atualmente no ambiente {environment}.",
|
||||
@@ -258,7 +262,9 @@
|
||||
"membership_not_found": "Assinatura não encontrada",
|
||||
"metadata": "metadados",
|
||||
"minimum": "Mínimo",
|
||||
"mobile_overlay_text": "O Formbricks não está disponível para dispositivos com resoluções menores.",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks funciona melhor em uma tela maior. Para gerenciar ou criar pesquisas, mude para outro dispositivo.",
|
||||
"mobile_overlay_surveys_look_good": "Não se preocupe – suas pesquisas ficam ótimas em qualquer dispositivo e tamanho de tela!",
|
||||
"mobile_overlay_title": "Eita, tela pequena detectada!",
|
||||
"move_down": "Descer",
|
||||
"move_up": "Subir",
|
||||
"multiple_languages": "Vários idiomas",
|
||||
@@ -269,6 +275,7 @@
|
||||
"no_background_image_found": "Imagem de fundo não encontrada.",
|
||||
"no_code": "Sem código",
|
||||
"no_files_uploaded": "Nenhum arquivo foi enviado",
|
||||
"no_quotas_found": "Nenhuma cota encontrada",
|
||||
"no_result_found": "Nenhum resultado encontrado",
|
||||
"no_results": "Nenhum resultado",
|
||||
"no_surveys_found": "Não foram encontradas pesquisas.",
|
||||
@@ -312,6 +319,7 @@
|
||||
"product_manager": "Gerente de Produto",
|
||||
"profile": "Perfil",
|
||||
"profile_id": "ID de Perfil",
|
||||
"progress": "Progresso",
|
||||
"project_configuration": "Configuração do Projeto",
|
||||
"project_creation_description": "Organize pesquisas em projetos para melhor controle de acesso.",
|
||||
"project_id": "ID do Projeto",
|
||||
@@ -323,6 +331,9 @@
|
||||
"question": "Pergunta",
|
||||
"question_id": "ID da Pergunta",
|
||||
"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",
|
||||
"recipients": "Destinatários",
|
||||
"remove": "remover",
|
||||
@@ -370,6 +381,7 @@
|
||||
"start_free_trial": "Iniciar Teste Grátis",
|
||||
"status": "status",
|
||||
"step_by_step_manual": "Manual passo a passo",
|
||||
"storage_not_configured": "Armazenamento de arquivos não configurado, uploads provavelmente falharão",
|
||||
"styling": "Estilização",
|
||||
"submit": "Enviar",
|
||||
"summary": "Resumo",
|
||||
@@ -579,6 +591,7 @@
|
||||
"contacts_table_refresh": "Atualizar contatos",
|
||||
"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_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",
|
||||
"not_provided": "Não fornecido",
|
||||
"search_contact": "Buscar contato",
|
||||
@@ -739,7 +752,6 @@
|
||||
},
|
||||
"project": {
|
||||
"api_keys": {
|
||||
"access_control": "Controle de Acesso",
|
||||
"add_api_key": "Adicionar Chave API",
|
||||
"api_key": "Chave de API",
|
||||
"api_key_copied_to_clipboard": "Chave da API copiada para a área de transferência",
|
||||
@@ -1280,7 +1292,7 @@
|
||||
"columns": "colunas",
|
||||
"company": "empresa",
|
||||
"company_logo": "Logo da empresa",
|
||||
"completed_responses": "respostas parciais ou completas.",
|
||||
"completed_responses": "Respostas concluídas.",
|
||||
"concat": "Concatenar +",
|
||||
"conditional_logic": "Lógica Condicional",
|
||||
"confirm_default_language": "Confirmar idioma padrão",
|
||||
@@ -1320,6 +1332,7 @@
|
||||
"end_screen_card": "cartão de tela final",
|
||||
"ending_card": "Cartão de encerramento",
|
||||
"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",
|
||||
"equals": "Igual",
|
||||
"equals_one_of": "É igual a um de",
|
||||
@@ -1330,6 +1343,7 @@
|
||||
"fallback_for": "Alternativa para",
|
||||
"fallback_missing": "Faltando alternativa",
|
||||
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} é usado na lógica da pergunta {questionIndex}. Por favor, remova-o da lógica primeiro.",
|
||||
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "Campo oculto \"{fieldId}\" está sendo usado na cota \"{quotaName}\"",
|
||||
"field_name_eg_score_price": "Nome do campo, por exemplo, pontuação, preço",
|
||||
"first_name": "Primeiro Nome",
|
||||
"five_points_recommended": "5 pontos (recomendado)",
|
||||
@@ -1361,8 +1375,9 @@
|
||||
"follow_ups_modal_action_subject_placeholder": "Assunto do e-mail",
|
||||
"follow_ups_modal_action_to_description": "Endereço de e-mail para enviar o e-mail para",
|
||||
"follow_ups_modal_action_to_label": "Para",
|
||||
"follow_ups_modal_action_to_warning": "Nenhum campo de e-mail detectado na pesquisa",
|
||||
"follow_ups_modal_action_to_warning": "Nenhuma opção válida encontrada para envio de emails, por favor, adicione algumas perguntas de texto livre / informações de contato ou campos ocultos",
|
||||
"follow_ups_modal_create_heading": "Criar um novo acompanhamento",
|
||||
"follow_ups_modal_created_successfull_toast": "Acompanhamento criado e será salvo assim que você salvar a pesquisa.",
|
||||
"follow_ups_modal_edit_heading": "Editar este acompanhamento",
|
||||
"follow_ups_modal_edit_no_id": "Nenhum ID de acompanhamento da pesquisa fornecido, não é possível atualizar o acompanhamento da pesquisa",
|
||||
"follow_ups_modal_name_label": "Nome do acompanhamento",
|
||||
@@ -1372,8 +1387,9 @@
|
||||
"follow_ups_modal_trigger_label": "Gatilho",
|
||||
"follow_ups_modal_trigger_type_ending": "Respondente vê um final específico",
|
||||
"follow_ups_modal_trigger_type_ending_select": "Selecione os finais: ",
|
||||
"follow_ups_modal_trigger_type_ending_warning": "Nenhum final encontrado na pesquisa!",
|
||||
"follow_ups_modal_trigger_type_ending_warning": "Por favor, selecione pelo menos um encerramento ou altere o tipo de gatilho",
|
||||
"follow_ups_modal_trigger_type_response": "Respondente completa a pesquisa",
|
||||
"follow_ups_modal_updated_successfull_toast": "Acompanhamento atualizado e será salvo assim que você salvar a pesquisa.",
|
||||
"follow_ups_new": "Novo acompanhamento",
|
||||
"follow_ups_upgrade_button_text": "Atualize para habilitar os Acompanhamentos",
|
||||
"form_styling": "Estilização de Formulários",
|
||||
@@ -1474,6 +1490,38 @@
|
||||
"question_duplicated": "Pergunta duplicada.",
|
||||
"question_id_updated": "ID da pergunta atualizado",
|
||||
"question_used_in_logic": "Essa pergunta é usada na lógica da pergunta {questionIndex}.",
|
||||
"question_used_in_quota": "Esta questão está sendo usada na cota \"{quotaName}\"",
|
||||
"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_except_last": "Randomizar tudo, exceto o último",
|
||||
"range": "alcance",
|
||||
@@ -1567,6 +1615,7 @@
|
||||
"url_not_supported": "URL não suportada",
|
||||
"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_quota_please_remove_it_from_quota_first": "Variável \"{variableName}\" está sendo usada na cota \"{quotaName}\"",
|
||||
"variable_name_is_already_taken_please_choose_another": "O nome da variável já está em uso, por favor escolha outro.",
|
||||
"variable_name_must_start_with_a_letter": "O nome da variável deve começar com uma letra.",
|
||||
"verify_email_before_submission": "Verifique o e-mail antes de enviar",
|
||||
@@ -1601,11 +1650,14 @@
|
||||
"address_line_2": "Complemento",
|
||||
"an_error_occurred_deleting_the_tag": "Ocorreu um erro ao deletar a tag",
|
||||
"browser": "navegador",
|
||||
"bulk_delete_response_quotas": "As respostas fazem parte das cotas desta pesquisa. Como você quer gerenciar as cotas?",
|
||||
"city": "Cidade",
|
||||
"company": "empresa",
|
||||
"completed": "Concluído ✅",
|
||||
"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_quotas": "A resposta faz parte das cotas desta pesquisa. Como você quer gerenciar as cotas?",
|
||||
"device": "dispositivo",
|
||||
"device_info": "Informações do dispositivo",
|
||||
"email": "Email",
|
||||
@@ -1737,6 +1789,7 @@
|
||||
"configure_alerts": "Configurar alertas",
|
||||
"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.",
|
||||
"current_count": "Contagem Atual",
|
||||
"custom_range": "Intervalo personalizado...",
|
||||
"delete_all_existing_responses_and_displays": "Excluir todas as respostas e exibições existentes",
|
||||
"download_qr_code": "baixar código QR",
|
||||
@@ -1790,6 +1843,7 @@
|
||||
"last_month": "Último mês",
|
||||
"last_quarter": "Último trimestre",
|
||||
"last_year": "Último ano",
|
||||
"limit": "Limite",
|
||||
"no_responses_found": "Nenhuma resposta encontrada",
|
||||
"other_values_found": "Outros valores encontrados",
|
||||
"overall": "No geral",
|
||||
@@ -1798,6 +1852,8 @@
|
||||
"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_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_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)",
|
||||
|
||||
@@ -169,11 +169,14 @@
|
||||
"connect_formbricks": "Ligar Formbricks",
|
||||
"connected": "Conectado",
|
||||
"contacts": "Contactos",
|
||||
"continue": "Continuar",
|
||||
"copied": "Copiado",
|
||||
"copied_to_clipboard": "Copiado para a área de transferência",
|
||||
"copy": "Copiar",
|
||||
"copy_code": "Copiar código",
|
||||
"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_project": "Criar projeto",
|
||||
"create_segment": "Criar segmento",
|
||||
@@ -201,6 +204,7 @@
|
||||
"e_commerce": "Comércio Eletrónico",
|
||||
"edit": "Editar",
|
||||
"email": "Email",
|
||||
"ending_card": "Cartão de encerramento",
|
||||
"enterprise_license": "Licença Enterprise",
|
||||
"environment_not_found": "Ambiente não encontrado",
|
||||
"environment_notice": "Está atualmente no ambiente {environment}.",
|
||||
@@ -258,7 +262,9 @@
|
||||
"membership_not_found": "Associação não encontrada",
|
||||
"metadata": "Metadados",
|
||||
"minimum": "Mínimo",
|
||||
"mobile_overlay_text": "O Formbricks não está disponível para dispositivos com resoluções menores.",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks funciona melhor num ecrã maior. Para gerir ou criar inquéritos, mude de dispositivo.",
|
||||
"mobile_overlay_surveys_look_good": "Não se preocupe – os seus inquéritos têm uma ótima aparência em todos os dispositivos e tamanhos de ecrã!",
|
||||
"mobile_overlay_title": "Oops, ecrã pequeno detectado!",
|
||||
"move_down": "Mover para baixo",
|
||||
"move_up": "Mover para cima",
|
||||
"multiple_languages": "Várias línguas",
|
||||
@@ -269,6 +275,7 @@
|
||||
"no_background_image_found": "Nenhuma imagem de fundo encontrada.",
|
||||
"no_code": "Sem código",
|
||||
"no_files_uploaded": "Nenhum ficheiro foi carregado",
|
||||
"no_quotas_found": "Nenhum quota encontrado",
|
||||
"no_result_found": "Nenhum resultado encontrado",
|
||||
"no_results": "Nenhum resultado",
|
||||
"no_surveys_found": "Nenhum inquérito encontrado.",
|
||||
@@ -312,6 +319,7 @@
|
||||
"product_manager": "Gestor de Produto",
|
||||
"profile": "Perfil",
|
||||
"profile_id": "ID do Perfil",
|
||||
"progress": "Progresso",
|
||||
"project_configuration": "Configuração do Projeto",
|
||||
"project_creation_description": "Organize questionários em projetos para um melhor controlo de acesso.",
|
||||
"project_id": "ID do Projeto",
|
||||
@@ -323,6 +331,9 @@
|
||||
"question": "Pergunta",
|
||||
"question_id": "ID da pergunta",
|
||||
"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",
|
||||
"recipients": "Destinatários",
|
||||
"remove": "Remover",
|
||||
@@ -370,6 +381,7 @@
|
||||
"start_free_trial": "Iniciar Teste Grátis",
|
||||
"status": "Estado",
|
||||
"step_by_step_manual": "Manual passo a passo",
|
||||
"storage_not_configured": "Armazenamento de ficheiros não configurado, uploads provavelmente falharão",
|
||||
"styling": "Estilo",
|
||||
"submit": "Submeter",
|
||||
"summary": "Resumo",
|
||||
@@ -579,6 +591,7 @@
|
||||
"contacts_table_refresh": "Atualizar contactos",
|
||||
"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_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",
|
||||
"not_provided": "Não fornecido",
|
||||
"search_contact": "Procurar contacto",
|
||||
@@ -739,7 +752,6 @@
|
||||
},
|
||||
"project": {
|
||||
"api_keys": {
|
||||
"access_control": "Controlo de Acesso",
|
||||
"add_api_key": "Adicionar Chave API",
|
||||
"api_key": "Chave API",
|
||||
"api_key_copied_to_clipboard": "Chave API copiada para a área de transferência",
|
||||
@@ -1280,7 +1292,7 @@
|
||||
"columns": "Colunas",
|
||||
"company": "Empresa",
|
||||
"company_logo": "Logotipo da empresa",
|
||||
"completed_responses": "respostas parciais ou completas",
|
||||
"completed_responses": "Respostas concluídas",
|
||||
"concat": "Concatenar +",
|
||||
"conditional_logic": "Lógica Condicional",
|
||||
"confirm_default_language": "Confirmar idioma padrão",
|
||||
@@ -1289,8 +1301,8 @@
|
||||
"contains": "Contém",
|
||||
"continue_to_settings": "Continuar para Definições",
|
||||
"control_which_file_types_can_be_uploaded": "Controlar quais tipos de ficheiros podem ser carregados.",
|
||||
"convert_to_multiple_choice": "Converter para Escolha Múltipla",
|
||||
"convert_to_single_choice": "Converter para Escolha Única",
|
||||
"convert_to_multiple_choice": "Converter para Seleção Múltipla",
|
||||
"convert_to_single_choice": "Converter para Seleção Única",
|
||||
"country": "País",
|
||||
"create_group": "Criar grupo",
|
||||
"create_your_own_survey": "Crie o seu próprio inquérito",
|
||||
@@ -1320,6 +1332,7 @@
|
||||
"end_screen_card": "Cartão de ecrã final",
|
||||
"ending_card": "Cartão de encerramento",
|
||||
"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",
|
||||
"equals": "Igual",
|
||||
"equals_one_of": "Igual a um de",
|
||||
@@ -1330,6 +1343,7 @@
|
||||
"fallback_for": "Alternativa para ",
|
||||
"fallback_missing": "Substituição em falta",
|
||||
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} é usado na lógica da pergunta {questionIndex}. Por favor, remova-o da lógica primeiro.",
|
||||
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "Campo oculto \"{fieldId}\" está a ser usado na quota \"{quotaName}\"",
|
||||
"field_name_eg_score_price": "Nome do campo, por exemplo, pontuação, preço",
|
||||
"first_name": "Primeiro Nome",
|
||||
"five_points_recommended": "5 pontos (recomendado)",
|
||||
@@ -1361,8 +1375,9 @@
|
||||
"follow_ups_modal_action_subject_placeholder": "Assunto do email",
|
||||
"follow_ups_modal_action_to_description": "Endereço de email para enviar o email",
|
||||
"follow_ups_modal_action_to_label": "Para",
|
||||
"follow_ups_modal_action_to_warning": "Nenhum campo de email detetado no inquérito",
|
||||
"follow_ups_modal_action_to_warning": "Não foram encontradas opções válidas para envio de emails, por favor adicione algumas perguntas de texto livre / informações de contato ou campos escondidos",
|
||||
"follow_ups_modal_create_heading": "Criar um novo acompanhamento",
|
||||
"follow_ups_modal_created_successfull_toast": "Seguimento criado e será guardado assim que guardar o questionário.",
|
||||
"follow_ups_modal_edit_heading": "Editar este acompanhamento",
|
||||
"follow_ups_modal_edit_no_id": "Nenhum ID de acompanhamento do inquérito fornecido, não é possível atualizar o acompanhamento do inquérito",
|
||||
"follow_ups_modal_name_label": "Nome do acompanhamento",
|
||||
@@ -1372,8 +1387,9 @@
|
||||
"follow_ups_modal_trigger_label": "Desencadeador",
|
||||
"follow_ups_modal_trigger_type_ending": "O respondente vê um final específico",
|
||||
"follow_ups_modal_trigger_type_ending_select": "Selecionar finais: ",
|
||||
"follow_ups_modal_trigger_type_ending_warning": "Não foram encontrados finais no inquérito!",
|
||||
"follow_ups_modal_trigger_type_ending_warning": "Por favor, selecione pelo menos um final ou mude o tipo de gatilho",
|
||||
"follow_ups_modal_trigger_type_response": "Respondente conclui inquérito",
|
||||
"follow_ups_modal_updated_successfull_toast": "Seguimento atualizado e será guardado assim que guardar o questionário.",
|
||||
"follow_ups_new": "Novo acompanhamento",
|
||||
"follow_ups_upgrade_button_text": "Atualize para ativar os acompanhamentos",
|
||||
"form_styling": "Estilo do formulário",
|
||||
@@ -1474,6 +1490,38 @@
|
||||
"question_duplicated": "Pergunta duplicada.",
|
||||
"question_id_updated": "ID da pergunta atualizado",
|
||||
"question_used_in_logic": "Esta pergunta é usada na lógica da pergunta {questionIndex}.",
|
||||
"question_used_in_quota": "Esta pergunta está a ser usada na quota \"{quotaName}\"",
|
||||
"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_except_last": "Aleatorizar todos exceto o último",
|
||||
"range": "Intervalo",
|
||||
@@ -1567,6 +1615,7 @@
|
||||
"url_not_supported": "URL não suportado",
|
||||
"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_quota_please_remove_it_from_quota_first": "Variável \"{variableName}\" está a ser utilizada na quota \"{quotaName}\"",
|
||||
"variable_name_is_already_taken_please_choose_another": "O nome da variável já está em uso, por favor escolha outro.",
|
||||
"variable_name_must_start_with_a_letter": "O nome da variável deve começar com uma letra.",
|
||||
"verify_email_before_submission": "Verificar email antes da submissão",
|
||||
@@ -1601,11 +1650,14 @@
|
||||
"address_line_2": "Endereço Linha 2",
|
||||
"an_error_occurred_deleting_the_tag": "Ocorreu um erro ao eliminar a etiqueta",
|
||||
"browser": "Navegador",
|
||||
"bulk_delete_response_quotas": "As respostas são parte das quotas deste inquérito. Como deseja gerir as quotas?",
|
||||
"city": "Cidade",
|
||||
"company": "Empresa",
|
||||
"completed": "Concluído ✅",
|
||||
"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_quotas": "A resposta faz parte das quotas deste inquérito. Como deseja gerir as quotas?",
|
||||
"device": "Dispositivo",
|
||||
"device_info": "Informações do dispositivo",
|
||||
"email": "Email",
|
||||
@@ -1737,6 +1789,7 @@
|
||||
"configure_alerts": "Configurar alertas",
|
||||
"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.",
|
||||
"current_count": "Contagem atual",
|
||||
"custom_range": "Intervalo personalizado...",
|
||||
"delete_all_existing_responses_and_displays": "Excluir todas as respostas existentes e exibições",
|
||||
"download_qr_code": "Transferir código QR",
|
||||
@@ -1790,6 +1843,7 @@
|
||||
"last_month": "Último mês",
|
||||
"last_quarter": "Último trimestre",
|
||||
"last_year": "Ano passado",
|
||||
"limit": "Limite",
|
||||
"no_responses_found": "Nenhuma resposta encontrada",
|
||||
"other_values_found": "Outros valores encontrados",
|
||||
"overall": "Geral",
|
||||
@@ -1798,6 +1852,8 @@
|
||||
"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_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_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)",
|
||||
|
||||
@@ -169,11 +169,14 @@
|
||||
"connect_formbricks": "Conectează Formbricks",
|
||||
"connected": "Conectat",
|
||||
"contacts": "Contacte",
|
||||
"continue": "Continuă",
|
||||
"copied": "Copiat",
|
||||
"copied_to_clipboard": "Copiat în clipboard",
|
||||
"copy": "Copiază",
|
||||
"copy_code": "Copiază codul",
|
||||
"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_project": "Creează proiect",
|
||||
"create_segment": "Creați segment",
|
||||
@@ -201,6 +204,7 @@
|
||||
"e_commerce": "Comerț electronic",
|
||||
"edit": "Editare",
|
||||
"email": "Email",
|
||||
"ending_card": "Cardul de finalizare",
|
||||
"enterprise_license": "Licență Întreprindere",
|
||||
"environment_not_found": "Mediul nu a fost găsit",
|
||||
"environment_notice": "Te afli în prezent în mediul {environment}",
|
||||
@@ -258,7 +262,9 @@
|
||||
"membership_not_found": "Apartenența nu a fost găsită",
|
||||
"metadata": "Metadate",
|
||||
"minimum": "Minim",
|
||||
"mobile_overlay_text": "Formbricks nu este disponibil pentru dispozitive cu rezoluții mai mici.",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks funcționează cel mai bine pe un ecran mai mare. Pentru a gestiona sau crea chestionare, treceți la un alt dispozitiv.",
|
||||
"mobile_overlay_surveys_look_good": "Nu vă faceți griji – chestionarele dumneavoastră arată grozav pe orice dispozitiv și dimensiune a ecranului!",
|
||||
"mobile_overlay_title": "Ups, ecran mic detectat!",
|
||||
"move_down": "Mută în jos",
|
||||
"move_up": "Mută sus",
|
||||
"multiple_languages": "Mai multe limbi",
|
||||
@@ -269,6 +275,7 @@
|
||||
"no_background_image_found": "Nu a fost găsită nicio imagine de fundal.",
|
||||
"no_code": "Fără Cod",
|
||||
"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_results": "Nicio rezultat",
|
||||
"no_surveys_found": "Nu au fost găsite sondaje.",
|
||||
@@ -312,6 +319,7 @@
|
||||
"product_manager": "Manager de Produs",
|
||||
"profile": "Profil",
|
||||
"profile_id": "ID Profil",
|
||||
"progress": "Progres",
|
||||
"project_configuration": "Configurare proiect",
|
||||
"project_creation_description": "Organizați sondajele în proiecte pentru un control mai bun al accesului.",
|
||||
"project_id": "ID proiect",
|
||||
@@ -323,6 +331,9 @@
|
||||
"question": "Întrebare",
|
||||
"question_id": "ID întrebare",
|
||||
"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",
|
||||
"recipients": "Destinatari",
|
||||
"remove": "Șterge",
|
||||
@@ -370,6 +381,7 @@
|
||||
"start_free_trial": "Începe perioada de testare gratuită",
|
||||
"status": "Stare",
|
||||
"step_by_step_manual": "Manual pas cu pas",
|
||||
"storage_not_configured": "Stocarea fișierelor neconfigurată, upload-urile vor eșua probabil",
|
||||
"styling": "Stilizare",
|
||||
"submit": "Trimite",
|
||||
"summary": "Sumar",
|
||||
@@ -579,6 +591,7 @@
|
||||
"contacts_table_refresh": "Reîmprospătare contacte",
|
||||
"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_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",
|
||||
"not_provided": "Nu a fost furnizat",
|
||||
"search_contact": "Căutați contact",
|
||||
@@ -739,7 +752,6 @@
|
||||
},
|
||||
"project": {
|
||||
"api_keys": {
|
||||
"access_control": "Control acces",
|
||||
"add_api_key": "Adaugă Cheie API",
|
||||
"api_key": "Cheie API",
|
||||
"api_key_copied_to_clipboard": "Cheia API a fost copiată în clipboard",
|
||||
@@ -1280,7 +1292,7 @@
|
||||
"columns": "Coloane",
|
||||
"company": "Companie",
|
||||
"company_logo": "Sigla companiei",
|
||||
"completed_responses": "răspunsuri parțiale sau finalizate",
|
||||
"completed_responses": "Răspunsuri completate",
|
||||
"concat": "Concat +",
|
||||
"conditional_logic": "Logică condițională",
|
||||
"confirm_default_language": "Confirmați limba implicită",
|
||||
@@ -1289,8 +1301,8 @@
|
||||
"contains": "Conține",
|
||||
"continue_to_settings": "Continuă către Setări",
|
||||
"control_which_file_types_can_be_uploaded": "Controlează ce tipuri de fișiere pot fi încărcate.",
|
||||
"convert_to_multiple_choice": "Convertiți la alegere multiplă",
|
||||
"convert_to_single_choice": "Convertiți la alegere unică",
|
||||
"convert_to_multiple_choice": "Convertiți la selectare multiplă",
|
||||
"convert_to_single_choice": "Convertiți la selectare unică",
|
||||
"country": "Țară",
|
||||
"create_group": "Creează grup",
|
||||
"create_your_own_survey": "Creează-ți propriul chestionar",
|
||||
@@ -1320,6 +1332,7 @@
|
||||
"end_screen_card": "Ecran final card",
|
||||
"ending_card": "Cardul de finalizare",
|
||||
"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",
|
||||
"equals": "Egal",
|
||||
"equals_one_of": "Egal unu dintre",
|
||||
@@ -1330,6 +1343,7 @@
|
||||
"fallback_for": "Varianta de rezervă pentru",
|
||||
"fallback_missing": "Rezerva lipsă",
|
||||
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} este folosit în logică întrebării {questionIndex}. Vă rugăm să-l eliminați din logică mai întâi.",
|
||||
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "Câmpul ascuns \"{fieldId}\" este folosit în cota \"{quotaName}\"",
|
||||
"field_name_eg_score_price": "Nume câmp, de exemplu, scor, preț",
|
||||
"first_name": "Prenume",
|
||||
"five_points_recommended": "5 puncte (recomandat)",
|
||||
@@ -1361,8 +1375,9 @@
|
||||
"follow_ups_modal_action_subject_placeholder": "Subiectul emailului",
|
||||
"follow_ups_modal_action_to_description": "Adresă de email către care se trimite emailul",
|
||||
"follow_ups_modal_action_to_label": "Către",
|
||||
"follow_ups_modal_action_to_warning": "Nu s-a detectat niciun câmp de e-mail în sondaj",
|
||||
"follow_ups_modal_action_to_warning": "Nu s-au găsit opțiuni valide pentru trimiterea e-mailurilor, vă rugăm să adăugați întrebări de tip text deschis / informații de contact sau câmpuri ascunse",
|
||||
"follow_ups_modal_create_heading": "Creați o nouă urmărire",
|
||||
"follow_ups_modal_created_successfull_toast": "Urmărirea a fost creată și va fi salvată odată ce salvați sondajul.",
|
||||
"follow_ups_modal_edit_heading": "Editează acest follow-up",
|
||||
"follow_ups_modal_edit_no_id": "Nu a fost furnizat un ID de urmărire al chestionarului, nu pot actualiza urmărirea chestionarului",
|
||||
"follow_ups_modal_name_label": "Numele ",
|
||||
@@ -1372,8 +1387,9 @@
|
||||
"follow_ups_modal_trigger_label": "Declanșator",
|
||||
"follow_ups_modal_trigger_type_ending": "Respondentul vede un sfârșit specific",
|
||||
"follow_ups_modal_trigger_type_ending_select": "Selectează finalurile:",
|
||||
"follow_ups_modal_trigger_type_ending_warning": "Nu s-au găsit finalizări în sondaj!",
|
||||
"follow_ups_modal_trigger_type_ending_warning": "Vă rugăm să selectați cel puțin un sfârșit sau să schimbați tipul declanșatorului",
|
||||
"follow_ups_modal_trigger_type_response": "Respondent finalizează sondajul",
|
||||
"follow_ups_modal_updated_successfull_toast": "Urmărirea a fost actualizată și va fi salvată odată ce salvați sondajul.",
|
||||
"follow_ups_new": "Follow-up nou",
|
||||
"follow_ups_upgrade_button_text": "Actualizați pentru a activa urmărările",
|
||||
"form_styling": "Stilizare formular",
|
||||
@@ -1474,6 +1490,38 @@
|
||||
"question_duplicated": "Întrebare duplicată.",
|
||||
"question_id_updated": "ID întrebare actualizat",
|
||||
"question_used_in_logic": "Această întrebare este folosită în logica întrebării {questionIndex}.",
|
||||
"question_used_in_quota": "Întrebarea aceasta este folosită în cota \"{quotaName}\"",
|
||||
"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_except_last": "Randomizează tot cu excepția ultimului",
|
||||
"range": "Interval",
|
||||
@@ -1567,6 +1615,7 @@
|
||||
"url_not_supported": "URL nesuportat",
|
||||
"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_quota_please_remove_it_from_quota_first": "Variabila \"{variableName}\" este folosită în cota \"{quotaName}\"",
|
||||
"variable_name_is_already_taken_please_choose_another": "Numele variabilei este deja utilizat, vă rugăm să alegeți altul.",
|
||||
"variable_name_must_start_with_a_letter": "Numele variabilei trebuie să înceapă cu o literă.",
|
||||
"verify_email_before_submission": "Verifică emailul înainte de trimitere",
|
||||
@@ -1601,11 +1650,14 @@
|
||||
"address_line_2": "Adresă Linie 2",
|
||||
"an_error_occurred_deleting_the_tag": "A apărut o eroare la ștergerea etichetei",
|
||||
"browser": "Browser",
|
||||
"bulk_delete_response_quotas": "Răspunsurile fac parte din cotele pentru acest sondaj. Cum doriți să gestionați cotele?",
|
||||
"city": "Oraș",
|
||||
"company": "Companie",
|
||||
"completed": "Finalizat ✅",
|
||||
"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_quotas": "Răspunsul face parte din cotele pentru acest sondaj. Cum doriți să gestionați cotele?",
|
||||
"device": "Dispozitiv",
|
||||
"device_info": "Informații despre dispozitiv",
|
||||
"email": "Email",
|
||||
@@ -1737,6 +1789,7 @@
|
||||
"configure_alerts": "Configurează alertele",
|
||||
"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.",
|
||||
"current_count": "Număr curent",
|
||||
"custom_range": "Interval personalizat...",
|
||||
"delete_all_existing_responses_and_displays": "Șterge toate răspunsurile și afișările existente",
|
||||
"download_qr_code": "Descărcare cod QR",
|
||||
@@ -1790,6 +1843,7 @@
|
||||
"last_month": "Ultima lună",
|
||||
"last_quarter": "Ultimul trimestru",
|
||||
"last_year": "Anul trecut",
|
||||
"limit": "Limită",
|
||||
"no_responses_found": "Nu s-au găsit răspunsuri",
|
||||
"other_values_found": "Alte valori găsite",
|
||||
"overall": "General",
|
||||
@@ -1798,6 +1852,8 @@
|
||||
"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_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_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)",
|
||||
|
||||
@@ -169,11 +169,14 @@
|
||||
"connect_formbricks": "连接 Formbricks",
|
||||
"connected": "已连接",
|
||||
"contacts": "联系人",
|
||||
"continue": "继续",
|
||||
"copied": "已复制",
|
||||
"copied_to_clipboard": "已 复制到 剪贴板",
|
||||
"copy": "复制",
|
||||
"copy_code": "复制 代码",
|
||||
"copy_link": "复制 链接",
|
||||
"count_contacts": "{value, plural, other {{value} 联系人} }",
|
||||
"count_responses": "{value, plural, other {{value} 回复} }",
|
||||
"create_new_organization": "创建 新的 组织",
|
||||
"create_project": "创建 项目",
|
||||
"create_segment": "创建 细分",
|
||||
@@ -201,6 +204,7 @@
|
||||
"e_commerce": "电子商务",
|
||||
"edit": "编辑",
|
||||
"email": "邮箱",
|
||||
"ending_card": "结尾卡片",
|
||||
"enterprise_license": "企业 许可证",
|
||||
"environment_not_found": "环境 未找到",
|
||||
"environment_notice": "你 目前 位于 {environment} 环境。",
|
||||
@@ -258,7 +262,9 @@
|
||||
"membership_not_found": "未找到会员资格",
|
||||
"metadata": "元数据",
|
||||
"minimum": "最低",
|
||||
"mobile_overlay_text": "Formbricks 不 适用 于 分辨率 较小 的 设备",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks 在 更大 的 屏幕 上 效果 最佳。 若 需要 管理 或 构建 调查, 请 切换 到 其他 设备。",
|
||||
"mobile_overlay_surveys_look_good": "别 担心 – 您 的 调查 在 每 一 种 设备 和 屏幕 尺寸 上 看起来 都 很 棒!",
|
||||
"mobile_overlay_title": "噢, 检测 到 小 屏幕!",
|
||||
"move_down": "下移",
|
||||
"move_up": "上移",
|
||||
"multiple_languages": "多种 语言",
|
||||
@@ -269,6 +275,7 @@
|
||||
"no_background_image_found": "未找到 背景 图片。",
|
||||
"no_code": "无代码",
|
||||
"no_files_uploaded": "没有 文件 被 上传",
|
||||
"no_quotas_found": "未找到配额",
|
||||
"no_result_found": "没有 结果",
|
||||
"no_results": "没有 结果",
|
||||
"no_surveys_found": "未找到 调查",
|
||||
@@ -312,6 +319,7 @@
|
||||
"product_manager": "产品经理",
|
||||
"profile": "资料",
|
||||
"profile_id": "资料 ID",
|
||||
"progress": "进度",
|
||||
"project_configuration": "项目 配置",
|
||||
"project_creation_description": "将 调查 组织 在 项目 中 以 便于 更好 的 访问 控制。",
|
||||
"project_id": "项目 ID",
|
||||
@@ -323,6 +331,9 @@
|
||||
"question": "问题",
|
||||
"question_id": "问题 ID",
|
||||
"questions": "问题",
|
||||
"quota": "配额",
|
||||
"quotas": "配额",
|
||||
"quotas_description": "限制 符合 特定 条件 的 参与者 的 响应 数量 。",
|
||||
"read_docs": "阅读 文档",
|
||||
"recipients": "收件人",
|
||||
"remove": "移除",
|
||||
@@ -370,6 +381,7 @@
|
||||
"start_free_trial": "开始 免费试用",
|
||||
"status": "状态",
|
||||
"step_by_step_manual": "分步 手册",
|
||||
"storage_not_configured": "文件存储 未设置,上传 可能 失败",
|
||||
"styling": "样式",
|
||||
"submit": "提交",
|
||||
"summary": "概要",
|
||||
@@ -579,6 +591,7 @@
|
||||
"contacts_table_refresh": "刷新 联系人",
|
||||
"contacts_table_refresh_success": "联系人 已成功刷新",
|
||||
"delete_contact_confirmation": "这将删除与此联系人相关的所有调查问卷回复和联系人属性。基于此联系人数据的任何定位和个性化将会丢失。",
|
||||
"delete_contact_confirmation_with_quotas": "{value, plural, one {这将删除与此联系人相关的所有调查回复和联系人属性。基于此联系人数据的任何定位和个性化将丢失。如果此联系人有影响调查配额的回复,配额数量将减少,但配额限制将保持不变。} other {这将删除与这些联系人相关的所有调查回复和联系人属性。基于这些联系人数据的任何定位和个性化将丢失。如果这些联系人有影响调查配额的回复,配额数量将减少,但配额限制将保持不变。}}",
|
||||
"no_responses_found": "未找到 响应",
|
||||
"not_provided": "未提供",
|
||||
"search_contact": "搜索 联系人",
|
||||
@@ -739,7 +752,6 @@
|
||||
},
|
||||
"project": {
|
||||
"api_keys": {
|
||||
"access_control": "访问控制",
|
||||
"add_api_key": "添加 API 密钥",
|
||||
"api_key": "API Key",
|
||||
"api_key_copied_to_clipboard": "API 密钥 已复制到 剪贴板",
|
||||
@@ -1280,7 +1292,7 @@
|
||||
"columns": "列",
|
||||
"company": "公司",
|
||||
"company_logo": "公司 徽标",
|
||||
"completed_responses": "部分 或 完成 的 反馈",
|
||||
"completed_responses": "完成反馈。",
|
||||
"concat": "拼接 +",
|
||||
"conditional_logic": "条件逻辑",
|
||||
"confirm_default_language": "确认 默认 语言",
|
||||
@@ -1289,8 +1301,8 @@
|
||||
"contains": "包含",
|
||||
"continue_to_settings": "继续 到 设置",
|
||||
"control_which_file_types_can_be_uploaded": "控制 可以 上传的 文件 类型",
|
||||
"convert_to_multiple_choice": "转换为多选题",
|
||||
"convert_to_single_choice": "转换为单选题",
|
||||
"convert_to_multiple_choice": "转换为 多选",
|
||||
"convert_to_single_choice": "转换为 单选",
|
||||
"country": "国家",
|
||||
"create_group": "创建 群组",
|
||||
"create_your_own_survey": "创建 你 的 调查",
|
||||
@@ -1320,6 +1332,7 @@
|
||||
"end_screen_card": "结束 屏幕 卡片",
|
||||
"ending_card": "结尾卡片",
|
||||
"ending_card_used_in_logic": "\"这个 结束卡片 在 问题 {questionIndex} 的 逻辑 中 使用。\"",
|
||||
"ending_used_in_quota": "此 结尾 正在 被 \"{quotaName}\" 配额 使用",
|
||||
"ends_with": "以...结束",
|
||||
"equals": "等于",
|
||||
"equals_one_of": "等于 其中 一个",
|
||||
@@ -1330,6 +1343,7 @@
|
||||
"fallback_for": "后备 用于",
|
||||
"fallback_missing": "备用 缺失",
|
||||
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "\"{fieldId} 在 问题 {questionIndex} 的 逻辑 中 使用。请 先 从 逻辑 中 删除 它。\"",
|
||||
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "隐藏 字段 \"{fieldId}\" 正在 被 \"{quotaName}\" 配额 使用",
|
||||
"field_name_eg_score_price": "字段 名称 例如 评分 ,价格",
|
||||
"first_name": "名字",
|
||||
"five_points_recommended": "5 点 (推荐)",
|
||||
@@ -1361,8 +1375,9 @@
|
||||
"follow_ups_modal_action_subject_placeholder": "电子邮件主题",
|
||||
"follow_ups_modal_action_to_description": "发送邮件的电子邮箱地址",
|
||||
"follow_ups_modal_action_to_label": "到",
|
||||
"follow_ups_modal_action_to_warning": "调查中未 检测到 电子邮件 字段",
|
||||
"follow_ups_modal_action_to_warning": "为 发送 邮件 找不到 有效 选项 ,请 增加 一些 开放文本 / 联系 信息 问题 或 隐藏 字段",
|
||||
"follow_ups_modal_create_heading": "创建一个新的跟进",
|
||||
"follow_ups_modal_created_successfull_toast": "后续 操作 已 创建, 并且 在 你 保存 调查 后 将 被 保存。",
|
||||
"follow_ups_modal_edit_heading": "编辑此跟进",
|
||||
"follow_ups_modal_edit_no_id": "未 提供 调查 跟进 id ,无法 更新 调查 跟进",
|
||||
"follow_ups_modal_name_label": "跟进 名称",
|
||||
@@ -1372,8 +1387,9 @@
|
||||
"follow_ups_modal_trigger_label": "触发",
|
||||
"follow_ups_modal_trigger_type_ending": "受访者 看到 一个 特定 的 结尾",
|
||||
"follow_ups_modal_trigger_type_ending_select": "选择结尾:",
|
||||
"follow_ups_modal_trigger_type_ending_warning": "在 调查 中 未找到 结尾 !",
|
||||
"follow_ups_modal_trigger_type_ending_warning": "请选择至少 一个结束条件 或更改触发条件类型",
|
||||
"follow_ups_modal_trigger_type_response": "受访者 完成 调查",
|
||||
"follow_ups_modal_updated_successfull_toast": "后续 操作 已 更新, 并且 在 你 保存 调查 后 将 被 保存。",
|
||||
"follow_ups_new": "新的跟进",
|
||||
"follow_ups_upgrade_button_text": "升级 以启用 跟进",
|
||||
"form_styling": "表单 样式",
|
||||
@@ -1474,6 +1490,38 @@
|
||||
"question_duplicated": "问题重复。",
|
||||
"question_id_updated": "问题 ID 更新",
|
||||
"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_except_last": "随机排列,最后一个除外",
|
||||
"range": "范围",
|
||||
@@ -1567,6 +1615,7 @@
|
||||
"url_not_supported": "URL 不支持",
|
||||
"use_with_caution": "谨慎 使用",
|
||||
"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_must_start_with_a_letter": "变量名 必须 以字母开头。",
|
||||
"verify_email_before_submission": "提交 之前 验证电子邮件",
|
||||
@@ -1601,11 +1650,14 @@
|
||||
"address_line_2": "地址 第2行",
|
||||
"an_error_occurred_deleting_the_tag": "删除 标签 时发生错误",
|
||||
"browser": "浏览器",
|
||||
"bulk_delete_response_quotas": "这些 响应是 此 调查配额 的一部分。 您 希望 如何 处理 这些 配额?",
|
||||
"city": "城市",
|
||||
"company": "公司",
|
||||
"completed": "完成 ✅",
|
||||
"country": "国家",
|
||||
"decrement_quotas": "减少所有配额限制,包括此回应",
|
||||
"delete_response_confirmation": "这 将 删除 调查 回应, 包括 所有 答案、 标签、 附件文档 和 回应元数据。",
|
||||
"delete_response_quotas": "该响应是 此 调查配额 的一部分。 您 希望 如何 处理 这些 配额?",
|
||||
"device": "设备",
|
||||
"device_info": "设备信息",
|
||||
"email": "邮件",
|
||||
@@ -1737,6 +1789,7 @@
|
||||
"configure_alerts": "配置 警报",
|
||||
"congrats": "恭喜!您的调查已上线。",
|
||||
"connect_your_website_or_app_with_formbricks_to_get_started": "将您 的网站 或应用 与 Formbricks 连接 , 以开始 使用。",
|
||||
"current_count": "当前数量",
|
||||
"custom_range": "自定义 范围...",
|
||||
"delete_all_existing_responses_and_displays": "删除 所有 现有 的 回复 和 显示",
|
||||
"download_qr_code": "下载 二维码",
|
||||
@@ -1790,6 +1843,7 @@
|
||||
"last_month": "上个月",
|
||||
"last_quarter": "上季度",
|
||||
"last_year": "去年",
|
||||
"limit": "限额",
|
||||
"no_responses_found": "未找到响应",
|
||||
"other_values_found": "找到其他值",
|
||||
"overall": "整体",
|
||||
@@ -1798,6 +1852,8 @@
|
||||
"qr_code_download_failed": "二维码下载失败",
|
||||
"qr_code_download_with_start_soon": "二维码下载将很快开始",
|
||||
"qr_code_generation_failed": "加载 调查 QR 码 时出现问题。 请重试。",
|
||||
"quotas_completed": "配额完成",
|
||||
"quotas_completed_tooltip": "受访者完成的配额数量。",
|
||||
"reset_survey": "重置 调查",
|
||||
"reset_survey_warning": "重置 一个调查 会移除与 此调查 相关 的 所有响应 和 展示 。此操作 不能 撤销 。",
|
||||
"selected_responses_csv": "选定 反馈 (CSV)",
|
||||
|
||||
@@ -169,11 +169,14 @@
|
||||
"connect_formbricks": "連線 Formbricks",
|
||||
"connected": "已連線",
|
||||
"contacts": "聯絡人",
|
||||
"continue": "繼續",
|
||||
"copied": "已 複製",
|
||||
"copied_to_clipboard": "已複製到剪貼簿",
|
||||
"copy": "複製",
|
||||
"copy_code": "複製程式碼",
|
||||
"copy_link": "複製連結",
|
||||
"count_contacts": "{value, plural, other {{value} 聯絡人} }",
|
||||
"count_responses": "{value, plural, other {{value} 回應} }",
|
||||
"create_new_organization": "建立新組織",
|
||||
"create_project": "建立專案",
|
||||
"create_segment": "建立區隔",
|
||||
@@ -201,6 +204,7 @@
|
||||
"e_commerce": "電子商務",
|
||||
"edit": "編輯",
|
||||
"email": "電子郵件",
|
||||
"ending_card": "結尾卡片",
|
||||
"enterprise_license": "企業授權",
|
||||
"environment_not_found": "找不到環境",
|
||||
"environment_notice": "您目前在 '{'environment'}' 環境中。",
|
||||
@@ -258,7 +262,9 @@
|
||||
"membership_not_found": "找不到成員資格",
|
||||
"metadata": "元數據",
|
||||
"minimum": "最小值",
|
||||
"mobile_overlay_text": "Formbricks 不適用於較小解析度的裝置。",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks 適合在大螢幕上使用。若要管理或建立問卷,請切換到其他裝置。",
|
||||
"mobile_overlay_surveys_look_good": "別擔心 -你的 問卷 在每個 裝置 和 螢幕尺寸 上 都 很出色!",
|
||||
"mobile_overlay_title": "糟糕 ,偵測到小螢幕!",
|
||||
"move_down": "下移",
|
||||
"move_up": "上移",
|
||||
"multiple_languages": "多種語言",
|
||||
@@ -269,6 +275,7 @@
|
||||
"no_background_image_found": "找不到背景圖片。",
|
||||
"no_code": "無程式碼",
|
||||
"no_files_uploaded": "沒有上傳任何檔案",
|
||||
"no_quotas_found": "找不到 配額",
|
||||
"no_result_found": "找不到結果",
|
||||
"no_results": "沒有結果",
|
||||
"no_surveys_found": "找不到問卷。",
|
||||
@@ -312,6 +319,7 @@
|
||||
"product_manager": "產品經理",
|
||||
"profile": "個人資料",
|
||||
"profile_id": "個人資料 ID",
|
||||
"progress": "進度",
|
||||
"project_configuration": "專案組態",
|
||||
"project_creation_description": "組織調查 在 專案中以便更好地存取控制。",
|
||||
"project_id": "專案 ID",
|
||||
@@ -323,6 +331,9 @@
|
||||
"question": "問題",
|
||||
"question_id": "問題 ID",
|
||||
"questions": "問題",
|
||||
"quota": "配額",
|
||||
"quotas": "額度",
|
||||
"quotas_description": "限制 擁有 特定 條件 的 參與者 所 提供 的 回應 數量。",
|
||||
"read_docs": "閱讀文件",
|
||||
"recipients": "收件者",
|
||||
"remove": "移除",
|
||||
@@ -370,6 +381,7 @@
|
||||
"start_free_trial": "開始免費試用",
|
||||
"status": "狀態",
|
||||
"step_by_step_manual": "逐步手冊",
|
||||
"storage_not_configured": "檔案儲存未設定,上傳可能會失敗",
|
||||
"styling": "樣式設定",
|
||||
"submit": "提交",
|
||||
"summary": "摘要",
|
||||
@@ -579,6 +591,7 @@
|
||||
"contacts_table_refresh": "重新整理聯絡人",
|
||||
"contacts_table_refresh_success": "聯絡人已成功重新整理",
|
||||
"delete_contact_confirmation": "這將刪除與此聯繫人相關的所有調查回應和聯繫屬性。任何基於此聯繫人數據的定位和個性化將會丟失。",
|
||||
"delete_contact_confirmation_with_quotas": "{value, plural, one {這將刪除與這個 contact 相關的所有調查響應和聯繫人屬性。基於這個 contact 數據的任何定向和個性化功能將會丟失。如果這個 contact 有作為調查配額依據的響應,配額計數將會減少,但配額限制將保持不變。} other {這將刪除與這些 contacts 相關的所有調查響應和聯繫人屬性。基於這些 contacts 數據的任何定向和個性化功能將會丟失。如果這些 contacts 有作為調查配額依據的響應,配額計數將會減少,但配額限制將保持不變。}}",
|
||||
"no_responses_found": "找不到回應",
|
||||
"not_provided": "未提供",
|
||||
"search_contact": "搜尋聯絡人",
|
||||
@@ -739,7 +752,6 @@
|
||||
},
|
||||
"project": {
|
||||
"api_keys": {
|
||||
"access_control": "存取控制",
|
||||
"add_api_key": "新增 API 金鑰",
|
||||
"api_key": "API 金鑰",
|
||||
"api_key_copied_to_clipboard": "API 金鑰已複製到剪貼簿",
|
||||
@@ -1280,7 +1292,7 @@
|
||||
"columns": "欄位",
|
||||
"company": "公司",
|
||||
"company_logo": "公司標誌",
|
||||
"completed_responses": "部分或完整答复。",
|
||||
"completed_responses": "完成 回應",
|
||||
"concat": "串連 +",
|
||||
"conditional_logic": "條件邏輯",
|
||||
"confirm_default_language": "確認預設語言",
|
||||
@@ -1320,6 +1332,7 @@
|
||||
"end_screen_card": "結束畫面卡片",
|
||||
"ending_card": "結尾卡片",
|
||||
"ending_card_used_in_logic": "此結尾卡片用於問題 '{'questionIndex'}' 的邏輯中。",
|
||||
"ending_used_in_quota": "此 結尾 正被使用於 \"{quotaName}\" 配額中",
|
||||
"ends_with": "結尾為",
|
||||
"equals": "等於",
|
||||
"equals_one_of": "等於其中之一",
|
||||
@@ -1330,6 +1343,7 @@
|
||||
"fallback_for": "備用 用於 ",
|
||||
"fallback_missing": "遺失的回退",
|
||||
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "'{'fieldId'}' 用於問題 '{'questionIndex'}' 的邏輯中。請先從邏輯中移除。",
|
||||
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "隱藏欄位 \"{fieldId}\" 正被使用於 \"{quotaName}\" 配額中",
|
||||
"field_name_eg_score_price": "欄位名稱,例如:分數、價格",
|
||||
"first_name": "名字",
|
||||
"five_points_recommended": "5 分(建議)",
|
||||
@@ -1361,8 +1375,9 @@
|
||||
"follow_ups_modal_action_subject_placeholder": "電子郵件主旨",
|
||||
"follow_ups_modal_action_to_description": "傳送電子郵件的電子郵件地址",
|
||||
"follow_ups_modal_action_to_label": "收件者",
|
||||
"follow_ups_modal_action_to_warning": "問卷中未偵測到電子郵件欄位",
|
||||
"follow_ups_modal_action_to_warning": "未找到 發送電子郵件 有效選項,請添加 一些 開放文本 / 聯絡資訊 問題或隱藏欄位",
|
||||
"follow_ups_modal_create_heading": "建立新的後續追蹤",
|
||||
"follow_ups_modal_created_successfull_toast": "後續 動作 已 建立 並 將 在 你 儲存 調查 後 儲存",
|
||||
"follow_ups_modal_edit_heading": "編輯此後續追蹤",
|
||||
"follow_ups_modal_edit_no_id": "未提供問卷後續追蹤 ID,無法更新問卷後續追蹤",
|
||||
"follow_ups_modal_name_label": "後續追蹤名稱",
|
||||
@@ -1372,8 +1387,9 @@
|
||||
"follow_ups_modal_trigger_label": "觸發器",
|
||||
"follow_ups_modal_trigger_type_ending": "回應者看到特定結尾",
|
||||
"follow_ups_modal_trigger_type_ending_select": "選取結尾:",
|
||||
"follow_ups_modal_trigger_type_ending_warning": "問卷中找不到結尾!",
|
||||
"follow_ups_modal_trigger_type_ending_warning": "請選擇至少一個結尾或更改觸發類型",
|
||||
"follow_ups_modal_trigger_type_response": "回應者完成問卷",
|
||||
"follow_ups_modal_updated_successfull_toast": "後續 動作 已 更新 並 將 在 你 儲存 調查 後 儲存",
|
||||
"follow_ups_new": "新增後續追蹤",
|
||||
"follow_ups_upgrade_button_text": "升級以啟用後續追蹤",
|
||||
"form_styling": "表單樣式設定",
|
||||
@@ -1474,6 +1490,38 @@
|
||||
"question_duplicated": "問題已複製。",
|
||||
"question_id_updated": "問題 ID 已更新",
|
||||
"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_except_last": "全部隨機排序(最後一項除外)",
|
||||
"range": "範圍",
|
||||
@@ -1567,6 +1615,7 @@
|
||||
"url_not_supported": "不支援網址",
|
||||
"use_with_caution": "謹慎使用",
|
||||
"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_must_start_with_a_letter": "變數名稱必須以字母開頭。",
|
||||
"verify_email_before_submission": "提交前驗證電子郵件",
|
||||
@@ -1601,11 +1650,14 @@
|
||||
"address_line_2": "地址 2",
|
||||
"an_error_occurred_deleting_the_tag": "刪除標籤時發生錯誤",
|
||||
"browser": "瀏覽器",
|
||||
"bulk_delete_response_quotas": "回應 屬於 此 調查 的 配額 一部分 . 你 想 如何 處理 配額?",
|
||||
"city": "城市",
|
||||
"company": "公司",
|
||||
"completed": "已完成 ✅",
|
||||
"country": "國家/地區",
|
||||
"decrement_quotas": "減少所有配額限制,包括此回應",
|
||||
"delete_response_confirmation": "這將刪除調查響應,包括所有回答、標籤、附件文件以及響應元數據。",
|
||||
"delete_response_quotas": "回應 屬於 此 調查 的 配額 一部分 . 你 想 如何 處理 配額?",
|
||||
"device": "裝置",
|
||||
"device_info": "裝置資訊",
|
||||
"email": "電子郵件",
|
||||
@@ -1737,6 +1789,7 @@
|
||||
"configure_alerts": "設定警示",
|
||||
"congrats": "恭喜!您的問卷已上線。",
|
||||
"connect_your_website_or_app_with_formbricks_to_get_started": "將您的網站或應用程式與 Formbricks 連線以開始使用。",
|
||||
"current_count": "目前計數",
|
||||
"custom_range": "自訂範圍...",
|
||||
"delete_all_existing_responses_and_displays": "刪除 所有 現有 回應 和 顯示",
|
||||
"download_qr_code": "下載 QR code",
|
||||
@@ -1790,6 +1843,7 @@
|
||||
"last_month": "上個月",
|
||||
"last_quarter": "上一季",
|
||||
"last_year": "去年",
|
||||
"limit": "限制",
|
||||
"no_responses_found": "找不到回應",
|
||||
"other_values_found": "找到其他值",
|
||||
"overall": "整體",
|
||||
@@ -1798,6 +1852,8 @@
|
||||
"qr_code_download_failed": "QR code 下載失敗",
|
||||
"qr_code_download_with_start_soon": "QR code 下載即將開始",
|
||||
"qr_code_generation_failed": "載入調查 QR Code 時發生問題。請再試一次。",
|
||||
"quotas_completed": "配額 已完成",
|
||||
"quotas_completed_tooltip": "受訪者完成的 配額 數量。",
|
||||
"reset_survey": "重設問卷",
|
||||
"reset_survey_warning": "重置 調查 會 移除 與 此 調查 相關 的 所有 回應 和 顯示 。 這 是 不可 撤銷 的 。",
|
||||
"selected_responses_csv": "選擇的回應 (CSV)",
|
||||
|
||||
@@ -230,7 +230,7 @@ describe("RenderResponse", () => {
|
||||
showId={false}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("ResponseBadges")).toHaveTextContent("Value");
|
||||
expect(screen.getByTestId("ResponseBadges")).toHaveTextContent("value");
|
||||
});
|
||||
|
||||
test("renders ResponseBadges for 'Consent' question (number)", () => {
|
||||
@@ -258,7 +258,7 @@ describe("RenderResponse", () => {
|
||||
showId={false}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("ResponseBadges")).toHaveTextContent("Click");
|
||||
expect(screen.getByTestId("ResponseBadges")).toHaveTextContent("click");
|
||||
});
|
||||
|
||||
test("renders ResponseBadges for 'MultipleChoiceSingle' question (string)", () => {
|
||||
|
||||
@@ -1,18 +1,6 @@
|
||||
import { cn } from "@/lib/cn";
|
||||
import { getLanguageCode, getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { getChoiceIdByValue } from "@/lib/response/utils";
|
||||
import { processResponseData } from "@/lib/responses";
|
||||
import { formatDateWithOrdinal } from "@/lib/utils/datetime";
|
||||
import { capitalizeFirstLetter } from "@/lib/utils/strings";
|
||||
import { renderHyperlinkedContent } from "@/modules/analysis/utils";
|
||||
import { ArrayResponse } from "@/modules/ui/components/array-response";
|
||||
import { FileUploadResponse } from "@/modules/ui/components/file-upload-response";
|
||||
import { PictureSelectionResponse } from "@/modules/ui/components/picture-selection-response";
|
||||
import { RankingResponse } from "@/modules/ui/components/ranking-response";
|
||||
import { RatingResponse } from "@/modules/ui/components/rating-response";
|
||||
import { ResponseBadges } from "@/modules/ui/components/response-badges";
|
||||
import { CheckCheckIcon, MousePointerClickIcon, PhoneIcon } from "lucide-react";
|
||||
import React from "react";
|
||||
import { TResponseDataValue } from "@formbricks/types/responses";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyMatrixQuestion,
|
||||
@@ -21,9 +9,21 @@ import {
|
||||
TSurveyQuestionTypeEnum,
|
||||
TSurveyRatingQuestion,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { getLanguageCode, getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { getChoiceIdByValue } from "@/lib/response/utils";
|
||||
import { processResponseData } from "@/lib/responses";
|
||||
import { formatDateWithOrdinal } from "@/lib/utils/datetime";
|
||||
import { renderHyperlinkedContent } from "@/modules/analysis/utils";
|
||||
import { ArrayResponse } from "@/modules/ui/components/array-response";
|
||||
import { FileUploadResponse } from "@/modules/ui/components/file-upload-response";
|
||||
import { PictureSelectionResponse } from "@/modules/ui/components/picture-selection-response";
|
||||
import { RankingResponse } from "@/modules/ui/components/ranking-response";
|
||||
import { RatingResponse } from "@/modules/ui/components/rating-response";
|
||||
import { ResponseBadges } from "@/modules/ui/components/response-badges";
|
||||
|
||||
interface RenderResponseProps {
|
||||
responseData: string | number | string[] | Record<string, string>;
|
||||
responseData: TResponseDataValue;
|
||||
question: TSurveyQuestion;
|
||||
survey: TSurvey;
|
||||
language: string | null;
|
||||
@@ -103,9 +103,7 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
|
||||
const rowValueInSelectedLanguage = getLocalizedValue(row.label, languagCode);
|
||||
if (!responseData[rowValueInSelectedLanguage]) return null;
|
||||
return (
|
||||
<p
|
||||
key={rowValueInSelectedLanguage}
|
||||
className="ph-no-capture my-1 font-normal capitalize text-slate-700">
|
||||
<p key={rowValueInSelectedLanguage} className="ph-no-capture my-1 font-normal text-slate-700">
|
||||
{rowValueInSelectedLanguage}:{processResponseData(responseData[rowValueInSelectedLanguage])}
|
||||
</p>
|
||||
);
|
||||
@@ -125,7 +123,7 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
|
||||
if (typeof responseData === "string" || typeof responseData === "number") {
|
||||
return (
|
||||
<ResponseBadges
|
||||
items={[{ value: capitalizeFirstLetter(responseData.toString()) }]}
|
||||
items={[{ value: responseData.toString() }]}
|
||||
isExpanded={isExpanded}
|
||||
icon={<PhoneIcon className="h-4 w-4 text-slate-500" />}
|
||||
showId={showId}
|
||||
@@ -137,7 +135,7 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
|
||||
if (typeof responseData === "string" || typeof responseData === "number") {
|
||||
return (
|
||||
<ResponseBadges
|
||||
items={[{ value: capitalizeFirstLetter(responseData.toString()) }]}
|
||||
items={[{ value: responseData.toString() }]}
|
||||
isExpanded={isExpanded}
|
||||
icon={<CheckCheckIcon className="h-4 w-4 text-slate-500" />}
|
||||
showId={showId}
|
||||
@@ -149,7 +147,7 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
|
||||
if (typeof responseData === "string" || typeof responseData === "number") {
|
||||
return (
|
||||
<ResponseBadges
|
||||
items={[{ value: capitalizeFirstLetter(responseData.toString()) }]}
|
||||
items={[{ value: responseData.toString() }]}
|
||||
isExpanded={isExpanded}
|
||||
icon={<MousePointerClickIcon className="h-4 w-4 text-slate-500" />}
|
||||
showId={showId}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export const isValidValue = (value: string | number | Record<string, string> | string[]) => {
|
||||
import { TResponseDataValue } from "@formbricks/types/responses";
|
||||
|
||||
export const isValidValue = (value: TResponseDataValue) => {
|
||||
return (
|
||||
(typeof value === "string" && value.trim() !== "") ||
|
||||
(Array.isArray(value) && value.length > 0) ||
|
||||
|
||||
101
apps/web/modules/api/v2/health/lib/health-checks.ts
Normal file
101
apps/web/modules/api/v2/health/lib/health-checks.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { getCacheService } from "@formbricks/cache";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
import { type OverallHealthStatus } from "@/modules/api/v2/health/types/health-status";
|
||||
import { type ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
|
||||
/**
|
||||
* Check if the main database is reachable and responding
|
||||
* @returns Promise<Result<boolean, ApiErrorResponseV2>> - Result of the database health check
|
||||
*/
|
||||
export const checkDatabaseHealth = async (): Promise<Result<boolean, ApiErrorResponseV2>> => {
|
||||
try {
|
||||
// Simple query to check if database is reachable
|
||||
await prisma.$queryRaw`SELECT 1`;
|
||||
return ok(true);
|
||||
} catch (error) {
|
||||
logger
|
||||
.withContext({
|
||||
component: "health_check",
|
||||
check_type: "main_database",
|
||||
error,
|
||||
})
|
||||
.error("Database health check failed");
|
||||
return err({
|
||||
type: "internal_server_error",
|
||||
details: [{ field: "main_database", issue: "Database health check failed" }],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the Redis cache is reachable and responding
|
||||
* @returns Promise<Result<boolean, ApiErrorResponseV2>> - Result of the cache health check
|
||||
*/
|
||||
export const checkCacheHealth = async (): Promise<Result<boolean, ApiErrorResponseV2>> => {
|
||||
try {
|
||||
const cacheServiceResult = await getCacheService();
|
||||
if (!cacheServiceResult.ok) {
|
||||
return err({
|
||||
type: "internal_server_error",
|
||||
details: [{ field: "cache_database", issue: "Cache service not available" }],
|
||||
});
|
||||
}
|
||||
|
||||
const isAvailable = await cacheServiceResult.data.isRedisAvailable();
|
||||
if (isAvailable) {
|
||||
return ok(true);
|
||||
}
|
||||
|
||||
return err({
|
||||
type: "internal_server_error",
|
||||
details: [{ field: "cache_database", issue: "Redis not available" }],
|
||||
});
|
||||
} catch (error) {
|
||||
logger
|
||||
.withContext({
|
||||
component: "health_check",
|
||||
check_type: "cache_database",
|
||||
error,
|
||||
})
|
||||
.error("Redis health check failed");
|
||||
return err({
|
||||
type: "internal_server_error",
|
||||
details: [{ field: "cache_database", issue: "Redis health check failed" }],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Perform all health checks and return the overall status
|
||||
* Always returns ok() with health status unless the health check endpoint itself fails
|
||||
* @returns Promise<Result<OverallHealthStatus, ApiErrorResponseV2>> - Overall health status of all dependencies
|
||||
*/
|
||||
export const performHealthChecks = async (): Promise<Result<OverallHealthStatus, ApiErrorResponseV2>> => {
|
||||
try {
|
||||
const [databaseResult, cacheResult] = await Promise.all([checkDatabaseHealth(), checkCacheHealth()]);
|
||||
|
||||
const healthStatus: OverallHealthStatus = {
|
||||
main_database: databaseResult.ok ? databaseResult.data : false,
|
||||
cache_database: cacheResult.ok ? cacheResult.data : false,
|
||||
};
|
||||
|
||||
// Always return ok() with the health status - individual dependency failures
|
||||
// are reflected in the boolean values
|
||||
return ok(healthStatus);
|
||||
} catch (error) {
|
||||
// Only return err() if the health check endpoint itself fails
|
||||
logger
|
||||
.withContext({
|
||||
component: "health_check",
|
||||
error,
|
||||
})
|
||||
.error("Health check endpoint failed");
|
||||
|
||||
return err({
|
||||
type: "internal_server_error",
|
||||
details: [{ field: "health", issue: "Failed to perform health checks" }],
|
||||
});
|
||||
}
|
||||
};
|
||||
29
apps/web/modules/api/v2/health/lib/openapi.ts
Normal file
29
apps/web/modules/api/v2/health/lib/openapi.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { ZOverallHealthStatus } from "@/modules/api/v2/health/types/health-status";
|
||||
import { makePartialSchema } from "@/modules/api/v2/types/openapi-response";
|
||||
import { ZodOpenApiOperationObject } from "zod-openapi";
|
||||
|
||||
export const healthCheckEndpoint: ZodOpenApiOperationObject = {
|
||||
tags: ["Health"],
|
||||
summary: "Health Check",
|
||||
description: "Check the health status of critical application dependencies including database and cache.",
|
||||
requestParams: {},
|
||||
operationId: "healthCheck",
|
||||
security: [],
|
||||
responses: {
|
||||
"200": {
|
||||
description:
|
||||
"Health check completed successfully. Check individual dependency status in response data.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: makePartialSchema(ZOverallHealthStatus),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const healthPaths = {
|
||||
"/health": {
|
||||
get: healthCheckEndpoint,
|
||||
},
|
||||
};
|
||||
288
apps/web/modules/api/v2/health/lib/tests/health-checks.test.ts
Normal file
288
apps/web/modules/api/v2/health/lib/tests/health-checks.test.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { ErrorCode, getCacheService } from "@formbricks/cache";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { err, ok } from "@formbricks/types/error-handlers";
|
||||
import { checkCacheHealth, checkDatabaseHealth, performHealthChecks } from "../health-checks";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
$queryRaw: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/cache", () => ({
|
||||
getCacheService: vi.fn(),
|
||||
ErrorCode: {
|
||||
RedisConnectionError: "redis_connection_error",
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
withContext: vi.fn(() => ({
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("Health Checks", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// Helper function to create a mock CacheService
|
||||
const createMockCacheService = (isRedisAvailable: boolean = true) => ({
|
||||
getRedisClient: vi.fn(),
|
||||
withTimeout: vi.fn(),
|
||||
get: vi.fn(),
|
||||
exists: vi.fn(),
|
||||
set: vi.fn(),
|
||||
del: vi.fn(),
|
||||
keys: vi.fn(),
|
||||
withCache: vi.fn(),
|
||||
flush: vi.fn(),
|
||||
tryGetCachedValue: vi.fn(),
|
||||
trySetCache: vi.fn(),
|
||||
isRedisAvailable: vi.fn().mockResolvedValue(isRedisAvailable),
|
||||
});
|
||||
|
||||
describe("checkDatabaseHealth", () => {
|
||||
test("should return healthy when database query succeeds", async () => {
|
||||
vi.mocked(prisma.$queryRaw).mockResolvedValue([{ "?column?": 1 }]);
|
||||
|
||||
const result = await checkDatabaseHealth();
|
||||
|
||||
expect(result).toEqual({ ok: true, data: true });
|
||||
expect(prisma.$queryRaw).toHaveBeenCalledWith(["SELECT 1"]);
|
||||
});
|
||||
|
||||
test("should return unhealthy when database query fails", async () => {
|
||||
const dbError = new Error("Database connection failed");
|
||||
vi.mocked(prisma.$queryRaw).mockRejectedValue(dbError);
|
||||
|
||||
const result = await checkDatabaseHealth();
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.type).toBe("internal_server_error");
|
||||
expect(result.error.details).toEqual([
|
||||
{ field: "main_database", issue: "Database health check failed" },
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
test("should handle different types of database errors", async () => {
|
||||
const networkError = new Error("ECONNREFUSED");
|
||||
vi.mocked(prisma.$queryRaw).mockRejectedValue(networkError);
|
||||
|
||||
const result = await checkDatabaseHealth();
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.type).toBe("internal_server_error");
|
||||
expect(result.error.details).toEqual([
|
||||
{ field: "main_database", issue: "Database health check failed" },
|
||||
]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkCacheHealth", () => {
|
||||
test("should return healthy when Redis is available", async () => {
|
||||
const mockCacheService = createMockCacheService(true);
|
||||
vi.mocked(getCacheService).mockResolvedValue(ok(mockCacheService as any));
|
||||
|
||||
const result = await checkCacheHealth();
|
||||
|
||||
expect(result).toEqual({ ok: true, data: true });
|
||||
expect(getCacheService).toHaveBeenCalled();
|
||||
expect(mockCacheService.isRedisAvailable).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should return unhealthy when cache service fails to initialize", async () => {
|
||||
const cacheError = { code: ErrorCode.RedisConnectionError };
|
||||
vi.mocked(getCacheService).mockResolvedValue(err(cacheError));
|
||||
|
||||
const result = await checkCacheHealth();
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.type).toBe("internal_server_error");
|
||||
expect(result.error.details).toEqual([
|
||||
{ field: "cache_database", issue: "Cache service not available" },
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
test("should return unhealthy when Redis is not available", async () => {
|
||||
const mockCacheService = createMockCacheService(false);
|
||||
vi.mocked(getCacheService).mockResolvedValue(ok(mockCacheService as any));
|
||||
|
||||
const result = await checkCacheHealth();
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.type).toBe("internal_server_error");
|
||||
expect(result.error.details).toEqual([{ field: "cache_database", issue: "Redis not available" }]);
|
||||
}
|
||||
expect(mockCacheService.isRedisAvailable).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should handle Redis availability check exceptions", async () => {
|
||||
const mockCacheService = createMockCacheService(true);
|
||||
mockCacheService.isRedisAvailable.mockRejectedValue(new Error("Redis ping failed"));
|
||||
vi.mocked(getCacheService).mockResolvedValue(ok(mockCacheService as any));
|
||||
|
||||
const result = await checkCacheHealth();
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.type).toBe("internal_server_error");
|
||||
expect(result.error.details).toEqual([
|
||||
{ field: "cache_database", issue: "Redis health check failed" },
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
test("should handle cache service initialization exceptions", async () => {
|
||||
const serviceException = new Error("Cache service unavailable");
|
||||
vi.mocked(getCacheService).mockRejectedValue(serviceException);
|
||||
|
||||
const result = await checkCacheHealth();
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.type).toBe("internal_server_error");
|
||||
expect(result.error.details).toEqual([
|
||||
{ field: "cache_database", issue: "Redis health check failed" },
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
test("should verify isRedisAvailable is called asynchronously", async () => {
|
||||
const mockCacheService = createMockCacheService(true);
|
||||
vi.mocked(getCacheService).mockResolvedValue(ok(mockCacheService as any));
|
||||
|
||||
await checkCacheHealth();
|
||||
|
||||
// Verify the async method was called
|
||||
expect(mockCacheService.isRedisAvailable).toHaveBeenCalledTimes(1);
|
||||
expect(mockCacheService.isRedisAvailable).toReturnWith(Promise.resolve(true));
|
||||
});
|
||||
});
|
||||
|
||||
describe("performHealthChecks", () => {
|
||||
test("should return all healthy when both checks pass", async () => {
|
||||
// Mock successful database check
|
||||
vi.mocked(prisma.$queryRaw).mockResolvedValue([{ "?column?": 1 }]);
|
||||
|
||||
// Mock successful cache check
|
||||
const mockCacheService = createMockCacheService(true);
|
||||
vi.mocked(getCacheService).mockResolvedValue(ok(mockCacheService as any));
|
||||
|
||||
const result = await performHealthChecks();
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
data: {
|
||||
main_database: true,
|
||||
cache_database: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("should return mixed results when only database is healthy", async () => {
|
||||
// Mock successful database check
|
||||
vi.mocked(prisma.$queryRaw).mockResolvedValue([{ "?column?": 1 }]);
|
||||
|
||||
// Mock failed cache check
|
||||
vi.mocked(getCacheService).mockResolvedValue(err({ code: ErrorCode.RedisConnectionError }));
|
||||
|
||||
const result = await performHealthChecks();
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
data: {
|
||||
main_database: true,
|
||||
cache_database: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("should return mixed results when only cache is healthy", async () => {
|
||||
// Mock failed database check
|
||||
vi.mocked(prisma.$queryRaw).mockRejectedValue(new Error("DB Error"));
|
||||
|
||||
// Mock successful cache check
|
||||
const mockCacheService = createMockCacheService(true);
|
||||
vi.mocked(getCacheService).mockResolvedValue(ok(mockCacheService as any));
|
||||
|
||||
const result = await performHealthChecks();
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
data: {
|
||||
main_database: false,
|
||||
cache_database: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("should return all unhealthy when both checks fail", async () => {
|
||||
// Mock failed database check
|
||||
vi.mocked(prisma.$queryRaw).mockRejectedValue(new Error("DB Error"));
|
||||
|
||||
// Mock failed cache check
|
||||
vi.mocked(getCacheService).mockResolvedValue(err({ code: ErrorCode.RedisConnectionError }));
|
||||
|
||||
const result = await performHealthChecks();
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
data: {
|
||||
main_database: false,
|
||||
cache_database: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("should run both checks in parallel", async () => {
|
||||
const dbPromise = new Promise((resolve) => setTimeout(() => resolve([{ "?column?": 1 }]), 100));
|
||||
const redisPromise = new Promise((resolve) => setTimeout(() => resolve(true), 100));
|
||||
|
||||
vi.mocked(prisma.$queryRaw).mockReturnValue(dbPromise as any);
|
||||
|
||||
const mockCacheService = createMockCacheService(true);
|
||||
mockCacheService.isRedisAvailable.mockReturnValue(redisPromise as any);
|
||||
vi.mocked(getCacheService).mockResolvedValue(ok(mockCacheService as any));
|
||||
|
||||
const startTime = Date.now();
|
||||
await performHealthChecks();
|
||||
const endTime = Date.now();
|
||||
|
||||
// Should complete in roughly 100ms (parallel) rather than 200ms (sequential)
|
||||
expect(endTime - startTime).toBeLessThan(150);
|
||||
});
|
||||
|
||||
test("should return error only on catastrophic failure (endpoint itself fails)", async () => {
|
||||
// Mock a catastrophic failure in Promise.all itself
|
||||
const originalPromiseAll = Promise.all;
|
||||
vi.spyOn(Promise, "all").mockRejectedValue(new Error("Catastrophic system failure"));
|
||||
|
||||
const result = await performHealthChecks();
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.type).toBe("internal_server_error");
|
||||
expect(result.error.details).toEqual([{ field: "health", issue: "Failed to perform health checks" }]);
|
||||
}
|
||||
|
||||
// Restore original Promise.all
|
||||
Promise.all = originalPromiseAll;
|
||||
});
|
||||
});
|
||||
});
|
||||
15
apps/web/modules/api/v2/health/route.ts
Normal file
15
apps/web/modules/api/v2/health/route.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { responses } from "@/modules/api/v2/lib/response";
|
||||
import { performHealthChecks } from "./lib/health-checks";
|
||||
|
||||
export const GET = async () => {
|
||||
const healthStatusResult = await performHealthChecks();
|
||||
if (!healthStatusResult.ok) {
|
||||
return responses.serviceUnavailableResponse({
|
||||
details: healthStatusResult.error.details,
|
||||
});
|
||||
}
|
||||
|
||||
return responses.successResponse({
|
||||
data: healthStatusResult.data,
|
||||
});
|
||||
};
|
||||
22
apps/web/modules/api/v2/health/types/health-status.ts
Normal file
22
apps/web/modules/api/v2/health/types/health-status.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { z } from "zod";
|
||||
import { extendZodWithOpenApi } from "zod-openapi";
|
||||
|
||||
extendZodWithOpenApi(z);
|
||||
|
||||
export const ZOverallHealthStatus = z
|
||||
.object({
|
||||
main_database: z.boolean().openapi({
|
||||
description: "Main database connection status - true if database is reachable and running",
|
||||
example: true,
|
||||
}),
|
||||
cache_database: z.boolean().openapi({
|
||||
description: "Cache database connection status - true if cache database is reachable and running",
|
||||
example: true,
|
||||
}),
|
||||
})
|
||||
.openapi({
|
||||
title: "Health Check Response",
|
||||
description: "Health check status for critical application dependencies",
|
||||
});
|
||||
|
||||
export type OverallHealthStatus = z.infer<typeof ZOverallHealthStatus>;
|
||||
@@ -232,6 +232,35 @@ const internalServerErrorResponse = ({
|
||||
);
|
||||
};
|
||||
|
||||
const serviceUnavailableResponse = ({
|
||||
details = [],
|
||||
cors = false,
|
||||
cache = "private, no-store",
|
||||
}: {
|
||||
details?: ApiErrorDetails;
|
||||
cors?: boolean;
|
||||
cache?: string;
|
||||
} = {}) => {
|
||||
const headers = {
|
||||
...(cors && corsHeaders),
|
||||
"Cache-Control": cache,
|
||||
};
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
error: {
|
||||
code: 503,
|
||||
message: "Service Unavailable",
|
||||
details,
|
||||
},
|
||||
},
|
||||
{
|
||||
status: 503,
|
||||
headers,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const successResponse = ({
|
||||
data,
|
||||
meta,
|
||||
@@ -325,6 +354,7 @@ export const responses = {
|
||||
unprocessableEntityResponse,
|
||||
tooManyRequestsResponse,
|
||||
internalServerErrorResponse,
|
||||
serviceUnavailableResponse,
|
||||
successResponse,
|
||||
createdResponse,
|
||||
multiStatusResponse,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { ZodError } from "zod";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { formatZodError, handleApiError, logApiError, logApiRequest } from "../utils";
|
||||
|
||||
const mockRequest = new Request("http://localhost");
|
||||
@@ -12,6 +12,15 @@ mockRequest.headers.set("x-request-id", "123");
|
||||
|
||||
vi.mock("@sentry/nextjs", () => ({
|
||||
captureException: vi.fn(),
|
||||
withScope: vi.fn((callback: (scope: any) => void) => {
|
||||
const mockScope = {
|
||||
setTag: vi.fn(),
|
||||
setContext: vi.fn(),
|
||||
setLevel: vi.fn(),
|
||||
setExtra: vi.fn(),
|
||||
};
|
||||
callback(mockScope);
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock SENTRY_DSN constant
|
||||
@@ -232,7 +241,7 @@ describe("utils", () => {
|
||||
});
|
||||
|
||||
// Verify error was called on the child logger
|
||||
expect(errorMock).toHaveBeenCalledWith("API Error Details");
|
||||
expect(errorMock).toHaveBeenCalledWith("API V2 Error Details");
|
||||
|
||||
// Restore the original method
|
||||
logger.withContext = originalWithContext;
|
||||
@@ -266,7 +275,7 @@ describe("utils", () => {
|
||||
});
|
||||
|
||||
// Verify error was called on the child logger
|
||||
expect(errorMock).toHaveBeenCalledWith("API Error Details");
|
||||
expect(errorMock).toHaveBeenCalledWith("API V2 Error Details");
|
||||
|
||||
// Restore the original method
|
||||
logger.withContext = originalWithContext;
|
||||
@@ -303,7 +312,7 @@ describe("utils", () => {
|
||||
});
|
||||
|
||||
// Verify error was called on the child logger
|
||||
expect(errorMock).toHaveBeenCalledWith("API Error Details");
|
||||
expect(errorMock).toHaveBeenCalledWith("API V2 Error Details");
|
||||
|
||||
// Verify Sentry.captureException was called
|
||||
expect(Sentry.captureException).toHaveBeenCalled();
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// Function is this file can be used in edge runtime functions, like api routes.
|
||||
import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
|
||||
export const logApiErrorEdge = (request: Request, error: ApiErrorResponseV2): void => {
|
||||
const correlationId = request.headers.get("x-request-id") ?? "";
|
||||
@@ -10,14 +10,14 @@ export const logApiErrorEdge = (request: Request, error: ApiErrorResponseV2): vo
|
||||
// Send the error to Sentry if the DSN is set and the error type is internal_server_error
|
||||
// This is useful for tracking down issues without overloading Sentry with errors
|
||||
if (SENTRY_DSN && IS_PRODUCTION && error.type === "internal_server_error") {
|
||||
const err = new Error(`API V2 error, id: ${correlationId}`);
|
||||
// Use Sentry scope to add correlation ID as a tag for easy filtering
|
||||
Sentry.withScope((scope) => {
|
||||
scope.setTag("correlationId", correlationId);
|
||||
scope.setLevel("error");
|
||||
|
||||
Sentry.captureException(err, {
|
||||
extra: {
|
||||
details: error.details,
|
||||
type: error.type,
|
||||
correlationId,
|
||||
},
|
||||
scope.setExtra("originalError", error);
|
||||
const err = new Error(`API V2 error, id: ${correlationId}`);
|
||||
Sentry.captureException(err);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -26,5 +26,5 @@ export const logApiErrorEdge = (request: Request, error: ApiErrorResponseV2): vo
|
||||
correlationId,
|
||||
error,
|
||||
})
|
||||
.error("API Error Details");
|
||||
.error("API V2 Error Details");
|
||||
};
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
// @ts-nocheck // We can remove this when we update the prisma client and the typescript version
|
||||
// if we don't add this we get build errors with prisma due to type-nesting
|
||||
import { ZodCustomIssue, ZodIssue } from "zod";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TApiAuditLog } from "@/app/lib/api/with-api-logging";
|
||||
import { AUDIT_LOG_ENABLED } from "@/lib/constants";
|
||||
import { responses } from "@/modules/api/v2/lib/response";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { ZodCustomIssue, ZodIssue } from "zod";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { logApiErrorEdge } from "./utils-edge";
|
||||
|
||||
export const handleApiError = (
|
||||
request: Request,
|
||||
err: ApiErrorResponseV2,
|
||||
auditLog?: ApiAuditLog
|
||||
auditLog?: TApiAuditLog
|
||||
): Response => {
|
||||
logApiError(request, err, auditLog);
|
||||
|
||||
@@ -55,7 +56,7 @@ export const formatZodError = (error: { issues: (ZodIssue | ZodCustomIssue)[] })
|
||||
});
|
||||
};
|
||||
|
||||
export const logApiRequest = (request: Request, responseStatus: number, auditLog?: ApiAuditLog): void => {
|
||||
export const logApiRequest = (request: Request, responseStatus: number, auditLog?: TApiAuditLog): void => {
|
||||
const method = request.method;
|
||||
const url = new URL(request.url);
|
||||
const path = url.pathname;
|
||||
@@ -82,13 +83,13 @@ export const logApiRequest = (request: Request, responseStatus: number, auditLog
|
||||
logAuditLog(request, auditLog);
|
||||
};
|
||||
|
||||
export const logApiError = (request: Request, error: ApiErrorResponseV2, auditLog?: ApiAuditLog): void => {
|
||||
export const logApiError = (request: Request, error: ApiErrorResponseV2, auditLog?: TApiAuditLog): void => {
|
||||
logApiErrorEdge(request, error);
|
||||
|
||||
logAuditLog(request, auditLog);
|
||||
};
|
||||
|
||||
const logAuditLog = (request: Request, auditLog?: ApiAuditLog): void => {
|
||||
const logAuditLog = (request: Request, auditLog?: TApiAuditLog): void => {
|
||||
if (AUDIT_LOG_ENABLED && auditLog) {
|
||||
const correlationId = request.headers.get("x-request-id") ?? "";
|
||||
queueAuditEvent({
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { ZContactLinkParams } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/types/survey";
|
||||
import { makePartialSchema } from "@/modules/api/v2/types/openapi-response";
|
||||
import { z } from "zod";
|
||||
import { ZodOpenApiOperationObject } from "zod-openapi";
|
||||
import {
|
||||
ZContactLinkParams,
|
||||
ZContactLinkQuery,
|
||||
} from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/types/survey";
|
||||
import { makePartialSchema } from "@/modules/api/v2/types/openapi-response";
|
||||
|
||||
export const getPersonalizedSurveyLink: ZodOpenApiOperationObject = {
|
||||
operationId: "getPersonalizedSurveyLink",
|
||||
@@ -9,6 +12,7 @@ export const getPersonalizedSurveyLink: ZodOpenApiOperationObject = {
|
||||
description: "Retrieves a personalized link for a specific survey.",
|
||||
requestParams: {
|
||||
path: ZContactLinkParams,
|
||||
query: ZContactLinkQuery,
|
||||
},
|
||||
tags: ["Management API - Surveys - Contact Links"],
|
||||
responses: {
|
||||
@@ -20,6 +24,10 @@ export const getPersonalizedSurveyLink: ZodOpenApiOperationObject = {
|
||||
z.object({
|
||||
data: z.object({
|
||||
surveyUrl: z.string().url(),
|
||||
expiresAt: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe("The date and time the link expires, null if no expiration"),
|
||||
}),
|
||||
})
|
||||
),
|
||||
|
||||
@@ -8,7 +8,9 @@ import { getSurvey } from "@/modules/api/v2/management/surveys/[surveyId]/contac
|
||||
import {
|
||||
TContactLinkParams,
|
||||
ZContactLinkParams,
|
||||
ZContactLinkQuery,
|
||||
} from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/types/survey";
|
||||
import { calculateExpirationDate } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/lib/utils";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { getContactSurveyLink } from "@/modules/ee/contacts/lib/contact-survey-link";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
@@ -19,9 +21,10 @@ export const GET = async (request: Request, props: { params: Promise<TContactLin
|
||||
externalParams: props.params,
|
||||
schemas: {
|
||||
params: ZContactLinkParams,
|
||||
query: ZContactLinkQuery,
|
||||
},
|
||||
handler: async ({ authentication, parsedInput }) => {
|
||||
const { params } = parsedInput;
|
||||
const { params, query } = parsedInput;
|
||||
|
||||
if (!params) {
|
||||
return handleApiError(request, {
|
||||
@@ -92,12 +95,27 @@ export const GET = async (request: Request, props: { params: Promise<TContactLin
|
||||
});
|
||||
}
|
||||
|
||||
const surveyUrlResult = await getContactSurveyLink(params.contactId, params.surveyId, 7);
|
||||
// Calculate expiration date based on expirationDays
|
||||
let expiresAt: string | null = null;
|
||||
if (query?.expirationDays) {
|
||||
expiresAt = calculateExpirationDate(query.expirationDays);
|
||||
}
|
||||
|
||||
const surveyUrlResult = await getContactSurveyLink(
|
||||
params.contactId,
|
||||
params.surveyId,
|
||||
query?.expirationDays || undefined
|
||||
);
|
||||
|
||||
if (!surveyUrlResult.ok) {
|
||||
return handleApiError(request, surveyUrlResult.error);
|
||||
}
|
||||
|
||||
return responses.successResponse({ data: { surveyUrl: surveyUrlResult.data } });
|
||||
return responses.successResponse({
|
||||
data: {
|
||||
surveyUrl: surveyUrlResult.data,
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -20,4 +20,15 @@ export const ZContactLinkParams = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
export const ZContactLinkQuery = z.object({
|
||||
expirationDays: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(365)
|
||||
.optional()
|
||||
.describe("Number of days until the generated JWT expires. If not provided, there is no expiration."),
|
||||
});
|
||||
|
||||
export type TContactLinkParams = z.infer<typeof ZContactLinkParams>;
|
||||
export type TContactLinkQuery = z.infer<typeof ZContactLinkQuery>;
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { calculateExpirationDate } from "./utils";
|
||||
|
||||
describe("calculateExpirationDate", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test("calculates expiration date for positive days", () => {
|
||||
const baseDate = new Date("2024-01-15T12:00:00.000Z");
|
||||
vi.setSystemTime(baseDate);
|
||||
|
||||
const result = calculateExpirationDate(7);
|
||||
const expectedDate = new Date("2024-01-22T12:00:00.000Z");
|
||||
|
||||
expect(result).toBe(expectedDate.toISOString());
|
||||
});
|
||||
|
||||
test("handles zero expiration days", () => {
|
||||
const baseDate = new Date("2024-01-15T12:00:00.000Z");
|
||||
vi.setSystemTime(baseDate);
|
||||
|
||||
const result = calculateExpirationDate(0);
|
||||
|
||||
expect(result).toBe(baseDate.toISOString());
|
||||
});
|
||||
|
||||
test("handles negative expiration days", () => {
|
||||
const baseDate = new Date("2024-01-15T12:00:00.000Z");
|
||||
vi.setSystemTime(baseDate);
|
||||
|
||||
const result = calculateExpirationDate(-5);
|
||||
const expectedDate = new Date("2024-01-10T12:00:00.000Z");
|
||||
|
||||
expect(result).toBe(expectedDate.toISOString());
|
||||
});
|
||||
|
||||
test("returns valid ISO string format", () => {
|
||||
const baseDate = new Date("2024-01-15T12:00:00.000Z");
|
||||
vi.setSystemTime(baseDate);
|
||||
|
||||
const result = calculateExpirationDate(10);
|
||||
const isoRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
||||
|
||||
expect(result).toMatch(isoRegex);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
export const calculateExpirationDate = (expirationDays: number) => {
|
||||
const expirationDate = new Date();
|
||||
expirationDate.setDate(expirationDate.getDate() + expirationDays);
|
||||
return expirationDate.toISOString();
|
||||
};
|
||||
@@ -1,7 +1,9 @@
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
|
||||
import { responses } from "@/modules/api/v2/lib/response";
|
||||
import { handleApiError } from "@/modules/api/v2/lib/utils";
|
||||
import { getEnvironmentId } from "@/modules/api/v2/management/lib/helper";
|
||||
import { calculateExpirationDate } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/lib/utils";
|
||||
import { getContactsInSegment } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/lib/contact";
|
||||
import {
|
||||
ZContactLinksBySegmentParams,
|
||||
@@ -11,7 +13,6 @@ import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { getContactSurveyLink } from "@/modules/ee/contacts/lib/contact-survey-link";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { logger } from "@formbricks/logger";
|
||||
|
||||
export const GET = async (
|
||||
request: Request,
|
||||
@@ -76,9 +77,7 @@ export const GET = async (
|
||||
// Calculate expiration date based on expirationDays
|
||||
let expiresAt: string | null = null;
|
||||
if (query?.expirationDays) {
|
||||
const expirationDate = new Date();
|
||||
expirationDate.setDate(expirationDate.getDate() + query.expirationDays);
|
||||
expiresAt = expirationDate.toISOString();
|
||||
expiresAt = calculateExpirationDate(query.expirationDays);
|
||||
}
|
||||
|
||||
// Generate survey links for each contact
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { healthPaths } from "@/modules/api/v2/health/lib/openapi";
|
||||
import { ZOverallHealthStatus } from "@/modules/api/v2/health/types/health-status";
|
||||
import { contactAttributeKeyPaths } from "@/modules/api/v2/management/contact-attribute-keys/lib/openapi";
|
||||
import { responsePaths } from "@/modules/api/v2/management/responses/lib/openapi";
|
||||
import { surveyContactLinksBySegmentPaths } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/lib/openapi";
|
||||
@@ -35,6 +37,7 @@ const document = createDocument({
|
||||
version: "2.0.0",
|
||||
},
|
||||
paths: {
|
||||
...healthPaths,
|
||||
...rolePaths,
|
||||
...mePaths,
|
||||
...responsePaths,
|
||||
@@ -55,6 +58,10 @@ const document = createDocument({
|
||||
},
|
||||
],
|
||||
tags: [
|
||||
{
|
||||
name: "Health",
|
||||
description: "Operations for checking critical application dependencies health status.",
|
||||
},
|
||||
{
|
||||
name: "Roles",
|
||||
description: "Operations for managing roles.",
|
||||
@@ -114,6 +121,7 @@ const document = createDocument({
|
||||
},
|
||||
},
|
||||
schemas: {
|
||||
health: ZOverallHealthStatus,
|
||||
role: ZRoles,
|
||||
me: ZApiKeyData,
|
||||
response: ZResponse,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { randomBytes } from "crypto";
|
||||
import { Provider } from "next-auth/providers/index";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { EMAIL_VERIFICATION_DISABLED } from "@/lib/constants";
|
||||
import { createToken } from "@/lib/jwt";
|
||||
// Import mocked rate limiting functions
|
||||
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import { randomBytes } from "crypto";
|
||||
import { Provider } from "next-auth/providers/index";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { authOptions } from "./authOptions";
|
||||
import { mockUser } from "./mock-data";
|
||||
import { hashPassword } from "./utils";
|
||||
@@ -31,7 +31,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
SESSION_MAX_AGE: 86400,
|
||||
NEXTAUTH_SECRET: "test-secret",
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
ENCRYPTION_KEY: "test-encryption-key-32-chars-long",
|
||||
ENCRYPTION_KEY: "12345678901234567890123456789012", // 32 bytes for AES-256
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: false,
|
||||
AUDIT_LOG_GET_USER_IP: false,
|
||||
@@ -261,7 +261,7 @@ describe("authOptions", () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
|
||||
vi.spyOn(prisma.user, "findUnique").mockResolvedValue(mockUser as any);
|
||||
|
||||
const credentials = { token: createToken(mockUser.id, mockUser.email) };
|
||||
const credentials = { token: createToken(mockUser.id) };
|
||||
|
||||
await expect(tokenProvider.options.authorize(credentials, {})).rejects.toThrow(
|
||||
"Email already verified"
|
||||
@@ -280,7 +280,7 @@ describe("authOptions", () => {
|
||||
groupId: null,
|
||||
} as any);
|
||||
|
||||
const credentials = { token: createToken(mockUserId, mockUser.email) };
|
||||
const credentials = { token: createToken(mockUserId) };
|
||||
|
||||
const result = await tokenProvider.options.authorize(credentials, {});
|
||||
expect(result.email).toBe(mockUser.email);
|
||||
@@ -303,7 +303,7 @@ describe("authOptions", () => {
|
||||
groupId: null,
|
||||
} as any);
|
||||
|
||||
const credentials = { token: createToken(mockUserId, mockUser.email) };
|
||||
const credentials = { token: createToken(mockUserId) };
|
||||
|
||||
await tokenProvider.options.authorize(credentials, {});
|
||||
|
||||
@@ -315,7 +315,7 @@ describe("authOptions", () => {
|
||||
new Error("Maximum number of requests reached. Please try again later.")
|
||||
);
|
||||
|
||||
const credentials = { token: createToken(mockUserId, mockUser.email) };
|
||||
const credentials = { token: createToken(mockUserId) };
|
||||
|
||||
await expect(tokenProvider.options.authorize(credentials, {})).rejects.toThrow(
|
||||
"Maximum number of requests reached. Please try again later."
|
||||
@@ -339,7 +339,7 @@ describe("authOptions", () => {
|
||||
groupId: null,
|
||||
} as any);
|
||||
|
||||
const credentials = { token: createToken(mockUserId, mockUser.email) };
|
||||
const credentials = { token: createToken(mockUserId) };
|
||||
|
||||
await tokenProvider.options.authorize(credentials, {});
|
||||
|
||||
|
||||
@@ -66,8 +66,21 @@ export const authOptions: NextAuthOptions = {
|
||||
throw new Error("Invalid credentials");
|
||||
}
|
||||
|
||||
// Validate password length to prevent CPU DoS attacks
|
||||
// bcrypt processes passwords up to 72 bytes, but we limit to 128 characters for security
|
||||
if (credentials.password && credentials.password.length > 128) {
|
||||
if (await shouldLogAuthFailure(identifier)) {
|
||||
logAuthAttempt("password_too_long", "credentials", "password_validation", UNKNOWN_DATA, credentials?.email);
|
||||
}
|
||||
throw new Error("Invalid credentials");
|
||||
}
|
||||
|
||||
// Use a control hash when user doesn't exist to maintain constant timing.
|
||||
const controlHash = "$2b$12$fzHf9le13Ss9UJ04xzmsjODXpFJxz6vsnupoepF5FiqDECkX2BH5q";
|
||||
|
||||
let user;
|
||||
try {
|
||||
// Perform database lookup
|
||||
user = await prisma.user.findUnique({
|
||||
where: {
|
||||
email: credentials?.email,
|
||||
@@ -79,6 +92,12 @@ export const authOptions: NextAuthOptions = {
|
||||
throw Error("Internal server error. Please try again later");
|
||||
}
|
||||
|
||||
// Always perform password verification to maintain constant timing. This is important to prevent timing attacks for user enumeration.
|
||||
// Use actual hash if user exists, control hash if user doesn't exist
|
||||
const hashToVerify = user?.password || controlHash;
|
||||
const isValid = await verifyPassword(credentials.password, hashToVerify);
|
||||
|
||||
// Now check all conditions after constant-time operations are complete
|
||||
if (!user) {
|
||||
if (await shouldLogAuthFailure(identifier)) {
|
||||
logAuthAttempt("user_not_found", "credentials", "user_lookup", UNKNOWN_DATA, credentials?.email);
|
||||
@@ -96,8 +115,6 @@ export const authOptions: NextAuthOptions = {
|
||||
throw new Error("Your account is currently inactive. Please contact the organization admin.");
|
||||
}
|
||||
|
||||
const isValid = await verifyPassword(credentials.password, user.password);
|
||||
|
||||
if (!isValid) {
|
||||
if (await shouldLogAuthFailure(user.email)) {
|
||||
logAuthAttempt("invalid_password", "credentials", "password_validation", user.id, user.email);
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { signIn } from "next-auth/react";
|
||||
import Link from "next/dist/client/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { FORMBRICKS_LOGGED_IN_WITH_LS } from "@/lib/localStorage";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
@@ -10,19 +19,13 @@ import { TwoFactorBackup } from "@/modules/ee/two-factor-auth/components/two-fac
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { FormControl, FormError, FormField, FormItem } from "@/modules/ui/components/form";
|
||||
import { PasswordInput } from "@/modules/ui/components/password-input";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { signIn } from "next-auth/react";
|
||||
import Link from "next/dist/client/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
|
||||
const ZLoginForm = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8),
|
||||
password: z
|
||||
.string()
|
||||
.min(8, { message: "Password must be at least 8 characters long" })
|
||||
.max(128, { message: "Password must be 128 characters or less" }),
|
||||
totpCode: z.string().optional(),
|
||||
backupCode: z.string().optional(),
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export const rateLimitConfigs = {
|
||||
// Authentication endpoints - stricter limits for security
|
||||
auth: {
|
||||
login: { interval: 900, allowedPerInterval: 30, namespace: "auth:login" }, // 30 per 15 minutes
|
||||
login: { interval: 900, allowedPerInterval: 10, namespace: "auth:login" }, // 10 per 15 minutes
|
||||
signup: { interval: 3600, allowedPerInterval: 30, namespace: "auth:signup" }, // 30 per hour
|
||||
forgotPassword: { interval: 3600, allowedPerInterval: 5, namespace: "auth:forgot" }, // 5 per hour
|
||||
verifyEmail: { interval: 3600, allowedPerInterval: 10, namespace: "auth:verify" }, // 10 per hour
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/cn";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { ConfirmationModal } from "@/modules/ui/components/confirmation-modal";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { TOrganization, TOrganizationBillingPeriod } from "@formbricks/types/organizations";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { ConfirmationModal } from "@/modules/ui/components/confirmation-modal";
|
||||
import { TPricingPlan } from "../api/lib/constants";
|
||||
|
||||
interface PricingCardProps {
|
||||
@@ -170,14 +170,13 @@ export const PricingCard = ({
|
||||
|
||||
{plan.id !== projectFeatureKeys.FREE && isCurrentPlan && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
loading={loading}
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
await onManageSubscription();
|
||||
setLoading(false);
|
||||
}}
|
||||
className="flex justify-center">
|
||||
className="flex justify-center bg-[#635bff]">
|
||||
{t("environments.settings.billing.manage_subscription")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/cn";
|
||||
import { capitalizeFirstLetter } from "@/lib/utils/strings";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { TOrganization, TOrganizationBillingPeriod } from "@formbricks/types/organizations";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { isSubscriptionCancelledAction, manageSubscriptionAction, upgradePlanAction } from "../actions";
|
||||
import { getCloudPricingData } from "../api/lib/constants";
|
||||
import { BillingSlider } from "./billing-slider";
|
||||
@@ -141,7 +140,7 @@ export const PricingTable = ({
|
||||
<div className="flex w-full">
|
||||
<h2 className="mb-3 mr-2 inline-flex w-full text-2xl font-bold text-slate-700">
|
||||
{t("environments.settings.billing.current_plan")}:{" "}
|
||||
{capitalizeFirstLetter(organization.billing.plan)}
|
||||
<span className="capitalize">{organization.billing.plan}</span>
|
||||
{cancellingOn && (
|
||||
<Badge
|
||||
className="mx-2"
|
||||
@@ -175,7 +174,7 @@ export const PricingTable = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-col rounded-xl border border-slate-200 bg-white py-4 capitalize shadow-sm dark:bg-slate-800">
|
||||
<div className="mt-2 flex flex-col rounded-xl border border-slate-200 bg-white py-4 shadow-sm dark:bg-slate-800">
|
||||
<div
|
||||
className={cn(
|
||||
"relative mx-8 mb-8 flex flex-col gap-4",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { getResponsesByContactId } from "@/lib/response/service";
|
||||
import { capitalizeFirstLetter } from "@/lib/utils/strings";
|
||||
import { getContactAttributes } from "@/modules/ee/contacts/lib/contact-attributes";
|
||||
import { getContact } from "@/modules/ee/contacts/lib/contacts";
|
||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||
@@ -59,7 +58,7 @@ export const AttributesSection = async ({ contactId }: { contactId: string }) =>
|
||||
.map(([key, attributeData]) => {
|
||||
return (
|
||||
<div key={key}>
|
||||
<dt className="text-sm font-medium text-slate-500">{capitalizeFirstLetter(key.toString())}</dt>
|
||||
<dt className="text-sm font-medium text-slate-500">{key.toString()}</dt>
|
||||
<dd className="mt-1 text-sm text-slate-900">{attributeData}</dd>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import { cache } from "@/lib/cache";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { evaluateSegment } from "@/modules/ee/contacts/segments/lib/segments";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { createCacheKey } from "@formbricks/cache";
|
||||
@@ -9,6 +6,9 @@ import { logger } from "@formbricks/logger";
|
||||
import { ZId, ZString } from "@formbricks/types/common";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { TBaseFilter } from "@formbricks/types/segment";
|
||||
import { cache } from "@/lib/cache";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { evaluateSegment } from "@/modules/ee/contacts/segments/lib/segments";
|
||||
|
||||
export const getSegments = reactCache(
|
||||
async (environmentId: string) =>
|
||||
@@ -34,7 +34,7 @@ export const getSegments = reactCache(
|
||||
}
|
||||
},
|
||||
createCacheKey.environment.segments(environmentId),
|
||||
5 * 60 * 1000 // 5 minutes in milliseconds
|
||||
60 * 1000 // 1 minutes in milliseconds
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
@@ -1,34 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/cn";
|
||||
import { structuredClone } from "@/lib/pollyfills/structuredClone";
|
||||
import { isCapitalized } from "@/lib/utils/strings";
|
||||
import {
|
||||
convertOperatorToText,
|
||||
convertOperatorToTitle,
|
||||
toggleFilterConnector,
|
||||
updateContactAttributeKeyInFilter,
|
||||
updateDeviceTypeInFilter,
|
||||
updateFilterValue,
|
||||
updateOperatorInFilter,
|
||||
updatePersonIdentifierInFilter,
|
||||
updateSegmentIdInFilter,
|
||||
} from "@/modules/ee/contacts/segments/lib/utils";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
@@ -64,6 +35,35 @@ import {
|
||||
DEVICE_OPERATORS,
|
||||
PERSON_OPERATORS,
|
||||
} from "@formbricks/types/segment";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { structuredClone } from "@/lib/pollyfills/structuredClone";
|
||||
import { isCapitalized } from "@/lib/utils/strings";
|
||||
import {
|
||||
convertOperatorToText,
|
||||
convertOperatorToTitle,
|
||||
toggleFilterConnector,
|
||||
updateContactAttributeKeyInFilter,
|
||||
updateDeviceTypeInFilter,
|
||||
updateFilterValue,
|
||||
updateOperatorInFilter,
|
||||
updatePersonIdentifierInFilter,
|
||||
updateSegmentIdInFilter,
|
||||
} from "@/modules/ee/contacts/segments/lib/utils";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
import { AddFilterModal } from "./add-filter-modal";
|
||||
|
||||
interface TSegmentFilterProps {
|
||||
@@ -314,7 +314,7 @@ function AttributeSegmentFilter({
|
||||
}}
|
||||
value={attrKeyValue}>
|
||||
<SelectTrigger
|
||||
className="flex w-auto items-center justify-center whitespace-nowrap bg-white capitalize"
|
||||
className="flex w-auto items-center justify-center whitespace-nowrap bg-white"
|
||||
hideArrow>
|
||||
<SelectValue>
|
||||
<div className={cn("flex items-center gap-2", !isCapitalized(attrKeyValue ?? "") && "lowercase")}>
|
||||
@@ -496,7 +496,7 @@ function PersonSegmentFilter({
|
||||
}}
|
||||
value={personIdentifier}>
|
||||
<SelectTrigger
|
||||
className="flex w-auto items-center justify-center whitespace-nowrap bg-white capitalize"
|
||||
className="flex w-auto items-center justify-center whitespace-nowrap bg-white"
|
||||
hideArrow>
|
||||
<SelectValue>
|
||||
<div className="flex items-center gap-1 lowercase">
|
||||
@@ -647,7 +647,7 @@ function SegmentSegmentFilter({
|
||||
}}
|
||||
value={currentSegment?.id}>
|
||||
<SelectTrigger
|
||||
className="flex w-auto items-center justify-center whitespace-nowrap bg-white capitalize"
|
||||
className="flex w-auto items-center justify-center whitespace-nowrap bg-white"
|
||||
hideArrow>
|
||||
<div className="flex items-center gap-1">
|
||||
<Users2Icon className="h-4 w-4 text-sm" />
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { CopyIcon, Trash2Icon } from "lucide-react";
|
||||
import { TSurveyQuota } from "@formbricks/types/quota";
|
||||
@@ -37,26 +38,30 @@ export const QuotaList = ({ quotas, onEdit, deleteQuota, duplicateQuota }: Quota
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteQuota(quota);
|
||||
}}
|
||||
className="h-8 w-8 p-0 text-slate-500">
|
||||
<Trash2Icon className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
duplicateQuota(quota);
|
||||
}}
|
||||
className="h-8 w-8 p-0 text-slate-500">
|
||||
<CopyIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<TooltipRenderer tooltipContent={t("common.delete")}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteQuota(quota);
|
||||
}}
|
||||
className="h-8 w-8 p-0 text-slate-500">
|
||||
<Trash2Icon className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipRenderer>
|
||||
<TooltipRenderer tooltipContent={t("common.duplicate")}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
duplicateQuota(quota);
|
||||
}}
|
||||
className="h-8 w-8 p-0 text-slate-500">
|
||||
<CopyIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipRenderer>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -25,6 +25,15 @@ vi.mock("../actions", () => ({
|
||||
updateInviteAction: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/membership/utils", () => ({
|
||||
getAccessFlags: (role: string) => ({
|
||||
isOwner: role === "owner",
|
||||
isManager: role === "manager",
|
||||
isMember: role === "member",
|
||||
isBilling: role === "billing",
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("EditMembershipRole Component", () => {
|
||||
const mockRouter = {
|
||||
refresh: vi.fn(),
|
||||
@@ -53,15 +62,21 @@ describe("EditMembershipRole Component", () => {
|
||||
|
||||
describe("Rendering", () => {
|
||||
test("renders a dropdown when user is owner", () => {
|
||||
render(<EditMembershipRole {...defaultProps} />);
|
||||
render(<EditMembershipRole {...defaultProps} isUserManagementDisabledFromUi={false} />);
|
||||
|
||||
const button = screen.queryByRole("button-role");
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button).toHaveTextContent("Member");
|
||||
expect(button).toHaveTextContent("member");
|
||||
});
|
||||
|
||||
test("renders a badge when user is not owner or manager", () => {
|
||||
render(<EditMembershipRole {...defaultProps} currentUserRole="member" />);
|
||||
render(
|
||||
<EditMembershipRole
|
||||
{...defaultProps}
|
||||
currentUserRole="member"
|
||||
isUserManagementDisabledFromUi={false}
|
||||
/>
|
||||
);
|
||||
|
||||
const badge = screen.queryByRole("badge-role");
|
||||
expect(badge).toBeInTheDocument();
|
||||
@@ -70,21 +85,42 @@ describe("EditMembershipRole Component", () => {
|
||||
});
|
||||
|
||||
test("disables the dropdown when editing own role", () => {
|
||||
render(<EditMembershipRole {...defaultProps} memberId="user-456" userId="user-456" />);
|
||||
render(
|
||||
<EditMembershipRole
|
||||
{...defaultProps}
|
||||
memberId="user-456"
|
||||
userId="user-456"
|
||||
isUserManagementDisabledFromUi={false}
|
||||
/>
|
||||
);
|
||||
|
||||
const button = screen.getByRole("button-role");
|
||||
expect(button).toBeDisabled();
|
||||
});
|
||||
|
||||
test("disables the dropdown when the user is the only owner", () => {
|
||||
render(<EditMembershipRole {...defaultProps} memberRole="owner" doesOrgHaveMoreThanOneOwner={false} />);
|
||||
render(
|
||||
<EditMembershipRole
|
||||
{...defaultProps}
|
||||
memberRole="owner"
|
||||
doesOrgHaveMoreThanOneOwner={false}
|
||||
isUserManagementDisabledFromUi={false}
|
||||
/>
|
||||
);
|
||||
|
||||
const button = screen.getByRole("button-role");
|
||||
expect(button).toBeDisabled();
|
||||
});
|
||||
|
||||
test("disables the dropdown when a manager tries to edit an owner", () => {
|
||||
render(<EditMembershipRole {...defaultProps} currentUserRole="manager" memberRole="owner" />);
|
||||
render(
|
||||
<EditMembershipRole
|
||||
{...defaultProps}
|
||||
currentUserRole="manager"
|
||||
memberRole="owner"
|
||||
isUserManagementDisabledFromUi={false}
|
||||
/>
|
||||
);
|
||||
|
||||
const button = screen.getByRole("button-role");
|
||||
expect(button).toBeDisabled();
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import type { TOrganizationRole } from "@formbricks/types/memberships";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { capitalizeFirstLetter } from "@/lib/utils/strings";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
@@ -11,12 +16,6 @@ import {
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import type { TOrganizationRole } from "@formbricks/types/memberships";
|
||||
import { updateInviteAction, updateMembershipAction } from "../actions";
|
||||
|
||||
interface Role {
|
||||
@@ -104,7 +103,7 @@ export function EditMembershipRole({
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
role="button-role">
|
||||
<span className="ml-1">{capitalizeFirstLetter(memberRole)}</span>
|
||||
<span className="ml-1 capitalize">{memberRole}</span>
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -128,5 +127,5 @@ export function EditMembershipRole({
|
||||
);
|
||||
}
|
||||
|
||||
return <Badge size="tiny" type="gray" role="badge-role" text={capitalizeFirstLetter(memberRole)} />;
|
||||
return <Badge size="tiny" type="gray" role="badge-role" text={memberRole} className="capitalize" />;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
import { render } from "@react-email/render";
|
||||
import { createTransport } from "nodemailer";
|
||||
import type SMTPTransport from "nodemailer/lib/smtp-transport";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import type { TLinkSurveyEmailData } from "@formbricks/types/email";
|
||||
import { InvalidInputError } from "@formbricks/types/errors";
|
||||
import type { TResponse } from "@formbricks/types/responses";
|
||||
import type { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserEmail, TUserLocale } from "@formbricks/types/user";
|
||||
import {
|
||||
DEBUG,
|
||||
MAIL_FROM,
|
||||
@@ -17,15 +26,6 @@ import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import NewEmailVerification from "@/modules/email/emails/auth/new-email-verification";
|
||||
import { EmailCustomizationPreviewEmail } from "@/modules/email/emails/general/email-customization-preview-email";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
import { render } from "@react-email/render";
|
||||
import { createTransport } from "nodemailer";
|
||||
import type SMTPTransport from "nodemailer/lib/smtp-transport";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import type { TLinkSurveyEmailData } from "@formbricks/types/email";
|
||||
import { InvalidInputError } from "@formbricks/types/errors";
|
||||
import type { TResponse } from "@formbricks/types/responses";
|
||||
import type { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserEmail, TUserLocale } from "@formbricks/types/user";
|
||||
import { ForgotPasswordEmail } from "./emails/auth/forgot-password-email";
|
||||
import { PasswordResetNotifyEmail } from "./emails/auth/password-reset-notify-email";
|
||||
import { VerificationEmail } from "./emails/auth/verification-email";
|
||||
@@ -111,7 +111,7 @@ export const sendVerificationEmail = async ({
|
||||
}): Promise<boolean> => {
|
||||
try {
|
||||
const t = await getTranslate();
|
||||
const token = createToken(id, email, {
|
||||
const token = createToken(id, {
|
||||
expiresIn: "1d",
|
||||
});
|
||||
const verifyLink = `${WEBAPP_URL}/auth/verify?token=${encodeURIComponent(token)}`;
|
||||
@@ -136,7 +136,7 @@ export const sendForgotPasswordEmail = async (user: {
|
||||
locale: TUserLocale;
|
||||
}): Promise<boolean> => {
|
||||
const t = await getTranslate();
|
||||
const token = createToken(user.id, user.email, {
|
||||
const token = createToken(user.id, {
|
||||
expiresIn: "1d",
|
||||
});
|
||||
const verifyLink = `${WEBAPP_URL}/auth/forgot-password/reset?token=${encodeURIComponent(token)}`;
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { convertDateTimeStringShort } from "@/lib/time";
|
||||
import { capitalizeFirstLetter } from "@/lib/utils/strings";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { Webhook } from "@prisma/client";
|
||||
import { TFnType, useTranslate } from "@tolgee/react";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { convertDateTimeStringShort } from "@/lib/time";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
|
||||
interface ActivityTabProps {
|
||||
webhook: Webhook;
|
||||
@@ -50,8 +49,8 @@ export const WebhookOverviewTab = ({ webhook, surveys }: ActivityTabProps) => {
|
||||
<Label className="text-slate-500">
|
||||
{t("environments.integrations.webhooks.created_by_third_party")}
|
||||
</Label>
|
||||
<p className="text-sm text-slate-900">
|
||||
{webhook.source === "user" ? "No" : capitalizeFirstLetter(webhook.source)}
|
||||
<p className="text-sm capitalize text-slate-900">
|
||||
{webhook.source === "user" ? "No" : webhook.source}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { timeSince } from "@/lib/time";
|
||||
import { capitalizeFirstLetter } from "@/lib/utils/strings";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Webhook } from "@prisma/client";
|
||||
import { TFnType, useTranslate } from "@tolgee/react";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { timeSince } from "@/lib/time";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
|
||||
const renderSelectedSurveysText = (webhook: Webhook, allSurveys: TSurvey[]) => {
|
||||
if (webhook.surveyIds.length === 0) {
|
||||
@@ -82,7 +81,7 @@ export const WebhookRowData = ({
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-1 my-auto text-center text-sm text-slate-800">
|
||||
<Badge type="gray" size="tiny" text={capitalizeFirstLetter(webhook.source) || t("common.user")} />
|
||||
<Badge type="gray" size="tiny" text={webhook.source || t("common.user")} className="capitalize" />
|
||||
</div>
|
||||
<div className="col-span-4 my-auto text-center text-sm text-slate-800">
|
||||
{renderSelectedSurveysText(webhook, surveys)}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { getOrganizationAccessKeyDisplayName } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { TOrganizationProject } from "@/modules/organization/settings/api-keys/types/api-keys";
|
||||
import { Alert, AlertTitle } from "@/modules/ui/components/alert";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
@@ -24,7 +23,7 @@ import { Switch } from "@/modules/ui/components/switch";
|
||||
import { ApiKeyPermission } from "@prisma/client";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { ChevronDownIcon, Trash2Icon } from "lucide-react";
|
||||
import { Fragment, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { TOrganizationAccess } from "@formbricks/types/api-key";
|
||||
@@ -220,10 +219,10 @@ export const AddApiKeyModal = ({
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("environments.project.api_keys.add_api_key")}</DialogTitle>
|
||||
<DialogTitle className="px-1">{t("environments.project.api_keys.add_api_key")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit(submitAPIKey)} className="contents">
|
||||
<DialogBody className="space-y-4 overflow-y-auto py-4">
|
||||
<DialogBody className="space-y-4 overflow-y-auto px-1 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label>{t("environments.project.api_keys.api_key_label")}</Label>
|
||||
<Input
|
||||
@@ -348,43 +347,31 @@ export const AddApiKeyModal = ({
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>{t("environments.project.api_keys.organization_access")}</Label>
|
||||
<p className="text-sm text-slate-500">
|
||||
{t("environments.project.api_keys.organization_access_description")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-[auto_100px_100px] gap-4">
|
||||
<div></div>
|
||||
<span className="flex items-center justify-center text-sm font-medium">Read</span>
|
||||
<span className="flex items-center justify-center text-sm font-medium">Write</span>
|
||||
|
||||
{Object.keys(selectedOrganizationAccess).map((key) => (
|
||||
<Fragment key={key}>
|
||||
<div className="py-1 text-sm">{getOrganizationAccessKeyDisplayName(key, t)}</div>
|
||||
<div className="flex items-center justify-center py-1">
|
||||
<Switch
|
||||
data-testid={`organization-access-${key}-read`}
|
||||
checked={selectedOrganizationAccess[key].read}
|
||||
onCheckedChange={(newVal) =>
|
||||
setSelectedOrganizationAccessValue(key, "read", newVal)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-center py-1">
|
||||
<Switch
|
||||
data-testid={`organization-access-${key}-write`}
|
||||
checked={selectedOrganizationAccess[key].write}
|
||||
onCheckedChange={(newVal) =>
|
||||
setSelectedOrganizationAccessValue(key, "write", newVal)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Fragment>
|
||||
))}
|
||||
<Label>{t("environments.project.api_keys.organization_access")}</Label>
|
||||
{Object.keys(selectedOrganizationAccess).map((key) => (
|
||||
<div key={key} className="mt-2 flex items-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label>Read</Label>
|
||||
<Switch
|
||||
data-testid={`organization-access-${key}-read`}
|
||||
checked={selectedOrganizationAccess[key].read || selectedOrganizationAccess[key].write}
|
||||
onCheckedChange={(newVal) => setSelectedOrganizationAccessValue(key, "read", newVal)}
|
||||
disabled={selectedOrganizationAccess[key].write}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label>Write</Label>
|
||||
<Switch
|
||||
data-testid={`organization-access-${key}-write`}
|
||||
checked={selectedOrganizationAccess[key].write}
|
||||
onCheckedChange={(newVal) => setSelectedOrganizationAccessValue(key, "write", newVal)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<p className="text-sm text-slate-500">
|
||||
{t("environments.project.api_keys.organization_access_description")}
|
||||
</p>
|
||||
</div>
|
||||
<Alert variant="warning">
|
||||
<AlertTitle>{t("environments.project.api_keys.api_key_security_warning")}</AlertTitle>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { ApiKeyPermission } from "@prisma/client";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TProject } from "@formbricks/types/project";
|
||||
import { TApiKeyWithEnvironmentPermission } from "../types/api-keys";
|
||||
@@ -104,6 +103,8 @@ describe("ViewPermissionModal", () => {
|
||||
setOpen: vi.fn(),
|
||||
projects: mockProjects,
|
||||
apiKey: mockApiKey,
|
||||
onSubmit: vi.fn(),
|
||||
isUpdating: false,
|
||||
};
|
||||
|
||||
test("renders the modal with correct title", () => {
|
||||
@@ -154,7 +155,7 @@ describe("ViewPermissionModal", () => {
|
||||
expect(screen.getByTestId("organization-access-accessControl-read")).toBeDisabled();
|
||||
expect(screen.getByTestId("organization-access-accessControl-write")).not.toBeChecked();
|
||||
expect(screen.getByTestId("organization-access-accessControl-write")).toBeDisabled();
|
||||
expect(screen.getByTestId("organization-access-otherAccess-read")).not.toBeChecked();
|
||||
expect(screen.getByTestId("organization-access-otherAccess-read")).toBeChecked();
|
||||
expect(screen.getByTestId("organization-access-otherAccess-write")).toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { getOrganizationAccessKeyDisplayName } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import {
|
||||
TApiKeyUpdateInput,
|
||||
TApiKeyWithEnvironmentPermission,
|
||||
@@ -22,7 +21,7 @@ import { Label } from "@/modules/ui/components/label";
|
||||
import { Switch } from "@/modules/ui/components/switch";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { Fragment, useEffect } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { TOrganizationAccess } from "@formbricks/types/api-key";
|
||||
|
||||
@@ -168,36 +167,28 @@ export const ViewPermissionModal = ({
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-4">
|
||||
<Label>{t("environments.project.api_keys.organization_access")}</Label>
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-[auto_100px_100px] gap-4">
|
||||
<div></div>
|
||||
<span className="flex items-center justify-center text-sm font-medium">Read</span>
|
||||
<span className="flex items-center justify-center text-sm font-medium">Write</span>
|
||||
|
||||
{Object.keys(organizationAccess).map((key) => (
|
||||
<Fragment key={key}>
|
||||
<div className="py-1 text-sm">{getOrganizationAccessKeyDisplayName(key, t)}</div>
|
||||
<div className="flex items-center justify-center py-1">
|
||||
<Switch
|
||||
disabled={true}
|
||||
data-testid={`organization-access-${key}-read`}
|
||||
checked={organizationAccess[key].read}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-center py-1">
|
||||
<Switch
|
||||
disabled={true}
|
||||
data-testid={`organization-access-${key}-write`}
|
||||
checked={organizationAccess[key].write}
|
||||
/>
|
||||
</div>
|
||||
</Fragment>
|
||||
))}
|
||||
{Object.keys(organizationAccess).map((key) => (
|
||||
<div key={key} className="mb-2 flex items-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-sm font-medium">Read</Label>
|
||||
<Switch
|
||||
disabled={true}
|
||||
data-testid={`organization-access-${key}-read`}
|
||||
checked={organizationAccess[key].read || organizationAccess[key].write}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-sm font-medium">Write</Label>
|
||||
<Switch
|
||||
disabled={true}
|
||||
data-testid={`organization-access-${key}-write`}
|
||||
checked={organizationAccess[key].write}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { TAPIKeyEnvironmentPermission } from "@formbricks/types/auth";
|
||||
import { getOrganizationAccessKeyDisplayName, hasPermission } from "./utils";
|
||||
import { hasPermission } from "./utils";
|
||||
|
||||
describe("hasPermission", () => {
|
||||
const envId = "env1";
|
||||
@@ -83,17 +83,3 @@ describe("hasPermission", () => {
|
||||
expect(hasPermission(permissions, "other", "GET")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getOrganizationAccessKeyDisplayName", () => {
|
||||
test("returns tolgee string for accessControl", () => {
|
||||
const t = vi.fn((k) => k);
|
||||
expect(getOrganizationAccessKeyDisplayName("accessControl", t)).toBe(
|
||||
"environments.project.api_keys.access_control"
|
||||
);
|
||||
expect(t).toHaveBeenCalledWith("environments.project.api_keys.access_control");
|
||||
});
|
||||
test("returns tolgee string for other keys", () => {
|
||||
const t = vi.fn((k) => k);
|
||||
expect(getOrganizationAccessKeyDisplayName("otherKey", t)).toBe("otherKey");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { TFnType } from "@tolgee/react";
|
||||
import { OrganizationAccessType } from "@formbricks/types/api-key";
|
||||
import { TAPIKeyEnvironmentPermission, TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
|
||||
@@ -43,15 +42,6 @@ export const hasPermission = (
|
||||
}
|
||||
};
|
||||
|
||||
export const getOrganizationAccessKeyDisplayName = (key: string, t: TFnType) => {
|
||||
switch (key) {
|
||||
case "accessControl":
|
||||
return t("environments.project.api_keys.access_control");
|
||||
default:
|
||||
return key;
|
||||
}
|
||||
};
|
||||
|
||||
export const hasOrganizationAccess = (
|
||||
authentication: TAuthenticationApiKey,
|
||||
accessType: OrganizationAccessType
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { getActiveInactiveSurveysAction } from "@/modules/projects/settings/(setup)/app-connection/actions";
|
||||
import { createActionClassAction } from "@/modules/survey/editor/actions";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import toast from "react-hot-toast";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TActionClass } from "@formbricks/types/action-classes";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { getActiveInactiveSurveysAction } from "@/modules/projects/settings/(setup)/app-connection/actions";
|
||||
import { createActionClassAction } from "@/modules/survey/editor/actions";
|
||||
import { ActionActivityTab } from "./ActionActivityTab";
|
||||
|
||||
// Mock dependencies
|
||||
@@ -51,10 +51,6 @@ vi.mock("@/lib/utils/helper", () => ({
|
||||
getFormattedErrorMessage: (error: any) => `Formatted error: ${error?.message || "Unknown error"}`,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/strings", () => ({
|
||||
capitalizeFirstLetter: (str: string) => str.charAt(0).toUpperCase() + str.slice(1),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/survey/editor/actions", () => ({
|
||||
createActionClassAction: vi.fn(),
|
||||
}));
|
||||
@@ -209,7 +205,7 @@ describe("ActionActivityTab", () => {
|
||||
expect(screen.getByText(`formatted-${mockActionClass.createdAt.toString()}`)).toBeInTheDocument(); // Created on
|
||||
expect(screen.getByText(`formatted-${mockActionClass.updatedAt.toString()}`)).toBeInTheDocument(); // Last updated
|
||||
expect(screen.getByText("NoCodeIcon")).toBeInTheDocument(); // Type icon
|
||||
expect(screen.getByText("NoCode")).toBeInTheDocument(); // Type text
|
||||
expect(screen.getByText("noCode")).toBeInTheDocument(); // Type text (now lowercase, capitalized via CSS)
|
||||
expect(screen.getByText("Development")).toBeInTheDocument(); // Environment
|
||||
expect(screen.getByText("Copy to Production")).toBeInTheDocument(); // Copy button text
|
||||
});
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { TActionClass, TActionClassInput, TActionClassInputCode } from "@formbricks/types/action-classes";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { convertDateTimeStringShort } from "@/lib/time";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { capitalizeFirstLetter } from "@/lib/utils/strings";
|
||||
import { getActiveInactiveSurveysAction } from "@/modules/projects/settings/(setup)/app-connection/actions";
|
||||
import { ACTION_TYPE_ICON_LOOKUP } from "@/modules/projects/settings/(setup)/app-connection/utils";
|
||||
import { createActionClassAction } from "@/modules/survey/editor/actions";
|
||||
@@ -10,11 +14,6 @@ import { Button } from "@/modules/ui/components/button";
|
||||
import { ErrorComponent } from "@/modules/ui/components/error-component";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { TActionClass, TActionClassInput, TActionClassInputCode } from "@formbricks/types/action-classes";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
|
||||
interface ActivityTabProps {
|
||||
actionClass: TActionClass;
|
||||
@@ -152,7 +151,7 @@ export const ActionActivityTab = ({
|
||||
<Label className="block text-xs font-normal text-slate-500">Type</Label>
|
||||
<div className="mt-1 flex items-center">
|
||||
<div className="mr-1.5 h-4 w-4 text-slate-600">{ACTION_TYPE_ICON_LOOKUP[actionClass.type]}</div>
|
||||
<p className="text-sm text-slate-700">{capitalizeFirstLetter(actionClass.type)}</p>
|
||||
<p className="text-sm capitalize text-slate-700">{actionClass.type}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="">
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { debounce } from "lodash";
|
||||
import { ImagePlusIcon, TrashIcon } from "lucide-react";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
TI18nString,
|
||||
TSurvey,
|
||||
TSurveyEndScreenCard,
|
||||
TSurveyQuestion,
|
||||
TSurveyQuestionChoice,
|
||||
TSurveyRedirectUrlCard,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
|
||||
import { useSyncScroll } from "@/lib/utils/hooks/useSyncScroll";
|
||||
import { recallToHeadline } from "@/lib/utils/recall";
|
||||
@@ -10,20 +24,6 @@ import { FileInput } from "@/modules/ui/components/file-input";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { debounce } from "lodash";
|
||||
import { ImagePlusIcon, TrashIcon } from "lucide-react";
|
||||
import { RefObject, useCallback, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
TI18nString,
|
||||
TSurvey,
|
||||
TSurveyEndScreenCard,
|
||||
TSurveyQuestion,
|
||||
TSurveyQuestionChoice,
|
||||
TSurveyRedirectUrlCard,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import {
|
||||
determineImageUploaderVisibility,
|
||||
getChoiceLabel,
|
||||
@@ -50,7 +50,6 @@ interface QuestionFormInputProps {
|
||||
label: string;
|
||||
maxLength?: number;
|
||||
placeholder?: string;
|
||||
ref?: RefObject<HTMLInputElement | null>;
|
||||
onBlur?: React.FocusEventHandler<HTMLInputElement>;
|
||||
className?: string;
|
||||
locale: TUserLocale;
|
||||
|
||||
@@ -32,13 +32,7 @@ describe("TemplateFilters", () => {
|
||||
|
||||
test("renders all filter categories and options", () => {
|
||||
const setSelectedFilter = vi.fn();
|
||||
render(
|
||||
<TemplateFilters
|
||||
selectedFilter={[null, null, null]}
|
||||
setSelectedFilter={setSelectedFilter}
|
||||
prefilledFilters={[null, null, null]}
|
||||
/>
|
||||
);
|
||||
render(<TemplateFilters selectedFilter={[null, null, null]} setSelectedFilter={setSelectedFilter} />);
|
||||
|
||||
expect(screen.getByText("environments.surveys.templates.all_channels")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.surveys.templates.all_industries")).toBeInTheDocument();
|
||||
@@ -54,13 +48,7 @@ describe("TemplateFilters", () => {
|
||||
const setSelectedFilter = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<TemplateFilters
|
||||
selectedFilter={[null, null, null]}
|
||||
setSelectedFilter={setSelectedFilter}
|
||||
prefilledFilters={[null, null, null]}
|
||||
/>
|
||||
);
|
||||
render(<TemplateFilters selectedFilter={[null, null, null]} setSelectedFilter={setSelectedFilter} />);
|
||||
|
||||
await user.click(screen.getByText("environments.surveys.templates.channel1"));
|
||||
expect(setSelectedFilter).toHaveBeenCalledWith(["channel1", null, null]);
|
||||
@@ -74,11 +62,7 @@ describe("TemplateFilters", () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<TemplateFilters
|
||||
selectedFilter={["link", "app", "website"]}
|
||||
setSelectedFilter={setSelectedFilter}
|
||||
prefilledFilters={[null, null, null]}
|
||||
/>
|
||||
<TemplateFilters selectedFilter={["link", "app", "website"]} setSelectedFilter={setSelectedFilter} />
|
||||
);
|
||||
|
||||
await user.click(screen.getByText("environments.surveys.templates.all_channels"));
|
||||
@@ -93,7 +77,6 @@ describe("TemplateFilters", () => {
|
||||
selectedFilter={[null, null, null]}
|
||||
setSelectedFilter={setSelectedFilter}
|
||||
templateSearch="search term"
|
||||
prefilledFilters={[null, null, null]}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -102,20 +85,4 @@ describe("TemplateFilters", () => {
|
||||
expect(button).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
test("does not render filter categories that are prefilled", () => {
|
||||
const setSelectedFilter = vi.fn();
|
||||
|
||||
render(
|
||||
<TemplateFilters
|
||||
selectedFilter={["link", null, null]}
|
||||
setSelectedFilter={setSelectedFilter}
|
||||
prefilledFilters={["link", null, null]}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByText("environments.surveys.templates.all_channels")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("environments.surveys.templates.all_industries")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.surveys.templates.all_roles")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,14 +9,12 @@ interface TemplateFiltersProps {
|
||||
selectedFilter: TTemplateFilter[];
|
||||
setSelectedFilter: (filter: TTemplateFilter[]) => void;
|
||||
templateSearch?: string;
|
||||
prefilledFilters: TTemplateFilter[];
|
||||
}
|
||||
|
||||
export const TemplateFilters = ({
|
||||
selectedFilter,
|
||||
setSelectedFilter,
|
||||
templateSearch,
|
||||
prefilledFilters,
|
||||
}: TemplateFiltersProps) => {
|
||||
const { t } = useTranslate();
|
||||
const handleFilterSelect = (filterValue: TTemplateFilter, index: number) => {
|
||||
@@ -31,7 +29,6 @@ export const TemplateFilters = ({
|
||||
return (
|
||||
<div className="mb-6 gap-3">
|
||||
{allFilters.map((filters, index) => {
|
||||
if (prefilledFilters[index] !== null) return;
|
||||
return (
|
||||
<div key={filters[0]?.value || index} className="mt-2 flex flex-wrap gap-1 last:border-r-0">
|
||||
<button
|
||||
|
||||
@@ -102,41 +102,20 @@ describe("TemplateList", () => {
|
||||
});
|
||||
|
||||
test("renders correctly with default props", () => {
|
||||
render(
|
||||
<TemplateList
|
||||
userId="user-id"
|
||||
environmentId="env-id"
|
||||
project={mockProject}
|
||||
prefilledFilters={[null, null, null]}
|
||||
/>
|
||||
);
|
||||
render(<TemplateList userId="user-id" environmentId="env-id" project={mockProject} />);
|
||||
|
||||
expect(screen.getByText("Start from scratch")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders filters when showFilters is true", () => {
|
||||
render(
|
||||
<TemplateList
|
||||
userId="user-id"
|
||||
environmentId="env-id"
|
||||
project={mockProject}
|
||||
prefilledFilters={[null, null, null]}
|
||||
showFilters={true}
|
||||
/>
|
||||
);
|
||||
render(<TemplateList userId="user-id" environmentId="env-id" project={mockProject} showFilters={true} />);
|
||||
|
||||
expect(screen.queryByTestId("template-filters")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("doesn't render filters when showFilters is false", () => {
|
||||
render(
|
||||
<TemplateList
|
||||
userId="user-id"
|
||||
environmentId="env-id"
|
||||
project={mockProject}
|
||||
prefilledFilters={[null, null, null]}
|
||||
showFilters={false}
|
||||
/>
|
||||
<TemplateList userId="user-id" environmentId="env-id" project={mockProject} showFilters={false} />
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("template-filters")).not.toBeInTheDocument();
|
||||
@@ -150,7 +129,6 @@ describe("TemplateList", () => {
|
||||
userId="user-id"
|
||||
environmentId="env-id"
|
||||
project={mockProject}
|
||||
prefilledFilters={[null, null, null]}
|
||||
templateSearch="Template 1"
|
||||
/>
|
||||
);
|
||||
@@ -167,7 +145,6 @@ describe("TemplateList", () => {
|
||||
userId="user-id"
|
||||
environmentId="env-id"
|
||||
project={mockProject}
|
||||
prefilledFilters={[null, null, null]}
|
||||
onTemplateClick={onTemplateClickMock}
|
||||
noPreview={true}
|
||||
/>
|
||||
@@ -186,14 +163,7 @@ describe("TemplateList", () => {
|
||||
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<TemplateList
|
||||
userId="user-id"
|
||||
environmentId="env-id"
|
||||
project={mockProject}
|
||||
prefilledFilters={[null, null, null]}
|
||||
/>
|
||||
);
|
||||
render(<TemplateList userId="user-id" environmentId="env-id" project={mockProject} />);
|
||||
|
||||
// First select the template
|
||||
const selectButton = screen.getAllByText("Select")[0];
|
||||
@@ -220,14 +190,7 @@ describe("TemplateList", () => {
|
||||
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<TemplateList
|
||||
userId="user-id"
|
||||
environmentId="env-id"
|
||||
project={mockProject}
|
||||
prefilledFilters={[null, null, null]}
|
||||
/>
|
||||
);
|
||||
render(<TemplateList userId="user-id" environmentId="env-id" project={mockProject} />);
|
||||
|
||||
// First select the template
|
||||
const selectButton = screen.getAllByText("Select")[0];
|
||||
@@ -250,12 +213,7 @@ describe("TemplateList", () => {
|
||||
};
|
||||
|
||||
const { rerender } = render(
|
||||
<TemplateList
|
||||
userId="user-id"
|
||||
environmentId="env-id"
|
||||
project={mobileProject as Project}
|
||||
prefilledFilters={[null, null, null]}
|
||||
/>
|
||||
<TemplateList userId="user-id" environmentId="env-id" project={mobileProject as Project} />
|
||||
);
|
||||
|
||||
// Test with no channel config
|
||||
@@ -264,14 +222,7 @@ describe("TemplateList", () => {
|
||||
config: {},
|
||||
};
|
||||
|
||||
rerender(
|
||||
<TemplateList
|
||||
userId="user-id"
|
||||
environmentId="env-id"
|
||||
project={noChannelProject as Project}
|
||||
prefilledFilters={[null, null, null]}
|
||||
/>
|
||||
);
|
||||
rerender(<TemplateList userId="user-id" environmentId="env-id" project={noChannelProject as Project} />);
|
||||
|
||||
expect(screen.getByText("Template 1")).toBeInTheDocument();
|
||||
});
|
||||
@@ -279,14 +230,7 @@ describe("TemplateList", () => {
|
||||
test("development mode shows templates correctly", () => {
|
||||
vi.stubEnv("NODE_ENV", "development");
|
||||
|
||||
render(
|
||||
<TemplateList
|
||||
userId="user-id"
|
||||
environmentId="env-id"
|
||||
project={mockProject}
|
||||
prefilledFilters={[null, null, null]}
|
||||
/>
|
||||
);
|
||||
render(<TemplateList userId="user-id" environmentId="env-id" project={mockProject} />);
|
||||
|
||||
expect(screen.getByText("Template 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Template 2")).toBeInTheDocument();
|
||||
|
||||
@@ -21,7 +21,6 @@ interface TemplateListProps {
|
||||
project: Project;
|
||||
templateSearch?: string;
|
||||
showFilters?: boolean;
|
||||
prefilledFilters: TTemplateFilter[];
|
||||
onTemplateClick?: (template: TTemplate) => void;
|
||||
noPreview?: boolean; // single click to create survey
|
||||
}
|
||||
@@ -32,7 +31,6 @@ export const TemplateList = ({
|
||||
environmentId,
|
||||
showFilters = true,
|
||||
templateSearch,
|
||||
prefilledFilters,
|
||||
onTemplateClick = () => {},
|
||||
noPreview,
|
||||
}: TemplateListProps) => {
|
||||
@@ -40,7 +38,7 @@ export const TemplateList = ({
|
||||
const router = useRouter();
|
||||
const [activeTemplate, setActiveTemplate] = useState<TTemplate | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedFilter, setSelectedFilter] = useState<TTemplateFilter[]>(prefilledFilters);
|
||||
const [selectedFilter, setSelectedFilter] = useState<TTemplateFilter[]>([null, null, null]);
|
||||
const surveyType: TSurveyType = useMemo(() => {
|
||||
if (project.config.channel) {
|
||||
if (project.config.channel === "website") {
|
||||
@@ -111,7 +109,6 @@ export const TemplateList = ({
|
||||
selectedFilter={selectedFilter}
|
||||
setSelectedFilter={setSelectedFilter}
|
||||
templateSearch={templateSearch}
|
||||
prefilledFilters={prefilledFilters}
|
||||
/>
|
||||
)}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
|
||||
@@ -241,4 +241,40 @@ describe("ConditionalLogic", () => {
|
||||
|
||||
expect(screen.getAllByTestId("logic-editor").length).toBe(2);
|
||||
});
|
||||
|
||||
test("should clear logicFallback when logic array is empty and logicFallback exists (useEffect)", () => {
|
||||
const mockUpdateQuestion = vi.fn();
|
||||
const mockQuestion: TSurveyQuestion = {
|
||||
id: "testQuestionId",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Test Question" },
|
||||
required: false,
|
||||
inputType: "text",
|
||||
charLimit: { enabled: false },
|
||||
logic: [], // Empty logic array
|
||||
logicFallback: "someTarget", // Has logicFallback but no logic
|
||||
};
|
||||
const mockSurvey = {
|
||||
id: "testSurveyId",
|
||||
name: "Test Survey",
|
||||
type: "link",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: "testEnvId",
|
||||
status: "inProgress",
|
||||
questions: [mockQuestion],
|
||||
endings: [],
|
||||
} as unknown as TSurvey;
|
||||
|
||||
render(
|
||||
<ConditionalLogic
|
||||
localSurvey={mockSurvey}
|
||||
question={mockQuestion}
|
||||
questionIdx={0}
|
||||
updateQuestion={mockUpdateQuestion}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(mockUpdateQuestion).toHaveBeenCalledWith(0, { logicFallback: undefined });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
ArrowUpIcon,
|
||||
CopyIcon,
|
||||
EllipsisVerticalIcon,
|
||||
PlusIcon,
|
||||
SplitIcon,
|
||||
TrashIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { TSurvey, TSurveyLogic, TSurveyQuestion } from "@formbricks/types/surveys/types";
|
||||
import { duplicateLogicItem } from "@/lib/surveyLogic/utils";
|
||||
import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
||||
import { LogicEditor } from "@/modules/survey/editor/components/logic-editor";
|
||||
@@ -15,20 +29,6 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
ArrowUpIcon,
|
||||
CopyIcon,
|
||||
EllipsisVerticalIcon,
|
||||
PlusIcon,
|
||||
SplitIcon,
|
||||
TrashIcon,
|
||||
} from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { TSurvey, TSurveyLogic, TSurveyQuestion } from "@formbricks/types/surveys/types";
|
||||
|
||||
interface ConditionalLogicProps {
|
||||
localSurvey: TSurvey;
|
||||
@@ -117,6 +117,12 @@ export function ConditionalLogic({
|
||||
};
|
||||
const [parent] = useAutoAnimate();
|
||||
|
||||
useEffect(() => {
|
||||
if (question.logic?.length === 0 && question.logicFallback) {
|
||||
updateQuestion(questionIdx, { logicFallback: undefined });
|
||||
}
|
||||
}, [question.logic, questionIdx, question.logicFallback, updateQuestion]);
|
||||
|
||||
return (
|
||||
<div className="mt-4" ref={parent}>
|
||||
<Label className="flex gap-2">
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { TConditionGroup, TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types";
|
||||
import { createSharedConditionsFactory } from "@/modules/survey/editor/lib/shared-conditions-factory";
|
||||
import { getDefaultOperatorForQuestion } from "@/modules/survey/editor/lib/utils";
|
||||
import { ConditionsEditor } from "@/modules/ui/components/conditions-editor";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { TConditionGroup, TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types";
|
||||
|
||||
interface LogicEditorConditionsProps {
|
||||
conditions: TConditionGroup;
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { LogicEditorActions } from "@/modules/survey/editor/components/logic-editor-actions";
|
||||
import { LogicEditorConditions } from "@/modules/survey/editor/components/logic-editor-conditions";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
@@ -8,6 +6,8 @@ import {
|
||||
TSurveyQuestion,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { LogicEditorActions } from "@/modules/survey/editor/components/logic-editor-actions";
|
||||
import { LogicEditorConditions } from "@/modules/survey/editor/components/logic-editor-conditions";
|
||||
import { LogicEditor } from "./logic-editor";
|
||||
|
||||
// Mock the subcomponents to isolate the LogicEditor component
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { ArrowRightIcon } from "lucide-react";
|
||||
import { ReactElement, useMemo } from "react";
|
||||
import { TSurvey, TSurveyLogic, TSurveyQuestion } from "@formbricks/types/surveys/types";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { LogicEditorActions } from "@/modules/survey/editor/components/logic-editor-actions";
|
||||
import { LogicEditorConditions } from "@/modules/survey/editor/components/logic-editor-conditions";
|
||||
@@ -11,10 +15,6 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { ArrowRightIcon } from "lucide-react";
|
||||
import { ReactElement, useMemo } from "react";
|
||||
import { TSurvey, TSurveyLogic, TSurveyQuestion } from "@formbricks/types/surveys/types";
|
||||
|
||||
interface LogicEditorProps {
|
||||
localSurvey: TSurvey;
|
||||
|
||||
@@ -1,335 +1,270 @@
|
||||
import { createI18nString } from "@/lib/i18n/utils";
|
||||
import { findOptionUsedInLogic } from "@/modules/survey/editor/lib/utils";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TLanguage } from "@formbricks/types/project";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyLanguage,
|
||||
TSurveyMatrixQuestion,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { MatrixQuestionForm } from "./matrix-question-form";
|
||||
|
||||
// Mock cuid2 to track CUID generation
|
||||
let cuidIndex = 0;
|
||||
|
||||
vi.mock("@paralleldrive/cuid2", () => ({
|
||||
default: {
|
||||
createId: vi.fn(() => `cuid${cuidIndex++}`),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock window.matchMedia - required for useAutoAnimate
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
|
||||
// Mock @formkit/auto-animate - simplify implementation
|
||||
vi.mock("@formkit/auto-animate/react", () => ({
|
||||
useAutoAnimate: () => [null],
|
||||
}));
|
||||
|
||||
// Mock react-hot-toast
|
||||
vi.mock("react-hot-toast", () => ({
|
||||
default: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
vi.mock("@dnd-kit/core", () => ({
|
||||
DndContext: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
// Mock findOptionUsedInLogic
|
||||
vi.mock("@/modules/survey/editor/lib/utils", () => ({
|
||||
findOptionUsedInLogic: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock constants
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
ENCRYPTION_KEY: "test",
|
||||
ENTERPRISE_LICENSE_KEY: "test",
|
||||
GITHUB_ID: "test",
|
||||
GITHUB_SECRET: "test",
|
||||
GOOGLE_CLIENT_ID: "test",
|
||||
GOOGLE_CLIENT_SECRET: "test",
|
||||
AZUREAD_CLIENT_ID: "mock-azuread-client-id",
|
||||
AZUREAD_CLIENT_SECRET: "mock-azure-client-secret",
|
||||
AZUREAD_TENANT_ID: "mock-azuread-tenant-id",
|
||||
OIDC_CLIENT_ID: "mock-oidc-client-id",
|
||||
OIDC_CLIENT_SECRET: "mock-oidc-client-secret",
|
||||
OIDC_ISSUER: "mock-oidc-issuer",
|
||||
OIDC_DISPLAY_NAME: "mock-oidc-display-name",
|
||||
OIDC_SIGNING_ALGORITHM: "mock-oidc-signing-algorithm",
|
||||
WEBAPP_URL: "mock-webapp-url",
|
||||
AI_AZURE_LLM_RESSOURCE_NAME: "mock-azure-llm-resource-name",
|
||||
AI_AZURE_LLM_API_KEY: "mock-azure-llm-api-key",
|
||||
AI_AZURE_LLM_DEPLOYMENT_ID: "mock-azure-llm-deployment-id",
|
||||
AI_AZURE_EMBEDDINGS_RESSOURCE_NAME: "mock-azure-embeddings-resource-name",
|
||||
AI_AZURE_EMBEDDINGS_API_KEY: "mock-azure-embeddings-api-key",
|
||||
AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID: "mock-azure-embeddings-deployment-id",
|
||||
IS_PRODUCTION: true,
|
||||
FB_LOGO_URL: "https://example.com/mock-logo.png",
|
||||
SMTP_HOST: "mock-smtp-host",
|
||||
SMTP_PORT: "mock-smtp-port",
|
||||
IS_POSTHOG_CONFIGURED: true,
|
||||
}));
|
||||
|
||||
// Mock tolgee
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({
|
||||
t: (key: string) => key,
|
||||
vi.mock("@dnd-kit/sortable", () => ({
|
||||
SortableContext: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
useSortable: () => ({
|
||||
attributes: {},
|
||||
listeners: {},
|
||||
setNodeRef: () => {},
|
||||
transform: null,
|
||||
transition: null,
|
||||
}),
|
||||
verticalListSortingStrategy: () => {},
|
||||
}));
|
||||
|
||||
// Mock QuestionFormInput component
|
||||
// Keep QuestionFormInput simple and forward keydown
|
||||
vi.mock("@/modules/survey/components/question-form-input", () => ({
|
||||
QuestionFormInput: vi.fn(({ id, updateMatrixLabel, value, updateQuestion, onKeyDown }) => (
|
||||
<div data-testid={`question-input-${id}`}>
|
||||
<input
|
||||
data-testid={`input-${id}`}
|
||||
onChange={(e) => {
|
||||
if (updateMatrixLabel) {
|
||||
const type = id.startsWith("row") ? "row" : "column";
|
||||
const index = parseInt(id.split("-")[1]);
|
||||
updateMatrixLabel(index, type, { default: e.target.value });
|
||||
} else if (updateQuestion) {
|
||||
updateQuestion(0, { [id]: { default: e.target.value } });
|
||||
}
|
||||
}}
|
||||
value={value?.default || ""}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
</div>
|
||||
)),
|
||||
QuestionFormInput: ({ id, value, onKeyDown }: { id: string; value: any; onKeyDown?: any }) => (
|
||||
<input
|
||||
data-testid={`qfi-${id}`}
|
||||
value={value?.en || value?.de || value?.default || ""}
|
||||
onChange={() => {}}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock ShuffleOptionSelect component
|
||||
vi.mock("@/modules/ui/components/shuffle-option-select", () => ({
|
||||
ShuffleOptionSelect: vi.fn(() => <div data-testid="shuffle-option-select" />),
|
||||
}));
|
||||
describe("MatrixQuestionForm - handleKeyDown", () => {
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
// Mock TooltipRenderer component
|
||||
vi.mock("@/modules/ui/components/tooltip", () => ({
|
||||
TooltipRenderer: vi.fn(({ children }) => (
|
||||
<div data-testid="tooltip-renderer">
|
||||
{children}
|
||||
<button>Delete</button>
|
||||
</div>
|
||||
)),
|
||||
}));
|
||||
|
||||
// Mock validation
|
||||
vi.mock("../lib/validation", () => ({
|
||||
isLabelValidForAllLanguages: vi.fn().mockReturnValue(true),
|
||||
}));
|
||||
|
||||
// Mock survey languages
|
||||
const mockSurveyLanguages: TSurveyLanguage[] = [
|
||||
{
|
||||
default: true,
|
||||
enabled: true,
|
||||
language: {
|
||||
id: "en",
|
||||
code: "en",
|
||||
alias: "English",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
projectId: "project-1",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Mock matrix question
|
||||
const mockMatrixQuestion: TSurveyMatrixQuestion = {
|
||||
id: "matrix-1",
|
||||
type: TSurveyQuestionTypeEnum.Matrix,
|
||||
headline: createI18nString("Matrix Question", ["en"]),
|
||||
subheader: createI18nString("Please rate the following", ["en"]),
|
||||
required: false,
|
||||
logic: [],
|
||||
rows: [
|
||||
{ id: "row-1", label: createI18nString("Row 1", ["en"]) },
|
||||
{ id: "row-2", label: createI18nString("Row 2", ["en"]) },
|
||||
{ id: "row-3", label: createI18nString("Row 3", ["en"]) },
|
||||
],
|
||||
columns: [
|
||||
{ id: "col-1", label: createI18nString("Column 1", ["en"]) },
|
||||
{ id: "col-2", label: createI18nString("Column 2", ["en"]) },
|
||||
{ id: "col-3", label: createI18nString("Column 3", ["en"]) },
|
||||
],
|
||||
shuffleOption: "none",
|
||||
};
|
||||
|
||||
// Mock survey
|
||||
const mockSurvey: TSurvey = {
|
||||
id: "survey-1",
|
||||
name: "Test Survey",
|
||||
questions: [mockMatrixQuestion],
|
||||
languages: mockSurveyLanguages,
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const mockUpdateQuestion = vi.fn();
|
||||
|
||||
const defaultProps = {
|
||||
localSurvey: mockSurvey,
|
||||
question: mockMatrixQuestion,
|
||||
questionIdx: 0,
|
||||
updateQuestion: mockUpdateQuestion,
|
||||
selectedLanguageCode: "en",
|
||||
setSelectedLanguageCode: vi.fn(),
|
||||
isInvalid: false,
|
||||
locale: "en-US" as TUserLocale,
|
||||
isStorageConfigured: true,
|
||||
};
|
||||
|
||||
describe("MatrixQuestionForm", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
cuidIndex = 0;
|
||||
});
|
||||
|
||||
test("renders the matrix question form with rows and columns", () => {
|
||||
render(<MatrixQuestionForm {...defaultProps} isStorageConfigured={true} />);
|
||||
const makeSurvey = (languages: Array<Pick<TSurveyLanguage, "language" | "default">>): TSurvey =>
|
||||
({
|
||||
id: "s1",
|
||||
name: "Survey",
|
||||
type: "link",
|
||||
languages: languages as unknown as TSurveyLanguage[],
|
||||
questions: [] as any,
|
||||
endings: [] as any,
|
||||
createdAt: new Date("2024-01-01T00:00:00.000Z"),
|
||||
environmentId: "env1",
|
||||
}) as unknown as TSurvey;
|
||||
|
||||
expect(screen.getByTestId("question-input-headline")).toBeInTheDocument();
|
||||
const langDefault: TSurveyLanguage = {
|
||||
language: { code: "default" } as unknown as TLanguage,
|
||||
default: true,
|
||||
} as unknown as TSurveyLanguage;
|
||||
|
||||
// Check for rows and columns
|
||||
expect(screen.getByTestId("question-input-row-0")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("question-input-row-1")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("question-input-column-0")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("question-input-column-1")).toBeInTheDocument();
|
||||
|
||||
// Check for shuffle options
|
||||
expect(screen.getByTestId("shuffle-option-select")).toBeInTheDocument();
|
||||
const baseQuestion = (): TSurveyMatrixQuestion => ({
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.Matrix,
|
||||
headline: { default: "Matrix" },
|
||||
required: false,
|
||||
rows: [
|
||||
{ id: "r1", label: { default: "Row 1" } },
|
||||
{ id: "r2", label: { default: "" } },
|
||||
],
|
||||
columns: [
|
||||
{ id: "c1", label: { default: "Col 1" } },
|
||||
{ id: "c2", label: { default: "" } },
|
||||
],
|
||||
shuffleOption: "none",
|
||||
});
|
||||
|
||||
test("adds description when button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const propsWithoutSubheader = {
|
||||
...defaultProps,
|
||||
question: {
|
||||
...mockMatrixQuestion,
|
||||
subheader: undefined,
|
||||
},
|
||||
};
|
||||
test("Enter on last row adds a new row", async () => {
|
||||
const question = baseQuestion();
|
||||
const localSurvey = makeSurvey([langDefault]);
|
||||
(localSurvey as any).questions = [question];
|
||||
|
||||
const { getByText } = render(
|
||||
<MatrixQuestionForm {...propsWithoutSubheader} isStorageConfigured={true} />
|
||||
const updateQuestion = vi.fn();
|
||||
|
||||
render(
|
||||
<MatrixQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={question}
|
||||
questionIdx={0}
|
||||
updateQuestion={updateQuestion}
|
||||
isInvalid={false}
|
||||
selectedLanguageCode="default"
|
||||
setSelectedLanguageCode={vi.fn()}
|
||||
locale="en-US"
|
||||
isStorageConfigured={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const addDescriptionButton = getByText("environments.surveys.edit.add_description");
|
||||
await user.click(addDescriptionButton);
|
||||
const lastRowInput = screen.getByTestId("qfi-row-1");
|
||||
await userEvent.type(lastRowInput, "{enter}");
|
||||
|
||||
expect(mockUpdateQuestion).toHaveBeenCalledWith(0, {
|
||||
subheader: expect.any(Object),
|
||||
});
|
||||
expect(updateQuestion).toHaveBeenCalledTimes(1);
|
||||
const [, payload] = updateQuestion.mock.calls[0];
|
||||
expect(payload.rows.length).toBe(3);
|
||||
expect(payload.rows[2]).toEqual(
|
||||
expect.objectContaining({ id: expect.any(String), label: expect.objectContaining({ default: "" }) })
|
||||
);
|
||||
});
|
||||
|
||||
test("renders subheader input when subheader is defined", () => {
|
||||
render(<MatrixQuestionForm {...defaultProps} />);
|
||||
test("Enter on non-last row focuses next row", async () => {
|
||||
const question = baseQuestion();
|
||||
const localSurvey = makeSurvey([langDefault]);
|
||||
(localSurvey as any).questions = [question];
|
||||
|
||||
expect(screen.getByTestId("question-input-subheader")).toBeInTheDocument();
|
||||
const updateQuestion = vi.fn();
|
||||
|
||||
render(
|
||||
<MatrixQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={question}
|
||||
questionIdx={0}
|
||||
updateQuestion={updateQuestion}
|
||||
isInvalid={false}
|
||||
selectedLanguageCode="default"
|
||||
setSelectedLanguageCode={vi.fn()}
|
||||
locale="en-US"
|
||||
isStorageConfigured={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const firstRowInput = screen.getByTestId("qfi-row-0");
|
||||
await userEvent.type(firstRowInput, "{enter}");
|
||||
|
||||
expect(updateQuestion).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("deletes a row when delete button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { findAllByTestId } = render(<MatrixQuestionForm {...defaultProps} />);
|
||||
vi.mocked(findOptionUsedInLogic).mockReturnValueOnce(-1);
|
||||
test("Enter on last column adds a new column", async () => {
|
||||
const question = baseQuestion();
|
||||
const localSurvey = makeSurvey([langDefault]);
|
||||
(localSurvey as any).questions = [question];
|
||||
|
||||
const deleteButtons = await findAllByTestId("tooltip-renderer");
|
||||
// First delete button is for the first column
|
||||
await user.click(deleteButtons[0].querySelector("button") as HTMLButtonElement);
|
||||
const updateQuestion = vi.fn();
|
||||
|
||||
expect(mockUpdateQuestion).toHaveBeenCalledWith(0, {
|
||||
rows: [mockMatrixQuestion.rows[1], mockMatrixQuestion.rows[2]],
|
||||
});
|
||||
render(
|
||||
<MatrixQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={question}
|
||||
questionIdx={0}
|
||||
updateQuestion={updateQuestion}
|
||||
isInvalid={false}
|
||||
selectedLanguageCode="default"
|
||||
setSelectedLanguageCode={vi.fn()}
|
||||
locale="en-US"
|
||||
isStorageConfigured={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const lastColInput = screen.getByTestId("qfi-column-1");
|
||||
await userEvent.type(lastColInput, "{enter}");
|
||||
|
||||
expect(updateQuestion).toHaveBeenCalledTimes(1);
|
||||
const [, payload] = updateQuestion.mock.calls[0];
|
||||
expect(payload.columns.length).toBe(3);
|
||||
expect(payload.columns[2]).toEqual(
|
||||
expect.objectContaining({ id: expect.any(String), label: expect.objectContaining({ default: "" }) })
|
||||
);
|
||||
});
|
||||
|
||||
test("doesn't delete a row if it would result in less than 2 rows", async () => {
|
||||
const user = userEvent.setup();
|
||||
const propsWithMinRows = {
|
||||
...defaultProps,
|
||||
question: {
|
||||
...mockMatrixQuestion,
|
||||
rows: [
|
||||
{ id: "row-1", label: createI18nString("Row 1", ["en"]) },
|
||||
{ id: "row-2", label: createI18nString("Row 2", ["en"]) },
|
||||
],
|
||||
},
|
||||
};
|
||||
test("Enter on non-last column focuses next column", async () => {
|
||||
const question = baseQuestion();
|
||||
const localSurvey = makeSurvey([langDefault]);
|
||||
(localSurvey as any).questions = [question];
|
||||
|
||||
const { findAllByTestId } = render(<MatrixQuestionForm {...propsWithMinRows} />);
|
||||
const updateQuestion = vi.fn();
|
||||
|
||||
// Try to delete rows until there are only 2 left
|
||||
const deleteButtons = await findAllByTestId("tooltip-renderer");
|
||||
await user.click(deleteButtons[0].querySelector("button") as HTMLButtonElement);
|
||||
render(
|
||||
<MatrixQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={question}
|
||||
questionIdx={0}
|
||||
updateQuestion={updateQuestion}
|
||||
isInvalid={false}
|
||||
selectedLanguageCode="default"
|
||||
setSelectedLanguageCode={vi.fn()}
|
||||
locale="en-US"
|
||||
isStorageConfigured={true}
|
||||
/>
|
||||
);
|
||||
|
||||
// Try to delete another row, which should fail
|
||||
vi.mocked(mockUpdateQuestion).mockClear();
|
||||
await user.click(deleteButtons[1].querySelector("button") as HTMLButtonElement);
|
||||
const firstColInput = screen.getByTestId("qfi-column-0");
|
||||
await userEvent.type(firstColInput, "{enter}");
|
||||
|
||||
// The mockUpdateQuestion should not be called again
|
||||
expect(mockUpdateQuestion).not.toHaveBeenCalled();
|
||||
expect(updateQuestion).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("handles row input changes", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { getByTestId } = render(<MatrixQuestionForm {...defaultProps} />);
|
||||
test("Arrow Down on row focuses next row", async () => {
|
||||
const question = baseQuestion();
|
||||
const localSurvey = makeSurvey([langDefault]);
|
||||
(localSurvey as any).questions = [question];
|
||||
|
||||
const rowInput = getByTestId("input-row-0");
|
||||
await user.clear(rowInput);
|
||||
await user.type(rowInput, "New Row Label");
|
||||
const updateQuestion = vi.fn();
|
||||
|
||||
expect(mockUpdateQuestion).toHaveBeenCalled();
|
||||
render(
|
||||
<MatrixQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={question}
|
||||
questionIdx={0}
|
||||
updateQuestion={updateQuestion}
|
||||
isInvalid={false}
|
||||
selectedLanguageCode="default"
|
||||
setSelectedLanguageCode={vi.fn()}
|
||||
locale="en-US"
|
||||
isStorageConfigured={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const firstRowInput = screen.getByTestId("qfi-row-0");
|
||||
await userEvent.type(firstRowInput, "{arrowdown}");
|
||||
|
||||
expect(updateQuestion).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("handles column input changes", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { getByTestId } = render(<MatrixQuestionForm {...defaultProps} />);
|
||||
test("Arrow Up on row focuses previous row", async () => {
|
||||
const question = baseQuestion();
|
||||
const localSurvey = makeSurvey([langDefault]);
|
||||
(localSurvey as any).questions = [question];
|
||||
|
||||
const columnInput = getByTestId("input-column-0");
|
||||
await user.clear(columnInput);
|
||||
await user.type(columnInput, "New Column Label");
|
||||
const updateQuestion = vi.fn();
|
||||
|
||||
expect(mockUpdateQuestion).toHaveBeenCalled();
|
||||
});
|
||||
render(
|
||||
<MatrixQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={question}
|
||||
questionIdx={0}
|
||||
updateQuestion={updateQuestion}
|
||||
isInvalid={false}
|
||||
selectedLanguageCode="default"
|
||||
setSelectedLanguageCode={vi.fn()}
|
||||
locale="en-US"
|
||||
isStorageConfigured={true}
|
||||
/>
|
||||
);
|
||||
|
||||
test("prevents deletion of a row used in logic", async () => {
|
||||
const { findOptionUsedInLogic } = await import("@/modules/survey/editor/lib/utils");
|
||||
vi.mocked(findOptionUsedInLogic).mockReturnValueOnce(1); // Mock that this row is used in logic
|
||||
const secondRowInput = screen.getByTestId("qfi-row-1");
|
||||
await userEvent.type(secondRowInput, "{arrowup}");
|
||||
|
||||
const user = userEvent.setup();
|
||||
const { findAllByTestId } = render(<MatrixQuestionForm {...defaultProps} />);
|
||||
|
||||
const deleteButtons = await findAllByTestId("tooltip-renderer");
|
||||
await user.click(deleteButtons[0].querySelector("button") as HTMLButtonElement);
|
||||
|
||||
expect(mockUpdateQuestion).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("prevents deletion of a column used in logic", async () => {
|
||||
const { findOptionUsedInLogic } = await import("@/modules/survey/editor/lib/utils");
|
||||
vi.mocked(findOptionUsedInLogic).mockReturnValueOnce(1); // Mock that this column is used in logic
|
||||
|
||||
const user = userEvent.setup();
|
||||
const { findAllByTestId } = render(<MatrixQuestionForm {...defaultProps} />);
|
||||
|
||||
// Column delete buttons are after row delete buttons
|
||||
const deleteButtons = await findAllByTestId("tooltip-renderer");
|
||||
// Click the first column delete button (index 2)
|
||||
await user.click(deleteButtons[2].querySelector("button") as HTMLButtonElement);
|
||||
|
||||
expect(mockUpdateQuestion).not.toHaveBeenCalled();
|
||||
expect(updateQuestion).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user