Compare commits

..

56 Commits

Author SHA1 Message Date
Cursor Agent 5cfeea0073 Normalize CSV contact attribute keys for case-insensitivity
Co-authored-by: johannes <johannes@formbricks.com>
2025-10-15 15:41:08 +00:00
Dhruwang Jariwala e26a188d1b fix: use /releases/latest endpoint to fetch correct latest version (#6690) 2025-10-15 07:01:00 +00:00
Victor Hugo dos Santos aaea129d4f fix: api key hashing algorithm (#6639)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-10-13 14:36:37 +00:00
Johannes 18f4cd977d feat: Add "None of the above" option for Multi-Select and Single-Select questions (#6646) 2025-10-10 07:50:45 -07:00
Dhruwang Jariwala 5468510f5a feat: recall in rich text (#6630)
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-10-09 09:45:08 +00:00
Victor Hugo dos Santos 76213af5d7 chore: update dependencies and improve logging format (#6672) 2025-10-09 09:02:07 +00:00
Anshuman Pandey cdf0926c60 fix: restricts management file uploads size to be less than 5MB (#6669) 2025-10-09 05:02:52 +00:00
devin-ai-integration[bot] 84b3c57087 docs: add setLanguage method to user identification documentation (#6670)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Johannes <johannes@formbricks.com>
2025-10-08 16:20:11 +00:00
Victor Hugo dos Santos ed10069b39 chore: update esbuild to latest version (#6662) 2025-10-08 14:11:24 +00:00
Anshuman Pandey 7c1033af20 fix: bumps nodemailer version (#6667) 2025-10-08 06:03:45 +00:00
Matti Nannt 98e3ad1068 perf(web): optimize Next.js image processing to prevent timeouts (#6665) 2025-10-08 05:02:04 +00:00
Johannes b11fbd9f95 fix: upgrade axios and tar-fs to resolve dependabot issues (#6655) 2025-10-07 05:27:24 +00:00
Matti Nannt c5e31d14d1 feat(docker): upgrade Traefik from v2.7 to v2.11.29 for security (#6636) 2025-10-07 05:20:49 +00:00
Matti Nannt d64d561498 feat(ci): add conditional tagging based on 'Set as latest release' option (#6628) 2025-10-06 12:25:19 +00:00
Johannes 1bddc9e960 refactor: remove hidden fields toggle from UI (#6649) 2025-10-06 12:19:45 +00:00
Matti Nannt 3f122ed9ee perf: reduce cache TTL to 1 minute for SDK environment state and segments (#6635) 2025-10-06 10:12:46 +00:00
Jakob Schott bdad80d6d1 fix: remove capitalize functions (#6610)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-10-06 10:07:23 +00:00
Johannes d9ea00d86e fix: allow deselecting optional single-select question responses (#6643)
Co-authored-by: Victor Santos <victor@formbricks.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-10-06 09:32:24 +00:00
Johannes 4a3c2fccba chore: add Cursor rule for Review & Refinement (#6648) 2025-10-06 01:38:42 -07:00
Johannes 3a09af674a feat: hit ENTER for new option (#6624)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-10-06 07:23:17 +00:00
Dhruwang Jariwala 1ced76c44d chore: added expirationDays param support in personal link api (#6578)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-10-06 07:12:29 +00:00
Victor Hugo dos Santos fa1663d858 docs: enhance file upload troubleshooting guidance in migration (#6645)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-10-06 06:40:06 +00:00
Victor Hugo dos Santos ebf591a7e0 fix: improve E2E test reliability and security (#6653) 2025-10-06 05:02:51 +00:00
Dhruwang Jariwala 5c9795cd23 chore: update @boxyhq/saml-jackson and posthog-node (#6647) 2025-10-04 09:26:30 +02:00
Victor Hugo dos Santos b67177ba55 Merge commit from fork
* fix(auth): enhance password validation and rate limiting for login attempts

- Added password length validation to prevent CPU DoS attacks, limiting to 128 characters.
- Implemented constant-time password verification to mitigate timing attacks.
- Adjusted rate limit for login attempts from 30 to 10 per 15 minutes for improved security.
- Updated login form validation to reflect new password length constraints.
- Introduced constants for authentication endpoints in the API.

* fixed sample size for timing test

* password validation messages

---------

Co-authored-by: Your Name <you@example.com>
2025-10-02 11:09:28 +02:00
Johannes 6cf1f49c8e docs: add tag docs (#6640) 2025-10-02 01:47:31 -07:00
Johannes 4afb95b92a fix: switch Manage Subscription button bg to stripe color (#6633) 2025-10-01 12:00:44 +00:00
Piyush Gupta 38089241b4 chore: adds surveys package readme (#6598) 2025-10-01 11:26:03 +00:00
Johannes 07487d4871 docs: update license pages (#6631) 2025-10-01 01:40:19 -07:00
Johannes fa0879e3a0 chore: increase visibility of hover effect to indicate clickability (#6622)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-09-30 12:44:13 +00:00
Anshuman Pandey 3733c22a6f fix: file uploads and cluster setup docs (#6623) 2025-09-30 01:46:02 -07:00
Anshuman Pandey 5e5baa76ab fix: fixes the formbricks.sh redis undefined volume bug (#6604) 2025-09-25 13:55:43 +00:00
Dhruwang Jariwala 2153d2aa16 fix: replace button with div in IdBadge to prevent hydration issues (#6601) 2025-09-25 13:42:41 +00:00
Matti Nannt 7fa4862fd9 feat: make S3_REGION optional in storage client configuration (#6577) 2025-09-25 12:25:35 +00:00
Matti Nannt 411e9a26ee fix(ci): update release tag validation to accept format without v prefix (#6585) 2025-09-25 12:09:19 +00:00
Victor Hugo dos Santos eb1349f205 fix: enhance JWT handling with improved encryption and decryption logic (#6596) 2025-09-25 11:45:08 +00:00
Johannes 5c25f25212 docs: remove beta note (#6593) 2025-09-24 02:51:58 -07:00
Victor Hugo dos Santos 6af81e46ee chore: improve Sentry API logs with correlation ID and request context (#6584) 2025-09-24 09:25:51 +00:00
Jakob Schott 7423fc9472 fix: Improve messaging for mobile users (#6579)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-09-23 10:13:00 +00:00
Victor Hugo dos Santos 1557ffcca1 feat: add redis migration script (#6575)
Co-authored-by: Matti Nannt <matti@formbricks.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-09-22 11:18:02 +00:00
Piyush Gupta 5d53ed76ed fix: logic fallback cleanup (#6568) 2025-09-22 08:10:27 +00:00
Dhruwang Jariwala ebd399e611 fix: block previews for completed and paused surveys (#6576) 2025-09-22 07:21:38 +00:00
Dhruwang Jariwala 843110b0d6 fix: followup toast (#6565) 2025-09-19 13:03:56 +00:00
Anshuman Pandey 51babf2f98 fix: minor csp change and removes uploads volume (#6566) 2025-09-19 10:20:38 +00:00
Victor Hugo dos Santos 6bc5f1e168 feat: add cache integration tests and update E2E workflow (#6551) 2025-09-19 08:44:31 +00:00
Piyush Gupta c9016802e7 docs: updated screenshots in docs (#6562) 2025-09-18 19:19:14 +00:00
Anshuman Pandey 6a49fb4700 feat: adds one-click MinIO migration script for Formbricks 4.0 (#6553)
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-09-18 16:23:03 +00:00
Dhruwang Jariwala 646921cd37 fix: logic issues (#6561) 2025-09-18 18:31:44 +02:00
Dhruwang Jariwala 34d3145fcd fix: broken churn survey template (#6559) 2025-09-18 11:18:39 +00:00
Dhruwang Jariwala c3c06eb309 fix: empty container in template UI (#6556) 2025-09-18 06:45:20 +00:00
Dhruwang Jariwala bf4c6238d5 fix: api key modal tweaks (#6552)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-09-17 15:00:42 +00:00
Dhruwang Jariwala 8972ef0fef fix: integration redirect links (#6555) 2025-09-17 14:59:35 +00:00
Matti Nannt 4e59924a5a fix: e2e tests issue due to security policy (#6558) 2025-09-17 16:54:07 +02:00
Matti Nannt 8b28353b79 fix: release tag extraction in release action (#6554) 2025-09-16 17:33:32 +00:00
Matti Nannt abbc7a065b chore: update release pipeline for new infrastructure (#6541) 2025-09-16 10:33:24 +00:00
Harsh Bhat 00e8ee27a2 docs: Add redirect error handling (#6548) 2025-09-15 06:03:41 -07:00
288 changed files with 17344 additions and 4307 deletions
+179
View 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.**
@@ -0,0 +1,319 @@
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"
make_latest:
description: "Whether to tag as latest/production (from GitHub release 'Set as the latest release' option)"
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 }}
MAKE_LATEST: ${{ inputs.make_latest }}
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" && "${MAKE_LATEST}" == "true" ]]; then
TAGS="${TAGS}\n${ECR_REGISTRY}/${ECR_REPOSITORY}:production"
echo "Adding production tag for stable release marked as latest"
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 }}
MAKE_LATEST: ${{ inputs.make_latest }}
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 marked as latest
if [[ "${IS_PRERELEASE}" == "false" && "${MAKE_LATEST}" == "true" ]]; then
TAGS="${TAGS}\nghcr.io/${IMAGE_NAME}:latest"
echo "Added latest tag for stable release marked as latest"
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 "Make Latest: ${{ inputs.make_latest }}"
echo "Version: ${{ steps.version.outputs.version }}"
if [[ "${{ inputs.registry_type }}" == "ecr" ]]; then
echo "ECR Tags: ${{ steps.ecr-tags.outputs.tags }}"
elif [[ "${{ inputs.experimental_mode }}" == "true" ]]; then
echo "GHCR Experimental Tags: ${{ steps.ghcr-meta-experimental.outputs.tags }}"
else
echo "GHCR Extra Tags: ${{ steps.ghcr-extra-tags.outputs.tags }}"
fi
- name: Build and push Docker image
id: build
uses: depot/build-push-action@636daae76684e38c301daa0c5eca1c095b24e780 # v1.14.0
with:
project: tw0fqmsx3c
token: ${{ env.DEPOT_PROJECT_TOKEN }}
context: ${{ inputs.context }}
file: ${{ inputs.dockerfile }}
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ inputs.registry_type == 'ecr' && steps.ecr-tags.outputs.tags || (inputs.registry_type == 'ghcr' && inputs.experimental_mode == 'true' && steps.ghcr-meta-experimental.outputs.tags) || (inputs.registry_type == 'ghcr' && inputs.experimental_mode == 'false' && steps.ghcr-extra-tags.outputs.tags) || (inputs.registry_type == 'ghcr' && format('ghcr.io/{0}:{1}', inputs.ghcr_image_name, steps.version.outputs.version)) || (inputs.registry_type == 'ecr' && format('{0}/{1}:{2}', inputs.ecr_registry, inputs.ecr_repository, steps.version.outputs.version)) }}
labels: ${{ inputs.registry_type == 'ghcr' && inputs.experimental_mode == 'true' && steps.ghcr-meta-experimental.outputs.labels || '' }}
secrets: |
database_url=${{ env.DUMMY_DATABASE_URL }}
encryption_key=${{ env.DUMMY_ENCRYPTION_KEY }}
redis_url=${{ env.DUMMY_REDIS_URL }}
sentry_auth_token=${{ env.SENTRY_AUTH_TOKEN }}
env:
DEPOT_PROJECT_TOKEN: ${{ env.DEPOT_PROJECT_TOKEN }}
DUMMY_DATABASE_URL: ${{ env.DUMMY_DATABASE_URL }}
DUMMY_ENCRYPTION_KEY: ${{ env.DUMMY_ENCRYPTION_KEY }}
DUMMY_REDIS_URL: ${{ env.DUMMY_REDIS_URL }}
SENTRY_AUTH_TOKEN: ${{ env.SENTRY_AUTH_TOKEN }}
- name: Sign GHCR image (GHCR only)
if: ${{ inputs.registry_type == 'ghcr' && (github.event_name == 'workflow_call' || github.event_name == 'release' || github.event_name == 'workflow_dispatch') }}
shell: bash
env:
TAGS: ${{ inputs.experimental_mode == 'true' && steps.ghcr-meta-experimental.outputs.tags || steps.ghcr-extra-tags.outputs.tags }}
DIGEST: ${{ steps.build.outputs.digest }}
run: |
set -euo pipefail
echo "${TAGS}" | xargs -I {} cosign sign --yes "{}@${DIGEST}"
- name: Output build summary
shell: bash
env:
REGISTRY_TYPE: ${{ inputs.registry_type }}
IMAGE_TAG: ${{ steps.version.outputs.version }}
VERSION_SOURCE: ${{ steps.version.outputs.source }}
run: |
echo "SUCCESS: Built and pushed Docker image to $REGISTRY_TYPE"
echo "Image Tag: $IMAGE_TAG (source: $VERSION_SOURCE)"
if [[ "$REGISTRY_TYPE" == "ecr" ]]; then
echo "ECR Registry: ${{ inputs.ecr_registry }}"
echo "ECR Repository: ${{ inputs.ecr_repository }}"
else
echo "GHCR Image: ghcr.io/${{ inputs.ghcr_image_name }}"
fi
@@ -0,0 +1,106 @@
name: Docker Build Setup
description: |
Sets up common Docker build tools and authentication with security validation.
Security Features:
- Registry URL validation
- Input sanitization
- Conditional setup based on event type
- Post-setup verification
Supports Depot CLI, Cosign signing, and Docker registry authentication.
inputs:
registry:
description: "Docker registry hostname to login to (e.g., ghcr.io, registry.example.com:5000). No paths allowed."
required: false
default: "ghcr.io"
setup_cosign:
description: "Whether to install cosign for image signing"
required: false
default: "true"
skip_login_on_pr:
description: "Whether to skip registry login on pull requests"
required: false
default: "true"
runs:
using: "composite"
steps:
- name: Validate inputs
shell: bash
env:
REGISTRY: ${{ inputs.registry }}
SETUP_COSIGN: ${{ inputs.setup_cosign }}
SKIP_LOGIN_ON_PR: ${{ inputs.skip_login_on_pr }}
run: |
set -euo pipefail
# Security: Validate registry input - must be hostname[:port] only, no paths
# Allow empty registry for cases where login is handled externally (e.g., ECR)
if [[ -n "$REGISTRY" ]]; then
if [[ "$REGISTRY" =~ / ]]; then
echo "ERROR: Invalid registry format: $REGISTRY"
echo "Registry must be host[:port] with no path (e.g., 'ghcr.io' or 'registry.example.com:5000')"
echo "Path components like 'ghcr.io/org' are not allowed as they break docker login"
exit 1
fi
# Validate hostname with optional port format
if [[ ! "$REGISTRY" =~ ^[a-zA-Z0-9.-]+(\:[0-9]+)?$ ]]; then
echo "ERROR: Invalid registry hostname format: $REGISTRY"
echo "Registry must be a valid hostname optionally with port (e.g., 'ghcr.io' or 'registry.example.com:5000')"
exit 1
fi
fi
# Validate boolean inputs
if [[ "$SETUP_COSIGN" != "true" && "$SETUP_COSIGN" != "false" ]]; then
echo "ERROR: setup_cosign must be 'true' or 'false', got: $SETUP_COSIGN"
exit 1
fi
if [[ "$SKIP_LOGIN_ON_PR" != "true" && "$SKIP_LOGIN_ON_PR" != "false" ]]; then
echo "ERROR: skip_login_on_pr must be 'true' or 'false', got: $SKIP_LOGIN_ON_PR"
exit 1
fi
echo "SUCCESS: Input validation passed"
- name: Set up Depot CLI
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
- name: Install cosign
# Install cosign when requested AND when we might actually sign images
# (i.e., non-PR contexts or when we login on PRs)
if: ${{ inputs.setup_cosign == 'true' && (inputs.skip_login_on_pr == 'false' || github.event_name != 'pull_request') }}
uses: sigstore/cosign-installer@3454372f43399081ed03b604cb2d021dabca52bb # v3.8.2
- name: Log into registry
if: ${{ inputs.registry != '' && (inputs.skip_login_on_pr == 'false' || github.event_name != 'pull_request') }}
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with:
registry: ${{ inputs.registry }}
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Verify setup completion
shell: bash
run: |
set -euo pipefail
# Verify Depot CLI is available
if ! command -v depot >/dev/null 2>&1; then
echo "ERROR: Depot CLI not found in PATH"
exit 1
fi
# Verify cosign if it should be installed (same conditions as install step)
if [[ "${{ inputs.setup_cosign }}" == "true" ]] && [[ "${{ inputs.skip_login_on_pr }}" == "false" || "${{ github.event_name }}" != "pull_request" ]]; then
if ! command -v cosign >/dev/null 2>&1; then
echo "ERROR: Cosign not found in PATH despite being requested"
exit 1
fi
fi
echo "SUCCESS: Docker build setup completed successfully"
@@ -0,0 +1,192 @@
name: Resolve Docker Version
description: |
Resolves and validates Docker-compatible SemVer versions for container builds with comprehensive security.
Security Features:
- Command injection protection
- Input sanitization and validation
- Docker tag character restrictions
- Length limits and boundary checks
- Safe branch name handling
Supports multiple modes: release, manual override, branch auto-detection, and experimental timestamped versions.
inputs:
version:
description: "Explicit version (SemVer only, e.g., 1.2.3-beta). If provided, this version is used directly. If empty, version is auto-generated from branch name."
required: false
current_branch:
description: "Current branch name for auto-detection"
required: true
experimental_mode:
description: "Enable experimental mode with timestamp-based versions"
required: false
default: "false"
outputs:
version:
description: "Resolved Docker-compatible SemVer version"
value: ${{ steps.resolve.outputs.version }}
source:
description: "Source of version (release|override|branch)"
value: ${{ steps.resolve.outputs.source }}
normalized:
description: "Whether the version was normalized (true/false)"
value: ${{ steps.resolve.outputs.normalized }}
runs:
using: "composite"
steps:
- name: Resolve and validate Docker version
id: resolve
shell: bash
env:
EXPLICIT_VERSION: ${{ inputs.version }}
CURRENT_BRANCH: ${{ inputs.current_branch }}
EXPERIMENTAL_MODE: ${{ inputs.experimental_mode }}
run: |
set -euo pipefail
# Function to validate SemVer format (Docker-compatible, no '+' build metadata)
validate_semver() {
local version="$1"
local context="$2"
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]]; then
echo "ERROR: Invalid $context format. Must be semver without build metadata (e.g., 1.2.3, 1.2.3-alpha)"
echo "Provided: $version"
echo "Note: Docker tags cannot contain '+' characters. Use prerelease identifiers instead."
exit 1
fi
}
# Function to generate branch-based version
generate_branch_version() {
local branch="$1"
local use_timestamp="${2:-true}"
local timestamp
if [[ "$use_timestamp" == "true" ]]; then
timestamp=$(date +%s)
else
timestamp=""
fi
# Sanitize branch name for Docker compatibility
local sanitized_branch=$(echo "$branch" | sed 's/[^a-zA-Z0-9.-]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g')
# Additional safety: truncate if too long (reserve space for prefix and timestamp)
if (( ${#sanitized_branch} > 80 )); then
sanitized_branch="${sanitized_branch:0:80}"
echo "INFO: Branch name truncated for Docker compatibility" >&2
fi
local version
# Generate version based on branch name (unified approach)
# All branches get alpha versions with sanitized branch name
if [[ -n "$timestamp" ]]; then
version="0.0.0-alpha-$sanitized_branch-$timestamp"
echo "INFO: Branch '$branch' detected - alpha version: $version" >&2
else
version="0.0.0-alpha-$sanitized_branch"
echo "INFO: Branch '$branch' detected - alpha version: $version" >&2
fi
echo "$version"
}
# Input validation and sanitization
if [[ -z "$CURRENT_BRANCH" ]]; then
echo "ERROR: current_branch input is required"
exit 1
fi
# Security: Validate inputs to prevent command injection
# Use grep to check for dangerous characters (more reliable than bash regex)
validate_input() {
local input="$1"
local name="$2"
# Check for dangerous characters using grep
if echo "$input" | grep -q '[;|&`$(){}\\[:space:]]'; then
echo "ERROR: $name contains potentially dangerous characters: $input"
echo "Input should only contain letters, numbers, hyphens, underscores, dots, and forward slashes"
return 1
fi
return 0
}
# Validate current branch
if ! validate_input "$CURRENT_BRANCH" "Branch name"; then
exit 1
fi
# Validate explicit version if provided
if [[ -n "$EXPLICIT_VERSION" ]] && ! validate_input "$EXPLICIT_VERSION" "Explicit version"; then
exit 1
fi
# Main resolution logic (ultra-simplified)
NORMALIZED="false"
if [[ -n "$EXPLICIT_VERSION" ]]; then
# Use provided explicit version (from either workflow_call or manual input)
validate_semver "$EXPLICIT_VERSION" "explicit version"
# Normalize to lowercase for Docker/ECR compatibility
RESOLVED_VERSION="${EXPLICIT_VERSION,,}"
if [[ "$EXPLICIT_VERSION" != "$RESOLVED_VERSION" ]]; then
NORMALIZED="true"
echo "INFO: Original version contained uppercase characters, normalized: $EXPLICIT_VERSION -> $RESOLVED_VERSION"
fi
SOURCE="explicit"
echo "INFO: Using explicit version: $RESOLVED_VERSION"
else
# Auto-generate version from branch name
if [[ "$EXPERIMENTAL_MODE" == "true" ]]; then
# Use timestamped version generation
echo "INFO: Experimental mode: generating timestamped version from branch: $CURRENT_BRANCH"
RESOLVED_VERSION=$(generate_branch_version "$CURRENT_BRANCH" "true")
SOURCE="experimental"
else
# Standard branch version (no timestamp)
echo "INFO: Auto-detecting version from branch: $CURRENT_BRANCH"
RESOLVED_VERSION=$(generate_branch_version "$CURRENT_BRANCH" "false")
SOURCE="branch"
fi
echo "Generated version: $RESOLVED_VERSION"
fi
# Final validation - ensure result is valid Docker tag
if [[ -z "$RESOLVED_VERSION" ]]; then
echo "ERROR: Failed to resolve version"
exit 1
fi
if (( ${#RESOLVED_VERSION} > 128 )); then
echo "ERROR: Version must be at most 128 characters (Docker limitation)"
echo "Generated version: $RESOLVED_VERSION (${#RESOLVED_VERSION} chars)"
exit 1
fi
if [[ ! "$RESOLVED_VERSION" =~ ^[a-z0-9._-]+$ ]]; then
echo "ERROR: Version contains invalid characters for Docker tags"
echo "Version: $RESOLVED_VERSION"
exit 1
fi
if [[ "$RESOLVED_VERSION" =~ ^[.-] || "$RESOLVED_VERSION" =~ [.-]$ ]]; then
echo "ERROR: Version must not start or end with '.' or '-'"
echo "Version: $RESOLVED_VERSION"
exit 1
fi
# Output results
echo "SUCCESS: Resolved Docker version: $RESOLVED_VERSION (source: $SOURCE)"
echo "version=$RESOLVED_VERSION" >> $GITHUB_OUTPUT
echo "source=$SOURCE" >> $GITHUB_OUTPUT
echo "normalized=$NORMALIZED" >> $GITHUB_OUTPUT
@@ -0,0 +1,160 @@
name: Update Package Version
description: |
Safely updates package.json version with comprehensive validation and atomic operations.
Security Features:
- Path traversal protection
- SemVer validation with length limits
- Atomic file operations with backup/recovery
- JSON validation before applying changes
This action is designed to be secure by default and prevent common attack vectors.
inputs:
version:
description: "Version to set in package.json (must be valid SemVer)"
required: true
package_path:
description: "Path to package.json file"
required: false
default: "./apps/web/package.json"
outputs:
updated_version:
description: "The version that was actually set in package.json"
value: ${{ steps.update.outputs.updated_version }}
runs:
using: "composite"
steps:
- name: Update and verify package.json version
id: update
shell: bash
env:
VERSION: ${{ inputs.version }}
PACKAGE_PATH: ${{ inputs.package_path }}
run: |
set -euo pipefail
# Validate inputs
if [[ -z "$VERSION" ]]; then
echo "ERROR: version input is required"
exit 1
fi
# Security: Validate package_path to prevent path traversal attacks
# Only allow paths within the workspace and must end with package.json
if [[ "$PACKAGE_PATH" =~ \.\./|^/|^~ ]]; then
echo "ERROR: Invalid package path - path traversal detected: $PACKAGE_PATH"
echo "Package path must be relative to workspace root and cannot contain '../', start with '/', or '~'"
exit 1
fi
if [[ ! "$PACKAGE_PATH" =~ package\.json$ ]]; then
echo "ERROR: Package path must end with 'package.json': $PACKAGE_PATH"
exit 1
fi
# Resolve to absolute path within workspace for additional security
WORKSPACE_ROOT="${GITHUB_WORKSPACE:-$(pwd)}"
# Use realpath to resolve both paths and handle symlinks properly
WORKSPACE_ROOT=$(realpath "$WORKSPACE_ROOT")
RESOLVED_PATH=$(realpath "${WORKSPACE_ROOT}/${PACKAGE_PATH}")
# Ensure WORKSPACE_ROOT has a trailing slash for proper prefix matching
WORKSPACE_ROOT="${WORKSPACE_ROOT}/"
# Use shell string matching to ensure RESOLVED_PATH is within workspace
# This is more secure than regex and handles edge cases properly
if [[ "$RESOLVED_PATH" != "$WORKSPACE_ROOT"* ]]; then
echo "ERROR: Resolved path is outside workspace: $RESOLVED_PATH"
echo "Workspace root: $WORKSPACE_ROOT"
exit 1
fi
if [[ ! -f "$RESOLVED_PATH" ]]; then
echo "ERROR: package.json not found at: $RESOLVED_PATH"
exit 1
fi
# Use resolved path for operations
PACKAGE_PATH="$RESOLVED_PATH"
# Validate SemVer format with additional security checks
if [[ ${#VERSION} -gt 128 ]]; then
echo "ERROR: Version string too long (${#VERSION} chars, max 128): $VERSION"
exit 1
fi
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]]; then
echo "ERROR: Invalid SemVer format: $VERSION"
echo "Expected format: MAJOR.MINOR.PATCH[-PRERELEASE]"
echo "Only alphanumeric characters, dots, and hyphens allowed in prerelease"
exit 1
fi
# Additional validation: Check for reasonable version component sizes
# Extract base version (MAJOR.MINOR.PATCH) without prerelease/build metadata
if [[ "$VERSION" =~ ^([0-9]+\.[0-9]+\.[0-9]+) ]]; then
BASE_VERSION="${BASH_REMATCH[1]}"
else
echo "ERROR: Could not extract base version from: $VERSION"
exit 1
fi
# Split version components safely
IFS='.' read -ra VERSION_PARTS <<< "$BASE_VERSION"
# Validate component sizes (should have exactly 3 parts due to regex above)
if (( ${VERSION_PARTS[0]} > 999 || ${VERSION_PARTS[1]} > 999 || ${VERSION_PARTS[2]} > 999 )); then
echo "ERROR: Version components too large (max 999 each): $VERSION"
echo "Components: ${VERSION_PARTS[0]}.${VERSION_PARTS[1]}.${VERSION_PARTS[2]}"
exit 1
fi
echo "Updating package.json version to: $VERSION"
# Create backup for atomic operations
BACKUP_PATH="${PACKAGE_PATH}.backup.$$"
cp "$PACKAGE_PATH" "$BACKUP_PATH"
# Use jq to safely update the version field with error handling
if ! jq --arg version "$VERSION" '.version = $version' "$PACKAGE_PATH" > "${PACKAGE_PATH}.tmp"; then
echo "ERROR: jq failed to process package.json"
rm -f "${PACKAGE_PATH}.tmp" "$BACKUP_PATH"
exit 1
fi
# Validate the generated JSON before applying changes
if ! jq empty "${PACKAGE_PATH}.tmp" 2>/dev/null; then
echo "ERROR: Generated invalid JSON"
rm -f "${PACKAGE_PATH}.tmp" "$BACKUP_PATH"
exit 1
fi
# Atomic move operation
if ! mv "${PACKAGE_PATH}.tmp" "$PACKAGE_PATH"; then
echo "ERROR: Failed to update package.json"
# Restore backup
mv "$BACKUP_PATH" "$PACKAGE_PATH"
exit 1
fi
# Verify the update was successful
UPDATED_VERSION=$(jq -r '.version' "$PACKAGE_PATH" 2>/dev/null)
if [[ "$UPDATED_VERSION" != "$VERSION" ]]; then
echo "ERROR: Version update failed!"
echo "Expected: $VERSION"
echo "Actual: $UPDATED_VERSION"
# Restore backup
mv "$BACKUP_PATH" "$PACKAGE_PATH"
exit 1
fi
# Clean up backup on success
rm -f "$BACKUP_PATH"
echo "SUCCESS: Updated package.json version to: $UPDATED_VERSION"
echo "updated_version=$UPDATED_VERSION" >> $GITHUB_OUTPUT
+54 -128
View File
@@ -1,12 +1,16 @@
name: Build & Push Docker to ECR
name: Build Cloud Deployment Images
# This workflow builds Formbricks Docker images for ECR deployment:
# - workflow_call: Used by releases with explicit SemVer versions
# - workflow_dispatch: Auto-detects version from current branch or uses override
on:
workflow_dispatch:
inputs:
image_tag:
description: "Image tag to push (e.g., v3.16.1, main)"
required: true
default: "v3.16.1"
version_override:
description: "Override version (SemVer only, e.g., 1.2.3). Leave empty to auto-detect from branch."
required: false
type: string
deploy_production:
description: "Tag image for production deployment"
required: false
@@ -17,6 +21,29 @@ 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
MAKE_LATEST:
description: "Whether to tag for production (from GitHub release 'Set as the latest release' option)"
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 +54,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 +72,23 @@ 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 }}
make_latest: ${{ inputs.MAKE_LATEST }}
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:
+40 -19
View File
@@ -33,7 +33,7 @@ jobs:
timeout-minutes: 60
services:
postgres:
image: pgvector/pgvector:pg17
image: pgvector/pgvector@sha256:9ae02a756ba16a2d69dd78058e25915e36e189bb36ddf01ceae86390d7ed786a
env:
POSTGRES_DB: postgres
POSTGRES_USER: postgres
@@ -41,7 +41,7 @@ jobs:
ports:
- 5432:5432
options: >-
--health-cmd="pg_isready -U testuser"
--health-cmd="pg_isready -U postgres"
--health-interval=10s
--health-timeout=5s
--health-retries=5
@@ -49,25 +49,15 @@ jobs:
image: valkey/valkey@sha256:12ba4f45a7c3e1d0f076acd616cb230834e75a77e8516dde382720af32832d6d
ports:
- 6379:6379
minio:
image: bitnami/minio:2025.7.23-debian-12-r5
env:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
ports:
- 9000:9000
options: >-
--health-cmd="curl -fsS http://localhost:9000/minio/health/live || exit 1"
--health-interval=10s
--health-timeout=5s
--health-retries=20
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: allow
egress-policy: audit
allowed-endpoints: |
ee.formbricks.com:443
registry-1.docker.io:443
docker.io:443
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: ./.github/actions/dangerous-git-checkout
@@ -101,8 +91,8 @@ jobs:
echo "S3_REGION=us-east-1" >> .env
echo "S3_BUCKET_NAME=formbricks-e2e" >> .env
echo "S3_ENDPOINT_URL=http://localhost:9000" >> .env
echo "S3_ACCESS_KEY=minioadmin" >> .env
echo "S3_SECRET_KEY=minioadmin" >> .env
echo "S3_ACCESS_KEY=devminio" >> .env
echo "S3_SECRET_KEY=devminio123" >> .env
echo "S3_FORCE_PATH_STYLE=1" >> .env
shell: bash
@@ -122,6 +112,22 @@ jobs:
chmod +x "${MC_BIN}"
sudo mv "${MC_BIN}" /usr/local/bin/mc
- name: Start MinIO Server
run: |
set -euo pipefail
# Start MinIO server in background
docker run -d \
--name minio-server \
-p 9000:9000 \
-p 9001:9001 \
-e MINIO_ROOT_USER=devminio \
-e MINIO_ROOT_PASSWORD=devminio123 \
minio/minio:RELEASE.2025-09-07T16-13-09Z \
server /data --console-address :9001
echo "MinIO server started"
- name: Wait for MinIO and create S3 bucket
run: |
set -euo pipefail
@@ -142,7 +148,7 @@ jobs:
exit 1
fi
mc alias set local http://localhost:9000 minioadmin minioadmin
mc alias set local http://localhost:9000 devminio devminio123
mc mb --ignore-existing local/formbricks-e2e
- name: Build App
@@ -160,6 +166,12 @@ jobs:
cd apps/web && pnpm vitest run modules/core/rate-limit/rate-limit-load.test.ts
shell: bash
- name: Run Cache Integration Tests
run: |
echo "Running cache integration tests with Redis/Valkey..."
cd packages/cache && pnpm vitest run src/cache-integration.test.ts
shell: bash
- name: Check for Enterprise License
run: |
LICENSE_KEY=$(grep '^ENTERPRISE_LICENSE_KEY=' .env | cut -d'=' -f2-)
@@ -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
+117 -18
View File
@@ -8,16 +8,103 @@ permissions:
contents: read
jobs:
docker-build:
name: Build & release docker image
check-latest-release:
name: Check if this is the latest release
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
outputs:
is_latest: ${{ steps.compare_tags.outputs.is_latest }}
# This job determines if the current release was marked as "Set as the latest release"
# by comparing it with the latest release from GitHub API
steps:
- name: Harden the runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Get latest release tag from API
id: get_latest_release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
# Get the latest release tag from GitHub API with error handling
echo "Fetching latest release from GitHub API..."
# Use curl with error handling - API returns 404 if no releases exist
http_code=$(curl -s -w "%{http_code}" -H "Authorization: token ${GITHUB_TOKEN}" \
"https://api.github.com/repos/${REPO}/releases/latest" -o /tmp/latest_release.json)
if [[ "$http_code" == "404" ]]; then
echo "⚠️ No previous releases found (404). This appears to be the first release."
echo "latest_release=" >> $GITHUB_OUTPUT
elif [[ "$http_code" == "200" ]]; then
latest_release=$(jq -r .tag_name /tmp/latest_release.json)
if [[ "$latest_release" == "null" || -z "$latest_release" ]]; then
echo "⚠️ API returned null/empty tag_name. Treating as first release."
echo "latest_release=" >> $GITHUB_OUTPUT
else
echo "Latest release from API: ${latest_release}"
echo "latest_release=${latest_release}" >> $GITHUB_OUTPUT
fi
else
echo "❌ GitHub API error (HTTP ${http_code}). Treating as first release."
echo "latest_release=" >> $GITHUB_OUTPUT
fi
echo "Current release tag: ${{ github.event.release.tag_name }}"
- name: Compare release tags
id: compare_tags
env:
CURRENT_TAG: ${{ github.event.release.tag_name }}
LATEST_TAG: ${{ steps.get_latest_release.outputs.latest_release }}
run: |
set -euo pipefail
# Handle first release case (no previous releases)
if [[ -z "${LATEST_TAG}" ]]; then
echo "🎉 This is the first release (${CURRENT_TAG}) - treating as latest"
echo "is_latest=true" >> $GITHUB_OUTPUT
elif [[ "${CURRENT_TAG}" == "${LATEST_TAG}" ]]; then
echo "✅ This release (${CURRENT_TAG}) is marked as the latest release"
echo "is_latest=true" >> $GITHUB_OUTPUT
else
echo "️ This release (${CURRENT_TAG}) is not the latest release (latest: ${LATEST_TAG})"
echo "is_latest=false" >> $GITHUB_OUTPUT
fi
docker-build-community:
name: Build & release community docker image
permissions:
contents: read
packages: write
id-token: write
uses: ./.github/workflows/release-docker-github.yml
secrets: inherit
needs:
- check-latest-release
with:
IS_PRERELEASE: ${{ github.event.release.prerelease }}
MAKE_LATEST: ${{ needs.check-latest-release.outputs.is_latest }}
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 }}
MAKE_LATEST: ${{ needs.check-latest-release.outputs.is_latest }}
needs:
- check-latest-release
- docker-build-community
helm-chart-release:
name: Release Helm Chart
@@ -27,32 +114,44 @@ jobs:
uses: ./.github/workflows/release-helm-chart.yml
secrets: inherit
needs:
- docker-build
- docker-build-community
with:
VERSION: ${{ needs.docker-build.outputs.VERSION }}
VERSION: ${{ needs.docker-build-community.outputs.VERSION }}
deploy-formbricks-cloud:
name: Deploy Helm Chart to Formbricks Cloud
permissions:
contents: read
id-token: write
secrets: inherit
uses: ./.github/workflows/deploy-formbricks-cloud.yml
verify-cloud-build:
name: Verify Cloud Build Outputs
runs-on: ubuntu-latest
timeout-minutes: 5 # Simple verification should be quick
needs:
- docker-build
- helm-chart-release
with:
VERSION: v${{ needs.docker-build.outputs.VERSION }}
ENVIRONMENT: ${{ github.event.release.prerelease && 'staging' || 'production' }}
- docker-build-cloud
steps:
- name: Harden the runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Display ECR build outputs
env:
IMAGE_TAG: ${{ needs.docker-build-cloud.outputs.IMAGE_TAG }}
TAGS: ${{ needs.docker-build-cloud.outputs.TAGS }}
run: |
set -euo pipefail
echo "✅ ECR Build Completed Successfully"
echo "Image Tag: ${IMAGE_TAG}"
echo "ECR Tags:"
printf '%s\n' "${TAGS}"
move-stable-tag:
name: Move stable tag to release
permissions:
contents: read
contents: write # Required for tag push operations in called workflow
uses: ./.github/workflows/move-stable-tag.yml
needs:
- docker-build # Ensure release is successful first
- check-latest-release
- docker-build-community # Ensure release is successful first
with:
release_tag: ${{ github.event.release.tag_name }}
commit_sha: ${{ github.sha }}
is_prerelease: ${{ github.event.release.prerelease }}
make_latest: ${{ needs.check-latest-release.outputs.is_latest }}
+10 -5
View File
@@ -4,7 +4,7 @@ on:
workflow_call:
inputs:
release_tag:
description: "The release tag name (e.g., v1.2.3)"
description: "The release tag name (e.g., 1.2.3)"
required: true
type: string
commit_sha:
@@ -16,6 +16,11 @@ on:
required: false
type: boolean
default: false
make_latest:
description: "Whether to move stable tag (from GitHub release 'Set as the latest release' option)"
required: false
type: boolean
default: false
permissions:
contents: read
@@ -32,8 +37,8 @@ jobs:
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 }}
# Only move stable tag for non-prerelease versions AND when make_latest is true
if: ${{ !inputs.is_prerelease && inputs.make_latest }}
steps:
- name: Harden the runner
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
@@ -53,8 +58,8 @@ jobs:
set -euo pipefail
# Validate release tag format
if [[ ! "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$ ]]; then
echo "❌ Error: Invalid release tag format. Expected format: v1.2.3, v1.2.3-alpha"
if [[ ! "$RELEASE_TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$ ]]; then
echo "❌ Error: Invalid release tag format. Expected format: 1.2.3, 1.2.3-alpha"
echo "Provided: $RELEASE_TAG"
exit 1
fi
@@ -1,39 +1,31 @@
name: Docker Release to Github Experimental
name: Build Community Testing Images
# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
# This workflow builds experimental/testing versions of Formbricks for self-hosting customers
# to test fixes and features before official releases. Images are pushed to GHCR with
# timestamped experimental versions for easy identification and testing.
on:
workflow_dispatch:
env:
# Use docker.io for Docker Hub if empty
REGISTRY: ghcr.io
# github.repository as <account>/<repo>
IMAGE_NAME: ${{ github.repository }}-experimental
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
inputs:
version_override:
description: "Override version (SemVer only, e.g., 1.2.3-beta). Leave empty for auto-generated experimental version."
required: false
type: string
permissions:
contents: read
packages: write
id-token: write
jobs:
build:
build-community-testing:
name: Build Community Testing Image
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
# This is used to complete the identity challenge
# with sigstore/fulcio when running outside of PRs.
id-token: write
timeout-minutes: 45
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
@@ -42,110 +34,17 @@ jobs:
with:
fetch-depth: 0
- name: Generate SemVer version from branch or tag
id: generate_version
- name: Build and push community testing image
uses: ./.github/actions/build-and-push-docker
with:
registry_type: "ghcr"
ghcr_image_name: "${{ github.repository }}-experimental"
experimental_mode: "true"
version: ${{ inputs.version_override }}
env:
REF_NAME: ${{ github.ref_name }}
REF_TYPE: ${{ github.ref_type }}
run: |
# Get reference name and type from environment variables
echo "Reference type: $REF_TYPE"
echo "Reference name: $REF_NAME"
# Create unique timestamped version for testing sourcemap resolution
TIMESTAMP=$(date +%s)
if [[ "$REF_TYPE" == "tag" ]]; then
# If running from a tag, use the tag name + timestamp
if [[ "$REF_NAME" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+.*$ ]]; then
# Tag looks like a SemVer, use it directly (remove 'v' prefix if present)
BASE_VERSION=$(echo "$REF_NAME" | sed 's/^v//')
VERSION="${BASE_VERSION}-${TIMESTAMP}"
echo "Using SemVer tag with timestamp: $VERSION"
else
# Tag is not SemVer, treat as prerelease
SANITIZED_TAG=$(echo "$REF_NAME" | sed 's/[^a-zA-Z0-9.-]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g')
VERSION="0.0.0-${SANITIZED_TAG}-${TIMESTAMP}"
echo "Using tag as prerelease with timestamp: $VERSION"
fi
else
# Running from branch, use branch name as prerelease + timestamp
SANITIZED_BRANCH=$(echo "$REF_NAME" | sed 's/[^a-zA-Z0-9.-]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g')
VERSION="0.0.0-${SANITIZED_BRANCH}-${TIMESTAMP}"
echo "Using branch as prerelease with timestamp: $VERSION"
fi
echo "VERSION=$VERSION" >> $GITHUB_ENV
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
echo "Generated SemVer version: $VERSION"
- name: Update package.json version
run: |
sed -i "s/\"version\": \"0.0.0\"/\"version\": \"${{ env.VERSION }}\"/" ./apps/web/package.json
cat ./apps/web/package.json | grep version
- name: Set up Depot CLI
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
# Install the cosign tool except on PR
# https://github.com/sigstore/cosign-installer
- name: Install cosign
if: github.event_name != 'pull_request'
uses: sigstore/cosign-installer@3454372f43399081ed03b604cb2d021dabca52bb # v3.8.2
# Login against a Docker registry except on PR
# https://github.com/docker/login-action
- name: Log into registry ${{ env.REGISTRY }}
if: github.event_name != 'pull_request'
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Extract metadata (tags, labels) for Docker
# https://github.com/docker/metadata-action
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=raw,value=${{ env.VERSION }}
# Build and push Docker image with Buildx (don't push on PR)
# https://github.com/docker/build-push-action
- name: Build and push Docker image
id: build-and-push
uses: depot/build-push-action@636daae76684e38c301daa0c5eca1c095b24e780 # v1.14.0
with:
project: tw0fqmsx3c
token: ${{ secrets.DEPOT_PROJECT_TOKEN }}
context: .
file: ./apps/web/Dockerfile
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
secrets: |
database_url=${{ secrets.DUMMY_DATABASE_URL }}
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
sentry_auth_token=${{ secrets.SENTRY_AUTH_TOKEN }}
# Sign the resulting Docker image digest except on PRs.
# This will only write to the public Rekor transparency log when the Docker
# repository is public to avoid leaking data. If you would like to publish
# transparency data even for private images, pass --force to cosign below.
# https://github.com/sigstore/cosign
- name: Sign the published Docker image
if: ${{ github.event_name != 'pull_request' }}
env:
# https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable
TAGS: ${{ steps.meta.outputs.tags }}
DIGEST: ${{ steps.build-and-push.outputs.digest }}
# This step uses the identity token to provision an ephemeral certificate
# against the sigstore community Fulcio instance.
run: echo "${TAGS}" | xargs -I {} cosign sign --yes "{}@${DIGEST}"
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DEPOT_PROJECT_TOKEN: ${{ secrets.DEPOT_PROJECT_TOKEN }}
DUMMY_DATABASE_URL: ${{ secrets.DUMMY_DATABASE_URL }}
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
DUMMY_REDIS_URL: ${{ secrets.DUMMY_REDIS_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
+49 -87
View File
@@ -1,4 +1,4 @@
name: Docker Release to Github
name: Release Community Docker Images
# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
@@ -13,6 +13,11 @@ on:
required: false
type: boolean
default: false
MAKE_LATEST:
description: "Whether to tag as latest (from GitHub release 'Set as the latest release' option)"
required: false
type: boolean
default: false
outputs:
VERSION:
description: release version
@@ -23,8 +28,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 +35,7 @@ permissions:
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 45
permissions:
contents: read
packages: write
@@ -44,103 +48,61 @@ 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 }}
make_latest: ${{ inputs.MAKE_LATEST }}
env:
# https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable
TAGS: ${{ steps.meta.outputs.tags }}
DIGEST: ${{ steps.build-and-push.outputs.digest }}
# This step uses the identity token to provision an ephemeral certificate
# against the sigstore community Fulcio instance.
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DEPOT_PROJECT_TOKEN: ${{ secrets.DEPOT_PROJECT_TOKEN }}
DUMMY_DATABASE_URL: ${{ secrets.DUMMY_DATABASE_URL }}
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
DUMMY_REDIS_URL: ${{ secrets.DUMMY_REDIS_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
+25 -4
View File
@@ -19,7 +19,7 @@ jobs:
contents: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
@@ -59,14 +59,35 @@ jobs:
uses: dcarbone/install-yq-action@4075b4dca348d74bd83f2bf82d30f25d7c54539b # v1.3.1
- name: Update Chart.yaml with new version
env:
VERSION: ${{ env.VERSION }}
run: |
yq -i ".version = \"$VERSION\"" helm-chart/Chart.yaml
yq -i ".appVersion = \"v$VERSION\"" helm-chart/Chart.yaml
set -euo pipefail
echo "Updating Chart.yaml with version: ${VERSION}"
yq -i ".version = \"${VERSION}\"" helm-chart/Chart.yaml
yq -i ".appVersion = \"${VERSION}\"" helm-chart/Chart.yaml
echo "✅ Successfully updated Chart.yaml"
- name: Package Helm chart
env:
VERSION: ${{ env.VERSION }}
run: |
set -euo pipefail
echo "Packaging Helm chart version: ${VERSION}"
helm package ./helm-chart
echo "✅ Successfully packaged formbricks-${VERSION}.tgz"
- name: Push Helm chart to GitHub Container Registry
env:
VERSION: ${{ env.VERSION }}
run: |
helm push "formbricks-$VERSION.tgz" oci://ghcr.io/formbricks/helm-charts
set -euo pipefail
echo "Pushing Helm chart to registry: formbricks-${VERSION}.tgz"
helm push "formbricks-${VERSION}.tgz" oci://ghcr.io/formbricks/helm-charts
echo "✅ Successfully pushed Helm chart to registry"
@@ -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")} />
@@ -1,5 +1,3 @@
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { getLatestStableFbReleaseAction } from "@/modules/projects/settings/(setup)/app-connection/actions";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { usePathname, useRouter } from "next/navigation";
@@ -8,6 +6,8 @@ import { TEnvironment } from "@formbricks/types/environment";
import { TOrganization } from "@formbricks/types/organizations";
import { TProject } from "@formbricks/types/project";
import { TUser } from "@formbricks/types/user";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { getLatestStableFbReleaseAction } from "@/modules/projects/settings/(setup)/app-connection/actions";
import { MainNavigation } from "./MainNavigation";
// Mock constants that this test needs
@@ -210,9 +210,10 @@ describe("MainNavigation", () => {
expect(userTrigger).toBeInTheDocument(); // Ensure the trigger element is found
await userEvent.click(userTrigger);
// Wait for the dropdown content to appear
// Wait for the dropdown content to appear - using getAllByText to handle multiple instances
await waitFor(() => {
expect(screen.getByText("common.account")).toBeInTheDocument();
const accountElements = screen.getAllByText("common.account");
expect(accountElements).toHaveLength(2);
});
expect(screen.getByText("common.documentation")).toBeInTheDocument();
@@ -1,5 +1,18 @@
"use client";
import { useTranslate } from "@tolgee/react";
import { PlusIcon, TrashIcon } from "lucide-react";
import Image from "next/image";
import React, { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { TIntegrationInput } from "@formbricks/types/integration";
import {
TIntegrationNotion,
TIntegrationNotionConfigData,
TIntegrationNotionDatabase,
} from "@formbricks/types/integration/notion";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
import {
ERRORS,
@@ -23,19 +36,6 @@ import {
} from "@/modules/ui/components/dialog";
import { DropdownSelector } from "@/modules/ui/components/dropdown-selector";
import { Label } from "@/modules/ui/components/label";
import { useTranslate } from "@tolgee/react";
import { PlusIcon, TrashIcon } from "lucide-react";
import Image from "next/image";
import React, { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { TIntegrationInput } from "@formbricks/types/integration";
import {
TIntegrationNotion,
TIntegrationNotionConfigData,
TIntegrationNotionDatabase,
} from "@formbricks/types/integration/notion";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
interface AddIntegrationModalProps {
environmentId: string;
@@ -134,13 +134,12 @@ export const AddIntegrationModal = ({
type: TSurveyQuestionTypeEnum.OpenText,
})) || [];
const hiddenFields = selectedSurvey?.hiddenFields.enabled
? selectedSurvey?.hiddenFields.fieldIds?.map((fId) => ({
id: fId,
name: `${t("common.hidden_field")} : ${fId}`,
type: TSurveyQuestionTypeEnum.OpenText,
})) || []
: [];
const hiddenFields =
selectedSurvey?.hiddenFields.fieldIds?.map((fId) => ({
id: fId,
name: `${t("common.hidden_field")} : ${fId}`,
type: TSurveyQuestionTypeEnum.OpenText,
})) || [];
const Metadata = [
{
id: "metadata",
@@ -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 && (
@@ -1,5 +1,3 @@
import { ResponseCardModal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseCardModal";
import { SingleResponseCard } from "@/modules/analysis/components/SingleResponseCard";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
@@ -8,6 +6,8 @@ import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TTag } from "@formbricks/types/tags";
import { TUser, TUserLocale } from "@formbricks/types/user";
import { ResponseCardModal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseCardModal";
import { SingleResponseCard } from "@/modules/analysis/components/SingleResponseCard";
vi.mock("@/modules/analysis/components/SingleResponseCard", () => ({
SingleResponseCard: vi.fn(() => <div data-testid="single-response-card">SingleResponseCard</div>),
@@ -46,6 +46,11 @@ vi.mock("@/modules/ui/components/dialog", () => ({
)),
DialogBody: vi.fn(({ children }) => <div data-testid="dialog-body">{children}</div>),
DialogFooter: vi.fn(({ children }) => <div data-testid="dialog-footer">{children}</div>),
DialogTitle: vi.fn(({ children }) => <div data-testid="dialog-title">{children}</div>),
}));
vi.mock("@radix-ui/react-visually-hidden", () => ({
VisuallyHidden: vi.fn(({ children }) => <div data-testid="visually-hidden">{children}</div>),
}));
const mockResponses = [
@@ -1,6 +1,4 @@
import { SingleResponseCard } from "@/modules/analysis/components/SingleResponseCard";
import { Button } from "@/modules/ui/components/button";
import { Dialog, DialogBody, DialogContent, DialogFooter } from "@/modules/ui/components/dialog";
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useEffect, useState } from "react";
import { TEnvironment } from "@formbricks/types/environment";
@@ -8,6 +6,9 @@ import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TTag } from "@formbricks/types/tags";
import { TUser, TUserLocale } from "@formbricks/types/user";
import { SingleResponseCard } from "@/modules/analysis/components/SingleResponseCard";
import { Button } from "@/modules/ui/components/button";
import { Dialog, DialogBody, DialogContent, DialogFooter, DialogTitle } from "@/modules/ui/components/dialog";
interface ResponseCardModalProps {
responses: TResponse[];
@@ -77,6 +78,9 @@ export const ResponseCardModal = ({
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent width="wide">
<VisuallyHidden asChild>
<DialogTitle>Survey Response Details</DialogTitle>
</VisuallyHidden>
<DialogBody>
<SingleResponseCard
survey={survey}
@@ -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")}
@@ -1,12 +1,4 @@
import "server-only";
import { getQuotasSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/survey";
import { RESPONSES_PER_PAGE } from "@/lib/constants";
import { getDisplayCountBySurveyId } from "@/lib/display/service";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { buildWhereClause } from "@/lib/response/utils";
import { getSurvey } from "@/lib/survey/service";
import { evaluateLogic, performActions } from "@/lib/surveyLogic/utils";
import { validateInputs } from "@/lib/utils/validate";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { z } from "zod";
@@ -41,6 +33,14 @@ import {
TSurveyQuestionTypeEnum,
TSurveySummary,
} from "@formbricks/types/surveys/types";
import { getQuotasSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/survey";
import { RESPONSES_PER_PAGE } from "@/lib/constants";
import { getDisplayCountBySurveyId } from "@/lib/display/service";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { buildWhereClause } from "@/lib/response/utils";
import { getSurvey } from "@/lib/survey/service";
import { evaluateLogic, performActions } from "@/lib/surveyLogic/utils";
import { validateInputs } from "@/lib/utils/validate";
import { convertFloatTo2Decimal } from "./utils";
interface TSurveySummaryResponse {
@@ -345,20 +345,23 @@ export const getQuestionSummary = async (
case TSurveyQuestionTypeEnum.MultipleChoiceSingle:
case TSurveyQuestionTypeEnum.MultipleChoiceMulti: {
let values: TSurveyQuestionSummaryMultipleChoice["choices"] = [];
// check last choice is others or not
const lastChoice = question.choices[question.choices.length - 1];
const isOthersEnabled = lastChoice.id === "other";
const questionChoices = question.choices.map((choice) => getLocalizedValue(choice.label, "default"));
if (isOthersEnabled) {
questionChoices.pop();
}
const otherOption = question.choices.find((choice) => choice.id === "other");
const noneOption = question.choices.find((choice) => choice.id === "none");
const questionChoices = question.choices
.filter((choice) => choice.id !== "other" && choice.id !== "none")
.map((choice) => getLocalizedValue(choice.label, "default"));
const choiceCountMap = questionChoices.reduce((acc: Record<string, number>, choice) => {
acc[choice] = 0;
return acc;
}, {});
// Track "none" count separately
const noneLabel = noneOption ? getLocalizedValue(noneOption.label, "default") : null;
let noneCount = 0;
const otherValues: TSurveyQuestionSummaryMultipleChoice["choices"][number]["others"] = [];
let totalSelectionCount = 0;
let totalResponseCount = 0;
@@ -378,7 +381,9 @@ export const getQuestionSummary = async (
totalSelectionCount++;
if (questionChoices.includes(value)) {
choiceCountMap[value]++;
} else if (isOthersEnabled) {
} else if (noneLabel && value === noneLabel) {
noneCount++;
} else if (otherOption) {
otherValues.push({
value,
contact: response.contact,
@@ -396,7 +401,9 @@ export const getQuestionSummary = async (
totalSelectionCount++;
if (questionChoices.includes(answer)) {
choiceCountMap[answer]++;
} else if (isOthersEnabled) {
} else if (noneLabel && answer === noneLabel) {
noneCount++;
} else if (otherOption) {
otherValues.push({
value: answer,
contact: response.contact,
@@ -421,9 +428,9 @@ export const getQuestionSummary = async (
});
});
if (isOthersEnabled) {
if (otherOption) {
values.push({
value: getLocalizedValue(lastChoice.label, "default") || "Other",
value: getLocalizedValue(otherOption.label, "default") || "Other",
count: otherValues.length,
percentage:
totalResponseCount > 0
@@ -432,6 +439,17 @@ export const getQuestionSummary = async (
others: otherValues.slice(0, VALUES_LIMIT),
});
}
// Add "none" option at the end if it exists
if (noneOption && noneLabel) {
values.push({
value: noneLabel,
count: noneCount,
percentage:
totalResponseCount > 0 ? convertFloatTo2Decimal((noneCount / totalResponseCount) * 100) : 0,
});
}
summary.push({
type: question.type,
question,
@@ -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");
+53 -36
View File
@@ -1,22 +1,12 @@
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
import { NextRequest } from "next/server";
import { describe, expect, test, vi } from "vitest";
import { TAPIKeyEnvironmentPermission } from "@formbricks/types/auth";
import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { TAPIKeyEnvironmentPermission } from "@formbricks/types/auth";
import { authenticateRequest } from "./auth";
vi.mock("@formbricks/database", () => ({
prisma: {
apiKey: {
findUnique: vi.fn(),
update: vi.fn(),
},
},
}));
vi.mock("@/modules/api/v2/management/lib/utils", () => ({
hashApiKey: vi.fn(),
vi.mock("@/modules/organization/settings/api-keys/lib/api-key", () => ({
getApiKeyWithPermissions: vi.fn(),
}));
describe("getApiKeyWithPermissions", () => {
@@ -24,6 +14,7 @@ describe("getApiKeyWithPermissions", () => {
const mockApiKeyData = {
id: "api-key-id",
organizationId: "org-id",
organizationAccess: "all" as const,
hashedKey: "hashed-key",
createdAt: new Date(),
createdBy: "user-id",
@@ -33,26 +24,29 @@ describe("getApiKeyWithPermissions", () => {
{
environmentId: "env-1",
permission: "manage" as const,
environment: { id: "env-1" },
environment: {
id: "env-1",
createdAt: new Date(),
updatedAt: new Date(),
type: "development" as const,
projectId: "project-1",
appSetupCompleted: true,
project: { id: "project-1", name: "Project 1" },
},
},
],
};
vi.mocked(hashApiKey).mockReturnValue("hashed-key");
vi.mocked(prisma.apiKey.findUnique).mockResolvedValue(mockApiKeyData);
vi.mocked(prisma.apiKey.update).mockResolvedValue(mockApiKeyData);
vi.mocked(getApiKeyWithPermissions).mockResolvedValue(mockApiKeyData as any);
const result = await getApiKeyWithPermissions("test-api-key");
expect(result).toEqual(mockApiKeyData);
expect(prisma.apiKey.update).toHaveBeenCalledWith({
where: { id: "api-key-id" },
data: { lastUsedAt: expect.any(Date) },
});
expect(getApiKeyWithPermissions).toHaveBeenCalledWith("test-api-key");
});
test("returns null when API key is not found", async () => {
vi.mocked(prisma.apiKey.findUnique).mockResolvedValue(null);
vi.mocked(getApiKeyWithPermissions).mockResolvedValue(null);
const result = await getApiKeyWithPermissions("invalid-key");
@@ -110,14 +104,14 @@ describe("hasPermission", () => {
describe("authenticateRequest", () => {
test("should return authentication data for valid API key", async () => {
const request = new Request("http://localhost", {
const request = new NextRequest("http://localhost", {
headers: { "x-api-key": "valid-api-key" },
});
const mockApiKeyData = {
id: "api-key-id",
organizationId: "org-id",
hashedKey: "hashed-key",
organizationAccess: "all" as const,
createdAt: new Date(),
createdBy: "user-id",
lastUsedAt: null,
@@ -128,18 +122,18 @@ describe("authenticateRequest", () => {
permission: "manage" as const,
environment: {
id: "env-1",
createdAt: new Date(),
updatedAt: new Date(),
type: "development" as const,
projectId: "project-1",
project: { name: "Project 1" },
type: "development",
appSetupCompleted: true,
project: { id: "project-1", name: "Project 1" },
},
},
],
};
vi.mocked(hashApiKey).mockReturnValue("hashed-key");
vi.mocked(prisma.apiKey.findUnique).mockResolvedValue(mockApiKeyData);
vi.mocked(prisma.apiKey.update).mockResolvedValue(mockApiKeyData);
vi.mocked(getApiKeyWithPermissions).mockResolvedValue(mockApiKeyData as any);
const result = await authenticateRequest(request);
expect(result).toEqual({
@@ -153,24 +147,47 @@ describe("authenticateRequest", () => {
projectName: "Project 1",
},
],
hashedApiKey: "hashed-key",
apiKeyId: "api-key-id",
organizationId: "org-id",
organizationAccess: "all",
});
expect(getApiKeyWithPermissions).toHaveBeenCalledWith("valid-api-key");
});
test("returns null when no API key is provided", async () => {
const request = new Request("http://localhost");
const request = new NextRequest("http://localhost");
const result = await authenticateRequest(request);
expect(result).toBeNull();
});
test("returns null when API key is invalid", async () => {
const request = new Request("http://localhost", {
const request = new NextRequest("http://localhost", {
headers: { "x-api-key": "invalid-api-key" },
});
vi.mocked(prisma.apiKey.findUnique).mockResolvedValue(null);
vi.mocked(getApiKeyWithPermissions).mockResolvedValue(null);
const result = await authenticateRequest(request);
expect(result).toBeNull();
});
test("returns null when API key has no environment permissions", async () => {
const request = new NextRequest("http://localhost", {
headers: { "x-api-key": "valid-api-key" },
});
const mockApiKeyData = {
id: "api-key-id",
organizationId: "org-id",
organizationAccess: "all" as const,
createdAt: new Date(),
createdBy: "user-id",
lastUsedAt: null,
label: "Test API Key",
apiKeyEnvironments: [],
};
vi.mocked(getApiKeyWithPermissions).mockResolvedValue(mockApiKeyData as any);
const result = await authenticateRequest(request);
expect(result).toBeNull();
+2 -5
View File
@@ -1,9 +1,8 @@
import { responses } from "@/app/lib/api/response";
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key";
import { NextRequest } from "next/server";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { responses } from "@/app/lib/api/response";
import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key";
export const authenticateRequest = async (request: NextRequest): Promise<TAuthenticationApiKey | null> => {
const apiKey = request.headers.get("x-api-key");
@@ -17,7 +16,6 @@ export const authenticateRequest = async (request: NextRequest): Promise<TAuthen
const environmentIds = apiKeyData.apiKeyEnvironments.map((env) => env.environmentId);
if (environmentIds.length === 0) return null;
const hashedApiKey = hashApiKey(apiKey);
const authentication: TAuthenticationApiKey = {
type: "apiKey",
environmentPermissions: apiKeyData.apiKeyEnvironments.map((env) => ({
@@ -27,7 +25,6 @@ export const authenticateRequest = async (request: NextRequest): Promise<TAuthen
projectId: env.environment.projectId,
projectName: env.environment.project.name,
})),
hashedApiKey,
apiKeyId: apiKeyData.id,
organizationId: apiKeyData.organizationId,
organizationAccess: apiKeyData.organizationAccess,
@@ -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}`
),
};
}
+180 -83
View File
@@ -1,94 +1,191 @@
import { getSessionUser } from "@/app/api/v1/management/me/lib/utils";
import { responses } from "@/app/lib/api/response";
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { headers } from "next/headers";
import { prisma } from "@formbricks/database";
import { getSessionUser } from "@/app/api/v1/management/me/lib/utils";
import { responses } from "@/app/lib/api/response";
import { CONTROL_HASH } from "@/lib/constants";
import { hashSha256, parseApiKeyV2, verifySecret } from "@/lib/crypto";
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
const ALLOWED_PERMISSIONS = ["manage", "read", "write"] as const;
const apiKeySelect = {
id: true,
organizationId: true,
lastUsedAt: true,
apiKeyEnvironments: {
select: {
environment: {
select: {
id: true,
type: true,
createdAt: true,
updatedAt: true,
projectId: true,
appSetupCompleted: true,
project: {
select: {
id: true,
name: true,
},
},
},
},
permission: true,
},
},
hashedKey: true,
};
type ApiKeyData = {
id: string;
hashedKey: string;
organizationId: string;
lastUsedAt: Date | null;
apiKeyEnvironments: Array<{
permission: string;
environment: {
id: string;
type: string;
createdAt: Date;
updatedAt: Date;
projectId: string;
appSetupCompleted: boolean;
project: {
id: string;
name: string;
};
};
}>;
};
const validateApiKey = async (apiKey: string): Promise<ApiKeyData | null> => {
const v2Parsed = parseApiKeyV2(apiKey);
if (v2Parsed) {
return validateV2ApiKey(v2Parsed);
}
return validateLegacyApiKey(apiKey);
};
const validateV2ApiKey = async (v2Parsed: { secret: string }): Promise<ApiKeyData | null> => {
// Step 1: Fast SHA-256 lookup by indexed lookupHash
const lookupHash = hashSha256(v2Parsed.secret);
const apiKeyData = await prisma.apiKey.findUnique({
where: { lookupHash },
select: apiKeySelect,
});
// Step 2: Security verification with bcrypt
// Always perform bcrypt verification to prevent timing attacks
// Use a control hash when API key doesn't exist to maintain constant timing
const hashToVerify = apiKeyData?.hashedKey || CONTROL_HASH;
const isValid = await verifySecret(v2Parsed.secret, hashToVerify);
if (!apiKeyData || !isValid) return null;
return apiKeyData;
};
const validateLegacyApiKey = async (apiKey: string): Promise<ApiKeyData | null> => {
const hashedKey = hashSha256(apiKey);
const result = await prisma.apiKey.findFirst({
where: { hashedKey },
select: apiKeySelect,
});
return result;
};
const checkRateLimit = async (userId: string) => {
try {
await applyRateLimit(rateLimitConfigs.api.v1, userId);
} catch (error) {
return responses.tooManyRequestsResponse(error.message);
}
return null;
};
const updateApiKeyUsage = async (apiKeyId: string) => {
await prisma.apiKey.update({
where: { id: apiKeyId },
data: { lastUsedAt: new Date() },
});
};
const buildEnvironmentResponse = (apiKeyData: ApiKeyData) => {
const env = apiKeyData.apiKeyEnvironments[0].environment;
return Response.json({
id: env.id,
type: env.type,
createdAt: env.createdAt,
updatedAt: env.updatedAt,
appSetupCompleted: env.appSetupCompleted,
project: {
id: env.projectId,
name: env.project.name,
},
});
};
const isValidApiKeyEnvironment = (apiKeyData: ApiKeyData): boolean => {
return (
apiKeyData.apiKeyEnvironments.length === 1 &&
ALLOWED_PERMISSIONS.includes(
apiKeyData.apiKeyEnvironments[0].permission as (typeof ALLOWED_PERMISSIONS)[number]
)
);
};
const handleApiKeyAuthentication = async (apiKey: string) => {
const apiKeyData = await validateApiKey(apiKey);
if (!apiKeyData) {
return responses.notAuthenticatedResponse();
}
if (!apiKeyData.lastUsedAt || apiKeyData.lastUsedAt <= new Date(Date.now() - 1000 * 30)) {
// Fire-and-forget: update lastUsedAt in the background without blocking the response
updateApiKeyUsage(apiKeyData.id).catch((error) => {
console.error("Failed to update API key usage:", error);
});
}
const rateLimitError = await checkRateLimit(apiKeyData.id);
if (rateLimitError) return rateLimitError;
if (!isValidApiKeyEnvironment(apiKeyData)) {
return responses.badRequestResponse("You can't use this method with this API key");
}
return buildEnvironmentResponse(apiKeyData);
};
const handleSessionAuthentication = async () => {
const sessionUser = await getSessionUser();
if (!sessionUser) {
return responses.notAuthenticatedResponse();
}
const rateLimitError = await checkRateLimit(sessionUser.id);
if (rateLimitError) return rateLimitError;
const user = await prisma.user.findUnique({
where: { id: sessionUser.id },
});
return Response.json(user);
};
export const GET = async () => {
const headersList = await headers();
const apiKey = headersList.get("x-api-key");
if (apiKey) {
const hashedApiKey = hashApiKey(apiKey);
const apiKeyData = await prisma.apiKey.findUnique({
where: {
hashedKey: hashedApiKey,
},
select: {
apiKeyEnvironments: {
select: {
environment: {
select: {
id: true,
type: true,
createdAt: true,
updatedAt: true,
projectId: true,
appSetupCompleted: true,
project: {
select: {
id: true,
name: true,
},
},
},
},
permission: true,
},
},
},
});
if (!apiKeyData) {
return responses.notAuthenticatedResponse();
}
try {
await applyRateLimit(rateLimitConfigs.api.v1, hashedApiKey);
} catch (error) {
return responses.tooManyRequestsResponse(error.message);
}
if (
apiKeyData.apiKeyEnvironments.length === 1 &&
ALLOWED_PERMISSIONS.includes(apiKeyData.apiKeyEnvironments[0].permission)
) {
return Response.json({
id: apiKeyData.apiKeyEnvironments[0].environment.id,
type: apiKeyData.apiKeyEnvironments[0].environment.type,
createdAt: apiKeyData.apiKeyEnvironments[0].environment.createdAt,
updatedAt: apiKeyData.apiKeyEnvironments[0].environment.updatedAt,
appSetupCompleted: apiKeyData.apiKeyEnvironments[0].environment.appSetupCompleted,
project: {
id: apiKeyData.apiKeyEnvironments[0].environment.projectId,
name: apiKeyData.apiKeyEnvironments[0].environment.project.name,
},
});
} else {
return responses.badRequestResponse("You can't use this method with this API key");
}
} else {
const sessionUser = await getSessionUser();
if (!sessionUser) {
return responses.notAuthenticatedResponse();
}
try {
await applyRateLimit(rateLimitConfigs.api.v1, sessionUser.id);
} catch (error) {
return responses.tooManyRequestsResponse(error.message);
}
const user = await prisma.user.findUnique({
where: {
id: sessionUser.id,
},
});
return Response.json(user);
return handleApiKeyAuthentication(apiKey);
}
return handleSessionAuthentication();
};
@@ -1,9 +1,9 @@
import { responses } from "@/app/lib/api/response";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { Session } from "next-auth";
import { describe, expect, test, vi } from "vitest";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { responses } from "@/app/lib/api/response";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { checkAuth } from "./utils";
// Create mock response objects
@@ -56,8 +56,7 @@ describe("checkAuth", () => {
projectName: "Project 1",
},
],
hashedApiKey: "hashed-key",
apiKeyId: "api-key-id",
apiKeyId: "hashed-key",
organizationId: "org-id",
organizationAccess: {
accessControl: {},
@@ -89,8 +88,7 @@ describe("checkAuth", () => {
projectName: "Project 1",
},
],
hashedApiKey: "hashed-key",
apiKeyId: "api-key-id",
apiKeyId: "hashed-key",
organizationId: "org-id",
organizationAccess: {
accessControl: {},
@@ -13,7 +13,7 @@ export const checkAuth = async (authentication: TApiV1Authentication, environmen
if (!isUserAuthorized) {
return responses.unauthorizedResponse();
}
} else if ("hashedApiKey" in authentication) {
} else if ("apiKeyId" in authentication) {
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
return responses.unauthorizedResponse();
}
@@ -1,3 +1,6 @@
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { TUploadPublicFileRequest, ZUploadPublicFileRequest } from "@formbricks/types/storage";
import { checkAuth } from "@/app/api/v1/management/storage/lib/utils";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
@@ -5,9 +8,6 @@ import { TApiV1Authentication, withV1ApiWrapper } from "@/app/lib/api/with-api-l
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { getSignedUrlForUpload } from "@/modules/storage/service";
import { getErrorResponseFromStorageError } from "@/modules/storage/utils";
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { TUploadPublicFileRequest, ZUploadPublicFileRequest } from "@formbricks/types/storage";
// api endpoint for getting a signed url for uploading a public file
// uploaded files will be public, anyone can access the file
@@ -52,7 +52,16 @@ export const POST = withV1ApiWrapper({
};
}
const signedUrlResponse = await getSignedUrlForUpload(fileName, environmentId, fileType, "public");
const MAX_PUBLIC_FILE_SIZE_MB = 5;
const maxFileUploadSize = MAX_PUBLIC_FILE_SIZE_MB * 1024 * 1024;
const signedUrlResponse = await getSignedUrlForUpload(
fileName,
environmentId,
fileType,
"public",
maxFileUploadSize
);
if (!signedUrlResponse.ok) {
logger.error({ error: signedUrlResponse.error }, "Error getting signed url for upload");
+1
View File
@@ -0,0 +1 @@
export { GET } from "@/modules/api/v2/health/route";
+28 -11
View File
@@ -1,9 +1,9 @@
import { AuthenticationMethod } from "@/app/middleware/endpoint-validator";
import * as Sentry from "@sentry/nextjs";
import { NextRequest } from "next/server";
import { Mock, beforeEach, describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { AuthenticationMethod } from "@/app/middleware/endpoint-validator";
import { responses } from "./response";
// Mocks
@@ -14,6 +14,10 @@ vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
vi.mock("@sentry/nextjs", () => ({
captureException: vi.fn(),
withScope: vi.fn((callback) => {
callback(mockSentryScope);
return mockSentryScope;
}),
}));
// Define these outside the mock factory so they can be referenced in tests and reset by clearAllMocks.
@@ -21,6 +25,14 @@ const mockContextualLoggerError = vi.fn();
const mockContextualLoggerWarn = vi.fn();
const mockContextualLoggerInfo = vi.fn();
// Mock Sentry scope that can be referenced in tests
const mockSentryScope = {
setTag: vi.fn(),
setExtra: vi.fn(),
setContext: vi.fn(),
setLevel: vi.fn(),
};
vi.mock("@formbricks/logger", () => {
const mockWithContextInstance = vi.fn(() => ({
error: mockContextualLoggerError,
@@ -92,10 +104,12 @@ function createMockRequest({ method = "GET", url = "https://api.test/endpoint",
}
const mockApiAuthentication = {
hashedApiKey: "test-api-key",
type: "apiKey" as const,
environmentPermissions: [],
apiKeyId: "api-key-1",
organizationId: "org-1",
} as TAuthenticationApiKey;
organizationAccess: "all" as const,
} as unknown as TAuthenticationApiKey;
describe("withV1ApiWrapper", () => {
beforeEach(() => {
@@ -110,6 +124,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 +181,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 +288,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 () => {
+22 -9
View File
@@ -1,3 +1,8 @@
import * as Sentry from "@sentry/nextjs";
import { Session, getServerSession } from "next-auth";
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { authenticateRequest } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import {
@@ -14,11 +19,6 @@ import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { TRateLimitConfig } from "@/modules/core/rate-limit/types/rate-limit";
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditAction, TAuditTarget, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import * as Sentry from "@sentry/nextjs";
import { Session, getServerSession } from "next-auth";
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
export type TApiAuditLog = Parameters<typeof queueAuditEvent>[0];
export type TApiV1Authentication = TAuthenticationApiKey | Session | null;
@@ -74,9 +74,9 @@ const handleRateLimiting = async (
if ("user" in authentication) {
// Session-based authentication for integration routes
await applyRateLimit(customRateLimitConfig ?? rateLimitConfigs.api.v1, authentication.user.id);
} else if ("hashedApiKey" in authentication) {
} else if ("apiKeyId" in authentication) {
// API key authentication for general routes
await applyRateLimit(customRateLimitConfig ?? rateLimitConfigs.api.v1, authentication.hashedApiKey);
await applyRateLimit(customRateLimitConfig ?? rateLimitConfigs.api.v1, authentication.apiKeyId);
} else {
logger.error({ authentication }, "Unknown authentication type");
return responses.internalServerErrorResponse("Invalid authentication configuration");
@@ -173,8 +173,21 @@ const logErrorDetails = (res: Response, req: NextRequest, correlationId: string,
logger.withContext(logContext).error("V1 API Error Details");
if (SENTRY_DSN && IS_PRODUCTION && res.status >= 500) {
const err = new Error(`API V1 error, id: ${correlationId}`);
Sentry.captureException(err, { extra: { error, correlationId } });
// Set correlation ID as a tag for easy filtering
Sentry.withScope((scope) => {
scope.setTag("correlationId", correlationId);
scope.setLevel("error");
// If we have an actual error, capture it with full stacktrace
// Otherwise, create a generic error with context
if (error instanceof Error) {
Sentry.captureException(error);
} else {
scope.setExtra("originalError", error);
const genericError = new Error(`API V1 error, id: ${correlationId}`);
Sentry.captureException(genericError);
}
});
}
};
-1
View File
@@ -1,6 +1,5 @@
import { describe, expect, test } from "vitest";
import { TShuffleOption, TSurveyLogic, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TTemplateRole } from "@formbricks/types/templates";
import {
buildCTAQuestion,
buildConsentQuestion,
+3 -1
View File
@@ -19,7 +19,7 @@ import {
TSurveyRatingQuestion,
TSurveyWelcomeCard,
} from "@formbricks/types/surveys/types";
import { TTemplate } from "@formbricks/types/templates";
import { TTemplate, TTemplateRole } from "@formbricks/types/templates";
const getDefaultButtonLabel = (label: string | undefined, t: TFnType) =>
createI18nString(label || t("common.next"), []);
@@ -391,6 +391,7 @@ export const buildSurvey = (
name: string;
industries: ("eCommerce" | "saas" | "other")[];
channels: ("link" | "app" | "website")[];
role: TTemplateRole;
description: string;
questions: TSurveyQuestion[];
endings?: TSurveyEnding[];
@@ -403,6 +404,7 @@ export const buildSurvey = (
name: config.name,
industries: config.industries,
channels: config.channels,
role: config.role,
description: config.description,
preset: {
...localSurvey,
+49
View File
@@ -24,6 +24,7 @@ const cartAbandonmentSurvey = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.card_abandonment_survey"),
role: "productManager",
industries: ["eCommerce"],
channels: ["app", "website", "link"],
description: t("templates.card_abandonment_survey_description"),
@@ -124,6 +125,7 @@ const siteAbandonmentSurvey = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.site_abandonment_survey"),
role: "productManager",
industries: ["eCommerce"],
channels: ["app", "website"],
description: t("templates.site_abandonment_survey_description"),
@@ -221,6 +223,7 @@ const productMarketFitSuperhuman = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.product_market_fit_superhuman"),
role: "productManager",
industries: ["saas"],
channels: ["app", "link"],
description: t("templates.product_market_fit_superhuman_description"),
@@ -295,6 +298,7 @@ const onboardingSegmentation = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.onboarding_segmentation"),
role: "productManager",
industries: ["saas"],
channels: ["app", "link"],
description: t("templates.onboarding_segmentation_description"),
@@ -358,6 +362,7 @@ const churnSurvey = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.churn_survey"),
role: "sales",
industries: ["saas", "eCommerce", "other"],
channels: ["app", "link"],
description: t("templates.churn_survey_description"),
@@ -447,6 +452,7 @@ const earnedAdvocacyScore = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.earned_advocacy_score_name"),
role: "customerSuccess",
industries: ["saas", "eCommerce", "other"],
channels: ["app", "link"],
description: t("templates.earned_advocacy_score_description"),
@@ -519,6 +525,7 @@ const usabilityScoreRatingSurvey = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.usability_score_name"),
role: "customerSuccess",
industries: ["saas"],
channels: ["app", "link"],
description: t("templates.usability_rating_description"),
@@ -644,6 +651,7 @@ const improveTrialConversion = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.improve_trial_conversion_name"),
role: "sales",
industries: ["saas"],
channels: ["link", "app"],
description: t("templates.improve_trial_conversion_description"),
@@ -745,6 +753,7 @@ const reviewPrompt = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.review_prompt_name"),
role: "marketing",
industries: ["saas", "eCommerce", "other"],
channels: ["link", "app"],
description: t("templates.review_prompt_description"),
@@ -823,6 +832,7 @@ const interviewPrompt = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.interview_prompt_name"),
role: "productManager",
industries: ["saas"],
channels: ["app"],
description: t("templates.interview_prompt_description"),
@@ -850,6 +860,7 @@ const improveActivationRate = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.improve_activation_rate_name"),
role: "productManager",
industries: ["saas"],
channels: ["link"],
description: t("templates.improve_activation_rate_description"),
@@ -940,6 +951,7 @@ const employeeSatisfaction = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.employee_satisfaction_name"),
role: "peopleManager",
industries: ["saas", "eCommerce", "other"],
channels: ["app", "link"],
description: t("templates.employee_satisfaction_description"),
@@ -1017,6 +1029,7 @@ const uncoverStrengthsAndWeaknesses = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.uncover_strengths_and_weaknesses_name"),
role: "productManager",
industries: ["saas", "other"],
channels: ["app", "link"],
description: t("templates.uncover_strengths_and_weaknesses_description"),
@@ -1070,6 +1083,7 @@ const productMarketFitShort = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.product_market_fit_short_name"),
role: "productManager",
industries: ["saas"],
channels: ["app", "link"],
description: t("templates.product_market_fit_short_description"),
@@ -1106,6 +1120,7 @@ const marketAttribution = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.market_attribution_name"),
role: "marketing",
industries: ["saas", "eCommerce"],
channels: ["website", "app", "link"],
description: t("templates.market_attribution_description"),
@@ -1136,6 +1151,7 @@ const changingSubscriptionExperience = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.changing_subscription_experience_name"),
role: "productManager",
industries: ["saas"],
channels: ["app"],
description: t("templates.changing_subscription_experience_description"),
@@ -1178,6 +1194,7 @@ const identifyCustomerGoals = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.identify_customer_goals_name"),
role: "productManager",
industries: ["saas", "other"],
channels: ["app", "website"],
description: t("templates.identify_customer_goals_description"),
@@ -1207,6 +1224,7 @@ const featureChaser = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.feature_chaser_name"),
role: "productManager",
industries: ["saas"],
channels: ["app"],
description: t("templates.feature_chaser_description"),
@@ -1245,6 +1263,7 @@ const fakeDoorFollowUp = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.fake_door_follow_up_name"),
role: "productManager",
industries: ["saas", "eCommerce"],
channels: ["app", "website"],
description: t("templates.fake_door_follow_up_description"),
@@ -1288,6 +1307,7 @@ const feedbackBox = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.feedback_box_name"),
role: "productManager",
industries: ["saas"],
channels: ["app"],
description: t("templates.feedback_box_description"),
@@ -1357,6 +1377,7 @@ const integrationSetupSurvey = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.integration_setup_survey_name"),
role: "productManager",
industries: ["saas"],
channels: ["app"],
description: t("templates.integration_setup_survey_description"),
@@ -1429,6 +1450,7 @@ const newIntegrationSurvey = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.new_integration_survey_name"),
role: "productManager",
industries: ["saas"],
channels: ["app"],
description: t("templates.new_integration_survey_description"),
@@ -1460,6 +1482,7 @@ const docsFeedback = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.docs_feedback_name"),
role: "productManager",
industries: ["saas"],
channels: ["app", "website", "link"],
description: t("templates.docs_feedback_description"),
@@ -1499,6 +1522,7 @@ const nps = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.nps_name"),
role: "customerSuccess",
industries: ["saas", "eCommerce", "other"],
channels: ["app", "link", "website"],
description: t("templates.nps_description"),
@@ -1539,6 +1563,7 @@ const customerSatisfactionScore = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.csat_name"),
role: "customerSuccess",
industries: ["saas", "eCommerce", "other"],
channels: ["app", "link", "website"],
description: t("templates.csat_description"),
@@ -1707,6 +1732,7 @@ const collectFeedback = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.collect_feedback_name"),
role: "productManager",
industries: ["other", "eCommerce"],
channels: ["website", "link"],
description: t("templates.collect_feedback_description"),
@@ -1853,6 +1879,7 @@ const identifyUpsellOpportunities = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.identify_upsell_opportunities_name"),
role: "sales",
industries: ["saas"],
channels: ["app", "link"],
description: t("templates.identify_upsell_opportunities_description"),
@@ -1882,6 +1909,7 @@ const prioritizeFeatures = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.prioritize_features_name"),
role: "productManager",
industries: ["saas"],
channels: ["app"],
description: t("templates.prioritize_features_description"),
@@ -1934,6 +1962,7 @@ const gaugeFeatureSatisfaction = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.gauge_feature_satisfaction_name"),
role: "productManager",
industries: ["saas"],
channels: ["app"],
description: t("templates.gauge_feature_satisfaction_description"),
@@ -1967,6 +1996,7 @@ const marketSiteClarity = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.market_site_clarity_name"),
role: "marketing",
industries: ["saas", "eCommerce", "other"],
channels: ["website"],
description: t("templates.market_site_clarity_description"),
@@ -2008,6 +2038,7 @@ const customerEffortScore = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.customer_effort_score_name"),
role: "productManager",
industries: ["saas"],
channels: ["app"],
description: t("templates.customer_effort_score_description"),
@@ -2039,6 +2070,7 @@ const careerDevelopmentSurvey = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.career_development_survey_name"),
role: "productManager",
industries: ["saas", "eCommerce", "other"],
channels: ["link"],
description: t("templates.career_development_survey_description"),
@@ -2125,6 +2157,7 @@ const professionalDevelopmentSurvey = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.professional_development_survey_name"),
role: "productManager",
industries: ["saas", "eCommerce", "other"],
channels: ["link"],
description: t("templates.professional_development_survey_description"),
@@ -2212,6 +2245,7 @@ const rateCheckoutExperience = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.rate_checkout_experience_name"),
role: "productManager",
industries: ["eCommerce"],
channels: ["website", "app"],
description: t("templates.rate_checkout_experience_description"),
@@ -2288,6 +2322,7 @@ const measureSearchExperience = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.measure_search_experience_name"),
role: "productManager",
industries: ["saas", "eCommerce"],
channels: ["app", "website"],
description: t("templates.measure_search_experience_description"),
@@ -2364,6 +2399,7 @@ const evaluateContentQuality = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.evaluate_content_quality_name"),
role: "marketing",
industries: ["other"],
channels: ["website"],
description: t("templates.evaluate_content_quality_description"),
@@ -2441,6 +2477,7 @@ const measureTaskAccomplishment = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.measure_task_accomplishment_name"),
role: "productManager",
industries: ["saas"],
channels: ["app", "website"],
description: t("templates.measure_task_accomplishment_description"),
@@ -2623,6 +2660,7 @@ const identifySignUpBarriers = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.identify_sign_up_barriers_name"),
role: "marketing",
industries: ["saas", "eCommerce", "other"],
channels: ["website"],
description: t("templates.identify_sign_up_barriers_description"),
@@ -2774,6 +2812,7 @@ const buildProductRoadmap = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.build_product_roadmap_name"),
role: "productManager",
industries: ["saas"],
channels: ["app", "link"],
description: t("templates.build_product_roadmap_description"),
@@ -2808,6 +2847,7 @@ const understandPurchaseIntention = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.understand_purchase_intention_name"),
role: "sales",
industries: ["eCommerce"],
channels: ["website", "link", "app"],
description: t("templates.understand_purchase_intention_description"),
@@ -2863,6 +2903,7 @@ const improveNewsletterContent = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.improve_newsletter_content_name"),
role: "marketing",
industries: ["eCommerce", "saas", "other"],
channels: ["link"],
description: t("templates.improve_newsletter_content_description"),
@@ -2953,6 +2994,7 @@ const evaluateAProductIdea = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.evaluate_a_product_idea_name"),
role: "productManager",
industries: ["saas", "other"],
channels: ["link", "app"],
description: t("templates.evaluate_a_product_idea_description"),
@@ -3055,6 +3097,7 @@ const understandLowEngagement = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.understand_low_engagement_name"),
role: "productManager",
industries: ["saas"],
channels: ["link"],
description: t("templates.understand_low_engagement_description"),
@@ -3140,6 +3183,7 @@ const employeeWellBeing = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.employee_well_being_name"),
role: "peopleManager",
industries: ["saas", "eCommerce", "other"],
channels: ["link"],
description: t("templates.employee_well_being_description"),
@@ -3189,6 +3233,7 @@ const longTermRetentionCheckIn = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.long_term_retention_check_in_name"),
role: "peopleManager",
industries: ["saas", "other"],
channels: ["app", "link"],
description: t("templates.long_term_retention_check_in_description"),
@@ -3297,6 +3342,7 @@ const professionalDevelopmentGrowth = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.professional_development_growth_survey_name"),
role: "peopleManager",
industries: ["saas", "eCommerce", "other"],
channels: ["link"],
description: t("templates.professional_development_growth_survey_description"),
@@ -3346,6 +3392,7 @@ const recognitionAndReward = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.recognition_and_reward_survey_name"),
role: "peopleManager",
industries: ["saas", "eCommerce", "other"],
channels: ["link"],
description: t("templates.recognition_and_reward_survey_description"),
@@ -3394,6 +3441,7 @@ const alignmentAndEngagement = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.alignment_and_engagement_survey_name"),
role: "peopleManager",
industries: ["saas", "eCommerce", "other"],
channels: ["link"],
description: t("templates.alignment_and_engagement_survey_description"),
@@ -3442,6 +3490,7 @@ const supportiveWorkCulture = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.supportive_work_culture_survey_name"),
role: "peopleManager",
industries: ["saas", "eCommerce", "other"],
channels: ["link"],
description: t("templates.supportive_work_culture_survey_description"),
@@ -1,10 +1,10 @@
import { getServerSession } from "next-auth";
import { NextRequest } from "next/server";
import { Result, err, ok } from "@formbricks/types/error-handlers";
import { authenticateRequest } from "@/app/api/v1/auth";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { getServerSession } from "next-auth";
import { NextRequest } from "next/server";
import { Result, err, ok } from "@formbricks/types/error-handlers";
export const authorizePrivateDownload = async (
request: NextRequest,
@@ -12,7 +12,7 @@ export const authorizePrivateDownload = async (
action: "GET" | "DELETE"
): Promise<
Result<
{ authType: "session"; userId: string } | { authType: "apiKey"; hashedApiKey: string },
{ authType: "session"; userId: string } | { authType: "apiKey"; apiKeyId: string },
{
unauthorized: boolean;
}
@@ -49,6 +49,6 @@ export const authorizePrivateDownload = async (
return ok({
authType: "apiKey",
hashedApiKey: auth.hashedApiKey,
apiKeyId: auth.apiKeyId,
});
};
@@ -1,3 +1,7 @@
import { getServerSession } from "next-auth";
import { type NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { TAccessType, ZDeleteFileRequest, ZDownloadFileRequest } from "@formbricks/types/storage";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { authorizePrivateDownload } from "@/app/storage/[environmentId]/[accessType]/[fileName]/lib/auth";
@@ -6,10 +10,6 @@ import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { deleteFile, getSignedUrlForDownload } from "@/modules/storage/service";
import { getErrorResponseFromStorageError } from "@/modules/storage/utils";
import { getServerSession } from "next-auth";
import { type NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { TAccessType, ZDeleteFileRequest, ZDownloadFileRequest } from "@formbricks/types/storage";
import { logFileDeletion } from "./lib/audit-logs";
export const GET = async (
@@ -100,7 +100,7 @@ export const DELETE = async (
if (authResult.ok) {
try {
if (authResult.data.authType === "apiKey") {
await applyRateLimit(rateLimitConfigs.storage.delete, authResult.data.hashedApiKey);
await applyRateLimit(rateLimitConfigs.storage.delete, authResult.data.apiKeyId);
} else {
await applyRateLimit(rateLimitConfigs.storage.delete, authResult.data.userId);
}
+14 -8
View File
@@ -100,10 +100,13 @@ export const getAirtableToken = async (environmentId: string) => {
});
if (!newToken) {
logger.error("Failed to fetch new Airtable token", {
environmentId,
airtableIntegration,
});
logger.error(
{
environmentId,
airtableIntegration,
},
"Failed to fetch new Airtable token"
);
throw new Error("Failed to fetch new Airtable token");
}
@@ -121,10 +124,13 @@ export const getAirtableToken = async (environmentId: string) => {
return access_token;
} catch (error) {
logger.error("Failed to get Airtable token", {
environmentId,
error,
});
logger.error(
{
environmentId,
error,
},
"Failed to get Airtable token"
);
throw new Error("Failed to get Airtable token");
}
};
+4 -1
View File
@@ -114,7 +114,7 @@ export const MAX_FILE_UPLOAD_SIZES = {
standard: 1024 * 1024 * 10, // 10MB
big: 1024 * 1024 * 1024, // 1GB
} as const;
export const IS_STORAGE_CONFIGURED = Boolean(S3_ACCESS_KEY && S3_SECRET_KEY && S3_REGION && S3_BUCKET_NAME);
export const IS_STORAGE_CONFIGURED = Boolean(S3_BUCKET_NAME);
// Colors for Survey Bg
export const SURVEY_BG_COLORS = [
@@ -260,3 +260,6 @@ export const USER_MANAGEMENT_MINIMUM_ROLE = env.USER_MANAGEMENT_MINIMUM_ROLE ??
export const AUDIT_LOG_ENABLED = env.AUDIT_LOG_ENABLED === "1";
export const AUDIT_LOG_GET_USER_IP = env.AUDIT_LOG_GET_USER_IP === "1";
export const SESSION_MAX_AGE = Number(env.SESSION_MAX_AGE) || 86400;
// Control hash for constant-time password verification to prevent timing attacks. Used when user doesn't exist to maintain consistent verification timing
export const CONTROL_HASH = "$2b$12$fzHf9le13Ss9UJ04xzmsjODXpFJxz6vsnupoepF5FiqDECkX2BH5q";
+365 -30
View File
@@ -1,41 +1,376 @@
import { createCipheriv, randomBytes } from "crypto";
import { describe, expect, test, vi } from "vitest";
import { getHash, symmetricDecrypt, symmetricEncrypt } from "./crypto";
import * as crypto from "crypto";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
// Import after unmocking
import {
hashSecret,
hashSha256,
parseApiKeyV2,
symmetricDecrypt,
symmetricEncrypt,
verifySecret,
} from "./crypto";
vi.mock("./constants", () => ({ ENCRYPTION_KEY: "0".repeat(32) }));
// Unmock crypto for these tests since we want to test the actual crypto functions
vi.unmock("crypto");
const key = "0".repeat(32);
const plain = "hello";
// Mock the logger
vi.mock("@formbricks/logger", () => ({
logger: {
warn: vi.fn(),
},
}));
describe("crypto", () => {
test("encrypt + decrypt roundtrip", () => {
const cipher = symmetricEncrypt(plain, key);
expect(symmetricDecrypt(cipher, key)).toBe(plain);
describe("Crypto Utils", () => {
describe("hashSecret and verifySecret", () => {
test("should hash and verify secrets correctly", async () => {
const secret = "test-secret-123";
const hash = await hashSecret(secret);
expect(hash).toMatch(/^\$2[aby]\$\d+\$[./A-Za-z0-9]{53}$/);
const isValid = await verifySecret(secret, hash);
expect(isValid).toBe(true);
});
test("should reject wrong secrets", async () => {
const secret = "test-secret-123";
const wrongSecret = "wrong-secret";
const hash = await hashSecret(secret);
const isValid = await verifySecret(wrongSecret, hash);
expect(isValid).toBe(false);
});
test("should generate different hashes for the same secret (due to salt)", async () => {
const secret = "test-secret-123";
const hash1 = await hashSecret(secret);
const hash2 = await hashSecret(secret);
expect(hash1).not.toBe(hash2);
// But both should verify correctly
expect(await verifySecret(secret, hash1)).toBe(true);
expect(await verifySecret(secret, hash2)).toBe(true);
});
test("should use custom cost factor", async () => {
const secret = "test-secret-123";
const hash = await hashSecret(secret, 10);
// Verify the cost factor is in the hash
expect(hash).toMatch(/^\$2[aby]\$10\$/);
expect(await verifySecret(secret, hash)).toBe(true);
});
test("should return false for invalid hash format", async () => {
const secret = "test-secret-123";
const invalidHash = "not-a-bcrypt-hash";
const isValid = await verifySecret(secret, invalidHash);
expect(isValid).toBe(false);
});
});
test("decrypt V2 GCM payload", () => {
const iv = randomBytes(16);
const bufKey = Buffer.from(key, "utf8");
const cipher = createCipheriv("aes-256-gcm", bufKey, iv);
let enc = cipher.update(plain, "utf8", "hex");
enc += cipher.final("hex");
const tag = cipher.getAuthTag().toString("hex");
const payload = `${iv.toString("hex")}:${enc}:${tag}`;
expect(symmetricDecrypt(payload, key)).toBe(plain);
describe("hashSha256", () => {
test("should generate deterministic SHA-256 hashes", () => {
const input = "test-input-123";
const hash1 = hashSha256(input);
const hash2 = hashSha256(input);
expect(hash1).toBe(hash2);
expect(hash1).toMatch(/^[a-f0-9]{64}$/);
});
test("should generate different hashes for different inputs", () => {
const hash1 = hashSha256("input1");
const hash2 = hashSha256("input2");
expect(hash1).not.toBe(hash2);
});
test("should generate correct SHA-256 hash", () => {
// Known SHA-256 hash for "hello"
const input = "hello";
const expectedHash = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824";
expect(hashSha256(input)).toBe(expectedHash);
});
});
test("decrypt legacy (single-colon) payload", () => {
const iv = randomBytes(16);
const cipher = createCipheriv("aes256", Buffer.from(key, "utf8"), iv); // NOSONAR typescript:S5542 // We are testing backwards compatibility
let enc = cipher.update(plain, "utf8", "hex");
enc += cipher.final("hex");
const legacy = `${iv.toString("hex")}:${enc}`;
expect(symmetricDecrypt(legacy, key)).toBe(plain);
describe("parseApiKeyV2", () => {
test("should parse valid v2 format keys (fbk_secret)", () => {
const secret = "secret456";
const key = `fbk_${secret}`;
const parsed = parseApiKeyV2(key);
expect(parsed).toEqual({
secret: "secret456",
});
});
test("should handle keys with underscores in secrets", () => {
// Valid - secrets can contain underscores (base64url-encoded)
const key1 = "fbk_secret_with_underscores";
const parsed1 = parseApiKeyV2(key1);
expect(parsed1).toEqual({
secret: "secret_with_underscores",
});
// Valid - multiple underscores in secret
const key2 = "fbk_secret_with_many_underscores_allowed";
const parsed2 = parseApiKeyV2(key2);
expect(parsed2).toEqual({
secret: "secret_with_many_underscores_allowed",
});
});
test("should handle keys with hyphens in secret", () => {
const key = "fbk_secret-with-hyphens";
const parsed = parseApiKeyV2(key);
expect(parsed).toEqual({
secret: "secret-with-hyphens",
});
});
test("should handle base64url-encoded secrets with all valid characters", () => {
// Base64url alphabet includes: A-Z, a-z, 0-9, - (hyphen), _ (underscore)
const key1 = "fbk_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
const parsed1 = parseApiKeyV2(key1);
expect(parsed1).toEqual({
secret: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_",
});
// Realistic base64url secret with underscores and hyphens
const key2 = "fbk_a1B2c3D4e5F6g7H8i9J0-_K1L2M3N4O5P6";
const parsed2 = parseApiKeyV2(key2);
expect(parsed2).toEqual({
secret: "a1B2c3D4e5F6g7H8i9J0-_K1L2M3N4O5P6",
});
});
test("should handle long secrets (GitHub-style PATs)", () => {
// Simulating a 32-byte base64url-encoded secret (43 chars)
const longSecret = "a".repeat(43);
const key = `fbk_${longSecret}`;
const parsed = parseApiKeyV2(key);
expect(parsed).toEqual({
secret: longSecret,
});
});
test("should return null for invalid formats", () => {
const invalidKeys = [
"invalid-key", // No fbk_ prefix
"fbk_", // No secret
"not_fbk_secret", // Wrong prefix
"", // Empty string
];
invalidKeys.forEach((key) => {
expect(parseApiKeyV2(key)).toBeNull();
});
});
test("should reject secrets with invalid characters", () => {
// Secrets should only contain base64url characters: [A-Za-z0-9_-]
const invalidKeys = [
"fbk_secret+with+plus", // + is not base64url (it's base64)
"fbk_secret/with/slash", // / is not base64url (it's base64)
"fbk_secret=with=equals", // = is padding, not in base64url alphabet
"fbk_secret with space", // spaces not allowed
"fbk_secret!special", // special chars not allowed
"fbk_secret@email", // @ not allowed
"fbk_secret#hash", // # not allowed
"fbk_secret$dollar", // $ not allowed
];
invalidKeys.forEach((key) => {
expect(parseApiKeyV2(key)).toBeNull();
});
});
});
test("getHash returns a non-empty string", () => {
const h = getHash("abc");
expect(typeof h).toBe("string");
expect(h.length).toBeGreaterThan(0);
describe("symmetricEncrypt and symmetricDecrypt", () => {
// 64 hex characters = 32 bytes when decoded
const testKey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
test("should encrypt and decrypt data correctly (V2 format)", () => {
const plaintext = "sensitive data to encrypt";
const encrypted = symmetricEncrypt(plaintext, testKey);
// V2 format should have 3 parts: iv:ciphertext:tag
const parts = encrypted.split(":");
expect(parts).toHaveLength(3);
const decrypted = symmetricDecrypt(encrypted, testKey);
expect(decrypted).toBe(plaintext);
});
test("should produce different encrypted values for the same plaintext (due to random IV)", () => {
const plaintext = "same data";
const encrypted1 = symmetricEncrypt(plaintext, testKey);
const encrypted2 = symmetricEncrypt(plaintext, testKey);
expect(encrypted1).not.toBe(encrypted2);
// But both should decrypt to the same value
expect(symmetricDecrypt(encrypted1, testKey)).toBe(plaintext);
expect(symmetricDecrypt(encrypted2, testKey)).toBe(plaintext);
});
test("should handle various data types and special characters", () => {
const testCases = [
"simple text",
"text with spaces and special chars: !@#$%^&*()",
'{"json": "data", "number": 123}',
"unicode: 你好世界 🚀",
"",
"a".repeat(1000), // long text
];
testCases.forEach((text) => {
const encrypted = symmetricEncrypt(text, testKey);
const decrypted = symmetricDecrypt(encrypted, testKey);
expect(decrypted).toBe(text);
});
});
test("should decrypt legacy V1 format (with only one colon)", () => {
// Simulate a V1 encrypted value (only has one colon: iv:ciphertext)
// This test verifies backward compatibility
const plaintext = "legacy data";
// Since we can't easily create a V1 format without the old code,
// we'll just verify that a payload with 2 parts triggers the V1 path
// For a real test, you'd need a known V1 encrypted value
// Skip this test or use a known V1 encrypted string if available
// For now, we'll test that the logic correctly identifies the format
const v2Encrypted = symmetricEncrypt(plaintext, testKey);
expect(v2Encrypted.split(":")).toHaveLength(3); // V2 has 3 parts
});
test("should throw error for invalid encrypted data", () => {
const invalidEncrypted = "invalid:encrypted:data:extra";
expect(() => {
symmetricDecrypt(invalidEncrypted, testKey);
}).toThrow();
});
test("should throw error when decryption key is wrong", () => {
const plaintext = "secret message";
const correctKey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const wrongKey = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff";
const encrypted = symmetricEncrypt(plaintext, correctKey);
expect(() => {
symmetricDecrypt(encrypted, wrongKey);
}).toThrow();
});
test("should handle empty string encryption and decryption", () => {
const plaintext = "";
const encrypted = symmetricEncrypt(plaintext, testKey);
const decrypted = symmetricDecrypt(encrypted, testKey);
expect(decrypted).toBe(plaintext);
expect(decrypted).toBe("");
});
});
describe("GCM decryption failure logging", () => {
// Test key - 32 bytes for AES-256
const testKey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const plaintext = "test message";
beforeEach(() => {
// Clear mock calls before each test
vi.clearAllMocks();
});
test("logs warning and throws when GCM decryption fails with invalid auth tag", () => {
// Create a valid GCM payload but corrupt the auth tag
const iv = crypto.randomBytes(16);
const bufKey = Buffer.from(testKey, "hex");
const cipher = crypto.createCipheriv("aes-256-gcm", bufKey, iv);
let enc = cipher.update(plaintext, "utf8", "hex");
enc += cipher.final("hex");
const validTag = cipher.getAuthTag().toString("hex");
// Corrupt the auth tag by flipping some bits
const corruptedTag = validTag
.split("")
.map((c, i) => (i < 4 ? (parseInt(c, 16) ^ 0xf).toString(16) : c))
.join("");
const corruptedPayload = `${iv.toString("hex")}:${enc}:${corruptedTag}`;
// Should throw an error and log a warning
expect(() => symmetricDecrypt(corruptedPayload, testKey)).toThrow();
// Verify logger.warn was called with the correct format (object first, message second)
expect(logger.warn).toHaveBeenCalledWith(
{ err: expect.any(Error) },
"AES-GCM decryption failed; refusing to fall back to insecure CBC"
);
expect(logger.warn).toHaveBeenCalledTimes(1);
});
test("logs warning and throws when GCM decryption fails with corrupted encrypted data", () => {
// Create a payload with valid structure but corrupted encrypted data
const iv = crypto.randomBytes(16);
const bufKey = Buffer.from(testKey, "hex");
const cipher = crypto.createCipheriv("aes-256-gcm", bufKey, iv);
let enc = cipher.update(plaintext, "utf8", "hex");
enc += cipher.final("hex");
const tag = cipher.getAuthTag().toString("hex");
// Corrupt the encrypted data
const corruptedEnc = enc
.split("")
.map((c, i) => (i < 4 ? (parseInt(c, 16) ^ 0xa).toString(16) : c))
.join("");
const corruptedPayload = `${iv.toString("hex")}:${corruptedEnc}:${tag}`;
// Should throw an error and log a warning
expect(() => symmetricDecrypt(corruptedPayload, testKey)).toThrow();
// Verify logger.warn was called
expect(logger.warn).toHaveBeenCalledWith(
{ err: expect.any(Error) },
"AES-GCM decryption failed; refusing to fall back to insecure CBC"
);
expect(logger.warn).toHaveBeenCalledTimes(1);
});
test("logs warning and throws when GCM decryption fails with wrong key", () => {
// Create a valid GCM payload with one key
const iv = crypto.randomBytes(16);
const bufKey = Buffer.from(testKey, "hex");
const cipher = crypto.createCipheriv("aes-256-gcm", bufKey, iv);
let enc = cipher.update(plaintext, "utf8", "hex");
enc += cipher.final("hex");
const tag = cipher.getAuthTag().toString("hex");
const payload = `${iv.toString("hex")}:${enc}:${tag}`;
// Try to decrypt with a different key (32 bytes)
const wrongKey = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff";
// Should throw an error and log a warning
expect(() => symmetricDecrypt(payload, wrongKey)).toThrow();
// Verify logger.warn was called
expect(logger.warn).toHaveBeenCalledWith(
{ err: expect.any(Error) },
"AES-GCM decryption failed; refusing to fall back to insecure CBC"
);
expect(logger.warn).toHaveBeenCalledTimes(1);
});
});
});
+52 -3
View File
@@ -1,6 +1,7 @@
import { compare, hash } from "bcryptjs";
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "crypto";
import { logger } from "@formbricks/logger";
import { ENCRYPTION_KEY } from "./constants";
import { ENCRYPTION_KEY } from "@/lib/constants";
const ALGORITHM_V1 = "aes256";
const ALGORITHM_V2 = "aes-256-gcm";
@@ -85,10 +86,58 @@ export function symmetricDecrypt(payload: string, key: string): string {
try {
return symmetricDecryptV2(payload, key);
} catch (err) {
logger.warn(err, "AES-GCM decryption failed; refusing to fall back to insecure CBC");
logger.warn({ err }, "AES-GCM decryption failed; refusing to fall back to insecure CBC");
throw err;
}
}
export const getHash = (key: string): string => createHash("sha256").update(key).digest("hex");
/**
* General bcrypt hashing utility for secrets (passwords, API keys, etc.)
*/
export const hashSecret = async (secret: string, cost: number = 12): Promise<string> => {
return await hash(secret, cost);
};
/**
* General bcrypt verification utility for secrets (passwords, API keys, etc.)
*/
export const verifySecret = async (secret: string, hashedSecret: string): Promise<boolean> => {
try {
const isValid = await compare(secret, hashedSecret);
return isValid;
} catch (error) {
// Log warning for debugging purposes, but don't throw to maintain security
logger.warn({ error }, "Secret verification failed due to invalid hash format");
// Return false for invalid hashes or other bcrypt errors
return false;
}
};
/**
* SHA-256 hashing utility (deterministic, for legacy support)
*/
export const hashSha256 = (input: string): string => {
return createHash("sha256").update(input).digest("hex");
};
/**
* Parse a v2 API key format: fbk_{secret}
* Returns null if the key doesn't match the expected format
*/
export const parseApiKeyV2 = (key: string): { secret: string } | null => {
// Check if it starts with fbk_
if (!key.startsWith("fbk_")) {
return null;
}
const secret = key.slice(4); // Skip 'fbk_' prefix
// Validate that secret contains only allowed characters and is not empty
// Secrets are base64url-encoded and can contain underscores, hyphens, and alphanumeric chars
if (!secret || !/^[A-Za-z0-9_-]+$/.test(secret)) {
return null;
}
return { secret };
};
+865 -28
View File
@@ -1,6 +1,7 @@
import { env } from "@/lib/env";
import jwt from "jsonwebtoken";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import * as crypto from "@/lib/crypto";
import {
createEmailChangeToken,
createEmailToken,
@@ -14,12 +15,69 @@ import {
verifyTokenForLinkSurvey,
} from "./jwt";
const TEST_ENCRYPTION_KEY = "0".repeat(32); // 32-byte key for AES-256-GCM
const TEST_NEXTAUTH_SECRET = "test-nextauth-secret";
const DIFFERENT_SECRET = "different-secret";
// Error message constants
const NEXTAUTH_SECRET_ERROR = "NEXTAUTH_SECRET is not set";
const ENCRYPTION_KEY_ERROR = "ENCRYPTION_KEY is not set";
// Helper function to test error cases for missing secrets/keys
const testMissingSecretsError = async (
testFn: (...args: any[]) => any,
args: any[],
options: {
testNextAuthSecret?: boolean;
testEncryptionKey?: boolean;
isAsync?: boolean;
} = {}
) => {
const { testNextAuthSecret = true, testEncryptionKey = true, isAsync = false } = options;
if (testNextAuthSecret) {
const constants = await import("@/lib/constants");
const originalSecret = (constants as any).NEXTAUTH_SECRET;
(constants as any).NEXTAUTH_SECRET = undefined;
if (isAsync) {
await expect(testFn(...args)).rejects.toThrow(NEXTAUTH_SECRET_ERROR);
} else {
expect(() => testFn(...args)).toThrow(NEXTAUTH_SECRET_ERROR);
}
// Restore
(constants as any).NEXTAUTH_SECRET = originalSecret;
}
if (testEncryptionKey) {
const constants = await import("@/lib/constants");
const originalKey = (constants as any).ENCRYPTION_KEY;
(constants as any).ENCRYPTION_KEY = undefined;
if (isAsync) {
await expect(testFn(...args)).rejects.toThrow(ENCRYPTION_KEY_ERROR);
} else {
expect(() => testFn(...args)).toThrow(ENCRYPTION_KEY_ERROR);
}
// Restore
(constants as any).ENCRYPTION_KEY = originalKey;
}
};
// Mock environment variables
vi.mock("@/lib/env", () => ({
env: {
ENCRYPTION_KEY: "0".repeat(32), // 32-byte key for AES-256-GCM
ENCRYPTION_KEY: "0".repeat(32),
NEXTAUTH_SECRET: "test-nextauth-secret",
} as typeof env,
},
}));
// Mock constants
vi.mock("@/lib/constants", () => ({
NEXTAUTH_SECRET: "test-nextauth-secret",
ENCRYPTION_KEY: "0".repeat(32),
}));
// Mock prisma
@@ -31,22 +89,65 @@ vi.mock("@formbricks/database", () => ({
},
}));
describe("JWT Functions", () => {
// Mock logger
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
},
}));
describe("JWT Functions - Comprehensive Security Tests", () => {
const mockUser = {
id: "test-user-id",
email: "test@example.com",
};
let mockSymmetricEncrypt: any;
let mockSymmetricDecrypt: any;
beforeEach(() => {
vi.clearAllMocks();
// Setup default crypto mocks
mockSymmetricEncrypt = vi
.spyOn(crypto, "symmetricEncrypt")
.mockImplementation((text: string) => `encrypted_${text}`);
mockSymmetricDecrypt = vi
.spyOn(crypto, "symmetricDecrypt")
.mockImplementation((encryptedText: string) => encryptedText.replace("encrypted_", ""));
(prisma.user.findUnique as any).mockResolvedValue(mockUser);
});
describe("createToken", () => {
test("should create a valid token", () => {
const token = createToken(mockUser.id, mockUser.email);
test("should create a valid token with encrypted user ID", () => {
const token = createToken(mockUser.id);
expect(token).toBeDefined();
expect(typeof token).toBe("string");
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(mockUser.id, TEST_ENCRYPTION_KEY);
});
test("should accept custom options", () => {
const customOptions = { expiresIn: "1h" };
const token = createToken(mockUser.id, customOptions);
expect(token).toBeDefined();
// Verify the token contains the expected expiration
const decoded = jwt.decode(token) as any;
expect(decoded.exp).toBeDefined();
expect(decoded.iat).toBeDefined();
// Should expire in approximately 1 hour (3600 seconds)
expect(decoded.exp - decoded.iat).toBe(3600);
});
test("should throw error if NEXTAUTH_SECRET is not set", async () => {
await testMissingSecretsError(createToken, [mockUser.id], {
testNextAuthSecret: true,
testEncryptionKey: false,
});
});
});
@@ -56,6 +157,18 @@ describe("JWT Functions", () => {
const token = createTokenForLinkSurvey(surveyId, mockUser.email);
expect(token).toBeDefined();
expect(typeof token).toBe("string");
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(mockUser.email, TEST_ENCRYPTION_KEY);
});
test("should include surveyId in payload", () => {
const surveyId = "test-survey-id";
const token = createTokenForLinkSurvey(surveyId, mockUser.email);
const decoded = jwt.decode(token) as any;
expect(decoded.surveyId).toBe(surveyId);
});
test("should throw error if NEXTAUTH_SECRET or ENCRYPTION_KEY is not set", async () => {
await testMissingSecretsError(createTokenForLinkSurvey, ["survey-id", mockUser.email]);
});
});
@@ -64,24 +177,30 @@ describe("JWT Functions", () => {
const token = createEmailToken(mockUser.email);
expect(token).toBeDefined();
expect(typeof token).toBe("string");
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(mockUser.email, TEST_ENCRYPTION_KEY);
});
test("should throw error if NEXTAUTH_SECRET is not set", () => {
const originalSecret = env.NEXTAUTH_SECRET;
try {
(env as any).NEXTAUTH_SECRET = undefined;
expect(() => createEmailToken(mockUser.email)).toThrow("NEXTAUTH_SECRET is not set");
} finally {
(env as any).NEXTAUTH_SECRET = originalSecret;
}
test("should throw error if NEXTAUTH_SECRET or ENCRYPTION_KEY is not set", async () => {
await testMissingSecretsError(createEmailToken, [mockUser.email]);
});
});
describe("getEmailFromEmailToken", () => {
test("should extract email from valid token", () => {
const token = createEmailToken(mockUser.email);
const extractedEmail = getEmailFromEmailToken(token);
expect(extractedEmail).toBe(mockUser.email);
describe("createEmailChangeToken", () => {
test("should create a valid email change token with 1 day expiration", () => {
const token = createEmailChangeToken(mockUser.id, mockUser.email);
expect(token).toBeDefined();
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(mockUser.id, TEST_ENCRYPTION_KEY);
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(mockUser.email, TEST_ENCRYPTION_KEY);
const decoded = jwt.decode(token) as any;
expect(decoded.exp).toBeDefined();
expect(decoded.iat).toBeDefined();
// Should expire in approximately 1 day (86400 seconds)
expect(decoded.exp - decoded.iat).toBe(86400);
});
test("should throw error if NEXTAUTH_SECRET or ENCRYPTION_KEY is not set", async () => {
await testMissingSecretsError(createEmailChangeToken, [mockUser.id, mockUser.email]);
});
});
@@ -91,6 +210,50 @@ describe("JWT Functions", () => {
const token = createInviteToken(inviteId, mockUser.email);
expect(token).toBeDefined();
expect(typeof token).toBe("string");
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(inviteId, TEST_ENCRYPTION_KEY);
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(mockUser.email, TEST_ENCRYPTION_KEY);
});
test("should accept custom options", () => {
const inviteId = "test-invite-id";
const customOptions = { expiresIn: "24h" };
const token = createInviteToken(inviteId, mockUser.email, customOptions);
expect(token).toBeDefined();
const decoded = jwt.decode(token) as any;
expect(decoded.exp).toBeDefined();
expect(decoded.iat).toBeDefined();
// Should expire in approximately 24 hours (86400 seconds)
expect(decoded.exp - decoded.iat).toBe(86400);
});
test("should throw error if NEXTAUTH_SECRET or ENCRYPTION_KEY is not set", async () => {
await testMissingSecretsError(createInviteToken, ["invite-id", mockUser.email]);
});
});
describe("getEmailFromEmailToken", () => {
test("should extract email from valid token", () => {
const token = createEmailToken(mockUser.email);
const extractedEmail = getEmailFromEmailToken(token);
expect(extractedEmail).toBe(mockUser.email);
expect(mockSymmetricDecrypt).toHaveBeenCalledWith(`encrypted_${mockUser.email}`, TEST_ENCRYPTION_KEY);
});
test("should fall back to original email if decryption fails", () => {
mockSymmetricDecrypt.mockImplementationOnce(() => {
throw new Error("Decryption failed");
});
// Create token manually with unencrypted email for legacy compatibility
const legacyToken = jwt.sign({ email: mockUser.email }, TEST_NEXTAUTH_SECRET);
const extractedEmail = getEmailFromEmailToken(legacyToken);
expect(extractedEmail).toBe(mockUser.email);
});
test("should throw error if NEXTAUTH_SECRET or ENCRYPTION_KEY is not set", async () => {
const token = jwt.sign({ email: "test@example.com" }, TEST_NEXTAUTH_SECRET);
await testMissingSecretsError(getEmailFromEmailToken, [token]);
});
});
@@ -106,23 +269,194 @@ describe("JWT Functions", () => {
const result = verifyTokenForLinkSurvey("invalid-token", "test-survey-id");
expect(result).toBeNull();
});
test("should return null if NEXTAUTH_SECRET is not set", async () => {
const constants = await import("@/lib/constants");
const originalSecret = (constants as any).NEXTAUTH_SECRET;
(constants as any).NEXTAUTH_SECRET = undefined;
const result = verifyTokenForLinkSurvey("any-token", "test-survey-id");
expect(result).toBeNull();
// Restore
(constants as any).NEXTAUTH_SECRET = originalSecret;
});
test("should return null if surveyId doesn't match", () => {
const surveyId = "test-survey-id";
const differentSurveyId = "different-survey-id";
const token = createTokenForLinkSurvey(surveyId, mockUser.email);
const result = verifyTokenForLinkSurvey(token, differentSurveyId);
expect(result).toBeNull();
});
test("should return null if email is missing from payload", () => {
const tokenWithoutEmail = jwt.sign({ surveyId: "test-survey-id" }, TEST_NEXTAUTH_SECRET);
const result = verifyTokenForLinkSurvey(tokenWithoutEmail, "test-survey-id");
expect(result).toBeNull();
});
test("should fall back to original email if decryption fails", () => {
mockSymmetricDecrypt.mockImplementationOnce(() => {
throw new Error("Decryption failed");
});
// Create legacy token with unencrypted email
const legacyToken = jwt.sign(
{
email: mockUser.email,
surveyId: "test-survey-id",
},
TEST_NEXTAUTH_SECRET
);
const result = verifyTokenForLinkSurvey(legacyToken, "test-survey-id");
expect(result).toBe(mockUser.email);
});
test("should fall back to original email if ENCRYPTION_KEY is not set", async () => {
const constants = await import("@/lib/constants");
const originalKey = (constants as any).ENCRYPTION_KEY;
(constants as any).ENCRYPTION_KEY = undefined;
// Create a token with unencrypted email (as it would be if ENCRYPTION_KEY was not set during creation)
const token = jwt.sign(
{
email: mockUser.email,
surveyId: "survey-id",
},
TEST_NEXTAUTH_SECRET
);
const result = verifyTokenForLinkSurvey(token, "survey-id");
expect(result).toBe(mockUser.email);
// Restore
(constants as any).ENCRYPTION_KEY = originalKey;
});
test("should verify legacy survey tokens with surveyId-based secret", async () => {
const surveyId = "test-survey-id";
// Create legacy token with old format (NEXTAUTH_SECRET + surveyId)
const legacyToken = jwt.sign({ email: `encrypted_${mockUser.email}` }, TEST_NEXTAUTH_SECRET + surveyId);
const result = verifyTokenForLinkSurvey(legacyToken, surveyId);
expect(result).toBe(mockUser.email);
});
test("should reject survey tokens that fail both new and legacy verification", async () => {
const surveyId = "test-survey-id";
const invalidToken = jwt.sign({ email: "encrypted_test@example.com" }, "wrong-secret");
const result = verifyTokenForLinkSurvey(invalidToken, surveyId);
expect(result).toBeNull();
// Verify error logging
const { logger } = await import("@formbricks/logger");
expect(logger.error).toHaveBeenCalledWith(expect.any(Error), "Survey link token verification failed");
});
test("should reject legacy survey tokens for wrong survey", () => {
const correctSurveyId = "correct-survey-id";
const wrongSurveyId = "wrong-survey-id";
// Create legacy token for one survey
const legacyToken = jwt.sign(
{ email: `encrypted_${mockUser.email}` },
TEST_NEXTAUTH_SECRET + correctSurveyId
);
// Try to verify with different survey ID
const result = verifyTokenForLinkSurvey(legacyToken, wrongSurveyId);
expect(result).toBeNull();
});
});
describe("verifyToken", () => {
test("should verify valid token", async () => {
const token = createToken(mockUser.id, mockUser.email);
const token = createToken(mockUser.id);
const verified = await verifyToken(token);
expect(verified).toEqual({
id: mockUser.id,
id: mockUser.id, // Returns the decrypted user ID
email: mockUser.email,
});
});
test("should throw error if user not found", async () => {
(prisma.user.findUnique as any).mockResolvedValue(null);
const token = createToken(mockUser.id, mockUser.email);
const token = createToken(mockUser.id);
await expect(verifyToken(token)).rejects.toThrow("User not found");
});
test("should throw error if NEXTAUTH_SECRET is not set", async () => {
await testMissingSecretsError(verifyToken, ["any-token"], {
testNextAuthSecret: true,
testEncryptionKey: false,
isAsync: true,
});
});
test("should throw error for invalid token signature", async () => {
const invalidToken = jwt.sign({ id: "test-id" }, DIFFERENT_SECRET);
await expect(verifyToken(invalidToken)).rejects.toThrow("Invalid token");
});
test("should throw error if token payload is missing id", async () => {
const tokenWithoutId = jwt.sign({ email: mockUser.email }, TEST_NEXTAUTH_SECRET);
await expect(verifyToken(tokenWithoutId)).rejects.toThrow("Invalid token");
});
test("should return raw id from payload", async () => {
// Create token with unencrypted id
const token = jwt.sign({ id: mockUser.id }, TEST_NEXTAUTH_SECRET);
const verified = await verifyToken(token);
expect(verified).toEqual({
id: mockUser.id, // Returns the raw ID from payload
email: mockUser.email,
});
});
test("should verify legacy tokens with email-based secret", async () => {
// Create legacy token with old format (NEXTAUTH_SECRET + userEmail)
const legacyToken = jwt.sign({ id: `encrypted_${mockUser.id}` }, TEST_NEXTAUTH_SECRET + mockUser.email);
const verified = await verifyToken(legacyToken);
expect(verified).toEqual({
id: mockUser.id, // Returns the decrypted user ID
email: mockUser.email,
});
});
test("should prioritize new tokens over legacy tokens", async () => {
// Create both new and legacy tokens for the same user
const newToken = createToken(mockUser.id);
const legacyToken = jwt.sign({ id: `encrypted_${mockUser.id}` }, TEST_NEXTAUTH_SECRET + mockUser.email);
// New token should verify without triggering legacy path
const verifiedNew = await verifyToken(newToken);
expect(verifiedNew.id).toBe(mockUser.id); // Returns decrypted user ID
// Legacy token should trigger legacy path
const verifiedLegacy = await verifyToken(legacyToken);
expect(verifiedLegacy.id).toBe(mockUser.id); // Returns decrypted user ID
});
test("should reject tokens that fail both new and legacy verification", async () => {
const invalidToken = jwt.sign({ id: "encrypted_test-id" }, "wrong-secret");
await expect(verifyToken(invalidToken)).rejects.toThrow("Invalid token");
// Verify both methods were attempted
const { logger } = await import("@formbricks/logger");
expect(logger.error).toHaveBeenCalledWith(
expect.any(Error),
"Token verification failed with new method"
);
expect(logger.error).toHaveBeenCalledWith(
expect.any(Error),
"Token verification failed with legacy method"
);
});
});
describe("verifyInviteToken", () => {
@@ -139,6 +473,53 @@ describe("JWT Functions", () => {
test("should throw error for invalid token", () => {
expect(() => verifyInviteToken("invalid-token")).toThrow("Invalid or expired invite token");
});
test("should throw error if NEXTAUTH_SECRET or ENCRYPTION_KEY is not set", async () => {
await testMissingSecretsError(verifyInviteToken, ["any-token"]);
});
test("should throw error if inviteId is missing", () => {
const tokenWithoutInviteId = jwt.sign({ email: mockUser.email }, TEST_NEXTAUTH_SECRET);
expect(() => verifyInviteToken(tokenWithoutInviteId)).toThrow("Invalid or expired invite token");
});
test("should throw error if email is missing", () => {
const tokenWithoutEmail = jwt.sign({ inviteId: "test-invite-id" }, TEST_NEXTAUTH_SECRET);
expect(() => verifyInviteToken(tokenWithoutEmail)).toThrow("Invalid or expired invite token");
});
test("should fall back to original values if decryption fails", () => {
mockSymmetricDecrypt.mockImplementation(() => {
throw new Error("Decryption failed");
});
const inviteId = "test-invite-id";
const legacyToken = jwt.sign(
{
inviteId,
email: mockUser.email,
},
TEST_NEXTAUTH_SECRET
);
const verified = verifyInviteToken(legacyToken);
expect(verified).toEqual({
inviteId,
email: mockUser.email,
});
});
test("should throw error for token with wrong signature", () => {
const invalidToken = jwt.sign(
{
inviteId: "test-invite-id",
email: mockUser.email,
},
DIFFERENT_SECRET
);
expect(() => verifyInviteToken(invalidToken)).toThrow("Invalid or expired invite token");
});
});
describe("verifyEmailChangeToken", () => {
@@ -150,22 +531,478 @@ describe("JWT Functions", () => {
expect(result).toEqual({ id: userId, email });
});
test("should throw error if NEXTAUTH_SECRET or ENCRYPTION_KEY is not set", async () => {
await testMissingSecretsError(verifyEmailChangeToken, ["any-token"], { isAsync: true });
});
test("should throw error if token is invalid or missing fields", async () => {
// Create a token with missing fields
const jwt = await import("jsonwebtoken");
const token = jwt.sign({ foo: "bar" }, env.NEXTAUTH_SECRET as string);
const token = jwt.sign({ foo: "bar" }, TEST_NEXTAUTH_SECRET);
await expect(verifyEmailChangeToken(token)).rejects.toThrow(
"Token is invalid or missing required fields"
);
});
test("should throw error if id is missing", async () => {
const token = jwt.sign({ email: "test@example.com" }, TEST_NEXTAUTH_SECRET);
await expect(verifyEmailChangeToken(token)).rejects.toThrow(
"Token is invalid or missing required fields"
);
});
test("should throw error if email is missing", async () => {
const token = jwt.sign({ id: "test-id" }, TEST_NEXTAUTH_SECRET);
await expect(verifyEmailChangeToken(token)).rejects.toThrow(
"Token is invalid or missing required fields"
);
});
test("should return original id/email if decryption fails", async () => {
// Create a token with non-encrypted id/email
const jwt = await import("jsonwebtoken");
mockSymmetricDecrypt.mockImplementation(() => {
throw new Error("Decryption failed");
});
const payload = { id: "plain-id", email: "plain@example.com" };
const token = jwt.sign(payload, env.NEXTAUTH_SECRET as string);
const token = jwt.sign(payload, TEST_NEXTAUTH_SECRET);
const result = await verifyEmailChangeToken(token);
expect(result).toEqual(payload);
});
test("should throw error for token with wrong signature", async () => {
const invalidToken = jwt.sign(
{
id: "test-id",
email: "test@example.com",
},
DIFFERENT_SECRET
);
await expect(verifyEmailChangeToken(invalidToken)).rejects.toThrow();
});
});
// SECURITY SCENARIO TESTS
describe("Security Scenarios", () => {
describe("Algorithm Confusion Attack Prevention", () => {
test("should reject 'none' algorithm tokens in verifyToken", async () => {
// Create malicious token with "none" algorithm
const maliciousToken =
Buffer.from(
JSON.stringify({
alg: "none",
typ: "JWT",
})
).toString("base64url") +
"." +
Buffer.from(
JSON.stringify({
id: "encrypted_malicious-id",
})
).toString("base64url") +
".";
await expect(verifyToken(maliciousToken)).rejects.toThrow("Invalid token");
});
test("should reject 'none' algorithm tokens in verifyTokenForLinkSurvey", () => {
const maliciousToken =
Buffer.from(
JSON.stringify({
alg: "none",
typ: "JWT",
})
).toString("base64url") +
"." +
Buffer.from(
JSON.stringify({
email: "encrypted_attacker@evil.com",
surveyId: "test-survey-id",
})
).toString("base64url") +
".";
const result = verifyTokenForLinkSurvey(maliciousToken, "test-survey-id");
expect(result).toBeNull();
});
test("should reject 'none' algorithm tokens in verifyInviteToken", () => {
const maliciousToken =
Buffer.from(
JSON.stringify({
alg: "none",
typ: "JWT",
})
).toString("base64url") +
"." +
Buffer.from(
JSON.stringify({
inviteId: "encrypted_malicious-invite",
email: "encrypted_attacker@evil.com",
})
).toString("base64url") +
".";
expect(() => verifyInviteToken(maliciousToken)).toThrow("Invalid or expired invite token");
});
test("should reject 'none' algorithm tokens in verifyEmailChangeToken", async () => {
const maliciousToken =
Buffer.from(
JSON.stringify({
alg: "none",
typ: "JWT",
})
).toString("base64url") +
"." +
Buffer.from(
JSON.stringify({
id: "encrypted_malicious-id",
email: "encrypted_attacker@evil.com",
})
).toString("base64url") +
".";
await expect(verifyEmailChangeToken(maliciousToken)).rejects.toThrow();
});
test("should reject RS256 algorithm tokens (HS256/RS256 confusion)", async () => {
// Create malicious token with RS256 algorithm header but HS256 signature
const maliciousHeader = Buffer.from(
JSON.stringify({
alg: "RS256",
typ: "JWT",
})
).toString("base64url");
const maliciousPayload = Buffer.from(
JSON.stringify({
id: "encrypted_malicious-id",
})
).toString("base64url");
// Create signature using HMAC (as if it were HS256)
const crypto = require("crypto");
const signature = crypto
.createHmac("sha256", TEST_NEXTAUTH_SECRET)
.update(`${maliciousHeader}.${maliciousPayload}`)
.digest("base64url");
const maliciousToken = `${maliciousHeader}.${maliciousPayload}.${signature}`;
await expect(verifyToken(maliciousToken)).rejects.toThrow("Invalid token");
});
test("should only accept HS256 algorithm", async () => {
// Test that other valid algorithms are rejected
const otherAlgorithms = ["HS384", "HS512", "RS256", "RS384", "RS512", "ES256", "ES384", "ES512"];
for (const alg of otherAlgorithms) {
const maliciousHeader = Buffer.from(
JSON.stringify({
alg,
typ: "JWT",
})
).toString("base64url");
const maliciousPayload = Buffer.from(
JSON.stringify({
id: "encrypted_test-id",
})
).toString("base64url");
const maliciousToken = `${maliciousHeader}.${maliciousPayload}.fake-signature`;
await expect(verifyToken(maliciousToken)).rejects.toThrow("Invalid token");
}
});
});
describe("Token Tampering", () => {
test("should reject tokens with modified payload", async () => {
const token = createToken(mockUser.id);
const [header, payload, signature] = token.split(".");
// Modify the payload
const decodedPayload = JSON.parse(Buffer.from(payload, "base64url").toString());
decodedPayload.id = "malicious-id";
const tamperedPayload = Buffer.from(JSON.stringify(decodedPayload)).toString("base64url");
const tamperedToken = `${header}.${tamperedPayload}.${signature}`;
await expect(verifyToken(tamperedToken)).rejects.toThrow("Invalid token");
});
test("should reject tokens with modified signature", async () => {
const token = createToken(mockUser.id);
const [header, payload] = token.split(".");
const tamperedToken = `${header}.${payload}.tamperedsignature`;
await expect(verifyToken(tamperedToken)).rejects.toThrow("Invalid token");
});
test("should reject malformed tokens", async () => {
const malformedTokens = [
"not.a.jwt",
"only.two.parts",
"too.many.parts.here.invalid",
"",
"invalid-base64",
];
for (const malformedToken of malformedTokens) {
await expect(verifyToken(malformedToken)).rejects.toThrow();
}
});
});
describe("Cross-Survey Token Reuse", () => {
test("should reject survey tokens used for different surveys", () => {
const surveyId1 = "survey-1";
const surveyId2 = "survey-2";
const token = createTokenForLinkSurvey(surveyId1, mockUser.email);
const result = verifyTokenForLinkSurvey(token, surveyId2);
expect(result).toBeNull();
});
});
describe("Expired Tokens", () => {
test("should reject expired tokens", async () => {
const expiredToken = jwt.sign(
{
id: "encrypted_test-id",
exp: Math.floor(Date.now() / 1000) - 3600, // Expired 1 hour ago
},
TEST_NEXTAUTH_SECRET
);
await expect(verifyToken(expiredToken)).rejects.toThrow("Invalid token");
});
test("should reject expired email change tokens", async () => {
const expiredToken = jwt.sign(
{
id: "encrypted_test-id",
email: "encrypted_test@example.com",
exp: Math.floor(Date.now() / 1000) - 3600, // Expired 1 hour ago
},
TEST_NEXTAUTH_SECRET
);
await expect(verifyEmailChangeToken(expiredToken)).rejects.toThrow();
});
});
describe("Encryption Key Attacks", () => {
test("should fail gracefully with wrong encryption key", async () => {
mockSymmetricDecrypt.mockImplementation(() => {
throw new Error("Authentication tag verification failed");
});
// Mock findUnique to only return user for correct decrypted ID, not ciphertext
(prisma.user.findUnique as any).mockImplementation(({ where }: { where: { id: string } }) => {
if (where.id === mockUser.id) {
return Promise.resolve(mockUser);
}
return Promise.resolve(null); // Return null for ciphertext IDs
});
const token = createToken(mockUser.id);
// Should fail because ciphertext passed as userId won't match any user in DB
await expect(verifyToken(token)).rejects.toThrow(/User not found/i);
});
test("should handle encryption key not set gracefully", async () => {
const constants = await import("@/lib/constants");
const originalKey = (constants as any).ENCRYPTION_KEY;
(constants as any).ENCRYPTION_KEY = undefined;
const token = jwt.sign(
{
email: "test@example.com",
surveyId: "test-survey-id",
},
TEST_NEXTAUTH_SECRET
);
const result = verifyTokenForLinkSurvey(token, "test-survey-id");
expect(result).toBe("test@example.com");
// Restore
(constants as any).ENCRYPTION_KEY = originalKey;
});
});
describe("SQL Injection Attempts", () => {
test("should safely handle malicious user IDs", async () => {
const maliciousIds = [
"'; DROP TABLE users; --",
"1' OR '1'='1",
"admin'/*",
"<script>alert('xss')</script>",
"../../etc/passwd",
];
for (const maliciousId of maliciousIds) {
mockSymmetricDecrypt.mockReturnValueOnce(maliciousId);
const token = jwt.sign({ id: "encrypted_malicious" }, TEST_NEXTAUTH_SECRET);
// The function should look up the user safely
await verifyToken(token);
expect(prisma.user.findUnique).toHaveBeenCalledWith({
where: { id: maliciousId },
});
}
});
});
describe("Token Reuse and Replay Attacks", () => {
test("should allow legitimate token reuse within validity period", async () => {
const token = createToken(mockUser.id);
// First use
const result1 = await verifyToken(token);
expect(result1.id).toBe(mockUser.id); // Returns decrypted user ID
// Second use (should still work)
const result2 = await verifyToken(token);
expect(result2.id).toBe(mockUser.id); // Returns decrypted user ID
});
});
describe("Legacy Token Compatibility", () => {
test("should handle legacy unencrypted tokens gracefully", async () => {
// Legacy token with plain text data
const legacyToken = jwt.sign({ id: mockUser.id }, TEST_NEXTAUTH_SECRET);
const result = await verifyToken(legacyToken);
expect(result.id).toBe(mockUser.id); // Returns raw ID from payload
expect(result.email).toBe(mockUser.email);
});
test("should handle mixed encrypted/unencrypted fields", async () => {
mockSymmetricDecrypt
.mockImplementationOnce(() => mockUser.id) // id decrypts successfully
.mockImplementationOnce(() => {
throw new Error("Email not encrypted");
}); // email fails
const token = jwt.sign(
{
id: "encrypted_test-id",
email: "plain-email@example.com",
},
TEST_NEXTAUTH_SECRET
);
const result = await verifyEmailChangeToken(token);
expect(result.id).toBe(mockUser.id);
expect(result.email).toBe("plain-email@example.com");
});
test("should verify old format user tokens with email-based secrets", async () => {
// Simulate old token format with per-user secret
const oldFormatToken = jwt.sign(
{ id: `encrypted_${mockUser.id}` },
TEST_NEXTAUTH_SECRET + mockUser.email
);
const result = await verifyToken(oldFormatToken);
expect(result.id).toBe(mockUser.id); // Returns decrypted user ID
expect(result.email).toBe(mockUser.email);
});
test("should verify old format survey tokens with survey-based secrets", () => {
const surveyId = "legacy-survey-id";
// Simulate old survey token format
const oldFormatSurveyToken = jwt.sign(
{ email: `encrypted_${mockUser.email}` },
TEST_NEXTAUTH_SECRET + surveyId
);
const result = verifyTokenForLinkSurvey(oldFormatSurveyToken, surveyId);
expect(result).toBe(mockUser.email);
});
test("should gracefully handle database errors during legacy verification", async () => {
// Create token that will fail new method
const legacyToken = jwt.sign(
{ id: `encrypted_${mockUser.id}` },
TEST_NEXTAUTH_SECRET + mockUser.email
);
// Make database lookup fail
(prisma.user.findUnique as any).mockRejectedValueOnce(new Error("DB connection lost"));
await expect(verifyToken(legacyToken)).rejects.toThrow("DB connection lost");
});
});
describe("Edge Cases and Error Handling", () => {
test("should handle database connection errors gracefully", async () => {
(prisma.user.findUnique as any).mockRejectedValue(new Error("Database connection failed"));
const token = createToken(mockUser.id);
await expect(verifyToken(token)).rejects.toThrow("Database connection failed");
});
test("should handle crypto module errors", () => {
mockSymmetricEncrypt.mockImplementation(() => {
throw new Error("Crypto module error");
});
expect(() => createToken(mockUser.id)).toThrow("Crypto module error");
});
test("should validate email format in tokens", () => {
const invalidEmails = ["", "not-an-email", "missing@", "@missing-local.com", "spaces in@email.com"];
invalidEmails.forEach((invalidEmail) => {
expect(() => createEmailToken(invalidEmail)).not.toThrow();
// Note: JWT functions don't validate email format, they just encrypt/decrypt
// Email validation should happen at a higher level
});
});
test("should handle extremely long inputs", () => {
const longString = "a".repeat(10000);
expect(() => createToken(longString)).not.toThrow();
expect(() => createEmailToken(longString)).not.toThrow();
});
test("should handle special characters in user data", () => {
const specialChars = "!@#$%^&*()_+-=[]{}|;:'\",.<>?/~`";
expect(() => createToken(specialChars)).not.toThrow();
expect(() => createEmailToken(specialChars)).not.toThrow();
});
});
describe("Performance and Resource Exhaustion", () => {
test("should handle rapid token creation without memory leaks", () => {
const tokens: string[] = [];
for (let i = 0; i < 1000; i++) {
tokens.push(createToken(`user-${i}`));
}
expect(tokens.length).toBe(1000);
expect(tokens.every((token) => typeof token === "string")).toBe(true);
});
test("should handle rapid token verification", async () => {
const token = createToken(mockUser.id);
const verifications: Promise<any>[] = [];
for (let i = 0; i < 100; i++) {
verifications.push(verifyToken(token));
}
const results = await Promise.all(verifications);
expect(results.length).toBe(100);
expect(results.every((result: any) => result.id === mockUser.id)).toBe(true); // Returns decrypted user ID
});
});
});
});
+214 -90
View File
@@ -1,43 +1,64 @@
import { symmetricDecrypt, symmetricEncrypt } from "@/lib/crypto";
import { env } from "@/lib/env";
import jwt, { JwtPayload } from "jsonwebtoken";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { ENCRYPTION_KEY, NEXTAUTH_SECRET } from "@/lib/constants";
import { symmetricDecrypt, symmetricEncrypt } from "@/lib/crypto";
export const createToken = (userId: string, userEmail: string, options = {}): string => {
const encryptedUserId = symmetricEncrypt(userId, env.ENCRYPTION_KEY);
return jwt.sign({ id: encryptedUserId }, env.NEXTAUTH_SECRET + userEmail, options);
};
export const createTokenForLinkSurvey = (surveyId: string, userEmail: string): string => {
const encryptedEmail = symmetricEncrypt(userEmail, env.ENCRYPTION_KEY);
return jwt.sign({ email: encryptedEmail }, env.NEXTAUTH_SECRET + surveyId);
// Helper function to decrypt with fallback to plain text
const decryptWithFallback = (encryptedText: string, key: string): string => {
try {
return symmetricDecrypt(encryptedText, key);
} catch {
return encryptedText; // Return as-is if decryption fails (legacy format)
}
};
export const verifyEmailChangeToken = async (token: string): Promise<{ id: string; email: string }> => {
if (!env.NEXTAUTH_SECRET) {
export const createToken = (userId: string, options = {}): string => {
if (!NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is not set");
}
const payload = jwt.verify(token, env.NEXTAUTH_SECRET) as { id: string; email: string };
if (!ENCRYPTION_KEY) {
throw new Error("ENCRYPTION_KEY is not set");
}
const encryptedUserId = symmetricEncrypt(userId, ENCRYPTION_KEY);
return jwt.sign({ id: encryptedUserId }, NEXTAUTH_SECRET, options);
};
export const createTokenForLinkSurvey = (surveyId: string, userEmail: string): string => {
if (!NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is not set");
}
if (!ENCRYPTION_KEY) {
throw new Error("ENCRYPTION_KEY is not set");
}
const encryptedEmail = symmetricEncrypt(userEmail, ENCRYPTION_KEY);
return jwt.sign({ email: encryptedEmail, surveyId }, NEXTAUTH_SECRET);
};
export const verifyEmailChangeToken = async (token: string): Promise<{ id: string; email: string }> => {
if (!NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is not set");
}
if (!ENCRYPTION_KEY) {
throw new Error("ENCRYPTION_KEY is not set");
}
const payload = jwt.verify(token, NEXTAUTH_SECRET, { algorithms: ["HS256"] }) as {
id: string;
email: string;
};
if (!payload?.id || !payload?.email) {
throw new Error("Token is invalid or missing required fields");
}
let decryptedId: string;
let decryptedEmail: string;
try {
decryptedId = symmetricDecrypt(payload.id, env.ENCRYPTION_KEY);
} catch {
decryptedId = payload.id;
}
try {
decryptedEmail = symmetricDecrypt(payload.email, env.ENCRYPTION_KEY);
} catch {
decryptedEmail = payload.email;
}
// Decrypt both fields with fallback
const decryptedId = decryptWithFallback(payload.id, ENCRYPTION_KEY);
const decryptedEmail = decryptWithFallback(payload.email, ENCRYPTION_KEY);
return {
id: decryptedId,
@@ -46,127 +67,230 @@ export const verifyEmailChangeToken = async (token: string): Promise<{ id: strin
};
export const createEmailChangeToken = (userId: string, email: string): string => {
const encryptedUserId = symmetricEncrypt(userId, env.ENCRYPTION_KEY);
const encryptedEmail = symmetricEncrypt(email, env.ENCRYPTION_KEY);
if (!NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is not set");
}
if (!ENCRYPTION_KEY) {
throw new Error("ENCRYPTION_KEY is not set");
}
const encryptedUserId = symmetricEncrypt(userId, ENCRYPTION_KEY);
const encryptedEmail = symmetricEncrypt(email, ENCRYPTION_KEY);
const payload = {
id: encryptedUserId,
email: encryptedEmail,
};
return jwt.sign(payload, env.NEXTAUTH_SECRET as string, {
return jwt.sign(payload, NEXTAUTH_SECRET, {
expiresIn: "1d",
});
};
export const createEmailToken = (email: string): string => {
if (!env.NEXTAUTH_SECRET) {
if (!NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is not set");
}
const encryptedEmail = symmetricEncrypt(email, env.ENCRYPTION_KEY);
return jwt.sign({ email: encryptedEmail }, env.NEXTAUTH_SECRET);
if (!ENCRYPTION_KEY) {
throw new Error("ENCRYPTION_KEY is not set");
}
const encryptedEmail = symmetricEncrypt(email, ENCRYPTION_KEY);
return jwt.sign({ email: encryptedEmail }, NEXTAUTH_SECRET);
};
export const getEmailFromEmailToken = (token: string): string => {
if (!env.NEXTAUTH_SECRET) {
if (!NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is not set");
}
const payload = jwt.verify(token, env.NEXTAUTH_SECRET) as JwtPayload;
try {
// Try to decrypt first (for newer tokens)
const decryptedEmail = symmetricDecrypt(payload.email, env.ENCRYPTION_KEY);
return decryptedEmail;
} catch {
// If decryption fails, return the original email (for older tokens)
return payload.email;
if (!ENCRYPTION_KEY) {
throw new Error("ENCRYPTION_KEY is not set");
}
const payload = jwt.verify(token, NEXTAUTH_SECRET, { algorithms: ["HS256"] }) as JwtPayload & {
email: string;
};
return decryptWithFallback(payload.email, ENCRYPTION_KEY);
};
export const createInviteToken = (inviteId: string, email: string, options = {}): string => {
if (!env.NEXTAUTH_SECRET) {
if (!NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is not set");
}
const encryptedInviteId = symmetricEncrypt(inviteId, env.ENCRYPTION_KEY);
const encryptedEmail = symmetricEncrypt(email, env.ENCRYPTION_KEY);
return jwt.sign({ inviteId: encryptedInviteId, email: encryptedEmail }, env.NEXTAUTH_SECRET, options);
if (!ENCRYPTION_KEY) {
throw new Error("ENCRYPTION_KEY is not set");
}
const encryptedInviteId = symmetricEncrypt(inviteId, ENCRYPTION_KEY);
const encryptedEmail = symmetricEncrypt(email, ENCRYPTION_KEY);
return jwt.sign({ inviteId: encryptedInviteId, email: encryptedEmail }, NEXTAUTH_SECRET, options);
};
export const verifyTokenForLinkSurvey = (token: string, surveyId: string): string | null => {
if (!NEXTAUTH_SECRET) {
return null;
}
try {
const { email } = jwt.verify(token, env.NEXTAUTH_SECRET + surveyId) as JwtPayload;
let payload: JwtPayload & { email: string; surveyId?: string };
// Try primary method first (consistent secret)
try {
// Try to decrypt first (for newer tokens)
if (!env.ENCRYPTION_KEY) {
throw new Error("ENCRYPTION_KEY is not set");
payload = jwt.verify(token, NEXTAUTH_SECRET, { algorithms: ["HS256"] }) as JwtPayload & {
email: string;
surveyId: string;
};
} catch (primaryError) {
logger.error(primaryError, "Token verification failed with primary method");
// Fallback to legacy method (surveyId-based secret)
try {
payload = jwt.verify(token, NEXTAUTH_SECRET + surveyId, { algorithms: ["HS256"] }) as JwtPayload & {
email: string;
};
} catch (legacyError) {
logger.error(legacyError, "Token verification failed with legacy method");
throw new Error("Invalid token");
}
const decryptedEmail = symmetricDecrypt(email, env.ENCRYPTION_KEY);
return decryptedEmail;
} catch {
// If decryption fails, return the original email (for older tokens)
return email;
}
} catch (err) {
// Verify the surveyId matches if present in payload (new format)
if (payload.surveyId && payload.surveyId !== surveyId) {
return null;
}
const { email } = payload;
if (!email) {
return null;
}
// Decrypt email with fallback to plain text
if (!ENCRYPTION_KEY) {
return email; // Return as-is if encryption key not set
}
return decryptWithFallback(email, ENCRYPTION_KEY);
} catch (error) {
logger.error(error, "Survey link token verification failed");
return null;
}
};
export const verifyToken = async (token: string): Promise<JwtPayload> => {
// First decode to get the ID
const decoded = jwt.decode(token);
const payload: JwtPayload = decoded as JwtPayload;
// Helper function to get user email for legacy verification
const getUserEmailForLegacyVerification = async (
token: string,
userId?: string
): Promise<{ userId: string; userEmail: string }> => {
if (!userId) {
const decoded = jwt.decode(token);
if (!payload) {
throw new Error("Token is invalid");
// Validate decoded token structure before using it
if (
!decoded ||
typeof decoded !== "object" ||
!decoded.id ||
typeof decoded.id !== "string" ||
decoded.id.trim() === ""
) {
logger.error("Invalid token: missing or invalid user ID");
throw new Error("Invalid token");
}
userId = decoded.id;
}
const { id } = payload;
if (!id) {
throw new Error("Token missing required field: id");
const decryptedId = decryptWithFallback(userId, ENCRYPTION_KEY);
// Validate decrypted ID before database query
if (!decryptedId || typeof decryptedId !== "string" || decryptedId.trim() === "") {
logger.error("Invalid token: missing or invalid user ID");
throw new Error("Invalid token");
}
// Try to decrypt the ID (for newer tokens), if it fails use the ID as-is (for older tokens)
let decryptedId: string;
try {
decryptedId = symmetricDecrypt(id, env.ENCRYPTION_KEY);
} catch {
decryptedId = id;
}
// If no email provided, look up the user
const foundUser = await prisma.user.findUnique({
where: { id: decryptedId },
});
if (!foundUser) {
throw new Error("User not found");
const errorMessage = "User not found";
logger.error(errorMessage);
throw new Error(errorMessage);
}
const userEmail = foundUser.email;
return { userId: decryptedId, userEmail: foundUser.email };
};
return { id: decryptedId, email: userEmail };
export const verifyToken = async (token: string): Promise<JwtPayload> => {
if (!NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is not set");
}
let payload: JwtPayload & { id: string };
let userData: { userId: string; userEmail: string } | null = null;
// Try new method first, with smart fallback to legacy
try {
payload = jwt.verify(token, NEXTAUTH_SECRET, { algorithms: ["HS256"] }) as JwtPayload & {
id: string;
};
} catch (newMethodError) {
logger.error(newMethodError, "Token verification failed with new method");
// Get user email for legacy verification
userData = await getUserEmailForLegacyVerification(token);
// Try legacy verification with email-based secret
try {
payload = jwt.verify(token, NEXTAUTH_SECRET + userData.userEmail, {
algorithms: ["HS256"],
}) as JwtPayload & {
id: string;
};
} catch (legacyMethodError) {
logger.error(legacyMethodError, "Token verification failed with legacy method");
throw new Error("Invalid token");
}
}
if (!payload?.id) {
throw new Error("Invalid token");
}
// Get user email if we don't have it yet
userData ??= await getUserEmailForLegacyVerification(token, payload.id);
return { id: userData.userId, email: userData.userEmail };
};
export const verifyInviteToken = (token: string): { inviteId: string; email: string } => {
if (!NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is not set");
}
if (!ENCRYPTION_KEY) {
throw new Error("ENCRYPTION_KEY is not set");
}
try {
const decoded = jwt.decode(token);
const payload: JwtPayload = decoded as JwtPayload;
const payload = jwt.verify(token, NEXTAUTH_SECRET, { algorithms: ["HS256"] }) as JwtPayload & {
inviteId: string;
email: string;
};
const { inviteId, email } = payload;
const { inviteId: encryptedInviteId, email: encryptedEmail } = payload;
let decryptedInviteId: string;
let decryptedEmail: string;
try {
// Try to decrypt first (for newer tokens)
decryptedInviteId = symmetricDecrypt(inviteId, env.ENCRYPTION_KEY);
decryptedEmail = symmetricDecrypt(email, env.ENCRYPTION_KEY);
} catch {
// If decryption fails, use original values (for older tokens)
decryptedInviteId = inviteId;
decryptedEmail = email;
if (!encryptedInviteId || !encryptedEmail) {
throw new Error("Invalid token");
}
// Decrypt both fields with fallback to original values
const decryptedInviteId = decryptWithFallback(encryptedInviteId, ENCRYPTION_KEY);
const decryptedEmail = decryptWithFallback(encryptedEmail, ENCRYPTION_KEY);
return {
inviteId: decryptedInviteId,
email: decryptedEmail,
+3 -5
View File
@@ -1,11 +1,11 @@
import { parseRecallInfo } from "@/lib/utils/recall";
import { TResponse } from "@formbricks/types/responses";
import { TResponse, TResponseDataValue } from "@formbricks/types/responses";
import { TSurvey, TSurveyQuestion, TSurveyQuestionType } from "@formbricks/types/surveys/types";
import { getLanguageCode, getLocalizedValue } from "./i18n/utils";
// function to convert response value of type string | number | string[] or Record<string, string> to string | string[]
export const convertResponseValue = (
answer: string | number | string[] | Record<string, string>,
answer: TResponseDataValue,
question: TSurveyQuestion
): string | string[] => {
switch (question.type) {
@@ -57,9 +57,7 @@ export const getQuestionResponseMapping = (
return questionResponseMapping;
};
export const processResponseData = (
responseData: string | number | string[] | Record<string, string>
): string => {
export const processResponseData = (responseData: TResponseDataValue): string => {
switch (typeof responseData) {
case "string":
return responseData;
+1 -1
View File
@@ -450,7 +450,7 @@ const evaluateSingleCondition = (
return (
Array.isArray(leftValue) &&
Array.isArray(rightValue) &&
rightValue.some((v) => !leftValue.includes(v))
!rightValue.some((v) => leftValue.includes(v))
);
case "isAccepted":
return leftValue === "accepted";
+7 -6
View File
@@ -1,7 +1,7 @@
import { getLocalizedValue } from "@/lib/i18n/utils";
import { structuredClone } from "@/lib/pollyfills/structuredClone";
import { TResponseData, TResponseDataValue, TResponseVariables } from "@formbricks/types/responses";
import { TI18nString, TSurvey, TSurveyQuestion, TSurveyRecallItem } from "@formbricks/types/surveys/types";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { structuredClone } from "@/lib/pollyfills/structuredClone";
import { formatDateWithOrdinal, isValidDateString } from "./datetime";
export interface fallbacks {
@@ -118,15 +118,16 @@ export const replaceRecallInfoWithUnderline = (label: string): string => {
// Checks for survey questions with a "recall" pattern but no fallback value.
export const checkForEmptyFallBackValue = (survey: TSurvey, language: string): TSurveyQuestion | null => {
const findRecalls = (text: string) => {
const doesTextHaveRecall = (text: string) => {
const recalls = text.match(/#recall:[^ ]+/g);
return recalls && recalls.some((recall) => !extractFallbackValue(recall));
return recalls?.some((recall) => !extractFallbackValue(recall));
};
for (const question of survey.questions) {
if (
findRecalls(getLocalizedValue(question.headline, language)) ||
(question.subheader && findRecalls(getLocalizedValue(question.subheader, language)))
doesTextHaveRecall(getLocalizedValue(question.headline, language)) ||
(question.subheader && doesTextHaveRecall(getLocalizedValue(question.subheader, language))) ||
("html" in question && doesTextHaveRecall(getLocalizedValue(question.html, language)))
) {
return question;
}
+1 -30
View File
@@ -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");
-7
View File
@@ -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 "";
+28 -7
View File
@@ -262,7 +262,9 @@
"membership_not_found": "Mitgliedschaft nicht gefunden",
"metadata": "Metadaten",
"minimum": "Minimum",
"mobile_overlay_text": "Formbricks ist für Geräte mit kleineren Auflösungen nicht verfügbar.",
"mobile_overlay_app_works_best_on_desktop": "Formbricks funktioniert am besten auf einem größeren Bildschirm. Um Umfragen zu verwalten oder zu erstellen, wechsle zu einem anderen Gerät.",
"mobile_overlay_surveys_look_good": "Keine Sorge deine Umfragen sehen auf jedem Gerät und jeder Bildschirmgröße großartig aus!",
"mobile_overlay_title": "Oops, Bildschirm zu klein erkannt!",
"move_down": "Nach unten bewegen",
"move_up": "Nach oben bewegen",
"multiple_languages": "Mehrsprachigkeit",
@@ -277,6 +279,7 @@
"no_result_found": "Kein Ergebnis gefunden",
"no_results": "Keine Ergebnisse",
"no_surveys_found": "Keine Umfragen gefunden.",
"none_of_the_above": "Keine der oben genannten Optionen",
"not_authenticated": "Du bist nicht authentifiziert, um diese Aktion durchzuführen.",
"not_authorized": "Nicht berechtigt",
"not_connected": "Nicht verbunden",
@@ -750,7 +753,6 @@
},
"project": {
"api_keys": {
"access_control": "Zugriffskontrolle",
"add_api_key": "API-Schlüssel hinzufügen",
"api_key": "API-Schlüssel",
"api_key_copied_to_clipboard": "API-Schlüssel in die Zwischenablage kopiert",
@@ -1202,12 +1204,12 @@
"add_description": "Beschreibung hinzufügen",
"add_ending": "Abschluss hinzufügen",
"add_ending_below": "Abschluss unten hinzufügen",
"add_fallback": "Hinzufügen",
"add_fallback_placeholder": "Hinzufügen eines Platzhalters, der angezeigt wird, wenn die Frage übersprungen wird:",
"add_fallback_placeholder": "Platzhalter hinzufügen, falls kein Wert zur Verfügung steht.",
"add_hidden_field_id": "Verstecktes Feld ID hinzufügen",
"add_highlight_border": "Rahmen hinzufügen",
"add_highlight_border_description": "Füge deiner Umfragekarte einen äußeren Rahmen hinzu.",
"add_logic": "Logik hinzufügen",
"add_none_of_the_above": "Füge \"Keine der oben genannten Optionen\" hinzu",
"add_option": "Option hinzufügen",
"add_other": "Anderes hinzufügen",
"add_photo_or_video": "Foto oder Video hinzufügen",
@@ -1240,6 +1242,7 @@
"automatically_mark_the_survey_as_complete_after": "Umfrage automatisch als abgeschlossen markieren nach",
"back_button_label": "Zurück\"- Button ",
"background_styling": "Hintergründe",
"bold": "Fett",
"brand_color": "Markenfarbe",
"brightness": "Helligkeit",
"button_label": "Beschriftung",
@@ -1323,6 +1326,7 @@
"does_not_include_all_of": "Enthält nicht alle von",
"does_not_include_one_of": "Enthält nicht eines von",
"does_not_start_with": "Fängt nicht an mit",
"edit_link": "Bearbeitungslink",
"edit_recall": "Erinnerung bearbeiten",
"edit_translations": "{lang} -Übersetzungen bearbeiten",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Teilnehmer können die Umfragesprache jederzeit während der Umfrage ändern.",
@@ -1333,13 +1337,13 @@
"ending_card_used_in_logic": "Diese Abschlusskarte wird in der Logik der Frage {questionIndex} verwendet.",
"ending_used_in_quota": "Dieses Ende wird in der \"{quotaName}\" Quote verwendet",
"ends_with": "endet mit",
"enter_fallback_value": "Ersatzwert eingeben",
"equals": "Gleich",
"equals_one_of": "Entspricht einem von",
"error_publishing_survey": "Beim Veröffentlichen der Umfrage ist ein Fehler aufgetreten.",
"error_saving_changes": "Fehler beim Speichern der Änderungen",
"even_after_they_submitted_a_response_e_g_feedback_box": "Sogar nachdem sie eine Antwort eingereicht haben (z.B. Feedback-Box)",
"everyone": "Jeder",
"fallback_for": "Ersatz für",
"fallback_missing": "Fehlender Fallback",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} wird in der Logik der Frage {questionIndex} verwendet. Bitte entferne es zuerst aus der Logik.",
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "Verstecktes Feld \"{fieldId}\" wird in der \"{quotaName}\" Quote verwendet",
@@ -1374,8 +1378,9 @@
"follow_ups_modal_action_subject_placeholder": "Betreff der E-Mail",
"follow_ups_modal_action_to_description": "Empfänger-E-Mail-Adresse",
"follow_ups_modal_action_to_label": "An",
"follow_ups_modal_action_to_warning": "Kein E-Mail-Feld in der Umfrage gefunden.",
"follow_ups_modal_action_to_warning": "Keine gültigen Optionen für den Versand von E-Mails gefunden, bitte fügen Sie einige Freitext- / Kontaktinformationen-Fragen oder versteckte Felder hinzu",
"follow_ups_modal_create_heading": "Neues Follow-up erstellen",
"follow_ups_modal_created_successfull_toast": "Nachverfolgung erstellt und wird gespeichert, sobald du die Umfrage speicherst.",
"follow_ups_modal_edit_heading": "Follow-up bearbeiten",
"follow_ups_modal_edit_no_id": "Keine Survey Follow-up-ID angegeben, das Survey-Follow-up kann nicht aktualisiert werden",
"follow_ups_modal_name_label": "Name des Follow-ups",
@@ -1385,8 +1390,9 @@
"follow_ups_modal_trigger_label": "Auslöser",
"follow_ups_modal_trigger_type_ending": "Teilnehmer sieht einen bestimmten Abschluss",
"follow_ups_modal_trigger_type_ending_select": "Abschlüsse auswählen: ",
"follow_ups_modal_trigger_type_ending_warning": "Keine Abschlüsse in der Umfrage gefunden!",
"follow_ups_modal_trigger_type_ending_warning": "Bitte wähle mindestens ein Ende aus oder ändere den Auslöser-Typ",
"follow_ups_modal_trigger_type_response": "Teilnehmer schließt Umfrage ab",
"follow_ups_modal_updated_successfull_toast": "Nachverfolgung aktualisiert und wird gespeichert, sobald du die Umfrage speicherst.",
"follow_ups_new": "Neues Follow-up",
"follow_ups_upgrade_button_text": "Upgrade, um Follow-ups zu aktivieren",
"form_styling": "Umfrage Styling",
@@ -1394,6 +1400,9 @@
"four_points": "4 Punkte",
"heading": "Überschrift",
"hidden_field_added_successfully": "Verstecktes Feld erfolgreich hinzugefügt",
"hidden_field_used_in_recall": "Verstecktes Feld \"{hiddenField}\" wird in Frage {questionIndex} abgerufen.",
"hidden_field_used_in_recall_ending_card": "Verstecktes Feld \"{hiddenField}\" wird in der Abschlusskarte abgerufen.",
"hidden_field_used_in_recall_welcome": "Verstecktes Feld \"{hiddenField}\" wird in der Willkommenskarte abgerufen.",
"hide_advanced_settings": "Erweiterte Einstellungen ausblenden",
"hide_back_button": "'Zurück'-Button ausblenden",
"hide_back_button_description": "Den Zurück-Button in der Umfrage nicht anzeigen",
@@ -1412,6 +1421,7 @@
"inner_text": "Innerer Text",
"input_border_color": "Randfarbe des Eingabefelds",
"input_color": "Farbe des Eingabefelds",
"insert_link": "Link einfügen",
"invalid_targeting": "Ungültiges Targeting: Bitte überprüfe deine Zielgruppenfilter",
"invalid_video_url_warning": "Bitte gib eine gültige YouTube-, Vimeo- oder Loom-URL ein. Andere Video-Plattformen werden derzeit nicht unterstützt.",
"invalid_youtube_url": "Ungültige YouTube-URL",
@@ -1429,6 +1439,7 @@
"is_set": "Ist festgelegt",
"is_skipped": "Wird übersprungen",
"is_submitted": "Wird eingereicht",
"italic": "Kursiv",
"jump_to_question": "Zur Frage springen",
"keep_current_order": "Bestehende Anordnung beibehalten",
"keep_showing_while_conditions_match": "Zeige weiter, solange die Bedingungen übereinstimmen",
@@ -1455,6 +1466,7 @@
"no_images_found_for": "Keine Bilder gefunden für ''{query}\"",
"no_languages_found_add_first_one_to_get_started": "Keine Sprachen gefunden. Füge die erste hinzu, um loszulegen.",
"no_option_found": "Keine Option gefunden",
"no_recall_items_found": "Keine Erinnerungsstücke gefunden",
"no_variables_yet_add_first_one_below": "Noch keine Variablen. Füge die erste hinzu.",
"number": "Nummer",
"once_set_the_default_language_for_this_survey_can_only_be_changed_by_disabling_the_multi_language_option_and_deleting_all_translations": "Sobald die Standardsprache für diese Umfrage festgelegt ist, kann sie nur geändert werden, indem die Mehrsprachigkeitsoption deaktiviert und alle Übersetzungen gelöscht werden.",
@@ -1474,6 +1486,7 @@
"pin_can_only_contain_numbers": "PIN darf nur Zahlen enthalten.",
"pin_must_be_a_four_digit_number": "Die PIN muss eine vierstellige Zahl sein.",
"please_enter_a_file_extension": "Bitte gib eine Dateierweiterung ein.",
"please_enter_a_valid_url": "Bitte geben Sie eine gültige URL ein (z. B. https://beispiel.de)",
"please_set_a_survey_trigger": "Bitte richte einen Umfrage-Trigger ein",
"please_specify": "Bitte angeben",
"prevent_double_submission": "Doppeltes Anbschicken verhindern",
@@ -1488,6 +1501,8 @@
"question_id_updated": "Frage-ID aktualisiert",
"question_used_in_logic": "Diese Frage wird in der Logik der Frage {questionIndex} verwendet.",
"question_used_in_quota": "Diese Frage wird in der \"{quotaName}\" Quote verwendet",
"question_used_in_recall": "Diese Frage wird in Frage {questionIndex} abgerufen.",
"question_used_in_recall_ending_card": "Diese Frage wird in der Abschlusskarte abgerufen.",
"quotas": {
"add_quota": "Quote hinzufügen",
"change_quota_for_public_survey": "Quote für öffentliche Umfrage ändern?",
@@ -1522,6 +1537,8 @@
"randomize_all": "Alle Optionen zufällig anordnen",
"randomize_all_except_last": "Alle Optionen zufällig anordnen außer der letzten",
"range": "Reichweite",
"recall_data": "Daten abrufen",
"recall_information_from": "Information abrufen von ...",
"recontact_options": "Optionen zur erneuten Kontaktaufnahme",
"redirect_thank_you_card": "Weiterleitung anlegen",
"redirect_to_url": "Zu URL weiterleiten",
@@ -1599,6 +1616,7 @@
"trigger_survey_when_one_of_the_actions_is_fired": "Umfrage auslösen, wenn eine der Aktionen ausgeführt wird...",
"try_lollipop_or_mountain": "Versuch 'Lolli' oder 'Berge'...",
"type_field_id": "Feld-ID eingeben",
"underline": "Unterstreichen",
"unlock_targeting_description": "Spezifische Nutzergruppen basierend auf Attributen oder Geräteinformationen ansprechen",
"unlock_targeting_title": "Targeting mit einem höheren Plan freischalten",
"unsaved_changes_warning": "Du hast ungespeicherte Änderungen in deiner Umfrage. Möchtest Du sie speichern, bevor Du gehst?",
@@ -1615,6 +1633,9 @@
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variable \"{variableName}\" wird in der \"{quotaName}\" Quote verwendet",
"variable_name_is_already_taken_please_choose_another": "Variablenname ist bereits vergeben, bitte wähle einen anderen.",
"variable_name_must_start_with_a_letter": "Variablenname muss mit einem Buchstaben beginnen.",
"variable_used_in_recall": "Variable \"{variable}\" wird in Frage {questionIndex} abgerufen.",
"variable_used_in_recall_ending_card": "Variable \"{variable}\" wird in der Abschlusskarte abgerufen.",
"variable_used_in_recall_welcome": "Variable \"{variable}\" wird in der Willkommenskarte abgerufen.",
"verify_email_before_submission": "E-Mail vor dem Absenden überprüfen",
"verify_email_before_submission_description": "Lass nur Leute mit einer echten E-Mail antworten.",
"wait": "Warte",
+30 -9
View File
@@ -262,7 +262,9 @@
"membership_not_found": "Membership not found",
"metadata": "Metadata",
"minimum": "Minimum",
"mobile_overlay_text": "Formbricks is not available for devices with smaller resolutions.",
"mobile_overlay_app_works_best_on_desktop": "Formbricks works best on a bigger screen. To manage or build surveys, switch to another device.",
"mobile_overlay_surveys_look_good": "Don't worry your surveys look great on every device and screen size!",
"mobile_overlay_title": "Oops, tiny screen detected!",
"move_down": "Move down",
"move_up": "Move up",
"multiple_languages": "Multiple languages",
@@ -277,6 +279,7 @@
"no_result_found": "No result found",
"no_results": "No results",
"no_surveys_found": "No surveys found.",
"none_of_the_above": "None of the above",
"not_authenticated": "You are not authenticated to perform this action.",
"not_authorized": "Not authorized",
"not_connected": "Not Connected",
@@ -750,7 +753,6 @@
},
"project": {
"api_keys": {
"access_control": "Access Control",
"add_api_key": "Add API Key",
"api_key": "API Key",
"api_key_copied_to_clipboard": "API key copied to clipboard",
@@ -1202,12 +1204,12 @@
"add_description": "Add description",
"add_ending": "Add ending",
"add_ending_below": "Add ending below",
"add_fallback": "Add",
"add_fallback_placeholder": "Add a placeholder to show if the question gets skipped:",
"add_fallback_placeholder": "Add a placeholder to show if there is no value to recall.",
"add_hidden_field_id": "Add hidden field ID",
"add_highlight_border": "Add highlight border",
"add_highlight_border_description": "Add an outer border to your survey card.",
"add_logic": "Add logic",
"add_none_of_the_above": "Add \"None of the Above\"",
"add_option": "Add option",
"add_other": "Add \"Other\"",
"add_photo_or_video": "Add photo or video",
@@ -1240,6 +1242,7 @@
"automatically_mark_the_survey_as_complete_after": "Automatically mark the survey as complete after",
"back_button_label": "\"Back\" Button Label",
"background_styling": "Background Styling",
"bold": "Bold",
"brand_color": "Brand color",
"brightness": "Brightness",
"button_label": "Button Label",
@@ -1300,8 +1303,8 @@
"contains": "Contains",
"continue_to_settings": "Continue to Settings",
"control_which_file_types_can_be_uploaded": "Control which file types can be uploaded.",
"convert_to_multiple_choice": "Convert to Multiple Choice",
"convert_to_single_choice": "Convert to Single Choice",
"convert_to_multiple_choice": "Convert to Multi-select",
"convert_to_single_choice": "Convert to Single-select",
"country": "Country",
"create_group": "Create group",
"create_your_own_survey": "Create your own survey",
@@ -1323,6 +1326,7 @@
"does_not_include_all_of": "Does not include all of",
"does_not_include_one_of": "Does not include one of",
"does_not_start_with": "Does not start with",
"edit_link": "Edit link",
"edit_recall": "Edit Recall",
"edit_translations": "Edit {lang} translations",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Enable participants to switch the survey language at any point during the survey.",
@@ -1333,13 +1337,13 @@
"ending_card_used_in_logic": "This ending card is used in logic of question {questionIndex}.",
"ending_used_in_quota": "This ending is being used in \"{quotaName}\" quota",
"ends_with": "Ends with",
"enter_fallback_value": "Enter fallback value",
"equals": "Equals",
"equals_one_of": "Equals one of",
"error_publishing_survey": "An error occured while publishing the survey.",
"error_saving_changes": "Error saving changes",
"even_after_they_submitted_a_response_e_g_feedback_box": "Even after they submitted a response (e.g. Feedback Box)",
"everyone": "Everyone",
"fallback_for": "Fallback for ",
"fallback_missing": "Fallback missing",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} is used in logic of question {questionIndex}. Please remove it from logic first.",
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "Hidden field \"{fieldId}\" is being used in \"{quotaName}\" quota",
@@ -1374,8 +1378,9 @@
"follow_ups_modal_action_subject_placeholder": "Subject of the email",
"follow_ups_modal_action_to_description": "Email address to send the email to",
"follow_ups_modal_action_to_label": "To",
"follow_ups_modal_action_to_warning": "No email field detected in the survey",
"follow_ups_modal_action_to_warning": "No valid options found for sending emails, please add some open-text / contact-info questions or hidden fields",
"follow_ups_modal_create_heading": "Create a new follow-up",
"follow_ups_modal_created_successfull_toast": "Follow-up created and will be saved once you save the survey.",
"follow_ups_modal_edit_heading": "Edit this follow-up",
"follow_ups_modal_edit_no_id": "No survey follow up id provided, can't update the survey follow up",
"follow_ups_modal_name_label": "Follow-up name",
@@ -1385,8 +1390,9 @@
"follow_ups_modal_trigger_label": "Trigger",
"follow_ups_modal_trigger_type_ending": "Respondent sees a specific ending",
"follow_ups_modal_trigger_type_ending_select": "Select endings: ",
"follow_ups_modal_trigger_type_ending_warning": "No endings found in the survey!",
"follow_ups_modal_trigger_type_ending_warning": "Please select at least one ending or change the trigger type",
"follow_ups_modal_trigger_type_response": "Respondent completes survey",
"follow_ups_modal_updated_successfull_toast": "Follow-up updated and will be saved once you save the survey.",
"follow_ups_new": "New follow-up",
"follow_ups_upgrade_button_text": "Upgrade to enable follow-ups",
"form_styling": "Form styling",
@@ -1394,6 +1400,9 @@
"four_points": "4 points",
"heading": "Heading",
"hidden_field_added_successfully": "Hidden field added successfully",
"hidden_field_used_in_recall": "Hidden field \"{hiddenField}\" is being recalled in question {questionIndex}.",
"hidden_field_used_in_recall_ending_card": "Hidden field \"{hiddenField}\" is being recalled in Ending Card",
"hidden_field_used_in_recall_welcome": "Hidden field \"{hiddenField}\" is being recalled in Welcome card.",
"hide_advanced_settings": "Hide advanced settings",
"hide_back_button": "Hide 'Back' button",
"hide_back_button_description": "Do not display the back button in the survey",
@@ -1412,6 +1421,7 @@
"inner_text": "Inner Text",
"input_border_color": "Input border color",
"input_color": "Input color",
"insert_link": "Insert link",
"invalid_targeting": "Invalid targeting: Please check your audience filters",
"invalid_video_url_warning": "Please enter a valid YouTube, Vimeo, or Loom URL. We currently do not support other video hosting providers.",
"invalid_youtube_url": "Invalid YouTube URL",
@@ -1429,6 +1439,7 @@
"is_set": "Is set",
"is_skipped": "Is skipped",
"is_submitted": "Is submitted",
"italic": "Italic",
"jump_to_question": "Jump to question",
"keep_current_order": "Keep current order",
"keep_showing_while_conditions_match": "Keep showing while conditions match",
@@ -1455,6 +1466,7 @@
"no_images_found_for": "No images found for ''{query}\"",
"no_languages_found_add_first_one_to_get_started": "No languages found. Add the first one to get started.",
"no_option_found": "No option found",
"no_recall_items_found": "No recall items found ",
"no_variables_yet_add_first_one_below": "No variables yet. Add the first one below.",
"number": "Number",
"once_set_the_default_language_for_this_survey_can_only_be_changed_by_disabling_the_multi_language_option_and_deleting_all_translations": "Once set, the default language for this survey can only be changed by disabling the multi-language option and deleting all translations.",
@@ -1474,6 +1486,7 @@
"pin_can_only_contain_numbers": "PIN can only contain numbers.",
"pin_must_be_a_four_digit_number": "PIN must be a four digit number.",
"please_enter_a_file_extension": "Please enter a file extension.",
"please_enter_a_valid_url": "Please enter a valid URL (e.g., https://example.com)",
"please_set_a_survey_trigger": "Please set a survey trigger",
"please_specify": "Please specify",
"prevent_double_submission": "Prevent double submission",
@@ -1488,6 +1501,8 @@
"question_id_updated": "Question ID updated",
"question_used_in_logic": "This question is used in logic of question {questionIndex}.",
"question_used_in_quota": "This question is being used in \"{quotaName}\" quota",
"question_used_in_recall": "This question is being recalled in question {questionIndex}.",
"question_used_in_recall_ending_card": "This question is being recalled in Ending Card",
"quotas": {
"add_quota": "Add quota",
"change_quota_for_public_survey": "Change quota for public survey?",
@@ -1522,6 +1537,8 @@
"randomize_all": "Randomize all",
"randomize_all_except_last": "Randomize all except last",
"range": "Range",
"recall_data": "Recall data",
"recall_information_from": "Recall information from ...",
"recontact_options": "Recontact Options",
"redirect_thank_you_card": "Redirect thank you card",
"redirect_to_url": "Redirect to Url",
@@ -1599,6 +1616,7 @@
"trigger_survey_when_one_of_the_actions_is_fired": "Trigger survey when one of the actions is fired...",
"try_lollipop_or_mountain": "Try 'lollipop' or 'mountain'...",
"type_field_id": "Type field id",
"underline": "Underline",
"unlock_targeting_description": "Target specific user groups based on attributes or device information",
"unlock_targeting_title": "Unlock targeting with a higher plan",
"unsaved_changes_warning": "You have unsaved changes in your survey. Would you like to save them before leaving?",
@@ -1615,6 +1633,9 @@
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variable \"{variableName}\" is being used in \"{quotaName}\" quota",
"variable_name_is_already_taken_please_choose_another": "Variable name is already taken, please choose another.",
"variable_name_must_start_with_a_letter": "Variable name must start with a letter.",
"variable_used_in_recall": "Variable \"{variable}\" is being recalled in question {questionIndex}.",
"variable_used_in_recall_ending_card": "Variable {variable} is being recalled in Ending Card",
"variable_used_in_recall_welcome": "Variable \"{variable}\" is being recalled in Welcome Card.",
"verify_email_before_submission": "Verify email before submission",
"verify_email_before_submission_description": "Only let people with a real email respond.",
"wait": "Wait",
+28 -7
View File
@@ -262,7 +262,9 @@
"membership_not_found": "Abonnement non trouvé",
"metadata": "Métadonnées",
"minimum": "Min",
"mobile_overlay_text": "Formbricks n'est pas disponible pour les appareils avec des résolutions plus petites.",
"mobile_overlay_app_works_best_on_desktop": "Formbricks fonctionne mieux sur un écran plus grand. Pour gérer ou créer des sondages, passez à un autre appareil.",
"mobile_overlay_surveys_look_good": "Ne t'inquiète pas tes enquêtes sont superbes sur tous les appareils et tailles d'écran!",
"mobile_overlay_title": "Oups, écran minuscule détecté!",
"move_down": "Déplacer vers le bas",
"move_up": "Déplacer vers le haut",
"multiple_languages": "Plusieurs langues",
@@ -277,6 +279,7 @@
"no_result_found": "Aucun résultat trouvé",
"no_results": "Aucun résultat",
"no_surveys_found": "Aucun sondage trouvé.",
"none_of_the_above": "Aucun des éléments ci-dessus",
"not_authenticated": "Vous n'êtes pas authentifié pour effectuer cette action.",
"not_authorized": "Non autorisé",
"not_connected": "Non connecté",
@@ -750,7 +753,6 @@
},
"project": {
"api_keys": {
"access_control": "Contrôle d'accès",
"add_api_key": "Ajouter une clé API",
"api_key": "Clé API",
"api_key_copied_to_clipboard": "Clé API copiée dans le presse-papiers",
@@ -1202,12 +1204,12 @@
"add_description": "Ajouter une description",
"add_ending": "Ajouter une fin",
"add_ending_below": "Ajouter une fin ci-dessous",
"add_fallback": "Ajouter",
"add_fallback_placeholder": "Ajouter un espace réservé pour montrer si la question est ignorée :",
"add_fallback_placeholder": "Ajouter un espace réservé à afficher s'il n'y a pas de valeur à rappeler.",
"add_hidden_field_id": "Ajouter un champ caché ID",
"add_highlight_border": "Ajouter une bordure de surlignage",
"add_highlight_border_description": "Ajoutez une bordure extérieure à votre carte d'enquête.",
"add_logic": "Ajouter de la logique",
"add_none_of_the_above": "Ajouter \"Aucun des éléments ci-dessus\"",
"add_option": "Ajouter une option",
"add_other": "Ajouter \"Autre",
"add_photo_or_video": "Ajouter une photo ou une vidéo",
@@ -1240,6 +1242,7 @@
"automatically_mark_the_survey_as_complete_after": "Marquer automatiquement l'enquête comme terminée après",
"back_button_label": "Label du bouton \"Retour''",
"background_styling": "Style de fond",
"bold": "Gras",
"brand_color": "Couleur de marque",
"brightness": "Luminosité",
"button_label": "Label du bouton",
@@ -1323,6 +1326,7 @@
"does_not_include_all_of": "n'inclut pas tout",
"does_not_include_one_of": "n'inclut pas un de",
"does_not_start_with": "Ne commence pas par",
"edit_link": "Modifier le lien",
"edit_recall": "Modifier le rappel",
"edit_translations": "Modifier les traductions {lang}",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permettre aux participants de changer la langue de l'enquête à tout moment pendant celle-ci.",
@@ -1333,13 +1337,13 @@
"ending_card_used_in_logic": "Cette carte de fin est utilisée dans la logique de la question '{'questionIndex'}'.",
"ending_used_in_quota": "Cette fin est utilisée dans le quota \"{quotaName}\"",
"ends_with": "Se termine par",
"enter_fallback_value": "Saisir une valeur de secours",
"equals": "Égal",
"equals_one_of": "Égal à l'un de",
"error_publishing_survey": "Une erreur est survenue lors de la publication de l'enquête.",
"error_saving_changes": "Erreur lors de l'enregistrement des modifications",
"even_after_they_submitted_a_response_e_g_feedback_box": "Même après avoir soumis une réponse (par exemple, la boîte de feedback)",
"everyone": "Tout le monde",
"fallback_for": "Solution de repli pour ",
"fallback_missing": "Fallback manquant",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} est utilisé dans la logique de la question {questionIndex}. Veuillez d'abord le supprimer de la logique.",
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "Le champ masqué \"{fieldId}\" est utilisé dans le quota \"{quotaName}\"",
@@ -1374,8 +1378,9 @@
"follow_ups_modal_action_subject_placeholder": "Objet de l'email",
"follow_ups_modal_action_to_description": "Adresse e-mail à laquelle envoyer l'e-mail",
"follow_ups_modal_action_to_label": "à",
"follow_ups_modal_action_to_warning": "Aucun champ d'email détecté dans l'enquête",
"follow_ups_modal_action_to_warning": "Aucune option valable trouvée pour l'envoi d'emails, veuillez ajouter des questions à texte libre / info-contact ou des champs cachés",
"follow_ups_modal_create_heading": "Créer un nouveau suivi",
"follow_ups_modal_created_successfull_toast": "\"Suivi créé et sera enregistré une fois que vous sauvegarderez le sondage.\"",
"follow_ups_modal_edit_heading": "Modifier ce suivi",
"follow_ups_modal_edit_no_id": "Aucun identifiant de suivi d'enquête fourni, impossible de mettre à jour le suivi de l'enquête.",
"follow_ups_modal_name_label": "Nom de suivi",
@@ -1385,8 +1390,9 @@
"follow_ups_modal_trigger_label": "Déclencheur",
"follow_ups_modal_trigger_type_ending": "Le répondant voit une fin spécifique",
"follow_ups_modal_trigger_type_ending_select": "Choisir des fins :",
"follow_ups_modal_trigger_type_ending_warning": "Aucune fin trouvée dans l'enquête !",
"follow_ups_modal_trigger_type_ending_warning": "Veuillez sélectionner au moins une fin ou changer le type de déclencheur.",
"follow_ups_modal_trigger_type_response": "Le répondant complète l'enquête",
"follow_ups_modal_updated_successfull_toast": "\"Suivi mis à jour et sera enregistré une fois que vous sauvegarderez le sondage.\"",
"follow_ups_new": "Nouveau suivi",
"follow_ups_upgrade_button_text": "Passez à la version supérieure pour activer les relances",
"form_styling": "Style de formulaire",
@@ -1394,6 +1400,9 @@
"four_points": "4 points",
"heading": "En-tête",
"hidden_field_added_successfully": "Champ caché ajouté avec succès",
"hidden_field_used_in_recall": "Le champ caché \"{hiddenField}\" est rappelé dans la question {questionIndex}.",
"hidden_field_used_in_recall_ending_card": "Le champ caché \"{hiddenField}\" est rappelé dans la carte de fin.",
"hidden_field_used_in_recall_welcome": "Le champ caché \"{hiddenField}\" est rappelé dans la carte de bienvenue.",
"hide_advanced_settings": "Cacher les paramètres avancés",
"hide_back_button": "Masquer le bouton 'Retour'",
"hide_back_button_description": "Ne pas afficher le bouton retour dans l'enquête",
@@ -1412,6 +1421,7 @@
"inner_text": "Texte interne",
"input_border_color": "Couleur de bordure d'entrée",
"input_color": "Couleur d'entrée",
"insert_link": "Insérer un lien",
"invalid_targeting": "Ciblage invalide : Veuillez vérifier vos filtres d'audience",
"invalid_video_url_warning": "Merci d'entrer une URL YouTube, Vimeo ou Loom valide. Les autres plateformes vidéo ne sont pas encore supportées.",
"invalid_youtube_url": "URL YouTube invalide",
@@ -1429,6 +1439,7 @@
"is_set": "Est défini",
"is_skipped": "Est ignoré",
"is_submitted": "Est soumis",
"italic": "Italique",
"jump_to_question": "Passer à la question",
"keep_current_order": "Conserver la commande actuelle",
"keep_showing_while_conditions_match": "Continuer à afficher tant que les conditions correspondent",
@@ -1455,6 +1466,7 @@
"no_images_found_for": "Aucune image trouvée pour ''{query}\"",
"no_languages_found_add_first_one_to_get_started": "Aucune langue trouvée. Ajoutez la première pour commencer.",
"no_option_found": "Aucune option trouvée",
"no_recall_items_found": "Aucun élément de rappel trouvé",
"no_variables_yet_add_first_one_below": "Aucune variable pour le moment. Ajoutez la première ci-dessous.",
"number": "Numéro",
"once_set_the_default_language_for_this_survey_can_only_be_changed_by_disabling_the_multi_language_option_and_deleting_all_translations": "Une fois défini, la langue par défaut de cette enquête ne peut être changée qu'en désactivant l'option multilingue et en supprimant toutes les traductions.",
@@ -1474,6 +1486,7 @@
"pin_can_only_contain_numbers": "Le code PIN ne peut contenir que des chiffres.",
"pin_must_be_a_four_digit_number": "Le code PIN doit être un numéro à quatre chiffres.",
"please_enter_a_file_extension": "Veuillez entrer une extension de fichier.",
"please_enter_a_valid_url": "Veuillez entrer une URL valide (par exemple, https://example.com)",
"please_set_a_survey_trigger": "Veuillez définir un déclencheur d'enquête.",
"please_specify": "Veuillez préciser",
"prevent_double_submission": "Empêcher la double soumission",
@@ -1488,6 +1501,8 @@
"question_id_updated": "ID de la question mis à jour",
"question_used_in_logic": "Cette question est utilisée dans la logique de la question '{'questionIndex'}'.",
"question_used_in_quota": "Cette question est utilisée dans le quota \"{quotaName}\"",
"question_used_in_recall": "Cette question est rappelée dans la question {questionIndex}.",
"question_used_in_recall_ending_card": "Cette question est rappelée dans la carte de fin.",
"quotas": {
"add_quota": "Ajouter un quota",
"change_quota_for_public_survey": "Changer le quota pour le sondage public ?",
@@ -1522,6 +1537,8 @@
"randomize_all": "Randomiser tout",
"randomize_all_except_last": "Randomiser tout sauf le dernier",
"range": "Plage",
"recall_data": "Rappel des données",
"recall_information_from": "Rappeler les informations de ...",
"recontact_options": "Options de recontact",
"redirect_thank_you_card": "Carte de remerciement de redirection",
"redirect_to_url": "Rediriger vers l'URL",
@@ -1599,6 +1616,7 @@
"trigger_survey_when_one_of_the_actions_is_fired": "Déclencher l'enquête lorsqu'une des actions est déclenchée...",
"try_lollipop_or_mountain": "Essayez 'sucette' ou 'montagne'...",
"type_field_id": "Identifiant de champ de type",
"underline": "Souligner",
"unlock_targeting_description": "Cibler des groupes d'utilisateurs spécifiques en fonction des attributs ou des informations sur l'appareil",
"unlock_targeting_title": "Débloquez le ciblage avec un plan supérieur.",
"unsaved_changes_warning": "Vous avez des modifications non enregistrées dans votre enquête. Souhaitez-vous les enregistrer avant de partir ?",
@@ -1615,6 +1633,9 @@
"variable_is_used_in_quota_please_remove_it_from_quota_first": "La variable \"{variableName}\" est utilisée dans le quota \"{quotaName}\"",
"variable_name_is_already_taken_please_choose_another": "Le nom de la variable est déjà pris, veuillez en choisir un autre.",
"variable_name_must_start_with_a_letter": "Le nom de la variable doit commencer par une lettre.",
"variable_used_in_recall": "La variable \"{variable}\" est rappelée dans la question {questionIndex}.",
"variable_used_in_recall_ending_card": "La variable {variable} est rappelée dans la carte de fin.",
"variable_used_in_recall_welcome": "La variable \"{variable}\" est rappelée dans la carte de bienvenue.",
"verify_email_before_submission": "Vérifiez l'email avant la soumission",
"verify_email_before_submission_description": "Ne laissez répondre que les personnes ayant une véritable adresse e-mail.",
"wait": "Attendre",
+27 -6
View File
@@ -262,7 +262,9 @@
"membership_not_found": "メンバーシップが見つかりません",
"metadata": "メタデータ",
"minimum": "最小",
"mobile_overlay_text": "Formbricksは、解像度の小さいデバイスでは利用できません。",
"mobile_overlay_app_works_best_on_desktop": "Formbricks は より 大きな 画面 で最適に 作動します。 フォーム を 管理または 構築する には、 別の デバイス に 切り替える 必要が あります。",
"mobile_overlay_surveys_look_good": "ご安心ください - お使い の デバイス や 画面 サイズ に 関係なく、 フォーム は 素晴らしく 見えます!",
"mobile_overlay_title": "おっと、 小さな 画面 が 検出されました!",
"move_down": "下に移動",
"move_up": "上に移動",
"multiple_languages": "多言語",
@@ -277,6 +279,7 @@
"no_result_found": "結果が見つかりません",
"no_results": "結果なし",
"no_surveys_found": "フォームが見つかりません。",
"none_of_the_above": "いずれも該当しません",
"not_authenticated": "このアクションを実行するための認証がされていません。",
"not_authorized": "権限がありません",
"not_connected": "未接続",
@@ -750,7 +753,6 @@
},
"project": {
"api_keys": {
"access_control": "アクセス制御",
"add_api_key": "APIキーを追加",
"api_key": "APIキー",
"api_key_copied_to_clipboard": "APIキーをクリップボードにコピーしました",
@@ -1202,12 +1204,12 @@
"add_description": "説明を追加",
"add_ending": "終了を追加",
"add_ending_below": "以下に終了を追加",
"add_fallback": "追加",
"add_fallback_placeholder": "質問がスキップされた場合に表示するプレースホルダーを追加:",
"add_hidden_field_id": "非表示フィールドIDを追加",
"add_highlight_border": "ハイライトボーダーを追加",
"add_highlight_border_description": "フォームカードに外側のボーダーを追加します。",
"add_logic": "ロジックを追加",
"add_none_of_the_above": "\"いずれも該当しません\" を追加",
"add_option": "オプションを追加",
"add_other": "「その他」を追加",
"add_photo_or_video": "写真または動画を追加",
@@ -1240,6 +1242,7 @@
"automatically_mark_the_survey_as_complete_after": "フォームを自動的に完了としてマークする",
"back_button_label": "「戻る」ボタンのラベル",
"background_styling": "背景のスタイル",
"bold": "太字",
"brand_color": "ブランドカラー",
"brightness": "明るさ",
"button_label": "ボタンのラベル",
@@ -1323,6 +1326,7 @@
"does_not_include_all_of": "のすべてを含まない",
"does_not_include_one_of": "のいずれも含まない",
"does_not_start_with": "で始まらない",
"edit_link": "編集 リンク",
"edit_recall": "リコールを編集",
"edit_translations": "{lang} 翻訳を編集",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "回答者がフォームの途中でいつでも言語を切り替えられるようにします。",
@@ -1333,13 +1337,13 @@
"ending_card_used_in_logic": "この終了カードは質問 {questionIndex} のロジックで使用されています。",
"ending_used_in_quota": "この 終了 は \"{quotaName}\" クォータ で使用されています",
"ends_with": "で終わる",
"enter_fallback_value": "フォールバック値を入力",
"equals": "と等しい",
"equals_one_of": "のいずれかと等しい",
"error_publishing_survey": "フォームの公開中にエラーが発生しました。",
"error_saving_changes": "変更の保存中にエラーが発生しました",
"even_after_they_submitted_a_response_e_g_feedback_box": "回答を送信した後でも(例:フィードバックボックス)",
"everyone": "全員",
"fallback_for": "のフォールバック",
"fallback_missing": "フォールバックがありません",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} は質問 {questionIndex} のロジックで使用されています。まず、ロジックから削除してください。",
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "隠しフィールド \"{fieldId}\" は \"{quotaName}\" クォータ で使用されています",
@@ -1374,8 +1378,9 @@
"follow_ups_modal_action_subject_placeholder": "メールの件名",
"follow_ups_modal_action_to_description": "メールを送信するメールアドレス",
"follow_ups_modal_action_to_label": "宛先",
"follow_ups_modal_action_to_warning": "フォームでメールアドレスのフィールドが検出されていません",
"follow_ups_modal_action_to_warning": "メールを送信するための有効な オプション が見つかりません 、いくつかの オープン テキスト / 連絡先 情報の質問 または 非表示 フィールドを追加してください",
"follow_ups_modal_create_heading": "新しいフォローアップを作成",
"follow_ups_modal_created_successfull_toast": "フォローアップ が 作成され、 アンケートを 保存すると保存されます。",
"follow_ups_modal_edit_heading": "このフォローアップを編集",
"follow_ups_modal_edit_no_id": "フォームのフォローアップIDが提供されていません。フォームのフォローアップを更新できません",
"follow_ups_modal_name_label": "フォローアップ名",
@@ -1385,8 +1390,9 @@
"follow_ups_modal_trigger_label": "トリガー",
"follow_ups_modal_trigger_type_ending": "回答者が特定の終了画面を見たとき",
"follow_ups_modal_trigger_type_ending_select": "終了を選択:",
"follow_ups_modal_trigger_type_ending_warning": "フォームに終了画面が見つかりません!",
"follow_ups_modal_trigger_type_ending_warning": "少なくとも1つの終了を選択するか、 トリガー タイプを変更してください",
"follow_ups_modal_trigger_type_response": "回答者がフォームを完了したとき",
"follow_ups_modal_updated_successfull_toast": "フォローアップ が 更新され、 アンケートを 保存すると保存されます。",
"follow_ups_new": "新しいフォローアップ",
"follow_ups_upgrade_button_text": "フォローアップを有効にするためにアップグレード",
"form_styling": "フォームのスタイル",
@@ -1394,6 +1400,9 @@
"four_points": "4点",
"heading": "見出し",
"hidden_field_added_successfully": "非表示フィールドを正常に追加しました",
"hidden_field_used_in_recall": "隠し フィールド \"{hiddenField}\" が 質問 {questionIndex} で 呼び出され て います 。",
"hidden_field_used_in_recall_ending_card": "隠し フィールド \"{hiddenField}\" が エンディング カード で 呼び出され て います。",
"hidden_field_used_in_recall_welcome": "隠し フィールド \"{hiddenField}\" が ウェルカム カード で 呼び出され て います。",
"hide_advanced_settings": "詳細設定を非表示",
"hide_back_button": "「戻る」ボタンを非表示",
"hide_back_button_description": "フォームに「戻る」ボタンを表示しない",
@@ -1412,6 +1421,7 @@
"inner_text": "内部テキスト",
"input_border_color": "入力の枠線の色",
"input_color": "入力の色",
"insert_link": "リンク を 挿入",
"invalid_targeting": "無効なターゲティング: オーディエンスフィルターを確認してください",
"invalid_video_url_warning": "有効なYouTube、Vimeo、またはLoomのURLを入力してください。現在、他の動画ホスティングプロバイダーはサポートしていません。",
"invalid_youtube_url": "無効なYouTube URL",
@@ -1429,6 +1439,7 @@
"is_set": "設定されている",
"is_skipped": "スキップ済み",
"is_submitted": "送信済み",
"italic": "イタリック",
"jump_to_question": "質問にジャンプ",
"keep_current_order": "現在の順序を維持",
"keep_showing_while_conditions_match": "条件が一致する間、表示し続ける",
@@ -1455,6 +1466,7 @@
"no_images_found_for": "''{query}'' の画像が見つかりません",
"no_languages_found_add_first_one_to_get_started": "言語が見つかりません。始めるには、最初のものを追加してください。",
"no_option_found": "オプションが見つかりません",
"no_recall_items_found": "リコールアイテムが見つかりません ",
"no_variables_yet_add_first_one_below": "まだ変数がありません。以下で最初のものを追加してください。",
"number": "数値",
"once_set_the_default_language_for_this_survey_can_only_be_changed_by_disabling_the_multi_language_option_and_deleting_all_translations": "一度設定すると、このフォームのデフォルト言語は、多言語オプションを無効にしてすべての翻訳を削除することによってのみ変更できます。",
@@ -1474,6 +1486,7 @@
"pin_can_only_contain_numbers": "PINは数字のみでなければなりません。",
"pin_must_be_a_four_digit_number": "PINは4桁の数字でなければなりません。",
"please_enter_a_file_extension": "ファイル拡張子を入力してください。",
"please_enter_a_valid_url": "有効な URL を入力してください (例:https://example.com)",
"please_set_a_survey_trigger": "フォームのトリガーを設定してください",
"please_specify": "具体的に指定してください",
"prevent_double_submission": "二重送信を防ぐ",
@@ -1488,6 +1501,8 @@
"question_id_updated": "質問IDを更新しました",
"question_used_in_logic": "この質問は質問 {questionIndex} のロジックで使用されています。",
"question_used_in_quota": "この 質問 は \"{quotaName}\" の クオータ に使用されています",
"question_used_in_recall": "この 質問 は 質問 {questionIndex} で 呼び出され て います 。",
"question_used_in_recall_ending_card": "この 質問 は エンディング カード で 呼び出され て います。",
"quotas": {
"add_quota": "クォータを追加",
"change_quota_for_public_survey": "パブリック フォームのクォータを変更しますか?",
@@ -1522,6 +1537,8 @@
"randomize_all": "すべてをランダム化",
"randomize_all_except_last": "最後を除くすべてをランダム化",
"range": "範囲",
"recall_data": "データを呼び出す",
"recall_information_from": "... からの情報を呼び戻す",
"recontact_options": "再接触オプション",
"redirect_thank_you_card": "サンクスクカードをリダイレクト",
"redirect_to_url": "URLにリダイレクト",
@@ -1599,6 +1616,7 @@
"trigger_survey_when_one_of_the_actions_is_fired": "以下のアクションのいずれかが発火したときにフォームをトリガーします...",
"try_lollipop_or_mountain": "「lollipop」や「mountain」を試してみてください...",
"type_field_id": "フィールドIDを入力",
"underline": "下線",
"unlock_targeting_description": "属性またはデバイス情報に基づいて、特定のユーザーグループをターゲットにします",
"unlock_targeting_title": "上位プランでターゲティングをアンロック",
"unsaved_changes_warning": "フォームに未保存の変更があります。離れる前に保存しますか?",
@@ -1615,6 +1633,9 @@
"variable_is_used_in_quota_please_remove_it_from_quota_first": "変数 \"{variableName}\" は \"{quotaName}\" クォータ で使用されています",
"variable_name_is_already_taken_please_choose_another": "変数名はすでに使用されています。別の名前を選択してください。",
"variable_name_must_start_with_a_letter": "変数名はアルファベットで始まらなければなりません。",
"variable_used_in_recall": "変数 \"{variable}\" が 質問 {questionIndex} で 呼び出され て います 。",
"variable_used_in_recall_ending_card": "変数 {variable} が エンディング カード で 呼び出され て います。",
"variable_used_in_recall_welcome": "変数 \"{variable}\" が ウェルカム カード で 呼び出され て います。",
"verify_email_before_submission": "送信前にメールアドレスを認証",
"verify_email_before_submission_description": "有効なメールアドレスを持つ人のみが回答できるようにする",
"wait": "待つ",
+27 -6
View File
@@ -262,7 +262,9 @@
"membership_not_found": "Assinatura não encontrada",
"metadata": "metadados",
"minimum": "Mínimo",
"mobile_overlay_text": "O Formbricks não está disponível para dispositivos com resoluções menores.",
"mobile_overlay_app_works_best_on_desktop": "Formbricks funciona melhor em uma tela maior. Para gerenciar ou criar pesquisas, mude para outro dispositivo.",
"mobile_overlay_surveys_look_good": "Não se preocupe suas pesquisas ficam ótimas em qualquer dispositivo e tamanho de tela!",
"mobile_overlay_title": "Eita, tela pequena detectada!",
"move_down": "Descer",
"move_up": "Subir",
"multiple_languages": "Vários idiomas",
@@ -277,6 +279,7 @@
"no_result_found": "Nenhum resultado encontrado",
"no_results": "Nenhum resultado",
"no_surveys_found": "Não foram encontradas pesquisas.",
"none_of_the_above": "Nenhuma das opções acima",
"not_authenticated": "Você não está autenticado para realizar essa ação.",
"not_authorized": "Não autorizado",
"not_connected": "Desconectado",
@@ -750,7 +753,6 @@
},
"project": {
"api_keys": {
"access_control": "Controle de Acesso",
"add_api_key": "Adicionar Chave API",
"api_key": "Chave de API",
"api_key_copied_to_clipboard": "Chave da API copiada para a área de transferência",
@@ -1202,12 +1204,12 @@
"add_description": "Adicionar Descrição",
"add_ending": "Adicionar final",
"add_ending_below": "Adicione o final abaixo",
"add_fallback": "Adicionar",
"add_fallback_placeholder": "Adicionar um texto padrão para mostrar se a pergunta for ignorada:",
"add_hidden_field_id": "Adicionar campo oculto ID",
"add_highlight_border": "Adicionar borda de destaque",
"add_highlight_border_description": "Adicione uma borda externa ao seu cartão de pesquisa.",
"add_logic": "Adicionar lógica",
"add_none_of_the_above": "Adicionar \"Nenhuma das opções acima\"",
"add_option": "Adicionar opção",
"add_other": "Adicionar \"Outro",
"add_photo_or_video": "Adicionar foto ou video",
@@ -1240,6 +1242,7 @@
"automatically_mark_the_survey_as_complete_after": "Marcar automaticamente a pesquisa como concluída após",
"back_button_label": "Voltar",
"background_styling": "Estilo de Fundo",
"bold": "Negrito",
"brand_color": "Cor da marca",
"brightness": "brilho",
"button_label": "Rótulo do Botão",
@@ -1323,6 +1326,7 @@
"does_not_include_all_of": "Não inclui todos de",
"does_not_include_one_of": "Não inclui um de",
"does_not_start_with": "Não começa com",
"edit_link": "Editar link",
"edit_recall": "Editar Lembrete",
"edit_translations": "Editar traduções de {lang}",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permitir que os participantes mudem o idioma da pesquisa a qualquer momento durante a pesquisa.",
@@ -1333,13 +1337,13 @@
"ending_card_used_in_logic": "Esse cartão de encerramento é usado na lógica da pergunta {questionIndex}.",
"ending_used_in_quota": "Este final está sendo usado na cota \"{quotaName}\"",
"ends_with": "Termina com",
"enter_fallback_value": "Insira o valor de fallback",
"equals": "Igual",
"equals_one_of": "É igual a um de",
"error_publishing_survey": "Ocorreu um erro ao publicar a pesquisa.",
"error_saving_changes": "Erro ao salvar alterações",
"even_after_they_submitted_a_response_e_g_feedback_box": "Mesmo depois de eles enviarem uma resposta (por exemplo, Caixa de Feedback)",
"everyone": "Todo mundo",
"fallback_for": "Alternativa para",
"fallback_missing": "Faltando alternativa",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} é usado na lógica da pergunta {questionIndex}. Por favor, remova-o da lógica primeiro.",
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "Campo oculto \"{fieldId}\" está sendo usado na cota \"{quotaName}\"",
@@ -1374,8 +1378,9 @@
"follow_ups_modal_action_subject_placeholder": "Assunto do e-mail",
"follow_ups_modal_action_to_description": "Endereço de e-mail para enviar o e-mail para",
"follow_ups_modal_action_to_label": "Para",
"follow_ups_modal_action_to_warning": "Nenhum campo de e-mail detectado na pesquisa",
"follow_ups_modal_action_to_warning": "Nenhuma opção válida encontrada para envio de emails, por favor, adicione algumas perguntas de texto livre / informações de contato ou campos ocultos",
"follow_ups_modal_create_heading": "Criar um novo acompanhamento",
"follow_ups_modal_created_successfull_toast": "Acompanhamento criado e será salvo assim que você salvar a pesquisa.",
"follow_ups_modal_edit_heading": "Editar este acompanhamento",
"follow_ups_modal_edit_no_id": "Nenhum ID de acompanhamento da pesquisa fornecido, não é possível atualizar o acompanhamento da pesquisa",
"follow_ups_modal_name_label": "Nome do acompanhamento",
@@ -1385,8 +1390,9 @@
"follow_ups_modal_trigger_label": "Gatilho",
"follow_ups_modal_trigger_type_ending": "Respondente vê um final específico",
"follow_ups_modal_trigger_type_ending_select": "Selecione os finais: ",
"follow_ups_modal_trigger_type_ending_warning": "Nenhum final encontrado na pesquisa!",
"follow_ups_modal_trigger_type_ending_warning": "Por favor, selecione pelo menos um encerramento ou altere o tipo de gatilho",
"follow_ups_modal_trigger_type_response": "Respondente completa a pesquisa",
"follow_ups_modal_updated_successfull_toast": "Acompanhamento atualizado e será salvo assim que você salvar a pesquisa.",
"follow_ups_new": "Novo acompanhamento",
"follow_ups_upgrade_button_text": "Atualize para habilitar os Acompanhamentos",
"form_styling": "Estilização de Formulários",
@@ -1394,6 +1400,9 @@
"four_points": "4 pontos",
"heading": "Título",
"hidden_field_added_successfully": "Campo oculto adicionado com sucesso",
"hidden_field_used_in_recall": "Campo oculto \"{hiddenField}\" está sendo recordado na pergunta {questionIndex}.",
"hidden_field_used_in_recall_ending_card": "Campo oculto \"{hiddenField}\" está sendo recordado no card de Encerramento.",
"hidden_field_used_in_recall_welcome": "Campo oculto \"{hiddenField}\" está sendo recordado no card de Boas-Vindas.",
"hide_advanced_settings": "Ocultar configurações avançadas",
"hide_back_button": "Ocultar botão 'Voltar'",
"hide_back_button_description": "Não exibir o botão de voltar na pesquisa",
@@ -1412,6 +1421,7 @@
"inner_text": "Texto Interno",
"input_border_color": "Cor da borda de entrada",
"input_color": "Cor de entrada",
"insert_link": "Inserir link",
"invalid_targeting": "Segmentação inválida: Por favor, verifique os filtros do seu público",
"invalid_video_url_warning": "Por favor, insira uma URL válida do YouTube, Vimeo ou Loom. No momento, não suportamos outros provedores de vídeo.",
"invalid_youtube_url": "URL do YouTube inválida",
@@ -1429,6 +1439,7 @@
"is_set": "Está definido",
"is_skipped": "é pulado",
"is_submitted": "é submetido",
"italic": "Itálico",
"jump_to_question": "Pular para a pergunta",
"keep_current_order": "Manter pedido atual",
"keep_showing_while_conditions_match": "Continue mostrando enquanto as condições corresponderem",
@@ -1455,6 +1466,7 @@
"no_images_found_for": "Nenhuma imagem encontrada para ''{query}\"",
"no_languages_found_add_first_one_to_get_started": "Nenhum idioma encontrado. Adicione o primeiro para começar.",
"no_option_found": "Nenhuma opção encontrada",
"no_recall_items_found": "Nenhum item de recordação encontrado",
"no_variables_yet_add_first_one_below": "Ainda não há variáveis. Adicione a primeira abaixo.",
"number": "Número",
"once_set_the_default_language_for_this_survey_can_only_be_changed_by_disabling_the_multi_language_option_and_deleting_all_translations": "Depois de definido, o idioma padrão desta pesquisa só pode ser alterado desativando a opção de vários idiomas e excluindo todas as traduções.",
@@ -1474,6 +1486,7 @@
"pin_can_only_contain_numbers": "O PIN só pode conter números.",
"pin_must_be_a_four_digit_number": "O PIN deve ser um número de quatro dígitos.",
"please_enter_a_file_extension": "Por favor, insira uma extensão de arquivo.",
"please_enter_a_valid_url": "Por favor, insira uma URL válida (ex.: https://example.com)",
"please_set_a_survey_trigger": "Por favor, configure um gatilho para a pesquisa",
"please_specify": "Por favor, especifique",
"prevent_double_submission": "Evitar envio duplicado",
@@ -1488,6 +1501,8 @@
"question_id_updated": "ID da pergunta atualizado",
"question_used_in_logic": "Essa pergunta é usada na lógica da pergunta {questionIndex}.",
"question_used_in_quota": "Esta questão está sendo usada na cota \"{quotaName}\"",
"question_used_in_recall": "Esta pergunta está sendo recordada na pergunta {questionIndex}.",
"question_used_in_recall_ending_card": "Esta pergunta está sendo recordada no card de Encerramento",
"quotas": {
"add_quota": "Adicionar cota",
"change_quota_for_public_survey": "Alterar cota para pesquisa pública?",
@@ -1522,6 +1537,8 @@
"randomize_all": "Randomizar tudo",
"randomize_all_except_last": "Randomizar tudo, exceto o último",
"range": "alcance",
"recall_data": "Lembrar dados",
"recall_information_from": "Recuperar informações de ...",
"recontact_options": "Opções de Recontato",
"redirect_thank_you_card": "Redirecionar cartão de agradecimento",
"redirect_to_url": "Redirecionar para URL",
@@ -1599,6 +1616,7 @@
"trigger_survey_when_one_of_the_actions_is_fired": "Disparar pesquisa quando uma das ações for executada...",
"try_lollipop_or_mountain": "Tenta 'pirulito' ou 'montanha'...",
"type_field_id": "Digite o id do campo",
"underline": "Sublinhar",
"unlock_targeting_description": "Direcione grupos específicos de usuários com base em atributos ou informações do dispositivo",
"unlock_targeting_title": "Desbloqueie o direcionamento com um plano superior",
"unsaved_changes_warning": "Você tem alterações não salvas na sua pesquisa. Quer salvar antes de sair?",
@@ -1615,6 +1633,9 @@
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variável \"{variableName}\" está sendo usada na cota \"{quotaName}\"",
"variable_name_is_already_taken_please_choose_another": "O nome da variável já está em uso, por favor escolha outro.",
"variable_name_must_start_with_a_letter": "O nome da variável deve começar com uma letra.",
"variable_used_in_recall": "Variável \"{variable}\" está sendo recordada na pergunta {questionIndex}.",
"variable_used_in_recall_ending_card": "Variável {variable} está sendo recordada no card de Encerramento",
"variable_used_in_recall_welcome": "Variável \"{variable}\" está sendo recordada no Card de Boas-Vindas.",
"verify_email_before_submission": "Verifique o e-mail antes de enviar",
"verify_email_before_submission_description": "Deixe só quem tem um email real responder.",
"wait": "Espera",
+30 -9
View File
@@ -262,7 +262,9 @@
"membership_not_found": "Associação não encontrada",
"metadata": "Metadados",
"minimum": "Mínimo",
"mobile_overlay_text": "O Formbricks não está disponível para dispositivos com resoluções menores.",
"mobile_overlay_app_works_best_on_desktop": "Formbricks funciona melhor num ecrã maior. Para gerir ou criar inquéritos, mude de dispositivo.",
"mobile_overlay_surveys_look_good": "Não se preocupe os seus inquéritos têm uma ótima aparência em todos os dispositivos e tamanhos de ecrã!",
"mobile_overlay_title": "Oops, ecrã pequeno detectado!",
"move_down": "Mover para baixo",
"move_up": "Mover para cima",
"multiple_languages": "Várias línguas",
@@ -277,6 +279,7 @@
"no_result_found": "Nenhum resultado encontrado",
"no_results": "Nenhum resultado",
"no_surveys_found": "Nenhum inquérito encontrado.",
"none_of_the_above": "Nenhuma das opções acima",
"not_authenticated": "Não está autenticado para realizar esta ação.",
"not_authorized": "Não autorizado",
"not_connected": "Não Conectado",
@@ -750,7 +753,6 @@
},
"project": {
"api_keys": {
"access_control": "Controlo de Acesso",
"add_api_key": "Adicionar Chave API",
"api_key": "Chave API",
"api_key_copied_to_clipboard": "Chave API copiada para a área de transferência",
@@ -1202,12 +1204,12 @@
"add_description": "Adicionar descrição",
"add_ending": "Adicionar encerramento",
"add_ending_below": "Adicionar encerramento abaixo",
"add_fallback": "Adicionar",
"add_fallback_placeholder": "Adicionar um espaço reservado para mostrar se a pergunta for ignorada:",
"add_fallback_placeholder": "Adicionar um espaço reservado para mostrar se não houver valor para recordar.",
"add_hidden_field_id": "Adicionar ID do campo oculto",
"add_highlight_border": "Adicionar borda de destaque",
"add_highlight_border_description": "Adicione uma borda externa ao seu cartão de inquérito.",
"add_logic": "Adicionar lógica",
"add_none_of_the_above": "Adicionar \"Nenhuma das Opções Acima\"",
"add_option": "Adicionar opção",
"add_other": "Adicionar \"Outro\"",
"add_photo_or_video": "Adicionar foto ou vídeo",
@@ -1240,6 +1242,7 @@
"automatically_mark_the_survey_as_complete_after": "Marcar automaticamente o inquérito como concluído após",
"back_button_label": "Rótulo do botão \"Voltar\"",
"background_styling": "Estilo de Fundo",
"bold": "Negrito",
"brand_color": "Cor da marca",
"brightness": "Brilho",
"button_label": "Rótulo do botão",
@@ -1300,8 +1303,8 @@
"contains": "Contém",
"continue_to_settings": "Continuar para Definições",
"control_which_file_types_can_be_uploaded": "Controlar quais tipos de ficheiros podem ser carregados.",
"convert_to_multiple_choice": "Converter para Escolha Múltipla",
"convert_to_single_choice": "Converter para Escolha Única",
"convert_to_multiple_choice": "Converter para Seleção Múltipla",
"convert_to_single_choice": "Converter para Seleção Única",
"country": "País",
"create_group": "Criar grupo",
"create_your_own_survey": "Crie o seu próprio inquérito",
@@ -1323,6 +1326,7 @@
"does_not_include_all_of": "Não inclui todos de",
"does_not_include_one_of": "Não inclui um de",
"does_not_start_with": "Não começa com",
"edit_link": "Editar link",
"edit_recall": "Editar Lembrete",
"edit_translations": "Editar traduções {lang}",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permitir aos participantes mudar a língua do inquérito a qualquer momento durante o inquérito.",
@@ -1333,13 +1337,13 @@
"ending_card_used_in_logic": "Este cartão final é usado na lógica da pergunta {questionIndex}.",
"ending_used_in_quota": "Este final está a ser usado na quota \"{quotaName}\"",
"ends_with": "Termina com",
"enter_fallback_value": "Inserir valor de substituição",
"equals": "Igual",
"equals_one_of": "Igual a um de",
"error_publishing_survey": "Ocorreu um erro ao publicar o questionário.",
"error_saving_changes": "Erro ao guardar alterações",
"even_after_they_submitted_a_response_e_g_feedback_box": "Mesmo depois de terem enviado uma resposta (por exemplo, Caixa de Feedback)",
"everyone": "Todos",
"fallback_for": "Alternativa para ",
"fallback_missing": "Substituição em falta",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} é usado na lógica da pergunta {questionIndex}. Por favor, remova-o da lógica primeiro.",
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "Campo oculto \"{fieldId}\" está a ser usado na quota \"{quotaName}\"",
@@ -1374,8 +1378,9 @@
"follow_ups_modal_action_subject_placeholder": "Assunto do email",
"follow_ups_modal_action_to_description": "Endereço de email para enviar o email",
"follow_ups_modal_action_to_label": "Para",
"follow_ups_modal_action_to_warning": "Nenhum campo de email detetado no inquérito",
"follow_ups_modal_action_to_warning": "Não foram encontradas opções válidas para envio de emails, por favor adicione algumas perguntas de texto livre / informações de contato ou campos escondidos",
"follow_ups_modal_create_heading": "Criar um novo acompanhamento",
"follow_ups_modal_created_successfull_toast": "Seguimento criado e será guardado assim que guardar o questionário.",
"follow_ups_modal_edit_heading": "Editar este acompanhamento",
"follow_ups_modal_edit_no_id": "Nenhum ID de acompanhamento do inquérito fornecido, não é possível atualizar o acompanhamento do inquérito",
"follow_ups_modal_name_label": "Nome do acompanhamento",
@@ -1385,8 +1390,9 @@
"follow_ups_modal_trigger_label": "Desencadeador",
"follow_ups_modal_trigger_type_ending": "O respondente vê um final específico",
"follow_ups_modal_trigger_type_ending_select": "Selecionar finais: ",
"follow_ups_modal_trigger_type_ending_warning": "Não foram encontrados finais no inquérito!",
"follow_ups_modal_trigger_type_ending_warning": "Por favor, selecione pelo menos um final ou mude o tipo de gatilho",
"follow_ups_modal_trigger_type_response": "Respondente conclui inquérito",
"follow_ups_modal_updated_successfull_toast": "Seguimento atualizado e será guardado assim que guardar o questionário.",
"follow_ups_new": "Novo acompanhamento",
"follow_ups_upgrade_button_text": "Atualize para ativar os acompanhamentos",
"form_styling": "Estilo do formulário",
@@ -1394,6 +1400,9 @@
"four_points": "4 pontos",
"heading": "Cabeçalho",
"hidden_field_added_successfully": "Campo oculto adicionado com sucesso",
"hidden_field_used_in_recall": "Campo oculto \"{hiddenField}\" está a ser recordado na pergunta {questionIndex}.",
"hidden_field_used_in_recall_ending_card": "Campo oculto \"{hiddenField}\" está a ser recordado no Cartão de Conclusão",
"hidden_field_used_in_recall_welcome": "Campo oculto \"{hiddenField}\" está a ser recordado no cartão de boas-vindas.",
"hide_advanced_settings": "Ocultar definições avançadas",
"hide_back_button": "Ocultar botão 'Retroceder'",
"hide_back_button_description": "Não mostrar o botão de retroceder no inquérito",
@@ -1412,6 +1421,7 @@
"inner_text": "Texto Interno",
"input_border_color": "Cor da borda do campo de entrada",
"input_color": "Cor do campo de entrada",
"insert_link": "Inserir ligação",
"invalid_targeting": "Segmentação inválida: Por favor, verifique os seus filtros de audiência",
"invalid_video_url_warning": "Por favor, insira um URL válido do YouTube, Vimeo ou Loom. Atualmente, não suportamos outros fornecedores de hospedagem de vídeo.",
"invalid_youtube_url": "URL do YouTube inválido",
@@ -1429,6 +1439,7 @@
"is_set": "Está definido",
"is_skipped": "É ignorado",
"is_submitted": "Está submetido",
"italic": "Itálico",
"jump_to_question": "Saltar para a pergunta",
"keep_current_order": "Manter ordem atual",
"keep_showing_while_conditions_match": "Continuar a mostrar enquanto as condições corresponderem",
@@ -1455,6 +1466,7 @@
"no_images_found_for": "Não foram encontradas imagens para ''{query}\"",
"no_languages_found_add_first_one_to_get_started": "Nenhuma língua encontrada. Adicione a primeira para começar.",
"no_option_found": "Nenhuma opção encontrada",
"no_recall_items_found": "Nenhum item de recordação encontrado",
"no_variables_yet_add_first_one_below": "Ainda não há variáveis. Adicione a primeira abaixo.",
"number": "Número",
"once_set_the_default_language_for_this_survey_can_only_be_changed_by_disabling_the_multi_language_option_and_deleting_all_translations": "Depois de definido, o idioma padrão desta pesquisa só pode ser alterado desativando a opção de vários idiomas e eliminando todas as traduções.",
@@ -1474,6 +1486,7 @@
"pin_can_only_contain_numbers": "O PIN só pode conter números.",
"pin_must_be_a_four_digit_number": "O PIN deve ser um número de quatro dígitos.",
"please_enter_a_file_extension": "Por favor, insira uma extensão de ficheiro.",
"please_enter_a_valid_url": "Por favor, insira um URL válido (por exemplo, https://example.com)",
"please_set_a_survey_trigger": "Por favor, defina um desencadeador de inquérito",
"please_specify": "Por favor, especifique",
"prevent_double_submission": "Impedir submissão dupla",
@@ -1488,6 +1501,8 @@
"question_id_updated": "ID da pergunta atualizado",
"question_used_in_logic": "Esta pergunta é usada na lógica da pergunta {questionIndex}.",
"question_used_in_quota": "Esta pergunta está a ser usada na quota \"{quotaName}\"",
"question_used_in_recall": "Esta pergunta está a ser recordada na pergunta {questionIndex}.",
"question_used_in_recall_ending_card": "Esta pergunta está a ser recordada no Cartão de Conclusão",
"quotas": {
"add_quota": "Adicionar quota",
"change_quota_for_public_survey": "Alterar quota para inquérito público?",
@@ -1522,6 +1537,8 @@
"randomize_all": "Aleatorizar todos",
"randomize_all_except_last": "Aleatorizar todos exceto o último",
"range": "Intervalo",
"recall_data": "Recuperar dados",
"recall_information_from": "Recordar informação de ...",
"recontact_options": "Opções de Recontacto",
"redirect_thank_you_card": "Redirecionar cartão de agradecimento",
"redirect_to_url": "Redirecionar para Url",
@@ -1599,6 +1616,7 @@
"trigger_survey_when_one_of_the_actions_is_fired": "Desencadear inquérito quando uma das ações for disparada...",
"try_lollipop_or_mountain": "Experimente 'lollipop' ou 'mountain'...",
"type_field_id": "Escreva o id do campo",
"underline": "Sublinhar",
"unlock_targeting_description": "Alvo de grupos de utilizadores específicos com base em atributos ou informações do dispositivo",
"unlock_targeting_title": "Desbloqueie a segmentação com um plano superior",
"unsaved_changes_warning": "Tem alterações não guardadas no seu inquérito. Gostaria de as guardar antes de sair?",
@@ -1615,6 +1633,9 @@
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variável \"{variableName}\" está a ser utilizada na quota \"{quotaName}\"",
"variable_name_is_already_taken_please_choose_another": "O nome da variável já está em uso, por favor escolha outro.",
"variable_name_must_start_with_a_letter": "O nome da variável deve começar com uma letra.",
"variable_used_in_recall": "Variável \"{variable}\" está a ser recordada na pergunta {questionIndex}.",
"variable_used_in_recall_ending_card": "Variável {variable} está a ser recordada no Cartão de Conclusão",
"variable_used_in_recall_welcome": "Variável \"{variable}\" está a ser recordada no cartão de boas-vindas.",
"verify_email_before_submission": "Verificar email antes da submissão",
"verify_email_before_submission_description": "Permitir apenas que pessoas com um email real respondam.",
"wait": "Aguardar",
+30 -9
View File
@@ -262,7 +262,9 @@
"membership_not_found": "Apartenența nu a fost găsită",
"metadata": "Metadate",
"minimum": "Minim",
"mobile_overlay_text": "Formbricks nu este disponibil pentru dispozitive cu rezoluții mai mici.",
"mobile_overlay_app_works_best_on_desktop": "Formbricks funcționează cel mai bine pe un ecran mai mare. Pentru a gestiona sau crea chestionare, treceți la un alt dispozitiv.",
"mobile_overlay_surveys_look_good": "Nu vă faceți griji chestionarele dumneavoastră arată grozav pe orice dispozitiv și dimensiune a ecranului!",
"mobile_overlay_title": "Ups, ecran mic detectat!",
"move_down": "Mută în jos",
"move_up": "Mută sus",
"multiple_languages": "Mai multe limbi",
@@ -277,6 +279,7 @@
"no_result_found": "Niciun rezultat găsit",
"no_results": "Nicio rezultat",
"no_surveys_found": "Nu au fost găsite sondaje.",
"none_of_the_above": "Niciuna dintre cele de mai sus",
"not_authenticated": "Nu sunteți autentificat pentru a efectua această acțiune.",
"not_authorized": "Neautorizat",
"not_connected": "Neconectat",
@@ -750,7 +753,6 @@
},
"project": {
"api_keys": {
"access_control": "Control acces",
"add_api_key": "Adaugă Cheie API",
"api_key": "Cheie API",
"api_key_copied_to_clipboard": "Cheia API a fost copiată în clipboard",
@@ -1202,12 +1204,12 @@
"add_description": "Adăugați descriere",
"add_ending": "Adaugă finalizare",
"add_ending_below": "Adaugă finalizare mai jos",
"add_fallback": "Adaugă",
"add_fallback_placeholder": "Adaugă un substituent pentru a afișa dacă întrebarea este omisă:",
"add_fallback_placeholder": "Adaugă un placeholder pentru a afișa dacă nu există valoare de reamintit",
"add_hidden_field_id": "Adăugați ID câmp ascuns",
"add_highlight_border": "Adaugă bordură evidențiată",
"add_highlight_border_description": "Adaugă o margine exterioară cardului tău de sondaj.",
"add_logic": "Adaugă logică",
"add_none_of_the_above": "Adăugați \"Niciuna dintre cele de mai sus\"",
"add_option": "Adăugați opțiune",
"add_other": "Adăugați \"Altele\"",
"add_photo_or_video": "Adaugă fotografie sau video",
@@ -1240,6 +1242,7 @@
"automatically_mark_the_survey_as_complete_after": "Marcați automat sondajul ca finalizat după",
"back_button_label": "Etichetă buton \"Înapoi\"",
"background_styling": "Stilizare fundal",
"bold": "Îngroșat",
"brand_color": "Culoarea brandului",
"brightness": "Luminozitate",
"button_label": "Etichetă buton",
@@ -1300,8 +1303,8 @@
"contains": "Conține",
"continue_to_settings": "Continuă către Setări",
"control_which_file_types_can_be_uploaded": "Controlează ce tipuri de fișiere pot fi încărcate.",
"convert_to_multiple_choice": "Convertiți la alegere multiplă",
"convert_to_single_choice": "Convertiți la alegere unică",
"convert_to_multiple_choice": "Convertiți la selectare multiplă",
"convert_to_single_choice": "Convertiți la selectare unică",
"country": "Țară",
"create_group": "Creează grup",
"create_your_own_survey": "Creează-ți propriul chestionar",
@@ -1323,6 +1326,7 @@
"does_not_include_all_of": "Nu include toate",
"does_not_include_one_of": "Nu include una dintre",
"does_not_start_with": "Nu începe cu",
"edit_link": "Editare legătură",
"edit_recall": "Editează Referințele",
"edit_translations": "Editează traducerile {lang}",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permite participanților să schimbe limba sondajului în orice moment în timpul sondajului.",
@@ -1333,13 +1337,13 @@
"ending_card_used_in_logic": "Această carte de încheiere este folosită în logica întrebării {questionIndex}.",
"ending_used_in_quota": "Finalul acesta este folosit în cota \"{quotaName}\"",
"ends_with": "Se termină cu",
"enter_fallback_value": "Introduceți valoarea implicită",
"equals": "Egal",
"equals_one_of": "Egal unu dintre",
"error_publishing_survey": "A apărut o eroare în timpul publicării sondajului.",
"error_saving_changes": "Eroare la salvarea modificărilor",
"even_after_they_submitted_a_response_e_g_feedback_box": "Chiar și după ce au furnizat un răspuns (de ex. Cutia de Feedback)",
"everyone": "Toată lumea",
"fallback_for": "Varianta de rezervă pentru",
"fallback_missing": "Rezerva lipsă",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} este folosit în logică întrebării {questionIndex}. Vă rugăm să-l eliminați din logică mai întâi.",
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "Câmpul ascuns \"{fieldId}\" este folosit în cota \"{quotaName}\"",
@@ -1374,8 +1378,9 @@
"follow_ups_modal_action_subject_placeholder": "Subiectul emailului",
"follow_ups_modal_action_to_description": "Adresă de email către care se trimite emailul",
"follow_ups_modal_action_to_label": "Către",
"follow_ups_modal_action_to_warning": "Nu s-a detectat niciun câmp de e-mail în sondaj",
"follow_ups_modal_action_to_warning": "Nu s-au găsit opțiuni valide pentru trimiterea e-mailurilor, vă rugăm să adăugați întrebări de tip text deschis / informații de contact sau câmpuri ascunse",
"follow_ups_modal_create_heading": "Creați o nouă urmărire",
"follow_ups_modal_created_successfull_toast": "Urmărirea a fost creată și va fi salvată odată ce salvați sondajul.",
"follow_ups_modal_edit_heading": "Editează acest follow-up",
"follow_ups_modal_edit_no_id": "Nu a fost furnizat un ID de urmărire al chestionarului, nu pot actualiza urmărirea chestionarului",
"follow_ups_modal_name_label": "Numele ",
@@ -1385,8 +1390,9 @@
"follow_ups_modal_trigger_label": "Declanșator",
"follow_ups_modal_trigger_type_ending": "Respondentul vede un sfârșit specific",
"follow_ups_modal_trigger_type_ending_select": "Selectează finalurile:",
"follow_ups_modal_trigger_type_ending_warning": "Nu s-au găsit finalizări în sondaj!",
"follow_ups_modal_trigger_type_ending_warning": "Vă rugăm să selectați cel puțin un sfârșit sau să schimbați tipul declanșatorului",
"follow_ups_modal_trigger_type_response": "Respondent finalizează sondajul",
"follow_ups_modal_updated_successfull_toast": "Urmărirea a fost actualizată și va fi salvată odată ce salvați sondajul.",
"follow_ups_new": "Follow-up nou",
"follow_ups_upgrade_button_text": "Actualizați pentru a activa urmărările",
"form_styling": "Stilizare formular",
@@ -1394,6 +1400,9 @@
"four_points": "4 puncte",
"heading": "Titlu",
"hidden_field_added_successfully": "Câmp ascuns adăugat cu succes",
"hidden_field_used_in_recall": "Câmpul ascuns \"{hiddenField}\" este reamintit în întrebarea {questionIndex}.",
"hidden_field_used_in_recall_ending_card": "Câmpul ascuns \"{hiddenField}\" este reamintit în Cardul de Încheiere.",
"hidden_field_used_in_recall_welcome": "Câmpul ascuns \"{hiddenField}\" este reamintit în cardul de bun venit.",
"hide_advanced_settings": "Ascunde setări avansate",
"hide_back_button": "Ascunde butonul 'Înapoi'",
"hide_back_button_description": "Nu afișa butonul Înapoi în sondaj",
@@ -1412,6 +1421,7 @@
"inner_text": "Text Interior",
"input_border_color": "Culoarea graniței câmpului de introducere",
"input_color": "Culoarea câmpului de introducere",
"insert_link": "Inserează link",
"invalid_targeting": "\"Targetare nevalidă: Vă rugăm să verificați filtrele pentru audiență\"",
"invalid_video_url_warning": "Vă rugăm să introduceți un URL valid de YouTube, Vimeo sau Loom. În prezent nu susținem alți furnizori de găzduire video.",
"invalid_youtube_url": "URL YouTube invalid",
@@ -1429,6 +1439,7 @@
"is_set": "Este setat",
"is_skipped": "Este sărit",
"is_submitted": "Este trimis",
"italic": "Cursiv",
"jump_to_question": "Sări la întrebare",
"keep_current_order": "Păstrați ordinea actuală",
"keep_showing_while_conditions_match": "Continuă să afișezi cât timp condițiile se potrivesc",
@@ -1455,6 +1466,7 @@
"no_images_found_for": "Nicio imagine găsită pentru ''{query}\"",
"no_languages_found_add_first_one_to_get_started": "Nu s-au găsit limbi. Adaugă prima pentru a începe.",
"no_option_found": "Nicio opțiune găsită",
"no_recall_items_found": "Nu s-au găsit elemente de reamintire",
"no_variables_yet_add_first_one_below": "Nu există variabile încă. Adăugați prima mai jos.",
"number": "Număr",
"once_set_the_default_language_for_this_survey_can_only_be_changed_by_disabling_the_multi_language_option_and_deleting_all_translations": "Odată setată, limba implicită pentru acest sondaj poate fi schimbată doar dezactivând opțiunea multi-limbă și ștergând toate traducerile.",
@@ -1474,6 +1486,7 @@
"pin_can_only_contain_numbers": "PIN-ul poate conține doar numere.",
"pin_must_be_a_four_digit_number": "PIN-ul trebuie să fie un număr de patru cifre",
"please_enter_a_file_extension": "Vă rugăm să introduceți o extensie de fișier.",
"please_enter_a_valid_url": "Vă rugăm să introduceți un URL valid (de exemplu, https://example.com)",
"please_set_a_survey_trigger": "Vă rugăm să setați un declanșator sondaj",
"please_specify": "Vă rugăm să specificați",
"prevent_double_submission": "Prevenire trimitere dublă",
@@ -1488,6 +1501,8 @@
"question_id_updated": "ID întrebare actualizat",
"question_used_in_logic": "Această întrebare este folosită în logica întrebării {questionIndex}.",
"question_used_in_quota": "Întrebarea aceasta este folosită în cota \"{quotaName}\"",
"question_used_in_recall": "Această întrebare este reamintită în întrebarea {questionIndex}.",
"question_used_in_recall_ending_card": "Această întrebare este reamintită în Cardul de Încheiere.",
"quotas": {
"add_quota": "Adăugați cotă",
"change_quota_for_public_survey": "Schimbați cota pentru sondaj public?",
@@ -1522,6 +1537,8 @@
"randomize_all": "Randomizează tot",
"randomize_all_except_last": "Randomizează tot cu excepția ultimului",
"range": "Interval",
"recall_data": "Reamintiți datele",
"recall_information_from": "Reamintiți informațiile din ...",
"recontact_options": "Opțiuni de recontactare",
"redirect_thank_you_card": "Redirecționează cardul de mulțumire",
"redirect_to_url": "Redirecționează către URL",
@@ -1599,6 +1616,7 @@
"trigger_survey_when_one_of_the_actions_is_fired": "Declanșați sondajul atunci când una dintre acțiuni este realizată...",
"try_lollipop_or_mountain": "Încercați „lollipop” sau „mountain”...",
"type_field_id": "ID câmp tip",
"underline": "Subliniază",
"unlock_targeting_description": "Vizează grupuri specifice de utilizatori pe baza atributelor sau a informațiilor despre dispozitiv",
"unlock_targeting_title": "Deblocați țintirea cu un plan superior",
"unsaved_changes_warning": "Aveți modificări nesalvate în sondajul dumneavoastră. Doriți să le salvați înainte de a pleca?",
@@ -1615,6 +1633,9 @@
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variabila \"{variableName}\" este folosită în cota \"{quotaName}\"",
"variable_name_is_already_taken_please_choose_another": "Numele variabilei este deja utilizat, vă rugăm să alegeți altul.",
"variable_name_must_start_with_a_letter": "Numele variabilei trebuie să înceapă cu o literă.",
"variable_used_in_recall": "Variabila \"{variable}\" este reamintită în întrebarea {questionIndex}.",
"variable_used_in_recall_ending_card": "Variabila {variable} este reamintită în Cardul de Încheiere.",
"variable_used_in_recall_welcome": "Variabila \"{variable}\" este reamintită în cardul de bun venit.",
"verify_email_before_submission": "Verifică emailul înainte de trimitere",
"verify_email_before_submission_description": "Permite doar persoanelor cu un email real să răspundă.",
"wait": "Așteptați",
+30 -9
View File
@@ -262,7 +262,9 @@
"membership_not_found": "未找到会员资格",
"metadata": "元数据",
"minimum": "最低",
"mobile_overlay_text": "Formbricks 不 适用 于 分辨率 较小 的 设备",
"mobile_overlay_app_works_best_on_desktop": "Formbricks 在 更大 的 屏幕 上 效果 最佳。 若 需要 管理 或 构建 调查, 请 切换 到 其他 设备",
"mobile_overlay_surveys_look_good": "别 担心 – 您 的 调查 在 每 一 种 设备 和 屏幕 尺寸 上 看起来 都 很 棒!",
"mobile_overlay_title": "噢, 检测 到 小 屏幕!",
"move_down": "下移",
"move_up": "上移",
"multiple_languages": "多种 语言",
@@ -277,6 +279,7 @@
"no_result_found": "没有 结果",
"no_results": "没有 结果",
"no_surveys_found": "未找到 调查",
"none_of_the_above": "以上 都 不 是",
"not_authenticated": "您 未 认证 以 执行 该 操作。",
"not_authorized": "未授权",
"not_connected": "未连接",
@@ -750,7 +753,6 @@
},
"project": {
"api_keys": {
"access_control": "访问控制",
"add_api_key": "添加 API 密钥",
"api_key": "API Key",
"api_key_copied_to_clipboard": "API 密钥 已复制到 剪贴板",
@@ -1202,12 +1204,12 @@
"add_description": "添加 描述",
"add_ending": "添加结尾",
"add_ending_below": "在下方 添加 结尾",
"add_fallback": "添加",
"add_fallback_placeholder": "添加 一个 占位符,以显示该问题是否被跳过:",
"add_fallback_placeholder": "添加 占位符 显示 如果 没有 值以 回忆",
"add_hidden_field_id": "添加 隐藏 字段 ID",
"add_highlight_border": "添加 高亮 边框",
"add_highlight_border_description": "在 你的 调查 卡片 添加 外 边框。",
"add_logic": "添加逻辑",
"add_none_of_the_above": "添加 “以上 都 不 是”",
"add_option": "添加 选项",
"add_other": "添加 \"其他\"",
"add_photo_or_video": "添加 照片 或 视频",
@@ -1240,6 +1242,7 @@
"automatically_mark_the_survey_as_complete_after": "自动 标记 调查 为 完成 在",
"back_button_label": "\"返回\" 按钮标签",
"background_styling": "背景 样式",
"bold": "粗体",
"brand_color": "品牌 颜色",
"brightness": "亮度",
"button_label": "按钮标签",
@@ -1300,8 +1303,8 @@
"contains": "包含",
"continue_to_settings": "继续 到 设置",
"control_which_file_types_can_be_uploaded": "控制 可以 上传的 文件 类型",
"convert_to_multiple_choice": "转换为多选",
"convert_to_single_choice": "转换为单选",
"convert_to_multiple_choice": "转换为 多选",
"convert_to_single_choice": "转换为 单选",
"country": "国家",
"create_group": "创建 群组",
"create_your_own_survey": "创建 你 的 调查",
@@ -1323,6 +1326,7 @@
"does_not_include_all_of": "不包括所有 ",
"does_not_include_one_of": "不包括一 个",
"does_not_start_with": "不 以 开头",
"edit_link": "编辑 链接",
"edit_recall": "编辑 调用",
"edit_translations": "编辑 {lang} 翻译",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "启用 参与者 在 调查 过程中 的 任何 时间 点 切换 调查 语言。",
@@ -1333,13 +1337,13 @@
"ending_card_used_in_logic": "\"这个 结束卡片 在 问题 {questionIndex} 的 逻辑 中 使用。\"",
"ending_used_in_quota": "此 结尾 正在 被 \"{quotaName}\" 配额 使用",
"ends_with": "以...结束",
"enter_fallback_value": "输入 后备 值",
"equals": "等于",
"equals_one_of": "等于 其中 一个",
"error_publishing_survey": "发布调查时发生了错误",
"error_saving_changes": "保存 更改 时 出错",
"even_after_they_submitted_a_response_e_g_feedback_box": "即使 他们 提交 了 回复(例如 反馈框)",
"everyone": "所有 人",
"fallback_for": "后备 用于",
"fallback_missing": "备用 缺失",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "\"{fieldId} 在 问题 {questionIndex} 的 逻辑 中 使用。请 先 从 逻辑 中 删除 它。\"",
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "隐藏 字段 \"{fieldId}\" 正在 被 \"{quotaName}\" 配额 使用",
@@ -1374,8 +1378,9 @@
"follow_ups_modal_action_subject_placeholder": "电子邮件主题",
"follow_ups_modal_action_to_description": "发送邮件的电子邮箱地址",
"follow_ups_modal_action_to_label": "到",
"follow_ups_modal_action_to_warning": "调查中未 检测到 电子邮件 字段",
"follow_ups_modal_action_to_warning": "为 发送 邮件 找不到 有效 选项 ,请 增加 一些 开放文本 / 联系 信息 问题 或 隐藏 字段",
"follow_ups_modal_create_heading": "创建一个新的跟进",
"follow_ups_modal_created_successfull_toast": "后续 操作 已 创建, 并且 在 你 保存 调查 后 将 被 保存。",
"follow_ups_modal_edit_heading": "编辑此跟进",
"follow_ups_modal_edit_no_id": "未 提供 调查 跟进 id ,无法 更新 调查 跟进",
"follow_ups_modal_name_label": "跟进 名称",
@@ -1385,8 +1390,9 @@
"follow_ups_modal_trigger_label": "触发",
"follow_ups_modal_trigger_type_ending": "受访者 看到 一个 特定 的 结尾",
"follow_ups_modal_trigger_type_ending_select": "选择结尾:",
"follow_ups_modal_trigger_type_ending_warning": "在 调查 中 未找到 结尾 ",
"follow_ups_modal_trigger_type_ending_warning": "请选择至少 一个结束条件 或更改触发条件类型",
"follow_ups_modal_trigger_type_response": "受访者 完成 调查",
"follow_ups_modal_updated_successfull_toast": "后续 操作 已 更新, 并且 在 你 保存 调查 后 将 被 保存。",
"follow_ups_new": "新的跟进",
"follow_ups_upgrade_button_text": "升级 以启用 跟进",
"form_styling": "表单 样式",
@@ -1394,6 +1400,9 @@
"four_points": "4 分",
"heading": "标题",
"hidden_field_added_successfully": "隐藏字段 添加成功",
"hidden_field_used_in_recall": "隐藏 字段 \"{hiddenField}\" 正在召回于问题 {questionIndex}。",
"hidden_field_used_in_recall_ending_card": "隐藏 字段 \"{hiddenField}\" 正在召回于结束 卡",
"hidden_field_used_in_recall_welcome": "隐藏 字段 \"{hiddenField}\" 正在召回于欢迎 卡 。",
"hide_advanced_settings": "隐藏 高级设置",
"hide_back_button": "隐藏 \"返回\" 按钮",
"hide_back_button_description": "不 显示 调查 中 的 返回 按钮",
@@ -1412,6 +1421,7 @@
"inner_text": "内文",
"input_border_color": "输入 边框 颜色",
"input_color": "输入颜色",
"insert_link": "插入 链接",
"invalid_targeting": "无效的目标: 请检查 您 的受众过滤器",
"invalid_video_url_warning": "请输入有效的 YouTube、Vimeo 或 Loom URL 。我们目前不支持其他 视频 托管服务提供商。",
"invalid_youtube_url": "无效的 YouTube URL",
@@ -1429,6 +1439,7 @@
"is_set": "已设置",
"is_skipped": "已跳过",
"is_submitted": "已提交",
"italic": "斜体",
"jump_to_question": "跳 转 到 问题",
"keep_current_order": "保持 当前 顺序",
"keep_showing_while_conditions_match": "条件 符合 时 保持 显示",
@@ -1455,6 +1466,7 @@
"no_images_found_for": "未找到与 \"{query}\" 相关的图片",
"no_languages_found_add_first_one_to_get_started": "没有找到语言。添加第一个以开始。",
"no_option_found": "找不到选择",
"no_recall_items_found": "未 找到 召回 项目",
"no_variables_yet_add_first_one_below": "还没有变量。 在下面添加第一个。",
"number": "数字",
"once_set_the_default_language_for_this_survey_can_only_be_changed_by_disabling_the_multi_language_option_and_deleting_all_translations": "一旦设置,此调查的默认语言只能通过禁用多语言选项并删除所有翻译来更改。",
@@ -1474,6 +1486,7 @@
"pin_can_only_contain_numbers": "PIN 只能包含数字。",
"pin_must_be_a_four_digit_number": "PIN 必须是 四 位数字。",
"please_enter_a_file_extension": "请输入 文件 扩展名。",
"please_enter_a_valid_url": "请输入有效的 URL(例如, https://example.com ",
"please_set_a_survey_trigger": "请 设置 一个 调查 触发",
"please_specify": "请 指定",
"prevent_double_submission": "防止 重复 提交",
@@ -1488,6 +1501,8 @@
"question_id_updated": "问题 ID 更新",
"question_used_in_logic": "\"这个 问题 在 问题 {questionIndex} 的 逻辑 中 使用。\"",
"question_used_in_quota": "此 问题 正在 被 \"{quotaName}\" 配额 使用",
"question_used_in_recall": "此问题正在召回于问题 {questionIndex}。",
"question_used_in_recall_ending_card": "此 问题 正在召回于结束 卡片。",
"quotas": {
"add_quota": "添加 配额",
"change_quota_for_public_survey": "更改 公共调查 的配额?",
@@ -1522,6 +1537,8 @@
"randomize_all": "随机排列",
"randomize_all_except_last": "随机排列,最后一个除外",
"range": "范围",
"recall_data": "调用 数据",
"recall_information_from": "从 ... 召回信息",
"recontact_options": "重新 联系 选项",
"redirect_thank_you_card": "重定向感谢卡",
"redirect_to_url": "重定向到 URL",
@@ -1599,6 +1616,7 @@
"trigger_survey_when_one_of_the_actions_is_fired": "当 其中 一个 动作 被 触发 时 启动 调查…",
"try_lollipop_or_mountain": "尝试 'lollipop' 或 'mountain' ...",
"type_field_id": "类型 字段 ID",
"underline": "下划线",
"unlock_targeting_description": "根据 属性 或 设备信息 定位 特定 用户组",
"unlock_targeting_title": "通过 更 高级 划解锁 定位",
"unsaved_changes_warning": "您在调查中有未保存的更改。离开前是否要保存?",
@@ -1615,6 +1633,9 @@
"variable_is_used_in_quota_please_remove_it_from_quota_first": "变量 \"{variableName}\" 正在 被 \"{quotaName}\" 配额 使用",
"variable_name_is_already_taken_please_choose_another": "变量名已被占用,请选择其他。",
"variable_name_must_start_with_a_letter": "变量名 必须 以字母开头。",
"variable_used_in_recall": "变量 \"{variable}\" 正在召回于问题 {questionIndex}。",
"variable_used_in_recall_ending_card": "变量 {variable} 正在召回于结束 卡片",
"variable_used_in_recall_welcome": "变量 \"{variable}\" 正在召回于欢迎 卡 。",
"verify_email_before_submission": "提交 之前 验证电子邮件",
"verify_email_before_submission_description": "仅允许 拥有 有效 电子邮件 的 人 回应。",
"wait": "等待",
+28 -7
View File
@@ -262,7 +262,9 @@
"membership_not_found": "找不到成員資格",
"metadata": "元數據",
"minimum": "最小值",
"mobile_overlay_text": "Formbricks 不適用於較小解析度的裝置。",
"mobile_overlay_app_works_best_on_desktop": "Formbricks 適合在大螢幕上使用。若要管理或建立問卷,請切換到其他裝置。",
"mobile_overlay_surveys_look_good": "別擔心 -你的 問卷 在每個 裝置 和 螢幕尺寸 上 都 很出色!",
"mobile_overlay_title": "糟糕 ,偵測到小螢幕!",
"move_down": "下移",
"move_up": "上移",
"multiple_languages": "多種語言",
@@ -277,6 +279,7 @@
"no_result_found": "找不到結果",
"no_results": "沒有結果",
"no_surveys_found": "找不到問卷。",
"none_of_the_above": "以上皆非",
"not_authenticated": "您未經授權執行此操作。",
"not_authorized": "未授權",
"not_connected": "未連線",
@@ -750,7 +753,6 @@
},
"project": {
"api_keys": {
"access_control": "存取控制",
"add_api_key": "新增 API 金鑰",
"api_key": "API 金鑰",
"api_key_copied_to_clipboard": "API 金鑰已複製到剪貼簿",
@@ -1202,12 +1204,12 @@
"add_description": "新增描述",
"add_ending": "新增結尾",
"add_ending_below": "在下方新增結尾",
"add_fallback": "新增",
"add_fallback_placeholder": "新增用于顯示問題被跳過時的佔位符",
"add_fallback_placeholder": "新增 預設 以顯示是否沒 有 值 可 回憶 。",
"add_hidden_field_id": "新增隱藏欄位 ID",
"add_highlight_border": "新增醒目提示邊框",
"add_highlight_border_description": "在您的問卷卡片新增外邊框。",
"add_logic": "新增邏輯",
"add_none_of_the_above": "新增 \"以上皆非\"",
"add_option": "新增選項",
"add_other": "新增「其他」",
"add_photo_or_video": "新增照片或影片",
@@ -1240,6 +1242,7 @@
"automatically_mark_the_survey_as_complete_after": "在指定時間後自動將問卷標記為完成",
"back_button_label": "「返回」按鈕標籤",
"background_styling": "背景樣式設定",
"bold": "粗體",
"brand_color": "品牌顏色",
"brightness": "亮度",
"button_label": "按鈕標籤",
@@ -1323,6 +1326,7 @@
"does_not_include_all_of": "不包含全部",
"does_not_include_one_of": "不包含其中之一",
"does_not_start_with": "不以...開頭",
"edit_link": "編輯 連結",
"edit_recall": "編輯回憶",
"edit_translations": "編輯 '{'language'}' 翻譯",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "允許參與者在問卷中的任何時間點切換問卷語言。",
@@ -1333,13 +1337,13 @@
"ending_card_used_in_logic": "此結尾卡片用於問題 '{'questionIndex'}' 的邏輯中。",
"ending_used_in_quota": "此 結尾 正被使用於 \"{quotaName}\" 配額中",
"ends_with": "結尾為",
"enter_fallback_value": "輸入 預設 值",
"equals": "等於",
"equals_one_of": "等於其中之一",
"error_publishing_survey": "發布問卷時發生錯誤。",
"error_saving_changes": "儲存變更時發生錯誤",
"even_after_they_submitted_a_response_e_g_feedback_box": "即使他們提交回應之後(例如,意見反應方塊)",
"everyone": "所有人",
"fallback_for": "備用 用於 ",
"fallback_missing": "遺失的回退",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "'{'fieldId'}' 用於問題 '{'questionIndex'}' 的邏輯中。請先從邏輯中移除。",
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "隱藏欄位 \"{fieldId}\" 正被使用於 \"{quotaName}\" 配額中",
@@ -1374,8 +1378,9 @@
"follow_ups_modal_action_subject_placeholder": "電子郵件主旨",
"follow_ups_modal_action_to_description": "傳送電子郵件的電子郵件地址",
"follow_ups_modal_action_to_label": "收件者",
"follow_ups_modal_action_to_warning": "問卷中未偵測到電子郵件欄位",
"follow_ups_modal_action_to_warning": "未找到 發送電子郵件 有效選項,請添加 一些 開放文本 / 聯絡資訊 問題或隱藏欄位",
"follow_ups_modal_create_heading": "建立新的後續追蹤",
"follow_ups_modal_created_successfull_toast": "後續 動作 已 建立 並 將 在 你 儲存 調查 後 儲存",
"follow_ups_modal_edit_heading": "編輯此後續追蹤",
"follow_ups_modal_edit_no_id": "未提供問卷後續追蹤 ID,無法更新問卷後續追蹤",
"follow_ups_modal_name_label": "後續追蹤名稱",
@@ -1385,8 +1390,9 @@
"follow_ups_modal_trigger_label": "觸發器",
"follow_ups_modal_trigger_type_ending": "回應者看到特定結尾",
"follow_ups_modal_trigger_type_ending_select": "選取結尾:",
"follow_ups_modal_trigger_type_ending_warning": "問卷中找不到結尾!",
"follow_ups_modal_trigger_type_ending_warning": "請選擇至少一個結尾或更改觸發類型",
"follow_ups_modal_trigger_type_response": "回應者完成問卷",
"follow_ups_modal_updated_successfull_toast": "後續 動作 已 更新 並 將 在 你 儲存 調查 後 儲存",
"follow_ups_new": "新增後續追蹤",
"follow_ups_upgrade_button_text": "升級以啟用後續追蹤",
"form_styling": "表單樣式設定",
@@ -1394,6 +1400,9 @@
"four_points": "4 分",
"heading": "標題",
"hidden_field_added_successfully": "隱藏欄位已成功新增",
"hidden_field_used_in_recall": "隱藏欄位 \"{hiddenField}\" 於問題 {questionIndex} 中被召回。",
"hidden_field_used_in_recall_ending_card": "隱藏欄位 \"{hiddenField}\" 於結束卡中被召回。",
"hidden_field_used_in_recall_welcome": "隱藏欄位 \"{hiddenField}\" 於歡迎卡中被召回。",
"hide_advanced_settings": "隱藏進階設定",
"hide_back_button": "隱藏「Back」按鈕",
"hide_back_button_description": "不要在問卷中顯示返回按鈕",
@@ -1412,6 +1421,7 @@
"inner_text": "內部文字",
"input_border_color": "輸入邊框顏色",
"input_color": "輸入顏色",
"insert_link": "插入 連結",
"invalid_targeting": "目標設定無效:請檢查您的受眾篩選器",
"invalid_video_url_warning": "請輸入有效的 YouTube、Vimeo 或 Loom 網址。我們目前不支援其他影片託管提供者。",
"invalid_youtube_url": "無效的 YouTube 網址",
@@ -1429,6 +1439,7 @@
"is_set": "已設定",
"is_skipped": "已跳過",
"is_submitted": "已提交",
"italic": "斜體",
"jump_to_question": "跳至問題",
"keep_current_order": "保留目前順序",
"keep_showing_while_conditions_match": "在條件符合時持續顯示",
@@ -1455,6 +1466,7 @@
"no_images_found_for": "找不到「'{'query'}'」的圖片",
"no_languages_found_add_first_one_to_get_started": "找不到語言。新增第一個語言以開始使用。",
"no_option_found": "找不到選項",
"no_recall_items_found": "找不到 召回 項目",
"no_variables_yet_add_first_one_below": "尚無變數。在下方新增第一個變數。",
"number": "數字",
"once_set_the_default_language_for_this_survey_can_only_be_changed_by_disabling_the_multi_language_option_and_deleting_all_translations": "設定後,此問卷的預設語言只能藉由停用多語言選項並刪除所有翻譯來變更。",
@@ -1474,6 +1486,7 @@
"pin_can_only_contain_numbers": "PIN 碼只能包含數字。",
"pin_must_be_a_four_digit_number": "PIN 碼必須是四位數的數字。",
"please_enter_a_file_extension": "請輸入檔案副檔名。",
"please_enter_a_valid_url": "請輸入有效的 URL(例如:https://example.com",
"please_set_a_survey_trigger": "請設定問卷觸發器",
"please_specify": "請指定",
"prevent_double_submission": "防止重複提交",
@@ -1488,6 +1501,8 @@
"question_id_updated": "問題 ID 已更新",
"question_used_in_logic": "此問題用於問題 '{'questionIndex'}' 的邏輯中。",
"question_used_in_quota": "此問題 正被使用於 \"{quotaName}\" 配額中",
"question_used_in_recall": "此問題於問題 {questionIndex} 中被召回。",
"question_used_in_recall_ending_card": "此問題於結尾卡中被召回。",
"quotas": {
"add_quota": "新增額度",
"change_quota_for_public_survey": "更改 公開 問卷 的 額度?",
@@ -1522,6 +1537,8 @@
"randomize_all": "全部隨機排序",
"randomize_all_except_last": "全部隨機排序(最後一項除外)",
"range": "範圍",
"recall_data": "回憶數據",
"recall_information_from": "從 ... 獲取 信息",
"recontact_options": "重新聯絡選項",
"redirect_thank_you_card": "重新導向感謝卡片",
"redirect_to_url": "重新導向至網址",
@@ -1599,6 +1616,7 @@
"trigger_survey_when_one_of_the_actions_is_fired": "當觸發其中一個操作時,觸發問卷...",
"try_lollipop_or_mountain": "嘗試「棒棒糖」或「山峰」...",
"type_field_id": "輸入欄位 ID",
"underline": "下 劃 線",
"unlock_targeting_description": "根據屬性或裝置資訊鎖定特定使用者群組",
"unlock_targeting_title": "使用更高等級的方案解鎖目標設定",
"unsaved_changes_warning": "您的問卷中有未儲存的變更。您要先儲存它們再離開嗎?",
@@ -1615,6 +1633,9 @@
"variable_is_used_in_quota_please_remove_it_from_quota_first": "變數 \"{variableName}\" 正被使用於 \"{quotaName}\" 配額中",
"variable_name_is_already_taken_please_choose_another": "已使用此變數名稱,請選擇另一個名稱。",
"variable_name_must_start_with_a_letter": "變數名稱必須以字母開頭。",
"variable_used_in_recall": "變數 \"{variable}\" 於問題 {questionIndex} 中被召回。",
"variable_used_in_recall_ending_card": "變數 {variable} 於 結束 卡 中被召回。",
"variable_used_in_recall_welcome": "變數 \"{variable}\" 於 歡迎 Card 中被召回。",
"verify_email_before_submission": "提交前驗證電子郵件",
"verify_email_before_submission_description": "僅允許擁有真實電子郵件的人員回應。",
"wait": "等待",
@@ -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,12 +1,12 @@
"use client";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { parseRecallInfo } from "@/lib/utils/recall";
import { ResponseCardQuotas } from "@/modules/ee/quotas/components/single-response-card-quotas";
import { useTranslate } from "@tolgee/react";
import { CheckCircle2Icon } from "lucide-react";
import { TResponseWithQuotas } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { parseRecallInfo } from "@/lib/utils/recall";
import { ResponseCardQuotas } from "@/modules/ee/quotas/components/single-response-card-quotas";
import { isValidValue } from "../util";
import { HiddenFields } from "./HiddenFields";
import { QuestionSkip } from "./QuestionSkip";
@@ -118,7 +118,7 @@ export const SingleResponseCardBody = ({
{survey.variables.length > 0 && (
<ResponseVariables variables={survey.variables} variablesData={response.variables} />
)}
{survey.hiddenFields.enabled && survey.hiddenFields.fieldIds && (
{survey.hiddenFields.fieldIds && (
<HiddenFields hiddenFields={survey.hiddenFields} responseData={response.data} />
)}
@@ -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) ||
+3 -3
View File
@@ -1,9 +1,9 @@
import { ZodRawShape, z } from "zod";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { TApiAuditLog } from "@/app/lib/api/with-api-logging";
import { formatZodError, handleApiError } from "@/modules/api/v2/lib/utils";
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { ZodRawShape, z } from "zod";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { authenticateRequest } from "./authenticate-request";
export type HandlerFn<TInput = Record<string, unknown>> = ({
@@ -106,7 +106,7 @@ export const apiWrapper = async <S extends ExtendedSchemas>({
if (rateLimit) {
try {
await applyRateLimit(rateLimitConfigs.api.v2, authentication.data.hashedApiKey);
await applyRateLimit(rateLimitConfigs.api.v2, authentication.data.apiKeyId);
} catch (error) {
return handleApiError(request, { type: "too_many_requests", details: error.message });
}
@@ -1,8 +1,7 @@
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { Result, err, ok } from "@formbricks/types/error-handlers";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key";
export const authenticateRequest = async (
request: Request
@@ -14,8 +13,6 @@ export const authenticateRequest = async (
if (!apiKeyData) return err({ type: "unauthorized" });
const hashedApiKey = hashApiKey(apiKey);
const authentication: TAuthenticationApiKey = {
type: "apiKey",
environmentPermissions: apiKeyData.apiKeyEnvironments.map((env) => ({
@@ -25,7 +22,6 @@ export const authenticateRequest = async (
projectId: env.environment.projectId,
projectName: env.environment.project.name,
})),
hashedApiKey,
apiKeyId: apiKeyData.id,
organizationId: apiKeyData.organizationId,
organizationAccess: apiKeyData.organizationAccess,
@@ -1,11 +1,11 @@
import { describe, expect, test, vi } from "vitest";
import { z } from "zod";
import { err, ok } from "@formbricks/types/error-handlers";
import { apiWrapper } from "@/modules/api/v2/auth/api-wrapper";
import { authenticateRequest } from "@/modules/api/v2/auth/authenticate-request";
import { handleApiError } from "@/modules/api/v2/lib/utils";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { checkRateLimit } from "@/modules/core/rate-limit/rate-limit";
import { describe, expect, test, vi } from "vitest";
import { z } from "zod";
import { err, ok } from "@formbricks/types/error-handlers";
vi.mock("../authenticate-request", () => ({
authenticateRequest: vi.fn(),
@@ -39,8 +39,7 @@ const mockAuthentication = {
permission: "manage" as const,
},
],
hashedApiKey: "hashed-api-key",
apiKeyId: "api-key-id",
apiKeyId: "hashed-api-key",
organizationId: "org-id",
organizationAccess: {} as any,
} as any;
@@ -1,25 +1,17 @@
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
import { describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key";
import { TApiKeyWithEnvironmentAndProject } from "@/modules/organization/settings/api-keys/types/api-keys";
import { authenticateRequest } from "../authenticate-request";
vi.mock("@formbricks/database", () => ({
prisma: {
apiKey: {
findUnique: vi.fn(),
update: vi.fn(),
},
},
}));
vi.mock("@/modules/api/v2/management/lib/utils", () => ({
hashApiKey: vi.fn(),
// Mock the getApiKeyWithPermissions function
vi.mock("@/modules/organization/settings/api-keys/lib/api-key", () => ({
getApiKeyWithPermissions: vi.fn(),
}));
describe("authenticateRequest", () => {
test("should return authentication data if apiKey is valid", async () => {
test("should return authentication data if apiKey is valid with environment permissions", async () => {
const request = new Request("http://localhost", {
headers: { "x-api-key": "valid-api-key" },
headers: { "x-api-key": "fbk_validApiKeySecret123" },
});
const mockApiKeyData = {
@@ -29,34 +21,52 @@ describe("authenticateRequest", () => {
createdBy: "user-id",
lastUsedAt: null,
label: "Test API Key",
hashedKey: "hashed-api-key",
hashedKey: "hashed-key",
organizationAccess: {
accessControl: {
read: true,
write: false,
},
},
apiKeyEnvironments: [
{
environmentId: "env-id-1",
permission: "manage",
apiKeyId: "api-key-id",
environment: {
id: "env-id-1",
projectId: "project-id-1",
type: "development",
project: { name: "Project 1" },
createdAt: new Date(),
updatedAt: new Date(),
appSetupCompleted: false,
project: {
id: "project-id-1",
name: "Project 1",
},
},
},
{
environmentId: "env-id-2",
permission: "read",
apiKeyId: "api-key-id",
environment: {
id: "env-id-2",
projectId: "project-id-2",
type: "production",
project: { name: "Project 2" },
createdAt: new Date(),
updatedAt: new Date(),
appSetupCompleted: false,
project: {
id: "project-id-2",
name: "Project 2",
},
},
},
],
};
} as unknown as TApiKeyWithEnvironmentAndProject;
vi.mocked(hashApiKey).mockReturnValue("hashed-api-key");
vi.mocked(prisma.apiKey.findUnique).mockResolvedValue(mockApiKeyData);
vi.mocked(prisma.apiKey.update).mockResolvedValue(mockApiKeyData);
vi.mocked(getApiKeyWithPermissions).mockResolvedValue(mockApiKeyData);
const result = await authenticateRequest(request);
@@ -80,18 +90,70 @@ describe("authenticateRequest", () => {
projectName: "Project 2",
},
],
hashedApiKey: "hashed-api-key",
apiKeyId: "api-key-id",
organizationId: "org-id",
organizationAccess: {
accessControl: {
read: true,
write: false,
},
},
});
}
expect(getApiKeyWithPermissions).toHaveBeenCalledWith("fbk_validApiKeySecret123");
});
test("should return authentication data if apiKey is valid with organization-level access only", async () => {
const request = new Request("http://localhost", {
headers: { "x-api-key": "fbk_orgLevelApiKey456" },
});
const mockApiKeyData = {
id: "org-api-key-id",
organizationId: "org-id",
createdAt: new Date(),
createdBy: "user-id",
lastUsedAt: null,
label: "Organization Level API Key",
hashedKey: "hashed-key-org",
organizationAccess: {
accessControl: {
read: true,
write: true,
},
},
apiKeyEnvironments: [], // No environment-specific permissions
} as unknown as TApiKeyWithEnvironmentAndProject;
vi.mocked(getApiKeyWithPermissions).mockResolvedValue(mockApiKeyData);
const result = await authenticateRequest(request);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual({
type: "apiKey",
environmentPermissions: [],
apiKeyId: "org-api-key-id",
organizationId: "org-id",
organizationAccess: {
accessControl: {
read: true,
write: true,
},
},
});
}
expect(getApiKeyWithPermissions).toHaveBeenCalledWith("fbk_orgLevelApiKey456");
});
test("should return unauthorized error if apiKey is not found", async () => {
const request = new Request("http://localhost", {
headers: { "x-api-key": "invalid-api-key" },
headers: { "x-api-key": "fbk_invalidApiKeySecret" },
});
vi.mocked(prisma.apiKey.findUnique).mockResolvedValue(null);
vi.mocked(getApiKeyWithPermissions).mockResolvedValue(null);
const result = await authenticateRequest(request);
@@ -99,9 +161,11 @@ describe("authenticateRequest", () => {
if (!result.ok) {
expect(result.error).toEqual({ type: "unauthorized" });
}
expect(getApiKeyWithPermissions).toHaveBeenCalledWith("fbk_invalidApiKeySecret");
});
test("should return unauthorized error if apiKey is missing", async () => {
test("should return unauthorized error if apiKey is missing from headers", async () => {
const request = new Request("http://localhost");
const result = await authenticateRequest(request);
@@ -110,5 +174,24 @@ describe("authenticateRequest", () => {
if (!result.ok) {
expect(result.error).toEqual({ type: "unauthorized" });
}
// Should not call getApiKeyWithPermissions if header is missing
expect(getApiKeyWithPermissions).not.toHaveBeenCalled();
});
test("should return unauthorized error if apiKey header is empty string", async () => {
const request = new Request("http://localhost", {
headers: { "x-api-key": "" },
});
const result = await authenticateRequest(request);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({ type: "unauthorized" });
}
// Should not call getApiKeyWithPermissions for empty string
expect(getApiKeyWithPermissions).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,101 @@
import { getCacheService } from "@formbricks/cache";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { Result, err, ok } from "@formbricks/types/error-handlers";
import { type OverallHealthStatus } from "@/modules/api/v2/health/types/health-status";
import { type ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
/**
* Check if the main database is reachable and responding
* @returns Promise<Result<boolean, ApiErrorResponseV2>> - Result of the database health check
*/
export const checkDatabaseHealth = async (): Promise<Result<boolean, ApiErrorResponseV2>> => {
try {
// Simple query to check if database is reachable
await prisma.$queryRaw`SELECT 1`;
return ok(true);
} catch (error) {
logger
.withContext({
component: "health_check",
check_type: "main_database",
error,
})
.error("Database health check failed");
return err({
type: "internal_server_error",
details: [{ field: "main_database", issue: "Database health check failed" }],
});
}
};
/**
* Check if the Redis cache is reachable and responding
* @returns Promise<Result<boolean, ApiErrorResponseV2>> - Result of the cache health check
*/
export const checkCacheHealth = async (): Promise<Result<boolean, ApiErrorResponseV2>> => {
try {
const cacheServiceResult = await getCacheService();
if (!cacheServiceResult.ok) {
return err({
type: "internal_server_error",
details: [{ field: "cache_database", issue: "Cache service not available" }],
});
}
const isAvailable = await cacheServiceResult.data.isRedisAvailable();
if (isAvailable) {
return ok(true);
}
return err({
type: "internal_server_error",
details: [{ field: "cache_database", issue: "Redis not available" }],
});
} catch (error) {
logger
.withContext({
component: "health_check",
check_type: "cache_database",
error,
})
.error("Redis health check failed");
return err({
type: "internal_server_error",
details: [{ field: "cache_database", issue: "Redis health check failed" }],
});
}
};
/**
* Perform all health checks and return the overall status
* Always returns ok() with health status unless the health check endpoint itself fails
* @returns Promise<Result<OverallHealthStatus, ApiErrorResponseV2>> - Overall health status of all dependencies
*/
export const performHealthChecks = async (): Promise<Result<OverallHealthStatus, ApiErrorResponseV2>> => {
try {
const [databaseResult, cacheResult] = await Promise.all([checkDatabaseHealth(), checkCacheHealth()]);
const healthStatus: OverallHealthStatus = {
main_database: databaseResult.ok ? databaseResult.data : false,
cache_database: cacheResult.ok ? cacheResult.data : false,
};
// Always return ok() with the health status - individual dependency failures
// are reflected in the boolean values
return ok(healthStatus);
} catch (error) {
// Only return err() if the health check endpoint itself fails
logger
.withContext({
component: "health_check",
error,
})
.error("Health check endpoint failed");
return err({
type: "internal_server_error",
details: [{ field: "health", issue: "Failed to perform health checks" }],
});
}
};
@@ -0,0 +1,29 @@
import { ZOverallHealthStatus } from "@/modules/api/v2/health/types/health-status";
import { makePartialSchema } from "@/modules/api/v2/types/openapi-response";
import { ZodOpenApiOperationObject } from "zod-openapi";
export const healthCheckEndpoint: ZodOpenApiOperationObject = {
tags: ["Health"],
summary: "Health Check",
description: "Check the health status of critical application dependencies including database and cache.",
requestParams: {},
operationId: "healthCheck",
security: [],
responses: {
"200": {
description:
"Health check completed successfully. Check individual dependency status in response data.",
content: {
"application/json": {
schema: makePartialSchema(ZOverallHealthStatus),
},
},
},
},
};
export const healthPaths = {
"/health": {
get: healthCheckEndpoint,
},
};
@@ -0,0 +1,288 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { ErrorCode, getCacheService } from "@formbricks/cache";
import { prisma } from "@formbricks/database";
import { err, ok } from "@formbricks/types/error-handlers";
import { checkCacheHealth, checkDatabaseHealth, performHealthChecks } from "../health-checks";
// Mock dependencies
vi.mock("@formbricks/database", () => ({
prisma: {
$queryRaw: vi.fn(),
},
}));
vi.mock("@formbricks/cache", () => ({
getCacheService: vi.fn(),
ErrorCode: {
RedisConnectionError: "redis_connection_error",
},
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
info: vi.fn(),
withContext: vi.fn(() => ({
error: vi.fn(),
info: vi.fn(),
})),
},
}));
describe("Health Checks", () => {
beforeEach(() => {
vi.clearAllMocks();
});
// Helper function to create a mock CacheService
const createMockCacheService = (isRedisAvailable: boolean = true) => ({
getRedisClient: vi.fn(),
withTimeout: vi.fn(),
get: vi.fn(),
exists: vi.fn(),
set: vi.fn(),
del: vi.fn(),
keys: vi.fn(),
withCache: vi.fn(),
flush: vi.fn(),
tryGetCachedValue: vi.fn(),
trySetCache: vi.fn(),
isRedisAvailable: vi.fn().mockResolvedValue(isRedisAvailable),
});
describe("checkDatabaseHealth", () => {
test("should return healthy when database query succeeds", async () => {
vi.mocked(prisma.$queryRaw).mockResolvedValue([{ "?column?": 1 }]);
const result = await checkDatabaseHealth();
expect(result).toEqual({ ok: true, data: true });
expect(prisma.$queryRaw).toHaveBeenCalledWith(["SELECT 1"]);
});
test("should return unhealthy when database query fails", async () => {
const dbError = new Error("Database connection failed");
vi.mocked(prisma.$queryRaw).mockRejectedValue(dbError);
const result = await checkDatabaseHealth();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
expect(result.error.details).toEqual([
{ field: "main_database", issue: "Database health check failed" },
]);
}
});
test("should handle different types of database errors", async () => {
const networkError = new Error("ECONNREFUSED");
vi.mocked(prisma.$queryRaw).mockRejectedValue(networkError);
const result = await checkDatabaseHealth();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
expect(result.error.details).toEqual([
{ field: "main_database", issue: "Database health check failed" },
]);
}
});
});
describe("checkCacheHealth", () => {
test("should return healthy when Redis is available", async () => {
const mockCacheService = createMockCacheService(true);
vi.mocked(getCacheService).mockResolvedValue(ok(mockCacheService as any));
const result = await checkCacheHealth();
expect(result).toEqual({ ok: true, data: true });
expect(getCacheService).toHaveBeenCalled();
expect(mockCacheService.isRedisAvailable).toHaveBeenCalled();
});
test("should return unhealthy when cache service fails to initialize", async () => {
const cacheError = { code: ErrorCode.RedisConnectionError };
vi.mocked(getCacheService).mockResolvedValue(err(cacheError));
const result = await checkCacheHealth();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
expect(result.error.details).toEqual([
{ field: "cache_database", issue: "Cache service not available" },
]);
}
});
test("should return unhealthy when Redis is not available", async () => {
const mockCacheService = createMockCacheService(false);
vi.mocked(getCacheService).mockResolvedValue(ok(mockCacheService as any));
const result = await checkCacheHealth();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
expect(result.error.details).toEqual([{ field: "cache_database", issue: "Redis not available" }]);
}
expect(mockCacheService.isRedisAvailable).toHaveBeenCalled();
});
test("should handle Redis availability check exceptions", async () => {
const mockCacheService = createMockCacheService(true);
mockCacheService.isRedisAvailable.mockRejectedValue(new Error("Redis ping failed"));
vi.mocked(getCacheService).mockResolvedValue(ok(mockCacheService as any));
const result = await checkCacheHealth();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
expect(result.error.details).toEqual([
{ field: "cache_database", issue: "Redis health check failed" },
]);
}
});
test("should handle cache service initialization exceptions", async () => {
const serviceException = new Error("Cache service unavailable");
vi.mocked(getCacheService).mockRejectedValue(serviceException);
const result = await checkCacheHealth();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
expect(result.error.details).toEqual([
{ field: "cache_database", issue: "Redis health check failed" },
]);
}
});
test("should verify isRedisAvailable is called asynchronously", async () => {
const mockCacheService = createMockCacheService(true);
vi.mocked(getCacheService).mockResolvedValue(ok(mockCacheService as any));
await checkCacheHealth();
// Verify the async method was called
expect(mockCacheService.isRedisAvailable).toHaveBeenCalledTimes(1);
expect(mockCacheService.isRedisAvailable).toReturnWith(Promise.resolve(true));
});
});
describe("performHealthChecks", () => {
test("should return all healthy when both checks pass", async () => {
// Mock successful database check
vi.mocked(prisma.$queryRaw).mockResolvedValue([{ "?column?": 1 }]);
// Mock successful cache check
const mockCacheService = createMockCacheService(true);
vi.mocked(getCacheService).mockResolvedValue(ok(mockCacheService as any));
const result = await performHealthChecks();
expect(result).toEqual({
ok: true,
data: {
main_database: true,
cache_database: true,
},
});
});
test("should return mixed results when only database is healthy", async () => {
// Mock successful database check
vi.mocked(prisma.$queryRaw).mockResolvedValue([{ "?column?": 1 }]);
// Mock failed cache check
vi.mocked(getCacheService).mockResolvedValue(err({ code: ErrorCode.RedisConnectionError }));
const result = await performHealthChecks();
expect(result).toEqual({
ok: true,
data: {
main_database: true,
cache_database: false,
},
});
});
test("should return mixed results when only cache is healthy", async () => {
// Mock failed database check
vi.mocked(prisma.$queryRaw).mockRejectedValue(new Error("DB Error"));
// Mock successful cache check
const mockCacheService = createMockCacheService(true);
vi.mocked(getCacheService).mockResolvedValue(ok(mockCacheService as any));
const result = await performHealthChecks();
expect(result).toEqual({
ok: true,
data: {
main_database: false,
cache_database: true,
},
});
});
test("should return all unhealthy when both checks fail", async () => {
// Mock failed database check
vi.mocked(prisma.$queryRaw).mockRejectedValue(new Error("DB Error"));
// Mock failed cache check
vi.mocked(getCacheService).mockResolvedValue(err({ code: ErrorCode.RedisConnectionError }));
const result = await performHealthChecks();
expect(result).toEqual({
ok: true,
data: {
main_database: false,
cache_database: false,
},
});
});
test("should run both checks in parallel", async () => {
const dbPromise = new Promise((resolve) => setTimeout(() => resolve([{ "?column?": 1 }]), 100));
const redisPromise = new Promise((resolve) => setTimeout(() => resolve(true), 100));
vi.mocked(prisma.$queryRaw).mockReturnValue(dbPromise as any);
const mockCacheService = createMockCacheService(true);
mockCacheService.isRedisAvailable.mockReturnValue(redisPromise as any);
vi.mocked(getCacheService).mockResolvedValue(ok(mockCacheService as any));
const startTime = Date.now();
await performHealthChecks();
const endTime = Date.now();
// Should complete in roughly 100ms (parallel) rather than 200ms (sequential)
expect(endTime - startTime).toBeLessThan(150);
});
test("should return error only on catastrophic failure (endpoint itself fails)", async () => {
// Mock a catastrophic failure in Promise.all itself
const originalPromiseAll = Promise.all;
vi.spyOn(Promise, "all").mockRejectedValue(new Error("Catastrophic system failure"));
const result = await performHealthChecks();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
expect(result.error.details).toEqual([{ field: "health", issue: "Failed to perform health checks" }]);
}
// Restore original Promise.all
Promise.all = originalPromiseAll;
});
});
});
+15
View File
@@ -0,0 +1,15 @@
import { responses } from "@/modules/api/v2/lib/response";
import { performHealthChecks } from "./lib/health-checks";
export const GET = async () => {
const healthStatusResult = await performHealthChecks();
if (!healthStatusResult.ok) {
return responses.serviceUnavailableResponse({
details: healthStatusResult.error.details,
});
}
return responses.successResponse({
data: healthStatusResult.data,
});
};
@@ -0,0 +1,22 @@
import { z } from "zod";
import { extendZodWithOpenApi } from "zod-openapi";
extendZodWithOpenApi(z);
export const ZOverallHealthStatus = z
.object({
main_database: z.boolean().openapi({
description: "Main database connection status - true if database is reachable and running",
example: true,
}),
cache_database: z.boolean().openapi({
description: "Cache database connection status - true if cache database is reachable and running",
example: true,
}),
})
.openapi({
title: "Health Check Response",
description: "Health check status for critical application dependencies",
});
export type OverallHealthStatus = z.infer<typeof ZOverallHealthStatus>;
+30
View File
@@ -232,6 +232,35 @@ const internalServerErrorResponse = ({
);
};
const serviceUnavailableResponse = ({
details = [],
cors = false,
cache = "private, no-store",
}: {
details?: ApiErrorDetails;
cors?: boolean;
cache?: string;
} = {}) => {
const headers = {
...(cors && corsHeaders),
"Cache-Control": cache,
};
return Response.json(
{
error: {
code: 503,
message: "Service Unavailable",
details,
},
},
{
status: 503,
headers,
}
);
};
const successResponse = ({
data,
meta,
@@ -325,6 +354,7 @@ export const responses = {
unprocessableEntityResponse,
tooManyRequestsResponse,
internalServerErrorResponse,
serviceUnavailableResponse,
successResponse,
createdResponse,
multiStatusResponse,
@@ -1,8 +1,8 @@
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import * as Sentry from "@sentry/nextjs";
import { describe, expect, test, vi } from "vitest";
import { ZodError } from "zod";
import { logger } from "@formbricks/logger";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { formatZodError, handleApiError, logApiError, logApiRequest } from "../utils";
const mockRequest = new Request("http://localhost");
@@ -12,6 +12,15 @@ mockRequest.headers.set("x-request-id", "123");
vi.mock("@sentry/nextjs", () => ({
captureException: vi.fn(),
withScope: vi.fn((callback: (scope: any) => void) => {
const mockScope = {
setTag: vi.fn(),
setContext: vi.fn(),
setLevel: vi.fn(),
setExtra: vi.fn(),
};
callback(mockScope);
}),
}));
// Mock SENTRY_DSN constant
@@ -232,7 +241,7 @@ describe("utils", () => {
});
// Verify error was called on the child logger
expect(errorMock).toHaveBeenCalledWith("API Error Details");
expect(errorMock).toHaveBeenCalledWith("API V2 Error Details");
// Restore the original method
logger.withContext = originalWithContext;
@@ -266,7 +275,7 @@ describe("utils", () => {
});
// Verify error was called on the child logger
expect(errorMock).toHaveBeenCalledWith("API Error Details");
expect(errorMock).toHaveBeenCalledWith("API V2 Error Details");
// Restore the original method
logger.withContext = originalWithContext;
@@ -303,7 +312,7 @@ describe("utils", () => {
});
// Verify error was called on the child logger
expect(errorMock).toHaveBeenCalledWith("API Error Details");
expect(errorMock).toHaveBeenCalledWith("API V2 Error Details");
// Verify Sentry.captureException was called
expect(Sentry.captureException).toHaveBeenCalled();
+10 -10
View File
@@ -1,8 +1,8 @@
// Function is this file can be used in edge runtime functions, like api routes.
import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import * as Sentry from "@sentry/nextjs";
import { logger } from "@formbricks/logger";
import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
export const logApiErrorEdge = (request: Request, error: ApiErrorResponseV2): void => {
const correlationId = request.headers.get("x-request-id") ?? "";
@@ -10,14 +10,14 @@ export const logApiErrorEdge = (request: Request, error: ApiErrorResponseV2): vo
// Send the error to Sentry if the DSN is set and the error type is internal_server_error
// This is useful for tracking down issues without overloading Sentry with errors
if (SENTRY_DSN && IS_PRODUCTION && error.type === "internal_server_error") {
const err = new Error(`API V2 error, id: ${correlationId}`);
// Use Sentry scope to add correlation ID as a tag for easy filtering
Sentry.withScope((scope) => {
scope.setTag("correlationId", correlationId);
scope.setLevel("error");
Sentry.captureException(err, {
extra: {
details: error.details,
type: error.type,
correlationId,
},
scope.setExtra("originalError", error);
const err = new Error(`API V2 error, id: ${correlationId}`);
Sentry.captureException(err);
});
}
@@ -26,5 +26,5 @@ export const logApiErrorEdge = (request: Request, error: ApiErrorResponseV2): vo
correlationId,
error,
})
.error("API Error Details");
.error("API V2 Error Details");
};
+7 -6
View File
@@ -1,17 +1,18 @@
// @ts-nocheck // We can remove this when we update the prisma client and the typescript version
// if we don't add this we get build errors with prisma due to type-nesting
import { ZodCustomIssue, ZodIssue } from "zod";
import { logger } from "@formbricks/logger";
import { TApiAuditLog } from "@/app/lib/api/with-api-logging";
import { AUDIT_LOG_ENABLED } from "@/lib/constants";
import { responses } from "@/modules/api/v2/lib/response";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
import { ZodCustomIssue, ZodIssue } from "zod";
import { logger } from "@formbricks/logger";
import { logApiErrorEdge } from "./utils-edge";
export const handleApiError = (
request: Request,
err: ApiErrorResponseV2,
auditLog?: ApiAuditLog
auditLog?: TApiAuditLog
): Response => {
logApiError(request, err, auditLog);
@@ -55,7 +56,7 @@ export const formatZodError = (error: { issues: (ZodIssue | ZodCustomIssue)[] })
});
};
export const logApiRequest = (request: Request, responseStatus: number, auditLog?: ApiAuditLog): void => {
export const logApiRequest = (request: Request, responseStatus: number, auditLog?: TApiAuditLog): void => {
const method = request.method;
const url = new URL(request.url);
const path = url.pathname;
@@ -82,13 +83,13 @@ export const logApiRequest = (request: Request, responseStatus: number, auditLog
logAuditLog(request, auditLog);
};
export const logApiError = (request: Request, error: ApiErrorResponseV2, auditLog?: ApiAuditLog): void => {
export const logApiError = (request: Request, error: ApiErrorResponseV2, auditLog?: TApiAuditLog): void => {
logApiErrorEdge(request, error);
logAuditLog(request, auditLog);
};
const logAuditLog = (request: Request, auditLog?: ApiAuditLog): void => {
const logAuditLog = (request: Request, auditLog?: TApiAuditLog): void => {
if (AUDIT_LOG_ENABLED && auditLog) {
const correlationId = request.headers.get("x-request-id") ?? "";
queueAuditEvent({
@@ -1,22 +1,7 @@
import { TGetFilter } from "@/modules/api/v2/types/api-filter";
import { Prisma } from "@prisma/client";
import { describe, expect, test } from "vitest";
import { buildCommonFilterQuery, hashApiKey, pickCommonFilter } from "../utils";
describe("hashApiKey", () => {
test("generate the correct sha256 hash for a given input", () => {
const input = "test";
const expectedHash = "fake-hash"; // mocked on the vitestSetup.ts file;
const result = hashApiKey(input);
expect(result).toEqual(expectedHash);
});
test("return a string with length 64", () => {
const input = "another-api-key";
const result = hashApiKey(input);
expect(result).toHaveLength(9); // mocked on the vitestSetup.ts file;;
});
});
import { TGetFilter } from "@/modules/api/v2/types/api-filter";
import { buildCommonFilterQuery, pickCommonFilter } from "../utils";
describe("pickCommonFilter", () => {
test("picks the common filter fields correctly", () => {
@@ -53,8 +38,9 @@ describe("pickCommonFilter", () => {
endDate: new Date("2023-12-31"),
} as TGetFilter;
const result = buildCommonFilterQuery(query, params);
expect(result.where?.createdAt?.gte).toEqual(params.startDate);
expect(result.where?.createdAt?.lte).toEqual(params.endDate);
const createdAt = result.where?.createdAt as Prisma.DateTimeFilter | undefined;
expect(createdAt?.gte).toEqual(params.startDate);
expect(createdAt?.lte).toEqual(params.endDate);
});
test("applies sortBy and order when provided", () => {
@@ -1,8 +1,5 @@
import { TGetFilter } from "@/modules/api/v2/types/api-filter";
import { Prisma } from "@prisma/client";
import { createHash } from "crypto";
export const hashApiKey = (key: string): string => createHash("sha256").update(key).digest("hex");
import { TGetFilter } from "@/modules/api/v2/types/api-filter";
export function pickCommonFilter<T extends TGetFilter>(params: T) {
const { limit, skip, sortBy, order, startDate, endDate } = params;
@@ -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,
+26 -16
View File
@@ -1,6 +1,6 @@
import { logSignOut } from "@/modules/auth/lib/utils";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
import { logSignOut } from "@/modules/auth/lib/utils";
import { logSignOutAction } from "./sign-out";
// Mock the dependencies
@@ -80,6 +80,7 @@ describe("logSignOutAction", () => {
"email_change",
"session_timeout",
"forced_logout",
"password_reset",
] as const;
for (const reason of reasons) {
@@ -100,11 +101,14 @@ describe("logSignOutAction", () => {
await expect(() => logSignOutAction(mockUserId, mockUserEmail, mockContext)).rejects.toThrow(mockError);
expect(logger.error).toHaveBeenCalledWith("Failed to log sign out event", {
userId: mockUserId,
context: mockContext,
error: mockError.message,
});
expect(logger.error).toHaveBeenCalledWith(
{
userId: mockUserId,
context: mockContext,
error: mockError.message,
},
"Failed to log sign out event"
);
expect(logger.error).toHaveBeenCalledTimes(1);
});
@@ -116,11 +120,14 @@ describe("logSignOutAction", () => {
await expect(() => logSignOutAction(mockUserId, mockUserEmail, mockContext)).rejects.toThrow(mockError);
expect(logger.error).toHaveBeenCalledWith("Failed to log sign out event", {
userId: mockUserId,
context: mockContext,
error: mockError,
});
expect(logger.error).toHaveBeenCalledWith(
{
userId: mockUserId,
context: mockContext,
error: mockError,
},
"Failed to log sign out event"
);
expect(logger.error).toHaveBeenCalledTimes(1);
});
@@ -133,11 +140,14 @@ describe("logSignOutAction", () => {
await expect(() => logSignOutAction(mockUserId, mockUserEmail, emptyContext)).rejects.toThrow(mockError);
expect(logger.error).toHaveBeenCalledWith("Failed to log sign out event", {
userId: mockUserId,
context: emptyContext,
error: mockError.message,
});
expect(logger.error).toHaveBeenCalledWith(
{
userId: mockUserId,
context: emptyContext,
error: mockError.message,
},
"Failed to log sign out event"
);
expect(logger.error).toHaveBeenCalledTimes(1);
});
+9 -6
View File
@@ -1,7 +1,7 @@
"use server";
import { logSignOut } from "@/modules/auth/lib/utils";
import { logger } from "@formbricks/logger";
import { logSignOut } from "@/modules/auth/lib/utils";
/**
* Logs a sign out event
@@ -27,11 +27,14 @@ export const logSignOutAction = async (
try {
logSignOut(userId, userEmail, context);
} catch (error) {
logger.error("Failed to log sign out event", {
userId,
context,
error: error instanceof Error ? error.message : String(error),
});
logger.error(
{
userId,
context,
error: error instanceof Error ? error.message : String(error),
},
"Failed to log sign out event"
);
// Re-throw to ensure callers are aware of the failure
throw error;
}
+18 -77
View File
@@ -1,16 +1,24 @@
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 { EMAIL_VERIFICATION_DISABLED } from "@/lib/constants";
// Import mocked rate limiting functions
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { authOptions } from "./authOptions";
import { mockUser } from "./mock-data";
import { hashPassword } from "./utils";
// Mock encryption utilities
vi.mock("@/lib/encryption", () => ({
symmetricEncrypt: vi.fn((value: string) => `encrypted_${value}`),
symmetricDecrypt: vi.fn((value: string) => value.replace("encrypted_", "")),
}));
// Mock JWT
vi.mock("@/lib/jwt");
// Mock rate limiting dependencies
vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyIPRateLimit: vi.fn(),
@@ -31,7 +39,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,
@@ -39,6 +47,7 @@ vi.mock("@/lib/constants", () => ({
SENTRY_DSN: undefined,
BREVO_API_KEY: undefined,
RATE_LIMITING_DISABLED: false,
CONTROL_HASH: "$2b$12$fzHf9le13Ss9UJ04xzmsjODXpFJxz6vsnupoepF5FiqDECkX2BH5q",
}));
// Mock next/headers
@@ -257,55 +266,13 @@ describe("authOptions", () => {
);
});
test("should throw error if email is already verified", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
vi.spyOn(prisma.user, "findUnique").mockResolvedValue(mockUser as any);
const credentials = { token: createToken(mockUser.id, mockUser.email) };
await expect(tokenProvider.options.authorize(credentials, {})).rejects.toThrow(
"Email already verified"
);
});
test("should update user and verify email when token is valid", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({ id: mockUser.id, emailVerified: null } as any);
vi.spyOn(prisma.user, "update").mockResolvedValue({
...mockUser,
password: mockHashedPassword,
backupCodes: null,
twoFactorSecret: null,
identityProviderAccountId: null,
groupId: null,
} as any);
const credentials = { token: createToken(mockUserId, mockUser.email) };
const result = await tokenProvider.options.authorize(credentials, {});
expect(result.email).toBe(mockUser.email);
expect(result.emailVerified).toBeInstanceOf(Date);
});
describe("Rate Limiting", () => {
test("should apply rate limiting before token verification", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({
id: mockUser.id,
emailVerified: null,
} as any);
vi.spyOn(prisma.user, "update").mockResolvedValue({
...mockUser,
password: mockHashedPassword,
backupCodes: null,
twoFactorSecret: null,
identityProviderAccountId: null,
groupId: null,
} as any);
const credentials = { token: createToken(mockUserId, mockUser.email) };
const credentials = { token: "sometoken" };
await tokenProvider.options.authorize(credentials, {});
await expect(tokenProvider.options.authorize(credentials, {})).rejects.toThrow();
expect(applyIPRateLimit).toHaveBeenCalledWith(rateLimitConfigs.auth.verifyEmail);
});
@@ -315,7 +282,7 @@ describe("authOptions", () => {
new Error("Maximum number of requests reached. Please try again later.")
);
const credentials = { token: createToken(mockUserId, mockUser.email) };
const credentials = { token: "sometoken" };
await expect(tokenProvider.options.authorize(credentials, {})).rejects.toThrow(
"Maximum number of requests reached. Please try again later."
@@ -323,32 +290,6 @@ describe("authOptions", () => {
expect(prisma.user.findUnique).not.toHaveBeenCalled();
});
test("should use correct rate limit configuration", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({
id: mockUser.id,
emailVerified: null,
} as any);
vi.spyOn(prisma.user, "update").mockResolvedValue({
...mockUser,
password: mockHashedPassword,
backupCodes: null,
twoFactorSecret: null,
identityProviderAccountId: null,
groupId: null,
} as any);
const credentials = { token: createToken(mockUserId, mockUser.email) };
await tokenProvider.options.authorize(credentials, {});
expect(applyIPRateLimit).toHaveBeenCalledWith({
interval: 3600,
allowedPerInterval: 10,
namespace: "auth:verify",
});
});
});
});
+29 -8
View File
@@ -1,4 +1,11 @@
import type { Account, NextAuthOptions } from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import { cookies } from "next/headers";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { TUser } from "@formbricks/types/user";
import {
CONTROL_HASH,
EMAIL_VERIFICATION_DISABLED,
ENCRYPTION_KEY,
ENTERPRISE_LICENSE_KEY,
@@ -21,12 +28,6 @@ import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import { getSSOProviders } from "@/modules/ee/sso/lib/providers";
import { handleSsoCallback } from "@/modules/ee/sso/lib/sso-handlers";
import type { Account, NextAuthOptions } from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import { cookies } from "next/headers";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { TUser } from "@formbricks/types/user";
import { createBrevoCustomer } from "./brevo";
export const authOptions: NextAuthOptions = {
@@ -66,8 +67,24 @@ 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");
}
let user;
try {
// Perform database lookup
user = await prisma.user.findUnique({
where: {
email: credentials?.email,
@@ -79,6 +96,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 || CONTROL_HASH;
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 +119,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);
+46 -3
View File
@@ -1,7 +1,7 @@
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import * as Sentry from "@sentry/nextjs";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import {
createAuditIdentifier,
hashPassword,
@@ -40,19 +40,30 @@ vi.mock("@/lib/constants", () => ({
SENTRY_DSN: "test-sentry-dsn",
IS_PRODUCTION: true,
REDIS_URL: "redis://localhost:6379",
ENCRYPTION_KEY: "test-encryption-key",
}));
// Mock cache module
const { mockCache } = vi.hoisted(() => ({
const { mockCache, mockLogger } = vi.hoisted(() => ({
mockCache: {
getRedisClient: vi.fn(),
},
mockLogger: {
warn: vi.fn(),
error: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
},
}));
vi.mock("@/lib/cache", () => ({
cache: mockCache,
}));
vi.mock("@formbricks/logger", () => ({
logger: mockLogger,
}));
// Mock @formbricks/cache
vi.mock("@formbricks/cache", () => ({
createCacheKey: {
@@ -125,6 +136,38 @@ describe("Auth Utils", () => {
expect(await verifyPassword(complexPassword, hashedComplex)).toBe(true);
expect(await verifyPassword("wrong", hashedComplex)).toBe(false);
});
test("should handle bcrypt errors gracefully and log warning", async () => {
// Save the original bcryptjs implementation
const originalModule = await import("bcryptjs");
// Mock bcryptjs to throw an error on compare
vi.doMock("bcryptjs", () => ({
...originalModule,
compare: vi.fn().mockRejectedValue(new Error("Invalid salt version")),
hash: originalModule.hash, // Keep hash working
}));
// Re-import the utils module to use the mocked bcryptjs
const { verifyPassword: verifyPasswordMocked } = await import("./utils?t=" + Date.now());
const password = "testPassword";
const invalidHash = "invalid-hash-format";
const result = await verifyPasswordMocked(password, invalidHash);
// Should return false for security
expect(result).toBe(false);
// Should log warning with correct signature (Pino format: object first, then message)
expect(mockLogger.warn).toHaveBeenCalledWith(
{ error: expect.any(Error) },
"Secret verification failed due to invalid hash format"
);
// Restore the module
vi.doUnmock("bcryptjs");
});
});
describe("Audit Identifier Utils", () => {
+8 -17
View File
@@ -1,28 +1,19 @@
import { cache } from "@/lib/cache";
import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditAction, TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import * as Sentry from "@sentry/nextjs";
import { compare, hash } from "bcryptjs";
import { createHash, randomUUID } from "crypto";
import { createCacheKey } from "@formbricks/cache";
import { logger } from "@formbricks/logger";
import { cache } from "@/lib/cache";
import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
import { hashSecret, verifySecret } from "@/lib/crypto";
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditAction, TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
export const hashPassword = async (password: string) => {
const hashedPassword = await hash(password, 12);
return hashedPassword;
return await hashSecret(password, 12);
};
export const verifyPassword = async (password: string, hashedPassword: string) => {
try {
const isValid = await compare(password, hashedPassword);
return isValid;
} catch (error) {
// Log warning for debugging purposes, but don't throw to maintain security
logger.warn("Password verification failed due to invalid hash format", { error });
// Return false for invalid hashes or other bcrypt errors
return false;
}
return await verifySecret(password, hashedPassword);
};
/**
@@ -279,7 +270,7 @@ export const shouldLogAuthFailure = async (
return currentCount % 10 === 0 || timeSinceLastLog > 60000;
} catch (error) {
logger.warn("Redis rate limiting failed, not logging due to Redis requirement", { error });
logger.warn({ error }, "Redis rate limiting failed, not logging due to Redis requirement");
// If Redis fails, do not log as Redis is required for audit logs
return false;
}
@@ -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,9 +1,9 @@
import { hashString } from "@/lib/hash-string";
// Import modules after mocking
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
import { err, ok } from "@formbricks/types/error-handlers";
import { hashString } from "@/lib/hash-string";
// Import modules after mocking
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
import { applyIPRateLimit, applyRateLimit, getClientIdentifier } from "./helpers";
import { checkRateLimit } from "./rate-limit";
@@ -67,8 +67,8 @@ describe("helpers", () => {
await expect(getClientIdentifier()).rejects.toThrow("Failed to hash IP");
// Verify that the error was logged with proper context
expect(logger.error).toHaveBeenCalledWith("Failed to hash IP", { error: originalError });
// Verify that the error was logged with proper context (pino 10 format: object first, message second)
expect(logger.error).toHaveBeenCalledWith({ error: originalError }, "Failed to hash IP");
});
});
+3 -3
View File
@@ -1,7 +1,7 @@
import { hashString } from "@/lib/hash-string";
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
import { logger } from "@formbricks/logger";
import { TooManyRequestsError } from "@formbricks/types/errors";
import { hashString } from "@/lib/hash-string";
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
import { checkRateLimit } from "./rate-limit";
import { type TRateLimitConfig } from "./types/rate-limit";
@@ -19,7 +19,7 @@ export const getClientIdentifier = async (): Promise<string> => {
return hashString(ip);
} catch (error) {
const errorMessage = "Failed to hash IP";
logger.error(errorMessage, { error });
logger.error({ error }, errorMessage);
throw new Error(errorMessage);
}
};

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