mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-18 15:20:10 -05:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 87867cb2f2 | |||
| ad03196ede |
@@ -1,232 +0,0 @@
|
||||
---
|
||||
description: Security best practices and guidelines for writing GitHub Actions and workflows
|
||||
globs: .github/workflows/*.yml,.github/workflows/*.yaml,.github/actions/*/action.yml,.github/actions/*/action.yaml
|
||||
---
|
||||
|
||||
# GitHub Actions Security Best Practices
|
||||
|
||||
## Required Security Measures
|
||||
|
||||
### 1. Set Minimum GITHUB_TOKEN Permissions
|
||||
|
||||
Always explicitly set the minimum required permissions for GITHUB_TOKEN:
|
||||
|
||||
```yaml
|
||||
permissions:
|
||||
contents: read
|
||||
# Only add additional permissions if absolutely necessary:
|
||||
# pull-requests: write # for commenting on PRs
|
||||
# issues: write # for creating/updating issues
|
||||
# checks: write # for publishing check results
|
||||
```
|
||||
|
||||
### 2. Add Harden-Runner as First Step
|
||||
|
||||
For **every job** on `ubuntu-latest`, add Harden-Runner as the first step:
|
||||
|
||||
```yaml
|
||||
- name: Harden the runner
|
||||
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
||||
with:
|
||||
egress-policy: audit # or 'block' for stricter security
|
||||
```
|
||||
|
||||
### 3. Pin Actions to Full Commit SHA
|
||||
|
||||
**Always** pin third-party actions to their full commit SHA, not tags:
|
||||
|
||||
```yaml
|
||||
# ❌ BAD - uses mutable tag
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# ✅ GOOD - pinned to immutable commit SHA
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
```
|
||||
|
||||
### 4. Secure Variable Handling
|
||||
|
||||
Prevent command injection by properly quoting variables:
|
||||
|
||||
```yaml
|
||||
# ❌ BAD - potential command injection
|
||||
run: echo "Processing ${{ inputs.user_input }}"
|
||||
|
||||
# ✅ GOOD - properly quoted
|
||||
env:
|
||||
USER_INPUT: ${{ inputs.user_input }}
|
||||
run: echo "Processing ${USER_INPUT}"
|
||||
```
|
||||
|
||||
Use `${VARIABLE}` syntax in shell scripts instead of `$VARIABLE`.
|
||||
|
||||
### 5. Environment Variables for Secrets
|
||||
|
||||
Store sensitive data in environment variables, not inline:
|
||||
|
||||
```yaml
|
||||
# ❌ BAD
|
||||
run: curl -H "Authorization: Bearer ${{ secrets.TOKEN }}" api.example.com
|
||||
|
||||
# ✅ GOOD
|
||||
env:
|
||||
API_TOKEN: ${{ secrets.TOKEN }}
|
||||
run: curl -H "Authorization: Bearer ${API_TOKEN}" api.example.com
|
||||
```
|
||||
|
||||
## Workflow Structure Best Practices
|
||||
|
||||
### Required Workflow Elements
|
||||
|
||||
```yaml
|
||||
name: "Descriptive Workflow Name"
|
||||
|
||||
on:
|
||||
# Define specific triggers
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
# Always set explicit permissions
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
job-name:
|
||||
name: "Descriptive Job Name"
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30 # tune per job; standardize repo-wide
|
||||
|
||||
# Set job-level permissions if different from workflow level
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
# Always start with Harden-Runner on ubuntu-latest
|
||||
- name: Harden the runner
|
||||
uses: step-security/harden-runner@v2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
# Pin all actions to commit SHA
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
```
|
||||
|
||||
### Input Validation for Actions
|
||||
|
||||
For composite actions, always validate inputs:
|
||||
|
||||
```yaml
|
||||
inputs:
|
||||
user_input:
|
||||
description: "User provided input"
|
||||
required: true
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Validate input
|
||||
shell: bash
|
||||
run: |
|
||||
# Harden shell and validate input format/content before use
|
||||
set -euo pipefail
|
||||
|
||||
USER_INPUT="${{ inputs.user_input }}"
|
||||
|
||||
if [[ ! "${USER_INPUT}" =~ ^[A-Za-z0-9._-]+$ ]]; then
|
||||
echo "❌ Invalid input format"
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
## Docker Security in Actions
|
||||
|
||||
### Pin Docker Images to Digests
|
||||
|
||||
```yaml
|
||||
# ❌ BAD - mutable tag
|
||||
container: node:18
|
||||
|
||||
# ✅ GOOD - pinned to digest
|
||||
container: node:18@sha256:a1ba21bf0c92931d02a8416f0a54daad66cb36a85d6a37b82dfe1604c4c09cad
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Secure File Operations
|
||||
|
||||
```yaml
|
||||
- name: Process files securely
|
||||
shell: bash
|
||||
env:
|
||||
FILE_PATH: ${{ inputs.file_path }}
|
||||
run: |
|
||||
set -euo pipefail # Fail on errors, undefined vars, pipe failures
|
||||
|
||||
# Use absolute paths and validate
|
||||
SAFE_PATH=$(realpath "${FILE_PATH}")
|
||||
if [[ "$SAFE_PATH" != "${GITHUB_WORKSPACE}"/* ]]; then
|
||||
echo "❌ Path outside workspace"
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
### Artifact Handling
|
||||
|
||||
```yaml
|
||||
- name: Upload artifacts securely
|
||||
uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.0
|
||||
with:
|
||||
name: build-artifacts
|
||||
path: |
|
||||
dist/
|
||||
!dist/**/*.log # Exclude sensitive files
|
||||
retention-days: 30
|
||||
```
|
||||
|
||||
### GHCR authentication for pulls/scans
|
||||
|
||||
```yaml
|
||||
# Minimal permissions required for GHCR pulls/scans
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
|
||||
steps:
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
```
|
||||
|
||||
## Security Checklist
|
||||
|
||||
- [ ] Minimum GITHUB_TOKEN permissions set
|
||||
- [ ] Harden-Runner added to all ubuntu-latest jobs
|
||||
- [ ] All third-party actions pinned to commit SHA
|
||||
- [ ] Input validation implemented for custom actions
|
||||
- [ ] Variables properly quoted in shell scripts
|
||||
- [ ] Secrets stored in environment variables
|
||||
- [ ] Docker images pinned to digests (if used)
|
||||
- [ ] Error handling with `set -euo pipefail`
|
||||
- [ ] File paths validated and sanitized
|
||||
- [ ] No sensitive data in logs or outputs
|
||||
- [ ] GHCR login performed before pulls/scans (packages: read)
|
||||
- [ ] Job timeouts configured (`timeout-minutes`)
|
||||
|
||||
## Recommended Additional Workflows
|
||||
|
||||
Consider adding these security-focused workflows to your repository:
|
||||
|
||||
1. **CodeQL Analysis** - Static Application Security Testing (SAST)
|
||||
2. **Dependency Review** - Scan for vulnerable dependencies in PRs
|
||||
3. **Dependabot Configuration** - Automated dependency updates
|
||||
|
||||
## Resources
|
||||
|
||||
- [GitHub Security Hardening Guide](https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions)
|
||||
- [Step Security Harden-Runner](https://github.com/step-security/harden-runner)
|
||||
- [Secure-Repo Best Practices](https://github.com/step-security/secure-repo)
|
||||
@@ -24,6 +24,36 @@ runs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Validate Sentry auth token
|
||||
shell: bash
|
||||
env:
|
||||
SENTRY_TOKEN: ${{ inputs.sentry_auth_token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "🔐 Validating Sentry authentication token..."
|
||||
|
||||
# Test the token by making a simple API call to Sentry
|
||||
response=$(curl -s -w "%{http_code}" -o /tmp/sentry_response.json \
|
||||
-H "Authorization: Bearer $SENTRY_TOKEN" \
|
||||
"https://sentry.io/api/0/organizations/formbricks/")
|
||||
|
||||
http_code=$(echo "$response" | tail -n1)
|
||||
|
||||
if [ "$http_code" != "200" ]; then
|
||||
echo "❌ Error: Invalid Sentry auth token (HTTP $http_code)"
|
||||
echo "Please check your SENTRY_AUTH_TOKEN is correct and has the necessary permissions."
|
||||
if [ -f /tmp/sentry_response.json ]; then
|
||||
echo "Response body:"
|
||||
cat /tmp/sentry_response.json
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Sentry auth token validated successfully"
|
||||
|
||||
# Clean up temp file
|
||||
rm -f /tmp/sentry_response.json
|
||||
|
||||
- name: Extract sourcemaps from Docker image
|
||||
shell: bash
|
||||
env:
|
||||
@@ -34,16 +64,16 @@ runs:
|
||||
# Validate docker image format (basic validation)
|
||||
if [[ ! "$DOCKER_IMAGE" =~ ^[a-zA-Z0-9._/-]+:[a-zA-Z0-9._-]+$ ]] && [[ ! "$DOCKER_IMAGE" =~ ^[a-zA-Z0-9._/-]+@sha256:[A-Fa-f0-9]{64}$ ]]; then
|
||||
echo "❌ Error: Invalid docker image format. Must be in format 'image:tag' or 'image@sha256:hash'"
|
||||
echo "Provided: ${DOCKER_IMAGE}"
|
||||
echo "Provided: $DOCKER_IMAGE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "📦 Extracting sourcemaps from Docker image: ${DOCKER_IMAGE}"
|
||||
echo "📦 Extracting sourcemaps from Docker image: $DOCKER_IMAGE"
|
||||
|
||||
# Create temporary container from the image and capture its ID
|
||||
echo "Creating temporary container..."
|
||||
CONTAINER_ID=$(docker create "$DOCKER_IMAGE")
|
||||
echo "Container created with ID: ${CONTAINER_ID}"
|
||||
echo "Container created with ID: $CONTAINER_ID"
|
||||
|
||||
# Set up cleanup function to ensure container is removed on script exit
|
||||
cleanup_container() {
|
||||
@@ -55,7 +85,7 @@ runs:
|
||||
# Remove the container if it exists (ignore errors if already removed)
|
||||
if [ -n "$CONTAINER_ID" ]; then
|
||||
docker rm -f "$CONTAINER_ID" 2>/dev/null || true
|
||||
echo "Container ${CONTAINER_ID} removed"
|
||||
echo "Container $CONTAINER_ID removed"
|
||||
fi
|
||||
|
||||
# Exit with the original exit code to preserve script success/failure status
|
||||
@@ -76,7 +106,7 @@ runs:
|
||||
fi
|
||||
|
||||
sourcemap_count=$(find ./extracted-next/static/chunks -name "*.map" | wc -l)
|
||||
echo "✅ Found ${sourcemap_count} sourcemap files"
|
||||
echo "✅ Found $sourcemap_count sourcemap files"
|
||||
|
||||
if [ "$sourcemap_count" -eq 0 ]; then
|
||||
echo "❌ Error: No sourcemap files found. Check that productionBrowserSourceMaps is enabled."
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
name: Build & Push Docker to ECR
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
image_tag:
|
||||
description: "Image tag to push (e.g., v3.16.1)"
|
||||
required: true
|
||||
default: "v3.16.1"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
env:
|
||||
ECR_REGION: ${{ vars.ECR_REGION }}
|
||||
# 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
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@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: Configure AWS credentials (OIDC)
|
||||
uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a
|
||||
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: |
|
||||
${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:${{ inputs.image_tag }}
|
||||
${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:latest
|
||||
secrets: |
|
||||
database_url=${{ secrets.DUMMY_DATABASE_URL }}
|
||||
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
||||
@@ -37,7 +37,7 @@ on:
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
helmfile-deploy:
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
name: Docker Security Scan
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 2 * * *" # Daily at 2 AM UTC
|
||||
workflow_dispatch:
|
||||
workflow_run:
|
||||
workflows: ["Docker Release to Github"]
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
security-events: write
|
||||
|
||||
jobs:
|
||||
scan:
|
||||
name: Vulnerability Scan
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Run Trivy vulnerability scanner
|
||||
uses: aquasecurity/trivy-action@dc5a429b52fcf669ce959baa2c2dd26090d2a6c4 # v0.32.0
|
||||
with:
|
||||
image-ref: "ghcr.io/${{ github.repository }}:latest"
|
||||
format: "sarif"
|
||||
output: "trivy-results.sarif"
|
||||
severity: "CRITICAL,HIGH,MEDIUM,LOW"
|
||||
|
||||
- name: Upload Trivy scan results to GitHub Security tab
|
||||
uses: github/codeql-action/upload-sarif@a4e1a019f5e24960714ff6296aee04b736cbc3cf # v3.29.6
|
||||
if: ${{ always() && hashFiles('trivy-results.sarif') != '' }}
|
||||
with:
|
||||
sarif_file: "trivy-results.sarif"
|
||||
@@ -7,13 +7,12 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
ENVIRONMENT: ${{ github.event.release.prerelease && 'staging' || 'production' }}
|
||||
|
||||
jobs:
|
||||
docker-build:
|
||||
name: Build & release docker image
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
uses: ./.github/workflows/release-docker-github.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
@@ -21,9 +20,6 @@ jobs:
|
||||
|
||||
helm-chart-release:
|
||||
name: Release Helm Chart
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
uses: ./.github/workflows/release-helm-chart.yml
|
||||
secrets: inherit
|
||||
needs:
|
||||
@@ -33,9 +29,6 @@ jobs:
|
||||
|
||||
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
|
||||
needs:
|
||||
@@ -43,7 +36,7 @@ jobs:
|
||||
- helm-chart-release
|
||||
with:
|
||||
VERSION: v${{ needs.docker-build.outputs.VERSION }}
|
||||
ENVIRONMENT: ${{ github.event.release.prerelease && 'staging' || 'production' }}
|
||||
ENVIRONMENT: ${{ env.ENVIRONMENT }}
|
||||
|
||||
upload-sentry-sourcemaps:
|
||||
name: Upload Sentry Sourcemaps
|
||||
@@ -71,4 +64,4 @@ jobs:
|
||||
docker_image: ghcr.io/formbricks/formbricks:v${{ needs.docker-build.outputs.VERSION }}
|
||||
release_version: v${{ needs.docker-build.outputs.VERSION }}
|
||||
sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
environment: ${{ github.event.release.prerelease && 'staging' || 'production' }}
|
||||
environment: ${{ env.ENVIRONMENT }}
|
||||
|
||||
@@ -56,3 +56,11 @@ jobs:
|
||||
```
|
||||
${{ steps.lint_pr_title.outputs.error_message }}
|
||||
```
|
||||
|
||||
# Delete a previous comment when the issue has been resolved
|
||||
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
|
||||
uses: marocchino/sticky-pull-request-comment@67d0dec7b07ed060a405f9b2a64b8ab319fdd7db # v2.9.2
|
||||
with:
|
||||
header: pr-title-lint-error
|
||||
message: |
|
||||
Thank you for following the naming conventions for pull request titles! 🙏
|
||||
|
||||
@@ -31,10 +31,6 @@
|
||||
{
|
||||
"language": "pt-PT",
|
||||
"path": "./apps/web/locales/pt-PT.json"
|
||||
},
|
||||
{
|
||||
"language": "ro-RO",
|
||||
"path": "./apps/web/locales/ro-RO.json"
|
||||
}
|
||||
],
|
||||
"forceMode": "OVERRIDE"
|
||||
|
||||
+3
@@ -60,6 +60,7 @@ const mockResponses = [
|
||||
userAgent: { browser: "Chrome", os: "Mac OS", device: "Desktop" },
|
||||
url: "http://localhost:3000",
|
||||
},
|
||||
notes: [],
|
||||
tags: [],
|
||||
} as unknown as TResponse,
|
||||
{
|
||||
@@ -73,6 +74,7 @@ const mockResponses = [
|
||||
userAgent: { browser: "Firefox", os: "Windows", device: "Desktop" },
|
||||
url: "http://localhost:3000/page2",
|
||||
},
|
||||
notes: [],
|
||||
tags: [],
|
||||
} as unknown as TResponse,
|
||||
{
|
||||
@@ -86,6 +88,7 @@ const mockResponses = [
|
||||
userAgent: { browser: "Safari", os: "iOS", device: "Mobile" },
|
||||
url: "http://localhost:3000/page3",
|
||||
},
|
||||
notes: [],
|
||||
tags: [],
|
||||
} as unknown as TResponse,
|
||||
] as unknown as TResponse[];
|
||||
|
||||
+1
@@ -82,6 +82,7 @@ export const ResponseCardModal = ({
|
||||
survey={survey}
|
||||
response={responses[currentIndex]}
|
||||
user={user}
|
||||
pageType="response"
|
||||
environment={environment}
|
||||
environmentTags={environmentTags}
|
||||
isReadOnly={isReadOnly}
|
||||
|
||||
+14
-12
@@ -117,6 +117,17 @@ const mockResponses: TResponse[] = [
|
||||
singleUseId: null,
|
||||
ttc: {},
|
||||
tags: [{ id: "tag1", name: "Tag1", environmentId: "env1", createdAt: new Date(), updatedAt: new Date() }],
|
||||
notes: [
|
||||
{
|
||||
id: "note1",
|
||||
text: "Note 1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
isResolved: false,
|
||||
isEdited: false,
|
||||
user: { id: "user1", name: "User 1" },
|
||||
},
|
||||
],
|
||||
variables: { var1: "Response Var Value" },
|
||||
language: "en",
|
||||
contact: null,
|
||||
@@ -133,6 +144,7 @@ const mockResponses: TResponse[] = [
|
||||
singleUseId: null,
|
||||
ttc: {},
|
||||
tags: [],
|
||||
notes: [],
|
||||
variables: {},
|
||||
language: "de",
|
||||
contact: null,
|
||||
@@ -222,17 +234,12 @@ describe("ResponseDataView", () => {
|
||||
status: "Completed",
|
||||
responseId: "response1",
|
||||
tags: mockResponses[0].tags,
|
||||
notes: mockResponses[0].notes,
|
||||
variables: { var1: "Response Var Value" },
|
||||
verifiedEmail: "test@example.com",
|
||||
language: "en",
|
||||
person: null,
|
||||
contactAttributes: null,
|
||||
meta: {
|
||||
url: "http://localhost",
|
||||
userAgent: {
|
||||
browser: "test-agent",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
responseData: {
|
||||
@@ -242,17 +249,12 @@ describe("ResponseDataView", () => {
|
||||
status: "Not Completed",
|
||||
responseId: "response2",
|
||||
tags: [],
|
||||
notes: [],
|
||||
variables: {},
|
||||
verifiedEmail: "",
|
||||
language: "de",
|
||||
person: null,
|
||||
contactAttributes: null,
|
||||
meta: {
|
||||
url: "http://localhost",
|
||||
userAgent: {
|
||||
browser: "test-agent-2",
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
+1
-1
@@ -90,6 +90,7 @@ export const mapResponsesToTableData = (
|
||||
: t("environments.surveys.responses.not_completed"),
|
||||
responseId: response.id,
|
||||
tags: response.tags,
|
||||
notes: response.notes,
|
||||
variables: survey.variables.reduce(
|
||||
(acc, curr) => {
|
||||
return Object.assign(acc, { [curr.id]: response.variables[curr.id] });
|
||||
@@ -100,7 +101,6 @@ export const mapResponsesToTableData = (
|
||||
language: response.language,
|
||||
person: response.contact,
|
||||
contactAttributes: response.contactAttributes,
|
||||
meta: response.meta,
|
||||
}));
|
||||
};
|
||||
|
||||
|
||||
+2
@@ -107,6 +107,7 @@ const mockResponses: TResponse[] = [
|
||||
finished: true,
|
||||
data: {},
|
||||
meta: { userAgent: {} },
|
||||
notes: [],
|
||||
tags: [],
|
||||
} as unknown as TResponse,
|
||||
{
|
||||
@@ -117,6 +118,7 @@ const mockResponses: TResponse[] = [
|
||||
finished: true,
|
||||
data: {},
|
||||
meta: { userAgent: {} },
|
||||
notes: [],
|
||||
tags: [],
|
||||
} as unknown as TResponse,
|
||||
];
|
||||
|
||||
+47
-7
@@ -5,7 +5,7 @@ import { getFormattedDateTimeString } from "@/lib/utils/datetime";
|
||||
import { getSelectionColumn } from "@/modules/ui/components/data-table";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TResponseTableData } from "@formbricks/types/responses";
|
||||
import { TResponseNote, TResponseNoteUser, TResponseTableData } from "@formbricks/types/responses";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyQuestion,
|
||||
@@ -102,12 +102,6 @@ vi.mock("lucide-react", () => ({
|
||||
EyeOffIcon: () => <span>EyeOff</span>,
|
||||
MailIcon: () => <span>Mail</span>,
|
||||
TagIcon: () => <span>Tag</span>,
|
||||
MousePointerClickIcon: () => <span>MousePointerClick</span>,
|
||||
AirplayIcon: () => <span>Airplay</span>,
|
||||
ArrowUpFromDotIcon: () => <span>ArrowUpFromDot</span>,
|
||||
FlagIcon: () => <span>Flag</span>,
|
||||
GlobeIcon: () => <span>Globe</span>,
|
||||
SmartphoneIcon: () => <span>Smartphone</span>,
|
||||
}));
|
||||
|
||||
// Mock new dependencies
|
||||
@@ -229,6 +223,14 @@ const mockResponseData = {
|
||||
var1: "Segment A",
|
||||
var2: 100,
|
||||
},
|
||||
notes: [
|
||||
{
|
||||
id: "note1",
|
||||
text: "This is a note",
|
||||
updatedAt: new Date(),
|
||||
user: { name: "User" } as unknown as TResponseNoteUser,
|
||||
} as TResponseNote,
|
||||
],
|
||||
status: "completed",
|
||||
tags: [{ id: "tag1", name: "Important" } as unknown as TTag],
|
||||
language: "default",
|
||||
@@ -294,6 +296,14 @@ describe("generateResponseTableColumns", () => {
|
||||
const hf1Col = columns.find((col) => (col as any).accessorKey === "hf1");
|
||||
expect(hf1Col).toBeUndefined();
|
||||
});
|
||||
|
||||
test("should generate Notes column", () => {
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
|
||||
const notesCol = columns.find((col) => (col as any).accessorKey === "notes");
|
||||
expect(notesCol).toBeDefined();
|
||||
(notesCol?.cell as any)?.({ row: { original: mockResponseData } } as any);
|
||||
expect(vi.mocked(processResponseData)).toHaveBeenCalledWith(["This is a note"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ResponseTableColumns", () => {
|
||||
@@ -433,6 +443,36 @@ describe("ResponseTableColumns - Column Implementations", () => {
|
||||
expect(cellResult).toBeUndefined();
|
||||
});
|
||||
|
||||
test("notesColumn renders when notes is an array", () => {
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
|
||||
const notesColumn: any = columns.find((col) => (col as any).accessorKey === "notes");
|
||||
expect(notesColumn).toBeDefined();
|
||||
|
||||
// Mock a response with notes
|
||||
const mockRow = {
|
||||
original: { notes: [{ text: "Note 1" }, { text: "Note 2" }] },
|
||||
} as any;
|
||||
|
||||
// Call the cell function
|
||||
notesColumn?.cell?.({ row: mockRow } as any);
|
||||
expect(vi.mocked(processResponseData)).toHaveBeenCalledWith(["Note 1", "Note 2"]);
|
||||
});
|
||||
|
||||
test("notesColumn returns undefined when notes is not an array", () => {
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
|
||||
const notesColumn: any = columns.find((col) => (col as any).accessorKey === "notes");
|
||||
expect(notesColumn).toBeDefined();
|
||||
|
||||
// Mock a response with no notes
|
||||
const mockRow = {
|
||||
original: { notes: null },
|
||||
} as any;
|
||||
|
||||
// Call the cell function
|
||||
const cellResult = notesColumn?.cell?.({ row: mockRow } as any);
|
||||
expect(cellResult).toBeUndefined();
|
||||
});
|
||||
|
||||
test("variableColumns render variable values correctly", () => {
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
|
||||
|
||||
|
||||
+51
-38
@@ -2,6 +2,7 @@
|
||||
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { extractChoiceIdsFromResponse } from "@/lib/response/utils";
|
||||
import { processResponseData } from "@/lib/responses";
|
||||
import { getContactIdentifier } from "@/lib/utils/contact";
|
||||
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
|
||||
import { recallToHeadline } from "@/lib/utils/recall";
|
||||
@@ -18,14 +19,43 @@ import { CircleHelpIcon, EyeOffIcon, MailIcon, TagIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { TResponseTableData } from "@formbricks/types/responses";
|
||||
import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
COLUMNS_ICON_MAP,
|
||||
METADATA_FIELDS,
|
||||
getAddressFieldLabel,
|
||||
getContactInfoFieldLabel,
|
||||
getMetadataFieldLabel,
|
||||
getMetadataValue,
|
||||
} from "../lib/utils";
|
||||
|
||||
const getAddressFieldLabel = (field: string, t: TFnType) => {
|
||||
switch (field) {
|
||||
case "addressLine1":
|
||||
return t("environments.surveys.responses.address_line_1");
|
||||
case "addressLine2":
|
||||
return t("environments.surveys.responses.address_line_2");
|
||||
case "city":
|
||||
return t("environments.surveys.responses.city");
|
||||
case "state":
|
||||
return t("environments.surveys.responses.state_region");
|
||||
case "zip":
|
||||
return t("environments.surveys.responses.zip_post_code");
|
||||
case "country":
|
||||
return t("environments.surveys.responses.country");
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const getContactInfoFieldLabel = (field: string, t: TFnType) => {
|
||||
switch (field) {
|
||||
case "firstName":
|
||||
return t("environments.surveys.responses.first_name");
|
||||
case "lastName":
|
||||
return t("environments.surveys.responses.last_name");
|
||||
case "email":
|
||||
return t("environments.surveys.responses.email");
|
||||
case "phone":
|
||||
return t("environments.surveys.responses.phone");
|
||||
case "company":
|
||||
return t("environments.surveys.responses.company");
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const getQuestionColumnsData = (
|
||||
question: TSurveyQuestion,
|
||||
@@ -226,33 +256,6 @@ const getQuestionColumnsData = (
|
||||
}
|
||||
};
|
||||
|
||||
const getMetadataColumnsData = (t: TFnType): ColumnDef<TResponseTableData>[] => {
|
||||
const metadataColumns: ColumnDef<TResponseTableData>[] = [];
|
||||
|
||||
METADATA_FIELDS.forEach((label) => {
|
||||
const IconComponent = COLUMNS_ICON_MAP[label];
|
||||
|
||||
metadataColumns.push({
|
||||
accessorKey: label,
|
||||
header: () => (
|
||||
<div className="flex items-center space-x-2 overflow-hidden">
|
||||
<span className="h-4 w-4">{IconComponent && <IconComponent className="h-4 w-4" />}</span>
|
||||
<span className="truncate">{getMetadataFieldLabel(label, t)}</span>
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const value = getMetadataValue(row.original.meta, label);
|
||||
if (value) {
|
||||
return <div className="truncate text-slate-900">{value}</div>;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return metadataColumns;
|
||||
};
|
||||
|
||||
export const generateResponseTableColumns = (
|
||||
survey: TSurvey,
|
||||
isExpanded: boolean,
|
||||
@@ -334,6 +337,18 @@ export const generateResponseTableColumns = (
|
||||
},
|
||||
};
|
||||
|
||||
const notesColumn: ColumnDef<TResponseTableData> = {
|
||||
accessorKey: "notes",
|
||||
header: t("common.notes"),
|
||||
cell: ({ row }) => {
|
||||
const notes = row.original.notes;
|
||||
if (Array.isArray(notes)) {
|
||||
const notesArray = notes.map((note) => note.text);
|
||||
return processResponseData(notesArray);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const variableColumns: ColumnDef<TResponseTableData>[] = survey.variables.map((variable) => {
|
||||
return {
|
||||
accessorKey: variable.id,
|
||||
@@ -374,8 +389,6 @@ export const generateResponseTableColumns = (
|
||||
})
|
||||
: [];
|
||||
|
||||
const metadataColumns = getMetadataColumnsData(t);
|
||||
|
||||
const verifiedEmailColumn: ColumnDef<TResponseTableData> = {
|
||||
accessorKey: "verifiedEmail",
|
||||
header: () => (
|
||||
@@ -397,8 +410,8 @@ export const generateResponseTableColumns = (
|
||||
...questionColumns,
|
||||
...variableColumns,
|
||||
...hiddenFieldColumns,
|
||||
...metadataColumns,
|
||||
tagsColumn,
|
||||
notesColumn,
|
||||
];
|
||||
|
||||
return isReadOnly ? baseColumns : [getSelectionColumn(), ...baseColumns];
|
||||
|
||||
-204
@@ -1,204 +0,0 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import {
|
||||
AirplayIcon,
|
||||
ArrowUpFromDotIcon,
|
||||
FlagIcon,
|
||||
GlobeIcon,
|
||||
MousePointerClickIcon,
|
||||
SmartphoneIcon,
|
||||
} from "lucide-react";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
COLUMNS_ICON_MAP,
|
||||
getAddressFieldLabel,
|
||||
getContactInfoFieldLabel,
|
||||
getMetadataFieldLabel,
|
||||
getMetadataValue,
|
||||
} from "./utils";
|
||||
|
||||
describe("utils", () => {
|
||||
const mockT = vi.fn((key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
"environments.surveys.responses.address_line_1": "Address Line 1",
|
||||
"environments.surveys.responses.address_line_2": "Address Line 2",
|
||||
"environments.surveys.responses.city": "City",
|
||||
"environments.surveys.responses.state_region": "State/Region",
|
||||
"environments.surveys.responses.zip_post_code": "ZIP/Post Code",
|
||||
"environments.surveys.responses.country": "Country",
|
||||
"environments.surveys.responses.first_name": "First Name",
|
||||
"environments.surveys.responses.last_name": "Last Name",
|
||||
"environments.surveys.responses.email": "Email",
|
||||
"environments.surveys.responses.phone": "Phone",
|
||||
"environments.surveys.responses.company": "Company",
|
||||
"common.action": "Action",
|
||||
"environments.surveys.responses.os": "OS",
|
||||
"environments.surveys.responses.device": "Device",
|
||||
"environments.surveys.responses.browser": "Browser",
|
||||
"common.url": "URL",
|
||||
"environments.surveys.responses.source": "Source",
|
||||
};
|
||||
return translations[key] || key;
|
||||
});
|
||||
|
||||
describe("getAddressFieldLabel", () => {
|
||||
test("returns correct label for addressLine1", () => {
|
||||
const result = getAddressFieldLabel("addressLine1", mockT);
|
||||
expect(result).toBe("Address Line 1");
|
||||
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.address_line_1");
|
||||
});
|
||||
|
||||
test("returns correct label for addressLine2", () => {
|
||||
const result = getAddressFieldLabel("addressLine2", mockT);
|
||||
expect(result).toBe("Address Line 2");
|
||||
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.address_line_2");
|
||||
});
|
||||
|
||||
test("returns correct label for city", () => {
|
||||
const result = getAddressFieldLabel("city", mockT);
|
||||
expect(result).toBe("City");
|
||||
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.city");
|
||||
});
|
||||
|
||||
test("returns correct label for state", () => {
|
||||
const result = getAddressFieldLabel("state", mockT);
|
||||
expect(result).toBe("State/Region");
|
||||
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.state_region");
|
||||
});
|
||||
|
||||
test("returns correct label for zip", () => {
|
||||
const result = getAddressFieldLabel("zip", mockT);
|
||||
expect(result).toBe("ZIP/Post Code");
|
||||
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.zip_post_code");
|
||||
});
|
||||
|
||||
test("returns correct label for country", () => {
|
||||
const result = getAddressFieldLabel("country", mockT);
|
||||
expect(result).toBe("Country");
|
||||
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.country");
|
||||
});
|
||||
|
||||
test("returns undefined for unknown field", () => {
|
||||
const result = getAddressFieldLabel("unknown", mockT);
|
||||
expect(result).toBeUndefined();
|
||||
expect(mockT).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getContactInfoFieldLabel", () => {
|
||||
test("returns correct label for firstName", () => {
|
||||
const result = getContactInfoFieldLabel("firstName", mockT);
|
||||
expect(result).toBe("First Name");
|
||||
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.first_name");
|
||||
});
|
||||
|
||||
test("returns correct label for lastName", () => {
|
||||
const result = getContactInfoFieldLabel("lastName", mockT);
|
||||
expect(result).toBe("Last Name");
|
||||
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.last_name");
|
||||
});
|
||||
|
||||
test("returns correct label for email", () => {
|
||||
const result = getContactInfoFieldLabel("email", mockT);
|
||||
expect(result).toBe("Email");
|
||||
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.email");
|
||||
});
|
||||
|
||||
test("returns correct label for phone", () => {
|
||||
const result = getContactInfoFieldLabel("phone", mockT);
|
||||
expect(result).toBe("Phone");
|
||||
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.phone");
|
||||
});
|
||||
|
||||
test("returns correct label for company", () => {
|
||||
const result = getContactInfoFieldLabel("company", mockT);
|
||||
expect(result).toBe("Company");
|
||||
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.company");
|
||||
});
|
||||
|
||||
test("returns undefined for unknown field", () => {
|
||||
const result = getContactInfoFieldLabel("unknown", mockT);
|
||||
expect(result).toBeUndefined();
|
||||
expect(mockT).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMetadataFieldLabel", () => {
|
||||
test("returns correct label for action", () => {
|
||||
const result = getMetadataFieldLabel("action", mockT);
|
||||
expect(result).toBe("Action");
|
||||
expect(mockT).toHaveBeenCalledWith("common.action");
|
||||
});
|
||||
|
||||
test("returns correct label for country", () => {
|
||||
const result = getMetadataFieldLabel("country", mockT);
|
||||
expect(result).toBe("Country");
|
||||
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.country");
|
||||
});
|
||||
|
||||
test("returns correct label for os", () => {
|
||||
const result = getMetadataFieldLabel("os", mockT);
|
||||
expect(result).toBe("OS");
|
||||
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.os");
|
||||
});
|
||||
|
||||
test("returns correct label for device", () => {
|
||||
const result = getMetadataFieldLabel("device", mockT);
|
||||
expect(result).toBe("Device");
|
||||
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.device");
|
||||
});
|
||||
|
||||
test("returns correct label for browser", () => {
|
||||
const result = getMetadataFieldLabel("browser", mockT);
|
||||
expect(result).toBe("Browser");
|
||||
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.browser");
|
||||
});
|
||||
|
||||
test("returns correct label for url", () => {
|
||||
const result = getMetadataFieldLabel("url", mockT);
|
||||
expect(result).toBe("URL");
|
||||
expect(mockT).toHaveBeenCalledWith("common.url");
|
||||
});
|
||||
|
||||
test("returns correct label for source", () => {
|
||||
const result = getMetadataFieldLabel("source", mockT);
|
||||
expect(result).toBe("Source");
|
||||
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.source");
|
||||
});
|
||||
|
||||
test("returns capitalized label for unknown field", () => {
|
||||
const result = getMetadataFieldLabel("customField", mockT);
|
||||
expect(result).toBe("Customfield");
|
||||
expect(mockT).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns capitalized label for field with underscores", () => {
|
||||
const result = getMetadataFieldLabel("custom_field", mockT);
|
||||
expect(result).toBe("Custom_field");
|
||||
expect(mockT).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("COLUMNS_ICON_MAP", () => {
|
||||
test("contains correct icon mappings", () => {
|
||||
expect(COLUMNS_ICON_MAP.action).toBe(MousePointerClickIcon);
|
||||
expect(COLUMNS_ICON_MAP.country).toBe(FlagIcon);
|
||||
expect(COLUMNS_ICON_MAP.browser).toBe(GlobeIcon);
|
||||
expect(COLUMNS_ICON_MAP.os).toBe(AirplayIcon);
|
||||
expect(COLUMNS_ICON_MAP.device).toBe(SmartphoneIcon);
|
||||
expect(COLUMNS_ICON_MAP.source).toBe(ArrowUpFromDotIcon);
|
||||
expect(COLUMNS_ICON_MAP.url).toBe(GlobeIcon);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMetadataValue", () => {
|
||||
test("returns correct value for action", () => {
|
||||
const result = getMetadataValue({ action: "action_column" }, "action");
|
||||
expect(result).toBe("action_column");
|
||||
});
|
||||
|
||||
test("returns correct value for userAgent", () => {
|
||||
const result = getMetadataValue({ userAgent: { browser: "browser_column" } }, "browser");
|
||||
expect(result).toBe("browser_column");
|
||||
});
|
||||
});
|
||||
});
|
||||
-88
@@ -1,88 +0,0 @@
|
||||
import { TFnType } from "@tolgee/react";
|
||||
import { capitalize } from "lodash";
|
||||
import {
|
||||
AirplayIcon,
|
||||
ArrowUpFromDotIcon,
|
||||
FlagIcon,
|
||||
GlobeIcon,
|
||||
MousePointerClickIcon,
|
||||
SmartphoneIcon,
|
||||
} from "lucide-react";
|
||||
import { TResponseMeta } from "@formbricks/types/responses";
|
||||
|
||||
export const getAddressFieldLabel = (field: string, t: TFnType) => {
|
||||
switch (field) {
|
||||
case "addressLine1":
|
||||
return t("environments.surveys.responses.address_line_1");
|
||||
case "addressLine2":
|
||||
return t("environments.surveys.responses.address_line_2");
|
||||
case "city":
|
||||
return t("environments.surveys.responses.city");
|
||||
case "state":
|
||||
return t("environments.surveys.responses.state_region");
|
||||
case "zip":
|
||||
return t("environments.surveys.responses.zip_post_code");
|
||||
case "country":
|
||||
return t("environments.surveys.responses.country");
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
export const getContactInfoFieldLabel = (field: string, t: TFnType) => {
|
||||
switch (field) {
|
||||
case "firstName":
|
||||
return t("environments.surveys.responses.first_name");
|
||||
case "lastName":
|
||||
return t("environments.surveys.responses.last_name");
|
||||
case "email":
|
||||
return t("environments.surveys.responses.email");
|
||||
case "phone":
|
||||
return t("environments.surveys.responses.phone");
|
||||
case "company":
|
||||
return t("environments.surveys.responses.company");
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
export const getMetadataFieldLabel = (label: string, t: TFnType) => {
|
||||
switch (label) {
|
||||
case "action":
|
||||
return t("common.action");
|
||||
case "country":
|
||||
return t("environments.surveys.responses.country");
|
||||
case "os":
|
||||
return t("environments.surveys.responses.os");
|
||||
case "device":
|
||||
return t("environments.surveys.responses.device");
|
||||
case "browser":
|
||||
return t("environments.surveys.responses.browser");
|
||||
case "url":
|
||||
return t("common.url");
|
||||
case "source":
|
||||
return t("environments.surveys.responses.source");
|
||||
default:
|
||||
return capitalize(label);
|
||||
}
|
||||
};
|
||||
|
||||
export const COLUMNS_ICON_MAP = {
|
||||
action: MousePointerClickIcon,
|
||||
country: FlagIcon,
|
||||
browser: GlobeIcon,
|
||||
os: AirplayIcon,
|
||||
device: SmartphoneIcon,
|
||||
source: ArrowUpFromDotIcon,
|
||||
url: GlobeIcon,
|
||||
};
|
||||
|
||||
const userAgentFields = ["browser", "os", "device"];
|
||||
export const METADATA_FIELDS = ["action", "country", ...userAgentFields, "source", "url"];
|
||||
|
||||
export const getMetadataValue = (meta: TResponseMeta, label: string) => {
|
||||
if (userAgentFields.includes(label)) {
|
||||
return meta.userAgent?.[label];
|
||||
}
|
||||
return meta[label];
|
||||
};
|
||||
+1
-48
@@ -172,15 +172,6 @@ vi.mock("./shareEmbedModal/success-view", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock LinkSettingsTab
|
||||
vi.mock("./shareEmbedModal/link-settings-tab", () => ({
|
||||
LinkSettingsTab: ({ survey, isReadOnly, locale }: any) => (
|
||||
<div data-testid="link-settings-tab">
|
||||
LinkSettings for {survey.id} - ReadOnly: {isReadOnly.toString()} - Locale: {locale}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock lucide-react icons
|
||||
vi.mock("lucide-react", async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as any;
|
||||
@@ -193,7 +184,6 @@ vi.mock("lucide-react", async (importOriginal) => {
|
||||
SmartphoneIcon: () => <svg data-testid="smartphone-icon" />,
|
||||
SquareStack: () => <svg data-testid="square-stack-icon" />,
|
||||
UserIcon: () => <svg data-testid="user-icon" />,
|
||||
Settings: () => <svg data-testid="settings-icon" />,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -237,7 +227,6 @@ const mockSurvey: TSurvey = {
|
||||
recaptcha: null,
|
||||
isSingleResponsePerEmailEnabled: false,
|
||||
isBackButtonHidden: false,
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
const mockAppSurvey: TSurvey = {
|
||||
@@ -281,7 +270,6 @@ const defaultProps = {
|
||||
segments: mockSegments,
|
||||
isContactsEnabled: true,
|
||||
isFormbricksCloud: false,
|
||||
isReadOnly: false,
|
||||
};
|
||||
|
||||
describe("ShareSurveyModal", () => {
|
||||
@@ -404,6 +392,7 @@ describe("ShareSurveyModal", () => {
|
||||
});
|
||||
|
||||
test("resets to start view when modal is closed and reopened", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = render(<ShareSurveyModal {...defaultProps} modalView="share" />);
|
||||
|
||||
expect(screen.getByTestId("share-view")).toBeInTheDocument();
|
||||
@@ -479,40 +468,4 @@ describe("ShareSurveyModal", () => {
|
||||
expect(screen.getByTestId("app-tab")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("share-view")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("includes link settings tab in share tabs for link surveys", () => {
|
||||
render(<ShareSurveyModal {...defaultProps} modalView="share" />);
|
||||
|
||||
const shareView = screen.getByTestId("share-view");
|
||||
expect(shareView).toBeInTheDocument();
|
||||
|
||||
// Check that tabs are rendered and include link settings
|
||||
const tabsContainer = screen.getByTestId("tabs");
|
||||
expect(tabsContainer).toBeInTheDocument();
|
||||
|
||||
// Look for the link-settings tab button
|
||||
expect(screen.getByTestId("tab-link-settings")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("does not include link settings for app surveys", () => {
|
||||
render(<ShareSurveyModal {...defaultProps} survey={mockAppSurvey} modalView="share" />);
|
||||
|
||||
// App surveys should render TabContainer, not ShareView with link settings
|
||||
expect(screen.getByTestId("tab-container")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("app-tab")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("share-view")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("tab-link-settings")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("handles locale prop correctly for link settings", () => {
|
||||
const customProps = {
|
||||
...defaultProps,
|
||||
modalView: "share" as const,
|
||||
};
|
||||
|
||||
render(<ShareSurveyModal {...customProps} />);
|
||||
|
||||
// Verify that ShareView is rendered (which would contain LinkSettingsTab)
|
||||
expect(screen.getByTestId("share-view")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
+19
-70
@@ -4,32 +4,18 @@ import { AnonymousLinksTab } from "@/app/(app)/environments/[environmentId]/surv
|
||||
import { AppTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/app-tab";
|
||||
import { DynamicPopupTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/dynamic-popup-tab";
|
||||
import { EmailTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/email-tab";
|
||||
import { LinkSettingsTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/link-settings-tab";
|
||||
import { PersonalLinksTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/personal-links-tab";
|
||||
import { QRCodeTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/qr-code-tab";
|
||||
import { SocialMediaTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/social-media-tab";
|
||||
import { TabContainer } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/tab-container";
|
||||
import { WebsiteEmbedTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/website-embed-tab";
|
||||
import {
|
||||
LinkTabsType,
|
||||
ShareSettingsType,
|
||||
ShareViaType,
|
||||
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/types/share";
|
||||
import { ShareViewType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/types/share";
|
||||
import { getSurveyUrl } from "@/modules/analysis/utils";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/modules/ui/components/dialog";
|
||||
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import {
|
||||
Code2Icon,
|
||||
LinkIcon,
|
||||
MailIcon,
|
||||
QrCodeIcon,
|
||||
Settings,
|
||||
Share2Icon,
|
||||
SquareStack,
|
||||
UserIcon,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Code2Icon, LinkIcon, MailIcon, QrCodeIcon, Share2Icon, SquareStack, UserIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
@@ -69,20 +55,17 @@ export const ShareSurveyModal = ({
|
||||
const { email } = user;
|
||||
const { t } = useTranslate();
|
||||
const linkTabs: {
|
||||
id: ShareViaType | ShareSettingsType;
|
||||
type: LinkTabsType;
|
||||
id: ShareViewType;
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
title: string;
|
||||
description: string;
|
||||
componentType: React.ComponentType<unknown>;
|
||||
componentProps: unknown;
|
||||
disabled?: boolean;
|
||||
componentType: React.ComponentType<any>;
|
||||
componentProps: any;
|
||||
}[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: ShareViaType.ANON_LINKS,
|
||||
type: LinkTabsType.SHARE_VIA,
|
||||
id: ShareViewType.ANON_LINKS,
|
||||
label: t("environments.surveys.share.anonymous_links.nav_title"),
|
||||
icon: LinkIcon,
|
||||
title: t("environments.surveys.share.anonymous_links.nav_title"),
|
||||
@@ -98,8 +81,7 @@ export const ShareSurveyModal = ({
|
||||
},
|
||||
},
|
||||
{
|
||||
id: ShareViaType.PERSONAL_LINKS,
|
||||
type: LinkTabsType.SHARE_VIA,
|
||||
id: ShareViewType.PERSONAL_LINKS,
|
||||
label: t("environments.surveys.share.personal_links.nav_title"),
|
||||
icon: UserIcon,
|
||||
title: t("environments.surveys.share.personal_links.nav_title"),
|
||||
@@ -112,55 +94,45 @@ export const ShareSurveyModal = ({
|
||||
isContactsEnabled,
|
||||
isFormbricksCloud,
|
||||
},
|
||||
disabled: survey.singleUse?.enabled,
|
||||
},
|
||||
{
|
||||
id: ShareViaType.WEBSITE_EMBED,
|
||||
type: LinkTabsType.SHARE_VIA,
|
||||
id: ShareViewType.WEBSITE_EMBED,
|
||||
label: t("environments.surveys.share.embed_on_website.nav_title"),
|
||||
icon: Code2Icon,
|
||||
title: t("environments.surveys.share.embed_on_website.nav_title"),
|
||||
description: t("environments.surveys.share.embed_on_website.description"),
|
||||
componentType: WebsiteEmbedTab,
|
||||
componentProps: { surveyUrl },
|
||||
disabled: survey.singleUse?.enabled,
|
||||
},
|
||||
{
|
||||
id: ShareViaType.EMAIL,
|
||||
type: LinkTabsType.SHARE_VIA,
|
||||
id: ShareViewType.EMAIL,
|
||||
label: t("environments.surveys.share.send_email.nav_title"),
|
||||
icon: MailIcon,
|
||||
title: t("environments.surveys.share.send_email.nav_title"),
|
||||
description: t("environments.surveys.share.send_email.description"),
|
||||
componentType: EmailTab,
|
||||
componentProps: { surveyId: survey.id, email },
|
||||
disabled: survey.singleUse?.enabled,
|
||||
},
|
||||
{
|
||||
id: ShareViaType.SOCIAL_MEDIA,
|
||||
type: LinkTabsType.SHARE_VIA,
|
||||
id: ShareViewType.SOCIAL_MEDIA,
|
||||
label: t("environments.surveys.share.social_media.title"),
|
||||
icon: Share2Icon,
|
||||
title: t("environments.surveys.share.social_media.title"),
|
||||
description: t("environments.surveys.share.social_media.description"),
|
||||
componentType: SocialMediaTab,
|
||||
componentProps: { surveyUrl, surveyTitle: survey.name },
|
||||
disabled: survey.singleUse?.enabled,
|
||||
},
|
||||
{
|
||||
id: ShareViaType.QR_CODE,
|
||||
type: LinkTabsType.SHARE_VIA,
|
||||
id: ShareViewType.QR_CODE,
|
||||
label: t("environments.surveys.summary.qr_code"),
|
||||
icon: QrCodeIcon,
|
||||
title: t("environments.surveys.summary.qr_code"),
|
||||
description: t("environments.surveys.summary.qr_code_description"),
|
||||
componentType: QRCodeTab,
|
||||
componentProps: { surveyUrl },
|
||||
disabled: survey.singleUse?.enabled,
|
||||
},
|
||||
{
|
||||
id: ShareViaType.DYNAMIC_POPUP,
|
||||
type: LinkTabsType.SHARE_VIA,
|
||||
id: ShareViewType.DYNAMIC_POPUP,
|
||||
label: t("environments.surveys.share.dynamic_popup.nav_title"),
|
||||
icon: SquareStack,
|
||||
title: t("environments.surveys.share.dynamic_popup.nav_title"),
|
||||
@@ -168,24 +140,14 @@ export const ShareSurveyModal = ({
|
||||
componentType: DynamicPopupTab,
|
||||
componentProps: { environmentId, surveyId: survey.id },
|
||||
},
|
||||
{
|
||||
id: ShareSettingsType.LINK_SETTINGS,
|
||||
type: LinkTabsType.SHARE_SETTING,
|
||||
label: t("environments.surveys.share.link_settings.title"),
|
||||
icon: Settings,
|
||||
title: t("environments.surveys.share.link_settings.title"),
|
||||
description: t("environments.surveys.share.link_settings.description"),
|
||||
componentType: LinkSettingsTab,
|
||||
componentProps: { isReadOnly, locale: user.locale },
|
||||
},
|
||||
],
|
||||
[
|
||||
t,
|
||||
survey,
|
||||
publicDomain,
|
||||
setSurveyUrl,
|
||||
user.locale,
|
||||
surveyUrl,
|
||||
isReadOnly,
|
||||
environmentId,
|
||||
segments,
|
||||
isContactsEnabled,
|
||||
@@ -194,14 +156,9 @@ export const ShareSurveyModal = ({
|
||||
]
|
||||
);
|
||||
|
||||
const getDefaultActiveId = useCallback(() => {
|
||||
if (survey.type !== "link") {
|
||||
return ShareViaType.APP;
|
||||
}
|
||||
return ShareViaType.ANON_LINKS;
|
||||
}, [survey.type]);
|
||||
|
||||
const [activeId, setActiveId] = useState<ShareViaType | ShareSettingsType>(getDefaultActiveId());
|
||||
const [activeId, setActiveId] = useState(
|
||||
survey.type === "link" ? ShareViewType.ANON_LINKS : ShareViewType.APP
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
@@ -209,19 +166,11 @@ export const ShareSurveyModal = ({
|
||||
}
|
||||
}, [open, modalView]);
|
||||
|
||||
// Ensure active tab is not disabled - if it is, switch to default
|
||||
useEffect(() => {
|
||||
const activeTab = linkTabs.find((tab) => tab.id === activeId);
|
||||
if (activeTab?.disabled) {
|
||||
setActiveId(getDefaultActiveId());
|
||||
}
|
||||
}, [activeId, linkTabs, getDefaultActiveId]);
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
setOpen(open);
|
||||
if (!open) {
|
||||
setShowView("start");
|
||||
setActiveId(getDefaultActiveId());
|
||||
setActiveId(ShareViewType.ANON_LINKS);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -229,7 +178,7 @@ export const ShareSurveyModal = ({
|
||||
setShowView(view);
|
||||
};
|
||||
|
||||
const handleEmbedViewWithTab = (tabId: ShareViaType | ShareSettingsType) => {
|
||||
const handleEmbedViewWithTab = (tabId: ShareViewType) => {
|
||||
setShowView("share");
|
||||
setActiveId(tabId);
|
||||
};
|
||||
|
||||
-428
@@ -1,428 +0,0 @@
|
||||
import { useSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context";
|
||||
import { createI18nString, extractLanguageCodes, getEnabledLanguages } from "@/lib/i18n/utils";
|
||||
import { updateSurveyAction } from "@/modules/survey/editor/actions";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
|
||||
import { TSurvey, TSurveyLanguage } from "@formbricks/types/surveys/types";
|
||||
import { LinkSettingsTab } from "./link-settings-tab";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/lib/i18n/utils", () => ({
|
||||
createI18nString: vi.fn(),
|
||||
extractLanguageCodes: vi.fn(),
|
||||
getEnabledLanguages: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/survey/editor/actions", () => ({
|
||||
updateSurveyAction: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/i18n-utils/src/utils", () => ({
|
||||
getLanguageLabel: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context", () => ({
|
||||
useSurvey: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("react-hot-toast", () => ({
|
||||
default: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/file-input", () => ({
|
||||
FileInput: vi.fn(({ onFileUpload, fileUrl, disabled, id }) => (
|
||||
<div data-testid={id}>
|
||||
<input
|
||||
data-testid="file-input"
|
||||
type="text"
|
||||
value={fileUrl || ""}
|
||||
onChange={(e) => onFileUpload(e.target.value ? [e.target.value] : undefined)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
)),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/button", () => ({
|
||||
Button: vi.fn(({ children, disabled, type, ...props }) => (
|
||||
<button data-testid="save-button" disabled={disabled} type={type} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
)),
|
||||
}));
|
||||
|
||||
const mockSurvey: TSurvey = {
|
||||
id: "test-survey-id",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: "Test Survey",
|
||||
type: "link",
|
||||
environmentId: "test-env-id",
|
||||
status: "inProgress",
|
||||
displayOption: "displayOnce",
|
||||
autoClose: null,
|
||||
triggers: [],
|
||||
recontactDays: null,
|
||||
displayLimit: null,
|
||||
welcomeCard: { enabled: false, timeToFinish: false, showResponseCount: false },
|
||||
questions: [],
|
||||
endings: [],
|
||||
hiddenFields: { enabled: false },
|
||||
displayPercentage: null,
|
||||
autoComplete: null,
|
||||
segment: null,
|
||||
languages: [
|
||||
{ language: { id: "lang1", code: "default" }, default: true, enabled: true } as TSurveyLanguage,
|
||||
{ language: { id: "lang2", code: "en" }, default: false, enabled: true } as TSurveyLanguage,
|
||||
],
|
||||
showLanguageSwitch: false,
|
||||
singleUse: { enabled: false, isEncrypted: false },
|
||||
projectOverwrites: null,
|
||||
surveyClosedMessage: null,
|
||||
delay: 0,
|
||||
isVerifyEmailEnabled: false,
|
||||
createdBy: null,
|
||||
variables: [],
|
||||
followUps: [],
|
||||
runOnDate: null,
|
||||
closeOnDate: null,
|
||||
styling: null,
|
||||
pin: null,
|
||||
recaptcha: null,
|
||||
isSingleResponsePerEmailEnabled: false,
|
||||
isBackButtonHidden: false,
|
||||
metadata: {
|
||||
title: { default: "Test Title", en: "Test Title EN" },
|
||||
description: { default: "Test Description", en: "Test Description EN" },
|
||||
ogImage: "https://example.com/image.png",
|
||||
},
|
||||
};
|
||||
|
||||
const mockSingleLanguageSurvey: TSurvey = {
|
||||
...mockSurvey,
|
||||
languages: [
|
||||
{ language: { id: "lang1", code: "default" }, default: true, enabled: true },
|
||||
] as TSurveyLanguage[],
|
||||
};
|
||||
|
||||
describe("LinkSettingsTab", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
vi.mocked(useSurvey).mockReturnValue({
|
||||
survey: mockSurvey,
|
||||
});
|
||||
|
||||
vi.mocked(getEnabledLanguages).mockReturnValue([
|
||||
{ language: { id: "lang1", code: "default" }, default: true, enabled: true } as TSurveyLanguage,
|
||||
{ language: { id: "lang2", code: "en" }, default: false, enabled: true } as TSurveyLanguage,
|
||||
]);
|
||||
|
||||
vi.mocked(extractLanguageCodes).mockReturnValue(["default", "en"]);
|
||||
|
||||
vi.mocked(createI18nString).mockImplementation((text, languages) => {
|
||||
const result = {};
|
||||
languages.forEach((lang) => {
|
||||
result[lang] = typeof text === "string" ? text : "";
|
||||
});
|
||||
return result;
|
||||
});
|
||||
|
||||
vi.mocked(getLanguageLabel).mockImplementation((code) => {
|
||||
const labels = {
|
||||
default: "Default",
|
||||
en: "English",
|
||||
};
|
||||
return labels[code] || code;
|
||||
});
|
||||
|
||||
vi.mocked(updateSurveyAction).mockResolvedValue({ data: mockSurvey });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders form fields correctly", () => {
|
||||
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
|
||||
|
||||
expect(screen.getByText("common.language")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.surveys.share.link_settings.link_title")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.surveys.share.link_settings.link_description")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.surveys.share.link_settings.preview_image")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("save-button")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("initializes form with existing metadata", () => {
|
||||
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
|
||||
|
||||
const titleInput = screen.getByDisplayValue("Test Title");
|
||||
const descriptionInput = screen.getByDisplayValue("Test Description");
|
||||
|
||||
expect(titleInput).toBeInTheDocument();
|
||||
expect(descriptionInput).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("initializes form with empty values when metadata is undefined", () => {
|
||||
const mockSurveyWithoutMetadata: TSurvey = {
|
||||
...mockSurvey,
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
vi.mocked(useSurvey).mockReturnValue({
|
||||
survey: mockSurveyWithoutMetadata,
|
||||
});
|
||||
|
||||
vi.mocked(createI18nString).mockReturnValue({ default: "", en: "" });
|
||||
|
||||
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
|
||||
|
||||
expect(vi.mocked(createI18nString)).toHaveBeenCalledWith("", ["default", "en"]);
|
||||
});
|
||||
|
||||
test("does not show language selector for single language surveys", () => {
|
||||
vi.mocked(useSurvey).mockReturnValue({
|
||||
survey: mockSingleLanguageSurvey,
|
||||
});
|
||||
|
||||
vi.mocked(getEnabledLanguages).mockReturnValue([
|
||||
{ language: { id: "lang1", code: "default" }, default: true, enabled: true } as TSurveyLanguage,
|
||||
]);
|
||||
|
||||
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
|
||||
|
||||
expect(screen.queryByText("common.language")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows language selector for multi-language surveys", () => {
|
||||
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
|
||||
|
||||
expect(screen.getByText("common.language")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("handles language change correctly", async () => {
|
||||
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
|
||||
|
||||
// Since the Select component is complex to test in JSDOM, let's test that
|
||||
// the language selector is rendered and has the expected options
|
||||
const languageSelect = screen.getByRole("combobox");
|
||||
expect(languageSelect).toBeInTheDocument();
|
||||
|
||||
// Check that the language options are available in the hidden select
|
||||
const hiddenSelect = screen.getByDisplayValue("Default");
|
||||
expect(hiddenSelect).toBeInTheDocument();
|
||||
|
||||
// Check for English option in the select
|
||||
const englishOption = screen
|
||||
.getByDisplayValue("Default")
|
||||
.closest("select")
|
||||
?.querySelector('option[value="en"]');
|
||||
expect(englishOption).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("handles title input change", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
|
||||
|
||||
const titleInput = screen.getByDisplayValue("Test Title");
|
||||
await user.clear(titleInput);
|
||||
await user.type(titleInput, "New Title");
|
||||
|
||||
expect(titleInput).toHaveValue("New Title");
|
||||
});
|
||||
|
||||
test("handles description input change", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
|
||||
|
||||
const descriptionInput = screen.getByDisplayValue("Test Description");
|
||||
await user.clear(descriptionInput);
|
||||
await user.type(descriptionInput, "New Description");
|
||||
|
||||
expect(descriptionInput).toHaveValue("New Description");
|
||||
});
|
||||
|
||||
test("handles file upload", async () => {
|
||||
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
|
||||
|
||||
const fileInput = screen.getByTestId("file-input");
|
||||
fireEvent.change(fileInput, { target: { value: "https://example.com/new-image.png" } });
|
||||
|
||||
expect(fileInput).toHaveValue("https://example.com/new-image.png");
|
||||
});
|
||||
|
||||
test("handles file removal", async () => {
|
||||
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
|
||||
|
||||
const fileInput = screen.getByTestId("file-input");
|
||||
fireEvent.change(fileInput, { target: { value: "" } });
|
||||
|
||||
expect(fileInput).toHaveValue("");
|
||||
});
|
||||
|
||||
test("disables form when isReadOnly is true", () => {
|
||||
render(<LinkSettingsTab isReadOnly={true} locale="en-US" />);
|
||||
|
||||
const titleInput = screen.getByDisplayValue("Test Title");
|
||||
const descriptionInput = screen.getByDisplayValue("Test Description");
|
||||
const saveButton = screen.getByTestId("save-button");
|
||||
|
||||
expect(titleInput).toBeDisabled();
|
||||
expect(descriptionInput).toBeDisabled();
|
||||
expect(saveButton).toBeDisabled();
|
||||
});
|
||||
|
||||
test("submits form successfully", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
|
||||
|
||||
const titleInput = screen.getByDisplayValue("Test Title");
|
||||
await user.clear(titleInput);
|
||||
await user.type(titleInput, "Updated Title");
|
||||
|
||||
const saveButton = screen.getByTestId("save-button");
|
||||
await user.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(vi.mocked(updateSurveyAction)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const callArgs = vi.mocked(updateSurveyAction).mock.calls[0][0];
|
||||
expect(callArgs.metadata.title?.default).toBe("Updated Title");
|
||||
});
|
||||
|
||||
test("handles submission error", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(updateSurveyAction).mockResolvedValue({ data: mockSurvey });
|
||||
|
||||
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
|
||||
|
||||
const titleInput = screen.getByDisplayValue("Test Title");
|
||||
await user.clear(titleInput);
|
||||
await user.type(titleInput, "Updated Title");
|
||||
|
||||
const saveButton = screen.getByTestId("save-button");
|
||||
await user.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(vi.mocked(updateSurveyAction)).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
test("does not submit when form is saving", async () => {
|
||||
const user = userEvent.setup();
|
||||
let resolvePromise: (value: any) => void;
|
||||
const pendingPromise = new Promise((resolve) => {
|
||||
resolvePromise = resolve;
|
||||
});
|
||||
vi.mocked(updateSurveyAction).mockReturnValue(pendingPromise as any);
|
||||
|
||||
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
|
||||
|
||||
// Make form dirty first
|
||||
const titleInput = screen.getByDisplayValue("Test Title");
|
||||
await user.clear(titleInput);
|
||||
await user.type(titleInput, "Modified Title");
|
||||
|
||||
const saveButton = screen.getByTestId("save-button");
|
||||
|
||||
// First click should trigger submission
|
||||
await user.click(saveButton);
|
||||
|
||||
// Wait a bit to ensure the first submission started
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
// Second click should be prevented because form is saving
|
||||
await user.click(saveButton);
|
||||
|
||||
// Should only be called once
|
||||
expect(vi.mocked(updateSurveyAction)).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Clean up by resolving the promise
|
||||
resolvePromise!({ data: mockSurvey });
|
||||
});
|
||||
|
||||
test("does not submit when isReadOnly is true", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LinkSettingsTab isReadOnly={true} locale="en-US" />);
|
||||
|
||||
const saveButton = screen.getByTestId("save-button");
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(vi.mocked(updateSurveyAction)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("handles ogImage correctly in form submission", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
|
||||
|
||||
const fileInput = screen.getByTestId("file-input");
|
||||
fireEvent.change(fileInput, { target: { value: "https://example.com/new-image.png" } });
|
||||
|
||||
const saveButton = screen.getByTestId("save-button");
|
||||
await user.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(vi.mocked(updateSurveyAction)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const callArgs = vi.mocked(updateSurveyAction).mock.calls[0][0];
|
||||
expect(callArgs.metadata.ogImage).toBe("https://example.com/new-image.png");
|
||||
});
|
||||
|
||||
test("handles empty ogImage correctly in form submission", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
|
||||
|
||||
const fileInput = screen.getByTestId("file-input");
|
||||
fireEvent.change(fileInput, { target: { value: "" } });
|
||||
|
||||
const saveButton = screen.getByTestId("save-button");
|
||||
await user.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(vi.mocked(updateSurveyAction)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const callArgs = vi.mocked(updateSurveyAction).mock.calls[0][0];
|
||||
expect(callArgs.metadata.ogImage).toBeUndefined();
|
||||
});
|
||||
|
||||
test("merges form data with existing metadata correctly", async () => {
|
||||
const user = userEvent.setup();
|
||||
const surveyWithPartialMetadata: TSurvey = {
|
||||
...mockSurvey,
|
||||
metadata: {
|
||||
title: { default: "Existing Title" },
|
||||
description: { default: "Existing Description" },
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(useSurvey).mockReturnValue({
|
||||
survey: surveyWithPartialMetadata,
|
||||
});
|
||||
|
||||
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
|
||||
|
||||
const titleInput = screen.getByDisplayValue("Existing Title");
|
||||
await user.clear(titleInput);
|
||||
await user.type(titleInput, "New Title");
|
||||
|
||||
const saveButton = screen.getByTestId("save-button");
|
||||
await user.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(vi.mocked(updateSurveyAction)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const callArgs = vi.mocked(updateSurveyAction).mock.calls[0][0];
|
||||
expect(callArgs.metadata.title?.default).toBe("New Title");
|
||||
expect(callArgs.metadata.description?.default).toBe("Existing Description");
|
||||
});
|
||||
});
|
||||
-257
@@ -1,257 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context";
|
||||
import { createI18nString, extractLanguageCodes, getEnabledLanguages } from "@/lib/i18n/utils";
|
||||
import { updateSurveyAction } from "@/modules/survey/editor/actions";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { FileInput } from "@/modules/ui/components/file-input";
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormError,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormProvider,
|
||||
} from "@/modules/ui/components/form";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
|
||||
import { TI18nString, TSurvey, TSurveyMetadata } from "@formbricks/types/surveys/types";
|
||||
|
||||
interface LinkSettingsTabProps {
|
||||
isReadOnly: boolean;
|
||||
locale: string;
|
||||
}
|
||||
|
||||
interface LinkSettingsFormData {
|
||||
title: TI18nString;
|
||||
description: TI18nString;
|
||||
ogImage?: string;
|
||||
}
|
||||
|
||||
export const LinkSettingsTab = ({ isReadOnly, locale }: LinkSettingsTabProps) => {
|
||||
const { t } = useTranslate();
|
||||
const { survey } = useSurvey();
|
||||
const enabledLanguages = getEnabledLanguages(survey.languages);
|
||||
const hasMultipleLanguages = enabledLanguages.length > 1;
|
||||
|
||||
// Set default language - use 'default' if no multi-language is set up
|
||||
const defaultLanguageCode = hasMultipleLanguages
|
||||
? survey.languages.find((lang) => lang.default)?.language.code || "default"
|
||||
: "default";
|
||||
|
||||
const languageCodes = useMemo(() => extractLanguageCodes(survey.languages), [survey.languages]);
|
||||
|
||||
const [selectedLanguageCode, setSelectedLanguageCode] = useState(defaultLanguageCode);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// Current language code for metadata storage
|
||||
const currentLangCode = selectedLanguageCode === defaultLanguageCode ? "default" : selectedLanguageCode;
|
||||
|
||||
// Initialize form with current values - memoize to prevent re-initialization
|
||||
const initialFormData = useMemo(() => {
|
||||
const metadata = survey.metadata || {
|
||||
title: createI18nString("", languageCodes),
|
||||
description: createI18nString("", languageCodes),
|
||||
ogImage: undefined,
|
||||
};
|
||||
|
||||
return {
|
||||
title: metadata.title || createI18nString("", languageCodes),
|
||||
description: metadata.description || createI18nString("", languageCodes),
|
||||
ogImage: metadata.ogImage || "",
|
||||
};
|
||||
}, [survey.metadata, languageCodes]);
|
||||
|
||||
const form = useForm<LinkSettingsFormData>({
|
||||
defaultValues: initialFormData,
|
||||
});
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
setValue,
|
||||
reset,
|
||||
formState: { isDirty },
|
||||
} = form;
|
||||
|
||||
const handleFileUpload = (urls: string[] | undefined) => {
|
||||
if (urls && urls.length > 0) {
|
||||
setValue("ogImage", urls[0], { shouldDirty: true });
|
||||
} else {
|
||||
// Handle removal of the image
|
||||
setValue("ogImage", "", { shouldDirty: true });
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async (data: LinkSettingsFormData) => {
|
||||
if (isSaving || isReadOnly) return;
|
||||
|
||||
setIsSaving(true);
|
||||
|
||||
// Get current linkMetadata
|
||||
const currentSurveyMetadata = survey.metadata || {
|
||||
title: createI18nString("", languageCodes),
|
||||
description: createI18nString("", languageCodes),
|
||||
ogImage: undefined,
|
||||
};
|
||||
|
||||
// Merge form data with existing linkMetadata
|
||||
const updatedTitle: TI18nString = {
|
||||
...currentSurveyMetadata.title,
|
||||
...data.title,
|
||||
};
|
||||
|
||||
const updatedDescription: TI18nString = {
|
||||
...currentSurveyMetadata.description,
|
||||
...data.description,
|
||||
};
|
||||
|
||||
const updatedSurveyMetadata: TSurveyMetadata = {
|
||||
title: updatedTitle,
|
||||
description: updatedDescription,
|
||||
ogImage: data.ogImage || undefined,
|
||||
};
|
||||
|
||||
const updatedSurvey: TSurvey = {
|
||||
...survey,
|
||||
metadata: updatedSurveyMetadata,
|
||||
};
|
||||
|
||||
const result = await updateSurveyAction(updatedSurvey);
|
||||
|
||||
if (result?.data) {
|
||||
toast.success(t("environments.surveys.edit.settings_saved_successfully"));
|
||||
reset(data);
|
||||
} else {
|
||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||
}
|
||||
|
||||
setIsSaving(false);
|
||||
};
|
||||
|
||||
const inputFields: {
|
||||
name: "title" | "description";
|
||||
label: string;
|
||||
description: string;
|
||||
placeholder: string;
|
||||
}[] = [
|
||||
{
|
||||
name: "title",
|
||||
label: t("environments.surveys.share.link_settings.link_title"),
|
||||
description: t("environments.surveys.share.link_settings.link_title_description"),
|
||||
placeholder: survey.name,
|
||||
},
|
||||
{
|
||||
name: "description",
|
||||
label: t("environments.surveys.share.link_settings.link_description"),
|
||||
description: t("environments.surveys.share.link_settings.link_description_description"),
|
||||
placeholder: "Please complete this survey.",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="px-1">
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
{/* Language Selection - only show if survey has multiple languages */}
|
||||
{hasMultipleLanguages && (
|
||||
<FormItem>
|
||||
<FormLabel>{t("common.language")}</FormLabel>
|
||||
<FormControl>
|
||||
<Select value={selectedLanguageCode} onValueChange={setSelectedLanguageCode}>
|
||||
<SelectTrigger className="bg-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{enabledLanguages.map((lang) => (
|
||||
<SelectItem key={lang.language.code} value={lang.language.code} className="bg-white">
|
||||
{getLanguageLabel(lang.language.code, locale)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("environments.surveys.share.link_settings.language_help_text")}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
|
||||
{inputFields.map((inputField) => {
|
||||
return (
|
||||
<FormField
|
||||
key={inputField.name}
|
||||
control={form.control}
|
||||
name={inputField.name}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{inputField.label}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
value={field.value[currentLangCode] || ""}
|
||||
className="bg-white"
|
||||
onChange={(e) => {
|
||||
const updatedValue = {
|
||||
...field.value,
|
||||
[currentLangCode]: e.target.value,
|
||||
};
|
||||
field.onChange(updatedValue);
|
||||
}}
|
||||
placeholder={inputField.placeholder}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{inputField.description}</FormDescription>
|
||||
<FormError />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Preview Image */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="ogImage"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("environments.surveys.share.link_settings.preview_image")}</FormLabel>
|
||||
<FormControl>
|
||||
<FileInput
|
||||
id={`og-image-upload-${survey.id}`}
|
||||
allowedFileExtensions={["png", "jpeg", "jpg", "webp"]}
|
||||
environmentId={survey.environmentId}
|
||||
onFileUpload={handleFileUpload}
|
||||
fileUrl={field.value}
|
||||
maxSizeInMB={5}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("environments.surveys.share.link_settings.preview_image_description")}
|
||||
</FormDescription>
|
||||
<FormError />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Save Button */}
|
||||
<Button type="submit" disabled={isSaving || isReadOnly || !isDirty}>
|
||||
{isSaving ? t("common.saving") : t("common.save")}
|
||||
</Button>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+24
-99
@@ -3,7 +3,7 @@ import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { LinkTabsType, ShareSettingsType, ShareViaType } from "../../types/share";
|
||||
import { ShareViewType } from "../../types/share";
|
||||
import { ShareView } from "./share-view";
|
||||
|
||||
// Mock sidebar components
|
||||
@@ -161,7 +161,6 @@ vi.mock("lucide-react", () => ({
|
||||
Share2Icon: () => <div data-testid="share2-icon">Share2Icon</div>,
|
||||
SquareStack: () => <div data-testid="square-stack-icon">SquareStack</div>,
|
||||
UserIcon: () => <div data-testid="user-icon">UserIcon</div>,
|
||||
Settings: () => <div data-testid="settings-icon">Settings</div>,
|
||||
}));
|
||||
|
||||
// Mock tooltip and typography components
|
||||
@@ -265,25 +264,9 @@ const mockSurvey = {
|
||||
styling: null,
|
||||
} as any;
|
||||
|
||||
// Mock LinkSettingsTab
|
||||
const MockLinkSettingsTab = ({
|
||||
survey,
|
||||
isReadOnly,
|
||||
locale,
|
||||
}: {
|
||||
survey: any;
|
||||
isReadOnly: boolean;
|
||||
locale: string;
|
||||
}) => (
|
||||
<div data-testid="link-settings-tab">
|
||||
LinkSettingsTab Content for {survey.id} - ReadOnly: {isReadOnly.toString()} - Locale: {locale}
|
||||
</div>
|
||||
);
|
||||
|
||||
const mockTabs = [
|
||||
{
|
||||
id: ShareViaType.EMAIL,
|
||||
type: LinkTabsType.SHARE_VIA,
|
||||
id: ShareViewType.EMAIL,
|
||||
label: "Email",
|
||||
icon: () => <div data-testid="email-tab-icon" />,
|
||||
componentType: MockEmailTab,
|
||||
@@ -292,8 +275,7 @@ const mockTabs = [
|
||||
description: "Send survey via email",
|
||||
},
|
||||
{
|
||||
id: ShareViaType.WEBSITE_EMBED,
|
||||
type: LinkTabsType.SHARE_VIA,
|
||||
id: ShareViewType.WEBSITE_EMBED,
|
||||
label: "Website Embed",
|
||||
icon: () => <div data-testid="website-embed-tab-icon" />,
|
||||
componentType: MockWebsiteEmbedTab,
|
||||
@@ -302,8 +284,7 @@ const mockTabs = [
|
||||
description: "Embed survey on your website",
|
||||
},
|
||||
{
|
||||
id: ShareViaType.DYNAMIC_POPUP,
|
||||
type: LinkTabsType.SHARE_VIA,
|
||||
id: ShareViewType.DYNAMIC_POPUP,
|
||||
label: "Dynamic Popup",
|
||||
icon: () => <div data-testid="dynamic-popup-tab-icon" />,
|
||||
componentType: MockDynamicPopupTab,
|
||||
@@ -312,8 +293,7 @@ const mockTabs = [
|
||||
description: "Show survey as popup",
|
||||
},
|
||||
{
|
||||
id: ShareViaType.ANON_LINKS,
|
||||
type: LinkTabsType.SHARE_VIA,
|
||||
id: ShareViewType.ANON_LINKS,
|
||||
label: "Anonymous Links",
|
||||
icon: () => <div data-testid="anonymous-links-tab-icon" />,
|
||||
componentType: MockAnonymousLinksTab,
|
||||
@@ -328,8 +308,7 @@ const mockTabs = [
|
||||
description: "Share anonymous links",
|
||||
},
|
||||
{
|
||||
id: ShareViaType.QR_CODE,
|
||||
type: LinkTabsType.SHARE_VIA,
|
||||
id: ShareViewType.QR_CODE,
|
||||
label: "QR Code",
|
||||
icon: () => <div data-testid="qr-code-tab-icon" />,
|
||||
componentType: MockQRCodeTab,
|
||||
@@ -338,8 +317,7 @@ const mockTabs = [
|
||||
description: "Generate QR code",
|
||||
},
|
||||
{
|
||||
id: ShareViaType.PERSONAL_LINKS,
|
||||
type: LinkTabsType.SHARE_VIA,
|
||||
id: ShareViewType.PERSONAL_LINKS,
|
||||
label: "Personal Links",
|
||||
icon: () => <div data-testid="personal-links-tab-icon" />,
|
||||
componentType: MockPersonalLinksTab,
|
||||
@@ -348,8 +326,7 @@ const mockTabs = [
|
||||
description: "Create personal links",
|
||||
},
|
||||
{
|
||||
id: ShareViaType.SOCIAL_MEDIA,
|
||||
type: LinkTabsType.SHARE_VIA,
|
||||
id: ShareViewType.SOCIAL_MEDIA,
|
||||
label: "Social Media",
|
||||
icon: () => <div data-testid="social-media-tab-icon" />,
|
||||
componentType: MockSocialMediaTab,
|
||||
@@ -357,21 +334,11 @@ const mockTabs = [
|
||||
title: "Social Media",
|
||||
description: "Share on social media",
|
||||
},
|
||||
{
|
||||
id: ShareSettingsType.LINK_SETTINGS,
|
||||
type: LinkTabsType.SHARE_SETTING,
|
||||
label: "Link Settings",
|
||||
icon: () => <div data-testid="link-settings-tab-icon" />,
|
||||
componentType: MockLinkSettingsTab,
|
||||
componentProps: { survey: mockSurvey, isReadOnly: false, locale: "en-US" },
|
||||
title: "Link Settings",
|
||||
description: "Configure link settings",
|
||||
},
|
||||
];
|
||||
|
||||
const defaultProps = {
|
||||
tabs: mockTabs,
|
||||
activeId: ShareViaType.EMAIL,
|
||||
activeId: ShareViewType.EMAIL,
|
||||
setActiveId: vi.fn(),
|
||||
};
|
||||
|
||||
@@ -403,51 +370,43 @@ describe("ShareView", () => {
|
||||
});
|
||||
|
||||
test("renders desktop tabs", () => {
|
||||
render(<ShareView {...defaultProps} activeId={ShareViaType.EMAIL} />);
|
||||
render(<ShareView {...defaultProps} activeId={ShareViewType.EMAIL} />);
|
||||
|
||||
// Desktop sidebar should be rendered
|
||||
const sidebarLabel = screen.getByText("environments.surveys.share.share_view_title");
|
||||
expect(sidebarLabel).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders share settings section", () => {
|
||||
render(<ShareView {...defaultProps} />);
|
||||
|
||||
// Share settings section should be rendered
|
||||
const shareSettingsLabel = screen.getByText("environments.surveys.share.share_settings_title");
|
||||
expect(shareSettingsLabel).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("calls setActiveId when a tab is clicked (desktop)", async () => {
|
||||
render(<ShareView {...defaultProps} activeId={ShareViaType.EMAIL} />);
|
||||
render(<ShareView {...defaultProps} activeId={ShareViewType.EMAIL} />);
|
||||
|
||||
const websiteEmbedTabButton = screen.getByLabelText("Website Embed");
|
||||
await userEvent.click(websiteEmbedTabButton);
|
||||
expect(defaultProps.setActiveId).toHaveBeenCalledWith(ShareViaType.WEBSITE_EMBED);
|
||||
expect(defaultProps.setActiveId).toHaveBeenCalledWith(ShareViewType.WEBSITE_EMBED);
|
||||
});
|
||||
|
||||
test("renders EmailTab when activeId is EMAIL", () => {
|
||||
render(<ShareView {...defaultProps} activeId={ShareViaType.EMAIL} />);
|
||||
render(<ShareView {...defaultProps} activeId={ShareViewType.EMAIL} />);
|
||||
expect(screen.getByTestId("email-tab")).toBeInTheDocument();
|
||||
expect(screen.getByText("EmailTab Content for survey1 with test@example.com")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders WebsiteEmbedTab when activeId is WEBSITE_EMBED", () => {
|
||||
render(<ShareView {...defaultProps} activeId={ShareViaType.WEBSITE_EMBED} />);
|
||||
render(<ShareView {...defaultProps} activeId={ShareViewType.WEBSITE_EMBED} />);
|
||||
expect(screen.getByTestId("tab-container")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("website-embed-tab")).toBeInTheDocument();
|
||||
expect(screen.getByText("WebsiteEmbedTab Content for http://example.com/survey1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders DynamicPopupTab when activeId is DYNAMIC_POPUP", () => {
|
||||
render(<ShareView {...defaultProps} activeId={ShareViaType.DYNAMIC_POPUP} />);
|
||||
render(<ShareView {...defaultProps} activeId={ShareViewType.DYNAMIC_POPUP} />);
|
||||
expect(screen.getByTestId("tab-container")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dynamic-popup-tab")).toBeInTheDocument();
|
||||
expect(screen.getByText("DynamicPopupTab Content for survey1 in env1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders AnonymousLinksTab when activeId is ANON_LINKS", () => {
|
||||
render(<ShareView {...defaultProps} activeId={ShareViaType.ANON_LINKS} />);
|
||||
render(<ShareView {...defaultProps} activeId={ShareViewType.ANON_LINKS} />);
|
||||
expect(screen.getByTestId("anonymous-links-tab")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("AnonymousLinksTab Content for survey1 at http://example.com/survey1")
|
||||
@@ -455,24 +414,16 @@ describe("ShareView", () => {
|
||||
});
|
||||
|
||||
test("renders QRCodeTab when activeId is QR_CODE", () => {
|
||||
render(<ShareView {...defaultProps} activeId={ShareViaType.QR_CODE} />);
|
||||
render(<ShareView {...defaultProps} activeId={ShareViewType.QR_CODE} />);
|
||||
expect(screen.getByTestId("qr-code-tab")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders LinkSettingsTab when activeId is LINK_SETTINGS", () => {
|
||||
render(<ShareView {...defaultProps} activeId={ShareSettingsType.LINK_SETTINGS} />);
|
||||
expect(screen.getByTestId("link-settings-tab")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("LinkSettingsTab Content for survey1 - ReadOnly: false - Locale: en-US")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders nothing when activeId doesn't match any tab", () => {
|
||||
// Create a special case with no matching tab
|
||||
const propsWithNoMatchingTab = {
|
||||
...defaultProps,
|
||||
tabs: mockTabs.slice(0, 3), // Only include first 3 tabs
|
||||
activeId: ShareViaType.SOCIAL_MEDIA, // Use a tab not in the subset
|
||||
activeId: ShareViewType.SOCIAL_MEDIA, // Use a tab not in the subset
|
||||
};
|
||||
|
||||
render(<ShareView {...propsWithNoMatchingTab} />);
|
||||
@@ -485,17 +436,16 @@ describe("ShareView", () => {
|
||||
expect(screen.queryByTestId("qr-code-tab")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("personal-links-tab")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("social-media-tab")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("link-settings-tab")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders PersonalLinksTab when activeId is PERSONAL_LINKS", () => {
|
||||
render(<ShareView {...defaultProps} activeId={ShareViaType.PERSONAL_LINKS} />);
|
||||
render(<ShareView {...defaultProps} activeId={ShareViewType.PERSONAL_LINKS} />);
|
||||
expect(screen.getByTestId("personal-links-tab")).toBeInTheDocument();
|
||||
expect(screen.getByText("PersonalLinksTab Content for survey1 in env1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders SocialMediaTab when activeId is SOCIAL_MEDIA", () => {
|
||||
render(<ShareView {...defaultProps} activeId={ShareViaType.SOCIAL_MEDIA} />);
|
||||
render(<ShareView {...defaultProps} activeId={ShareViewType.SOCIAL_MEDIA} />);
|
||||
expect(screen.getByTestId("social-media-tab")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("SocialMediaTab Content for Test Survey at http://example.com/survey1")
|
||||
@@ -503,7 +453,7 @@ describe("ShareView", () => {
|
||||
});
|
||||
|
||||
test("calls setActiveId when a responsive tab is clicked", async () => {
|
||||
render(<ShareView {...defaultProps} activeId={ShareViaType.EMAIL} />);
|
||||
render(<ShareView {...defaultProps} activeId={ShareViewType.EMAIL} />);
|
||||
|
||||
// Get responsive buttons - these are Button components containing icons
|
||||
const responsiveButtons = screen.getAllByTestId("website-embed-tab-icon");
|
||||
@@ -517,12 +467,12 @@ describe("ShareView", () => {
|
||||
|
||||
if (responsiveButton) {
|
||||
await userEvent.click(responsiveButton);
|
||||
expect(defaultProps.setActiveId).toHaveBeenCalledWith(ShareViaType.WEBSITE_EMBED);
|
||||
expect(defaultProps.setActiveId).toHaveBeenCalledWith(ShareViewType.WEBSITE_EMBED);
|
||||
}
|
||||
});
|
||||
|
||||
test("applies active styles to the active tab (desktop)", () => {
|
||||
render(<ShareView {...defaultProps} activeId={ShareViaType.EMAIL} />);
|
||||
render(<ShareView {...defaultProps} activeId={ShareViewType.EMAIL} />);
|
||||
|
||||
const emailTabButton = screen.getByLabelText("Email");
|
||||
expect(emailTabButton).toHaveClass("bg-slate-100");
|
||||
@@ -535,7 +485,7 @@ describe("ShareView", () => {
|
||||
});
|
||||
|
||||
test("applies active styles to the active tab (responsive)", () => {
|
||||
render(<ShareView {...defaultProps} activeId={ShareViaType.EMAIL} />);
|
||||
render(<ShareView {...defaultProps} activeId={ShareViewType.EMAIL} />);
|
||||
|
||||
// Get responsive buttons - these are Button components with ghost variant
|
||||
const responsiveButtons = screen.getAllByTestId("email-tab-icon");
|
||||
@@ -587,7 +537,6 @@ describe("ShareView", () => {
|
||||
"qr-code-tab-icon",
|
||||
"personal-links-tab-icon",
|
||||
"social-media-tab-icon",
|
||||
"link-settings-tab-icon",
|
||||
];
|
||||
|
||||
expectedTestIds.forEach((testId) => {
|
||||
@@ -599,28 +548,4 @@ describe("ShareView", () => {
|
||||
expect(responsiveButton).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
test("separates share via and share settings tabs correctly", () => {
|
||||
render(<ShareView {...defaultProps} />);
|
||||
|
||||
// Check that share via tabs are rendered
|
||||
expect(screen.getByLabelText("Email")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Website Embed")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Anonymous Links")).toBeInTheDocument();
|
||||
|
||||
// Check that share settings tabs are rendered
|
||||
expect(screen.getByLabelText("Link Settings")).toBeInTheDocument();
|
||||
|
||||
// Check that both sections have their titles
|
||||
expect(screen.getByText("environments.surveys.share.share_view_title")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.surveys.share.share_settings_title")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("calls setActiveId when a share settings tab is clicked", async () => {
|
||||
render(<ShareView {...defaultProps} activeId={ShareViaType.EMAIL} />);
|
||||
|
||||
const linkSettingsTabButton = screen.getByLabelText("Link Settings");
|
||||
await userEvent.click(linkSettingsTabButton);
|
||||
expect(defaultProps.setActiveId).toHaveBeenCalledWith(ShareSettingsType.LINK_SETTINGS);
|
||||
});
|
||||
});
|
||||
|
||||
+31
-56
@@ -1,11 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { TabContainer } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/tab-container";
|
||||
import {
|
||||
LinkTabsType,
|
||||
ShareSettingsType,
|
||||
ShareViaType,
|
||||
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/types/share";
|
||||
import { ShareViewType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/types/share";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
@@ -26,18 +22,16 @@ import { useEffect, useState } from "react";
|
||||
|
||||
interface ShareViewProps {
|
||||
tabs: Array<{
|
||||
id: ShareViaType | ShareSettingsType;
|
||||
type: LinkTabsType;
|
||||
id: ShareViewType;
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
componentType: React.ComponentType<any>;
|
||||
componentProps: any;
|
||||
title: string;
|
||||
description?: string;
|
||||
disabled?: boolean;
|
||||
}>;
|
||||
activeId: ShareViaType | ShareSettingsType;
|
||||
setActiveId: React.Dispatch<React.SetStateAction<ShareViaType | ShareSettingsType>>;
|
||||
activeId: ShareViewType;
|
||||
setActiveId: React.Dispatch<React.SetStateAction<ShareViewType>>;
|
||||
}
|
||||
|
||||
export const ShareView = ({ tabs, activeId, setActiveId }: ShareViewProps) => {
|
||||
@@ -69,21 +63,6 @@ export const ShareView = ({ tabs, activeId, setActiveId }: ShareViewProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
const shareSettingsTabs = tabs.filter((tab) => tab.type === LinkTabsType.SHARE_SETTING);
|
||||
const shareViaTabs = tabs.filter((tab) => tab.type === LinkTabsType.SHARE_VIA);
|
||||
const sideBarGroups = [
|
||||
{
|
||||
id: LinkTabsType.SHARE_VIA,
|
||||
label: t("environments.surveys.share.share_view_title"),
|
||||
tabs: shareViaTabs,
|
||||
},
|
||||
{
|
||||
id: LinkTabsType.SHARE_SETTING,
|
||||
label: t("environments.surveys.share.share_settings_title"),
|
||||
tabs: shareSettingsTabs,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="h-full">
|
||||
<div className={`flex h-full lg:grid lg:grid-cols-4`}>
|
||||
@@ -97,35 +76,32 @@ export const ShareView = ({ tabs, activeId, setActiveId }: ShareViewProps) => {
|
||||
}>
|
||||
<Sidebar className="relative h-full p-0" variant="inset" collapsible="icon">
|
||||
<SidebarContent className="h-full rounded-l-lg border-r border-slate-200 bg-white p-4">
|
||||
{sideBarGroups.map((group) => (
|
||||
<SidebarGroup className="p-0" key={group.id}>
|
||||
<SidebarGroupLabel>
|
||||
<Small className="text-xs text-slate-500">{group.label}</Small>
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu className="flex flex-col gap-1">
|
||||
{group.tabs.map((tab) => (
|
||||
<SidebarMenuItem key={tab.id}>
|
||||
<SidebarMenuButton
|
||||
onClick={() => setActiveId(tab.id)}
|
||||
className={cn(
|
||||
"flex w-full justify-start rounded-md p-2 text-slate-600 hover:bg-slate-100 hover:text-slate-900",
|
||||
tab.id === activeId && !tab.disabled
|
||||
? "bg-slate-100 font-medium text-slate-900"
|
||||
: "text-slate-700"
|
||||
)}
|
||||
tooltip={tab.label}
|
||||
isActive={tab.id === activeId}
|
||||
disabled={tab.disabled}>
|
||||
<tab.icon className="h-4 w-4 text-slate-700" />
|
||||
<span>{tab.label}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
))}
|
||||
<SidebarGroup className="p-0">
|
||||
<SidebarGroupLabel>
|
||||
<Small className="text-xs text-slate-500">
|
||||
{t("environments.surveys.share.share_view_title")}
|
||||
</Small>
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu className="flex flex-col gap-1">
|
||||
{tabs.map((tab) => (
|
||||
<SidebarMenuItem key={tab.id}>
|
||||
<SidebarMenuButton
|
||||
onClick={() => setActiveId(tab.id)}
|
||||
className={cn(
|
||||
"flex w-full justify-start rounded-md p-2 text-slate-600 hover:bg-slate-100 hover:text-slate-900",
|
||||
tab.id === activeId ? "bg-slate-100 font-medium text-slate-900" : "text-slate-700"
|
||||
)}
|
||||
tooltip={tab.label}
|
||||
isActive={tab.id === activeId}>
|
||||
<tab.icon className="h-4 w-4 text-slate-700" />
|
||||
<span>{tab.label}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
</SidebarProvider>
|
||||
@@ -138,10 +114,9 @@ export const ShareView = ({ tabs, activeId, setActiveId }: ShareViewProps) => {
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setActiveId(tab.id)}
|
||||
disabled={tab.disabled}
|
||||
className={cn(
|
||||
"rounded-md px-4 py-2",
|
||||
tab.id === activeId && !tab.disabled
|
||||
tab.id === activeId
|
||||
? "bg-white text-slate-900 shadow-sm hover:bg-white"
|
||||
: "border-transparent text-slate-700 hover:text-slate-900"
|
||||
)}>
|
||||
|
||||
+1
-10
@@ -1,4 +1,4 @@
|
||||
export enum ShareViaType {
|
||||
export enum ShareViewType {
|
||||
ANON_LINKS = "anon-links",
|
||||
PERSONAL_LINKS = "personal-links",
|
||||
EMAIL = "email",
|
||||
@@ -9,12 +9,3 @@ export enum ShareViaType {
|
||||
SOCIAL_MEDIA = "social-media",
|
||||
QR_CODE = "qr-code",
|
||||
}
|
||||
|
||||
export enum ShareSettingsType {
|
||||
LINK_SETTINGS = "link-settings",
|
||||
}
|
||||
|
||||
export enum LinkTabsType {
|
||||
SHARE_VIA = "share_via",
|
||||
SHARE_SETTING = "share_setting",
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
const Loading = () => {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="flex h-1/2 w-1/4 flex-col">
|
||||
<div className="ph-no-capture h-16 w-1/3 animate-pulse rounded-lg bg-slate-200 font-medium text-slate-900"></div>
|
||||
<div className="ph-no-capture mt-4 h-full animate-pulse rounded-lg bg-slate-200 text-slate-900"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Loading;
|
||||
@@ -0,0 +1,59 @@
|
||||
import { getShortUrl } from "@/lib/shortUrl/service";
|
||||
import { getMetadataForLinkSurvey } from "@/modules/survey/link/metadata";
|
||||
import type { Metadata } from "next";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TShortUrl, ZShortUrlId } from "@formbricks/types/short-url";
|
||||
|
||||
export const generateMetadata = async (props): Promise<Metadata> => {
|
||||
const params = await props.params;
|
||||
if (!params.shortUrlId) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
if (ZShortUrlId.safeParse(params.shortUrlId).success !== true) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
try {
|
||||
const shortUrl = await getShortUrl(params.shortUrlId);
|
||||
|
||||
if (!shortUrl) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const surveyId = shortUrl.url.substring(shortUrl.url.lastIndexOf("/") + 1);
|
||||
return getMetadataForLinkSurvey(surveyId);
|
||||
} catch (error) {
|
||||
notFound();
|
||||
}
|
||||
};
|
||||
|
||||
const Page = async (props) => {
|
||||
const params = await props.params;
|
||||
if (!params.shortUrlId) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
if (ZShortUrlId.safeParse(params.shortUrlId).success !== true) {
|
||||
// return not found if unable to parse short url id
|
||||
notFound();
|
||||
}
|
||||
|
||||
let shortUrl: TShortUrl | null = null;
|
||||
|
||||
try {
|
||||
shortUrl = await getShortUrl(params.shortUrlId);
|
||||
} catch (error) {
|
||||
logger.error(error, "Could not fetch short url");
|
||||
notFound();
|
||||
}
|
||||
|
||||
if (shortUrl) {
|
||||
redirect(shortUrl.url);
|
||||
}
|
||||
|
||||
notFound();
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -253,6 +253,7 @@ export const mockResponse: TResponse = {
|
||||
contactAttributes: {},
|
||||
meta: {},
|
||||
finished: true,
|
||||
notes: [],
|
||||
singleUseId: null,
|
||||
tags: [],
|
||||
displayId: null,
|
||||
|
||||
@@ -90,6 +90,7 @@ const mockPipelineInput = {
|
||||
personAttributes: {},
|
||||
singleUseId: null,
|
||||
personId: "person1",
|
||||
notes: [],
|
||||
tags: [],
|
||||
variables: {
|
||||
[variableId]: "Variable Value",
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
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";
|
||||
|
||||
export const authenticateRequest = async (request: NextRequest): Promise<TAuthenticationApiKey | null> => {
|
||||
export const authenticateRequest = async (request: Request): Promise<TAuthenticationApiKey | null> => {
|
||||
const apiKey = request.headers.get("x-api-key");
|
||||
if (!apiKey) return null;
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import { getSyncSurveys } from "@/app/api/v1/client/[environmentId]/app/sync/lib
|
||||
import { replaceAttributeRecall } from "@/app/api/v1/client/[environmentId]/app/sync/lib/utils";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { getActionClasses } from "@/lib/actionClass/service";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getEnvironment, updateEnvironment } from "@/lib/environment/service";
|
||||
@@ -22,180 +21,168 @@ import { COLOR_DEFAULTS } from "@/lib/styling/constants";
|
||||
import { NextRequest, userAgent } from "next/server";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TJsPeopleUserIdInput, ZJsPeopleUserIdInput } from "@formbricks/types/js";
|
||||
import { ZJsPeopleUserIdInput } from "@formbricks/types/js";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
|
||||
const validateInput = (
|
||||
environmentId: string,
|
||||
userId: string
|
||||
): { isValid: true; data: TJsPeopleUserIdInput } | { isValid: false; error: Response } => {
|
||||
const inputValidation = ZJsPeopleUserIdInput.safeParse({ environmentId, userId });
|
||||
if (!inputValidation.success) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: responses.badRequestResponse(
|
||||
"Fields are missing or incorrectly formatted",
|
||||
transformErrorToDetails(inputValidation.error),
|
||||
true
|
||||
),
|
||||
};
|
||||
}
|
||||
return { isValid: true, data: inputValidation.data };
|
||||
};
|
||||
|
||||
const checkResponseLimit = async (environmentId: string): Promise<boolean> => {
|
||||
if (!IS_FORMBRICKS_CLOUD) return false;
|
||||
|
||||
const organization = await getOrganizationByEnvironmentId(environmentId);
|
||||
if (!organization) {
|
||||
logger.error({ environmentId }, "Organization does not exist");
|
||||
|
||||
// fail closed if the organization does not exist
|
||||
return true;
|
||||
}
|
||||
|
||||
const currentResponseCount = await getMonthlyOrganizationResponseCount(organization.id);
|
||||
const monthlyResponseLimit = organization.billing.limits.monthly.responses;
|
||||
const isLimitReached = monthlyResponseLimit !== null && currentResponseCount >= monthlyResponseLimit;
|
||||
|
||||
if (isLimitReached) {
|
||||
try {
|
||||
await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, {
|
||||
plan: organization.billing.plan,
|
||||
limits: {
|
||||
projects: null,
|
||||
monthly: { responses: monthlyResponseLimit, miu: null },
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error({ error }, `Error sending plan limits reached event to Posthog`);
|
||||
}
|
||||
}
|
||||
|
||||
return isLimitReached;
|
||||
};
|
||||
|
||||
export const OPTIONS = async (): Promise<Response> => {
|
||||
return responses.successResponse({}, true);
|
||||
};
|
||||
|
||||
export const GET = withV1ApiWrapper({
|
||||
handler: async ({
|
||||
req,
|
||||
props,
|
||||
}: {
|
||||
req: NextRequest;
|
||||
props: { params: Promise<{ environmentId: string; userId: string }> };
|
||||
}) => {
|
||||
const params = await props.params;
|
||||
try {
|
||||
const { device } = userAgent(req);
|
||||
export const GET = async (
|
||||
request: NextRequest,
|
||||
props: {
|
||||
params: Promise<{
|
||||
environmentId: string;
|
||||
userId: string;
|
||||
}>;
|
||||
}
|
||||
): Promise<Response> => {
|
||||
const params = await props.params;
|
||||
try {
|
||||
const { device } = userAgent(request);
|
||||
|
||||
// validate using zod
|
||||
const validation = validateInput(params.environmentId, params.userId);
|
||||
if (!validation.isValid) {
|
||||
return { response: validation.error };
|
||||
}
|
||||
// validate using zod
|
||||
const inputValidation = ZJsPeopleUserIdInput.safeParse({
|
||||
environmentId: params.environmentId,
|
||||
userId: params.userId,
|
||||
});
|
||||
if (!inputValidation.success) {
|
||||
return responses.badRequestResponse(
|
||||
"Fields are missing or incorrectly formatted",
|
||||
transformErrorToDetails(inputValidation.error),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
const { environmentId, userId } = validation.data;
|
||||
const { environmentId, userId } = inputValidation.data;
|
||||
|
||||
const environment = await getEnvironment(environmentId);
|
||||
if (!environment) {
|
||||
throw new Error("Environment does not exist");
|
||||
}
|
||||
const environment = await getEnvironment(environmentId);
|
||||
if (!environment) {
|
||||
throw new Error("Environment does not exist");
|
||||
}
|
||||
|
||||
const project = await getProjectByEnvironmentId(environmentId);
|
||||
const project = await getProjectByEnvironmentId(environmentId);
|
||||
|
||||
if (!project) {
|
||||
throw new Error("Project not found");
|
||||
}
|
||||
if (!project) {
|
||||
throw new Error("Project not found");
|
||||
}
|
||||
|
||||
if (!environment.appSetupCompleted) {
|
||||
await Promise.all([
|
||||
updateEnvironment(environment.id, { appSetupCompleted: true }),
|
||||
capturePosthogEnvironmentEvent(environmentId, "app setup completed"),
|
||||
]);
|
||||
}
|
||||
if (!environment.appSetupCompleted) {
|
||||
await Promise.all([
|
||||
updateEnvironment(environment.id, { appSetupCompleted: true }),
|
||||
capturePosthogEnvironmentEvent(environmentId, "app setup completed"),
|
||||
]);
|
||||
}
|
||||
|
||||
// check organization subscriptions and response limits
|
||||
const isAppSurveyResponseLimitReached = await checkResponseLimit(environmentId);
|
||||
// check organization subscriptions
|
||||
const organization = await getOrganizationByEnvironmentId(environmentId);
|
||||
|
||||
let contact = await getContactByUserId(environmentId, userId);
|
||||
if (!contact) {
|
||||
contact = await prisma.contact.create({
|
||||
data: {
|
||||
attributes: {
|
||||
create: {
|
||||
attributeKey: {
|
||||
connect: {
|
||||
key_environmentId: {
|
||||
key: "userId",
|
||||
environmentId,
|
||||
},
|
||||
},
|
||||
},
|
||||
value: userId,
|
||||
if (!organization) {
|
||||
throw new Error("Organization does not exist");
|
||||
}
|
||||
|
||||
// check if response limit is reached
|
||||
let isAppSurveyResponseLimitReached = false;
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
const currentResponseCount = await getMonthlyOrganizationResponseCount(organization.id);
|
||||
const monthlyResponseLimit = organization.billing.limits.monthly.responses;
|
||||
|
||||
isAppSurveyResponseLimitReached =
|
||||
monthlyResponseLimit !== null && currentResponseCount >= monthlyResponseLimit;
|
||||
|
||||
if (isAppSurveyResponseLimitReached) {
|
||||
try {
|
||||
await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, {
|
||||
plan: organization.billing.plan,
|
||||
limits: {
|
||||
projects: null,
|
||||
monthly: {
|
||||
responses: monthlyResponseLimit,
|
||||
miu: null,
|
||||
},
|
||||
},
|
||||
environment: { connect: { id: environmentId } },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
attributes: { select: { attributeKey: { select: { key: true } }, value: true } },
|
||||
},
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error({ error, url: request.url }, `Error sending plan limits reached event to Posthog`);
|
||||
}
|
||||
}
|
||||
|
||||
const contactAttributes = contact.attributes.reduce((acc, attribute) => {
|
||||
acc[attribute.attributeKey.key] = attribute.value;
|
||||
return acc;
|
||||
}, {}) as Record<string, string>;
|
||||
|
||||
const [surveys, actionClasses] = await Promise.all([
|
||||
getSyncSurveys(
|
||||
environmentId,
|
||||
contact.id,
|
||||
contactAttributes,
|
||||
device.type === "mobile" ? "phone" : "desktop"
|
||||
),
|
||||
getActionClasses(environmentId),
|
||||
]);
|
||||
|
||||
const updatedProject: any = {
|
||||
...project,
|
||||
brandColor: project.styling.brandColor?.light ?? COLOR_DEFAULTS.brandColor,
|
||||
...(project.styling.highlightBorderColor?.light && {
|
||||
highlightBorderColor: project.styling.highlightBorderColor.light,
|
||||
}),
|
||||
};
|
||||
|
||||
const language = contactAttributes["language"];
|
||||
|
||||
// Scenario 1: Multi language and updated trigger action classes supported.
|
||||
// Use the surveys as they are.
|
||||
let transformedSurveys: TSurvey[] = surveys;
|
||||
|
||||
// creating state object
|
||||
let state = {
|
||||
surveys: !isAppSurveyResponseLimitReached
|
||||
? transformedSurveys.map((survey) => replaceAttributeRecall(survey, contactAttributes))
|
||||
: [],
|
||||
actionClasses,
|
||||
language,
|
||||
project: updatedProject,
|
||||
};
|
||||
|
||||
return {
|
||||
response: responses.successResponse({ ...state }, true),
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error({ error, url: req.url }, "Error in GET /api/v1/client/[environmentId]/app/sync/[userId]");
|
||||
return {
|
||||
response: responses.internalServerErrorResponse(
|
||||
"Unable to handle the request: " + error.message,
|
||||
true
|
||||
),
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
let contact = await getContactByUserId(environmentId, userId);
|
||||
if (!contact) {
|
||||
contact = await prisma.contact.create({
|
||||
data: {
|
||||
attributes: {
|
||||
create: {
|
||||
attributeKey: {
|
||||
connect: {
|
||||
key_environmentId: {
|
||||
key: "userId",
|
||||
environmentId,
|
||||
},
|
||||
},
|
||||
},
|
||||
value: userId,
|
||||
},
|
||||
},
|
||||
environment: { connect: { id: environmentId } },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
attributes: { select: { attributeKey: { select: { key: true } }, value: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const contactAttributes = contact.attributes.reduce((acc, attribute) => {
|
||||
acc[attribute.attributeKey.key] = attribute.value;
|
||||
return acc;
|
||||
}, {}) as Record<string, string>;
|
||||
|
||||
const [surveys, actionClasses] = await Promise.all([
|
||||
getSyncSurveys(
|
||||
environmentId,
|
||||
contact.id,
|
||||
contactAttributes,
|
||||
device.type === "mobile" ? "phone" : "desktop"
|
||||
),
|
||||
getActionClasses(environmentId),
|
||||
]);
|
||||
|
||||
if (!project) {
|
||||
throw new Error("Project not found");
|
||||
}
|
||||
|
||||
const updatedProject: any = {
|
||||
...project,
|
||||
brandColor: project.styling.brandColor?.light ?? COLOR_DEFAULTS.brandColor,
|
||||
...(project.styling.highlightBorderColor?.light && {
|
||||
highlightBorderColor: project.styling.highlightBorderColor.light,
|
||||
}),
|
||||
};
|
||||
|
||||
const language = contactAttributes["language"];
|
||||
|
||||
// Scenario 1: Multi language and updated trigger action classes supported.
|
||||
// Use the surveys as they are.
|
||||
let transformedSurveys: TSurvey[] = surveys;
|
||||
|
||||
// creating state object
|
||||
let state = {
|
||||
surveys: !isAppSurveyResponseLimitReached
|
||||
? transformedSurveys.map((survey) => replaceAttributeRecall(survey, contactAttributes))
|
||||
: [],
|
||||
actionClasses,
|
||||
language,
|
||||
project: updatedProject,
|
||||
};
|
||||
|
||||
return responses.successResponse({ ...state }, true);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ error, url: request.url },
|
||||
"Error in GET /api/v1/client/[environmentId]/app/sync/[userId]"
|
||||
);
|
||||
return responses.internalServerErrorResponse("Unable to handle the request: " + error.message, true);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { NextRequest } from "next/server";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZDisplayCreateInput } from "@formbricks/types/displays";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
@@ -25,55 +23,40 @@ export const OPTIONS = async (): Promise<Response> => {
|
||||
);
|
||||
};
|
||||
|
||||
export const POST = withV1ApiWrapper({
|
||||
handler: async ({ req, props }: { req: NextRequest; props: Context }) => {
|
||||
const params = await props.params;
|
||||
const jsonInput = await req.json();
|
||||
const inputValidation = ZDisplayCreateInput.safeParse({
|
||||
...jsonInput,
|
||||
environmentId: params.environmentId,
|
||||
});
|
||||
export const POST = async (request: Request, context: Context): Promise<Response> => {
|
||||
const params = await context.params;
|
||||
const jsonInput = await request.json();
|
||||
const inputValidation = ZDisplayCreateInput.safeParse({
|
||||
...jsonInput,
|
||||
environmentId: params.environmentId,
|
||||
});
|
||||
|
||||
if (!inputValidation.success) {
|
||||
return {
|
||||
response: responses.badRequestResponse(
|
||||
"Fields are missing or incorrectly formatted",
|
||||
transformErrorToDetails(inputValidation.error),
|
||||
true
|
||||
),
|
||||
};
|
||||
if (!inputValidation.success) {
|
||||
return responses.badRequestResponse(
|
||||
"Fields are missing or incorrectly formatted",
|
||||
transformErrorToDetails(inputValidation.error),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
if (inputValidation.data.userId) {
|
||||
const isContactsEnabled = await getIsContactsEnabled();
|
||||
if (!isContactsEnabled) {
|
||||
return responses.forbiddenResponse("User identification is only available for enterprise users.", true);
|
||||
}
|
||||
}
|
||||
|
||||
if (inputValidation.data.userId) {
|
||||
const isContactsEnabled = await getIsContactsEnabled();
|
||||
if (!isContactsEnabled) {
|
||||
return {
|
||||
response: responses.forbiddenResponse(
|
||||
"User identification is only available for enterprise users.",
|
||||
true
|
||||
),
|
||||
};
|
||||
}
|
||||
try {
|
||||
const response = await createDisplay(inputValidation.data);
|
||||
|
||||
await capturePosthogEnvironmentEvent(inputValidation.data.environmentId, "display created");
|
||||
return responses.successResponse(response, true);
|
||||
} catch (error) {
|
||||
if (error instanceof ResourceNotFoundError) {
|
||||
return responses.notFoundResponse("Survey", inputValidation.data.surveyId);
|
||||
} else {
|
||||
logger.error({ error, url: request.url }, "Error in POST /api/v1/client/[environmentId]/displays");
|
||||
return responses.internalServerErrorResponse("Something went wrong. Please try again.");
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await createDisplay(inputValidation.data);
|
||||
|
||||
await capturePosthogEnvironmentEvent(inputValidation.data.environmentId, "display created");
|
||||
return {
|
||||
response: responses.successResponse(response, true),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof ResourceNotFoundError) {
|
||||
return {
|
||||
response: responses.notFoundResponse("Survey", inputValidation.data.surveyId),
|
||||
};
|
||||
} else {
|
||||
logger.error({ error, url: req.url }, "Error in POST /api/v1/client/[environmentId]/displays");
|
||||
return {
|
||||
response: responses.internalServerErrorResponse("Something went wrong. Please try again."),
|
||||
};
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { getEnvironmentState } from "@/app/api/v1/client/[environmentId]/environment/lib/environmentState";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { NextRequest } from "next/server";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
@@ -17,69 +16,60 @@ export const OPTIONS = async (): Promise<Response> => {
|
||||
);
|
||||
};
|
||||
|
||||
export const GET = withV1ApiWrapper({
|
||||
handler: async ({
|
||||
req,
|
||||
props,
|
||||
}: {
|
||||
req: NextRequest;
|
||||
props: { params: Promise<{ environmentId: string }> };
|
||||
}) => {
|
||||
const params = await props.params;
|
||||
export const GET = async (
|
||||
request: NextRequest,
|
||||
props: {
|
||||
params: Promise<{
|
||||
environmentId: string;
|
||||
}>;
|
||||
}
|
||||
): Promise<Response> => {
|
||||
const params = await props.params;
|
||||
|
||||
try {
|
||||
// Simple validation for environmentId (faster than Zod for high-frequency endpoint)
|
||||
if (typeof params.environmentId !== "string") {
|
||||
return {
|
||||
response: responses.badRequestResponse("Environment ID is required", undefined, true),
|
||||
};
|
||||
}
|
||||
|
||||
// Use optimized environment state fetcher with new caching approach
|
||||
const environmentState = await getEnvironmentState(params.environmentId);
|
||||
const { data } = environmentState;
|
||||
|
||||
return {
|
||||
response: responses.successResponse(
|
||||
{
|
||||
data,
|
||||
expiresAt: new Date(Date.now() + 1000 * 60 * 60), // 1 hour for SDK to recheck
|
||||
},
|
||||
true,
|
||||
// Optimized cache headers for Cloudflare CDN and browser caching
|
||||
// max-age=3600: 1hr browser cache (per guidelines)
|
||||
// s-maxage=1800: 30min Cloudflare cache (per guidelines)
|
||||
// stale-while-revalidate=1800: 30min stale serving during revalidation
|
||||
// stale-if-error=3600: 1hr stale serving on origin errors
|
||||
"public, s-maxage=1800, max-age=3600, stale-while-revalidate=1800, stale-if-error=3600"
|
||||
),
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof ResourceNotFoundError) {
|
||||
logger.warn(
|
||||
{
|
||||
environmentId: params.environmentId,
|
||||
resourceType: err.resourceType,
|
||||
resourceId: err.resourceId,
|
||||
},
|
||||
"Resource not found in environment endpoint"
|
||||
);
|
||||
return {
|
||||
response: responses.notFoundResponse(err.resourceType, err.resourceId),
|
||||
};
|
||||
}
|
||||
|
||||
logger.error(
|
||||
{
|
||||
error: err,
|
||||
url: req.url,
|
||||
environmentId: params.environmentId,
|
||||
},
|
||||
"Error in GET /api/v1/client/[environmentId]/environment"
|
||||
);
|
||||
return {
|
||||
response: responses.internalServerErrorResponse(err.message, true),
|
||||
};
|
||||
try {
|
||||
// Simple validation for environmentId (faster than Zod for high-frequency endpoint)
|
||||
if (!params.environmentId || typeof params.environmentId !== "string") {
|
||||
return responses.badRequestResponse("Environment ID is required", undefined, true);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Use optimized environment state fetcher with new caching approach
|
||||
const environmentState = await getEnvironmentState(params.environmentId);
|
||||
const { data } = environmentState;
|
||||
|
||||
return responses.successResponse(
|
||||
{
|
||||
data,
|
||||
expiresAt: new Date(Date.now() + 1000 * 60 * 60), // 1 hour for SDK to recheck
|
||||
},
|
||||
true,
|
||||
// Optimized cache headers for Cloudflare CDN and browser caching
|
||||
// max-age=3600: 1hr browser cache (per guidelines)
|
||||
// s-maxage=1800: 30min Cloudflare cache (per guidelines)
|
||||
// stale-while-revalidate=1800: 30min stale serving during revalidation
|
||||
// stale-if-error=3600: 1hr stale serving on origin errors
|
||||
"public, s-maxage=1800, max-age=3600, stale-while-revalidate=1800, stale-if-error=3600"
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof ResourceNotFoundError) {
|
||||
logger.warn(
|
||||
{
|
||||
environmentId: params.environmentId,
|
||||
resourceType: err.resourceType,
|
||||
resourceId: err.resourceId,
|
||||
},
|
||||
"Resource not found in environment endpoint"
|
||||
);
|
||||
return responses.notFoundResponse(err.resourceType, err.resourceId);
|
||||
}
|
||||
|
||||
logger.error(
|
||||
{
|
||||
error: err,
|
||||
url: request.url,
|
||||
environmentId: params.environmentId,
|
||||
},
|
||||
"Error in GET /api/v1/client/[environmentId]/environment"
|
||||
);
|
||||
return responses.internalServerErrorResponse(err.message, true);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { sendToPipeline } from "@/app/lib/pipelines";
|
||||
import { validateFileUploads } from "@/lib/fileValidation";
|
||||
import { getResponse, updateResponse } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/question";
|
||||
import { NextRequest } from "next/server";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { ZResponseUpdateInput } from "@formbricks/types/responses";
|
||||
@@ -29,135 +27,108 @@ const handleDatabaseError = (error: Error, url: string, endpoint: string, respon
|
||||
return responses.internalServerErrorResponse("Unknown error occurred", true);
|
||||
};
|
||||
|
||||
export const PUT = withV1ApiWrapper({
|
||||
handler: async ({
|
||||
req,
|
||||
props,
|
||||
}: {
|
||||
req: NextRequest;
|
||||
props: { params: Promise<{ responseId: string }> };
|
||||
}) => {
|
||||
const params = await props.params;
|
||||
const { responseId } = params;
|
||||
export const PUT = async (
|
||||
request: Request,
|
||||
props: { params: Promise<{ responseId: string }> }
|
||||
): Promise<Response> => {
|
||||
const params = await props.params;
|
||||
const { responseId } = params;
|
||||
|
||||
if (!responseId) {
|
||||
return {
|
||||
response: responses.badRequestResponse("Response ID is missing", undefined, true),
|
||||
};
|
||||
if (!responseId) {
|
||||
return responses.badRequestResponse("Response ID is missing", undefined, true);
|
||||
}
|
||||
|
||||
const responseUpdate = await request.json();
|
||||
const inputValidation = ZResponseUpdateInput.safeParse(responseUpdate);
|
||||
|
||||
if (!inputValidation.success) {
|
||||
return responses.badRequestResponse(
|
||||
"Fields are missing or incorrectly formatted",
|
||||
transformErrorToDetails(inputValidation.error),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await getResponse(responseId);
|
||||
} catch (error) {
|
||||
const endpoint = "PUT /api/v1/client/[environmentId]/responses/[responseId]";
|
||||
return handleDatabaseError(error, request.url, endpoint, responseId);
|
||||
}
|
||||
|
||||
if (response.finished) {
|
||||
return responses.badRequestResponse("Response is already finished", undefined, true);
|
||||
}
|
||||
|
||||
// get survey to get environmentId
|
||||
let survey;
|
||||
try {
|
||||
survey = await getSurvey(response.surveyId);
|
||||
} catch (error) {
|
||||
const endpoint = "PUT /api/v1/client/[environmentId]/responses/[responseId]";
|
||||
return handleDatabaseError(error, request.url, endpoint, responseId);
|
||||
}
|
||||
|
||||
if (!validateFileUploads(inputValidation.data.data, survey.questions)) {
|
||||
return responses.badRequestResponse("Invalid file upload response", undefined, true);
|
||||
}
|
||||
|
||||
// Validate response data for "other" options exceeding character limit
|
||||
const otherResponseInvalidQuestionId = validateOtherOptionLengthForMultipleChoice({
|
||||
responseData: inputValidation.data.data,
|
||||
surveyQuestions: survey.questions,
|
||||
responseLanguage: inputValidation.data.language,
|
||||
});
|
||||
|
||||
if (otherResponseInvalidQuestionId) {
|
||||
return responses.badRequestResponse(
|
||||
`Response exceeds character limit`,
|
||||
{
|
||||
questionId: otherResponseInvalidQuestionId,
|
||||
},
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
// update response
|
||||
let updatedResponse;
|
||||
try {
|
||||
updatedResponse = await updateResponse(responseId, inputValidation.data);
|
||||
} catch (error) {
|
||||
if (error instanceof ResourceNotFoundError) {
|
||||
return responses.notFoundResponse("Response", responseId, true);
|
||||
}
|
||||
|
||||
const responseUpdate = await req.json();
|
||||
const inputValidation = ZResponseUpdateInput.safeParse(responseUpdate);
|
||||
|
||||
if (!inputValidation.success) {
|
||||
return {
|
||||
response: responses.badRequestResponse(
|
||||
"Fields are missing or incorrectly formatted",
|
||||
transformErrorToDetails(inputValidation.error),
|
||||
true
|
||||
),
|
||||
};
|
||||
if (error instanceof InvalidInputError) {
|
||||
return responses.badRequestResponse(error.message);
|
||||
}
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await getResponse(responseId);
|
||||
} catch (error) {
|
||||
const endpoint = "PUT /api/v1/client/[environmentId]/responses/[responseId]";
|
||||
return {
|
||||
response: handleDatabaseError(error, req.url, endpoint, responseId),
|
||||
};
|
||||
if (error instanceof DatabaseError) {
|
||||
logger.error(
|
||||
{ error, url: request.url },
|
||||
"Error in PUT /api/v1/client/[environmentId]/responses/[responseId]"
|
||||
);
|
||||
return responses.internalServerErrorResponse(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (response.finished) {
|
||||
return {
|
||||
response: responses.badRequestResponse("Response is already finished", undefined, true),
|
||||
};
|
||||
}
|
||||
// send response update to pipeline
|
||||
// don't await to not block the response
|
||||
sendToPipeline({
|
||||
event: "responseUpdated",
|
||||
environmentId: survey.environmentId,
|
||||
surveyId: survey.id,
|
||||
response: updatedResponse,
|
||||
});
|
||||
|
||||
// get survey to get environmentId
|
||||
let survey;
|
||||
try {
|
||||
survey = await getSurvey(response.surveyId);
|
||||
} catch (error) {
|
||||
const endpoint = "PUT /api/v1/client/[environmentId]/responses/[responseId]";
|
||||
return {
|
||||
response: handleDatabaseError(error, req.url, endpoint, responseId),
|
||||
};
|
||||
}
|
||||
|
||||
if (!validateFileUploads(inputValidation.data.data, survey.questions)) {
|
||||
return {
|
||||
response: responses.badRequestResponse("Invalid file upload response", undefined, true),
|
||||
};
|
||||
}
|
||||
|
||||
// Validate response data for "other" options exceeding character limit
|
||||
const otherResponseInvalidQuestionId = validateOtherOptionLengthForMultipleChoice({
|
||||
responseData: inputValidation.data.data,
|
||||
surveyQuestions: survey.questions,
|
||||
responseLanguage: inputValidation.data.language,
|
||||
});
|
||||
|
||||
if (otherResponseInvalidQuestionId) {
|
||||
return {
|
||||
response: responses.badRequestResponse(
|
||||
`Response exceeds character limit`,
|
||||
{
|
||||
questionId: otherResponseInvalidQuestionId,
|
||||
},
|
||||
true
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// update response
|
||||
let updatedResponse;
|
||||
try {
|
||||
updatedResponse = await updateResponse(responseId, inputValidation.data);
|
||||
} catch (error) {
|
||||
if (error instanceof ResourceNotFoundError) {
|
||||
return {
|
||||
response: responses.notFoundResponse("Response", responseId, true),
|
||||
};
|
||||
}
|
||||
if (error instanceof InvalidInputError) {
|
||||
return {
|
||||
response: responses.badRequestResponse(error.message),
|
||||
};
|
||||
}
|
||||
if (error instanceof DatabaseError) {
|
||||
logger.error(
|
||||
{ error, url: req.url },
|
||||
"Error in PUT /api/v1/client/[environmentId]/responses/[responseId]"
|
||||
);
|
||||
return {
|
||||
response: responses.internalServerErrorResponse(error.message),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// send response update to pipeline
|
||||
if (updatedResponse.finished) {
|
||||
// send response to pipeline
|
||||
// don't await to not block the response
|
||||
sendToPipeline({
|
||||
event: "responseUpdated",
|
||||
event: "responseFinished",
|
||||
environmentId: survey.environmentId,
|
||||
surveyId: survey.id,
|
||||
response: updatedResponse,
|
||||
});
|
||||
|
||||
if (updatedResponse.finished) {
|
||||
// send response to pipeline
|
||||
// don't await to not block the response
|
||||
sendToPipeline({
|
||||
event: "responseFinished",
|
||||
environmentId: survey.environmentId,
|
||||
surveyId: survey.id,
|
||||
response: updatedResponse,
|
||||
});
|
||||
}
|
||||
return {
|
||||
response: responses.successResponse({}, true),
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
return responses.successResponse({}, true);
|
||||
};
|
||||
|
||||
@@ -98,6 +98,7 @@ const mockResponsePrisma = {
|
||||
language: null,
|
||||
displayId: null,
|
||||
tags: [],
|
||||
notes: [],
|
||||
};
|
||||
|
||||
describe("createResponse", () => {
|
||||
|
||||
@@ -53,6 +53,22 @@ export const responseSelection = {
|
||||
},
|
||||
},
|
||||
},
|
||||
notes: {
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
text: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
isResolved: true,
|
||||
isEdited: true,
|
||||
},
|
||||
},
|
||||
} satisfies Prisma.ResponseSelect;
|
||||
|
||||
export const createResponse = async (responseInput: TResponseInput): Promise<TResponse> => {
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { sendToPipeline } from "@/app/lib/pipelines";
|
||||
import { validateFileUploads } from "@/lib/fileValidation";
|
||||
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { headers } from "next/headers";
|
||||
import { NextRequest } from "next/server";
|
||||
import { UAParser } from "ua-parser-js";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
@@ -31,150 +29,121 @@ export const OPTIONS = async (): Promise<Response> => {
|
||||
);
|
||||
};
|
||||
|
||||
export const POST = withV1ApiWrapper({
|
||||
handler: async ({ req, props }: { req: NextRequest; props: Context }) => {
|
||||
const params = await props.params;
|
||||
const requestHeaders = await headers();
|
||||
let responseInput;
|
||||
try {
|
||||
responseInput = await req.json();
|
||||
} catch (error) {
|
||||
return {
|
||||
response: responses.badRequestResponse(
|
||||
"Invalid JSON in request body",
|
||||
{ error: error.message },
|
||||
true
|
||||
),
|
||||
};
|
||||
export const POST = async (request: Request, context: Context): Promise<Response> => {
|
||||
const params = await context.params;
|
||||
const requestHeaders = await headers();
|
||||
let responseInput;
|
||||
try {
|
||||
responseInput = await request.json();
|
||||
} catch (error) {
|
||||
return responses.badRequestResponse("Invalid JSON in request body", { error: error.message }, true);
|
||||
}
|
||||
|
||||
const { environmentId } = params;
|
||||
const environmentIdValidation = ZId.safeParse(environmentId);
|
||||
const responseInputValidation = ZResponseInput.safeParse({ ...responseInput, environmentId });
|
||||
|
||||
if (!environmentIdValidation.success) {
|
||||
return responses.badRequestResponse(
|
||||
"Fields are missing or incorrectly formatted",
|
||||
transformErrorToDetails(environmentIdValidation.error),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
if (!responseInputValidation.success) {
|
||||
return responses.badRequestResponse(
|
||||
"Fields are missing or incorrectly formatted",
|
||||
transformErrorToDetails(responseInputValidation.error),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
const userAgent = request.headers.get("user-agent") || undefined;
|
||||
const agent = new UAParser(userAgent);
|
||||
|
||||
const country =
|
||||
requestHeaders.get("CF-IPCountry") ||
|
||||
requestHeaders.get("X-Vercel-IP-Country") ||
|
||||
requestHeaders.get("CloudFront-Viewer-Country") ||
|
||||
undefined;
|
||||
|
||||
const responseInputData = responseInputValidation.data;
|
||||
|
||||
if (responseInputData.userId) {
|
||||
const isContactsEnabled = await getIsContactsEnabled();
|
||||
if (!isContactsEnabled) {
|
||||
return responses.forbiddenResponse("User identification is only available for enterprise users.", true);
|
||||
}
|
||||
}
|
||||
|
||||
const { environmentId } = params;
|
||||
const environmentIdValidation = ZId.safeParse(environmentId);
|
||||
const responseInputValidation = ZResponseInput.safeParse({ ...responseInput, environmentId });
|
||||
// get and check survey
|
||||
const survey = await getSurvey(responseInputData.surveyId);
|
||||
if (!survey) {
|
||||
return responses.notFoundResponse("Survey", responseInputData.surveyId, true);
|
||||
}
|
||||
if (survey.environmentId !== environmentId) {
|
||||
return responses.badRequestResponse(
|
||||
"Survey is part of another environment",
|
||||
{
|
||||
"survey.environmentId": survey.environmentId,
|
||||
environmentId,
|
||||
},
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
if (!environmentIdValidation.success) {
|
||||
return {
|
||||
response: responses.badRequestResponse(
|
||||
"Fields are missing or incorrectly formatted",
|
||||
transformErrorToDetails(environmentIdValidation.error),
|
||||
true
|
||||
),
|
||||
};
|
||||
if (!validateFileUploads(responseInputData.data, survey.questions)) {
|
||||
return responses.badRequestResponse("Invalid file upload response");
|
||||
}
|
||||
|
||||
let response: TResponse;
|
||||
try {
|
||||
const meta: TResponseInput["meta"] = {
|
||||
source: responseInputData?.meta?.source,
|
||||
url: responseInputData?.meta?.url,
|
||||
userAgent: {
|
||||
browser: agent.getBrowser().name,
|
||||
device: agent.getDevice().type || "desktop",
|
||||
os: agent.getOS().name,
|
||||
},
|
||||
country: country,
|
||||
action: responseInputData?.meta?.action,
|
||||
};
|
||||
|
||||
response = await createResponse({
|
||||
...responseInputData,
|
||||
meta,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidInputError) {
|
||||
return responses.badRequestResponse(error.message);
|
||||
} else {
|
||||
logger.error({ error, url: request.url }, "Error creating response");
|
||||
return responses.internalServerErrorResponse(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (!responseInputValidation.success) {
|
||||
return {
|
||||
response: responses.badRequestResponse(
|
||||
"Fields are missing or incorrectly formatted",
|
||||
transformErrorToDetails(responseInputValidation.error),
|
||||
true
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const userAgent = req.headers.get("user-agent") || undefined;
|
||||
const agent = new UAParser(userAgent);
|
||||
|
||||
const country =
|
||||
requestHeaders.get("CF-IPCountry") ||
|
||||
requestHeaders.get("X-Vercel-IP-Country") ||
|
||||
requestHeaders.get("CloudFront-Viewer-Country") ||
|
||||
undefined;
|
||||
|
||||
const responseInputData = responseInputValidation.data;
|
||||
|
||||
if (responseInputData.userId) {
|
||||
const isContactsEnabled = await getIsContactsEnabled();
|
||||
if (!isContactsEnabled) {
|
||||
return {
|
||||
response: responses.forbiddenResponse(
|
||||
"User identification is only available for enterprise users.",
|
||||
true
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// get and check survey
|
||||
const survey = await getSurvey(responseInputData.surveyId);
|
||||
if (!survey) {
|
||||
return {
|
||||
response: responses.notFoundResponse("Survey", responseInputData.surveyId, true),
|
||||
};
|
||||
}
|
||||
if (survey.environmentId !== environmentId) {
|
||||
return {
|
||||
response: responses.badRequestResponse(
|
||||
"Survey is part of another environment",
|
||||
{
|
||||
"survey.environmentId": survey.environmentId,
|
||||
environmentId,
|
||||
},
|
||||
true
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (!validateFileUploads(responseInputData.data, survey.questions)) {
|
||||
return {
|
||||
response: responses.badRequestResponse("Invalid file upload response"),
|
||||
};
|
||||
}
|
||||
|
||||
let response: TResponse;
|
||||
try {
|
||||
const meta: TResponseInput["meta"] = {
|
||||
source: responseInputData?.meta?.source,
|
||||
url: responseInputData?.meta?.url,
|
||||
userAgent: {
|
||||
browser: agent.getBrowser().name,
|
||||
device: agent.getDevice().type || "desktop",
|
||||
os: agent.getOS().name,
|
||||
},
|
||||
country: country,
|
||||
action: responseInputData?.meta?.action,
|
||||
};
|
||||
|
||||
response = await createResponse({
|
||||
...responseInputData,
|
||||
meta,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidInputError) {
|
||||
return {
|
||||
response: responses.badRequestResponse(error.message),
|
||||
};
|
||||
} else {
|
||||
logger.error({ error, url: req.url }, "Error creating response");
|
||||
return {
|
||||
response: responses.internalServerErrorResponse(error.message),
|
||||
};
|
||||
}
|
||||
}
|
||||
sendToPipeline({
|
||||
event: "responseCreated",
|
||||
environmentId: survey.environmentId,
|
||||
surveyId: response.surveyId,
|
||||
response: response,
|
||||
});
|
||||
|
||||
if (responseInput.finished) {
|
||||
sendToPipeline({
|
||||
event: "responseCreated",
|
||||
event: "responseFinished",
|
||||
environmentId: survey.environmentId,
|
||||
surveyId: response.surveyId,
|
||||
response: response,
|
||||
});
|
||||
}
|
||||
|
||||
if (responseInput.finished) {
|
||||
sendToPipeline({
|
||||
event: "responseFinished",
|
||||
environmentId: survey.environmentId,
|
||||
surveyId: response.surveyId,
|
||||
response: response,
|
||||
});
|
||||
}
|
||||
await capturePosthogEnvironmentEvent(survey.environmentId, "response created", {
|
||||
surveyId: response.surveyId,
|
||||
surveyType: survey.type,
|
||||
});
|
||||
|
||||
await capturePosthogEnvironmentEvent(survey.environmentId, "response created", {
|
||||
surveyId: response.surveyId,
|
||||
surveyType: survey.type,
|
||||
});
|
||||
|
||||
return {
|
||||
response: responses.successResponse({ id: response.id }, true),
|
||||
};
|
||||
},
|
||||
});
|
||||
return responses.successResponse({ id: response.id }, true);
|
||||
};
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
// body -> should be a valid file object (buffer)
|
||||
// method -> PUT (to be the same as the signedUrl method)
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { ENCRYPTION_KEY, UPLOADS_DIR } from "@/lib/constants";
|
||||
import { validateLocalSignedUrl } from "@/lib/crypto";
|
||||
import { validateFile } from "@/lib/fileValidation";
|
||||
@@ -29,150 +28,115 @@ export const OPTIONS = async (): Promise<Response> => {
|
||||
);
|
||||
};
|
||||
|
||||
export const POST = withV1ApiWrapper({
|
||||
handler: async ({ req, props }: { req: NextRequest; props: Context }) => {
|
||||
if (!ENCRYPTION_KEY) {
|
||||
return {
|
||||
response: responses.internalServerErrorResponse("Encryption key is not set"),
|
||||
};
|
||||
}
|
||||
const params = await props.params;
|
||||
const environmentId = params.environmentId;
|
||||
export const POST = async (req: NextRequest, context: Context): Promise<Response> => {
|
||||
if (!ENCRYPTION_KEY) {
|
||||
return responses.internalServerErrorResponse("Encryption key is not set");
|
||||
}
|
||||
const params = await context.params;
|
||||
const environmentId = params.environmentId;
|
||||
|
||||
const accessType = "private"; // private files are accessible only by authorized users
|
||||
const accessType = "private"; // private files are accessible only by authorized users
|
||||
|
||||
const jsonInput = await req.json();
|
||||
const fileType = jsonInput.fileType as string;
|
||||
const encodedFileName = jsonInput.fileName as string;
|
||||
const surveyId = jsonInput.surveyId as string;
|
||||
const signedSignature = jsonInput.signature as string;
|
||||
const signedUuid = jsonInput.uuid as string;
|
||||
const signedTimestamp = jsonInput.timestamp as string;
|
||||
const jsonInput = await req.json();
|
||||
const fileType = jsonInput.fileType as string;
|
||||
const encodedFileName = jsonInput.fileName as string;
|
||||
const surveyId = jsonInput.surveyId as string;
|
||||
const signedSignature = jsonInput.signature as string;
|
||||
const signedUuid = jsonInput.uuid as string;
|
||||
const signedTimestamp = jsonInput.timestamp as string;
|
||||
|
||||
if (!fileType) {
|
||||
return {
|
||||
response: responses.badRequestResponse("contentType is required"),
|
||||
};
|
||||
}
|
||||
if (!fileType) {
|
||||
return responses.badRequestResponse("contentType is required");
|
||||
}
|
||||
|
||||
if (!encodedFileName) {
|
||||
return {
|
||||
response: responses.badRequestResponse("fileName is required"),
|
||||
};
|
||||
}
|
||||
if (!encodedFileName) {
|
||||
return responses.badRequestResponse("fileName is required");
|
||||
}
|
||||
|
||||
if (!surveyId) {
|
||||
return {
|
||||
response: responses.badRequestResponse("surveyId is required"),
|
||||
};
|
||||
}
|
||||
if (!surveyId) {
|
||||
return responses.badRequestResponse("surveyId is required");
|
||||
}
|
||||
|
||||
if (!signedSignature) {
|
||||
return {
|
||||
response: responses.unauthorizedResponse(),
|
||||
};
|
||||
}
|
||||
if (!signedSignature) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
|
||||
if (!signedUuid) {
|
||||
return {
|
||||
response: responses.unauthorizedResponse(),
|
||||
};
|
||||
}
|
||||
if (!signedUuid) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
|
||||
if (!signedTimestamp) {
|
||||
return {
|
||||
response: responses.unauthorizedResponse(),
|
||||
};
|
||||
}
|
||||
if (!signedTimestamp) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
|
||||
const [survey, organization] = await Promise.all([
|
||||
getSurvey(surveyId),
|
||||
getOrganizationByEnvironmentId(environmentId),
|
||||
]);
|
||||
const [survey, organization] = await Promise.all([
|
||||
getSurvey(surveyId),
|
||||
getOrganizationByEnvironmentId(environmentId),
|
||||
]);
|
||||
|
||||
if (!survey) {
|
||||
return {
|
||||
response: responses.notFoundResponse("Survey", surveyId),
|
||||
};
|
||||
}
|
||||
if (!survey) {
|
||||
return responses.notFoundResponse("Survey", surveyId);
|
||||
}
|
||||
|
||||
if (!organization) {
|
||||
return {
|
||||
response: responses.notFoundResponse("OrganizationByEnvironmentId", environmentId),
|
||||
};
|
||||
}
|
||||
if (!organization) {
|
||||
return responses.notFoundResponse("OrganizationByEnvironmentId", environmentId);
|
||||
}
|
||||
|
||||
const fileName = decodeURIComponent(encodedFileName);
|
||||
const fileName = decodeURIComponent(encodedFileName);
|
||||
|
||||
// Perform server-side file validation again
|
||||
// This is crucial as attackers could bypass the initial validation and directly call this endpoint
|
||||
const fileValidation = validateFile(fileName, fileType);
|
||||
if (!fileValidation.valid) {
|
||||
return {
|
||||
response: responses.badRequestResponse(fileValidation.error ?? "Invalid file", {
|
||||
fileName,
|
||||
fileType,
|
||||
}),
|
||||
};
|
||||
}
|
||||
// Perform server-side file validation again
|
||||
// This is crucial as attackers could bypass the initial validation and directly call this endpoint
|
||||
const fileValidation = validateFile(fileName, fileType);
|
||||
if (!fileValidation.valid) {
|
||||
return responses.badRequestResponse(fileValidation.error ?? "Invalid file", { fileName, fileType });
|
||||
}
|
||||
|
||||
// validate signature
|
||||
const validated = validateLocalSignedUrl(
|
||||
signedUuid,
|
||||
// validate signature
|
||||
const validated = validateLocalSignedUrl(
|
||||
signedUuid,
|
||||
fileName,
|
||||
environmentId,
|
||||
fileType,
|
||||
Number(signedTimestamp),
|
||||
signedSignature,
|
||||
ENCRYPTION_KEY
|
||||
);
|
||||
|
||||
if (!validated) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
|
||||
const base64String = jsonInput.fileBase64String as string;
|
||||
|
||||
const buffer = Buffer.from(base64String.split(",")[1], "base64");
|
||||
const file = new Blob([buffer], { type: fileType });
|
||||
|
||||
if (!file) {
|
||||
return responses.badRequestResponse("fileBuffer is required");
|
||||
}
|
||||
|
||||
try {
|
||||
const isBiggerFileUploadAllowed = await getBiggerUploadFileSizePermission(organization.billing.plan);
|
||||
const bytes = await file.arrayBuffer();
|
||||
const fileBuffer = Buffer.from(bytes);
|
||||
|
||||
await putFileToLocalStorage(
|
||||
fileName,
|
||||
fileBuffer,
|
||||
accessType,
|
||||
environmentId,
|
||||
fileType,
|
||||
Number(signedTimestamp),
|
||||
signedSignature,
|
||||
ENCRYPTION_KEY
|
||||
UPLOADS_DIR,
|
||||
isBiggerFileUploadAllowed
|
||||
);
|
||||
|
||||
if (!validated) {
|
||||
return {
|
||||
response: responses.unauthorizedResponse(),
|
||||
};
|
||||
return responses.successResponse({
|
||||
message: "File uploaded successfully",
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error({ error: err, url: req.url }, "Error in POST /api/v1/client/[environmentId]/upload");
|
||||
if (err.name === "FileTooLargeError") {
|
||||
return responses.badRequestResponse(err.message);
|
||||
}
|
||||
|
||||
const base64String = jsonInput.fileBase64String as string;
|
||||
|
||||
const buffer = Buffer.from(base64String.split(",")[1], "base64");
|
||||
const file = new Blob([buffer], { type: fileType });
|
||||
|
||||
if (!file) {
|
||||
return {
|
||||
response: responses.badRequestResponse("fileBuffer is required"),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const isBiggerFileUploadAllowed = await getBiggerUploadFileSizePermission(organization.billing.plan);
|
||||
const bytes = await file.arrayBuffer();
|
||||
const fileBuffer = Buffer.from(bytes);
|
||||
|
||||
await putFileToLocalStorage(
|
||||
fileName,
|
||||
fileBuffer,
|
||||
accessType,
|
||||
environmentId,
|
||||
UPLOADS_DIR,
|
||||
isBiggerFileUploadAllowed
|
||||
);
|
||||
|
||||
return {
|
||||
response: responses.successResponse({
|
||||
message: "File uploaded successfully",
|
||||
}),
|
||||
};
|
||||
} catch (err) {
|
||||
logger.error({ error: err, url: req.url }, "Error in POST /api/v1/client/[environmentId]/upload");
|
||||
if (err.name === "FileTooLargeError") {
|
||||
return {
|
||||
response: responses.badRequestResponse(err.message),
|
||||
};
|
||||
}
|
||||
return {
|
||||
response: responses.internalServerErrorResponse("File upload failed"),
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
return responses.internalServerErrorResponse("File upload failed");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { validateFile } from "@/lib/fileValidation";
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
@@ -31,62 +30,46 @@ export const OPTIONS = async (): Promise<Response> => {
|
||||
// use this to let users upload files to a survey for example
|
||||
// this api endpoint will return a signed url for uploading the file to s3 and another url for uploading file to the local storage
|
||||
|
||||
export const POST = withV1ApiWrapper({
|
||||
handler: async ({ req, props }: { req: NextRequest; props: Context }) => {
|
||||
const params = await props.params;
|
||||
const environmentId = params.environmentId;
|
||||
export const POST = async (req: NextRequest, context: Context): Promise<Response> => {
|
||||
const params = await context.params;
|
||||
const environmentId = params.environmentId;
|
||||
|
||||
const jsonInput = await req.json();
|
||||
const inputValidation = ZUploadFileRequest.safeParse({
|
||||
...jsonInput,
|
||||
environmentId,
|
||||
});
|
||||
const jsonInput = await req.json();
|
||||
const inputValidation = ZUploadFileRequest.safeParse({
|
||||
...jsonInput,
|
||||
environmentId,
|
||||
});
|
||||
|
||||
if (!inputValidation.success) {
|
||||
return {
|
||||
response: responses.badRequestResponse(
|
||||
"Invalid request",
|
||||
transformErrorToDetails(inputValidation.error),
|
||||
true
|
||||
),
|
||||
};
|
||||
}
|
||||
if (!inputValidation.success) {
|
||||
return responses.badRequestResponse(
|
||||
"Invalid request",
|
||||
transformErrorToDetails(inputValidation.error),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
const { fileName, fileType, surveyId } = inputValidation.data;
|
||||
const { fileName, fileType, surveyId } = inputValidation.data;
|
||||
|
||||
// Perform server-side file validation
|
||||
const fileValidation = validateFile(fileName, fileType);
|
||||
if (!fileValidation.valid) {
|
||||
return {
|
||||
response: responses.badRequestResponse(
|
||||
fileValidation.error ?? "Invalid file",
|
||||
{ fileName, fileType },
|
||||
true
|
||||
),
|
||||
};
|
||||
}
|
||||
// Perform server-side file validation
|
||||
const fileValidation = validateFile(fileName, fileType);
|
||||
if (!fileValidation.valid) {
|
||||
return responses.badRequestResponse(fileValidation.error ?? "Invalid file", { fileName, fileType }, true);
|
||||
}
|
||||
|
||||
const [survey, organization] = await Promise.all([
|
||||
getSurvey(surveyId),
|
||||
getOrganizationByEnvironmentId(environmentId),
|
||||
]);
|
||||
const [survey, organization] = await Promise.all([
|
||||
getSurvey(surveyId),
|
||||
getOrganizationByEnvironmentId(environmentId),
|
||||
]);
|
||||
|
||||
if (!survey) {
|
||||
return {
|
||||
response: responses.notFoundResponse("Survey", surveyId),
|
||||
};
|
||||
}
|
||||
if (!survey) {
|
||||
return responses.notFoundResponse("Survey", surveyId);
|
||||
}
|
||||
|
||||
if (!organization) {
|
||||
return {
|
||||
response: responses.notFoundResponse("OrganizationByEnvironmentId", environmentId),
|
||||
};
|
||||
}
|
||||
if (!organization) {
|
||||
return responses.notFoundResponse("OrganizationByEnvironmentId", environmentId);
|
||||
}
|
||||
|
||||
const isBiggerFileUploadAllowed = await getBiggerUploadFileSizePermission(organization.billing.plan);
|
||||
const isBiggerFileUploadAllowed = await getBiggerUploadFileSizePermission(organization.billing.plan);
|
||||
|
||||
return {
|
||||
response: await uploadPrivateFile(fileName, environmentId, fileType, isBiggerFileUploadAllowed),
|
||||
};
|
||||
},
|
||||
});
|
||||
return await uploadPrivateFile(fileName, environmentId, fileType, isBiggerFileUploadAllowed);
|
||||
};
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { TSessionAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { fetchAirtableAuthToken } from "@/lib/airtable/service";
|
||||
import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@/lib/constants";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { createOrUpdateIntegration } from "@/lib/integration/service";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { NextRequest } from "next/server";
|
||||
import * as z from "zod";
|
||||
import { logger } from "@formbricks/logger";
|
||||
@@ -20,83 +21,65 @@ const getEmail = async (token: string) => {
|
||||
return z.string().parse(res_?.email);
|
||||
};
|
||||
|
||||
export const GET = withV1ApiWrapper({
|
||||
handler: async ({
|
||||
req,
|
||||
authentication,
|
||||
}: {
|
||||
req: NextRequest;
|
||||
authentication: NonNullable<TSessionAuthentication>;
|
||||
}) => {
|
||||
const url = req.url;
|
||||
const queryParams = new URLSearchParams(url.split("?")[1]); // Split the URL and get the query parameters
|
||||
const environmentId = queryParams.get("state"); // Get the value of the 'state' parameter
|
||||
const code = queryParams.get("code");
|
||||
export const GET = async (req: NextRequest) => {
|
||||
const url = req.url;
|
||||
const queryParams = new URLSearchParams(url.split("?")[1]); // Split the URL and get the query parameters
|
||||
const environmentId = queryParams.get("state"); // Get the value of the 'state' parameter
|
||||
const code = queryParams.get("code");
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!environmentId) {
|
||||
return {
|
||||
response: responses.badRequestResponse("Invalid environmentId"),
|
||||
};
|
||||
if (!environmentId) {
|
||||
return responses.badRequestResponse("Invalid environmentId");
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
|
||||
if (code && typeof code !== "string") {
|
||||
return responses.badRequestResponse("`code` must be a string");
|
||||
}
|
||||
const canUserAccessEnvironment = await hasUserEnvironmentAccess(session?.user.id, environmentId);
|
||||
if (!canUserAccessEnvironment) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
|
||||
const client_id = AIRTABLE_CLIENT_ID;
|
||||
const redirect_uri = WEBAPP_URL + "/api/v1/integrations/airtable/callback";
|
||||
const code_verifier = Buffer.from(environmentId + session.user.id + environmentId).toString("base64");
|
||||
|
||||
if (!client_id) return responses.internalServerErrorResponse("Airtable client id is missing");
|
||||
if (!redirect_uri) return responses.internalServerErrorResponse("Airtable redirect url is missing");
|
||||
|
||||
const formData = {
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
redirect_uri,
|
||||
client_id,
|
||||
code_verifier,
|
||||
};
|
||||
|
||||
try {
|
||||
const key = await fetchAirtableAuthToken(formData);
|
||||
if (!key) {
|
||||
return responses.notFoundResponse("airtable auth token", key);
|
||||
}
|
||||
const email = await getEmail(key.access_token);
|
||||
|
||||
if (!code) {
|
||||
return {
|
||||
response: responses.badRequestResponse("`code` is missing"),
|
||||
};
|
||||
}
|
||||
const canUserAccessEnvironment = await hasUserEnvironmentAccess(authentication.user.id, environmentId);
|
||||
if (!canUserAccessEnvironment) {
|
||||
return {
|
||||
response: responses.unauthorizedResponse(),
|
||||
};
|
||||
}
|
||||
|
||||
const client_id = AIRTABLE_CLIENT_ID;
|
||||
const redirect_uri = WEBAPP_URL + "/api/v1/integrations/airtable/callback";
|
||||
const code_verifier = Buffer.from(environmentId + authentication.user.id + environmentId).toString(
|
||||
"base64"
|
||||
);
|
||||
|
||||
if (!client_id)
|
||||
return {
|
||||
response: responses.internalServerErrorResponse("Airtable client id is missing"),
|
||||
};
|
||||
|
||||
const formData = {
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
redirect_uri,
|
||||
client_id,
|
||||
code_verifier,
|
||||
const airtableIntegrationInput = {
|
||||
type: "airtable" as "airtable",
|
||||
environment: environmentId,
|
||||
config: {
|
||||
key,
|
||||
data: [],
|
||||
email,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const key = await fetchAirtableAuthToken(formData);
|
||||
if (!key) {
|
||||
return {
|
||||
response: responses.notFoundResponse("airtable auth token", key),
|
||||
};
|
||||
}
|
||||
const email = await getEmail(key.access_token);
|
||||
|
||||
const airtableIntegrationInput = {
|
||||
type: "airtable" as "airtable",
|
||||
environment: environmentId,
|
||||
config: {
|
||||
key,
|
||||
data: [],
|
||||
email,
|
||||
},
|
||||
};
|
||||
await createOrUpdateIntegration(environmentId, airtableIntegrationInput);
|
||||
return {
|
||||
response: Response.redirect(`${WEBAPP_URL}/environments/${environmentId}/integrations/airtable`),
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error({ error, url: req.url }, "Error in GET /api/v1/integrations/airtable/callback");
|
||||
return {
|
||||
response: responses.internalServerErrorResponse(error),
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
await createOrUpdateIntegration(environmentId, airtableIntegrationInput);
|
||||
return Response.redirect(`${WEBAPP_URL}/environments/${environmentId}/integrations/airtable`);
|
||||
} catch (error) {
|
||||
logger.error({ error, url: req.url }, "Error in GET /api/v1/integrations/airtable/callback");
|
||||
responses.internalServerErrorResponse(error);
|
||||
}
|
||||
responses.badRequestResponse("unknown error occurred");
|
||||
};
|
||||
|
||||
@@ -1,66 +1,54 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { TSessionAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@/lib/constants";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import crypto from "crypto";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
const scope = `data.records:read data.records:write schema.bases:read schema.bases:write user.email:read`;
|
||||
|
||||
export const GET = withV1ApiWrapper({
|
||||
handler: async ({
|
||||
req,
|
||||
authentication,
|
||||
}: {
|
||||
req: NextRequest;
|
||||
authentication: NonNullable<TSessionAuthentication>;
|
||||
}) => {
|
||||
const environmentId = req.headers.get("environmentId");
|
||||
export const GET = async (req: NextRequest) => {
|
||||
const environmentId = req.headers.get("environmentId");
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!environmentId) {
|
||||
return {
|
||||
response: responses.badRequestResponse("environmentId is missing"),
|
||||
};
|
||||
}
|
||||
if (!environmentId) {
|
||||
return responses.badRequestResponse("environmentId is missing");
|
||||
}
|
||||
|
||||
const canUserAccessEnvironment = await hasUserEnvironmentAccess(authentication.user.id, environmentId);
|
||||
if (!canUserAccessEnvironment) {
|
||||
return {
|
||||
response: responses.unauthorizedResponse(),
|
||||
};
|
||||
}
|
||||
if (!session) {
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
|
||||
const client_id = AIRTABLE_CLIENT_ID;
|
||||
const redirect_uri = WEBAPP_URL + "/api/v1/integrations/airtable/callback";
|
||||
if (!client_id)
|
||||
return {
|
||||
response: responses.internalServerErrorResponse("Airtable client id is missing"),
|
||||
};
|
||||
const codeVerifier = Buffer.from(environmentId + authentication.user.id + environmentId).toString(
|
||||
"base64"
|
||||
);
|
||||
const canUserAccessEnvironment = await hasUserEnvironmentAccess(session?.user.id, environmentId);
|
||||
if (!canUserAccessEnvironment) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
|
||||
const codeChallengeMethod = "S256";
|
||||
const codeChallenge = crypto
|
||||
.createHash("sha256")
|
||||
.update(codeVerifier) // hash the code verifier with the sha256 algorithm
|
||||
.digest("base64") // base64 encode, needs to be transformed to base64url
|
||||
.replace(/=/g, "") // remove =
|
||||
.replace(/\+/g, "-") // replace + with -
|
||||
.replace(/\//g, "_"); // replace / with _ now base64url encoded
|
||||
const client_id = AIRTABLE_CLIENT_ID;
|
||||
const redirect_uri = WEBAPP_URL + "/api/v1/integrations/airtable/callback";
|
||||
if (!client_id) return responses.internalServerErrorResponse("Airtable client id is missing");
|
||||
if (!redirect_uri) return responses.internalServerErrorResponse("Airtable redirect url is missing");
|
||||
const codeVerifier = Buffer.from(environmentId + session.user.id + environmentId).toString("base64");
|
||||
|
||||
const authUrl = new URL("https://airtable.com/oauth2/v1/authorize");
|
||||
const codeChallengeMethod = "S256";
|
||||
const codeChallenge = crypto
|
||||
.createHash("sha256")
|
||||
.update(codeVerifier) // hash the code verifier with the sha256 algorithm
|
||||
.digest("base64") // base64 encode, needs to be transformed to base64url
|
||||
.replace(/=/g, "") // remove =
|
||||
.replace(/\+/g, "-") // replace + with -
|
||||
.replace(/\//g, "_"); // replace / with _ now base64url encoded
|
||||
|
||||
authUrl.searchParams.append("client_id", client_id);
|
||||
authUrl.searchParams.append("redirect_uri", redirect_uri);
|
||||
authUrl.searchParams.append("state", environmentId);
|
||||
authUrl.searchParams.append("scope", scope);
|
||||
authUrl.searchParams.append("response_type", "code");
|
||||
authUrl.searchParams.append("code_challenge_method", codeChallengeMethod);
|
||||
authUrl.searchParams.append("code_challenge", codeChallenge);
|
||||
const authUrl = new URL("https://airtable.com/oauth2/v1/authorize");
|
||||
|
||||
return {
|
||||
response: responses.successResponse({ authUrl: authUrl.toString() }),
|
||||
};
|
||||
},
|
||||
});
|
||||
authUrl.searchParams.append("client_id", client_id);
|
||||
authUrl.searchParams.append("redirect_uri", redirect_uri);
|
||||
authUrl.searchParams.append("state", environmentId);
|
||||
authUrl.searchParams.append("scope", scope);
|
||||
authUrl.searchParams.append("response_type", "code");
|
||||
authUrl.searchParams.append("code_challenge_method", codeChallengeMethod);
|
||||
authUrl.searchParams.append("code_challenge", codeChallenge);
|
||||
|
||||
return responses.successResponse({ authUrl: authUrl.toString() });
|
||||
};
|
||||
|
||||
@@ -1,55 +1,43 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { TSessionAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { getTables } from "@/lib/airtable/service";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { getIntegrationByType } from "@/lib/integration/service";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { NextRequest } from "next/server";
|
||||
import * as z from "zod";
|
||||
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
|
||||
|
||||
export const GET = withV1ApiWrapper({
|
||||
handler: async ({
|
||||
req,
|
||||
authentication,
|
||||
}: {
|
||||
req: NextRequest;
|
||||
authentication: NonNullable<TSessionAuthentication>;
|
||||
}) => {
|
||||
const url = req.url;
|
||||
const environmentId = req.headers.get("environmentId");
|
||||
const queryParams = new URLSearchParams(url.split("?")[1]);
|
||||
const baseId = z.string().safeParse(queryParams.get("baseId"));
|
||||
export const GET = async (req: NextRequest) => {
|
||||
const url = req.url;
|
||||
const environmentId = req.headers.get("environmentId");
|
||||
const queryParams = new URLSearchParams(url.split("?")[1]);
|
||||
const session = await getServerSession(authOptions);
|
||||
const baseId = z.string().safeParse(queryParams.get("baseId"));
|
||||
|
||||
if (!baseId.success) {
|
||||
return {
|
||||
response: responses.badRequestResponse("Base Id is Required"),
|
||||
};
|
||||
}
|
||||
if (!baseId.success) {
|
||||
return responses.badRequestResponse("Base Id is Required");
|
||||
}
|
||||
|
||||
if (!environmentId) {
|
||||
return {
|
||||
response: responses.badRequestResponse("environmentId is missing"),
|
||||
};
|
||||
}
|
||||
if (!session) {
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
|
||||
const canUserAccessEnvironment = await hasUserEnvironmentAccess(authentication.user.id, environmentId);
|
||||
if (!canUserAccessEnvironment) {
|
||||
return {
|
||||
response: responses.unauthorizedResponse(),
|
||||
};
|
||||
}
|
||||
if (!environmentId) {
|
||||
return responses.badRequestResponse("environmentId is missing");
|
||||
}
|
||||
|
||||
const integration = (await getIntegrationByType(environmentId, "airtable")) as TIntegrationAirtable;
|
||||
const canUserAccessEnvironment = await hasUserEnvironmentAccess(session?.user.id, environmentId);
|
||||
if (!canUserAccessEnvironment || !environmentId) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
|
||||
if (!integration) {
|
||||
return {
|
||||
response: responses.notFoundResponse("Integration not found", environmentId),
|
||||
};
|
||||
}
|
||||
const integration = (await getIntegrationByType(environmentId, "airtable")) as TIntegrationAirtable;
|
||||
|
||||
const tables = await getTables(integration.config.key, baseId.data);
|
||||
return {
|
||||
response: responses.successResponse(tables),
|
||||
};
|
||||
},
|
||||
});
|
||||
if (!integration) {
|
||||
return responses.notFoundResponse("Integration not found", environmentId);
|
||||
}
|
||||
|
||||
const tables = await getTables(integration.config.key, baseId.data);
|
||||
return responses.successResponse(tables);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import {
|
||||
ENCRYPTION_KEY,
|
||||
NOTION_OAUTH_CLIENT_ID,
|
||||
@@ -12,93 +11,70 @@ import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integrati
|
||||
import { NextRequest } from "next/server";
|
||||
import { TIntegrationNotionConfigData, TIntegrationNotionInput } from "@formbricks/types/integration/notion";
|
||||
|
||||
export const GET = withV1ApiWrapper({
|
||||
handler: async ({ req }: { req: NextRequest }) => {
|
||||
const url = req.url;
|
||||
const queryParams = new URLSearchParams(url.split("?")[1]); // Split the URL and get the query parameters
|
||||
const environmentId = queryParams.get("state"); // Get the value of the 'state' parameter
|
||||
const code = queryParams.get("code");
|
||||
const error = queryParams.get("error");
|
||||
export const GET = async (req: NextRequest) => {
|
||||
const url = req.url;
|
||||
const queryParams = new URLSearchParams(url.split("?")[1]); // Split the URL and get the query parameters
|
||||
const environmentId = queryParams.get("state"); // Get the value of the 'state' parameter
|
||||
const code = queryParams.get("code");
|
||||
const error = queryParams.get("error");
|
||||
|
||||
if (!environmentId) {
|
||||
return {
|
||||
response: responses.badRequestResponse("Invalid environmentId"),
|
||||
};
|
||||
}
|
||||
if (!environmentId) {
|
||||
return responses.badRequestResponse("Invalid environmentId");
|
||||
}
|
||||
|
||||
if (code && typeof code !== "string") {
|
||||
return {
|
||||
response: responses.badRequestResponse("`code` must be a string"),
|
||||
};
|
||||
}
|
||||
if (code && typeof code !== "string") {
|
||||
return responses.badRequestResponse("`code` must be a string");
|
||||
}
|
||||
|
||||
const client_id = NOTION_OAUTH_CLIENT_ID;
|
||||
const client_secret = NOTION_OAUTH_CLIENT_SECRET;
|
||||
const redirect_uri = NOTION_REDIRECT_URI;
|
||||
if (!client_id)
|
||||
return {
|
||||
response: responses.internalServerErrorResponse("Notion client id is missing"),
|
||||
};
|
||||
if (!redirect_uri)
|
||||
return {
|
||||
response: responses.internalServerErrorResponse("Notion redirect url is missing"),
|
||||
};
|
||||
if (!client_secret)
|
||||
return {
|
||||
response: responses.internalServerErrorResponse("Notion client secret is missing"),
|
||||
};
|
||||
if (code) {
|
||||
// encode in base 64
|
||||
const encoded = Buffer.from(`${client_id}:${client_secret}`).toString("base64");
|
||||
const client_id = NOTION_OAUTH_CLIENT_ID;
|
||||
const client_secret = NOTION_OAUTH_CLIENT_SECRET;
|
||||
const redirect_uri = NOTION_REDIRECT_URI;
|
||||
if (!client_id) return responses.internalServerErrorResponse("Notion client id is missing");
|
||||
if (!redirect_uri) return responses.internalServerErrorResponse("Notion redirect url is missing");
|
||||
if (!client_secret) return responses.internalServerErrorResponse("Notion client secret is missing");
|
||||
if (code) {
|
||||
// encode in base 64
|
||||
const encoded = Buffer.from(`${client_id}:${client_secret}`).toString("base64");
|
||||
|
||||
const response = await fetch("https://api.notion.com/v1/oauth/token", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Basic ${encoded}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
grant_type: "authorization_code",
|
||||
code: code,
|
||||
redirect_uri: redirect_uri,
|
||||
}),
|
||||
});
|
||||
const response = await fetch("https://api.notion.com/v1/oauth/token", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Basic ${encoded}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
grant_type: "authorization_code",
|
||||
code: code,
|
||||
redirect_uri: redirect_uri,
|
||||
}),
|
||||
});
|
||||
|
||||
const tokenData = await response.json();
|
||||
const encryptedAccessToken = symmetricEncrypt(tokenData.access_token, ENCRYPTION_KEY);
|
||||
tokenData.access_token = encryptedAccessToken;
|
||||
const tokenData = await response.json();
|
||||
const encryptedAccessToken = symmetricEncrypt(tokenData.access_token, ENCRYPTION_KEY!);
|
||||
tokenData.access_token = encryptedAccessToken;
|
||||
|
||||
const notionIntegration: TIntegrationNotionInput = {
|
||||
type: "notion" as "notion",
|
||||
config: {
|
||||
key: tokenData,
|
||||
data: [],
|
||||
},
|
||||
};
|
||||
|
||||
const existingIntegration = await getIntegrationByType(environmentId, "notion");
|
||||
if (existingIntegration) {
|
||||
notionIntegration.config.data = existingIntegration.config.data as TIntegrationNotionConfigData[];
|
||||
}
|
||||
|
||||
const result = await createOrUpdateIntegration(environmentId, notionIntegration);
|
||||
|
||||
if (result) {
|
||||
return {
|
||||
response: Response.redirect(`${WEBAPP_URL}/environments/${environmentId}/integrations/notion`),
|
||||
};
|
||||
}
|
||||
} else if (error) {
|
||||
return {
|
||||
response: Response.redirect(
|
||||
`${WEBAPP_URL}/environments/${environmentId}/integrations/notion?error=${error}`
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
response: responses.badRequestResponse("Missing code or error parameter"),
|
||||
const notionIntegration: TIntegrationNotionInput = {
|
||||
type: "notion" as "notion",
|
||||
config: {
|
||||
key: tokenData,
|
||||
data: [],
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const existingIntegration = await getIntegrationByType(environmentId, "notion");
|
||||
if (existingIntegration) {
|
||||
notionIntegration.config.data = existingIntegration.config.data as TIntegrationNotionConfigData[];
|
||||
}
|
||||
|
||||
const result = await createOrUpdateIntegration(environmentId, notionIntegration);
|
||||
|
||||
if (result) {
|
||||
return Response.redirect(`${WEBAPP_URL}/environments/${environmentId}/integrations/notion`);
|
||||
}
|
||||
} else if (error) {
|
||||
return Response.redirect(
|
||||
`${WEBAPP_URL}/environments/${environmentId}/integrations/notion?error=${error}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { TSessionAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import {
|
||||
NOTION_AUTH_URL,
|
||||
NOTION_OAUTH_CLIENT_ID,
|
||||
@@ -7,54 +6,35 @@ import {
|
||||
NOTION_REDIRECT_URI,
|
||||
} from "@/lib/constants";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
export const GET = withV1ApiWrapper({
|
||||
handler: async ({
|
||||
req,
|
||||
authentication,
|
||||
}: {
|
||||
req: NextRequest;
|
||||
authentication: NonNullable<TSessionAuthentication>;
|
||||
}) => {
|
||||
const environmentId = req.headers.get("environmentId");
|
||||
export const GET = async (req: NextRequest) => {
|
||||
const environmentId = req.headers.get("environmentId");
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!environmentId) {
|
||||
return {
|
||||
response: responses.badRequestResponse("environmentId is missing"),
|
||||
};
|
||||
}
|
||||
if (!environmentId) {
|
||||
return responses.badRequestResponse("environmentId is missing");
|
||||
}
|
||||
|
||||
const canUserAccessEnvironment = await hasUserEnvironmentAccess(authentication.user.id, environmentId);
|
||||
if (!canUserAccessEnvironment) {
|
||||
return {
|
||||
response: responses.unauthorizedResponse(),
|
||||
};
|
||||
}
|
||||
if (!session) {
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
|
||||
const client_id = NOTION_OAUTH_CLIENT_ID;
|
||||
const client_secret = NOTION_OAUTH_CLIENT_SECRET;
|
||||
const auth_url = NOTION_AUTH_URL;
|
||||
const redirect_uri = NOTION_REDIRECT_URI;
|
||||
if (!client_id)
|
||||
return {
|
||||
response: responses.internalServerErrorResponse("Notion client id is missing"),
|
||||
};
|
||||
if (!redirect_uri)
|
||||
return {
|
||||
response: responses.internalServerErrorResponse("Notion redirect url is missing"),
|
||||
};
|
||||
if (!client_secret)
|
||||
return {
|
||||
response: responses.internalServerErrorResponse("Notion client secret is missing"),
|
||||
};
|
||||
if (!auth_url)
|
||||
return {
|
||||
response: responses.internalServerErrorResponse("Notion auth url is missing"),
|
||||
};
|
||||
const canUserAccessEnvironment = await hasUserEnvironmentAccess(session?.user.id, environmentId);
|
||||
if (!canUserAccessEnvironment) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
|
||||
return {
|
||||
response: responses.successResponse({ authUrl: `${auth_url}&state=${environmentId}` }),
|
||||
};
|
||||
},
|
||||
});
|
||||
const client_id = NOTION_OAUTH_CLIENT_ID;
|
||||
const client_secret = NOTION_OAUTH_CLIENT_SECRET;
|
||||
const auth_url = NOTION_AUTH_URL;
|
||||
const redirect_uri = NOTION_REDIRECT_URI;
|
||||
if (!client_id) return responses.internalServerErrorResponse("Notion client id is missing");
|
||||
if (!redirect_uri) return responses.internalServerErrorResponse("Notion redirect url is missing");
|
||||
if (!client_secret) return responses.internalServerErrorResponse("Notion client secret is missing");
|
||||
if (!auth_url) return responses.internalServerErrorResponse("Notion auth url is missing");
|
||||
|
||||
return responses.successResponse({ authUrl: `${auth_url}&state=${environmentId}` });
|
||||
};
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@/lib/constants";
|
||||
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
|
||||
import { NextRequest } from "next/server";
|
||||
@@ -9,103 +8,79 @@ import {
|
||||
TIntegrationSlackCredential,
|
||||
} from "@formbricks/types/integration/slack";
|
||||
|
||||
export const GET = withV1ApiWrapper({
|
||||
handler: async ({ req }: { req: NextRequest }) => {
|
||||
const url = req.url;
|
||||
const queryParams = new URLSearchParams(url.split("?")[1]); // Split the URL and get the query parameters
|
||||
const environmentId = queryParams.get("state"); // Get the value of the 'state' parameter
|
||||
const code = queryParams.get("code");
|
||||
const error = queryParams.get("error");
|
||||
export const GET = async (req: NextRequest) => {
|
||||
const url = req.url;
|
||||
const queryParams = new URLSearchParams(url.split("?")[1]); // Split the URL and get the query parameters
|
||||
const environmentId = queryParams.get("state"); // Get the value of the 'state' parameter
|
||||
const code = queryParams.get("code");
|
||||
const error = queryParams.get("error");
|
||||
|
||||
if (!environmentId) {
|
||||
return {
|
||||
response: responses.badRequestResponse("Invalid environmentId"),
|
||||
};
|
||||
if (!environmentId) {
|
||||
return responses.badRequestResponse("Invalid environmentId");
|
||||
}
|
||||
|
||||
if (code && typeof code !== "string") {
|
||||
return responses.badRequestResponse("`code` must be a string");
|
||||
}
|
||||
|
||||
if (!SLACK_CLIENT_ID) return responses.internalServerErrorResponse("Slack client id is missing");
|
||||
if (!SLACK_CLIENT_SECRET) return responses.internalServerErrorResponse("Slack client secret is missing");
|
||||
|
||||
const formData = {
|
||||
code,
|
||||
client_id: SLACK_CLIENT_ID,
|
||||
client_secret: SLACK_CLIENT_SECRET,
|
||||
};
|
||||
const formBody: string[] = [];
|
||||
for (const property in formData) {
|
||||
const encodedKey = encodeURIComponent(property);
|
||||
const encodedValue = encodeURIComponent(formData[property]);
|
||||
formBody.push(encodedKey + "=" + encodedValue);
|
||||
}
|
||||
const bodyString = formBody.join("&");
|
||||
if (code) {
|
||||
const response = await fetch("https://slack.com/api/oauth.v2.access", {
|
||||
method: "POST",
|
||||
body: bodyString,
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.ok) {
|
||||
return responses.badRequestResponse(data.error);
|
||||
}
|
||||
|
||||
if (code && typeof code !== "string") {
|
||||
return {
|
||||
response: responses.badRequestResponse("`code` must be a string"),
|
||||
};
|
||||
}
|
||||
|
||||
if (!SLACK_CLIENT_ID)
|
||||
return {
|
||||
response: responses.internalServerErrorResponse("Slack client id is missing"),
|
||||
};
|
||||
if (!SLACK_CLIENT_SECRET)
|
||||
return {
|
||||
response: responses.internalServerErrorResponse("Slack client secret is missing"),
|
||||
};
|
||||
|
||||
const formData = {
|
||||
code,
|
||||
client_id: SLACK_CLIENT_ID,
|
||||
client_secret: SLACK_CLIENT_SECRET,
|
||||
const slackCredentials: TIntegrationSlackCredential = {
|
||||
app_id: data.app_id,
|
||||
authed_user: data.authed_user,
|
||||
token_type: data.token_type,
|
||||
access_token: data.access_token,
|
||||
bot_user_id: data.bot_user_id,
|
||||
team: data.team,
|
||||
};
|
||||
const formBody: string[] = [];
|
||||
for (const property in formData) {
|
||||
const encodedKey = encodeURIComponent(property);
|
||||
const encodedValue = encodeURIComponent(formData[property]);
|
||||
formBody.push(encodedKey + "=" + encodedValue);
|
||||
}
|
||||
const bodyString = formBody.join("&");
|
||||
if (code) {
|
||||
const response = await fetch("https://slack.com/api/oauth.v2.access", {
|
||||
method: "POST",
|
||||
body: bodyString,
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
const slackIntegration = await getIntegrationByType(environmentId, "slack");
|
||||
|
||||
if (!data.ok) {
|
||||
return {
|
||||
response: responses.badRequestResponse(data.error),
|
||||
};
|
||||
}
|
||||
|
||||
const slackCredentials: TIntegrationSlackCredential = {
|
||||
app_id: data.app_id,
|
||||
authed_user: data.authed_user,
|
||||
token_type: data.token_type,
|
||||
access_token: data.access_token,
|
||||
bot_user_id: data.bot_user_id,
|
||||
team: data.team,
|
||||
};
|
||||
|
||||
const slackIntegration = await getIntegrationByType(environmentId, "slack");
|
||||
|
||||
const slackConfiguration: TIntegrationSlackConfig = {
|
||||
data: (slackIntegration?.config.data as TIntegrationSlackConfigData[]) ?? [],
|
||||
key: slackCredentials,
|
||||
};
|
||||
|
||||
const integration = {
|
||||
type: "slack" as "slack",
|
||||
environment: environmentId,
|
||||
config: slackConfiguration,
|
||||
};
|
||||
|
||||
const result = await createOrUpdateIntegration(environmentId, integration);
|
||||
|
||||
if (result) {
|
||||
return {
|
||||
response: Response.redirect(`${WEBAPP_URL}/environments/${environmentId}/integrations/slack`),
|
||||
};
|
||||
}
|
||||
} else if (error) {
|
||||
return {
|
||||
response: Response.redirect(
|
||||
`${WEBAPP_URL}/environments/${environmentId}/integrations/slack?error=${error}`
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
response: responses.badRequestResponse("Missing code or error parameter"),
|
||||
const slackConfiguration: TIntegrationSlackConfig = {
|
||||
data: (slackIntegration?.config.data as TIntegrationSlackConfigData[]) ?? [],
|
||||
key: slackCredentials,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const integration = {
|
||||
type: "slack" as "slack",
|
||||
environment: environmentId,
|
||||
config: slackConfiguration,
|
||||
};
|
||||
|
||||
const result = await createOrUpdateIntegration(environmentId, integration);
|
||||
|
||||
if (result) {
|
||||
return Response.redirect(`${WEBAPP_URL}/environments/${environmentId}/integrations/slack`);
|
||||
}
|
||||
} else if (error) {
|
||||
return Response.redirect(`${WEBAPP_URL}/environments/${environmentId}/integrations/slack?error=${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,47 +1,30 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { TSessionAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { SLACK_AUTH_URL, SLACK_CLIENT_ID, SLACK_CLIENT_SECRET } from "@/lib/constants";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
export const GET = withV1ApiWrapper({
|
||||
handler: async ({
|
||||
req,
|
||||
authentication,
|
||||
}: {
|
||||
req: NextRequest;
|
||||
authentication: NonNullable<TSessionAuthentication>;
|
||||
}) => {
|
||||
const environmentId = req.headers.get("environmentId");
|
||||
export const GET = async (req: NextRequest) => {
|
||||
const environmentId = req.headers.get("environmentId");
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!environmentId) {
|
||||
return {
|
||||
response: responses.badRequestResponse("environmentId is missing"),
|
||||
};
|
||||
}
|
||||
if (!environmentId) {
|
||||
return responses.badRequestResponse("environmentId is missing");
|
||||
}
|
||||
|
||||
const canUserAccessEnvironment = await hasUserEnvironmentAccess(authentication.user.id, environmentId);
|
||||
if (!canUserAccessEnvironment) {
|
||||
return {
|
||||
response: responses.unauthorizedResponse(),
|
||||
};
|
||||
}
|
||||
if (!session) {
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
|
||||
if (!SLACK_CLIENT_ID)
|
||||
return {
|
||||
response: responses.internalServerErrorResponse("Slack client id is missing"),
|
||||
};
|
||||
if (!SLACK_CLIENT_SECRET)
|
||||
return {
|
||||
response: responses.internalServerErrorResponse("Slack client secret is missing"),
|
||||
};
|
||||
if (!SLACK_AUTH_URL)
|
||||
return {
|
||||
response: responses.internalServerErrorResponse("Slack auth url is missing"),
|
||||
};
|
||||
const canUserAccessEnvironment = await hasUserEnvironmentAccess(session?.user.id, environmentId);
|
||||
if (!canUserAccessEnvironment) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
|
||||
return {
|
||||
response: responses.successResponse({ authUrl: `${SLACK_AUTH_URL}&state=${environmentId}` }),
|
||||
};
|
||||
},
|
||||
});
|
||||
if (!SLACK_CLIENT_ID) return responses.internalServerErrorResponse("Slack client id is missing");
|
||||
if (!SLACK_CLIENT_SECRET) return responses.internalServerErrorResponse("Slack client secret is missing");
|
||||
if (!SLACK_AUTH_URL) return responses.internalServerErrorResponse("Slack auth url is missing");
|
||||
|
||||
return responses.successResponse({ authUrl: `${SLACK_AUTH_URL}&state=${environmentId}` });
|
||||
};
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { handleErrorResponse } from "@/app/api/v1/auth";
|
||||
import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { ApiAuditLog, withApiLogging } from "@/app/lib/api/with-api-logging";
|
||||
import { deleteActionClass, getActionClass, updateActionClass } from "@/lib/actionClass/service";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { NextRequest } from "next/server";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TActionClass, ZActionClassInput } from "@formbricks/types/action-classes";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
@@ -28,49 +27,36 @@ const fetchAndAuthorizeActionClass = async (
|
||||
return actionClass;
|
||||
};
|
||||
|
||||
export const GET = withV1ApiWrapper({
|
||||
handler: async ({
|
||||
props,
|
||||
authentication,
|
||||
}: {
|
||||
props: { params: Promise<{ actionClassId: string }> };
|
||||
authentication: NonNullable<TApiKeyAuthentication>;
|
||||
}) => {
|
||||
const params = await props.params;
|
||||
export const GET = async (
|
||||
request: Request,
|
||||
props: { params: Promise<{ actionClassId: string }> }
|
||||
): Promise<Response> => {
|
||||
const params = await props.params;
|
||||
try {
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) return responses.notAuthenticatedResponse();
|
||||
const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId, "GET");
|
||||
if (actionClass) {
|
||||
return responses.successResponse(actionClass);
|
||||
}
|
||||
return responses.notFoundResponse("Action Class", params.actionClassId);
|
||||
} catch (error) {
|
||||
return handleErrorResponse(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const PUT = withApiLogging(
|
||||
async (request: Request, props: { params: Promise<{ actionClassId: string }> }, auditLog: ApiAuditLog) => {
|
||||
const params = await props.params;
|
||||
try {
|
||||
const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId, "GET");
|
||||
if (actionClass) {
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) {
|
||||
return {
|
||||
response: responses.successResponse(actionClass),
|
||||
response: responses.notAuthenticatedResponse(),
|
||||
};
|
||||
}
|
||||
return {
|
||||
response: responses.notFoundResponse("Action Class", params.actionClassId),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
response: handleErrorResponse(error),
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
auditLog.userId = authentication.apiKeyId;
|
||||
|
||||
export const PUT = withV1ApiWrapper({
|
||||
handler: async ({
|
||||
req,
|
||||
props,
|
||||
auditLog,
|
||||
authentication,
|
||||
}: {
|
||||
req: NextRequest;
|
||||
props: { params: Promise<{ actionClassId: string }> };
|
||||
auditLog: TApiAuditLog;
|
||||
authentication: NonNullable<TApiKeyAuthentication>;
|
||||
}) => {
|
||||
const params = await props.params;
|
||||
|
||||
try {
|
||||
const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId, "PUT");
|
||||
if (!actionClass) {
|
||||
return {
|
||||
@@ -78,12 +64,13 @@ export const PUT = withV1ApiWrapper({
|
||||
};
|
||||
}
|
||||
auditLog.oldObject = actionClass;
|
||||
auditLog.organizationId = authentication.organizationId;
|
||||
|
||||
let actionClassUpdate;
|
||||
try {
|
||||
actionClassUpdate = await req.json();
|
||||
actionClassUpdate = await request.json();
|
||||
} catch (error) {
|
||||
logger.error({ error, url: req.url }, "Error parsing JSON");
|
||||
logger.error({ error, url: request.url }, "Error parsing JSON");
|
||||
return {
|
||||
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
|
||||
};
|
||||
@@ -118,25 +105,24 @@ export const PUT = withV1ApiWrapper({
|
||||
};
|
||||
}
|
||||
},
|
||||
action: "updated",
|
||||
targetType: "actionClass",
|
||||
});
|
||||
"updated",
|
||||
"actionClass"
|
||||
);
|
||||
|
||||
export const DELETE = withV1ApiWrapper({
|
||||
handler: async ({
|
||||
props,
|
||||
auditLog,
|
||||
authentication,
|
||||
}: {
|
||||
props: { params: Promise<{ actionClassId: string }> };
|
||||
auditLog: TApiAuditLog;
|
||||
authentication: NonNullable<TApiKeyAuthentication>;
|
||||
}) => {
|
||||
export const DELETE = withApiLogging(
|
||||
async (request: Request, props: { params: Promise<{ actionClassId: string }> }, auditLog: ApiAuditLog) => {
|
||||
const params = await props.params;
|
||||
|
||||
auditLog.targetId = params.actionClassId;
|
||||
|
||||
try {
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) {
|
||||
return {
|
||||
response: responses.notAuthenticatedResponse(),
|
||||
};
|
||||
}
|
||||
auditLog.userId = authentication.apiKeyId;
|
||||
|
||||
const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId, "DELETE");
|
||||
if (!actionClass) {
|
||||
return {
|
||||
@@ -145,6 +131,7 @@ export const DELETE = withV1ApiWrapper({
|
||||
}
|
||||
|
||||
auditLog.oldObject = actionClass;
|
||||
auditLog.organizationId = authentication.organizationId;
|
||||
|
||||
const deletedActionClass = await deleteActionClass(params.actionClassId);
|
||||
return {
|
||||
@@ -156,6 +143,6 @@ export const DELETE = withV1ApiWrapper({
|
||||
};
|
||||
}
|
||||
},
|
||||
action: "deleted",
|
||||
targetType: "actionClass",
|
||||
});
|
||||
"deleted",
|
||||
"actionClass"
|
||||
);
|
||||
|
||||
@@ -1,53 +1,51 @@
|
||||
import { authenticateRequest } from "@/app/api/v1/auth";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { ApiAuditLog, withApiLogging } from "@/app/lib/api/with-api-logging";
|
||||
import { createActionClass } from "@/lib/actionClass/service";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { NextRequest } from "next/server";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TActionClass, ZActionClassInput } from "@formbricks/types/action-classes";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { getActionClasses } from "./lib/action-classes";
|
||||
|
||||
export const GET = withV1ApiWrapper({
|
||||
handler: async ({ authentication }: { authentication: NonNullable<TApiKeyAuthentication> }) => {
|
||||
export const GET = async (request: Request) => {
|
||||
try {
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) return responses.notAuthenticatedResponse();
|
||||
|
||||
const environmentIds = authentication.environmentPermissions.map(
|
||||
(permission) => permission.environmentId
|
||||
);
|
||||
|
||||
const actionClasses = await getActionClasses(environmentIds);
|
||||
|
||||
return responses.successResponse(actionClasses);
|
||||
} catch (error) {
|
||||
if (error instanceof DatabaseError) {
|
||||
return responses.badRequestResponse(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const POST = withApiLogging(
|
||||
async (request: Request, _, auditLog: ApiAuditLog) => {
|
||||
try {
|
||||
const environmentIds = authentication.environmentPermissions.map(
|
||||
(permission) => permission.environmentId
|
||||
);
|
||||
|
||||
const actionClasses = await getActionClasses(environmentIds);
|
||||
|
||||
return {
|
||||
response: responses.successResponse(actionClasses),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof DatabaseError) {
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) {
|
||||
return {
|
||||
response: responses.badRequestResponse(error.message),
|
||||
response: responses.notAuthenticatedResponse(),
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
});
|
||||
auditLog.userId = authentication.apiKeyId;
|
||||
auditLog.organizationId = authentication.organizationId;
|
||||
|
||||
export const POST = withV1ApiWrapper({
|
||||
handler: async ({
|
||||
req,
|
||||
auditLog,
|
||||
authentication,
|
||||
}: {
|
||||
req: NextRequest;
|
||||
auditLog: TApiAuditLog;
|
||||
authentication: NonNullable<TApiKeyAuthentication>;
|
||||
}) => {
|
||||
try {
|
||||
let actionClassInput;
|
||||
try {
|
||||
actionClassInput = await req.json();
|
||||
actionClassInput = await request.json();
|
||||
} catch (error) {
|
||||
logger.error({ error, url: req.url }, "Error parsing JSON input");
|
||||
logger.error({ error, url: request.url }, "Error parsing JSON input");
|
||||
return {
|
||||
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
|
||||
};
|
||||
@@ -87,6 +85,6 @@ export const POST = withV1ApiWrapper({
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
action: "created",
|
||||
targetType: "actionClass",
|
||||
});
|
||||
"created",
|
||||
"actionClass"
|
||||
);
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
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";
|
||||
|
||||
@@ -12,11 +9,9 @@ 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,
|
||||
hashedKey: hashApiKey(apiKey),
|
||||
},
|
||||
select: {
|
||||
apiKeyEnvironments: {
|
||||
@@ -44,13 +39,9 @@ export const GET = async () => {
|
||||
});
|
||||
|
||||
if (!apiKeyData) {
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
|
||||
try {
|
||||
await applyRateLimit(rateLimitConfigs.api.v1, hashedApiKey);
|
||||
} catch (error) {
|
||||
return responses.tooManyRequestsResponse(error.message);
|
||||
return new Response("Not authenticated", {
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -69,18 +60,16 @@ export const GET = async () => {
|
||||
},
|
||||
});
|
||||
} else {
|
||||
return responses.badRequestResponse("You can't use this method with this API key");
|
||||
return new Response("You can't use this method with this API key", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
} 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);
|
||||
return new Response("Not authenticated", {
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
|
||||
@@ -1,24 +1,19 @@
|
||||
import { handleErrorResponse } from "@/app/api/v1/auth";
|
||||
import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { ApiAuditLog, withApiLogging } from "@/app/lib/api/with-api-logging";
|
||||
import { validateFileUploads } from "@/lib/fileValidation";
|
||||
import { deleteResponse, getResponse, updateResponse } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { NextRequest } from "next/server";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZResponseUpdateInput } from "@formbricks/types/responses";
|
||||
|
||||
async function fetchAndAuthorizeResponse(
|
||||
responseId: string,
|
||||
authentication: TApiKeyAuthentication,
|
||||
authentication: any,
|
||||
requiredPermission: "GET" | "PUT" | "DELETE"
|
||||
) {
|
||||
if (!authentication) {
|
||||
return { error: responses.notAuthenticatedResponse() };
|
||||
}
|
||||
|
||||
const response = await getResponse(responseId);
|
||||
if (!response) {
|
||||
return { error: responses.notFoundResponse("Response", responseId) };
|
||||
@@ -36,47 +31,38 @@ async function fetchAndAuthorizeResponse(
|
||||
return { response, survey };
|
||||
}
|
||||
|
||||
export const GET = withV1ApiWrapper({
|
||||
handler: async ({
|
||||
props,
|
||||
authentication,
|
||||
}: {
|
||||
props: { params: Promise<{ responseId: string }> };
|
||||
authentication: TApiKeyAuthentication;
|
||||
}) => {
|
||||
const params = await props.params;
|
||||
try {
|
||||
const result = await fetchAndAuthorizeResponse(params.responseId, authentication, "GET");
|
||||
if (result.error) {
|
||||
return {
|
||||
response: result.error,
|
||||
};
|
||||
}
|
||||
export const GET = async (
|
||||
request: Request,
|
||||
props: { params: Promise<{ responseId: string }> }
|
||||
): Promise<Response> => {
|
||||
const params = await props.params;
|
||||
try {
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) return responses.notAuthenticatedResponse();
|
||||
|
||||
return {
|
||||
response: responses.successResponse(result.response),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
response: handleErrorResponse(error),
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
const result = await fetchAndAuthorizeResponse(params.responseId, authentication, "GET");
|
||||
if (result.error) return result.error;
|
||||
|
||||
export const DELETE = withV1ApiWrapper({
|
||||
handler: async ({
|
||||
props,
|
||||
auditLog,
|
||||
authentication,
|
||||
}: {
|
||||
props: { params: Promise<{ responseId: string }> };
|
||||
auditLog: TApiAuditLog;
|
||||
authentication: TApiKeyAuthentication;
|
||||
}) => {
|
||||
return responses.successResponse(result.response);
|
||||
} catch (error) {
|
||||
return handleErrorResponse(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const DELETE = withApiLogging(
|
||||
async (request: Request, props: { params: Promise<{ responseId: string }> }, auditLog: ApiAuditLog) => {
|
||||
const params = await props.params;
|
||||
auditLog.targetId = params.responseId;
|
||||
try {
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) {
|
||||
return {
|
||||
response: responses.notAuthenticatedResponse(),
|
||||
};
|
||||
}
|
||||
auditLog.userId = authentication.apiKeyId;
|
||||
auditLog.organizationId = authentication.organizationId;
|
||||
|
||||
const result = await fetchAndAuthorizeResponse(params.responseId, authentication, "DELETE");
|
||||
if (result.error) {
|
||||
return {
|
||||
@@ -95,25 +81,24 @@ export const DELETE = withV1ApiWrapper({
|
||||
};
|
||||
}
|
||||
},
|
||||
action: "deleted",
|
||||
targetType: "response",
|
||||
});
|
||||
"deleted",
|
||||
"response"
|
||||
);
|
||||
|
||||
export const PUT = withV1ApiWrapper({
|
||||
handler: async ({
|
||||
req,
|
||||
props,
|
||||
auditLog,
|
||||
authentication,
|
||||
}: {
|
||||
req: NextRequest;
|
||||
props: { params: Promise<{ responseId: string }> };
|
||||
auditLog: TApiAuditLog;
|
||||
authentication: TApiKeyAuthentication;
|
||||
}) => {
|
||||
export const PUT = withApiLogging(
|
||||
async (request: Request, props: { params: Promise<{ responseId: string }> }, auditLog: ApiAuditLog) => {
|
||||
const params = await props.params;
|
||||
auditLog.targetId = params.responseId;
|
||||
try {
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) {
|
||||
return {
|
||||
response: responses.notAuthenticatedResponse(),
|
||||
};
|
||||
}
|
||||
auditLog.userId = authentication.apiKeyId;
|
||||
auditLog.organizationId = authentication.organizationId;
|
||||
|
||||
const result = await fetchAndAuthorizeResponse(params.responseId, authentication, "PUT");
|
||||
if (result.error) {
|
||||
return {
|
||||
@@ -124,9 +109,9 @@ export const PUT = withV1ApiWrapper({
|
||||
|
||||
let responseUpdate;
|
||||
try {
|
||||
responseUpdate = await req.json();
|
||||
responseUpdate = await request.json();
|
||||
} catch (error) {
|
||||
logger.error({ error, url: req.url }, "Error parsing JSON");
|
||||
logger.error({ error, url: request.url }, "Error parsing JSON");
|
||||
return {
|
||||
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
|
||||
};
|
||||
@@ -159,6 +144,6 @@ export const PUT = withV1ApiWrapper({
|
||||
};
|
||||
}
|
||||
},
|
||||
action: "updated",
|
||||
targetType: "response",
|
||||
});
|
||||
"updated",
|
||||
"response"
|
||||
);
|
||||
|
||||
@@ -65,7 +65,8 @@ const mockResponsePrisma = {
|
||||
displayId,
|
||||
contact: null, // Prisma relation
|
||||
tags: [], // Prisma relation
|
||||
} as unknown as ResponsePrisma & { contact: any; tags: any[] }; // Adjust type as needed
|
||||
notes: [], // Prisma relation
|
||||
} as unknown as ResponsePrisma & { contact: any; tags: any[]; notes: any[] }; // Adjust type as needed
|
||||
|
||||
const mockResponse: TResponse = {
|
||||
id: responseId,
|
||||
@@ -84,6 +85,7 @@ const mockResponse: TResponse = {
|
||||
displayId,
|
||||
contact: null, // Transformed structure
|
||||
tags: [], // Transformed structure
|
||||
notes: [], // Transformed structure
|
||||
};
|
||||
|
||||
const mockEnvironmentIds = [environmentId, "env-2"];
|
||||
|
||||
@@ -57,6 +57,22 @@ export const responseSelection = {
|
||||
},
|
||||
},
|
||||
},
|
||||
notes: {
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
text: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
isResolved: true,
|
||||
isEdited: true,
|
||||
},
|
||||
},
|
||||
} satisfies Prisma.ResponseSelect;
|
||||
|
||||
export const createResponse = async (responseInput: TResponseInput): Promise<TResponse> => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { authenticateRequest } from "@/app/api/v1/auth";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { ApiAuditLog, withApiLogging } from "@/app/lib/api/with-api-logging";
|
||||
import { validateFileUploads } from "@/lib/fileValidation";
|
||||
import { getResponses } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
@@ -11,56 +12,42 @@ import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
|
||||
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
|
||||
import { createResponse, getResponsesByEnvironmentIds } from "./lib/response";
|
||||
|
||||
export const GET = withV1ApiWrapper({
|
||||
handler: async ({
|
||||
req,
|
||||
authentication,
|
||||
}: {
|
||||
req: NextRequest;
|
||||
authentication: NonNullable<TApiKeyAuthentication>;
|
||||
}) => {
|
||||
const searchParams = req.nextUrl.searchParams;
|
||||
const surveyId = searchParams.get("surveyId");
|
||||
const limit = searchParams.get("limit") ? Number(searchParams.get("limit")) : undefined;
|
||||
const offset = searchParams.get("skip") ? Number(searchParams.get("skip")) : undefined;
|
||||
export const GET = async (request: NextRequest) => {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const surveyId = searchParams.get("surveyId");
|
||||
const limit = searchParams.get("limit") ? Number(searchParams.get("limit")) : undefined;
|
||||
const offset = searchParams.get("skip") ? Number(searchParams.get("skip")) : undefined;
|
||||
|
||||
try {
|
||||
let allResponses: TResponse[] = [];
|
||||
try {
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) return responses.notAuthenticatedResponse();
|
||||
let allResponses: TResponse[] = [];
|
||||
|
||||
if (surveyId) {
|
||||
const survey = await getSurvey(surveyId);
|
||||
if (!survey) {
|
||||
return {
|
||||
response: responses.notFoundResponse("Survey", surveyId, true),
|
||||
};
|
||||
}
|
||||
if (!hasPermission(authentication.environmentPermissions, survey.environmentId, "GET")) {
|
||||
return {
|
||||
response: responses.unauthorizedResponse(),
|
||||
};
|
||||
}
|
||||
const surveyResponses = await getResponses(surveyId, limit, offset);
|
||||
allResponses.push(...surveyResponses);
|
||||
} else {
|
||||
const environmentIds = authentication.environmentPermissions.map(
|
||||
(permission) => permission.environmentId
|
||||
);
|
||||
const environmentResponses = await getResponsesByEnvironmentIds(environmentIds, limit, offset);
|
||||
allResponses.push(...environmentResponses);
|
||||
if (surveyId) {
|
||||
const survey = await getSurvey(surveyId);
|
||||
if (!survey) {
|
||||
return responses.notFoundResponse("Survey", surveyId, true);
|
||||
}
|
||||
return {
|
||||
response: responses.successResponse(allResponses),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof DatabaseError) {
|
||||
return {
|
||||
response: responses.badRequestResponse(error.message),
|
||||
};
|
||||
if (!hasPermission(authentication.environmentPermissions, survey.environmentId, "GET")) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
throw error;
|
||||
const surveyResponses = await getResponses(surveyId, limit, offset);
|
||||
allResponses.push(...surveyResponses);
|
||||
} else {
|
||||
const environmentIds = authentication.environmentPermissions.map(
|
||||
(permission) => permission.environmentId
|
||||
);
|
||||
const environmentResponses = await getResponsesByEnvironmentIds(environmentIds, limit, offset);
|
||||
allResponses.push(...environmentResponses);
|
||||
}
|
||||
},
|
||||
});
|
||||
return responses.successResponse(allResponses);
|
||||
} catch (error) {
|
||||
if (error instanceof DatabaseError) {
|
||||
return responses.badRequestResponse(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const validateInput = async (request: Request) => {
|
||||
let jsonInput;
|
||||
@@ -105,18 +92,19 @@ const validateSurvey = async (responseInput: TResponseInput, environmentId: stri
|
||||
return { survey };
|
||||
};
|
||||
|
||||
export const POST = withV1ApiWrapper({
|
||||
handler: async ({
|
||||
req,
|
||||
auditLog,
|
||||
authentication,
|
||||
}: {
|
||||
req: NextRequest;
|
||||
auditLog: TApiAuditLog;
|
||||
authentication: NonNullable<TApiKeyAuthentication>;
|
||||
}) => {
|
||||
export const POST = withApiLogging(
|
||||
async (request: Request, _, auditLog: ApiAuditLog) => {
|
||||
try {
|
||||
const inputResult = await validateInput(req);
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) {
|
||||
return {
|
||||
response: responses.notAuthenticatedResponse(),
|
||||
};
|
||||
}
|
||||
auditLog.userId = authentication.apiKeyId;
|
||||
auditLog.organizationId = authentication.organizationId;
|
||||
|
||||
const inputResult = await validateInput(request);
|
||||
if (inputResult.error) {
|
||||
return {
|
||||
response: inputResult.error,
|
||||
@@ -157,14 +145,16 @@ export const POST = withV1ApiWrapper({
|
||||
response: responses.successResponse(response, true),
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error({ error, url: req.url }, "Error in POST /api/v1/management/responses");
|
||||
|
||||
if (error instanceof InvalidInputError) {
|
||||
return {
|
||||
response: responses.badRequestResponse(error.message),
|
||||
};
|
||||
} else if (error instanceof DatabaseError) {
|
||||
return {
|
||||
response: responses.badRequestResponse(error.message),
|
||||
};
|
||||
}
|
||||
|
||||
logger.error({ error, url: request.url }, "Error in POST /api/v1/management/responses");
|
||||
return {
|
||||
response: responses.internalServerErrorResponse(error.message),
|
||||
};
|
||||
@@ -178,6 +168,6 @@ export const POST = withV1ApiWrapper({
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
action: "created",
|
||||
targetType: "response",
|
||||
});
|
||||
"created",
|
||||
"response"
|
||||
);
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
import { authenticateRequest } from "@/app/api/v1/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 { Session } from "next-auth";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { vi } from "vitest";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { checkAuth, checkForRequiredFields } from "./utils";
|
||||
import { checkForRequiredFields } from "./utils";
|
||||
import { checkAuth } from "./utils";
|
||||
|
||||
// Create mock response objects
|
||||
const mockBadRequestResponse = new Response("Bad Request", { status: 400 });
|
||||
const mockNotAuthenticatedResponse = new Response("Not authenticated", { status: 401 });
|
||||
const mockUnauthorizedResponse = new Response("Unauthorized", { status: 401 });
|
||||
|
||||
vi.mock("@/app/api/v1/auth", () => ({
|
||||
authenticateRequest: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/environment/auth", () => ({
|
||||
hasUserEnvironmentAccess: vi.fn(),
|
||||
}));
|
||||
@@ -72,22 +80,19 @@ describe("checkForRequiredFields", () => {
|
||||
|
||||
describe("checkAuth", () => {
|
||||
const environmentId = "env-123";
|
||||
const mockRequest = new NextRequest("http://localhost:3000/api/test");
|
||||
|
||||
test("returns notAuthenticatedResponse when authentication is null", async () => {
|
||||
const result = await checkAuth(null, environmentId);
|
||||
test("returns notAuthenticatedResponse when no session and no authentication", async () => {
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(null);
|
||||
|
||||
const result = await checkAuth(null, environmentId, mockRequest);
|
||||
|
||||
expect(authenticateRequest).toHaveBeenCalledWith(mockRequest);
|
||||
expect(responses.notAuthenticatedResponse).toHaveBeenCalled();
|
||||
expect(result).toBe(mockNotAuthenticatedResponse);
|
||||
});
|
||||
|
||||
test("returns notAuthenticatedResponse when authentication is undefined", async () => {
|
||||
const result = await checkAuth(undefined as any, environmentId);
|
||||
|
||||
expect(responses.notAuthenticatedResponse).toHaveBeenCalled();
|
||||
expect(result).toBe(mockNotAuthenticatedResponse);
|
||||
});
|
||||
|
||||
test("returns unauthorizedResponse when API key authentication lacks POST permission", async () => {
|
||||
test("returns unauthorizedResponse when no session and authentication lacks POST permission", async () => {
|
||||
const mockAuthentication: TAuthenticationApiKey = {
|
||||
type: "apiKey",
|
||||
environmentPermissions: [
|
||||
@@ -107,10 +112,12 @@ describe("checkAuth", () => {
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(mockAuthentication);
|
||||
vi.mocked(hasPermission).mockReturnValue(false);
|
||||
|
||||
const result = await checkAuth(mockAuthentication, environmentId);
|
||||
const result = await checkAuth(null, environmentId, mockRequest);
|
||||
|
||||
expect(authenticateRequest).toHaveBeenCalledWith(mockRequest);
|
||||
expect(hasPermission).toHaveBeenCalledWith(
|
||||
mockAuthentication.environmentPermissions,
|
||||
environmentId,
|
||||
@@ -120,7 +127,7 @@ describe("checkAuth", () => {
|
||||
expect(result).toBe(mockUnauthorizedResponse);
|
||||
});
|
||||
|
||||
test("returns undefined when API key authentication has POST permission", async () => {
|
||||
test("returns undefined when no session and authentication has POST permission", async () => {
|
||||
const mockAuthentication: TAuthenticationApiKey = {
|
||||
type: "apiKey",
|
||||
environmentPermissions: [
|
||||
@@ -140,10 +147,12 @@ describe("checkAuth", () => {
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(mockAuthentication);
|
||||
vi.mocked(hasPermission).mockReturnValue(true);
|
||||
|
||||
const result = await checkAuth(mockAuthentication, environmentId);
|
||||
const result = await checkAuth(null, environmentId, mockRequest);
|
||||
|
||||
expect(authenticateRequest).toHaveBeenCalledWith(mockRequest);
|
||||
expect(hasPermission).toHaveBeenCalledWith(
|
||||
mockAuthentication.environmentPermissions,
|
||||
environmentId,
|
||||
@@ -162,7 +171,7 @@ describe("checkAuth", () => {
|
||||
|
||||
vi.mocked(hasUserEnvironmentAccess).mockResolvedValue(false);
|
||||
|
||||
const result = await checkAuth(mockSession, environmentId);
|
||||
const result = await checkAuth(mockSession, environmentId, mockRequest);
|
||||
|
||||
expect(hasUserEnvironmentAccess).toHaveBeenCalledWith("user-123", environmentId);
|
||||
expect(responses.unauthorizedResponse).toHaveBeenCalled();
|
||||
@@ -179,18 +188,25 @@ describe("checkAuth", () => {
|
||||
|
||||
vi.mocked(hasUserEnvironmentAccess).mockResolvedValue(true);
|
||||
|
||||
const result = await checkAuth(mockSession, environmentId);
|
||||
const result = await checkAuth(mockSession, environmentId, mockRequest);
|
||||
|
||||
expect(hasUserEnvironmentAccess).toHaveBeenCalledWith("user-123", environmentId);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns notAuthenticatedResponse when authentication object is neither session nor API key", async () => {
|
||||
const invalidAuth = { someProperty: "value" } as any;
|
||||
test("does not call authenticateRequest when session exists", async () => {
|
||||
const mockSession: Session = {
|
||||
user: {
|
||||
id: "user-123",
|
||||
},
|
||||
expires: "2024-12-31T23:59:59.999Z",
|
||||
};
|
||||
|
||||
const result = await checkAuth(invalidAuth, environmentId);
|
||||
vi.mocked(hasUserEnvironmentAccess).mockResolvedValue(true);
|
||||
|
||||
expect(responses.notAuthenticatedResponse).toHaveBeenCalled();
|
||||
expect(result).toBe(mockNotAuthenticatedResponse);
|
||||
await checkAuth(mockSession, environmentId, mockRequest);
|
||||
|
||||
expect(authenticateRequest).not.toHaveBeenCalled();
|
||||
expect(hasUserEnvironmentAccess).toHaveBeenCalledWith("user-123", environmentId);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { authenticateRequest } from "@/app/api/v1/auth";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { TApiV1Authentication } from "@/app/lib/api/with-api-logging";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { Session } from "next-auth";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
export const checkForRequiredFields = (
|
||||
environmentId: string,
|
||||
@@ -21,21 +23,19 @@ export const checkForRequiredFields = (
|
||||
}
|
||||
};
|
||||
|
||||
export const checkAuth = async (authentication: TApiV1Authentication, environmentId: string) => {
|
||||
if (!authentication) {
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
export const checkAuth = async (session: Session | null, environmentId: string, request: NextRequest) => {
|
||||
if (!session) {
|
||||
//check whether its using API key
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) return responses.notAuthenticatedResponse();
|
||||
|
||||
if ("user" in authentication) {
|
||||
const isUserAuthorized = await hasUserEnvironmentAccess(authentication.user.id, environmentId);
|
||||
if (!isUserAuthorized) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
} else if ("hashedApiKey" in authentication) {
|
||||
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
} else {
|
||||
return responses.notAuthenticatedResponse();
|
||||
const isUserAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId);
|
||||
if (!isUserAuthorized) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3,107 +3,88 @@
|
||||
// method -> PUT (to be the same as the signedUrl method)
|
||||
import { checkAuth, checkForRequiredFields } from "@/app/api/v1/management/storage/lib/utils";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { TApiV1Authentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { ENCRYPTION_KEY, UPLOADS_DIR } from "@/lib/constants";
|
||||
import { validateLocalSignedUrl } from "@/lib/crypto";
|
||||
import { validateFile } from "@/lib/fileValidation";
|
||||
import { putFileToLocalStorage } from "@/lib/storage/service";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { NextRequest } from "next/server";
|
||||
import { logger } from "@formbricks/logger";
|
||||
|
||||
export const POST = withV1ApiWrapper({
|
||||
handler: async ({ req, authentication }: { req: NextRequest; authentication: TApiV1Authentication }) => {
|
||||
if (!ENCRYPTION_KEY) {
|
||||
return {
|
||||
response: responses.internalServerErrorResponse("Encryption key is not set"),
|
||||
};
|
||||
export const POST = async (req: NextRequest): Promise<Response> => {
|
||||
if (!ENCRYPTION_KEY) {
|
||||
return responses.internalServerErrorResponse("Encryption key is not set");
|
||||
}
|
||||
|
||||
const accessType = "public"; // public files are accessible by anyone
|
||||
|
||||
const jsonInput = await req.json();
|
||||
const fileType = jsonInput.fileType as string;
|
||||
const encodedFileName = jsonInput.fileName as string;
|
||||
const signedSignature = jsonInput.signature as string;
|
||||
const signedUuid = jsonInput.uuid as string;
|
||||
const signedTimestamp = jsonInput.timestamp as string;
|
||||
const environmentId = jsonInput.environmentId as string;
|
||||
|
||||
const requiredFieldResponse = checkForRequiredFields(environmentId, fileType, encodedFileName);
|
||||
if (requiredFieldResponse) return requiredFieldResponse;
|
||||
|
||||
if (!signedSignature || !signedUuid || !signedTimestamp) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
const authResponse = await checkAuth(session, environmentId, req);
|
||||
if (authResponse) return authResponse;
|
||||
|
||||
const fileName = decodeURIComponent(encodedFileName);
|
||||
|
||||
// Perform server-side file validation
|
||||
const fileValidation = validateFile(fileName, fileType);
|
||||
if (!fileValidation.valid) {
|
||||
return responses.badRequestResponse(fileValidation.error ?? "Invalid file");
|
||||
}
|
||||
|
||||
// validate signature
|
||||
|
||||
const validated = validateLocalSignedUrl(
|
||||
signedUuid,
|
||||
fileName,
|
||||
environmentId,
|
||||
fileType,
|
||||
Number(signedTimestamp),
|
||||
signedSignature,
|
||||
ENCRYPTION_KEY
|
||||
);
|
||||
|
||||
if (!validated) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
|
||||
const base64String = jsonInput.fileBase64String as string;
|
||||
const buffer = Buffer.from(base64String.split(",")[1], "base64");
|
||||
const file = new Blob([buffer], { type: fileType });
|
||||
|
||||
if (!file) {
|
||||
return responses.badRequestResponse("fileBuffer is required");
|
||||
}
|
||||
|
||||
try {
|
||||
const bytes = await file.arrayBuffer();
|
||||
const fileBuffer = Buffer.from(bytes);
|
||||
|
||||
await putFileToLocalStorage(fileName, fileBuffer, accessType, environmentId, UPLOADS_DIR);
|
||||
|
||||
return responses.successResponse({
|
||||
message: "File uploaded successfully",
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(err, "Error uploading file");
|
||||
if (err.name === "FileTooLargeError") {
|
||||
return responses.badRequestResponse(err.message);
|
||||
}
|
||||
|
||||
const accessType = "public"; // public files are accessible by anyone
|
||||
|
||||
const jsonInput = await req.json();
|
||||
const fileType = jsonInput.fileType as string;
|
||||
const encodedFileName = jsonInput.fileName as string;
|
||||
const signedSignature = jsonInput.signature as string;
|
||||
const signedUuid = jsonInput.uuid as string;
|
||||
const signedTimestamp = jsonInput.timestamp as string;
|
||||
const environmentId = jsonInput.environmentId as string;
|
||||
|
||||
const requiredFieldResponse = checkForRequiredFields(environmentId, fileType, encodedFileName);
|
||||
if (requiredFieldResponse) {
|
||||
return {
|
||||
response: requiredFieldResponse,
|
||||
};
|
||||
}
|
||||
|
||||
if (!signedSignature || !signedUuid || !signedTimestamp) {
|
||||
return {
|
||||
response: responses.unauthorizedResponse(),
|
||||
};
|
||||
}
|
||||
|
||||
const authResponse = await checkAuth(authentication, environmentId);
|
||||
if (authResponse) return { response: authResponse };
|
||||
|
||||
const fileName = decodeURIComponent(encodedFileName);
|
||||
|
||||
// Perform server-side file validation
|
||||
const fileValidation = validateFile(fileName, fileType);
|
||||
if (!fileValidation.valid) {
|
||||
return {
|
||||
response: responses.badRequestResponse(fileValidation.error ?? "Invalid file"),
|
||||
};
|
||||
}
|
||||
|
||||
// validate signature
|
||||
|
||||
const validated = validateLocalSignedUrl(
|
||||
signedUuid,
|
||||
fileName,
|
||||
environmentId,
|
||||
fileType,
|
||||
Number(signedTimestamp),
|
||||
signedSignature,
|
||||
ENCRYPTION_KEY
|
||||
);
|
||||
|
||||
if (!validated) {
|
||||
return {
|
||||
response: responses.unauthorizedResponse(),
|
||||
};
|
||||
}
|
||||
|
||||
const base64String = jsonInput.fileBase64String as string;
|
||||
const buffer = Buffer.from(base64String.split(",")[1], "base64");
|
||||
const file = new Blob([buffer], { type: fileType });
|
||||
|
||||
if (!file) {
|
||||
return {
|
||||
response: responses.badRequestResponse("fileBuffer is required"),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const bytes = await file.arrayBuffer();
|
||||
const fileBuffer = Buffer.from(bytes);
|
||||
|
||||
await putFileToLocalStorage(fileName, fileBuffer, accessType, environmentId, UPLOADS_DIR);
|
||||
|
||||
return {
|
||||
response: responses.successResponse({
|
||||
message: "File uploaded successfully",
|
||||
}),
|
||||
};
|
||||
} catch (err) {
|
||||
logger.error(err, "Error uploading file");
|
||||
if (err.name === "FileTooLargeError") {
|
||||
return {
|
||||
response: responses.badRequestResponse(err.message),
|
||||
};
|
||||
}
|
||||
return {
|
||||
response: responses.internalServerErrorResponse("File upload failed"),
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
return responses.internalServerErrorResponse("File upload failed");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { checkAuth, checkForRequiredFields } from "@/app/api/v1/management/storage/lib/utils";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { TApiV1Authentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { validateFile } from "@/lib/fileValidation";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { NextRequest } from "next/server";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { getSignedUrlForPublicFile } from "./lib/getSignedUrl";
|
||||
@@ -12,57 +13,40 @@ import { getSignedUrlForPublicFile } from "./lib/getSignedUrl";
|
||||
// use this to upload files for a specific resource, e.g. a user profile picture or a survey
|
||||
// this api endpoint will return a signed url for uploading the file to s3 and another url for uploading file to the local storage
|
||||
|
||||
export const POST = withV1ApiWrapper({
|
||||
handler: async ({ req, authentication }: { req: NextRequest; authentication: TApiV1Authentication }) => {
|
||||
let storageInput;
|
||||
export const POST = async (request: NextRequest): Promise<Response> => {
|
||||
let storageInput;
|
||||
|
||||
try {
|
||||
storageInput = await req.json();
|
||||
} catch (error) {
|
||||
logger.error({ error, url: req.url }, "Error parsing JSON input");
|
||||
return {
|
||||
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
|
||||
};
|
||||
try {
|
||||
storageInput = await request.json();
|
||||
} catch (error) {
|
||||
logger.error({ error, url: request.url }, "Error parsing JSON input");
|
||||
return responses.badRequestResponse("Malformed JSON input, please check your request body");
|
||||
}
|
||||
|
||||
const { fileName, fileType, environmentId, allowedFileExtensions } = storageInput;
|
||||
|
||||
const requiredFieldResponse = checkForRequiredFields(environmentId, fileType, fileName);
|
||||
if (requiredFieldResponse) return requiredFieldResponse;
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
const authResponse = await checkAuth(session, environmentId, request);
|
||||
if (authResponse) return authResponse;
|
||||
|
||||
// Perform server-side file validation first to block dangerous file types
|
||||
const fileValidation = validateFile(fileName, fileType);
|
||||
if (!fileValidation.valid) {
|
||||
return responses.badRequestResponse(fileValidation.error ?? "Invalid file type");
|
||||
}
|
||||
|
||||
// Also perform client-specified allowed file extensions validation if provided
|
||||
if (allowedFileExtensions?.length) {
|
||||
const fileExtension = fileName.split(".").pop()?.toLowerCase();
|
||||
if (!fileExtension || !allowedFileExtensions.includes(fileExtension)) {
|
||||
return responses.badRequestResponse(
|
||||
`File extension is not allowed, allowed extensions are: ${allowedFileExtensions.join(", ")}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const { fileName, fileType, environmentId, allowedFileExtensions } = storageInput;
|
||||
|
||||
const requiredFieldResponse = checkForRequiredFields(environmentId, fileType, fileName);
|
||||
if (requiredFieldResponse) {
|
||||
return {
|
||||
response: requiredFieldResponse,
|
||||
};
|
||||
}
|
||||
|
||||
const authResponse = await checkAuth(authentication, environmentId);
|
||||
if (authResponse) {
|
||||
return {
|
||||
response: authResponse,
|
||||
};
|
||||
}
|
||||
|
||||
// Perform server-side file validation first to block dangerous file types
|
||||
const fileValidation = validateFile(fileName, fileType);
|
||||
if (!fileValidation.valid) {
|
||||
return {
|
||||
response: responses.badRequestResponse(fileValidation.error ?? "Invalid file type"),
|
||||
};
|
||||
}
|
||||
|
||||
// Also perform client-specified allowed file extensions validation if provided
|
||||
if (allowedFileExtensions?.length) {
|
||||
const fileExtension = fileName.split(".").pop()?.toLowerCase();
|
||||
if (!fileExtension || !allowedFileExtensions.includes(fileExtension)) {
|
||||
return {
|
||||
response: responses.badRequestResponse(
|
||||
`File extension is not allowed, allowed extensions are: ${allowedFileExtensions.join(", ")}`
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
response: await getSignedUrlForPublicFile(fileName, environmentId, fileType),
|
||||
};
|
||||
},
|
||||
});
|
||||
return await getSignedUrlForPublicFile(fileName, environmentId, fileType);
|
||||
};
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { handleErrorResponse } from "@/app/api/v1/auth";
|
||||
import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth";
|
||||
import { deleteSurvey } from "@/app/api/v1/management/surveys/[surveyId]/lib/surveys";
|
||||
import { checkFeaturePermissions } from "@/app/api/v1/management/surveys/lib/utils";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { ApiAuditLog, withApiLogging } from "@/app/lib/api/with-api-logging";
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import { getSurvey, updateSurvey } from "@/lib/survey/service";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { NextRequest } from "next/server";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { ZSurveyUpdateInput } from "@formbricks/types/surveys/types";
|
||||
@@ -28,47 +27,36 @@ const fetchAndAuthorizeSurvey = async (
|
||||
return { survey };
|
||||
};
|
||||
|
||||
export const GET = withV1ApiWrapper({
|
||||
handler: async ({
|
||||
props,
|
||||
authentication,
|
||||
}: {
|
||||
props: { params: Promise<{ surveyId: string }> };
|
||||
authentication: NonNullable<TApiKeyAuthentication>;
|
||||
}) => {
|
||||
const params = await props.params;
|
||||
export const GET = async (
|
||||
request: Request,
|
||||
props: { params: Promise<{ surveyId: string }> }
|
||||
): Promise<Response> => {
|
||||
const params = await props.params;
|
||||
try {
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) return responses.notAuthenticatedResponse();
|
||||
const result = await fetchAndAuthorizeSurvey(params.surveyId, authentication, "GET");
|
||||
if (result.error) return result.error;
|
||||
return responses.successResponse(result.survey);
|
||||
} catch (error) {
|
||||
return handleErrorResponse(error);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await fetchAndAuthorizeSurvey(params.surveyId, authentication, "GET");
|
||||
if (result.error) {
|
||||
return {
|
||||
response: result.error,
|
||||
};
|
||||
}
|
||||
return {
|
||||
response: responses.successResponse(result.survey),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
response: handleErrorResponse(error),
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const DELETE = withV1ApiWrapper({
|
||||
handler: async ({
|
||||
props,
|
||||
auditLog,
|
||||
authentication,
|
||||
}: {
|
||||
props: { params: Promise<{ surveyId: string }> };
|
||||
auditLog: TApiAuditLog;
|
||||
authentication: NonNullable<TApiKeyAuthentication>;
|
||||
}) => {
|
||||
export const DELETE = withApiLogging(
|
||||
async (request: Request, props: { params: Promise<{ surveyId: string }> }, auditLog: ApiAuditLog) => {
|
||||
const params = await props.params;
|
||||
auditLog.targetId = params.surveyId;
|
||||
try {
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) {
|
||||
return {
|
||||
response: responses.notAuthenticatedResponse(),
|
||||
};
|
||||
}
|
||||
auditLog.userId = authentication.apiKeyId;
|
||||
auditLog.organizationId = authentication.organizationId;
|
||||
|
||||
const result = await fetchAndAuthorizeSurvey(params.surveyId, authentication, "DELETE");
|
||||
if (result.error) {
|
||||
return {
|
||||
@@ -87,25 +75,23 @@ export const DELETE = withV1ApiWrapper({
|
||||
};
|
||||
}
|
||||
},
|
||||
action: "deleted",
|
||||
targetType: "survey",
|
||||
});
|
||||
"deleted",
|
||||
"survey"
|
||||
);
|
||||
|
||||
export const PUT = withV1ApiWrapper({
|
||||
handler: async ({
|
||||
req,
|
||||
props,
|
||||
auditLog,
|
||||
authentication,
|
||||
}: {
|
||||
req: NextRequest;
|
||||
props: { params: Promise<{ surveyId: string }> };
|
||||
auditLog: TApiAuditLog;
|
||||
authentication: NonNullable<TApiKeyAuthentication>;
|
||||
}) => {
|
||||
export const PUT = withApiLogging(
|
||||
async (request: Request, props: { params: Promise<{ surveyId: string }> }, auditLog: ApiAuditLog) => {
|
||||
const params = await props.params;
|
||||
auditLog.targetId = params.surveyId;
|
||||
try {
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) {
|
||||
return {
|
||||
response: responses.notAuthenticatedResponse(),
|
||||
};
|
||||
}
|
||||
auditLog.userId = authentication.apiKeyId;
|
||||
|
||||
const result = await fetchAndAuthorizeSurvey(params.surveyId, authentication, "PUT");
|
||||
if (result.error) {
|
||||
return {
|
||||
@@ -120,12 +106,13 @@ export const PUT = withV1ApiWrapper({
|
||||
response: responses.notFoundResponse("Organization", null),
|
||||
};
|
||||
}
|
||||
auditLog.organizationId = organization.id;
|
||||
|
||||
let surveyUpdate;
|
||||
try {
|
||||
surveyUpdate = await req.json();
|
||||
surveyUpdate = await request.json();
|
||||
} catch (error) {
|
||||
logger.error({ error, url: req.url }, "Error parsing JSON input");
|
||||
logger.error({ error, url: request.url }, "Error parsing JSON input");
|
||||
return {
|
||||
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
|
||||
};
|
||||
@@ -159,16 +146,18 @@ export const PUT = withV1ApiWrapper({
|
||||
response: responses.successResponse(updatedSurvey),
|
||||
};
|
||||
} catch (error) {
|
||||
auditLog.status = "failure";
|
||||
return {
|
||||
response: handleErrorResponse(error),
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
auditLog.status = "failure";
|
||||
return {
|
||||
response: handleErrorResponse(error),
|
||||
};
|
||||
}
|
||||
},
|
||||
action: "updated",
|
||||
targetType: "survey",
|
||||
});
|
||||
"updated",
|
||||
"survey"
|
||||
);
|
||||
|
||||
@@ -1,77 +1,55 @@
|
||||
import { handleErrorResponse } from "@/app/api/v1/auth";
|
||||
import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { generateSurveySingleUseIds } from "@/lib/utils/single-use-surveys";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
export const GET = withV1ApiWrapper({
|
||||
handler: async ({
|
||||
req,
|
||||
props,
|
||||
authentication,
|
||||
}: {
|
||||
req: NextRequest;
|
||||
props: { params: Promise<{ surveyId: string }> };
|
||||
authentication: NonNullable<TApiKeyAuthentication>;
|
||||
}) => {
|
||||
try {
|
||||
const params = await props.params;
|
||||
const survey = await getSurvey(params.surveyId);
|
||||
if (!survey) {
|
||||
return {
|
||||
response: responses.notFoundResponse("Survey", params.surveyId),
|
||||
};
|
||||
}
|
||||
if (!hasPermission(authentication.environmentPermissions, survey.environmentId, "GET")) {
|
||||
return {
|
||||
response: responses.unauthorizedResponse(),
|
||||
};
|
||||
}
|
||||
|
||||
if (survey.type !== "link") {
|
||||
return {
|
||||
response: responses.badRequestResponse("Single use links are only available for link surveys"),
|
||||
};
|
||||
}
|
||||
|
||||
if (!survey.singleUse || !survey.singleUse.enabled) {
|
||||
return {
|
||||
response: responses.badRequestResponse("Single use links are not enabled for this survey"),
|
||||
};
|
||||
}
|
||||
const searchParams = req.nextUrl.searchParams;
|
||||
const limit = searchParams.get("limit") ? Number(searchParams.get("limit")) : 10;
|
||||
|
||||
if (limit < 1) {
|
||||
return {
|
||||
response: responses.badRequestResponse("Limit cannot be less than 1"),
|
||||
};
|
||||
}
|
||||
|
||||
if (limit > 5000) {
|
||||
return {
|
||||
response: responses.badRequestResponse("Limit cannot be more than 5000"),
|
||||
};
|
||||
}
|
||||
|
||||
const singleUseIds = generateSurveySingleUseIds(limit, survey.singleUse.isEncrypted);
|
||||
|
||||
const publicDomain = getPublicDomain();
|
||||
// map single use ids to survey links
|
||||
const surveyLinks = singleUseIds.map(
|
||||
(singleUseId) => `${publicDomain}/s/${survey.id}?suId=${singleUseId}`
|
||||
);
|
||||
|
||||
return {
|
||||
response: responses.successResponse(surveyLinks),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
response: handleErrorResponse(error),
|
||||
};
|
||||
export const GET = async (
|
||||
request: NextRequest,
|
||||
props: { params: Promise<{ surveyId: string }> }
|
||||
): Promise<Response> => {
|
||||
const params = await props.params;
|
||||
try {
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) return responses.notAuthenticatedResponse();
|
||||
const survey = await getSurvey(params.surveyId);
|
||||
if (!survey) {
|
||||
return responses.notFoundResponse("Survey", params.surveyId);
|
||||
}
|
||||
},
|
||||
});
|
||||
if (!hasPermission(authentication.environmentPermissions, survey.environmentId, "GET")) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
|
||||
if (survey.type !== "link") {
|
||||
return responses.badRequestResponse("Single use links are only available for link surveys");
|
||||
}
|
||||
|
||||
if (!survey.singleUse || !survey.singleUse.enabled) {
|
||||
return responses.badRequestResponse("Single use links are not enabled for this survey");
|
||||
}
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const limit = searchParams.get("limit") ? Number(searchParams.get("limit")) : 10;
|
||||
|
||||
if (limit < 1) {
|
||||
return responses.badRequestResponse("Limit cannot be less than 1");
|
||||
}
|
||||
|
||||
if (limit > 5000) {
|
||||
return responses.badRequestResponse("Limit cannot be more than 5000");
|
||||
}
|
||||
|
||||
const singleUseIds = generateSurveySingleUseIds(limit, survey.singleUse.isEncrypted);
|
||||
|
||||
const publicDomain = getPublicDomain();
|
||||
// map single use ids to survey links
|
||||
const surveyLinks = singleUseIds.map(
|
||||
(singleUseId) => `${publicDomain}/s/${survey.id}?suId=${singleUseId}`
|
||||
);
|
||||
|
||||
return responses.successResponse(surveyLinks);
|
||||
} catch (error) {
|
||||
return handleErrorResponse(error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,64 +1,55 @@
|
||||
import { authenticateRequest } from "@/app/api/v1/auth";
|
||||
import { checkFeaturePermissions } from "@/app/api/v1/management/surveys/lib/utils";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { ApiAuditLog, withApiLogging } from "@/app/lib/api/with-api-logging";
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import { createSurvey } from "@/lib/survey/service";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { NextRequest } from "next/server";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { ZSurveyCreateInputWithEnvironmentId } from "@formbricks/types/surveys/types";
|
||||
import { getSurveys } from "./lib/surveys";
|
||||
|
||||
export const GET = withV1ApiWrapper({
|
||||
handler: async ({
|
||||
req,
|
||||
authentication,
|
||||
}: {
|
||||
req: NextRequest;
|
||||
authentication: NonNullable<TApiKeyAuthentication>;
|
||||
}) => {
|
||||
export const GET = async (request: Request) => {
|
||||
try {
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) return responses.notAuthenticatedResponse();
|
||||
|
||||
const searchParams = new URL(request.url).searchParams;
|
||||
const limit = searchParams.has("limit") ? Number(searchParams.get("limit")) : undefined;
|
||||
const offset = searchParams.has("offset") ? Number(searchParams.get("offset")) : undefined;
|
||||
|
||||
const environmentIds = authentication.environmentPermissions.map(
|
||||
(permission) => permission.environmentId
|
||||
);
|
||||
const surveys = await getSurveys(environmentIds, limit, offset);
|
||||
|
||||
return responses.successResponse(surveys);
|
||||
} catch (error) {
|
||||
if (error instanceof DatabaseError) {
|
||||
return responses.badRequestResponse(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const POST = withApiLogging(
|
||||
async (request: Request, _, auditLog: ApiAuditLog) => {
|
||||
try {
|
||||
const searchParams = new URL(req.url).searchParams;
|
||||
const limit = searchParams.has("limit") ? Number(searchParams.get("limit")) : undefined;
|
||||
const offset = searchParams.has("offset") ? Number(searchParams.get("offset")) : undefined;
|
||||
|
||||
const environmentIds = authentication.environmentPermissions.map(
|
||||
(permission) => permission.environmentId
|
||||
);
|
||||
const surveys = await getSurveys(environmentIds, limit, offset);
|
||||
|
||||
return {
|
||||
response: responses.successResponse(surveys),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof DatabaseError) {
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) {
|
||||
return {
|
||||
response: responses.badRequestResponse(error.message),
|
||||
response: responses.notAuthenticatedResponse(),
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
});
|
||||
auditLog.userId = authentication.apiKeyId;
|
||||
|
||||
export const POST = withV1ApiWrapper({
|
||||
handler: async ({
|
||||
req,
|
||||
auditLog,
|
||||
authentication,
|
||||
}: {
|
||||
req: NextRequest;
|
||||
auditLog: TApiAuditLog;
|
||||
authentication: NonNullable<TApiKeyAuthentication>;
|
||||
}) => {
|
||||
try {
|
||||
let surveyInput;
|
||||
try {
|
||||
surveyInput = await req.json();
|
||||
surveyInput = await request.json();
|
||||
} catch (error) {
|
||||
logger.error({ error, url: req.url }, "Error parsing JSON");
|
||||
logger.error({ error, url: request.url }, "Error parsing JSON");
|
||||
return {
|
||||
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
|
||||
};
|
||||
@@ -89,6 +80,7 @@ export const POST = withV1ApiWrapper({
|
||||
response: responses.notFoundResponse("Organization", null),
|
||||
};
|
||||
}
|
||||
auditLog.organizationId = organization.id;
|
||||
|
||||
const surveyData = { ...inputValidation.data, environmentId };
|
||||
|
||||
@@ -102,19 +94,18 @@ export const POST = withV1ApiWrapper({
|
||||
const survey = await createSurvey(environmentId, { ...surveyData, environmentId: undefined });
|
||||
auditLog.targetId = survey.id;
|
||||
auditLog.newObject = survey;
|
||||
|
||||
return {
|
||||
response: responses.successResponse(survey),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof DatabaseError) {
|
||||
return {
|
||||
response: responses.internalServerErrorResponse(error.message),
|
||||
response: responses.badRequestResponse(error.message),
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
action: "created",
|
||||
targetType: "survey",
|
||||
});
|
||||
"created",
|
||||
"survey"
|
||||
);
|
||||
|
||||
@@ -1,52 +1,53 @@
|
||||
import { authenticateRequest } from "@/app/api/v1/auth";
|
||||
import { deleteWebhook, getWebhook } from "@/app/api/v1/webhooks/[webhookId]/lib/webhook";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { ApiAuditLog, withApiLogging } from "@/app/lib/api/with-api-logging";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { NextRequest } from "next/server";
|
||||
import { headers } from "next/headers";
|
||||
import { logger } from "@formbricks/logger";
|
||||
|
||||
export const GET = withV1ApiWrapper({
|
||||
handler: async ({
|
||||
props,
|
||||
authentication,
|
||||
}: {
|
||||
props: { params: Promise<{ webhookId: string }> };
|
||||
authentication: NonNullable<TApiKeyAuthentication>;
|
||||
}) => {
|
||||
const params = await props.params;
|
||||
export const GET = async (request: Request, props: { params: Promise<{ webhookId: string }> }) => {
|
||||
const params = await props.params;
|
||||
const headersList = await headers();
|
||||
const apiKey = headersList.get("x-api-key");
|
||||
if (!apiKey) {
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) {
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
|
||||
const webhook = await getWebhook(params.webhookId);
|
||||
if (!webhook) {
|
||||
return {
|
||||
response: responses.notFoundResponse("Webhook", params.webhookId),
|
||||
};
|
||||
}
|
||||
if (!hasPermission(authentication.environmentPermissions, webhook.environmentId, "GET")) {
|
||||
return {
|
||||
response: responses.unauthorizedResponse(),
|
||||
};
|
||||
}
|
||||
return {
|
||||
response: responses.successResponse(webhook),
|
||||
};
|
||||
},
|
||||
});
|
||||
// add webhook to database
|
||||
const webhook = await getWebhook(params.webhookId);
|
||||
if (!webhook) {
|
||||
return responses.notFoundResponse("Webhook", params.webhookId);
|
||||
}
|
||||
if (!hasPermission(authentication.environmentPermissions, webhook.environmentId, "GET")) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
return responses.successResponse(webhook);
|
||||
};
|
||||
|
||||
export const DELETE = withV1ApiWrapper({
|
||||
handler: async ({
|
||||
req,
|
||||
props,
|
||||
auditLog,
|
||||
authentication,
|
||||
}: {
|
||||
req: NextRequest;
|
||||
props: { params: Promise<{ webhookId: string }> };
|
||||
auditLog: TApiAuditLog;
|
||||
authentication: NonNullable<TApiKeyAuthentication>;
|
||||
}) => {
|
||||
export const DELETE = withApiLogging(
|
||||
async (request: Request, props: { params: Promise<{ webhookId: string }> }, auditLog: ApiAuditLog) => {
|
||||
const params = await props.params;
|
||||
auditLog.targetId = params.webhookId;
|
||||
|
||||
const headersList = headers();
|
||||
const apiKey = headersList.get("x-api-key");
|
||||
if (!apiKey) {
|
||||
return {
|
||||
response: responses.notAuthenticatedResponse(),
|
||||
};
|
||||
}
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) {
|
||||
return {
|
||||
response: responses.notAuthenticatedResponse(),
|
||||
};
|
||||
}
|
||||
auditLog.userId = authentication.apiKeyId;
|
||||
auditLog.organizationId = authentication.organizationId;
|
||||
// check if webhook exists
|
||||
const webhook = await getWebhook(params.webhookId);
|
||||
if (!webhook) {
|
||||
@@ -70,12 +71,12 @@ export const DELETE = withV1ApiWrapper({
|
||||
};
|
||||
} catch (e) {
|
||||
auditLog.status = "failure";
|
||||
logger.error({ error: e, url: req.url }, "Error deleting webhook");
|
||||
logger.error({ error: e, url: request.url }, "Error deleting webhook");
|
||||
return {
|
||||
response: responses.notFoundResponse("Webhook", params.webhookId),
|
||||
};
|
||||
}
|
||||
},
|
||||
action: "deleted",
|
||||
targetType: "webhook",
|
||||
});
|
||||
"deleted",
|
||||
"webhook"
|
||||
);
|
||||
|
||||
@@ -1,44 +1,43 @@
|
||||
import { authenticateRequest } from "@/app/api/v1/auth";
|
||||
import { createWebhook, getWebhooks } from "@/app/api/v1/webhooks/lib/webhook";
|
||||
import { ZWebhookInput } from "@/app/api/v1/webhooks/types/webhooks";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { ApiAuditLog, withApiLogging } from "@/app/lib/api/with-api-logging";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { NextRequest } from "next/server";
|
||||
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
|
||||
|
||||
export const GET = withV1ApiWrapper({
|
||||
handler: async ({ authentication }: { authentication: NonNullable<TApiKeyAuthentication> }) => {
|
||||
try {
|
||||
const environmentIds = authentication.environmentPermissions.map(
|
||||
(permission) => permission.environmentId
|
||||
);
|
||||
const webhooks = await getWebhooks(environmentIds);
|
||||
return {
|
||||
response: responses.successResponse(webhooks),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof DatabaseError) {
|
||||
return {
|
||||
response: responses.internalServerErrorResponse(error.message),
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
export const GET = async (request: Request) => {
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) {
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
try {
|
||||
const environmentIds = authentication.environmentPermissions.map(
|
||||
(permission) => permission.environmentId
|
||||
);
|
||||
const webhooks = await getWebhooks(environmentIds);
|
||||
return responses.successResponse(webhooks);
|
||||
} catch (error) {
|
||||
if (error instanceof DatabaseError) {
|
||||
return responses.internalServerErrorResponse(error.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const POST = withV1ApiWrapper({
|
||||
handler: async ({
|
||||
req,
|
||||
auditLog,
|
||||
authentication,
|
||||
}: {
|
||||
req: NextRequest;
|
||||
auditLog: TApiAuditLog;
|
||||
authentication: NonNullable<TApiKeyAuthentication>;
|
||||
}) => {
|
||||
const webhookInput = await req.json();
|
||||
export const POST = withApiLogging(
|
||||
async (request: Request, _, auditLog: ApiAuditLog) => {
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) {
|
||||
return {
|
||||
response: responses.notAuthenticatedResponse(),
|
||||
};
|
||||
}
|
||||
|
||||
auditLog.organizationId = authentication.organizationId;
|
||||
auditLog.userId = authentication.apiKeyId;
|
||||
const webhookInput = await request.json();
|
||||
const inputValidation = ZWebhookInput.safeParse(webhookInput);
|
||||
|
||||
if (!inputValidation.success) {
|
||||
@@ -86,6 +85,6 @@ export const POST = withV1ApiWrapper({
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
action: "created",
|
||||
targetType: "webhook",
|
||||
});
|
||||
"created",
|
||||
"webhook"
|
||||
);
|
||||
|
||||
@@ -114,6 +114,7 @@ const mockResponsePrisma = {
|
||||
language: "en",
|
||||
displayId: null,
|
||||
tags: [],
|
||||
notes: [],
|
||||
};
|
||||
|
||||
const expectedResponse: TResponse = {
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
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 { responses } from "./response";
|
||||
import { ApiAuditLog } from "./with-api-logging";
|
||||
|
||||
// Mocks
|
||||
// This top-level mock is crucial for the SUT (withApiLogging.ts)
|
||||
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
|
||||
__esModule: true,
|
||||
queueAuditEvent: vi.fn(),
|
||||
@@ -30,6 +29,7 @@ vi.mock("@formbricks/logger", () => {
|
||||
return {
|
||||
logger: {
|
||||
withContext: mockWithContextInstance,
|
||||
// These are for direct calls like logger.error(), logger.warn()
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
@@ -37,70 +37,35 @@ vi.mock("@formbricks/logger", () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/app/api/v1/auth", () => ({
|
||||
authenticateRequest: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/auth/lib/authOptions", () => ({
|
||||
authOptions: {},
|
||||
}));
|
||||
|
||||
vi.mock("next-auth", () => ({
|
||||
getServerSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/middleware/endpoint-validator", async () => {
|
||||
const original = await vi.importActual("@/app/middleware/endpoint-validator");
|
||||
return {
|
||||
...original,
|
||||
isClientSideApiRoute: vi.fn().mockReturnValue({ isClientSideApi: false, isRateLimited: true }),
|
||||
isManagementApiRoute: vi.fn().mockReturnValue({ isManagementApi: false, authenticationMethod: "apiKey" }),
|
||||
isIntegrationRoute: vi.fn().mockReturnValue(false),
|
||||
isSyncWithUserIdentificationEndpoint: vi.fn().mockReturnValue(null),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/modules/core/rate-limit/helpers", () => ({
|
||||
applyIPRateLimit: vi.fn(),
|
||||
applyRateLimit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({
|
||||
rateLimitConfigs: {
|
||||
api: {
|
||||
client: { windowMs: 60000, max: 100 },
|
||||
v1: { windowMs: 60000, max: 1000 },
|
||||
syncUserIdentification: { windowMs: 60000, max: 50 },
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
function createMockRequest({ method = "GET", url = "https://api.test/endpoint", headers = new Map() } = {}) {
|
||||
// Parse the URL to get the pathname
|
||||
const parsedUrl = url.startsWith("/") ? new URL(url, "http://localhost:3000") : new URL(url);
|
||||
|
||||
return {
|
||||
method,
|
||||
url,
|
||||
headers: {
|
||||
get: (key: string) => headers.get(key),
|
||||
},
|
||||
nextUrl: {
|
||||
pathname: parsedUrl.pathname,
|
||||
},
|
||||
} as unknown as NextRequest;
|
||||
} as unknown as Request;
|
||||
}
|
||||
|
||||
const mockApiAuthentication = {
|
||||
hashedApiKey: "test-api-key",
|
||||
apiKeyId: "api-key-1",
|
||||
// Minimal valid ApiAuditLog
|
||||
const baseAudit: ApiAuditLog = {
|
||||
action: "created",
|
||||
targetType: "survey",
|
||||
userId: "user-1",
|
||||
targetId: "target-1",
|
||||
organizationId: "org-1",
|
||||
} as TAuthenticationApiKey;
|
||||
status: "failure",
|
||||
userType: "api",
|
||||
};
|
||||
|
||||
describe("withV1ApiWrapper", () => {
|
||||
describe("withApiLogging", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.resetModules(); // Reset SUT and other potentially cached modules
|
||||
// vi.doMock for constants if a specific test needs to override it
|
||||
// The top-level mocks for audit-logs, sentry, logger should be re-applied implicitly
|
||||
// or are already in place due to vi.mock hoisting.
|
||||
|
||||
// Restore the mock for constants to its default for most tests
|
||||
vi.doMock("@/lib/constants", () => ({
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
IS_PRODUCTION: true,
|
||||
@@ -109,45 +74,34 @@ describe("withV1ApiWrapper", () => {
|
||||
REDIS_URL: "redis://localhost:6379",
|
||||
}));
|
||||
|
||||
vi.clearAllMocks();
|
||||
vi.clearAllMocks(); // Clear call counts etc. for all vi.fn()
|
||||
});
|
||||
|
||||
test("logs and audits on error response with API key authentication", async () => {
|
||||
test("logs and audits on error response", async () => {
|
||||
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
|
||||
"@/modules/ee/audit-logs/lib/handler"
|
||||
)) as unknown as { queueAuditEvent: Mock };
|
||||
const { authenticateRequest } = await import("@/app/api/v1/auth");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
|
||||
"@/app/middleware/endpoint-validator"
|
||||
);
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
|
||||
vi.mocked(isManagementApiRoute).mockReturnValue({
|
||||
isManagementApi: true,
|
||||
authenticationMethod: AuthenticationMethod.ApiKey,
|
||||
});
|
||||
vi.mocked(isIntegrationRoute).mockReturnValue(false);
|
||||
|
||||
const handler = vi.fn().mockImplementation(async ({ auditLog }) => {
|
||||
const handler = vi.fn().mockImplementation(async (req, _props, auditLog) => {
|
||||
if (auditLog) {
|
||||
auditLog.action = "created";
|
||||
auditLog.targetType = "survey";
|
||||
auditLog.userId = "user-1";
|
||||
auditLog.targetId = "target-1";
|
||||
auditLog.organizationId = "org-1";
|
||||
auditLog.userType = "api";
|
||||
}
|
||||
return {
|
||||
response: responses.internalServerErrorResponse("fail"),
|
||||
};
|
||||
});
|
||||
|
||||
const req = createMockRequest({
|
||||
url: "https://api.test/v1/management/surveys",
|
||||
headers: new Map([["x-request-id", "abc-123"]]),
|
||||
});
|
||||
const { withV1ApiWrapper } = await import("./with-api-logging");
|
||||
const wrapped = withV1ApiWrapper({ handler, action: "created", targetType: "survey" });
|
||||
const req = createMockRequest({ headers: new Map([["x-request-id", "abc-123"]]) });
|
||||
const { withApiLogging } = await import("./with-api-logging"); // SUT dynamically imported
|
||||
const wrapped = withApiLogging(handler, "created", "survey");
|
||||
await wrapped(req, undefined);
|
||||
|
||||
expect(logger.withContext).toHaveBeenCalled();
|
||||
expect(mockContextualLoggerError).toHaveBeenCalled();
|
||||
expect(mockContextualLoggerWarn).not.toHaveBeenCalled();
|
||||
expect(mockContextualLoggerInfo).not.toHaveBeenCalled();
|
||||
expect(mockedQueueAuditEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
eventId: "abc-123",
|
||||
@@ -156,7 +110,7 @@ describe("withV1ApiWrapper", () => {
|
||||
action: "created",
|
||||
status: "failure",
|
||||
targetType: "survey",
|
||||
userId: "api-key-1",
|
||||
userId: "user-1",
|
||||
targetId: "target-1",
|
||||
organizationId: "org-1",
|
||||
})
|
||||
@@ -171,36 +125,28 @@ describe("withV1ApiWrapper", () => {
|
||||
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
|
||||
"@/modules/ee/audit-logs/lib/handler"
|
||||
)) as unknown as { queueAuditEvent: Mock };
|
||||
const { authenticateRequest } = await import("@/app/api/v1/auth");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
|
||||
"@/app/middleware/endpoint-validator"
|
||||
);
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
|
||||
vi.mocked(isManagementApiRoute).mockReturnValue({
|
||||
isManagementApi: true,
|
||||
authenticationMethod: AuthenticationMethod.ApiKey,
|
||||
});
|
||||
vi.mocked(isIntegrationRoute).mockReturnValue(false);
|
||||
|
||||
const handler = vi.fn().mockImplementation(async ({ auditLog }) => {
|
||||
const handler = vi.fn().mockImplementation(async (req, _props, auditLog) => {
|
||||
if (auditLog) {
|
||||
auditLog.action = "created";
|
||||
auditLog.targetType = "survey";
|
||||
auditLog.userId = "user-1";
|
||||
auditLog.targetId = "target-1";
|
||||
auditLog.organizationId = "org-1";
|
||||
auditLog.userType = "api";
|
||||
}
|
||||
return {
|
||||
response: responses.badRequestResponse("bad req"),
|
||||
};
|
||||
});
|
||||
|
||||
const req = createMockRequest({ url: "https://api.test/v1/management/surveys" });
|
||||
const { withV1ApiWrapper } = await import("./with-api-logging");
|
||||
const wrapped = withV1ApiWrapper({ handler, action: "created", targetType: "survey" });
|
||||
const req = createMockRequest();
|
||||
const { withApiLogging } = await import("./with-api-logging");
|
||||
const wrapped = withApiLogging(handler, "created", "survey");
|
||||
await wrapped(req, undefined);
|
||||
|
||||
expect(Sentry.captureException).not.toHaveBeenCalled();
|
||||
expect(logger.withContext).toHaveBeenCalled();
|
||||
expect(mockContextualLoggerError).toHaveBeenCalled();
|
||||
expect(mockContextualLoggerWarn).not.toHaveBeenCalled();
|
||||
expect(mockContextualLoggerInfo).not.toHaveBeenCalled();
|
||||
expect(mockedQueueAuditEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userType: "api",
|
||||
@@ -208,7 +154,7 @@ describe("withV1ApiWrapper", () => {
|
||||
action: "created",
|
||||
status: "failure",
|
||||
targetType: "survey",
|
||||
userId: "api-key-1",
|
||||
userId: "user-1",
|
||||
targetId: "target-1",
|
||||
organizationId: "org-1",
|
||||
})
|
||||
@@ -219,34 +165,21 @@ describe("withV1ApiWrapper", () => {
|
||||
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
|
||||
"@/modules/ee/audit-logs/lib/handler"
|
||||
)) as unknown as { queueAuditEvent: Mock };
|
||||
const { authenticateRequest } = await import("@/app/api/v1/auth");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
|
||||
"@/app/middleware/endpoint-validator"
|
||||
);
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
|
||||
vi.mocked(isManagementApiRoute).mockReturnValue({
|
||||
isManagementApi: true,
|
||||
authenticationMethod: AuthenticationMethod.ApiKey,
|
||||
});
|
||||
vi.mocked(isIntegrationRoute).mockReturnValue(false);
|
||||
|
||||
const handler = vi.fn().mockImplementation(async ({ auditLog }) => {
|
||||
const handler = vi.fn().mockImplementation(async (req, _props, auditLog) => {
|
||||
if (auditLog) {
|
||||
auditLog.action = "created";
|
||||
auditLog.targetType = "survey";
|
||||
auditLog.userId = "user-1";
|
||||
auditLog.targetId = "target-1";
|
||||
auditLog.organizationId = "org-1";
|
||||
auditLog.userType = "api";
|
||||
}
|
||||
throw new Error("fail!");
|
||||
});
|
||||
|
||||
const req = createMockRequest({
|
||||
url: "https://api.test/v1/management/surveys",
|
||||
headers: new Map([["x-request-id", "err-1"]]),
|
||||
});
|
||||
const { withV1ApiWrapper } = await import("./with-api-logging");
|
||||
const wrapped = withV1ApiWrapper({ handler, action: "created", targetType: "survey" });
|
||||
const req = createMockRequest({ headers: new Map([["x-request-id", "err-1"]]) });
|
||||
const { withApiLogging } = await import("./with-api-logging");
|
||||
const wrapped = withApiLogging(handler, "created", "survey");
|
||||
const res = await wrapped(req, undefined);
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
const body = await res.json();
|
||||
expect(body).toEqual({
|
||||
@@ -256,6 +189,8 @@ describe("withV1ApiWrapper", () => {
|
||||
});
|
||||
expect(logger.withContext).toHaveBeenCalled();
|
||||
expect(mockContextualLoggerError).toHaveBeenCalled();
|
||||
expect(mockContextualLoggerWarn).not.toHaveBeenCalled();
|
||||
expect(mockContextualLoggerInfo).not.toHaveBeenCalled();
|
||||
expect(mockedQueueAuditEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
eventId: "err-1",
|
||||
@@ -264,7 +199,7 @@ describe("withV1ApiWrapper", () => {
|
||||
action: "created",
|
||||
status: "failure",
|
||||
targetType: "survey",
|
||||
userId: "api-key-1",
|
||||
userId: "user-1",
|
||||
targetId: "target-1",
|
||||
organizationId: "org-1",
|
||||
})
|
||||
@@ -275,39 +210,31 @@ describe("withV1ApiWrapper", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("does not log on success response but still audits", async () => {
|
||||
test("does not log/audit on success response", async () => {
|
||||
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
|
||||
"@/modules/ee/audit-logs/lib/handler"
|
||||
)) as unknown as { queueAuditEvent: Mock };
|
||||
const { authenticateRequest } = await import("@/app/api/v1/auth");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
|
||||
"@/app/middleware/endpoint-validator"
|
||||
);
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
|
||||
vi.mocked(isManagementApiRoute).mockReturnValue({
|
||||
isManagementApi: true,
|
||||
authenticationMethod: AuthenticationMethod.ApiKey,
|
||||
});
|
||||
vi.mocked(isIntegrationRoute).mockReturnValue(false);
|
||||
|
||||
const handler = vi.fn().mockImplementation(async ({ auditLog }) => {
|
||||
const handler = vi.fn().mockImplementation(async (req, _props, auditLog) => {
|
||||
if (auditLog) {
|
||||
auditLog.action = "created";
|
||||
auditLog.targetType = "survey";
|
||||
auditLog.userId = "user-1";
|
||||
auditLog.targetId = "target-1";
|
||||
auditLog.organizationId = "org-1";
|
||||
auditLog.userType = "api";
|
||||
}
|
||||
return {
|
||||
response: responses.successResponse({ ok: true }),
|
||||
};
|
||||
});
|
||||
|
||||
const req = createMockRequest({ url: "https://api.test/v1/management/surveys" });
|
||||
const { withV1ApiWrapper } = await import("./with-api-logging");
|
||||
const wrapped = withV1ApiWrapper({ handler, action: "created", targetType: "survey" });
|
||||
const req = createMockRequest();
|
||||
const { withApiLogging } = await import("./with-api-logging");
|
||||
const wrapped = withApiLogging(handler, "created", "survey");
|
||||
await wrapped(req, undefined);
|
||||
|
||||
expect(logger.withContext).not.toHaveBeenCalled();
|
||||
expect(mockContextualLoggerError).not.toHaveBeenCalled();
|
||||
expect(mockContextualLoggerWarn).not.toHaveBeenCalled();
|
||||
expect(mockContextualLoggerInfo).not.toHaveBeenCalled();
|
||||
expect(mockedQueueAuditEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userType: "api",
|
||||
@@ -315,7 +242,7 @@ describe("withV1ApiWrapper", () => {
|
||||
action: "created",
|
||||
status: "success",
|
||||
targetType: "survey",
|
||||
userId: "api-key-1",
|
||||
userId: "user-1",
|
||||
targetId: "target-1",
|
||||
organizationId: "org-1",
|
||||
})
|
||||
@@ -324,6 +251,7 @@ describe("withV1ApiWrapper", () => {
|
||||
});
|
||||
|
||||
test("does not call audit if AUDIT_LOG_ENABLED is false", async () => {
|
||||
// For this specific test, we override the AUDIT_LOG_ENABLED constant
|
||||
vi.doMock("@/lib/constants", () => ({
|
||||
AUDIT_LOG_ENABLED: false,
|
||||
IS_PRODUCTION: true,
|
||||
@@ -335,209 +263,15 @@ describe("withV1ApiWrapper", () => {
|
||||
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
|
||||
"@/modules/ee/audit-logs/lib/handler"
|
||||
)) as unknown as { queueAuditEvent: Mock };
|
||||
const { authenticateRequest } = await import("@/app/api/v1/auth");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
|
||||
"@/app/middleware/endpoint-validator"
|
||||
);
|
||||
const { withV1ApiWrapper } = await import("./with-api-logging");
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
|
||||
vi.mocked(isManagementApiRoute).mockReturnValue({
|
||||
isManagementApi: true,
|
||||
authenticationMethod: AuthenticationMethod.ApiKey,
|
||||
});
|
||||
vi.mocked(isIntegrationRoute).mockReturnValue(false);
|
||||
const { withApiLogging } = await import("./with-api-logging");
|
||||
|
||||
const handler = vi.fn().mockResolvedValue({
|
||||
response: responses.internalServerErrorResponse("fail"),
|
||||
audit: { ...baseAudit },
|
||||
});
|
||||
|
||||
const req = createMockRequest({ url: "https://api.test/v1/management/surveys" });
|
||||
const wrapped = withV1ApiWrapper({ handler, action: "created", targetType: "survey" });
|
||||
const req = createMockRequest();
|
||||
const wrapped = withApiLogging(handler, "created", "survey");
|
||||
await wrapped(req, undefined);
|
||||
|
||||
expect(mockedQueueAuditEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("handles client-side API routes without authentication", async () => {
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
|
||||
"@/app/middleware/endpoint-validator"
|
||||
);
|
||||
const { authenticateRequest } = await import("@/app/api/v1/auth");
|
||||
const { applyIPRateLimit } = await import("@/modules/core/rate-limit/helpers");
|
||||
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: true, isRateLimited: true });
|
||||
vi.mocked(isManagementApiRoute).mockReturnValue({
|
||||
isManagementApi: false,
|
||||
authenticationMethod: AuthenticationMethod.None,
|
||||
});
|
||||
vi.mocked(isIntegrationRoute).mockReturnValue(false);
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(null);
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue(undefined);
|
||||
|
||||
const handler = vi.fn().mockResolvedValue({
|
||||
response: responses.successResponse({ data: "test" }),
|
||||
});
|
||||
|
||||
const req = createMockRequest({ url: "/api/v1/client/displays" });
|
||||
const { withV1ApiWrapper } = await import("./with-api-logging");
|
||||
const wrapped = withV1ApiWrapper({ handler });
|
||||
const res = await wrapped(req, undefined);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(handler).toHaveBeenCalledWith({
|
||||
req,
|
||||
props: undefined,
|
||||
auditLog: undefined,
|
||||
authentication: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("returns authentication error for non-client routes without auth", async () => {
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
|
||||
"@/app/middleware/endpoint-validator"
|
||||
);
|
||||
const { authenticateRequest } = await import("@/app/api/v1/auth");
|
||||
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
|
||||
vi.mocked(isManagementApiRoute).mockReturnValue({
|
||||
isManagementApi: true,
|
||||
authenticationMethod: AuthenticationMethod.ApiKey,
|
||||
});
|
||||
vi.mocked(isIntegrationRoute).mockReturnValue(false);
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(null);
|
||||
|
||||
const handler = vi.fn();
|
||||
const req = createMockRequest({ url: "https://api.test/v1/management/surveys" });
|
||||
const { withV1ApiWrapper } = await import("./with-api-logging");
|
||||
const wrapped = withV1ApiWrapper({ handler });
|
||||
const res = await wrapped(req, undefined);
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("handles rate limiting errors", async () => {
|
||||
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
|
||||
"@/app/middleware/endpoint-validator"
|
||||
);
|
||||
const { authenticateRequest } = await import("@/app/api/v1/auth");
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
|
||||
vi.mocked(isManagementApiRoute).mockReturnValue({
|
||||
isManagementApi: true,
|
||||
authenticationMethod: AuthenticationMethod.ApiKey,
|
||||
});
|
||||
vi.mocked(isIntegrationRoute).mockReturnValue(false);
|
||||
const rateLimitError = new Error("Rate limit exceeded");
|
||||
rateLimitError.message = "Rate limit exceeded";
|
||||
vi.mocked(applyRateLimit).mockRejectedValue(rateLimitError);
|
||||
|
||||
const handler = vi.fn();
|
||||
const req = createMockRequest({ url: "https://api.test/v1/management/surveys" });
|
||||
const { withV1ApiWrapper } = await import("./with-api-logging");
|
||||
const wrapped = withV1ApiWrapper({ handler });
|
||||
const res = await wrapped(req, undefined);
|
||||
|
||||
expect(res.status).toBe(429);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("handles sync user identification rate limiting", async () => {
|
||||
const { applyRateLimit, applyIPRateLimit } = await import("@/modules/core/rate-limit/helpers");
|
||||
const {
|
||||
isClientSideApiRoute,
|
||||
isManagementApiRoute,
|
||||
isIntegrationRoute,
|
||||
isSyncWithUserIdentificationEndpoint,
|
||||
} = await import("@/app/middleware/endpoint-validator");
|
||||
const { authenticateRequest } = await import("@/app/api/v1/auth");
|
||||
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: true, isRateLimited: true });
|
||||
vi.mocked(isManagementApiRoute).mockReturnValue({
|
||||
isManagementApi: false,
|
||||
authenticationMethod: AuthenticationMethod.None,
|
||||
});
|
||||
vi.mocked(isIntegrationRoute).mockReturnValue(false);
|
||||
vi.mocked(isSyncWithUserIdentificationEndpoint).mockReturnValue({
|
||||
userId: "user-123",
|
||||
environmentId: "env-123",
|
||||
});
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(null);
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue(undefined);
|
||||
const rateLimitError = new Error("Sync rate limit exceeded");
|
||||
rateLimitError.message = "Sync rate limit exceeded";
|
||||
vi.mocked(applyRateLimit).mockRejectedValue(rateLimitError);
|
||||
|
||||
const handler = vi.fn();
|
||||
const req = createMockRequest({ url: "/api/v1/client/env-123/app/sync/user-123" });
|
||||
const { withV1ApiWrapper } = await import("./with-api-logging");
|
||||
const wrapped = withV1ApiWrapper({ handler });
|
||||
const res = await wrapped(req, undefined);
|
||||
|
||||
expect(res.status).toBe(429);
|
||||
expect(applyRateLimit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ windowMs: 60000, max: 50 }),
|
||||
"user-123"
|
||||
);
|
||||
});
|
||||
|
||||
test("skips audit log creation when no action/targetType provided", async () => {
|
||||
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
|
||||
"@/modules/ee/audit-logs/lib/handler"
|
||||
)) as unknown as { queueAuditEvent: Mock };
|
||||
const { authenticateRequest } = await import("@/app/api/v1/auth");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
|
||||
"@/app/middleware/endpoint-validator"
|
||||
);
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
|
||||
vi.mocked(isManagementApiRoute).mockReturnValue({
|
||||
isManagementApi: true,
|
||||
authenticationMethod: AuthenticationMethod.ApiKey,
|
||||
});
|
||||
vi.mocked(isIntegrationRoute).mockReturnValue(false);
|
||||
|
||||
const handler = vi.fn().mockResolvedValue({
|
||||
response: responses.successResponse({ data: "test" }),
|
||||
});
|
||||
|
||||
const req = createMockRequest({ url: "https://api.test/v1/management/surveys" });
|
||||
const { withV1ApiWrapper } = await import("./with-api-logging");
|
||||
const wrapped = withV1ApiWrapper({ handler });
|
||||
await wrapped(req, undefined);
|
||||
|
||||
expect(handler).toHaveBeenCalledWith({
|
||||
req,
|
||||
props: undefined,
|
||||
auditLog: undefined,
|
||||
authentication: mockApiAuthentication,
|
||||
});
|
||||
expect(mockedQueueAuditEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildAuditLogBaseObject", () => {
|
||||
test("creates audit log base object with correct structure", async () => {
|
||||
const { buildAuditLogBaseObject } = await import("./with-api-logging");
|
||||
|
||||
const result = buildAuditLogBaseObject("created", "survey", "https://api.test/v1/management/surveys");
|
||||
|
||||
expect(result).toEqual({
|
||||
action: "created",
|
||||
targetType: "survey",
|
||||
userId: "unknown",
|
||||
targetId: "unknown",
|
||||
organizationId: "unknown",
|
||||
status: "failure",
|
||||
oldObject: undefined,
|
||||
newObject: undefined,
|
||||
userType: "api",
|
||||
apiUrl: "https://api.test/v1/management/surveys",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,327 +1,83 @@
|
||||
import { authenticateRequest } from "@/app/api/v1/auth";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import {
|
||||
AuthenticationMethod,
|
||||
isClientSideApiRoute,
|
||||
isIntegrationRoute,
|
||||
isManagementApiRoute,
|
||||
isSyncWithUserIdentificationEndpoint,
|
||||
} from "@/app/middleware/endpoint-validator";
|
||||
import { AUDIT_LOG_ENABLED, IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { applyIPRateLimit, applyRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
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;
|
||||
export type TApiKeyAuthentication = TAuthenticationApiKey | null;
|
||||
export type TSessionAuthentication = Session | null;
|
||||
|
||||
// Interface for handler function parameters
|
||||
export interface THandlerParams<TProps = unknown> {
|
||||
req?: NextRequest;
|
||||
props?: TProps;
|
||||
auditLog?: TApiAuditLog;
|
||||
authentication?: TApiKeyAuthentication | TSessionAuthentication;
|
||||
}
|
||||
|
||||
// Interface for wrapper function parameters
|
||||
export interface TWithV1ApiWrapperParams<TResult extends { response: Response }, TProps = unknown> {
|
||||
handler: (params: THandlerParams<TProps>) => Promise<TResult>;
|
||||
action?: TAuditAction;
|
||||
targetType?: TAuditTarget;
|
||||
}
|
||||
|
||||
enum ApiV1RouteTypeEnum {
|
||||
Client = "client",
|
||||
General = "general",
|
||||
Integration = "integration",
|
||||
}
|
||||
export type ApiAuditLog = Parameters<typeof queueAuditEvent>[0];
|
||||
|
||||
/**
|
||||
* Apply client-side API rate limiting (IP-based or sync-specific)
|
||||
* withApiLogging wraps an V1 API handler to provide unified error/audit/system logging.
|
||||
* - Handler must return { response }.
|
||||
* - If not a successResponse, calls audit log, system log, and Sentry as needed.
|
||||
* - System and Sentry logs are always called for non-success responses.
|
||||
*/
|
||||
const applyClientRateLimit = async (url: string): Promise<void> => {
|
||||
const syncEndpoint = isSyncWithUserIdentificationEndpoint(url);
|
||||
if (syncEndpoint) {
|
||||
const syncRateLimitConfig = rateLimitConfigs.api.syncUserIdentification;
|
||||
await applyRateLimit(syncRateLimitConfig, syncEndpoint.userId);
|
||||
} else {
|
||||
await applyIPRateLimit(rateLimitConfigs.api.client);
|
||||
}
|
||||
};
|
||||
export const withApiLogging = <TResult extends { response: Response }>(
|
||||
handler: (req: Request, props?: any, auditLog?: ApiAuditLog) => Promise<TResult>,
|
||||
action: TAuditAction,
|
||||
targetType: TAuditTarget
|
||||
) => {
|
||||
return async function (req: Request, props: any): Promise<Response> {
|
||||
const auditLog = buildAuditLogBaseObject(action, targetType, req.url);
|
||||
|
||||
/**
|
||||
* Handle rate limiting based on authentication and API type
|
||||
*/
|
||||
const handleRateLimiting = async (
|
||||
url: string,
|
||||
authentication: TApiV1Authentication,
|
||||
routeType: ApiV1RouteTypeEnum
|
||||
): Promise<Response | null> => {
|
||||
try {
|
||||
if (authentication) {
|
||||
if ("user" in authentication) {
|
||||
// Session-based authentication for integration routes
|
||||
await applyRateLimit(rateLimitConfigs.api.v1, authentication.user.id);
|
||||
} else if ("hashedApiKey" in authentication) {
|
||||
// API key authentication for general routes
|
||||
await applyRateLimit(rateLimitConfigs.api.v1, authentication.hashedApiKey);
|
||||
} else {
|
||||
logger.error({ authentication }, "Unknown authentication type");
|
||||
return responses.internalServerErrorResponse("Invalid authentication configuration");
|
||||
}
|
||||
}
|
||||
|
||||
if (routeType === ApiV1RouteTypeEnum.Client) {
|
||||
await applyClientRateLimit(url);
|
||||
}
|
||||
} catch (error) {
|
||||
return responses.tooManyRequestsResponse(error.message);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Execute handler with error handling
|
||||
*/
|
||||
const executeHandler = async <TResult extends { response: Response }, TProps>(
|
||||
handler: (params: THandlerParams<TProps>) => Promise<TResult>,
|
||||
req: NextRequest,
|
||||
props: TProps,
|
||||
auditLog: TApiAuditLog | undefined,
|
||||
authentication: TApiV1Authentication
|
||||
): Promise<{ result: TResult; error?: unknown }> => {
|
||||
try {
|
||||
const result = await handler({ req, props, auditLog, authentication });
|
||||
return { result };
|
||||
} catch (err) {
|
||||
const result = {
|
||||
response: responses.internalServerErrorResponse("An unexpected error occurred."),
|
||||
} as TResult;
|
||||
return { result, error: err };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Set up audit log with authentication details
|
||||
*/
|
||||
const setupAuditLog = (
|
||||
authentication: TApiV1Authentication,
|
||||
auditLog: TApiAuditLog | undefined,
|
||||
routeType: ApiV1RouteTypeEnum
|
||||
): void => {
|
||||
if (
|
||||
authentication &&
|
||||
auditLog &&
|
||||
routeType === ApiV1RouteTypeEnum.General &&
|
||||
"apiKeyId" in authentication
|
||||
) {
|
||||
auditLog.userId = authentication.apiKeyId;
|
||||
auditLog.organizationId = authentication.organizationId;
|
||||
}
|
||||
|
||||
if (authentication && auditLog && "user" in authentication) {
|
||||
auditLog.userId = authentication.user.id;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle authentication based on method
|
||||
*/
|
||||
const handleAuthentication = async (
|
||||
authenticationMethod: AuthenticationMethod,
|
||||
req: NextRequest
|
||||
): Promise<TApiV1Authentication> => {
|
||||
switch (authenticationMethod) {
|
||||
case AuthenticationMethod.ApiKey:
|
||||
return await authenticateRequest(req);
|
||||
case AuthenticationMethod.Session:
|
||||
return await getServerSession(authOptions);
|
||||
case AuthenticationMethod.Both: {
|
||||
const session = await getServerSession(authOptions);
|
||||
return session ?? (await authenticateRequest(req));
|
||||
}
|
||||
case AuthenticationMethod.None:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Log error details to system logger and Sentry
|
||||
*/
|
||||
const logErrorDetails = (res: Response, req: NextRequest, correlationId: string, error?: any): void => {
|
||||
const logContext = {
|
||||
correlationId,
|
||||
method: req.method,
|
||||
path: req.url,
|
||||
status: res.status,
|
||||
...(error && { error }),
|
||||
};
|
||||
|
||||
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 } });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle response processing and logging
|
||||
*/
|
||||
const processResponse = async (
|
||||
res: Response,
|
||||
req: NextRequest,
|
||||
auditLog?: TApiAuditLog,
|
||||
error?: any
|
||||
): Promise<void> => {
|
||||
const correlationId = req.headers.get("x-request-id") ?? "";
|
||||
|
||||
// Handle audit logging
|
||||
if (auditLog) {
|
||||
if (res.ok) {
|
||||
auditLog.status = "success";
|
||||
} else {
|
||||
auditLog.eventId = correlationId;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle error logging
|
||||
if (!res.ok) {
|
||||
logErrorDetails(res, req, correlationId, error);
|
||||
}
|
||||
|
||||
// Queue audit event if enabled and audit log exists
|
||||
if (AUDIT_LOG_ENABLED && auditLog) {
|
||||
queueAuditEvent(auditLog);
|
||||
}
|
||||
};
|
||||
|
||||
const getRouteType = (
|
||||
req: NextRequest
|
||||
): { routeType: ApiV1RouteTypeEnum; isRateLimited: boolean; authenticationMethod: AuthenticationMethod } => {
|
||||
const pathname = req.nextUrl.pathname;
|
||||
|
||||
const { isClientSideApi, isRateLimited } = isClientSideApiRoute(pathname);
|
||||
const { isManagementApi, authenticationMethod } = isManagementApiRoute(pathname);
|
||||
const isIntegration = isIntegrationRoute(pathname);
|
||||
|
||||
if (isClientSideApi)
|
||||
return {
|
||||
routeType: ApiV1RouteTypeEnum.Client,
|
||||
isRateLimited,
|
||||
authenticationMethod: AuthenticationMethod.None,
|
||||
};
|
||||
if (isManagementApi)
|
||||
return { routeType: ApiV1RouteTypeEnum.General, isRateLimited: true, authenticationMethod };
|
||||
if (isIntegration)
|
||||
return {
|
||||
routeType: ApiV1RouteTypeEnum.Integration,
|
||||
isRateLimited: true,
|
||||
authenticationMethod: AuthenticationMethod.Session,
|
||||
};
|
||||
|
||||
throw new Error(`Unknown route type: ${pathname}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* withV1ApiWrapper wraps a V1 API handler to provide unified authentication, rate limiting, and optional audit/system logging.
|
||||
*
|
||||
* Features:
|
||||
* - Performs authentication once and passes result to handler
|
||||
* - Applies API key-based rate limiting with differentiated limits for client vs management APIs
|
||||
* - Includes additional sync user identification rate limiting for client-side sync endpoints
|
||||
* - Sets userId and organizationId in audit log automatically when audit logging is enabled
|
||||
* - System and Sentry logs are always called for non-success responses
|
||||
* - Uses function overloads to provide type safety without requiring type guards
|
||||
*
|
||||
* @param params - Configuration object for the wrapper
|
||||
* @param params.handler - The API handler function that processes the request, receives an object with:
|
||||
* - req: The incoming HTTP request object
|
||||
* - props: Optional route parameters (e.g., { params: { id: string } })
|
||||
* - auditLog: Optional audit log object for tracking API actions (only present when action/targetType provided)
|
||||
* - authentication: Authentication result (type determined by route - API key for general, session for integration)
|
||||
* @param params.action - Optional audit action type (e.g., "created", "updated", "deleted"). Required for audit logging
|
||||
* @param params.targetType - Optional audit target type (e.g., "webhook", "survey", "response"). Required for audit logging
|
||||
* @returns Wrapped handler function that returns the final HTTP response
|
||||
*
|
||||
*/
|
||||
export const withV1ApiWrapper: {
|
||||
<TResult extends { response: Response }, TProps = unknown>(
|
||||
params: TWithV1ApiWrapperParams<TResult, TProps> & {
|
||||
handler: (
|
||||
params: THandlerParams<TProps> & { authentication?: TApiKeyAuthentication }
|
||||
) => Promise<TResult>;
|
||||
}
|
||||
): (req: NextRequest, props: TProps) => Promise<Response>;
|
||||
|
||||
<TResult extends { response: Response }, TProps = unknown>(
|
||||
params: TWithV1ApiWrapperParams<TResult, TProps> & {
|
||||
handler: (
|
||||
params: THandlerParams<TProps> & { authentication?: TSessionAuthentication }
|
||||
) => Promise<TResult>;
|
||||
}
|
||||
): (req: NextRequest, props: TProps) => Promise<Response>;
|
||||
|
||||
<TResult extends { response: Response }, TProps = unknown>(
|
||||
params: TWithV1ApiWrapperParams<TResult, TProps> & {
|
||||
handler: (
|
||||
params: THandlerParams<TProps> & { authentication?: TApiV1Authentication }
|
||||
) => Promise<TResult>;
|
||||
}
|
||||
): (req: NextRequest, props: TProps) => Promise<Response>;
|
||||
} = <TResult extends { response: Response }, TProps = unknown>(
|
||||
params: TWithV1ApiWrapperParams<TResult, TProps>
|
||||
): ((req: NextRequest, props: TProps) => Promise<Response>) => {
|
||||
const { handler, action, targetType } = params;
|
||||
return async (req: NextRequest, props: TProps): Promise<Response> => {
|
||||
// === Audit Log Setup ===
|
||||
const saveAuditLog = action && targetType;
|
||||
const auditLog = saveAuditLog ? buildAuditLogBaseObject(action, targetType, req.url) : undefined;
|
||||
|
||||
let routeType: ApiV1RouteTypeEnum;
|
||||
let isRateLimited: boolean;
|
||||
let authenticationMethod: AuthenticationMethod;
|
||||
|
||||
// === Route Classification ===
|
||||
let result: { response: Response };
|
||||
let error: any = undefined;
|
||||
try {
|
||||
({ routeType, isRateLimited, authenticationMethod } = getRouteType(req));
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Error getting route type");
|
||||
return responses.internalServerErrorResponse("An unexpected error occurred.");
|
||||
result = await handler(req, props, auditLog);
|
||||
} catch (err) {
|
||||
error = err;
|
||||
result = {
|
||||
response: responses.internalServerErrorResponse("An unexpected error occurred."),
|
||||
};
|
||||
}
|
||||
|
||||
// === Authentication ===
|
||||
const authentication = await handleAuthentication(authenticationMethod, req);
|
||||
|
||||
if (!authentication && routeType !== ApiV1RouteTypeEnum.Client) {
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
|
||||
// === Audit Log Enhancement ===
|
||||
setupAuditLog(authentication, auditLog, routeType);
|
||||
|
||||
// === Rate Limiting ===
|
||||
if (isRateLimited) {
|
||||
const rateLimitResponse = await handleRateLimiting(req.nextUrl.pathname, authentication, routeType);
|
||||
if (rateLimitResponse) return rateLimitResponse;
|
||||
}
|
||||
|
||||
// === Handler Execution ===
|
||||
const { result, error } = await executeHandler(handler, req, props, auditLog, authentication);
|
||||
const res = result.response;
|
||||
// Try to parse the response as JSON to check if it's a success or error
|
||||
let isSuccess = false;
|
||||
let parsed: any = undefined;
|
||||
try {
|
||||
parsed = await res.clone().json();
|
||||
isSuccess = parsed && typeof parsed === "object" && "data" in parsed;
|
||||
} catch {
|
||||
isSuccess = false;
|
||||
}
|
||||
|
||||
// === Response Processing & Logging ===
|
||||
await processResponse(res, req, auditLog, error);
|
||||
const correlationId = req.headers.get("x-request-id") ?? "";
|
||||
|
||||
if (!isSuccess) {
|
||||
if (auditLog) {
|
||||
auditLog.eventId = correlationId;
|
||||
}
|
||||
|
||||
// System log
|
||||
const logContext: any = {
|
||||
correlationId,
|
||||
method: req.method,
|
||||
path: req.url,
|
||||
status: res.status,
|
||||
};
|
||||
if (error) {
|
||||
logContext.error = error;
|
||||
}
|
||||
logger.withContext(logContext).error("API Error Details");
|
||||
// Sentry log
|
||||
if (SENTRY_DSN && IS_PRODUCTION && res.status === 500) {
|
||||
const err = new Error(`API V1 error, id: ${correlationId}`);
|
||||
Sentry.captureException(err, {
|
||||
extra: {
|
||||
error,
|
||||
correlationId,
|
||||
},
|
||||
});
|
||||
}
|
||||
} else {
|
||||
auditLog.status = "success";
|
||||
}
|
||||
|
||||
if (AUDIT_LOG_ENABLED && auditLog) {
|
||||
queueAuditEvent(auditLog);
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
@@ -331,7 +87,7 @@ export const buildAuditLogBaseObject = (
|
||||
action: TAuditAction,
|
||||
targetType: TAuditTarget,
|
||||
apiUrl: string
|
||||
): TApiAuditLog => {
|
||||
): ApiAuditLog => {
|
||||
return {
|
||||
action,
|
||||
targetType,
|
||||
|
||||
@@ -3703,6 +3703,5 @@ export const previewSurvey = (projectName: string, t: TFnType) => {
|
||||
showLanguageSwitch: false,
|
||||
followUps: [],
|
||||
isBackButtonHidden: false,
|
||||
metadata: {},
|
||||
} as TSurvey;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import * as constants from "@/lib/constants";
|
||||
import { rateLimit } from "@/lib/utils/rate-limit";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import type { Mock } from "vitest";
|
||||
|
||||
vi.mock("@/lib/utils/rate-limit", () => ({ rateLimit: vi.fn() }));
|
||||
|
||||
describe("bucket middleware rate limiters", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
const mockedRateLimit = rateLimit as unknown as Mock;
|
||||
mockedRateLimit.mockImplementation((config) => config);
|
||||
});
|
||||
|
||||
test("clientSideApiEndpointsLimiter uses CLIENT_SIDE_API_RATE_LIMIT settings", async () => {
|
||||
const { clientSideApiEndpointsLimiter } = await import("./bucket");
|
||||
expect(rateLimit).toHaveBeenCalledWith({
|
||||
interval: constants.CLIENT_SIDE_API_RATE_LIMIT.interval,
|
||||
allowedPerInterval: constants.CLIENT_SIDE_API_RATE_LIMIT.allowedPerInterval,
|
||||
});
|
||||
expect(clientSideApiEndpointsLimiter).toEqual({
|
||||
interval: constants.CLIENT_SIDE_API_RATE_LIMIT.interval,
|
||||
allowedPerInterval: constants.CLIENT_SIDE_API_RATE_LIMIT.allowedPerInterval,
|
||||
});
|
||||
});
|
||||
|
||||
test("syncUserIdentificationLimiter uses SYNC_USER_IDENTIFICATION_RATE_LIMIT settings", async () => {
|
||||
const { syncUserIdentificationLimiter } = await import("./bucket");
|
||||
expect(rateLimit).toHaveBeenCalledWith({
|
||||
interval: constants.SYNC_USER_IDENTIFICATION_RATE_LIMIT.interval,
|
||||
allowedPerInterval: constants.SYNC_USER_IDENTIFICATION_RATE_LIMIT.allowedPerInterval,
|
||||
});
|
||||
expect(syncUserIdentificationLimiter).toEqual({
|
||||
interval: constants.SYNC_USER_IDENTIFICATION_RATE_LIMIT.interval,
|
||||
allowedPerInterval: constants.SYNC_USER_IDENTIFICATION_RATE_LIMIT.allowedPerInterval,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { CLIENT_SIDE_API_RATE_LIMIT, SYNC_USER_IDENTIFICATION_RATE_LIMIT } from "@/lib/constants";
|
||||
import { rateLimit } from "@/lib/utils/rate-limit";
|
||||
|
||||
export const clientSideApiEndpointsLimiter = rateLimit({
|
||||
interval: CLIENT_SIDE_API_RATE_LIMIT.interval,
|
||||
allowedPerInterval: CLIENT_SIDE_API_RATE_LIMIT.allowedPerInterval,
|
||||
});
|
||||
|
||||
export const syncUserIdentificationLimiter = rateLimit({
|
||||
interval: SYNC_USER_IDENTIFICATION_RATE_LIMIT.interval,
|
||||
allowedPerInterval: SYNC_USER_IDENTIFICATION_RATE_LIMIT.allowedPerInterval,
|
||||
});
|
||||
@@ -1,10 +1,8 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
AuthenticationMethod,
|
||||
isAdminDomainRoute,
|
||||
isAuthProtectedRoute,
|
||||
isClientSideApiRoute,
|
||||
isIntegrationRoute,
|
||||
isManagementApiRoute,
|
||||
isPublicDomainRoute,
|
||||
isRouteAllowedForDomain,
|
||||
@@ -12,230 +10,34 @@ import {
|
||||
} from "./endpoint-validator";
|
||||
|
||||
describe("endpoint-validator", () => {
|
||||
describe("AuthenticationMethod enum", () => {
|
||||
test("should have correct values", () => {
|
||||
expect(AuthenticationMethod.ApiKey).toBe("apiKey");
|
||||
expect(AuthenticationMethod.Session).toBe("session");
|
||||
expect(AuthenticationMethod.Both).toBe("both");
|
||||
expect(AuthenticationMethod.None).toBe("none");
|
||||
});
|
||||
});
|
||||
describe("isClientSideApiRoute", () => {
|
||||
test("should return correct object for client-side API routes with rate limiting", () => {
|
||||
expect(isClientSideApiRoute("/api/v1/client/storage")).toEqual({
|
||||
isClientSideApi: true,
|
||||
isRateLimited: true,
|
||||
});
|
||||
expect(isClientSideApiRoute("/api/v1/client/other")).toEqual({
|
||||
isClientSideApi: true,
|
||||
isRateLimited: true,
|
||||
});
|
||||
expect(isClientSideApiRoute("/api/v2/client/other")).toEqual({
|
||||
isClientSideApi: true,
|
||||
isRateLimited: true,
|
||||
});
|
||||
expect(isClientSideApiRoute("/api/v3/client/test")).toEqual({
|
||||
isClientSideApi: true,
|
||||
isRateLimited: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("should return correct object for OG route (client-side but not rate limited)", () => {
|
||||
expect(isClientSideApiRoute("/api/v1/client/og")).toEqual({
|
||||
isClientSideApi: true,
|
||||
isRateLimited: false,
|
||||
});
|
||||
expect(isClientSideApiRoute("/api/v1/client/og/image")).toEqual({
|
||||
isClientSideApi: true,
|
||||
isRateLimited: false,
|
||||
});
|
||||
test("should return true for client-side API routes", () => {
|
||||
expect(isClientSideApiRoute("/api/v1/js/actions")).toBe(true);
|
||||
expect(isClientSideApiRoute("/api/v1/client/storage")).toBe(true);
|
||||
expect(isClientSideApiRoute("/api/v1/client/other")).toBe(true);
|
||||
expect(isClientSideApiRoute("/api/v2/client/other")).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false for non-client-side API routes", () => {
|
||||
expect(isClientSideApiRoute("/api/v1/management/something")).toEqual({
|
||||
isClientSideApi: false,
|
||||
isRateLimited: true,
|
||||
});
|
||||
expect(isClientSideApiRoute("/api/v1/js/actions")).toEqual({
|
||||
isClientSideApi: false,
|
||||
isRateLimited: true,
|
||||
});
|
||||
expect(isClientSideApiRoute("/api/something")).toEqual({
|
||||
isClientSideApi: false,
|
||||
isRateLimited: true,
|
||||
});
|
||||
expect(isClientSideApiRoute("/auth/login")).toEqual({
|
||||
isClientSideApi: false,
|
||||
isRateLimited: true,
|
||||
});
|
||||
expect(isClientSideApiRoute("/api/v1/integrations/webhook")).toEqual({
|
||||
isClientSideApi: false,
|
||||
isRateLimited: true,
|
||||
});
|
||||
});
|
||||
expect(isClientSideApiRoute("/api/v1/management/something")).toBe(false);
|
||||
expect(isClientSideApiRoute("/api/something")).toBe(false);
|
||||
expect(isClientSideApiRoute("/auth/login")).toBe(false);
|
||||
|
||||
test("should handle edge cases", () => {
|
||||
expect(isClientSideApiRoute("/api/v1/client")).toEqual({
|
||||
isClientSideApi: false,
|
||||
isRateLimited: true,
|
||||
});
|
||||
expect(isClientSideApiRoute("/api/v1/client/")).toEqual({
|
||||
isClientSideApi: true,
|
||||
isRateLimited: true,
|
||||
});
|
||||
expect(isClientSideApiRoute("/api/client/test")).toEqual({
|
||||
isClientSideApi: false,
|
||||
isRateLimited: true,
|
||||
});
|
||||
// exception for open graph image generation route, it should not be rate limited
|
||||
expect(isClientSideApiRoute("/api/v1/client/og")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isManagementApiRoute", () => {
|
||||
test("should return correct object for management API routes with API key authentication", () => {
|
||||
expect(isManagementApiRoute("/api/v1/management/something")).toEqual({
|
||||
isManagementApi: true,
|
||||
authenticationMethod: AuthenticationMethod.ApiKey,
|
||||
});
|
||||
expect(isManagementApiRoute("/api/v2/management/other")).toEqual({
|
||||
isManagementApi: true,
|
||||
authenticationMethod: AuthenticationMethod.ApiKey,
|
||||
});
|
||||
expect(isManagementApiRoute("/api/v1/management/surveys")).toEqual({
|
||||
isManagementApi: true,
|
||||
authenticationMethod: AuthenticationMethod.ApiKey,
|
||||
});
|
||||
expect(isManagementApiRoute("/api/v3/management/users")).toEqual({
|
||||
isManagementApi: true,
|
||||
authenticationMethod: AuthenticationMethod.ApiKey,
|
||||
});
|
||||
test("should return true for management API routes", () => {
|
||||
expect(isManagementApiRoute("/api/v1/management/something")).toBe(true);
|
||||
expect(isManagementApiRoute("/api/v2/management/other")).toBe(true);
|
||||
});
|
||||
|
||||
test("should return correct object for storage management routes with both authentication methods", () => {
|
||||
expect(isManagementApiRoute("/api/v1/management/storage")).toEqual({
|
||||
isManagementApi: true,
|
||||
authenticationMethod: AuthenticationMethod.Both,
|
||||
});
|
||||
expect(isManagementApiRoute("/api/v1/management/storage/files")).toEqual({
|
||||
isManagementApi: true,
|
||||
authenticationMethod: AuthenticationMethod.Both,
|
||||
});
|
||||
expect(isManagementApiRoute("/api/v1/management/storage/upload")).toEqual({
|
||||
isManagementApi: true,
|
||||
authenticationMethod: AuthenticationMethod.Both,
|
||||
});
|
||||
});
|
||||
|
||||
test("should return correct object for webhooks routes with API key authentication", () => {
|
||||
expect(isManagementApiRoute("/api/v1/webhooks")).toEqual({
|
||||
isManagementApi: true,
|
||||
authenticationMethod: AuthenticationMethod.ApiKey,
|
||||
});
|
||||
expect(isManagementApiRoute("/api/v1/webhooks/123")).toEqual({
|
||||
isManagementApi: true,
|
||||
authenticationMethod: AuthenticationMethod.ApiKey,
|
||||
});
|
||||
expect(isManagementApiRoute("/api/v1/webhooks/webhook-id/config")).toEqual({
|
||||
isManagementApi: true,
|
||||
authenticationMethod: AuthenticationMethod.ApiKey,
|
||||
});
|
||||
});
|
||||
|
||||
test("should return correct object for non-v1 storage management routes (only v1 supports both auth methods)", () => {
|
||||
expect(isManagementApiRoute("/api/v2/management/storage")).toEqual({
|
||||
isManagementApi: true,
|
||||
authenticationMethod: AuthenticationMethod.ApiKey,
|
||||
});
|
||||
expect(isManagementApiRoute("/api/v3/management/storage/upload")).toEqual({
|
||||
isManagementApi: true,
|
||||
authenticationMethod: AuthenticationMethod.ApiKey,
|
||||
});
|
||||
});
|
||||
|
||||
test("should return correct object for non-v1 webhooks routes (falls back to management regex)", () => {
|
||||
expect(isManagementApiRoute("/api/v2/webhooks")).toEqual({
|
||||
isManagementApi: false,
|
||||
authenticationMethod: AuthenticationMethod.ApiKey,
|
||||
});
|
||||
expect(isManagementApiRoute("/api/v3/webhooks/123")).toEqual({
|
||||
isManagementApi: false,
|
||||
authenticationMethod: AuthenticationMethod.ApiKey,
|
||||
});
|
||||
expect(isManagementApiRoute("/api/v2/management/webhooks")).toEqual({
|
||||
isManagementApi: true,
|
||||
authenticationMethod: AuthenticationMethod.ApiKey,
|
||||
});
|
||||
});
|
||||
|
||||
test("should return correct object for non-management API routes", () => {
|
||||
expect(isManagementApiRoute("/api/v1/client/something")).toEqual({
|
||||
isManagementApi: false,
|
||||
authenticationMethod: AuthenticationMethod.ApiKey,
|
||||
});
|
||||
expect(isManagementApiRoute("/api/something")).toEqual({
|
||||
isManagementApi: false,
|
||||
authenticationMethod: AuthenticationMethod.ApiKey,
|
||||
});
|
||||
expect(isManagementApiRoute("/auth/login")).toEqual({
|
||||
isManagementApi: false,
|
||||
authenticationMethod: AuthenticationMethod.ApiKey,
|
||||
});
|
||||
expect(isManagementApiRoute("/api/v1/integrations/webhook")).toEqual({
|
||||
isManagementApi: false,
|
||||
authenticationMethod: AuthenticationMethod.ApiKey,
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle edge cases", () => {
|
||||
expect(isManagementApiRoute("/api/v1/management")).toEqual({
|
||||
isManagementApi: false,
|
||||
authenticationMethod: AuthenticationMethod.ApiKey,
|
||||
});
|
||||
expect(isManagementApiRoute("/api/v1/management/")).toEqual({
|
||||
isManagementApi: true,
|
||||
authenticationMethod: AuthenticationMethod.ApiKey,
|
||||
});
|
||||
expect(isManagementApiRoute("/api/management/test")).toEqual({
|
||||
isManagementApi: false,
|
||||
authenticationMethod: AuthenticationMethod.ApiKey,
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle webhooks edge cases", () => {
|
||||
expect(isManagementApiRoute("/api/v1/webhook")).toEqual({
|
||||
isManagementApi: false,
|
||||
authenticationMethod: AuthenticationMethod.ApiKey,
|
||||
});
|
||||
expect(isManagementApiRoute("/api/webhooks")).toEqual({
|
||||
isManagementApi: false,
|
||||
authenticationMethod: AuthenticationMethod.ApiKey,
|
||||
});
|
||||
expect(isManagementApiRoute("/webhooks/api/v1")).toEqual({
|
||||
isManagementApi: false,
|
||||
authenticationMethod: AuthenticationMethod.ApiKey,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("isIntegrationRoute", () => {
|
||||
test("should return true for integration API routes", () => {
|
||||
expect(isIntegrationRoute("/api/v1/integrations/webhook")).toBe(true);
|
||||
expect(isIntegrationRoute("/api/v2/integrations/slack")).toBe(true);
|
||||
expect(isIntegrationRoute("/api/v1/integrations/zapier")).toBe(true);
|
||||
expect(isIntegrationRoute("/api/v3/integrations/other")).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false for non-integration API routes", () => {
|
||||
expect(isIntegrationRoute("/api/v1/client/something")).toBe(false);
|
||||
expect(isIntegrationRoute("/api/v1/management/something")).toBe(false);
|
||||
expect(isIntegrationRoute("/api/something")).toBe(false);
|
||||
expect(isIntegrationRoute("/auth/login")).toBe(false);
|
||||
expect(isIntegrationRoute("/integrations/webhook")).toBe(false);
|
||||
});
|
||||
|
||||
test("should handle edge cases", () => {
|
||||
expect(isIntegrationRoute("/api/v1/integrations")).toBe(false);
|
||||
expect(isIntegrationRoute("/api/v1/integrations/")).toBe(true);
|
||||
expect(isIntegrationRoute("/api/integrations/test")).toBe(false);
|
||||
test("should return false for non-management API routes", () => {
|
||||
expect(isManagementApiRoute("/api/v1/client/something")).toBe(false);
|
||||
expect(isManagementApiRoute("/api/something")).toBe(false);
|
||||
expect(isManagementApiRoute("/auth/login")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -243,30 +45,15 @@ describe("endpoint-validator", () => {
|
||||
test("should return true for protected routes", () => {
|
||||
expect(isAuthProtectedRoute("/environments")).toBe(true);
|
||||
expect(isAuthProtectedRoute("/environments/something")).toBe(true);
|
||||
expect(isAuthProtectedRoute("/environments/123/surveys")).toBe(true);
|
||||
expect(isAuthProtectedRoute("/setup/organization")).toBe(true);
|
||||
expect(isAuthProtectedRoute("/setup/organization/create")).toBe(true);
|
||||
expect(isAuthProtectedRoute("/organizations")).toBe(true);
|
||||
expect(isAuthProtectedRoute("/organizations/something")).toBe(true);
|
||||
expect(isAuthProtectedRoute("/organizations/123/settings")).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false for non-protected routes", () => {
|
||||
expect(isAuthProtectedRoute("/auth/login")).toBe(false);
|
||||
expect(isAuthProtectedRoute("/auth/signup")).toBe(false);
|
||||
expect(isAuthProtectedRoute("/api/something")).toBe(false);
|
||||
expect(isAuthProtectedRoute("/api/v1/client/test")).toBe(false);
|
||||
expect(isAuthProtectedRoute("/")).toBe(false);
|
||||
expect(isAuthProtectedRoute("/s/survey123")).toBe(false);
|
||||
expect(isAuthProtectedRoute("/c/jwt-token")).toBe(false);
|
||||
expect(isAuthProtectedRoute("/health")).toBe(false);
|
||||
});
|
||||
|
||||
test("should handle edge cases", () => {
|
||||
expect(isAuthProtectedRoute("/environment")).toBe(false); // partial match should not work
|
||||
expect(isAuthProtectedRoute("/organization")).toBe(false); // partial match should not work
|
||||
expect(isAuthProtectedRoute("/setup/team")).toBe(false); // not in protected routes
|
||||
expect(isAuthProtectedRoute("/setup")).toBe(false); // partial match should not work
|
||||
});
|
||||
});
|
||||
|
||||
@@ -283,42 +70,12 @@ describe("endpoint-validator", () => {
|
||||
environmentId: "abc-123",
|
||||
userId: "xyz-789",
|
||||
});
|
||||
|
||||
const result3 = isSyncWithUserIdentificationEndpoint(
|
||||
"/api/v1/client/env_123_test/app/sync/user_456_test"
|
||||
);
|
||||
expect(result3).toEqual({
|
||||
environmentId: "env_123_test",
|
||||
userId: "user_456_test",
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle optional trailing slash", () => {
|
||||
// Test both with and without trailing slash
|
||||
const result1 = isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/app/sync/user456");
|
||||
expect(result1).toEqual({
|
||||
environmentId: "env123",
|
||||
userId: "user456",
|
||||
});
|
||||
|
||||
const result2 = isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/app/sync/user456/");
|
||||
expect(result2).toEqual({
|
||||
environmentId: "env123",
|
||||
userId: "user456",
|
||||
});
|
||||
});
|
||||
|
||||
test("should return false for invalid sync URLs", () => {
|
||||
expect(isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/app/sync")).toBe(false);
|
||||
expect(isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/something")).toBe(false);
|
||||
expect(isSyncWithUserIdentificationEndpoint("/api/something")).toBe(false);
|
||||
expect(isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/app/other/user456")).toBe(false);
|
||||
expect(isSyncWithUserIdentificationEndpoint("/api/v2/client/env123/app/sync/user456")).toBe(false); // only v1 supported
|
||||
});
|
||||
|
||||
test("should handle empty or malformed IDs", () => {
|
||||
expect(isSyncWithUserIdentificationEndpoint("/api/v1/client//app/sync/user456")).toBe(false);
|
||||
expect(isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/app/sync/")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -327,57 +84,21 @@ describe("endpoint-validator", () => {
|
||||
expect(isPublicDomainRoute("/health")).toBe(true);
|
||||
});
|
||||
|
||||
test("should return true for public storage routes", () => {
|
||||
expect(isPublicDomainRoute("/storage/env123/public/file.jpg")).toBe(true);
|
||||
expect(isPublicDomainRoute("/storage/abc-456/public/document.pdf")).toBe(true);
|
||||
expect(isPublicDomainRoute("/storage/env123/public/folder/image.png")).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false for private storage routes", () => {
|
||||
expect(isPublicDomainRoute("/storage/env123/private/file.jpg")).toBe(false);
|
||||
expect(isPublicDomainRoute("/storage/env123")).toBe(false);
|
||||
expect(isPublicDomainRoute("/storage")).toBe(false);
|
||||
});
|
||||
|
||||
// Static assets are not handled by domain routing - middleware doesn't run on them
|
||||
|
||||
test("should return true for survey routes", () => {
|
||||
expect(isPublicDomainRoute("/s/survey123")).toBe(true);
|
||||
expect(isPublicDomainRoute("/s/survey-id-with-dashes")).toBe(true);
|
||||
expect(isPublicDomainRoute("/s/survey_id_with_underscores")).toBe(true);
|
||||
expect(isPublicDomainRoute("/s/abc123def456")).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false for malformed survey routes", () => {
|
||||
expect(isPublicDomainRoute("/s/")).toBe(false);
|
||||
expect(isPublicDomainRoute("/s")).toBe(false);
|
||||
expect(isPublicDomainRoute("/survey/123")).toBe(false);
|
||||
});
|
||||
|
||||
test("should return true for contact survey routes", () => {
|
||||
expect(isPublicDomainRoute("/c/jwt-token")).toBe(true);
|
||||
expect(isPublicDomainRoute("/c/very-long-jwt-token-123")).toBe(true);
|
||||
expect(isPublicDomainRoute("/c/token.with.dots")).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false for malformed contact survey routes", () => {
|
||||
expect(isPublicDomainRoute("/c/")).toBe(false);
|
||||
expect(isPublicDomainRoute("/c")).toBe(false);
|
||||
expect(isPublicDomainRoute("/contact/token")).toBe(false);
|
||||
});
|
||||
|
||||
test("should return true for client API routes", () => {
|
||||
expect(isPublicDomainRoute("/api/v1/client/something")).toBe(true);
|
||||
expect(isPublicDomainRoute("/api/v2/client/other")).toBe(true);
|
||||
expect(isPublicDomainRoute("/api/v1/client/env/actions")).toBe(true);
|
||||
expect(isPublicDomainRoute("/api/v2/client/env/responses")).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false for non-client API routes", () => {
|
||||
expect(isPublicDomainRoute("/api/v3/client/something")).toBe(false); // only v1 and v2 supported
|
||||
expect(isPublicDomainRoute("/api/client/something")).toBe(false);
|
||||
expect(isPublicDomainRoute("/api/v1/management/users")).toBe(false);
|
||||
expect(isPublicDomainRoute("/api/v1/integrations/webhook")).toBe(false);
|
||||
});
|
||||
|
||||
test("should return false for admin-only routes", () => {
|
||||
@@ -395,11 +116,7 @@ describe("endpoint-validator", () => {
|
||||
describe("isAdminDomainRoute", () => {
|
||||
test("should return true for health endpoint (backward compatibility)", () => {
|
||||
expect(isAdminDomainRoute("/health")).toBe(true);
|
||||
});
|
||||
|
||||
test("should return true for public storage routes (backward compatibility)", () => {
|
||||
expect(isAdminDomainRoute("/storage/env123/public/file.jpg")).toBe(true);
|
||||
expect(isAdminDomainRoute("/storage/abc-456/public/document.pdf")).toBe(true);
|
||||
expect(isAdminDomainRoute("/health")).toBe(true);
|
||||
});
|
||||
|
||||
// Static assets are not handled by domain routing - middleware doesn't run on them
|
||||
@@ -418,261 +135,73 @@ describe("endpoint-validator", () => {
|
||||
expect(isAdminDomainRoute("/product/features")).toBe(true);
|
||||
expect(isAdminDomainRoute("/api/v1/management/users")).toBe(true);
|
||||
expect(isAdminDomainRoute("/api/v2/management/surveys")).toBe(true);
|
||||
expect(isAdminDomainRoute("/api/v1/integrations/webhook")).toBe(true);
|
||||
expect(isAdminDomainRoute("/pipeline/jobs")).toBe(true);
|
||||
expect(isAdminDomainRoute("/cron/tasks")).toBe(true);
|
||||
expect(isAdminDomainRoute("/random/route")).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false for public-only routes", () => {
|
||||
expect(isAdminDomainRoute("/s/survey123")).toBe(false);
|
||||
expect(isAdminDomainRoute("/s/survey-id-with-dashes")).toBe(false);
|
||||
expect(isAdminDomainRoute("/c/jwt-token")).toBe(false);
|
||||
expect(isAdminDomainRoute("/c/very-long-jwt-token-123")).toBe(false);
|
||||
expect(isAdminDomainRoute("/api/v1/client/test")).toBe(false);
|
||||
expect(isAdminDomainRoute("/api/v2/client/other")).toBe(false);
|
||||
});
|
||||
|
||||
test("should handle edge cases", () => {
|
||||
expect(isAdminDomainRoute("")).toBe(true);
|
||||
expect(isAdminDomainRoute("/unknown/path")).toBe(true); // unknown routes default to admin
|
||||
});
|
||||
});
|
||||
|
||||
describe("isRouteAllowedForDomain", () => {
|
||||
describe("public domain routing", () => {
|
||||
test("should allow public routes on public domain", () => {
|
||||
expect(isRouteAllowedForDomain("/s/survey123", true)).toBe(true);
|
||||
expect(isRouteAllowedForDomain("/c/jwt-token", true)).toBe(true);
|
||||
expect(isRouteAllowedForDomain("/api/v1/client/test", true)).toBe(true);
|
||||
expect(isRouteAllowedForDomain("/api/v2/client/other", true)).toBe(true);
|
||||
expect(isRouteAllowedForDomain("/health", true)).toBe(true);
|
||||
expect(isRouteAllowedForDomain("/storage/env123/public/file.jpg", true)).toBe(true);
|
||||
// Static assets not tested - middleware doesn't run on them
|
||||
});
|
||||
|
||||
test("should block admin routes on public domain", () => {
|
||||
expect(isRouteAllowedForDomain("/", true)).toBe(false);
|
||||
expect(isRouteAllowedForDomain("/environments/123", true)).toBe(false);
|
||||
expect(isRouteAllowedForDomain("/auth/login", true)).toBe(false);
|
||||
expect(isRouteAllowedForDomain("/api/v1/management/users", true)).toBe(false);
|
||||
expect(isRouteAllowedForDomain("/api/v1/integrations/webhook", true)).toBe(false);
|
||||
expect(isRouteAllowedForDomain("/organizations/123", true)).toBe(false);
|
||||
expect(isRouteAllowedForDomain("/setup/organization", true)).toBe(false);
|
||||
});
|
||||
test("should allow public routes on public domain", () => {
|
||||
expect(isRouteAllowedForDomain("/s/survey123", true)).toBe(true);
|
||||
expect(isRouteAllowedForDomain("/c/jwt-token", true)).toBe(true);
|
||||
expect(isRouteAllowedForDomain("/api/v1/client/test", true)).toBe(true);
|
||||
expect(isRouteAllowedForDomain("/health", true)).toBe(true);
|
||||
// Static assets not tested - middleware doesn't run on them
|
||||
});
|
||||
|
||||
describe("admin domain routing", () => {
|
||||
test("should allow admin routes on admin domain", () => {
|
||||
expect(isRouteAllowedForDomain("/", false)).toBe(true);
|
||||
expect(isRouteAllowedForDomain("/environments/123", false)).toBe(true);
|
||||
expect(isRouteAllowedForDomain("/auth/login", false)).toBe(true);
|
||||
expect(isRouteAllowedForDomain("/api/v1/management/users", false)).toBe(true);
|
||||
expect(isRouteAllowedForDomain("/api/v1/integrations/webhook", false)).toBe(true);
|
||||
expect(isRouteAllowedForDomain("/health", false)).toBe(true);
|
||||
expect(isRouteAllowedForDomain("/storage/env123/public/file.jpg", false)).toBe(true);
|
||||
expect(isRouteAllowedForDomain("/pipeline/jobs", false)).toBe(true);
|
||||
expect(isRouteAllowedForDomain("/cron/tasks", false)).toBe(true);
|
||||
expect(isRouteAllowedForDomain("/unknown/route", false)).toBe(true);
|
||||
});
|
||||
|
||||
test("should block public-only routes on admin domain when PUBLIC_URL is configured", () => {
|
||||
expect(isRouteAllowedForDomain("/s/survey123", false)).toBe(false);
|
||||
expect(isRouteAllowedForDomain("/s/survey-id-with-dashes", false)).toBe(false);
|
||||
expect(isRouteAllowedForDomain("/c/jwt-token", false)).toBe(false);
|
||||
expect(isRouteAllowedForDomain("/c/very-long-jwt-token-123", false)).toBe(false);
|
||||
expect(isRouteAllowedForDomain("/api/v1/client/test", false)).toBe(false);
|
||||
expect(isRouteAllowedForDomain("/api/v2/client/other", false)).toBe(false);
|
||||
});
|
||||
test("should block admin routes on public domain", () => {
|
||||
expect(isRouteAllowedForDomain("/", true)).toBe(false);
|
||||
expect(isRouteAllowedForDomain("/environments/123", true)).toBe(false);
|
||||
expect(isRouteAllowedForDomain("/auth/login", true)).toBe(false);
|
||||
expect(isRouteAllowedForDomain("/api/v1/management/users", true)).toBe(false);
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
test("should handle empty paths", () => {
|
||||
expect(isRouteAllowedForDomain("", true)).toBe(false);
|
||||
expect(isRouteAllowedForDomain("", false)).toBe(true);
|
||||
});
|
||||
test("should block public routes on admin domain when PUBLIC_URL is configured", () => {
|
||||
// Admin routes should be allowed
|
||||
expect(isRouteAllowedForDomain("/", false)).toBe(true);
|
||||
expect(isRouteAllowedForDomain("/environments/123", false)).toBe(true);
|
||||
expect(isRouteAllowedForDomain("/auth/login", false)).toBe(true);
|
||||
expect(isRouteAllowedForDomain("/api/v1/management/users", false)).toBe(true);
|
||||
expect(isRouteAllowedForDomain("/health", false)).toBe(true);
|
||||
expect(isRouteAllowedForDomain("/pipeline/jobs", false)).toBe(true);
|
||||
expect(isRouteAllowedForDomain("/cron/tasks", false)).toBe(true);
|
||||
|
||||
test("should handle paths with query parameters and fragments", () => {
|
||||
expect(isRouteAllowedForDomain("/s/survey123?param=value", true)).toBe(true);
|
||||
expect(isRouteAllowedForDomain("/s/survey123#section", true)).toBe(true);
|
||||
expect(isRouteAllowedForDomain("/environments/123?tab=settings", true)).toBe(false);
|
||||
expect(isRouteAllowedForDomain("/environments/123?tab=settings", false)).toBe(true);
|
||||
});
|
||||
// Public routes should be blocked on admin domain
|
||||
expect(isRouteAllowedForDomain("/s/survey123", false)).toBe(false);
|
||||
expect(isRouteAllowedForDomain("/c/jwt-token", false)).toBe(false);
|
||||
expect(isRouteAllowedForDomain("/api/v1/client/test", false)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("comprehensive integration tests", () => {
|
||||
describe("URL parsing edge cases", () => {
|
||||
test("should handle paths with query parameters", () => {
|
||||
expect(isPublicDomainRoute("/s/survey123?param=value&other=test")).toBe(true);
|
||||
expect(isPublicDomainRoute("/api/v1/client/test?query=data")).toBe(true);
|
||||
expect(isPublicDomainRoute("/environments/123?tab=settings")).toBe(false);
|
||||
expect(isAuthProtectedRoute("/environments/123?tab=overview")).toBe(true);
|
||||
});
|
||||
|
||||
test("should handle paths with fragments", () => {
|
||||
expect(isPublicDomainRoute("/s/survey123#section")).toBe(true);
|
||||
expect(isPublicDomainRoute("/c/jwt-token#top")).toBe(true);
|
||||
expect(isPublicDomainRoute("/environments/123#overview")).toBe(false);
|
||||
expect(isAuthProtectedRoute("/organizations/456#settings")).toBe(true);
|
||||
});
|
||||
|
||||
test("should handle trailing slashes", () => {
|
||||
expect(isPublicDomainRoute("/s/survey123/")).toBe(true);
|
||||
expect(isPublicDomainRoute("/api/v1/client/test/")).toBe(true);
|
||||
expect(isManagementApiRoute("/api/v1/management/test/")).toEqual({
|
||||
isManagementApi: true,
|
||||
authenticationMethod: AuthenticationMethod.ApiKey,
|
||||
});
|
||||
expect(isIntegrationRoute("/api/v1/integrations/webhook/")).toBe(true);
|
||||
});
|
||||
describe("edge cases", () => {
|
||||
test("should handle empty paths", () => {
|
||||
expect(isPublicDomainRoute("")).toBe(false);
|
||||
expect(isAdminDomainRoute("")).toBe(true);
|
||||
expect(isAdminDomainRoute("")).toBe(true);
|
||||
});
|
||||
|
||||
describe("nested route handling", () => {
|
||||
test("should handle nested survey routes", () => {
|
||||
expect(isPublicDomainRoute("/s/survey123/preview")).toBe(true);
|
||||
expect(isPublicDomainRoute("/s/survey123/embed")).toBe(true);
|
||||
expect(isPublicDomainRoute("/s/survey123/thank-you")).toBe(true);
|
||||
});
|
||||
|
||||
test("should handle nested client API routes", () => {
|
||||
expect(isPublicDomainRoute("/api/v1/client/env123/actions")).toBe(true);
|
||||
expect(isPublicDomainRoute("/api/v2/client/env456/responses")).toBe(true);
|
||||
expect(isPublicDomainRoute("/api/v1/client/env789/surveys/123")).toBe(true);
|
||||
expect(isClientSideApiRoute("/api/v1/client/env123/actions")).toEqual({
|
||||
isClientSideApi: true,
|
||||
isRateLimited: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle deeply nested admin routes", () => {
|
||||
expect(isAuthProtectedRoute("/environments/123/surveys/456/settings")).toBe(true);
|
||||
expect(isAuthProtectedRoute("/organizations/789/members/123/roles")).toBe(true);
|
||||
expect(isAuthProtectedRoute("/setup/organization/team/invites")).toBe(true);
|
||||
});
|
||||
test("should handle paths with query parameters", () => {
|
||||
expect(isPublicDomainRoute("/s/survey123?param=value")).toBe(true);
|
||||
expect(isPublicDomainRoute("/environments/123?tab=settings")).toBe(false);
|
||||
});
|
||||
|
||||
describe("version handling", () => {
|
||||
test("should handle different API versions correctly", () => {
|
||||
// Client API - only v1 and v2 supported in public routes
|
||||
expect(isPublicDomainRoute("/api/v1/client/test")).toBe(true);
|
||||
expect(isPublicDomainRoute("/api/v2/client/test")).toBe(true);
|
||||
expect(isPublicDomainRoute("/api/v3/client/test")).toBe(false);
|
||||
|
||||
// Management API - all versions supported
|
||||
expect(isManagementApiRoute("/api/v1/management/test")).toEqual({
|
||||
isManagementApi: true,
|
||||
authenticationMethod: AuthenticationMethod.ApiKey,
|
||||
});
|
||||
expect(isManagementApiRoute("/api/v2/management/test")).toEqual({
|
||||
isManagementApi: true,
|
||||
authenticationMethod: AuthenticationMethod.ApiKey,
|
||||
});
|
||||
expect(isManagementApiRoute("/api/v3/management/test")).toEqual({
|
||||
isManagementApi: true,
|
||||
authenticationMethod: AuthenticationMethod.ApiKey,
|
||||
});
|
||||
|
||||
// Integration API - all versions supported
|
||||
expect(isIntegrationRoute("/api/v1/integrations/test")).toBe(true);
|
||||
expect(isIntegrationRoute("/api/v2/integrations/test")).toBe(true);
|
||||
expect(isIntegrationRoute("/api/v3/integrations/test")).toBe(true);
|
||||
});
|
||||
test("should handle paths with fragments", () => {
|
||||
expect(isPublicDomainRoute("/s/survey123#section")).toBe(true);
|
||||
expect(isPublicDomainRoute("/environments/123#overview")).toBe(false);
|
||||
});
|
||||
|
||||
describe("special characters in routes", () => {
|
||||
test("should handle special characters in survey IDs", () => {
|
||||
expect(isPublicDomainRoute("/s/survey-123_test.v2")).toBe(true);
|
||||
expect(isPublicDomainRoute("/c/jwt.token.with.dots")).toBe(true);
|
||||
expect(
|
||||
isSyncWithUserIdentificationEndpoint("/api/v1/client/env-123_test/app/sync/user-456_test")
|
||||
).toEqual({
|
||||
environmentId: "env-123_test",
|
||||
userId: "user-456_test",
|
||||
});
|
||||
});
|
||||
test("should handle nested survey routes", () => {
|
||||
expect(isPublicDomainRoute("/s/survey123/preview")).toBe(true);
|
||||
expect(isPublicDomainRoute("/s/survey123/embed")).toBe(true);
|
||||
});
|
||||
|
||||
describe("security considerations", () => {
|
||||
test("should properly validate malicious or injection-like URLs", () => {
|
||||
// SQL injection-like attempts
|
||||
expect(isPublicDomainRoute("/s/'; DROP TABLE users; --")).toBe(true); // Still valid survey ID format
|
||||
expect(isManagementApiRoute("/api/v1/management/'; DROP TABLE users; --")).toEqual({
|
||||
isManagementApi: true,
|
||||
authenticationMethod: AuthenticationMethod.ApiKey,
|
||||
});
|
||||
|
||||
// Path traversal attempts
|
||||
expect(isPublicDomainRoute("/s/../../../etc/passwd")).toBe(true); // Still matches pattern
|
||||
expect(isAuthProtectedRoute("/environments/../../../etc/passwd")).toBe(true);
|
||||
|
||||
// XSS-like attempts
|
||||
expect(isPublicDomainRoute("/s/<script>alert('xss')</script>")).toBe(true);
|
||||
expect(isClientSideApiRoute("/api/v1/client/<script>alert('xss')</script>")).toEqual({
|
||||
isClientSideApi: true,
|
||||
isRateLimited: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle URL encoding", () => {
|
||||
expect(isPublicDomainRoute("/s/survey%20123")).toBe(true);
|
||||
expect(isPublicDomainRoute("/c/jwt%2Etoken")).toBe(true);
|
||||
expect(isAuthProtectedRoute("/environments%2F123")).toBe(true);
|
||||
expect(isManagementApiRoute("/api/v1/management/test%20route")).toEqual({
|
||||
isManagementApi: true,
|
||||
authenticationMethod: AuthenticationMethod.ApiKey,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("performance considerations", () => {
|
||||
test("should handle very long URLs efficiently", () => {
|
||||
const longSurveyId = "a".repeat(1000);
|
||||
const longPath = `s/${longSurveyId}`;
|
||||
expect(isPublicDomainRoute(`/${longPath}`)).toBe(true);
|
||||
|
||||
const longEnvironmentId = "env" + "a".repeat(1000);
|
||||
const longUserId = "user" + "b".repeat(1000);
|
||||
expect(
|
||||
isSyncWithUserIdentificationEndpoint(`/api/v1/client/${longEnvironmentId}/app/sync/${longUserId}`)
|
||||
).toEqual({
|
||||
environmentId: longEnvironmentId,
|
||||
userId: longUserId,
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle empty and minimal inputs", () => {
|
||||
expect(isPublicDomainRoute("")).toBe(false);
|
||||
expect(isClientSideApiRoute("")).toEqual({
|
||||
isClientSideApi: false,
|
||||
isRateLimited: true,
|
||||
});
|
||||
expect(isManagementApiRoute("")).toEqual({
|
||||
isManagementApi: false,
|
||||
authenticationMethod: AuthenticationMethod.ApiKey,
|
||||
});
|
||||
expect(isIntegrationRoute("")).toBe(false);
|
||||
expect(isAuthProtectedRoute("")).toBe(false);
|
||||
expect(isSyncWithUserIdentificationEndpoint("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("case sensitivity", () => {
|
||||
test("should be case sensitive for route patterns", () => {
|
||||
// These should not match due to case sensitivity
|
||||
expect(isPublicDomainRoute("/S/survey123")).toBe(false);
|
||||
expect(isPublicDomainRoute("/C/jwt-token")).toBe(false);
|
||||
expect(isClientSideApiRoute("/API/V1/CLIENT/test")).toEqual({
|
||||
isClientSideApi: false,
|
||||
isRateLimited: true,
|
||||
});
|
||||
expect(isManagementApiRoute("/API/V1/MANAGEMENT/test")).toEqual({
|
||||
isManagementApi: false,
|
||||
authenticationMethod: AuthenticationMethod.ApiKey,
|
||||
});
|
||||
expect(isIntegrationRoute("/API/V1/INTEGRATIONS/test")).toBe(false);
|
||||
expect(isAuthProtectedRoute("/ENVIRONMENTS/123")).toBe(false);
|
||||
});
|
||||
test("should handle nested client API routes", () => {
|
||||
expect(isPublicDomainRoute("/api/v1/client/env123/actions")).toBe(true);
|
||||
expect(isPublicDomainRoute("/api/v2/client/env456/responses")).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,35 +4,18 @@ import {
|
||||
matchesAnyPattern,
|
||||
} from "./route-config";
|
||||
|
||||
export enum AuthenticationMethod {
|
||||
ApiKey = "apiKey",
|
||||
Session = "session",
|
||||
Both = "both",
|
||||
None = "none",
|
||||
}
|
||||
|
||||
export const isClientSideApiRoute = (url: string): { isClientSideApi: boolean; isRateLimited: boolean } => {
|
||||
export const isClientSideApiRoute = (url: string): boolean => {
|
||||
// Open Graph image generation route is a client side API route but it should not be rate limited
|
||||
if (url.includes("/api/v1/client/og")) return { isClientSideApi: true, isRateLimited: false };
|
||||
if (url.includes("/api/v1/client/og")) return false;
|
||||
|
||||
if (url.includes("/api/v1/js/actions")) return true;
|
||||
if (url.includes("/api/v1/client/storage")) return true;
|
||||
const regex = /^\/api\/v\d+\/client\//;
|
||||
return { isClientSideApi: regex.test(url), isRateLimited: true };
|
||||
return regex.test(url);
|
||||
};
|
||||
|
||||
export const isManagementApiRoute = (
|
||||
url: string
|
||||
): { isManagementApi: boolean; authenticationMethod: AuthenticationMethod } => {
|
||||
if (url.includes("/api/v1/management/storage"))
|
||||
return { isManagementApi: true, authenticationMethod: AuthenticationMethod.Both };
|
||||
if (url.includes("/api/v1/webhooks"))
|
||||
return { isManagementApi: true, authenticationMethod: AuthenticationMethod.ApiKey };
|
||||
|
||||
export const isManagementApiRoute = (url: string): boolean => {
|
||||
const regex = /^\/api\/v\d+\/management\//;
|
||||
return { isManagementApi: regex.test(url), authenticationMethod: AuthenticationMethod.ApiKey };
|
||||
};
|
||||
|
||||
export const isIntegrationRoute = (url: string): boolean => {
|
||||
const regex = /^\/api\/v\d+\/integrations\//;
|
||||
return regex.test(url);
|
||||
};
|
||||
|
||||
|
||||
@@ -153,6 +153,20 @@ export const SURVEY_BG_COLORS = [
|
||||
"#CDFAD5",
|
||||
];
|
||||
|
||||
// Rate Limiting
|
||||
export const CLIENT_SIDE_API_RATE_LIMIT = {
|
||||
interval: 60, // 1 minute
|
||||
allowedPerInterval: 100,
|
||||
};
|
||||
export const MANAGEMENT_API_RATE_LIMIT = {
|
||||
interval: 60, // 1 minute
|
||||
allowedPerInterval: 100,
|
||||
};
|
||||
export const SYNC_USER_IDENTIFICATION_RATE_LIMIT = {
|
||||
interval: 60, // 1 minute
|
||||
allowedPerInterval: 5,
|
||||
};
|
||||
|
||||
export const DEBUG = env.DEBUG === "1";
|
||||
|
||||
// Enterprise License constant
|
||||
@@ -174,15 +188,7 @@ export const STRIPE_API_VERSION = "2024-06-20";
|
||||
export const MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT = 150;
|
||||
|
||||
export const DEFAULT_LOCALE = "en-US";
|
||||
export const AVAILABLE_LOCALES: TUserLocale[] = [
|
||||
"en-US",
|
||||
"de-DE",
|
||||
"pt-BR",
|
||||
"fr-FR",
|
||||
"zh-Hant-TW",
|
||||
"pt-PT",
|
||||
"ro-RO",
|
||||
];
|
||||
export const AVAILABLE_LOCALES: TUserLocale[] = ["en-US", "de-DE", "pt-BR", "fr-FR", "zh-Hant-TW", "pt-PT"];
|
||||
|
||||
// Billing constants
|
||||
|
||||
|
||||
@@ -140,7 +140,6 @@ export const appLanguages = [
|
||||
"fr-FR": "Anglais (États-Unis)",
|
||||
"zh-Hant-TW": "英文 (美國)",
|
||||
"pt-PT": "Inglês (EUA)",
|
||||
"ro-RO": "Engleză (SUA)",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -152,7 +151,6 @@ export const appLanguages = [
|
||||
"fr-FR": "Allemand",
|
||||
"zh-Hant-TW": "德語",
|
||||
"pt-PT": "Alemão",
|
||||
"ro-RO": "Germană",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -164,7 +162,6 @@ export const appLanguages = [
|
||||
"fr-FR": "Portugais (Brésil)",
|
||||
"zh-Hant-TW": "葡萄牙語 (巴西)",
|
||||
"pt-PT": "Português (Brasil)",
|
||||
"ro-RO": "Portugheză (Brazilia)",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -176,7 +173,6 @@ export const appLanguages = [
|
||||
"fr-FR": "Français",
|
||||
"zh-Hant-TW": "法語",
|
||||
"pt-PT": "Francês",
|
||||
"ro-RO": "Franceză",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -188,7 +184,6 @@ export const appLanguages = [
|
||||
"fr-FR": "Chinois (Traditionnel)",
|
||||
"zh-Hant-TW": "繁體中文",
|
||||
"pt-PT": "Chinês (Tradicional)",
|
||||
"ro-RO": "Chineză (Tradicională)",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -200,19 +195,6 @@ export const appLanguages = [
|
||||
"fr-FR": "Portugais (Portugal)",
|
||||
"zh-Hant-TW": "葡萄牙語 (葡萄牙)",
|
||||
"pt-PT": "Português (Portugal)",
|
||||
"ro-RO": "Portugheză (Portugalia)",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "ro-RO",
|
||||
label: {
|
||||
"en-US": "Romanian",
|
||||
"de-DE": "Rumänisch",
|
||||
"pt-BR": "Romeno",
|
||||
"fr-FR": "Roumain",
|
||||
"zh-Hant-TW": "羅馬尼亞語",
|
||||
"pt-PT": "Romeno",
|
||||
"ro-RO": "Română",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
+28
-865
@@ -1,5 +1,4 @@
|
||||
import * as crypto from "@/lib/crypto";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { env } from "@/lib/env";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import {
|
||||
@@ -15,69 +14,12 @@ 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),
|
||||
ENCRYPTION_KEY: "0".repeat(32), // 32-byte key for AES-256-GCM
|
||||
NEXTAUTH_SECRET: "test-nextauth-secret",
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock constants
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
NEXTAUTH_SECRET: "test-nextauth-secret",
|
||||
ENCRYPTION_KEY: "0".repeat(32),
|
||||
} as typeof env,
|
||||
}));
|
||||
|
||||
// Mock prisma
|
||||
@@ -89,65 +31,22 @@ vi.mock("@formbricks/database", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock logger
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("JWT Functions - Comprehensive Security Tests", () => {
|
||||
describe("JWT Functions", () => {
|
||||
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 with encrypted user ID", () => {
|
||||
const token = createToken(mockUser.id);
|
||||
test("should create a valid token", () => {
|
||||
const token = createToken(mockUser.id, mockUser.email);
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -157,18 +56,6 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
|
||||
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]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -177,30 +64,24 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
|
||||
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 or ENCRYPTION_KEY is not set", async () => {
|
||||
await testMissingSecretsError(createEmailToken, [mockUser.email]);
|
||||
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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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]);
|
||||
describe("getEmailFromEmailToken", () => {
|
||||
test("should extract email from valid token", () => {
|
||||
const token = createEmailToken(mockUser.email);
|
||||
const extractedEmail = getEmailFromEmailToken(token);
|
||||
expect(extractedEmail).toBe(mockUser.email);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -210,50 +91,6 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
|
||||
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]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -269,194 +106,23 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
|
||||
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);
|
||||
const token = createToken(mockUser.id, mockUser.email);
|
||||
const verified = await verifyToken(token);
|
||||
expect(verified).toEqual({
|
||||
id: mockUser.id, // Returns the decrypted user ID
|
||||
id: mockUser.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);
|
||||
const token = createToken(mockUser.id, mockUser.email);
|
||||
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", () => {
|
||||
@@ -473,53 +139,6 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
|
||||
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", () => {
|
||||
@@ -531,478 +150,22 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
|
||||
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 () => {
|
||||
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);
|
||||
// Create a token with missing fields
|
||||
const jwt = await import("jsonwebtoken");
|
||||
const token = jwt.sign({ foo: "bar" }, env.NEXTAUTH_SECRET as string);
|
||||
await expect(verifyEmailChangeToken(token)).rejects.toThrow(
|
||||
"Token is invalid or missing required fields"
|
||||
);
|
||||
});
|
||||
|
||||
test("should return original id/email if decryption fails", async () => {
|
||||
mockSymmetricDecrypt.mockImplementation(() => {
|
||||
throw new Error("Decryption failed");
|
||||
});
|
||||
|
||||
// Create a token with non-encrypted id/email
|
||||
const jwt = await import("jsonwebtoken");
|
||||
const payload = { id: "plain-id", email: "plain@example.com" };
|
||||
const token = jwt.sign(payload, TEST_NEXTAUTH_SECRET);
|
||||
const token = jwt.sign(payload, env.NEXTAUTH_SECRET as string);
|
||||
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
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+86
-210
@@ -1,64 +1,43 @@
|
||||
import { ENCRYPTION_KEY, NEXTAUTH_SECRET } from "@/lib/constants";
|
||||
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";
|
||||
|
||||
// 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 createToken = (userId: string, options = {}): string => {
|
||||
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);
|
||||
return jwt.sign({ id: encryptedUserId }, NEXTAUTH_SECRET, options);
|
||||
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 => {
|
||||
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);
|
||||
const encryptedEmail = symmetricEncrypt(userEmail, env.ENCRYPTION_KEY);
|
||||
return jwt.sign({ email: encryptedEmail }, env.NEXTAUTH_SECRET + surveyId);
|
||||
};
|
||||
|
||||
export const verifyEmailChangeToken = async (token: string): Promise<{ id: string; email: string }> => {
|
||||
if (!NEXTAUTH_SECRET) {
|
||||
if (!env.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;
|
||||
};
|
||||
const payload = jwt.verify(token, env.NEXTAUTH_SECRET) as { id: string; email: string };
|
||||
|
||||
if (!payload?.id || !payload?.email) {
|
||||
throw new Error("Token is invalid or missing required fields");
|
||||
}
|
||||
|
||||
// Decrypt both fields with fallback
|
||||
const decryptedId = decryptWithFallback(payload.id, ENCRYPTION_KEY);
|
||||
const decryptedEmail = decryptWithFallback(payload.email, ENCRYPTION_KEY);
|
||||
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;
|
||||
}
|
||||
|
||||
return {
|
||||
id: decryptedId,
|
||||
@@ -67,230 +46,127 @@ export const verifyEmailChangeToken = async (token: string): Promise<{ id: strin
|
||||
};
|
||||
|
||||
export const createEmailChangeToken = (userId: string, email: 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 encryptedUserId = symmetricEncrypt(userId, ENCRYPTION_KEY);
|
||||
const encryptedEmail = symmetricEncrypt(email, ENCRYPTION_KEY);
|
||||
const encryptedUserId = symmetricEncrypt(userId, env.ENCRYPTION_KEY);
|
||||
const encryptedEmail = symmetricEncrypt(email, env.ENCRYPTION_KEY);
|
||||
|
||||
const payload = {
|
||||
id: encryptedUserId,
|
||||
email: encryptedEmail,
|
||||
};
|
||||
|
||||
return jwt.sign(payload, NEXTAUTH_SECRET, {
|
||||
return jwt.sign(payload, env.NEXTAUTH_SECRET as string, {
|
||||
expiresIn: "1d",
|
||||
});
|
||||
};
|
||||
|
||||
export const createEmailToken = (email: string): string => {
|
||||
if (!NEXTAUTH_SECRET) {
|
||||
if (!env.NEXTAUTH_SECRET) {
|
||||
throw new Error("NEXTAUTH_SECRET is not set");
|
||||
}
|
||||
|
||||
if (!ENCRYPTION_KEY) {
|
||||
throw new Error("ENCRYPTION_KEY is not set");
|
||||
}
|
||||
|
||||
const encryptedEmail = symmetricEncrypt(email, ENCRYPTION_KEY);
|
||||
return jwt.sign({ email: encryptedEmail }, NEXTAUTH_SECRET);
|
||||
const encryptedEmail = symmetricEncrypt(email, env.ENCRYPTION_KEY);
|
||||
return jwt.sign({ email: encryptedEmail }, env.NEXTAUTH_SECRET);
|
||||
};
|
||||
|
||||
export const getEmailFromEmailToken = (token: string): string => {
|
||||
if (!NEXTAUTH_SECRET) {
|
||||
if (!env.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, 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;
|
||||
}
|
||||
|
||||
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 (!NEXTAUTH_SECRET) {
|
||||
if (!env.NEXTAUTH_SECRET) {
|
||||
throw new Error("NEXTAUTH_SECRET is not set");
|
||||
}
|
||||
|
||||
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);
|
||||
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);
|
||||
};
|
||||
|
||||
export const verifyTokenForLinkSurvey = (token: string, surveyId: string): string | null => {
|
||||
if (!NEXTAUTH_SECRET) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
let payload: JwtPayload & { email: string; surveyId?: string };
|
||||
|
||||
// Try primary method first (consistent secret)
|
||||
const { email } = jwt.verify(token, env.NEXTAUTH_SECRET + surveyId) as JwtPayload;
|
||||
try {
|
||||
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");
|
||||
// Try to decrypt first (for newer tokens)
|
||||
if (!env.ENCRYPTION_KEY) {
|
||||
throw new Error("ENCRYPTION_KEY is not set");
|
||||
}
|
||||
const decryptedEmail = symmetricDecrypt(email, env.ENCRYPTION_KEY);
|
||||
return decryptedEmail;
|
||||
} catch {
|
||||
// If decryption fails, return the original email (for older tokens)
|
||||
return email;
|
||||
}
|
||||
|
||||
// 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");
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// 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);
|
||||
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;
|
||||
|
||||
// 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;
|
||||
if (!payload) {
|
||||
throw new Error("Token is invalid");
|
||||
}
|
||||
|
||||
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");
|
||||
const { id } = payload;
|
||||
if (!id) {
|
||||
throw new Error("Token missing required field: id");
|
||||
}
|
||||
|
||||
// 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) {
|
||||
const errorMessage = "User not found";
|
||||
logger.error(errorMessage);
|
||||
throw new Error(errorMessage);
|
||||
throw new Error("User not found");
|
||||
}
|
||||
|
||||
return { userId: decryptedId, userEmail: foundUser.email };
|
||||
};
|
||||
const userEmail = foundUser.email;
|
||||
|
||||
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 };
|
||||
return { id: decryptedId, email: 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 payload = jwt.verify(token, NEXTAUTH_SECRET, { algorithms: ["HS256"] }) as JwtPayload & {
|
||||
inviteId: string;
|
||||
email: string;
|
||||
};
|
||||
const decoded = jwt.decode(token);
|
||||
const payload: JwtPayload = decoded as JwtPayload;
|
||||
|
||||
const { inviteId: encryptedInviteId, email: encryptedEmail } = payload;
|
||||
const { inviteId, email } = payload;
|
||||
|
||||
if (!encryptedInviteId || !encryptedEmail) {
|
||||
throw new Error("Invalid token");
|
||||
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;
|
||||
}
|
||||
|
||||
// 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,
|
||||
|
||||
@@ -18,6 +18,7 @@ import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/type
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import { ITEMS_PER_PAGE, WEBAPP_URL } from "../constants";
|
||||
import { deleteDisplay } from "../display/service";
|
||||
import { getResponseNotes } from "../responseNote/service";
|
||||
import { deleteFile, putFile } from "../storage/service";
|
||||
import { getSurvey } from "../survey/service";
|
||||
import { convertToCsv, convertToXlsxBuffer } from "../utils/file-conversion";
|
||||
@@ -71,6 +72,22 @@ export const responseSelection = {
|
||||
},
|
||||
},
|
||||
},
|
||||
notes: {
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
text: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
isResolved: true,
|
||||
isEdited: true,
|
||||
},
|
||||
},
|
||||
} satisfies Prisma.ResponseSelect;
|
||||
|
||||
export const getResponseContact = (
|
||||
@@ -110,6 +127,7 @@ export const getResponsesByContactId = reactCache(
|
||||
|
||||
await Promise.all(
|
||||
responsePrisma.map(async (response) => {
|
||||
const responseNotes = await getResponseNotes(response.id);
|
||||
const responseContact: TResponseContact = {
|
||||
id: response.contact?.id as string,
|
||||
userId: response.contact?.attributes.find((attribute) => attribute.attributeKey.key === "userId")
|
||||
@@ -119,7 +137,7 @@ export const getResponsesByContactId = reactCache(
|
||||
responses.push({
|
||||
...response,
|
||||
contact: responseContact,
|
||||
|
||||
notes: responseNotes,
|
||||
tags: response.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
|
||||
});
|
||||
})
|
||||
@@ -532,10 +550,11 @@ export const deleteResponse = async (responseId: string): Promise<TResponse> =>
|
||||
select: responseSelection,
|
||||
});
|
||||
|
||||
const responseNotes = await getResponseNotes(responsePrisma.id);
|
||||
const response: TResponse = {
|
||||
...responsePrisma,
|
||||
contact: getResponseContact(responsePrisma),
|
||||
|
||||
notes: responseNotes,
|
||||
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
|
||||
};
|
||||
|
||||
|
||||
@@ -5,12 +5,16 @@ import { TDisplay } from "@formbricks/types/displays";
|
||||
import { TResponse, TResponseFilterCriteria, TResponseUpdateInput } from "@formbricks/types/responses";
|
||||
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import { responseNoteSelect } from "../../../responseNote/service";
|
||||
import { responseSelection } from "../../service";
|
||||
import { constantsForTests } from "../constants";
|
||||
|
||||
type ResponseMock = Prisma.ResponseGetPayload<{
|
||||
include: typeof responseSelection;
|
||||
}>;
|
||||
type ResponseNoteMock = Prisma.ResponseNoteGetPayload<{
|
||||
include: typeof responseNoteSelect;
|
||||
}>;
|
||||
|
||||
export const mockEnvironmentId = "ars2tjk8hsi8oqk1uac00mo7";
|
||||
export const mockContactId = "lhwy39ga2zy8by1ol1bnaiso";
|
||||
@@ -30,6 +34,25 @@ export const mockMeta = {
|
||||
},
|
||||
};
|
||||
|
||||
export const mockResponseNote: ResponseNoteMock = {
|
||||
id: "clnndevho0mqrqp0fm2ozul8p",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
text: constantsForTests.text,
|
||||
isEdited: constantsForTests.boolean,
|
||||
isResolved: constantsForTests.boolean,
|
||||
responseId: mockResponseId,
|
||||
userId: mockUserId,
|
||||
response: {
|
||||
id: mockResponseId,
|
||||
surveyId: mockSurveyId,
|
||||
},
|
||||
user: {
|
||||
id: mockContactId,
|
||||
name: constantsForTests.fullName,
|
||||
},
|
||||
};
|
||||
|
||||
export const mockContact = {
|
||||
id: mockContactId,
|
||||
userId: mockUserId,
|
||||
@@ -71,6 +94,7 @@ export const mockResponse: ResponseMock = {
|
||||
createdAt: new Date(),
|
||||
finished: constantsForTests.boolean,
|
||||
meta: mockMeta,
|
||||
notes: [mockResponseNote],
|
||||
tags: mockTags,
|
||||
personId: mockContactId,
|
||||
updatedAt: new Date(),
|
||||
@@ -118,6 +142,7 @@ export const mockResponses: ResponseMock[] = [
|
||||
},
|
||||
language: null,
|
||||
tags: getMockTags(["tag1", "tag3"]),
|
||||
notes: [],
|
||||
endingId: null,
|
||||
displayId: null,
|
||||
},
|
||||
@@ -144,6 +169,7 @@ export const mockResponses: ResponseMock[] = [
|
||||
person: null,
|
||||
language: null,
|
||||
tags: getMockTags(["tag1", "tag2"]),
|
||||
notes: [],
|
||||
},
|
||||
{
|
||||
id: "clsk7b15p001fk8iu04qpvo2f",
|
||||
@@ -166,6 +192,7 @@ export const mockResponses: ResponseMock[] = [
|
||||
personId: mockContactId,
|
||||
person: null,
|
||||
tags: getMockTags(["tag2", "tag3"]),
|
||||
notes: [],
|
||||
language: null,
|
||||
},
|
||||
{
|
||||
@@ -189,6 +216,7 @@ export const mockResponses: ResponseMock[] = [
|
||||
personId: mockContactId,
|
||||
person: null,
|
||||
tags: getMockTags(["tag1", "tag4"]),
|
||||
notes: [],
|
||||
language: null,
|
||||
},
|
||||
{
|
||||
@@ -212,6 +240,7 @@ export const mockResponses: ResponseMock[] = [
|
||||
personId: mockContactId,
|
||||
person: null,
|
||||
tags: getMockTags(["tag4", "tag5"]),
|
||||
notes: [],
|
||||
language: null,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
mockEnvironmentId,
|
||||
mockResponse,
|
||||
mockResponseData,
|
||||
mockResponseNote,
|
||||
mockSingleUseId,
|
||||
mockSurveyId,
|
||||
mockSurveySummaryOutput,
|
||||
@@ -55,6 +56,7 @@ beforeEach(() => {
|
||||
|
||||
// mocking the person findFirst call as it is used in the transformPrismaPerson function
|
||||
prisma.contact.findFirst.mockResolvedValue(mockContact);
|
||||
prisma.responseNote.findMany.mockResolvedValue([mockResponseNote]);
|
||||
|
||||
prisma.response.findUnique.mockResolvedValue(mockResponse);
|
||||
|
||||
|
||||
@@ -367,6 +367,7 @@ describe("Response Utils", () => {
|
||||
finished: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
notes: [],
|
||||
tags: [],
|
||||
},
|
||||
];
|
||||
@@ -417,6 +418,7 @@ describe("Response Utils", () => {
|
||||
finished: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
notes: [],
|
||||
tags: [],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -613,6 +613,7 @@ export const getResponsesJson = (
|
||||
"Survey ID": response.surveyId,
|
||||
"Formbricks ID (internal)": response.contact?.id || "",
|
||||
"User ID": response.contact?.userId || "",
|
||||
Notes: response.notes.map((note) => `${note.user.name}: ${note.text}`).join("\n"),
|
||||
Tags: response.tags.map((tag) => tag.name).join(", "),
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import "server-only";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZId, ZString } from "@formbricks/types/common";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TResponseNote } from "@formbricks/types/responses";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
|
||||
export const responseNoteSelect = {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
text: true,
|
||||
isEdited: true,
|
||||
isResolved: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
response: {
|
||||
select: {
|
||||
id: true,
|
||||
surveyId: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const createResponseNote = async (
|
||||
responseId: string,
|
||||
userId: string,
|
||||
text: string
|
||||
): Promise<TResponseNote> => {
|
||||
validateInputs([responseId, ZId], [userId, ZId], [text, ZString]);
|
||||
|
||||
try {
|
||||
const responseNote = await prisma.responseNote.create({
|
||||
data: {
|
||||
responseId: responseId,
|
||||
userId: userId,
|
||||
text: text,
|
||||
},
|
||||
select: responseNoteSelect,
|
||||
});
|
||||
|
||||
return responseNote;
|
||||
} catch (error) {
|
||||
logger.error(error, "Error creating response note");
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getResponseNote = reactCache(
|
||||
async (responseNoteId: string): Promise<(TResponseNote & { responseId: string }) | null> => {
|
||||
try {
|
||||
const responseNote = await prisma.responseNote.findUnique({
|
||||
where: {
|
||||
id: responseNoteId,
|
||||
},
|
||||
select: {
|
||||
...responseNoteSelect,
|
||||
responseId: true,
|
||||
},
|
||||
});
|
||||
return responseNote;
|
||||
} catch (error) {
|
||||
logger.error(error, "Error getting response note");
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const getResponseNotes = reactCache(async (responseId: string): Promise<TResponseNote[]> => {
|
||||
try {
|
||||
validateInputs([responseId, ZId]);
|
||||
|
||||
const responseNotes = await prisma.responseNote.findMany({
|
||||
where: {
|
||||
responseId,
|
||||
},
|
||||
select: responseNoteSelect,
|
||||
});
|
||||
if (!responseNotes) {
|
||||
throw new ResourceNotFoundError("Response Notes by ResponseId", responseId);
|
||||
}
|
||||
return responseNotes;
|
||||
} catch (error) {
|
||||
logger.error(error, "Error getting response notes");
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
export const updateResponseNote = async (responseNoteId: string, text: string): Promise<TResponseNote> => {
|
||||
validateInputs([responseNoteId, ZString], [text, ZString]);
|
||||
|
||||
try {
|
||||
const updatedResponseNote = await prisma.responseNote.update({
|
||||
where: {
|
||||
id: responseNoteId,
|
||||
},
|
||||
data: {
|
||||
text: text,
|
||||
updatedAt: new Date(),
|
||||
isEdited: true,
|
||||
},
|
||||
select: responseNoteSelect,
|
||||
});
|
||||
|
||||
return updatedResponseNote;
|
||||
} catch (error) {
|
||||
logger.error(error, "Error updating response note");
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const resolveResponseNote = async (responseNoteId: string): Promise<TResponseNote> => {
|
||||
validateInputs([responseNoteId, ZString]);
|
||||
|
||||
try {
|
||||
const responseNote = await prisma.responseNote.update({
|
||||
where: {
|
||||
id: responseNoteId,
|
||||
},
|
||||
data: {
|
||||
updatedAt: new Date(),
|
||||
isResolved: true,
|
||||
},
|
||||
select: responseNoteSelect,
|
||||
});
|
||||
|
||||
return responseNote;
|
||||
} catch (error) {
|
||||
logger.error(error, "Error resolving response note");
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -278,6 +278,7 @@ describe("Response Processing", () => {
|
||||
source: undefined,
|
||||
userAgent: undefined,
|
||||
},
|
||||
notes: [],
|
||||
tags: [],
|
||||
person: null,
|
||||
personAttributes: {},
|
||||
@@ -319,6 +320,7 @@ describe("Response Processing", () => {
|
||||
source: undefined,
|
||||
userAgent: undefined,
|
||||
},
|
||||
notes: [],
|
||||
tags: [],
|
||||
person: null,
|
||||
personAttributes: {},
|
||||
@@ -390,6 +392,7 @@ describe("Response Processing", () => {
|
||||
source: undefined,
|
||||
userAgent: undefined,
|
||||
},
|
||||
notes: [],
|
||||
tags: [],
|
||||
person: null,
|
||||
personAttributes: {},
|
||||
@@ -419,6 +422,7 @@ describe("Response Processing", () => {
|
||||
source: undefined,
|
||||
userAgent: undefined,
|
||||
},
|
||||
notes: [],
|
||||
tags: [],
|
||||
person: null,
|
||||
personAttributes: {},
|
||||
@@ -449,6 +453,7 @@ describe("Response Processing", () => {
|
||||
source: undefined,
|
||||
userAgent: undefined,
|
||||
},
|
||||
notes: [],
|
||||
tags: [],
|
||||
person: null,
|
||||
personAttributes: {},
|
||||
@@ -483,6 +488,7 @@ describe("Response Processing", () => {
|
||||
source: undefined,
|
||||
userAgent: undefined,
|
||||
},
|
||||
notes: [],
|
||||
tags: [],
|
||||
person: null,
|
||||
personAttributes: {},
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// DEPRECATED
|
||||
// The ShortUrl feature is deprecated and only available for backward compatibility.
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { TShortUrl, ZShortUrlId } from "@formbricks/types/short-url";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
|
||||
// Get the full url from short url and return it
|
||||
export const getShortUrl = reactCache(async (id: string): Promise<TShortUrl | null> => {
|
||||
validateInputs([id, ZShortUrlId]);
|
||||
try {
|
||||
return await prisma.shortUrl.findUnique({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
export const getShortUrlByUrl = reactCache(async (url: string): Promise<TShortUrl | null> => {
|
||||
validateInputs([url, z.string().url()]);
|
||||
try {
|
||||
return await prisma.shortUrl.findUnique({
|
||||
where: {
|
||||
url,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
@@ -1,7 +1,4 @@
|
||||
import { S3Client } from "@aws-sdk/client-s3";
|
||||
import { readFile } from "fs/promises";
|
||||
import { lookup } from "mime-types";
|
||||
import path from "path";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
// Mock AWS SDK
|
||||
@@ -10,19 +7,6 @@ const mockS3Client = {
|
||||
send: mockSend,
|
||||
};
|
||||
|
||||
vi.mock("fs/promises", () => ({
|
||||
readFile: vi.fn(),
|
||||
access: vi.fn(),
|
||||
mkdir: vi.fn(),
|
||||
rmdir: vi.fn(),
|
||||
unlink: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("mime-types", () => ({
|
||||
lookup: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@aws-sdk/client-s3", () => ({
|
||||
S3Client: vi.fn(() => mockS3Client),
|
||||
HeadBucketCommand: vi.fn(),
|
||||
@@ -210,103 +194,5 @@ describe("Storage Service", () => {
|
||||
expect(result.signedUrl).toBe("https://test-bucket.s3.test-region.amazonaws.com");
|
||||
expect(result.presignedFields).toEqual({ key: "test-key", policy: "test-policy" });
|
||||
});
|
||||
|
||||
test("use local storage for private files when S3 is not configured", async () => {
|
||||
vi.resetModules();
|
||||
|
||||
vi.doMock("../constants", () => ({
|
||||
S3_ACCESS_KEY: "test-access-key",
|
||||
S3_SECRET_KEY: "test-secret-key",
|
||||
S3_REGION: "test-region",
|
||||
S3_BUCKET_NAME: "test-bucket",
|
||||
S3_ENDPOINT_URL: "http://test-endpoint",
|
||||
S3_FORCE_PATH_STYLE: true,
|
||||
isS3Configured: () => false,
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
MAX_SIZES: {
|
||||
standard: 5 * 1024 * 1024,
|
||||
big: 10 * 1024 * 1024,
|
||||
},
|
||||
WEBAPP_URL: "http://test-webapp",
|
||||
ENCRYPTION_KEY: "test-encryption-key-32-chars-long!!",
|
||||
UPLOADS_DIR: "/tmp/uploads",
|
||||
}));
|
||||
|
||||
vi.mock("../getPublicUrl", () => ({
|
||||
getPublicDomain: () => "https://public-domain.com",
|
||||
}));
|
||||
|
||||
const freshModule = await import("./service");
|
||||
const freshGetUploadSignedUrl = freshModule.getUploadSignedUrl as typeof getUploadSignedUrl;
|
||||
|
||||
const result = await freshGetUploadSignedUrl("test.jpg", "env123", "image/jpeg", "private");
|
||||
|
||||
expect(result.fileUrl).toContain("http://test-webapp");
|
||||
expect(result.fileUrl).toMatch(
|
||||
/http:\/\/test-webapp\/storage\/env123\/private\/test--fid--test-uuid\.jpg/
|
||||
);
|
||||
expect(result.fileUrl).not.toContain("test-bucket");
|
||||
expect(result.fileUrl).not.toContain("test-endpoint");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getLocalFile", () => {
|
||||
let getLocalFile: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
const serviceModule = await import("./service");
|
||||
getLocalFile = serviceModule.getLocalFile;
|
||||
});
|
||||
|
||||
test("should return file buffer and metadata", async () => {
|
||||
vi.mocked(readFile).mockResolvedValue(Buffer.from("test"));
|
||||
vi.mocked(lookup).mockReturnValue("image/jpeg");
|
||||
|
||||
const result = await getLocalFile("/tmp/uploads/test/test.jpg");
|
||||
expect(result.fileBuffer).toBeInstanceOf(Buffer);
|
||||
expect(result.metaData).toEqual({ contentType: "image/jpeg" });
|
||||
});
|
||||
|
||||
test("should throw error when file does not exist", async () => {
|
||||
vi.mocked(readFile).mockRejectedValue(new Error("File not found"));
|
||||
await expect(getLocalFile("/tmp/uploads/test/test.jpg")).rejects.toThrow("File not found");
|
||||
});
|
||||
|
||||
test("should throw error when file path attempts traversal outside uploads dir", async () => {
|
||||
const traversalOutside = path.join("/tmp/uploads", "../outside.txt");
|
||||
await expect(getLocalFile(traversalOutside)).rejects.toThrow(
|
||||
"Invalid file path: Path must be within uploads folder"
|
||||
);
|
||||
});
|
||||
|
||||
test("should reject path traversal using '../secret' with security error", async () => {
|
||||
await expect(getLocalFile("../secret")).rejects.toThrow(
|
||||
"Invalid file path: Path must be within uploads folder"
|
||||
);
|
||||
});
|
||||
|
||||
test("should reject Windows-style traversal '..\\\\secret' with security error", async () => {
|
||||
await expect(getLocalFile("..\\secret")).rejects.toThrow(
|
||||
"Invalid file path: Path must be within uploads folder"
|
||||
);
|
||||
});
|
||||
|
||||
test("should reject nested traversal 'subdir/../../etc/passwd' with security error", async () => {
|
||||
await expect(getLocalFile("subdir/../../etc/passwd")).rejects.toThrow(
|
||||
"Invalid file path: Path must be within uploads folder"
|
||||
);
|
||||
});
|
||||
|
||||
test("should throw EISDIR when provided path is a directory inside uploads", async () => {
|
||||
// Simulate Node throwing EISDIR when attempting to read a directory
|
||||
const eisdirError: any = new Error("EISDIR: illegal operation on a directory, read");
|
||||
eisdirError.code = "EISDIR";
|
||||
vi.mocked(readFile).mockRejectedValueOnce(eisdirError);
|
||||
|
||||
await expect(getLocalFile("/tmp/uploads/some-dir")).rejects.toMatchObject({
|
||||
code: "EISDIR",
|
||||
message: expect.stringContaining("EISDIR"),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -72,28 +72,12 @@ export const testS3BucketAccess = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to validate file paths are within the uploads directory
|
||||
const validateAndResolvePath = (filePath: string): string => {
|
||||
// Resolve and normalize the path to prevent directory traversal attacks
|
||||
const resolvedPath = path.resolve(filePath);
|
||||
const uploadsPath = path.resolve(UPLOADS_DIR);
|
||||
|
||||
// Ensure the resolved path is within the uploads directory
|
||||
if (!resolvedPath.startsWith(uploadsPath)) {
|
||||
throw new Error("Invalid file path: Path must be within uploads folder");
|
||||
}
|
||||
|
||||
return resolvedPath;
|
||||
};
|
||||
|
||||
const ensureDirectoryExists = async (dirPath: string) => {
|
||||
const safePath = validateAndResolvePath(dirPath);
|
||||
|
||||
try {
|
||||
await access(safePath);
|
||||
await access(dirPath);
|
||||
} catch (error: any) {
|
||||
if (error.code === "ENOENT") {
|
||||
await mkdir(safePath, { recursive: true });
|
||||
await mkdir(dirPath, { recursive: true });
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
@@ -142,7 +126,7 @@ export const getS3File = async (fileKey: string): Promise<string> => {
|
||||
|
||||
export const getLocalFile = async (filePath: string): Promise<TGetFileResponse> => {
|
||||
try {
|
||||
const safeFilePath = validateAndResolvePath(filePath);
|
||||
const safeFilePath = path.resolve(process.cwd(), filePath);
|
||||
const file = await readFile(safeFilePath);
|
||||
let contentType = "";
|
||||
|
||||
@@ -279,7 +263,6 @@ export const putFileToLocalStorage = async (
|
||||
await ensureDirectoryExists(`${rootDir}/${environmentId}/${accessType}`);
|
||||
|
||||
const uploadPath = `${rootDir}/${environmentId}/${accessType}/${fileName}`;
|
||||
const safeUploadPath = validateAndResolvePath(uploadPath);
|
||||
|
||||
const buffer = Buffer.from(fileBuffer as unknown as WithImplicitCoercion<string>);
|
||||
const bufferBytes = buffer.byteLength;
|
||||
@@ -297,7 +280,7 @@ export const putFileToLocalStorage = async (
|
||||
throw err;
|
||||
}
|
||||
|
||||
await writeFile(safeUploadPath, buffer as unknown as any);
|
||||
await writeFile(uploadPath, buffer as unknown as any);
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
@@ -363,8 +346,7 @@ export const deleteFile = async (
|
||||
|
||||
export const deleteLocalFile = async (filePath: string) => {
|
||||
try {
|
||||
const safeFilePath = validateAndResolvePath(filePath);
|
||||
await unlink(safeFilePath);
|
||||
await unlink(filePath);
|
||||
} catch (err: any) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
@@ -267,14 +267,12 @@ export const mockSyncSurveyOutput: SurveyMock = {
|
||||
showLanguageSwitch: null,
|
||||
thankYouCard: null,
|
||||
verifyEmail: null,
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
export const mockSurveyOutput: SurveyMock = {
|
||||
type: "link",
|
||||
status: "inProgress",
|
||||
displayOption: "respondMultiple",
|
||||
metadata: {},
|
||||
triggers: [{ actionClass: mockActionClass }],
|
||||
projectOverwrites: null,
|
||||
recaptcha: null,
|
||||
@@ -319,7 +317,6 @@ export const updateSurveyInput: TSurvey = {
|
||||
showLanguageSwitch: null,
|
||||
variables: [],
|
||||
followUps: [],
|
||||
metadata: {},
|
||||
...commonMockProperties,
|
||||
...baseSurveyProperties,
|
||||
};
|
||||
|
||||
@@ -60,7 +60,6 @@ export const selectSurvey = {
|
||||
pin: true,
|
||||
showLanguageSwitch: true,
|
||||
recaptcha: true,
|
||||
metadata: true,
|
||||
languages: {
|
||||
select: {
|
||||
default: true,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
|
||||
import { TResponseData, TResponseVariables } from "@formbricks/types/responses";
|
||||
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
TConditionGroup,
|
||||
TSingleCondition,
|
||||
TSurveyLogic,
|
||||
TSurveyLogicAction,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
addConditionBelow,
|
||||
@@ -109,7 +109,6 @@ describe("surveyLogic", () => {
|
||||
languages: [],
|
||||
triggers: [],
|
||||
segment: null,
|
||||
recaptcha: null,
|
||||
};
|
||||
|
||||
const simpleGroup = (): TConditionGroup => ({
|
||||
@@ -176,8 +175,7 @@ describe("surveyLogic", () => {
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = removeCondition(group, "c");
|
||||
expect(result).toBe(true);
|
||||
removeCondition(group, "c");
|
||||
expect(group.conditions).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -435,8 +433,6 @@ describe("surveyLogic", () => {
|
||||
)
|
||||
).toBe(true);
|
||||
expect(evaluateLogic(mockSurvey, { f: "foo" }, vars, group(baseCond("isSet")), "en")).toBe(true);
|
||||
expect(evaluateLogic(mockSurvey, { f: "foo" }, vars, group(baseCond("isNotEmpty")), "en")).toBe(true);
|
||||
expect(evaluateLogic(mockSurvey, { f: "" }, vars, group(baseCond("isNotSet")), "en")).toBe(true);
|
||||
expect(evaluateLogic(mockSurvey, { f: "" }, vars, group(baseCond("isEmpty")), "en")).toBe(true);
|
||||
expect(
|
||||
evaluateLogic(mockSurvey, { f: "foo" }, vars, group({ ...baseCond("isAnyOf", ["foo", "bar"]) }), "en")
|
||||
@@ -514,8 +510,7 @@ describe("surveyLogic", () => {
|
||||
expect(group.conditions.length).toBe(2);
|
||||
toggleGroupConnector(group, "notfound");
|
||||
expect(group.connector).toBe("and");
|
||||
const result = removeCondition(group, "notfound");
|
||||
expect(result).toBe(false);
|
||||
removeCondition(group, "notfound");
|
||||
expect(group.conditions.length).toBe(2);
|
||||
duplicateCondition(group, "notfound");
|
||||
expect(group.conditions.length).toBe(2);
|
||||
@@ -525,192 +520,6 @@ describe("surveyLogic", () => {
|
||||
expect(group.conditions.length).toBe(2);
|
||||
});
|
||||
|
||||
test("removeCondition returns false when condition not found in nested groups", () => {
|
||||
const nestedGroup: TConditionGroup = {
|
||||
id: "nested",
|
||||
connector: "and",
|
||||
conditions: [
|
||||
{
|
||||
id: "nestedC1",
|
||||
leftOperand: { type: "hiddenField", value: "nf1" },
|
||||
operator: "equals",
|
||||
rightOperand: { type: "static", value: "nv1" },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const group: TConditionGroup = {
|
||||
id: "parent",
|
||||
connector: "and",
|
||||
conditions: [nestedGroup],
|
||||
};
|
||||
|
||||
const result = removeCondition(group, "nonexistent");
|
||||
expect(result).toBe(false);
|
||||
expect(group.conditions).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("removeCondition successfully removes from nested groups and cleans up", () => {
|
||||
const nestedGroup: TConditionGroup = {
|
||||
id: "nested",
|
||||
connector: "and",
|
||||
conditions: [
|
||||
{
|
||||
id: "nestedC1",
|
||||
leftOperand: { type: "hiddenField", value: "nf1" },
|
||||
operator: "equals",
|
||||
rightOperand: { type: "static", value: "nv1" },
|
||||
},
|
||||
{
|
||||
id: "nestedC2",
|
||||
leftOperand: { type: "hiddenField", value: "nf2" },
|
||||
operator: "equals",
|
||||
rightOperand: { type: "static", value: "nv2" },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const otherCondition: TSingleCondition = {
|
||||
id: "otherCondition",
|
||||
leftOperand: { type: "hiddenField", value: "other" },
|
||||
operator: "equals",
|
||||
rightOperand: { type: "static", value: "value" },
|
||||
};
|
||||
|
||||
const group: TConditionGroup = {
|
||||
id: "parent",
|
||||
connector: "and",
|
||||
conditions: [nestedGroup, otherCondition],
|
||||
};
|
||||
|
||||
const result = removeCondition(group, "nestedC1");
|
||||
expect(result).toBe(true);
|
||||
expect(group.conditions).toHaveLength(2);
|
||||
expect((group.conditions[0] as TConditionGroup).conditions).toHaveLength(1);
|
||||
expect((group.conditions[0] as TConditionGroup).conditions[0].id).toBe("nestedC2");
|
||||
expect(group.conditions[1].id).toBe("otherCondition");
|
||||
});
|
||||
|
||||
test("removeCondition flattens group when nested group has only one condition left", () => {
|
||||
const deeplyNestedGroup: TConditionGroup = {
|
||||
id: "deepNested",
|
||||
connector: "or",
|
||||
conditions: [
|
||||
{
|
||||
id: "deepC1",
|
||||
leftOperand: { type: "hiddenField", value: "df1" },
|
||||
operator: "equals",
|
||||
rightOperand: { type: "static", value: "dv1" },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const nestedGroup: TConditionGroup = {
|
||||
id: "nested",
|
||||
connector: "and",
|
||||
conditions: [
|
||||
{
|
||||
id: "nestedC1",
|
||||
leftOperand: { type: "hiddenField", value: "nf1" },
|
||||
operator: "equals",
|
||||
rightOperand: { type: "static", value: "nv1" },
|
||||
},
|
||||
deeplyNestedGroup,
|
||||
],
|
||||
};
|
||||
|
||||
const otherCondition: TSingleCondition = {
|
||||
id: "otherCondition",
|
||||
leftOperand: { type: "hiddenField", value: "other" },
|
||||
operator: "equals",
|
||||
rightOperand: { type: "static", value: "value" },
|
||||
};
|
||||
|
||||
const group: TConditionGroup = {
|
||||
id: "parent",
|
||||
connector: "and",
|
||||
conditions: [nestedGroup, otherCondition],
|
||||
};
|
||||
|
||||
// Remove the regular condition, leaving only the deeply nested group in the nested group
|
||||
const result = removeCondition(group, "nestedC1");
|
||||
expect(result).toBe(true);
|
||||
|
||||
// The parent group should still have 2 conditions: the nested group and the other condition
|
||||
expect(group.conditions).toHaveLength(2);
|
||||
// The nested group should still be there but now contain only the deeply nested group
|
||||
expect(group.conditions[0].id).toBe("nested");
|
||||
expect((group.conditions[0] as TConditionGroup).conditions).toHaveLength(1);
|
||||
// The nested group should contain the flattened content from the deeply nested group
|
||||
expect((group.conditions[0] as TConditionGroup).conditions[0].id).toBe("deepC1");
|
||||
expect(group.conditions[1].id).toBe("otherCondition");
|
||||
});
|
||||
|
||||
test("removeCondition removes empty groups after cleanup", () => {
|
||||
const emptyNestedGroup: TConditionGroup = {
|
||||
id: "emptyNested",
|
||||
connector: "and",
|
||||
conditions: [
|
||||
{
|
||||
id: "toBeRemoved",
|
||||
leftOperand: { type: "hiddenField", value: "f1" },
|
||||
operator: "equals",
|
||||
rightOperand: { type: "static", value: "v1" },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const group: TConditionGroup = {
|
||||
id: "parent",
|
||||
connector: "and",
|
||||
conditions: [
|
||||
emptyNestedGroup,
|
||||
{
|
||||
id: "keepThis",
|
||||
leftOperand: { type: "hiddenField", value: "f2" },
|
||||
operator: "equals",
|
||||
rightOperand: { type: "static", value: "v2" },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Remove the only condition from the nested group
|
||||
const result = removeCondition(group, "toBeRemoved");
|
||||
expect(result).toBe(true);
|
||||
|
||||
// The empty nested group should be removed, leaving only the other condition
|
||||
expect(group.conditions).toHaveLength(1);
|
||||
expect(group.conditions[0].id).toBe("keepThis");
|
||||
});
|
||||
|
||||
test("deleteEmptyGroups with complex nested structure", () => {
|
||||
const deepEmptyGroup: TConditionGroup = { id: "deepEmpty", connector: "and", conditions: [] };
|
||||
const middleGroup: TConditionGroup = {
|
||||
id: "middle",
|
||||
connector: "or",
|
||||
conditions: [deepEmptyGroup],
|
||||
};
|
||||
const topGroup: TConditionGroup = {
|
||||
id: "top",
|
||||
connector: "and",
|
||||
conditions: [
|
||||
middleGroup,
|
||||
{
|
||||
id: "validCondition",
|
||||
leftOperand: { type: "hiddenField", value: "f" },
|
||||
operator: "equals",
|
||||
rightOperand: { type: "static", value: "v" },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
deleteEmptyGroups(topGroup);
|
||||
|
||||
// Should remove the nested empty groups and keep only the valid condition
|
||||
expect(topGroup.conditions).toHaveLength(1);
|
||||
expect(topGroup.conditions[0].id).toBe("validCondition");
|
||||
});
|
||||
|
||||
// Additional tests for complete coverage
|
||||
|
||||
test("addConditionBelow with nested group correctly adds condition", () => {
|
||||
|
||||
@@ -94,48 +94,21 @@ export const toggleGroupConnector = (group: TConditionGroup, resourceId: string)
|
||||
}
|
||||
};
|
||||
|
||||
export const removeCondition = (group: TConditionGroup, resourceId: string): boolean => {
|
||||
for (let i = group.conditions.length - 1; i >= 0; i--) {
|
||||
export const removeCondition = (group: TConditionGroup, resourceId: string) => {
|
||||
for (let i = 0; i < group.conditions.length; i++) {
|
||||
const item = group.conditions[i];
|
||||
|
||||
if (item.id === resourceId) {
|
||||
group.conditions.splice(i, 1);
|
||||
cleanupGroup(group);
|
||||
return true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (isConditionGroup(item) && removeCondition(item, resourceId)) {
|
||||
cleanupGroup(group);
|
||||
return true;
|
||||
if (isConditionGroup(item)) {
|
||||
removeCondition(item, resourceId);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const cleanupGroup = (group: TConditionGroup) => {
|
||||
// Remove empty condition groups first
|
||||
for (let i = group.conditions.length - 1; i >= 0; i--) {
|
||||
const condition = group.conditions[i];
|
||||
if (isConditionGroup(condition)) {
|
||||
cleanupGroup(condition);
|
||||
|
||||
// Remove if empty after cleanup
|
||||
if (condition.conditions.length === 0) {
|
||||
group.conditions.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flatten if group has only one condition and it's a condition group
|
||||
if (group.conditions.length === 1 && isConditionGroup(group.conditions[0])) {
|
||||
group.connector = group.conditions[0].connector || "and";
|
||||
group.conditions = group.conditions[0].conditions;
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteEmptyGroups = (group: TConditionGroup) => {
|
||||
cleanupGroup(group);
|
||||
deleteEmptyGroups(group);
|
||||
};
|
||||
|
||||
export const duplicateCondition = (group: TConditionGroup, resourceId: string) => {
|
||||
@@ -157,6 +130,18 @@ export const duplicateCondition = (group: TConditionGroup, resourceId: string) =
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteEmptyGroups = (group: TConditionGroup) => {
|
||||
for (let i = 0; i < group.conditions.length; i++) {
|
||||
const resource = group.conditions[i];
|
||||
|
||||
if (isConditionGroup(resource) && resource.conditions.length === 0) {
|
||||
group.conditions.splice(i, 1);
|
||||
} else if (isConditionGroup(resource)) {
|
||||
deleteEmptyGroups(resource);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const createGroupFromResource = (group: TConditionGroup, resourceId: string) => {
|
||||
for (let i = 0; i < group.conditions.length; i++) {
|
||||
const item = group.conditions[i];
|
||||
@@ -685,9 +670,8 @@ const performCalculation = (
|
||||
if (typeof val === "number" || typeof val === "string") {
|
||||
if (variable.type === "number" && !isNaN(Number(val))) {
|
||||
operandValue = Number(val);
|
||||
} else {
|
||||
operandValue = val;
|
||||
}
|
||||
operandValue = val;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { formatDistance, intlFormat } from "date-fns";
|
||||
import { de, enUS, fr, pt, ptBR, ro, zhTW } from "date-fns/locale";
|
||||
import { de, enUS, fr, pt, ptBR, zhTW } from "date-fns/locale";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
|
||||
export const convertDateString = (dateString: string | null) => {
|
||||
@@ -95,8 +95,6 @@ const getLocaleForTimeSince = (locale: TUserLocale) => {
|
||||
return zhTW;
|
||||
case "pt-PT":
|
||||
return pt;
|
||||
case "ro-RO":
|
||||
return ro;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ export type AuditLoggingCtx = {
|
||||
contactId?: string;
|
||||
apiKeyId?: string;
|
||||
responseId?: string;
|
||||
|
||||
responseNoteId?: string;
|
||||
teamId?: string;
|
||||
integrationId?: string;
|
||||
};
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
getOrganizationIdFromLanguageId,
|
||||
getOrganizationIdFromProjectId,
|
||||
getOrganizationIdFromResponseId,
|
||||
getOrganizationIdFromResponseNoteId,
|
||||
getOrganizationIdFromSegmentId,
|
||||
getOrganizationIdFromSurveyId,
|
||||
getOrganizationIdFromTagId,
|
||||
@@ -33,6 +34,7 @@ import {
|
||||
getProjectIdFromIntegrationId,
|
||||
getProjectIdFromLanguageId,
|
||||
getProjectIdFromResponseId,
|
||||
getProjectIdFromResponseNoteId,
|
||||
getProjectIdFromSegmentId,
|
||||
getProjectIdFromSurveyId,
|
||||
getProjectIdFromTagId,
|
||||
@@ -47,7 +49,7 @@ vi.mock("@/lib/utils/services", () => ({
|
||||
getSurvey: vi.fn(),
|
||||
getResponse: vi.fn(),
|
||||
getContact: vi.fn(),
|
||||
|
||||
getResponseNote: vi.fn(),
|
||||
getSegment: vi.fn(),
|
||||
getActionClass: vi.fn(),
|
||||
getIntegration: vi.fn(),
|
||||
@@ -228,6 +230,33 @@ describe("Helper Utilities", () => {
|
||||
await expect(getOrganizationIdFromTagId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
|
||||
});
|
||||
|
||||
test("getOrganizationIdFromResponseNoteId returns organization ID correctly", async () => {
|
||||
vi.mocked(services.getResponseNote).mockResolvedValueOnce({
|
||||
responseId: "response1",
|
||||
});
|
||||
vi.mocked(services.getResponse).mockResolvedValueOnce({
|
||||
surveyId: "survey1",
|
||||
});
|
||||
vi.mocked(services.getSurvey).mockResolvedValueOnce({
|
||||
environmentId: "env1",
|
||||
});
|
||||
vi.mocked(services.getEnvironment).mockResolvedValueOnce({
|
||||
projectId: "project1",
|
||||
});
|
||||
vi.mocked(services.getProject).mockResolvedValueOnce({
|
||||
organizationId: "org1",
|
||||
});
|
||||
|
||||
const orgId = await getOrganizationIdFromResponseNoteId("note1");
|
||||
expect(orgId).toBe("org1");
|
||||
});
|
||||
|
||||
test("getOrganizationIdFromResponseNoteId throws error when note not found", async () => {
|
||||
vi.mocked(services.getResponseNote).mockResolvedValueOnce(null);
|
||||
|
||||
await expect(getOrganizationIdFromResponseNoteId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
|
||||
});
|
||||
|
||||
test("getOrganizationIdFromSegmentId returns organization ID correctly", async () => {
|
||||
vi.mocked(services.getSegment).mockResolvedValueOnce({
|
||||
environmentId: "env1",
|
||||
@@ -563,6 +592,29 @@ describe("Helper Utilities", () => {
|
||||
await expect(getProjectIdFromResponseId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
|
||||
});
|
||||
|
||||
test("getProjectIdFromResponseNoteId returns project ID correctly", async () => {
|
||||
vi.mocked(services.getResponseNote).mockResolvedValueOnce({
|
||||
responseId: "response1",
|
||||
});
|
||||
vi.mocked(services.getResponse).mockResolvedValueOnce({
|
||||
surveyId: "survey1",
|
||||
});
|
||||
vi.mocked(services.getSurvey).mockResolvedValueOnce({
|
||||
environmentId: "env1",
|
||||
});
|
||||
vi.mocked(services.getEnvironment).mockResolvedValueOnce({
|
||||
projectId: "project1",
|
||||
});
|
||||
|
||||
const projectId = await getProjectIdFromResponseNoteId("note1");
|
||||
expect(projectId).toBe("project1");
|
||||
});
|
||||
|
||||
test("getProjectIdFromResponseNoteId throws error when responseNote not found", async () => {
|
||||
vi.mocked(services.getResponseNote).mockResolvedValueOnce(null);
|
||||
await expect(getProjectIdFromResponseNoteId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
|
||||
});
|
||||
|
||||
test("getProductIdFromContactId returns project ID correctly", async () => {
|
||||
vi.mocked(services.getContact).mockResolvedValueOnce({
|
||||
environmentId: "env1",
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
getLanguage,
|
||||
getProject,
|
||||
getResponse,
|
||||
getResponseNote,
|
||||
getSegment,
|
||||
getSurvey,
|
||||
getTag,
|
||||
@@ -103,6 +104,15 @@ export const getOrganizationIdFromTagId = async (tagId: string) => {
|
||||
return await getOrganizationIdFromEnvironmentId(tag.environmentId);
|
||||
};
|
||||
|
||||
export const getOrganizationIdFromResponseNoteId = async (responseNoteId: string) => {
|
||||
const responseNote = await getResponseNote(responseNoteId);
|
||||
if (!responseNote) {
|
||||
throw new ResourceNotFoundError("responseNote", responseNoteId);
|
||||
}
|
||||
|
||||
return await getOrganizationIdFromResponseId(responseNote.responseId);
|
||||
};
|
||||
|
||||
export const getOrganizationIdFromSegmentId = async (segmentId: string) => {
|
||||
const segment = await getSegment(segmentId);
|
||||
if (!segment) {
|
||||
@@ -266,6 +276,15 @@ export const getProjectIdFromResponseId = async (responseId: string) => {
|
||||
return await getProjectIdFromSurveyId(response.surveyId);
|
||||
};
|
||||
|
||||
export const getProjectIdFromResponseNoteId = async (responseNoteId: string) => {
|
||||
const responseNote = await getResponseNote(responseNoteId);
|
||||
if (!responseNote) {
|
||||
throw new ResourceNotFoundError("responseNote", responseNoteId);
|
||||
}
|
||||
|
||||
return await getProjectIdFromResponseId(responseNote.responseId);
|
||||
};
|
||||
|
||||
export const getProductIdFromContactId = async (contactId: string) => {
|
||||
const contact = await getContact(contactId);
|
||||
if (!contact) {
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
getLanguage,
|
||||
getProject,
|
||||
getResponse,
|
||||
getResponseNote,
|
||||
getSegment,
|
||||
getSurvey,
|
||||
getTag,
|
||||
@@ -55,7 +56,9 @@ vi.mock("@formbricks/database", () => ({
|
||||
response: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
|
||||
responseNote: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
survey: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
@@ -316,6 +319,33 @@ describe("Service Functions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getResponseNote", () => {
|
||||
const responseNoteId = "note123";
|
||||
|
||||
test("returns the response note when found", async () => {
|
||||
const mockResponseNote = { responseId: "resp123" };
|
||||
vi.mocked(prisma.responseNote.findUnique).mockResolvedValue(mockResponseNote);
|
||||
|
||||
const result = await getResponseNote(responseNoteId);
|
||||
expect(prisma.responseNote.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: responseNoteId },
|
||||
select: { responseId: true },
|
||||
});
|
||||
expect(result).toEqual(mockResponseNote);
|
||||
});
|
||||
|
||||
test("throws DatabaseError when database operation fails", async () => {
|
||||
vi.mocked(prisma.responseNote.findUnique).mockRejectedValue(
|
||||
new Prisma.PrismaClientKnownRequestError("Error", {
|
||||
code: "P2002",
|
||||
clientVersion: "4.7.0",
|
||||
})
|
||||
);
|
||||
|
||||
await expect(getResponseNote(responseNoteId)).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSurvey", () => {
|
||||
const surveyId = "survey123";
|
||||
|
||||
|
||||
@@ -184,6 +184,28 @@ export const getResponse = reactCache(async (responseId: string): Promise<{ surv
|
||||
}
|
||||
});
|
||||
|
||||
export const getResponseNote = reactCache(
|
||||
async (responseNoteId: string): Promise<{ responseId: string } | null> => {
|
||||
try {
|
||||
const responseNote = await prisma.responseNote.findUnique({
|
||||
where: {
|
||||
id: responseNoteId,
|
||||
},
|
||||
select: {
|
||||
responseId: true,
|
||||
},
|
||||
});
|
||||
return responseNote;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const getSurvey = reactCache(async (surveyId: string): Promise<{ environmentId: string } | null> => {
|
||||
validateInputs([surveyId, ZId]);
|
||||
try {
|
||||
|
||||
+66
-93
@@ -118,7 +118,6 @@
|
||||
"account_settings": "Kontoeinstellungen",
|
||||
"action": "Aktion",
|
||||
"actions": "Aktionen",
|
||||
"actions_description": "Code- und No-Code-Aktionen werden verwendet, um Abfangumfragen innerhalb von Apps und auf Websites auszulösen.",
|
||||
"active_surveys": "Aktive Umfragen",
|
||||
"activity": "Aktivität",
|
||||
"add": "Hinzufügen",
|
||||
@@ -126,7 +125,6 @@
|
||||
"add_filter": "Filter hinzufügen",
|
||||
"add_logo": "Logo hinzufügen",
|
||||
"add_member": "Mitglied hinzufügen",
|
||||
"add_new_project": "Neues Projekt hinzufügen",
|
||||
"add_project": "Projekt hinzufügen",
|
||||
"add_to_team": "Zum Team hinzufügen",
|
||||
"all": "Alle",
|
||||
@@ -143,6 +141,7 @@
|
||||
"apply_filters": "Filter anwenden",
|
||||
"are_you_sure": "Bist Du sicher?",
|
||||
"attributes": "Attribute",
|
||||
"avatar": "Avatar",
|
||||
"back": "Zurück",
|
||||
"billing": "Abrechnung",
|
||||
"booked": "Gebucht",
|
||||
@@ -151,9 +150,6 @@
|
||||
"cancel": "Abbrechen",
|
||||
"centered_modal": "Zentriertes Modalfenster",
|
||||
"choices": "Entscheidungen",
|
||||
"choose_environment": "Umgebung auswählen",
|
||||
"choose_organization": "Organisation auswählen",
|
||||
"choose_project": "Projekt wählen",
|
||||
"clear_all": "Alles löschen",
|
||||
"clear_filters": "Filter löschen",
|
||||
"clear_selection": "Auswahl aufheben",
|
||||
@@ -169,14 +165,11 @@
|
||||
"connect_formbricks": "Formbricks verbinden",
|
||||
"connected": "Verbunden",
|
||||
"contacts": "Kontakte",
|
||||
"continue": "Weitermachen",
|
||||
"copied": "Kopiert",
|
||||
"copied_to_clipboard": "In die Zwischenablage kopiert",
|
||||
"copy": "Kopieren",
|
||||
"copy_code": "Code kopieren",
|
||||
"copy_link": "Link kopieren",
|
||||
"count_contacts": "{value, plural, other {'{'value, plural,\none '{{#}' Kontakt'}'\nother '{{#}' Kontakte'}'\n'}'}}",
|
||||
"count_responses": "{value, plural, other {{count} Antworten}}",
|
||||
"create_new_organization": "Neue Organisation erstellen",
|
||||
"create_project": "Projekt erstellen",
|
||||
"create_segment": "Segment erstellen",
|
||||
@@ -185,6 +178,7 @@
|
||||
"created_at": "Erstellt am",
|
||||
"created_by": "Erstellt von",
|
||||
"customer_success": "Kundenerfolg",
|
||||
"danger_zone": "Gefahrenzone",
|
||||
"dark_overlay": "Dunkle Überlagerung",
|
||||
"date": "Datum",
|
||||
"default": "Standard",
|
||||
@@ -204,15 +198,10 @@
|
||||
"e_commerce": "E-Commerce",
|
||||
"edit": "Bearbeiten",
|
||||
"email": "E-Mail",
|
||||
"ending_card": "Abschluss-Karte",
|
||||
"enterprise_license": "Enterprise Lizenz",
|
||||
"environment_not_found": "Umgebung nicht gefunden",
|
||||
"environment_notice": "Du befindest dich derzeit in der {environment}-Umgebung.",
|
||||
"error": "Fehler",
|
||||
"error_component_description": "Diese Ressource existiert nicht oder Du hast nicht die notwendigen Rechte, um darauf zuzugreifen.",
|
||||
"error_component_title": "Fehler beim Laden der Ressourcen",
|
||||
"error_rate_limit_description": "Maximale Anzahl an Anfragen erreicht. Bitte später erneut versuchen.",
|
||||
"error_rate_limit_title": "Rate Limit Überschritten",
|
||||
"expand_rows": "Zeilen erweitern",
|
||||
"finish": "Fertigstellen",
|
||||
"follow_these": "Folge diesen",
|
||||
@@ -225,6 +214,7 @@
|
||||
"hidden": "Versteckt",
|
||||
"hidden_field": "Verstecktes Feld",
|
||||
"hidden_fields": "Versteckte Felder",
|
||||
"hide": "Verstecken",
|
||||
"hide_column": "Spalte ausblenden",
|
||||
"image": "Bild",
|
||||
"images": "Bilder",
|
||||
@@ -244,9 +234,11 @@
|
||||
"label": "Bezeichnung",
|
||||
"language": "Sprache",
|
||||
"learn_more": "Mehr erfahren",
|
||||
"license": "Lizenz",
|
||||
"light_overlay": "Helle Überlagerung",
|
||||
"limits_reached": "Limits erreicht",
|
||||
"link": "Link",
|
||||
"link_and_email": "Link & E-Mail",
|
||||
"link_survey": "Link-Umfrage",
|
||||
"link_surveys": "Umfragen verknüpfen",
|
||||
"load_more": "Mehr laden",
|
||||
@@ -262,20 +254,18 @@
|
||||
"membership_not_found": "Mitgliedschaft nicht gefunden",
|
||||
"metadata": "Metadaten",
|
||||
"minimum": "Minimum",
|
||||
"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!",
|
||||
"mobile_overlay_text": "Formbricks ist für Geräte mit kleineren Auflösungen nicht verfügbar.",
|
||||
"move_down": "Nach unten bewegen",
|
||||
"move_up": "Nach oben bewegen",
|
||||
"multiple_languages": "Mehrsprachigkeit",
|
||||
"name": "Name",
|
||||
"new": "Neu",
|
||||
"new_survey": "Neue Umfrage",
|
||||
"new_version_available": "Formbricks {version} ist da. Jetzt aktualisieren!",
|
||||
"next": "Weiter",
|
||||
"no_background_image_found": "Kein Hintergrundbild gefunden.",
|
||||
"no_code": "No Code",
|
||||
"no_files_uploaded": "Keine Dateien hochgeladen",
|
||||
"no_quotas_found": "Keine Kontingente gefunden",
|
||||
"no_result_found": "Kein Ergebnis gefunden",
|
||||
"no_results": "Keine Ergebnisse",
|
||||
"no_surveys_found": "Keine Umfragen gefunden.",
|
||||
@@ -283,6 +273,7 @@
|
||||
"not_authorized": "Nicht berechtigt",
|
||||
"not_connected": "Nicht verbunden",
|
||||
"note": "Notiz",
|
||||
"notes": "Notizen",
|
||||
"notifications": "Benachrichtigungen",
|
||||
"number": "Nummer",
|
||||
"off": "Aus",
|
||||
@@ -295,7 +286,6 @@
|
||||
"organization": "Organisation",
|
||||
"organization_id": "Organisations-ID",
|
||||
"organization_not_found": "Organisation nicht gefunden",
|
||||
"organization_settings": "Organisationseinstellungen",
|
||||
"organization_teams_not_found": "Organisations-Teams nicht gefunden",
|
||||
"other": "Andere",
|
||||
"others": "Andere",
|
||||
@@ -319,8 +309,7 @@
|
||||
"product_manager": "Produktmanager",
|
||||
"profile": "Profil",
|
||||
"profile_id": "Profil-ID",
|
||||
"progress": "Fortschritt",
|
||||
"project_configuration": "Projekteinstellungen",
|
||||
"project_configuration": "Projektkonfiguration",
|
||||
"project_creation_description": "Organisieren Sie Umfragen in Projekten für eine bessere Zugriffskontrolle.",
|
||||
"project_id": "Projekt-ID",
|
||||
"project_name": "Projektname",
|
||||
@@ -331,9 +320,6 @@
|
||||
"question": "Frage",
|
||||
"question_id": "Frage-ID",
|
||||
"questions": "Fragen",
|
||||
"quota": "Kontingent",
|
||||
"quotas": "Quoten",
|
||||
"quotas_description": "Begrenze die Anzahl der Antworten, die du von Teilnehmern erhältst, die bestimmte Kriterien erfüllen.",
|
||||
"read_docs": "Dokumentation lesen",
|
||||
"recipients": "Empfänger",
|
||||
"remove": "Entfernen",
|
||||
@@ -351,7 +337,7 @@
|
||||
"sales": "Vertrieb",
|
||||
"save": "Speichern",
|
||||
"save_changes": "Änderungen speichern",
|
||||
"saving": "Speichern",
|
||||
"scheduled": "Geplant",
|
||||
"search": "Suchen",
|
||||
"security": "Sicherheit",
|
||||
"segment": "Segment",
|
||||
@@ -364,6 +350,7 @@
|
||||
"selected_questions": "Ausgewählte Fragen",
|
||||
"selection": "Auswahl",
|
||||
"selections": "Auswahlen",
|
||||
"send": "Senden",
|
||||
"send_test_email": "Test-E-Mail senden",
|
||||
"session_not_found": "Sitzung nicht gefunden",
|
||||
"settings": "Einstellungen",
|
||||
@@ -381,7 +368,6 @@
|
||||
"start_free_trial": "Kostenlos starten",
|
||||
"status": "Status",
|
||||
"step_by_step_manual": "Schritt-für-Schritt-Anleitung",
|
||||
"storage_not_configured": "Dateispeicher nicht eingerichtet, Uploads werden wahrscheinlich fehlschlagen",
|
||||
"styling": "Styling",
|
||||
"submit": "Abschicken",
|
||||
"summary": "Zusammenfassung",
|
||||
@@ -392,8 +378,10 @@
|
||||
"survey_live": "Umfrage live",
|
||||
"survey_not_found": "Umfrage nicht gefunden",
|
||||
"survey_paused": "Umfrage pausiert.",
|
||||
"survey_scheduled": "Umfrage geplant.",
|
||||
"survey_type": "Umfragetyp",
|
||||
"surveys": "Umfragen",
|
||||
"switch_organization": "Organisation wechseln",
|
||||
"switch_to": "Wechseln zu {environment}",
|
||||
"table_items_deleted_successfully": "{type}s erfolgreich gelöscht",
|
||||
"table_settings": "Tabelleinstellungen",
|
||||
@@ -591,7 +579,8 @@
|
||||
"contacts_table_refresh": "Kontakte aktualisieren",
|
||||
"contacts_table_refresh_success": "Kontakte erfolgreich aktualisiert",
|
||||
"delete_contact_confirmation": "Dies wird alle Umfrageantworten und Kontaktattribute löschen, die mit diesem Kontakt verbunden sind. Jegliche zielgerichtete Kommunikation und Personalisierung basierend auf den Daten dieses Kontakts gehen verloren.",
|
||||
"delete_contact_confirmation_with_quotas": "{value, plural, other {Dies wird alle Umfrageantworten und Kontaktattribute löschen, die mit diesem Kontakt verbunden sind. Jegliche zielgerichtete Kommunikation und Personalisierung basierend auf den Daten dieses Kontakts gehen verloren. Wenn dieser Kontakt Antworten hat, die zu den Umfragequoten zählen, werden die Quotenstände reduziert, aber die Quotenlimits bleiben unverändert.}}",
|
||||
"first_name": "Vorname",
|
||||
"last_name": "Nachname",
|
||||
"no_responses_found": "Keine Antworten gefunden",
|
||||
"not_provided": "Nicht angegeben",
|
||||
"search_contact": "Kontakt suchen",
|
||||
@@ -752,6 +741,7 @@
|
||||
},
|
||||
"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",
|
||||
@@ -771,21 +761,43 @@
|
||||
"unable_to_delete_api_key": "API-Schlüssel kann nicht gelöscht werden"
|
||||
},
|
||||
"app-connection": {
|
||||
"api_host_description": "Dies ist die URL deines Formbricks Backends.",
|
||||
"app_connection": "App-Verbindung",
|
||||
"app_connection_description": "Verbinde deine App mit Formbricks.",
|
||||
"cache_update_delay_description": "Wenn du Aktualisierungen an Umfragen, Kontakten, Aktionen oder anderen Daten vornimmst, kann es bis zu 5 Minuten dauern, bis diese Änderungen in deiner lokalen App, die das Formbricks SDK verwendet, angezeigt werden. Diese Verzögerung ist auf eine Einschränkung unseres aktuellen Caching-Systems zurückzuführen. Wir arbeiten aktiv an einer Überarbeitung des Cache und werden in Formbricks 4.0 eine Lösung veröffentlichen.",
|
||||
"cache_update_delay_title": "Änderungen werden aufgrund von Caching nach 5 Minuten angezeigt",
|
||||
"check_out_the_docs": "Schau dir die Docs an.",
|
||||
"dive_into_the_docs": "Tauch in die Docs ein.",
|
||||
"does_your_widget_work": "Funktioniert dein Widget?",
|
||||
"environment_id": "Deine Umgebungs-ID",
|
||||
"environment_id_description": "Diese ID identifiziert eindeutig diese Formbricks Umgebung.",
|
||||
"environment_id_description_with_environment_id": "Wird verwendet, um die richtige Umgebung zu identifizieren: {environmentId} ist deine.",
|
||||
"formbricks_sdk": "Formbricks SDK",
|
||||
"formbricks_sdk_connected": "Formbricks SDK ist verbunden",
|
||||
"formbricks_sdk_not_connected": "Formbricks SDK ist noch nicht verbunden.",
|
||||
"formbricks_sdk_not_connected_description": "Verbinde deine Website oder App mit Formbricks",
|
||||
"have_a_problem": "Hast Du ein Problem?",
|
||||
"how_to_setup": "Wie einrichten",
|
||||
"how_to_setup_description": "Befolge diese Schritte, um das Formbricks Widget in deiner App einzurichten.",
|
||||
"identifying_your_users": "deine Nutzer identifizieren",
|
||||
"if_you_are_planning_to": "Wenn Du planst zu",
|
||||
"insert_this_code_into_the": "Füge diesen Code in die",
|
||||
"need_a_more_detailed_setup_guide_for": "Brauche eine detailliertere Anleitung für",
|
||||
"not_working": "Klappt nicht?",
|
||||
"open_an_issue_on_github": "Eine Issue auf GitHub öffnen",
|
||||
"open_the_browser_console_to_see_the_logs": "Öffne die Browser Konsole, um die Logs zu sehen.",
|
||||
"receiving_data": "Daten werden empfangen \uD83D\uDC83\uD83D\uDD7A",
|
||||
"recheck": "Erneut prüfen",
|
||||
"setup_alert_description": "Befolge dieses Schritt-für-Schritt-Tutorial, um deine App oder Website in weniger als 5 Minuten zu verbinden.",
|
||||
"setup_alert_title": "Wie man verbindet"
|
||||
"scroll_to_the_top": "Scroll nach oben!",
|
||||
"step_1": "Schritt 1: Installiere mit pnpm, npm oder yarn",
|
||||
"step_2": "Schritt 2: Widget initialisieren",
|
||||
"step_2_description": "Importiere Formbricks und initialisiere das Widget in deiner Komponente (z.B. App.tsx):",
|
||||
"step_3": "Schritt 3: Debug-Modus",
|
||||
"switch_on_the_debug_mode_by_appending": "Schalte den Debug-Modus ein, indem Du anhängst",
|
||||
"tag_of_your_app": "Tag deiner App",
|
||||
"to_the_url_where_you_load_the": "URL, wo Du die lädst",
|
||||
"want_to_learn_how_to_add_user_attributes": "Willst Du lernen, wie man Attribute hinzufügt?",
|
||||
"you_are_done": "Du bist fertig \uD83C\uDF89",
|
||||
"you_can_set_the_user_id_with": "du kannst die Benutzer-ID festlegen mit",
|
||||
"your_app_now_communicates_with_formbricks": "Deine App kommuniziert jetzt mit Formbricks - sie sendet Ereignisse und lädt Umfragen automatisch!"
|
||||
},
|
||||
"general": {
|
||||
"cannot_delete_only_project": "Dies ist dein einziges Projekt, es kann nicht gelöscht werden. Erstelle zuerst ein neues Projekt.",
|
||||
@@ -977,6 +989,7 @@
|
||||
"free": "Kostenlos",
|
||||
"free_description": "Unbegrenzte Umfragen, Teammitglieder und mehr.",
|
||||
"get_2_months_free": "2 Monate gratis",
|
||||
"get_in_touch": "Kontaktiere uns",
|
||||
"hosted_in_frankfurt": "Gehostet in Frankfurt",
|
||||
"ios_android_sdks": "iOS & Android SDK für mobile Umfragen",
|
||||
"link_surveys": "Umfragen verlinken (teilbar)",
|
||||
@@ -1098,7 +1111,9 @@
|
||||
},
|
||||
"profile": {
|
||||
"account_deletion_consequences_warning": "Was passiert, wenn Du das Konto löschst",
|
||||
"avatar_update_failed": "Aktualisierung des Avatars fehlgeschlagen. Bitte versuche es erneut.",
|
||||
"backup_code": "Backup-Code",
|
||||
"change_image": "Bild ändern",
|
||||
"confirm_delete_account": "Lösche dein Konto mit all deinen persönlichen Informationen und Daten",
|
||||
"confirm_delete_my_account": "Konto löschen",
|
||||
"confirm_your_current_password_to_get_started": "Bestätige dein aktuelles Passwort, um loszulegen.",
|
||||
@@ -1109,13 +1124,17 @@
|
||||
"email_change_initiated": "Deine Anfrage zur Änderung der E-Mail wurde eingeleitet.",
|
||||
"enable_two_factor_authentication": "Zwei-Faktor-Authentifizierung aktivieren",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Gib den Code aus deiner Authentifizierungs-App unten ein.",
|
||||
"file_size_must_be_less_than_10mb": "Dateigröße muss weniger als 10MB sein.",
|
||||
"invalid_file_type": "Ungültiger Dateityp. Nur JPEG-, PNG- und WEBP-Dateien sind erlaubt.",
|
||||
"lost_access": "Zugriff verloren",
|
||||
"or_enter_the_following_code_manually": "Oder gib den folgenden Code manuell ein:",
|
||||
"organization_identification": "Hilf deiner Organisation, Dich auf Formbricks zu identifizieren",
|
||||
"organizations_delete_message": "Du bist der einzige Besitzer dieser Organisationen, also werden sie <b>auch gelöscht.</b>",
|
||||
"permanent_removal_of_all_of_your_personal_information_and_data": "Dauerhafte Entfernung all deiner persönlichen Informationen und Daten",
|
||||
"personal_information": "Persönliche Informationen",
|
||||
"please_enter_email_to_confirm_account_deletion": "Bitte gib {email} in das folgende Feld ein, um die endgültige Löschung deines Kontos zu bestätigen:",
|
||||
"profile_updated_successfully": "Dein Profil wurde erfolgreich aktualisiert",
|
||||
"remove_image": "Bild entfernen",
|
||||
"save_the_following_backup_codes_in_a_safe_place": "Speichere die folgenden Backup-Codes an einem sicheren Ort.",
|
||||
"scan_the_qr_code_below_with_your_authenticator_app": "Scanne den QR-Code unten mit deiner Authentifizierungs-App.",
|
||||
"security_description": "Verwalte dein Passwort und andere Sicherheitseinstellungen wie Zwei-Faktor-Authentifizierung (2FA).",
|
||||
@@ -1125,8 +1144,10 @@
|
||||
"two_factor_code": "Zwei-Faktor-Code",
|
||||
"unlock_two_factor_authentication": "Zwei-Faktor-Authentifizierung mit einem höheren Plan freischalten",
|
||||
"update_personal_info": "Persönliche Daten aktualisieren",
|
||||
"upload_image": "Bild hochladen",
|
||||
"warning_cannot_delete_account": "Du bist der einzige Besitzer dieser Organisation. Bitte übertrage das Eigentum zuerst an ein anderes Mitglied.",
|
||||
"warning_cannot_undo": "Das kann nicht rückgängig gemacht werden"
|
||||
"warning_cannot_undo": "Das kann nicht rückgängig gemacht werden",
|
||||
"you_must_select_a_file": "Du musst eine Datei auswählen."
|
||||
},
|
||||
"teams": {
|
||||
"add_members_description": "Füge Mitglieder zum Team hinzu und bestimme ihre Rolle.",
|
||||
@@ -1238,7 +1259,9 @@
|
||||
"automatically_close_survey_after": "Umfrage automatisch schließen nach",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Schließe die Umfrage automatisch nach einer bestimmten Anzahl von Antworten.",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Schließe die Umfrage automatisch, wenn der Benutzer nach einer bestimmten Anzahl von Sekunden nicht antwortet.",
|
||||
"automatically_closes_the_survey_at_the_beginning_of_the_day_utc": "Schließt die Umfrage automatisch zu Beginn des Tages (UTC).",
|
||||
"automatically_mark_the_survey_as_complete_after": "Umfrage automatisch als abgeschlossen markieren nach",
|
||||
"automatically_release_the_survey_at_the_beginning_of_the_day_utc": "Umfrage automatisch zu Beginn des Tages (UTC) freigeben.",
|
||||
"back_button_label": "Zurück\"- Button ",
|
||||
"background_styling": "Hintergründe",
|
||||
"brand_color": "Markenfarbe",
|
||||
@@ -1286,13 +1309,14 @@
|
||||
"choose_the_actions_which_trigger_the_survey": "Aktionen auswählen, die die Umfrage auslösen.",
|
||||
"choose_where_to_run_the_survey": "Wähle, wo die Umfrage durchgeführt werden soll.",
|
||||
"city": "Stadt",
|
||||
"close_survey_on_date": "Umfrage am Datum schließen",
|
||||
"close_survey_on_response_limit": "Umfrage bei Erreichen des Antwortlimits schließen",
|
||||
"color": "Farbe",
|
||||
"column_used_in_logic_error": "Diese Spalte wird in der Logik der Frage {questionIndex} verwendet. Bitte entferne sie zuerst aus der Logik.",
|
||||
"columns": "Spalten",
|
||||
"company": "Firma",
|
||||
"company_logo": "Firmenlogo",
|
||||
"completed_responses": "Abgeschlossene Antworten.",
|
||||
"completed_responses": "unvollständige oder vollständige Antworten.",
|
||||
"concat": "Verketten +",
|
||||
"conditional_logic": "Bedingte Logik",
|
||||
"confirm_default_language": "Standardsprache bestätigen",
|
||||
@@ -1332,7 +1356,6 @@
|
||||
"end_screen_card": "Abschluss-Karte",
|
||||
"ending_card": "Abschluss-Karte",
|
||||
"ending_card_used_in_logic": "Diese Abschlusskarte wird in der Logik der Frage {questionIndex} verwendet.",
|
||||
"ending_used_in_quota": "Dieses Ende wird in der \"{quotaName}\" Quote verwendet",
|
||||
"ends_with": "endet mit",
|
||||
"equals": "Gleich",
|
||||
"equals_one_of": "Entspricht einem von",
|
||||
@@ -1343,7 +1366,6 @@
|
||||
"fallback_for": "Ersatz für",
|
||||
"fallback_missing": "Fehlender Fallback",
|
||||
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} wird in der Logik der Frage {questionIndex} verwendet. Bitte entferne es zuerst aus der Logik.",
|
||||
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "Verstecktes Feld \"{fieldId}\" wird in der \"{quotaName}\" Quote verwendet",
|
||||
"field_name_eg_score_price": "Feldname z.B. Punktzahl, Preis",
|
||||
"first_name": "Vorname",
|
||||
"five_points_recommended": "5 Punkte (empfohlen)",
|
||||
@@ -1375,9 +1397,8 @@
|
||||
"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": "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_action_to_warning": "Kein E-Mail-Feld in der Umfrage gefunden.",
|
||||
"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",
|
||||
@@ -1387,9 +1408,8 @@
|
||||
"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": "Bitte wähle mindestens ein Ende aus oder ändere den Auslöser-Typ",
|
||||
"follow_ups_modal_trigger_type_ending_warning": "Keine Abschlüsse in der Umfrage gefunden!",
|
||||
"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",
|
||||
@@ -1490,38 +1510,6 @@
|
||||
"question_duplicated": "Frage dupliziert.",
|
||||
"question_id_updated": "Frage-ID aktualisiert",
|
||||
"question_used_in_logic": "Diese Frage wird in der Logik der Frage {questionIndex} verwendet.",
|
||||
"question_used_in_quota": "Diese Frage wird in der \"{quotaName}\" Quote verwendet",
|
||||
"quotas": {
|
||||
"add_quota": "Quote hinzufügen",
|
||||
"change_quota_for_public_survey": "Quote für öffentliche Umfrage ändern?",
|
||||
"confirm_quota_changes": "Änderungen der Quoten bestätigen",
|
||||
"confirm_quota_changes_body": "Du hast ungespeicherte Änderungen in deinem Kontingent. Möchtest Du sie speichern, bevor Du gehst?",
|
||||
"continue_survey_normally": "Umfrage normal fortsetzen",
|
||||
"count_partial_submissions": "Teilweise Abgaben zählen",
|
||||
"count_partial_submissions_description": "Einschließlich Teilnehmer, die die Quotenanforderungen erfüllen, aber die Umfrage nicht abgeschlossen haben",
|
||||
"create_quota_for_public_survey": "Quote für öffentliche Umfrage erstellen?",
|
||||
"create_quota_for_public_survey_description": "Nur zukünftige Antworten werden für das Kontingent berücksichtigt",
|
||||
"create_quota_for_public_survey_text": "Diese Umfrage ist bereits öffentlich. Bestehende Antworten werden für die neue Quote nicht berücksichtigt.",
|
||||
"delete_quota_confirmation_text": "Dies wird die Quote {quotaName} dauerhaft löschen.",
|
||||
"duplicate_quota": "Duplizieren der Quote",
|
||||
"edit_quota": "Bearbeite Quote",
|
||||
"end_survey_for_matching_participants": "Umfrage für passende Teilnehmer beenden",
|
||||
"inclusion_criteria": "Einschlusskriterien",
|
||||
"limit_must_be_greater_than_or_equal_to_the_number_of_responses": "{value, plural, other {Limit muss größer oder gleich der Anzahl der Antworten sein}}",
|
||||
"limited_to_x_responses": "Begrenzt auf {limit}",
|
||||
"new_quota": "Neues Kontingent",
|
||||
"quota_created_successfull_toast": "Kontingent erfolgreich erstellt",
|
||||
"quota_deleted_successfull_toast": "Kontingent erfolgreich gelöscht",
|
||||
"quota_duplicated_successfull_toast": "Kontingent erfolgreich dupliziert",
|
||||
"quota_name_placeholder": "z.B., Teilnehmende im Alter von 18-25",
|
||||
"quota_updated_successfull_toast": "Kontingent erfolgreich aktualisiert",
|
||||
"response_limit": "Grenzen",
|
||||
"save_changes_confirmation_body": "Jegliche Änderungen an den Einschlusskriterien betreffen nur zukünftige Antworten.\nWir empfehlen, entweder ein bestehendes Kontingent zu duplizieren oder ein neues zu erstellen.",
|
||||
"save_changes_confirmation_text": "Vorhandene Antworten bleiben im Kontingent",
|
||||
"select_ending_card": "Abschlusskarte auswählen",
|
||||
"upgrade_prompt_title": "Verwende Quoten mit einem höheren Plan",
|
||||
"when_quota_has_been_reached": "Wenn das Kontingent erreicht ist"
|
||||
},
|
||||
"randomize_all": "Alle Optionen zufällig anordnen",
|
||||
"randomize_all_except_last": "Alle Optionen zufällig anordnen außer der letzten",
|
||||
"range": "Reichweite",
|
||||
@@ -1529,6 +1517,7 @@
|
||||
"redirect_thank_you_card": "Weiterleitung anlegen",
|
||||
"redirect_to_url": "Zu URL weiterleiten",
|
||||
"redirect_to_url_not_available_on_free_plan": "Umleitung zu URL ist im kostenlosen Plan nicht verfügbar",
|
||||
"release_survey_on_date": "Umfrage an Datum veröffentlichen",
|
||||
"remove_description": "Beschreibung entfernen",
|
||||
"remove_translations": "Übersetzungen entfernen",
|
||||
"require_answer": "Antwort erforderlich",
|
||||
@@ -1554,7 +1543,6 @@
|
||||
"send_survey_to_audience_who_match": "Umfrage an das Publikum senden, das übereinstimmt...",
|
||||
"send_your_respondents_to_a_page_of_your_choice": "Schicke deine Befragten auf eine Seite deiner Wahl.",
|
||||
"set_the_global_placement_in_the_look_feel_settings": "Stelle die globale Platzierung in den Look & Feel-Einstellungen ein.",
|
||||
"settings_saved_successfully": "Einstellungen erfolgreich gespeichert",
|
||||
"seven_points": "7 Punkte",
|
||||
"show_advanced_settings": "Erweiterte Einstellungen anzeigen",
|
||||
"show_button": "Button anzeigen",
|
||||
@@ -1615,7 +1603,6 @@
|
||||
"url_not_supported": "URL nicht unterstützt",
|
||||
"use_with_caution": "Mit Vorsicht verwenden",
|
||||
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} wird in der Logik der Frage {questionIndex} verwendet. Bitte entferne es zuerst aus der Logik.",
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variable \"{variableName}\" wird in der \"{quotaName}\" Quote verwendet",
|
||||
"variable_name_is_already_taken_please_choose_another": "Variablenname ist bereits vergeben, bitte wähle einen anderen.",
|
||||
"variable_name_must_start_with_a_letter": "Variablenname muss mit einem Buchstaben beginnen.",
|
||||
"verify_email_before_submission": "E-Mail vor dem Absenden überprüfen",
|
||||
@@ -1648,16 +1635,16 @@
|
||||
"responses": {
|
||||
"address_line_1": "Adresszeile 1",
|
||||
"address_line_2": "Adresszeile 2",
|
||||
"an_error_occurred_creating_a_new_note": "Beim Erstellen einer neuen Notiz ist ein Fehler aufgetreten",
|
||||
"an_error_occurred_deleting_the_tag": "Beim Löschen des Tags ist ein Fehler aufgetreten",
|
||||
"an_error_occurred_resolving_a_note": "Beim Auflösen einer Notiz ist ein Fehler aufgetreten",
|
||||
"an_error_occurred_updating_a_note": "Beim Aktualisieren einer Notiz ist ein Fehler aufgetreten",
|
||||
"browser": "Browser",
|
||||
"bulk_delete_response_quotas": "Die Antworten sind Teil der Quoten für diese Umfrage. Wie möchten Sie die Quoten verwalten?",
|
||||
"city": "Stadt",
|
||||
"company": "Firma",
|
||||
"completed": "Erledigt ✅",
|
||||
"country": "Land",
|
||||
"decrement_quotas": "Alle Grenzwerte der Kontingente einschließlich dieser Antwort verringern",
|
||||
"delete_response_confirmation": "Dies wird die Umfrageantwort einschließlich aller Antworten, Tags, angehängter Dokumente und Antwort-Metadaten löschen.",
|
||||
"delete_response_quotas": "Die Antwort ist Teil der Quoten für diese Umfrage. Wie möchten Sie die Quoten verwalten?",
|
||||
"delete_response_confirmation": "Dies wird die Umfrageantwort einschließlich aller Antworten, Notizen, Tags, angehängter Dokumente und Antwortmetadaten löschen.",
|
||||
"device": "Gerät",
|
||||
"device_info": "Geräteinfo",
|
||||
"email": "E-Mail",
|
||||
@@ -1669,6 +1656,7 @@
|
||||
"os": "Betriebssystem",
|
||||
"person_attributes": "Personenattribute",
|
||||
"phone": "Telefon",
|
||||
"resolve": "Lösen",
|
||||
"respondent_skipped_questions": "Der Befragte hat diese Fragen übersprungen.",
|
||||
"response_deleted_successfully": "Antwort erfolgreich gelöscht.",
|
||||
"single_use_id": "Einmalige ID",
|
||||
@@ -1725,17 +1713,6 @@
|
||||
"embed_mode_description": "Bette deine Umfrage mit einem minimalistischen Design ein, ohne Karten und Hintergrund.",
|
||||
"nav_title": "Auf Website einbetten"
|
||||
},
|
||||
"link_settings": {
|
||||
"description": "Vergebe Links einen benutzerdefinierten Titel, eine Beschreibung und ein Bild zum Teilen in der Öffentlichkeit.",
|
||||
"language_help_text": "Die Meta-Daten werden basierend auf dem `lang` Wert in der URL geladen.",
|
||||
"link_description": "Linkbeschreibung",
|
||||
"link_description_description": "Beschreibung mit 55-200 Zeichen funktionieren am besten.",
|
||||
"link_title": "Linktitel",
|
||||
"link_title_description": "Kurze Titel funktionieren am besten als Meta-Titel.",
|
||||
"preview_image": "Vorschaubild",
|
||||
"preview_image_description": "Querformatige Bilder mit kleiner Dateigröße (<4MB) funktionieren am besten.",
|
||||
"title": "Link-Einstellungen"
|
||||
},
|
||||
"personal_links": {
|
||||
"create_and_manage_segments": "Erstellen und verwalten Sie Ihre Segmente unter Kontakte > Segmente",
|
||||
"description": "Erstellen Sie persönliche Links für ein Segment und ordnen Sie Umfrageantworten jedem Kontakt zu.",
|
||||
@@ -1767,7 +1744,6 @@
|
||||
"send_preview": "Vorschau senden",
|
||||
"send_preview_email": "Vorschau-E-Mail senden"
|
||||
},
|
||||
"share_settings_title": "Freigabeeinstellungen",
|
||||
"share_view_title": "Teilen über",
|
||||
"social_media": {
|
||||
"description": "Erhalte Rückmeldungen von deinen Kontakten auf verschiedenen sozialen Medien.",
|
||||
@@ -1789,7 +1765,6 @@
|
||||
"configure_alerts": "Benachrichtigungen konfigurieren",
|
||||
"congrats": "Glückwunsch! Deine Umfrage ist jetzt live.",
|
||||
"connect_your_website_or_app_with_formbricks_to_get_started": "Verbinde deine Website oder App mit Formbricks, um loszulegen.",
|
||||
"current_count": "Aktuelle Anzahl",
|
||||
"custom_range": "Benutzerdefinierter Bereich...",
|
||||
"delete_all_existing_responses_and_displays": "Alle bestehenden Antworten und Anzeigen löschen",
|
||||
"download_qr_code": "QR Code herunterladen",
|
||||
@@ -1843,7 +1818,6 @@
|
||||
"last_month": "Letztes Monat",
|
||||
"last_quarter": "Letztes Quartal",
|
||||
"last_year": "Letztes Jahr",
|
||||
"limit": "Limit",
|
||||
"no_responses_found": "Keine Antworten gefunden",
|
||||
"other_values_found": "Andere Werte gefunden",
|
||||
"overall": "Insgesamt",
|
||||
@@ -1852,8 +1826,6 @@
|
||||
"qr_code_download_failed": "QR-Code-Download fehlgeschlagen",
|
||||
"qr_code_download_with_start_soon": "QR Code-Download startet bald",
|
||||
"qr_code_generation_failed": "Es gab ein Problem beim Laden des QR-Codes für die Umfrage. Bitte versuchen Sie es erneut.",
|
||||
"quotas_completed": "Kontingente abgeschlossen",
|
||||
"quotas_completed_tooltip": "Die Anzahl der von den Befragten abgeschlossenen Quoten.",
|
||||
"reset_survey": "Umfrage zurücksetzen",
|
||||
"reset_survey_warning": "Das Zurücksetzen einer Umfrage entfernt alle Antworten und Anzeigen, die mit dieser Umfrage verbunden sind. Dies kann nicht rückgängig gemacht werden.",
|
||||
"selected_responses_csv": "Ausgewählte Antworten (CSV)",
|
||||
@@ -1869,7 +1841,7 @@
|
||||
"this_quarter": "Dieses Quartal",
|
||||
"this_year": "Dieses Jahr",
|
||||
"time_to_complete": "Zeit zur Fertigstellung",
|
||||
"ttc_tooltip": "Durchschnittliche Zeit zum Beantworten der Frage.",
|
||||
"ttc_tooltip": "Durchschnittliche Zeit bis zum Abschluss der Umfrage.",
|
||||
"unknown_question_type": "Unbekannter Fragetyp",
|
||||
"use_personal_links": "Nutze persönliche Links",
|
||||
"waiting_for_response": "Warte auf eine Antwort \uD83E\uDDD8♂️",
|
||||
@@ -1880,6 +1852,7 @@
|
||||
"survey_deleted_successfully": "Umfrage erfolgreich gelöscht",
|
||||
"survey_duplicated_successfully": "Umfrage erfolgreich dupliziert",
|
||||
"survey_duplication_error": "Duplizieren der Umfrage fehlgeschlagen",
|
||||
"survey_status_tooltip": "Um den Umfragestatus zu aktualisieren, aktualisiere den Zeitplan in den Umfrageoptionen.",
|
||||
"templates": {
|
||||
"all_channels": "Alle Kanäle",
|
||||
"all_industries": "Alle Branchen",
|
||||
|
||||
+67
-94
@@ -118,7 +118,6 @@
|
||||
"account_settings": "Account settings",
|
||||
"action": "Action",
|
||||
"actions": "Actions",
|
||||
"actions_description": "Code and No-Code Actions are used to trigger intercept surveys within apps & on websites.",
|
||||
"active_surveys": "Active surveys",
|
||||
"activity": "Activity",
|
||||
"add": "Add",
|
||||
@@ -126,7 +125,6 @@
|
||||
"add_filter": "Add filter",
|
||||
"add_logo": "Add logo",
|
||||
"add_member": "Add member",
|
||||
"add_new_project": "Add new project",
|
||||
"add_project": "Add project",
|
||||
"add_to_team": "Add to team",
|
||||
"all": "All",
|
||||
@@ -143,6 +141,7 @@
|
||||
"apply_filters": "Apply filters",
|
||||
"are_you_sure": "Are you sure?",
|
||||
"attributes": "Attributes",
|
||||
"avatar": "Avatar",
|
||||
"back": "Back",
|
||||
"billing": "Billing",
|
||||
"booked": "Booked",
|
||||
@@ -151,9 +150,6 @@
|
||||
"cancel": "Cancel",
|
||||
"centered_modal": "Centered Modal",
|
||||
"choices": "Choices",
|
||||
"choose_environment": "Choose environment",
|
||||
"choose_organization": "Choose organization",
|
||||
"choose_project": "Choose project",
|
||||
"clear_all": "Clear all",
|
||||
"clear_filters": "Clear filters",
|
||||
"clear_selection": "Clear selection",
|
||||
@@ -169,14 +165,11 @@
|
||||
"connect_formbricks": "Connect Formbricks",
|
||||
"connected": "Connected",
|
||||
"contacts": "Contacts",
|
||||
"continue": "Continue",
|
||||
"copied": "Copied",
|
||||
"copied_to_clipboard": "Copied to clipboard",
|
||||
"copy": "Copy",
|
||||
"copy_code": "Copy code",
|
||||
"copy_link": "Copy Link",
|
||||
"count_contacts": "{value, plural, one {{value} contact} other {{value} contacts}}",
|
||||
"count_responses": "{value, plural, one {{value} response} other {{value} responses}}",
|
||||
"create_new_organization": "Create new organization",
|
||||
"create_project": "Create project",
|
||||
"create_segment": "Create segment",
|
||||
@@ -185,6 +178,7 @@
|
||||
"created_at": "Created at",
|
||||
"created_by": "Created by",
|
||||
"customer_success": "Customer Success",
|
||||
"danger_zone": "Danger Zone",
|
||||
"dark_overlay": "Dark overlay",
|
||||
"date": "Date",
|
||||
"default": "Default",
|
||||
@@ -204,15 +198,10 @@
|
||||
"e_commerce": "E-Commerce",
|
||||
"edit": "Edit",
|
||||
"email": "Email",
|
||||
"ending_card": "Ending card",
|
||||
"enterprise_license": "Enterprise License",
|
||||
"environment_not_found": "Environment not found",
|
||||
"environment_notice": "You're currently in the {environment} environment.",
|
||||
"error": "Error",
|
||||
"error_component_description": "This resource doesn't exist or you don't have the necessary rights to access it.",
|
||||
"error_component_title": "Error loading resources",
|
||||
"error_rate_limit_description": "Maximum number of requests reached. Please try again later.",
|
||||
"error_rate_limit_title": "Rate Limit Exceeded",
|
||||
"expand_rows": "Expand rows",
|
||||
"finish": "Finish",
|
||||
"follow_these": "Follow these",
|
||||
@@ -225,6 +214,7 @@
|
||||
"hidden": "Hidden",
|
||||
"hidden_field": "Hidden field",
|
||||
"hidden_fields": "Hidden fields",
|
||||
"hide": "Hide",
|
||||
"hide_column": "Hide column",
|
||||
"image": "Image",
|
||||
"images": "Images",
|
||||
@@ -244,9 +234,11 @@
|
||||
"label": "Label",
|
||||
"language": "Language",
|
||||
"learn_more": "Learn more",
|
||||
"license": "License",
|
||||
"light_overlay": "Light overlay",
|
||||
"limits_reached": "Limits Reached",
|
||||
"link": "Link",
|
||||
"link_and_email": "Link & Email",
|
||||
"link_survey": "Link Survey",
|
||||
"link_surveys": "Link Surveys",
|
||||
"load_more": "Load more",
|
||||
@@ -262,20 +254,18 @@
|
||||
"membership_not_found": "Membership not found",
|
||||
"metadata": "Metadata",
|
||||
"minimum": "Minimum",
|
||||
"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!",
|
||||
"mobile_overlay_text": "Formbricks is not available for devices with smaller resolutions.",
|
||||
"move_down": "Move down",
|
||||
"move_up": "Move up",
|
||||
"multiple_languages": "Multiple languages",
|
||||
"name": "Name",
|
||||
"new": "New",
|
||||
"new_survey": "New Survey",
|
||||
"new_version_available": "Formbricks {version} is here. Upgrade now!",
|
||||
"next": "Next",
|
||||
"no_background_image_found": "No background image found.",
|
||||
"no_code": "No code",
|
||||
"no_files_uploaded": "No files were uploaded",
|
||||
"no_quotas_found": "No quotas found",
|
||||
"no_result_found": "No result found",
|
||||
"no_results": "No results",
|
||||
"no_surveys_found": "No surveys found.",
|
||||
@@ -283,6 +273,7 @@
|
||||
"not_authorized": "Not authorized",
|
||||
"not_connected": "Not Connected",
|
||||
"note": "Note",
|
||||
"notes": "Notes",
|
||||
"notifications": "Notifications",
|
||||
"number": "Number",
|
||||
"off": "Off",
|
||||
@@ -295,7 +286,6 @@
|
||||
"organization": "Organization",
|
||||
"organization_id": "Organization ID",
|
||||
"organization_not_found": "Organization not found",
|
||||
"organization_settings": "Organization settings",
|
||||
"organization_teams_not_found": "Organization teams not found",
|
||||
"other": "Other",
|
||||
"others": "Others",
|
||||
@@ -319,8 +309,7 @@
|
||||
"product_manager": "Product Manager",
|
||||
"profile": "Profile",
|
||||
"profile_id": "Profile ID",
|
||||
"progress": "Progress",
|
||||
"project_configuration": "Project Configuration",
|
||||
"project_configuration": "Project's Configuration",
|
||||
"project_creation_description": "Organize surveys in projects for better access control.",
|
||||
"project_id": "Project ID",
|
||||
"project_name": "Project Name",
|
||||
@@ -331,9 +320,6 @@
|
||||
"question": "Question",
|
||||
"question_id": "Question ID",
|
||||
"questions": "Questions",
|
||||
"quota": "Quota",
|
||||
"quotas": "Quotas",
|
||||
"quotas_description": "Limit the amount of responses you receive from participants who meet certain criteria.",
|
||||
"read_docs": "Read Docs",
|
||||
"recipients": "Recipients",
|
||||
"remove": "Remove",
|
||||
@@ -351,7 +337,7 @@
|
||||
"sales": "Sales",
|
||||
"save": "Save",
|
||||
"save_changes": "Save changes",
|
||||
"saving": "Saving",
|
||||
"scheduled": "Scheduled",
|
||||
"search": "Search",
|
||||
"security": "Security",
|
||||
"segment": "Segment",
|
||||
@@ -364,6 +350,7 @@
|
||||
"selected_questions": "Selected questions",
|
||||
"selection": "Selection",
|
||||
"selections": "Selections",
|
||||
"send": "Send",
|
||||
"send_test_email": "Send test email",
|
||||
"session_not_found": "Session not found",
|
||||
"settings": "Settings",
|
||||
@@ -381,7 +368,6 @@
|
||||
"start_free_trial": "Start Free Trial",
|
||||
"status": "Status",
|
||||
"step_by_step_manual": "Step by step manual",
|
||||
"storage_not_configured": "File storage not set up, uploads will likely fail",
|
||||
"styling": "Styling",
|
||||
"submit": "Submit",
|
||||
"summary": "Summary",
|
||||
@@ -392,8 +378,10 @@
|
||||
"survey_live": "Survey live",
|
||||
"survey_not_found": "Survey not found",
|
||||
"survey_paused": "Survey paused.",
|
||||
"survey_scheduled": "Survey scheduled.",
|
||||
"survey_type": "Survey Type",
|
||||
"surveys": "Surveys",
|
||||
"switch_organization": "Switch organization",
|
||||
"switch_to": "Switch to {environment}",
|
||||
"table_items_deleted_successfully": "{type}s deleted successfully",
|
||||
"table_settings": "Table settings",
|
||||
@@ -591,7 +579,8 @@
|
||||
"contacts_table_refresh": "Refresh contacts",
|
||||
"contacts_table_refresh_success": "Contacts refreshed successfully",
|
||||
"delete_contact_confirmation": "This will delete all survey responses and contact attributes associated with this contact. Any targeting and personalization based on this contact's data will be lost.",
|
||||
"delete_contact_confirmation_with_quotas": "{value, plural, one {This will delete all survey responses and contact attributes associated with this contact. Any targeting and personalization based on this contact's data will be lost. If this contact has responses that count towards survey quotas, the quota counts will be reduced but the quota limits will remain unchanged.} other {This will delete all survey responses and contact attributes associated with these contacts. Any targeting and personalization based on these contacts' data will be lost. If these contacts have responses that count towards survey quotas, the quota counts will be reduced but the quota limits will remain unchanged.}}",
|
||||
"first_name": "First Name",
|
||||
"last_name": "Last Name",
|
||||
"no_responses_found": "No responses found",
|
||||
"not_provided": "Not provided",
|
||||
"search_contact": "Search contact",
|
||||
@@ -752,6 +741,7 @@
|
||||
},
|
||||
"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",
|
||||
@@ -771,21 +761,43 @@
|
||||
"unable_to_delete_api_key": "Unable to delete API Key"
|
||||
},
|
||||
"app-connection": {
|
||||
"api_host_description": "This is the URL of your Formbricks backend.",
|
||||
"app_connection": "App Connection",
|
||||
"app_connection_description": "Connect your app to Formbricks.",
|
||||
"cache_update_delay_description": "When you make updates to surveys, contacts, actions, or other data, it can take up to 5 minutes for those changes to appear in your local app running the Formbricks SDK. This delay is due to a limitation in our current caching system. We’re actively reworking the cache and will release a fix in Formbricks 4.0.",
|
||||
"cache_update_delay_title": "Changes will be reflected after 5 minutes due to caching",
|
||||
"environment_id": "Your Environment ID",
|
||||
"check_out_the_docs": "Check out the docs.",
|
||||
"dive_into_the_docs": "Dive into the docs.",
|
||||
"does_your_widget_work": "Does your widget work?",
|
||||
"environment_id": "Your EnvironmentId",
|
||||
"environment_id_description": "This id uniquely identifies this Formbricks environment.",
|
||||
"environment_id_description_with_environment_id": "Used to identify the correct environment: {environmentId} is yours.",
|
||||
"formbricks_sdk": "Formbricks SDK",
|
||||
"formbricks_sdk_connected": "Formbricks SDK is connected",
|
||||
"formbricks_sdk_not_connected": "Formbricks SDK is not yet connected.",
|
||||
"formbricks_sdk_not_connected_description": "Connect your website or app with Formbricks",
|
||||
"have_a_problem": "Have a problem?",
|
||||
"how_to_setup": "How to setup",
|
||||
"how_to_setup_description": "Follow these steps to setup the Formbricks widget within your app.",
|
||||
"identifying_your_users": "identifying your users",
|
||||
"if_you_are_planning_to": "If you are planning to",
|
||||
"insert_this_code_into_the": "Insert this code into the",
|
||||
"need_a_more_detailed_setup_guide_for": "Need a more detailed setup guide for",
|
||||
"not_working": "Not working?",
|
||||
"open_an_issue_on_github": "Open an issue on GitHub",
|
||||
"open_the_browser_console_to_see_the_logs": "Open the browser console to see the logs.",
|
||||
"receiving_data": "Receiving data \uD83D\uDC83\uD83D\uDD7A",
|
||||
"recheck": "Re-check",
|
||||
"setup_alert_description": "Follow this step-by-step tutorial to connect your app or website in under 5 minutes.",
|
||||
"setup_alert_title": "How to connect"
|
||||
"scroll_to_the_top": "Scroll to the top!",
|
||||
"step_1": "Step 1: Install with pnpm, npm or yarn",
|
||||
"step_2": "Step 2: Initialize widget",
|
||||
"step_2_description": "Import Formbricks and initialize the widget in your Component (e.g. App.tsx):",
|
||||
"step_3": "Step 3: Debug mode",
|
||||
"switch_on_the_debug_mode_by_appending": "Switch on the debug mode by appending",
|
||||
"tag_of_your_app": "tag of your app",
|
||||
"to_the_url_where_you_load_the": "to the URL where you load the",
|
||||
"want_to_learn_how_to_add_user_attributes": "Want to learn how to add user attributes, custom events and more?",
|
||||
"you_are_done": "You're done \uD83C\uDF89",
|
||||
"you_can_set_the_user_id_with": "you can set the user id with",
|
||||
"your_app_now_communicates_with_formbricks": "Your app now communicates with Formbricks - sending events, and loading surveys automatically!"
|
||||
},
|
||||
"general": {
|
||||
"cannot_delete_only_project": "This is your only project, it cannot be deleted. Create a new project first.",
|
||||
@@ -977,6 +989,7 @@
|
||||
"free": "Free",
|
||||
"free_description": "Unlimited Surveys, Team Members, and more.",
|
||||
"get_2_months_free": "Get 2 months free",
|
||||
"get_in_touch": "Get in touch",
|
||||
"hosted_in_frankfurt": "Hosted in Frankfurt",
|
||||
"ios_android_sdks": "iOS & Android SDK for mobile surveys",
|
||||
"link_surveys": "Link Surveys (Shareable)",
|
||||
@@ -1098,7 +1111,9 @@
|
||||
},
|
||||
"profile": {
|
||||
"account_deletion_consequences_warning": "Account deletion consequences",
|
||||
"avatar_update_failed": "Avatar update failed. Please try again.",
|
||||
"backup_code": "Backup Code",
|
||||
"change_image": "Change image",
|
||||
"confirm_delete_account": "Delete your account with all of your personal information and data",
|
||||
"confirm_delete_my_account": "Delete My Account",
|
||||
"confirm_your_current_password_to_get_started": "Confirm your current password to get started.",
|
||||
@@ -1109,13 +1124,17 @@
|
||||
"email_change_initiated": "Your email change request has been initiated.",
|
||||
"enable_two_factor_authentication": "Enable two factor authentication",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Enter the code from your authenticator app below.",
|
||||
"file_size_must_be_less_than_10mb": "File size must be less than 10MB.",
|
||||
"invalid_file_type": "Invalid file type. Only JPEG, PNG, and WEBP files are allowed.",
|
||||
"lost_access": "Lost access",
|
||||
"or_enter_the_following_code_manually": "Or enter the following code manually:",
|
||||
"organization_identification": "Assist your organization in identifying you on Formbricks",
|
||||
"organizations_delete_message": "You are the only owner of these organizations, so they <b>will be deleted as well.</b>",
|
||||
"permanent_removal_of_all_of_your_personal_information_and_data": "Permanent removal of all of your personal information and data",
|
||||
"personal_information": "Personal information",
|
||||
"please_enter_email_to_confirm_account_deletion": "Please enter {email} in the following field to confirm the definitive deletion of your account:",
|
||||
"profile_updated_successfully": "Your profile was updated successfully",
|
||||
"remove_image": "Remove image",
|
||||
"save_the_following_backup_codes_in_a_safe_place": "Save the following backup codes in a safe place.",
|
||||
"scan_the_qr_code_below_with_your_authenticator_app": "Scan the QR code below with your authenticator app.",
|
||||
"security_description": "Manage your password and other security settings like two-factor authentication (2FA).",
|
||||
@@ -1125,8 +1144,10 @@
|
||||
"two_factor_code": "Two-Factor Code",
|
||||
"unlock_two_factor_authentication": "Unlock two-factor authentication with a higher plan",
|
||||
"update_personal_info": "Update your personal information",
|
||||
"upload_image": "Upload image",
|
||||
"warning_cannot_delete_account": "You are the only owner of this organization. Please transfer ownership to another member first.",
|
||||
"warning_cannot_undo": "This cannot be undone"
|
||||
"warning_cannot_undo": "This cannot be undone",
|
||||
"you_must_select_a_file": "You must select a file."
|
||||
},
|
||||
"teams": {
|
||||
"add_members_description": "Add members to the team and determine their role.",
|
||||
@@ -1238,7 +1259,9 @@
|
||||
"automatically_close_survey_after": "Automatically close survey after",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Automatically close the survey after a certain number of responses.",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Automatically close the survey if the user does not respond after certain number of seconds.",
|
||||
"automatically_closes_the_survey_at_the_beginning_of_the_day_utc": "Automatically closes the survey at the beginning of the day (UTC).",
|
||||
"automatically_mark_the_survey_as_complete_after": "Automatically mark the survey as complete after",
|
||||
"automatically_release_the_survey_at_the_beginning_of_the_day_utc": "Automatically release the survey at the beginning of the day (UTC).",
|
||||
"back_button_label": "\"Back\" Button Label",
|
||||
"background_styling": "Background Styling",
|
||||
"brand_color": "Brand color",
|
||||
@@ -1286,13 +1309,14 @@
|
||||
"choose_the_actions_which_trigger_the_survey": "Choose the actions which trigger the survey.",
|
||||
"choose_where_to_run_the_survey": "Choose where to run the survey.",
|
||||
"city": "City",
|
||||
"close_survey_on_date": "Close survey on date",
|
||||
"close_survey_on_response_limit": "Close survey on response limit",
|
||||
"color": "Color",
|
||||
"column_used_in_logic_error": "This column is used in logic of question {questionIndex}. Please remove it from logic first.",
|
||||
"columns": "Columns",
|
||||
"company": "Company",
|
||||
"company_logo": "Company logo",
|
||||
"completed_responses": "completed responses.",
|
||||
"completed_responses": "partial or completed responses.",
|
||||
"concat": "Concat +",
|
||||
"conditional_logic": "Conditional Logic",
|
||||
"confirm_default_language": "Confirm default language",
|
||||
@@ -1332,7 +1356,6 @@
|
||||
"end_screen_card": "End screen card",
|
||||
"ending_card": "Ending card",
|
||||
"ending_card_used_in_logic": "This ending card is used in logic of question {questionIndex}.",
|
||||
"ending_used_in_quota": "This ending is being used in \"{quotaName}\" quota",
|
||||
"ends_with": "Ends with",
|
||||
"equals": "Equals",
|
||||
"equals_one_of": "Equals one of",
|
||||
@@ -1343,7 +1366,6 @@
|
||||
"fallback_for": "Fallback for ",
|
||||
"fallback_missing": "Fallback missing",
|
||||
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} is used in logic of question {questionIndex}. Please remove it from logic first.",
|
||||
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "Hidden field \"{fieldId}\" is being used in \"{quotaName}\" quota",
|
||||
"field_name_eg_score_price": "Field name e.g, score, price",
|
||||
"first_name": "First Name",
|
||||
"five_points_recommended": "5 points (recommended)",
|
||||
@@ -1375,9 +1397,8 @@
|
||||
"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 valid options found for sending emails, please add some open-text / contact-info questions or hidden fields",
|
||||
"follow_ups_modal_action_to_warning": "No email field detected in the survey",
|
||||
"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",
|
||||
@@ -1387,9 +1408,8 @@
|
||||
"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": "Please select at least one ending or change the trigger type",
|
||||
"follow_ups_modal_trigger_type_ending_warning": "No endings found in the survey!",
|
||||
"follow_ups_modal_trigger_type_response": "Respondent completes survey",
|
||||
"follow_ups_modal_updated_successfull_toast": "Follow-up updated and will be saved once you save the survey.",
|
||||
"follow_ups_new": "New follow-up",
|
||||
"follow_ups_upgrade_button_text": "Upgrade to enable follow-ups",
|
||||
"form_styling": "Form styling",
|
||||
@@ -1490,38 +1510,6 @@
|
||||
"question_duplicated": "Question duplicated.",
|
||||
"question_id_updated": "Question ID updated",
|
||||
"question_used_in_logic": "This question is used in logic of question {questionIndex}.",
|
||||
"question_used_in_quota": "This question is being used in \"{quotaName}\" quota",
|
||||
"quotas": {
|
||||
"add_quota": "Add quota",
|
||||
"change_quota_for_public_survey": "Change quota for public survey?",
|
||||
"confirm_quota_changes": "Confirm quota changes",
|
||||
"confirm_quota_changes_body": "You have unsaved changes in your quota. Would you like to save them before leaving?",
|
||||
"continue_survey_normally": "Continue survey normally",
|
||||
"count_partial_submissions": "Count partial submissions",
|
||||
"count_partial_submissions_description": "Include respondents that match the quota criteria but did not complete the survey",
|
||||
"create_quota_for_public_survey": "Create quota for public survey?",
|
||||
"create_quota_for_public_survey_description": "Only future answers will be screened into quota",
|
||||
"create_quota_for_public_survey_text": "This survey is already public. Existing responses will not be taken into account for the new quota.",
|
||||
"delete_quota_confirmation_text": "This will permanently delete the quota {quotaName}.",
|
||||
"duplicate_quota": "Duplicate quota",
|
||||
"edit_quota": "Edit quota",
|
||||
"end_survey_for_matching_participants": "End survey for matching participants",
|
||||
"inclusion_criteria": "Inclusion Criteria",
|
||||
"limit_must_be_greater_than_or_equal_to_the_number_of_responses": "{value, plural, one {You already have {value} response for this quota, so the limit must be greater than {value}.} other {You already have {value} responses for this quota, so the limit must be greater than {value}.}}",
|
||||
"limited_to_x_responses": "Limited to {limit}",
|
||||
"new_quota": "New Quota",
|
||||
"quota_created_successfull_toast": "Quota created successfully",
|
||||
"quota_deleted_successfull_toast": "Quota deleted successfully",
|
||||
"quota_duplicated_successfull_toast": "Quota duplicated successfully",
|
||||
"quota_name_placeholder": "e.g., Age 18-25 participants",
|
||||
"quota_updated_successfull_toast": "Quota updated successfully",
|
||||
"response_limit": "Limits",
|
||||
"save_changes_confirmation_body": "Any changes to the inclusion criteria only affect future responses. \nWe recommend to either duplicate an existing or create a new quota.",
|
||||
"save_changes_confirmation_text": "Existing responses stay in the quota",
|
||||
"select_ending_card": "Select ending card",
|
||||
"upgrade_prompt_title": "Use quotas with a higher plan",
|
||||
"when_quota_has_been_reached": "When quota has been reached"
|
||||
},
|
||||
"randomize_all": "Randomize all",
|
||||
"randomize_all_except_last": "Randomize all except last",
|
||||
"range": "Range",
|
||||
@@ -1529,6 +1517,7 @@
|
||||
"redirect_thank_you_card": "Redirect thank you card",
|
||||
"redirect_to_url": "Redirect to Url",
|
||||
"redirect_to_url_not_available_on_free_plan": "Redirect To Url is not available on free plan",
|
||||
"release_survey_on_date": "Release survey on date",
|
||||
"remove_description": "Remove description",
|
||||
"remove_translations": "Remove translations",
|
||||
"require_answer": "Require Answer",
|
||||
@@ -1554,7 +1543,6 @@
|
||||
"send_survey_to_audience_who_match": "Send survey to audience who match...",
|
||||
"send_your_respondents_to_a_page_of_your_choice": "Send your respondents to a page of your choice.",
|
||||
"set_the_global_placement_in_the_look_feel_settings": "Set the global placement in the Look & Feel settings.",
|
||||
"settings_saved_successfully": "Settings saved successfully.",
|
||||
"seven_points": "7 points",
|
||||
"show_advanced_settings": "Show Advanced settings",
|
||||
"show_button": "Show Button",
|
||||
@@ -1615,7 +1603,6 @@
|
||||
"url_not_supported": "URL not supported",
|
||||
"use_with_caution": "Use with caution",
|
||||
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} is used in logic of question {questionIndex}. Please remove it from logic first.",
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variable \"{variableName}\" is being used in \"{quotaName}\" quota",
|
||||
"variable_name_is_already_taken_please_choose_another": "Variable name is already taken, please choose another.",
|
||||
"variable_name_must_start_with_a_letter": "Variable name must start with a letter.",
|
||||
"verify_email_before_submission": "Verify email before submission",
|
||||
@@ -1648,16 +1635,16 @@
|
||||
"responses": {
|
||||
"address_line_1": "Address Line 1",
|
||||
"address_line_2": "Address Line 2",
|
||||
"an_error_occurred_creating_a_new_note": "An error occurred creating a new note",
|
||||
"an_error_occurred_deleting_the_tag": "An error occurred deleting the tag",
|
||||
"an_error_occurred_resolving_a_note": "An error occurred resolving a note",
|
||||
"an_error_occurred_updating_a_note": "An error occurred updating a note",
|
||||
"browser": "Browser",
|
||||
"bulk_delete_response_quotas": "The responses are part of quotas for this survey. How do you want to handle the quotas?",
|
||||
"city": "City",
|
||||
"company": "Company",
|
||||
"completed": "Completed ✅",
|
||||
"country": "Country",
|
||||
"decrement_quotas": "Decrement all limits of quotas including this response",
|
||||
"delete_response_confirmation": "This will delete the survey response, including all answers, tags, attached documents, and response metadata.",
|
||||
"delete_response_quotas": "The response is part of quotas for this survey. How do you want to handle the quotas?",
|
||||
"delete_response_confirmation": "This will delete the survey response, including all answers, notes, tags, attached documents, and response metadata.",
|
||||
"device": "Device",
|
||||
"device_info": "Device info",
|
||||
"email": "Email",
|
||||
@@ -1669,6 +1656,7 @@
|
||||
"os": "OS",
|
||||
"person_attributes": "Person attributes",
|
||||
"phone": "Phone",
|
||||
"resolve": "Resolve",
|
||||
"respondent_skipped_questions": "Respondent skipped these questions.",
|
||||
"response_deleted_successfully": "Response deleted successfully.",
|
||||
"single_use_id": "SingleUse ID",
|
||||
@@ -1725,17 +1713,6 @@
|
||||
"embed_mode_description": "Embed your survey with a minimalist design, discarding padding and background.",
|
||||
"nav_title": "Website embed"
|
||||
},
|
||||
"link_settings": {
|
||||
"description": "Give links a custom title, description and image for sharing in public.",
|
||||
"language_help_text": "The meta data is loaded based on the `lang` value in the URL.",
|
||||
"link_description": "Link description",
|
||||
"link_description_description": "Descriptions between 55-200 characters perform best.",
|
||||
"link_title": "Link title",
|
||||
"link_title_description": "Short titles perform best as Meta Titles.",
|
||||
"preview_image": "Preview image",
|
||||
"preview_image_description": "Landscape images with small file sizes (<4MB) perform best.",
|
||||
"title": "Link settings"
|
||||
},
|
||||
"personal_links": {
|
||||
"create_and_manage_segments": "Create and manage your Segments under Contacts > Segments",
|
||||
"description": "Generate personal links for a segment and match survey responses to each contact.",
|
||||
@@ -1767,7 +1744,6 @@
|
||||
"send_preview": "Send preview",
|
||||
"send_preview_email": "Send preview email"
|
||||
},
|
||||
"share_settings_title": "Share settings",
|
||||
"share_view_title": "Share via",
|
||||
"social_media": {
|
||||
"description": "Get responses from your contacts on various social media networks.",
|
||||
@@ -1789,7 +1765,6 @@
|
||||
"configure_alerts": "Configure alerts",
|
||||
"congrats": "Congrats! Your survey is live.",
|
||||
"connect_your_website_or_app_with_formbricks_to_get_started": "Connect your website or app with Formbricks to get started.",
|
||||
"current_count": "Current count",
|
||||
"custom_range": "Custom range...",
|
||||
"delete_all_existing_responses_and_displays": "Delete all existing responses and displays",
|
||||
"download_qr_code": "Download QR code",
|
||||
@@ -1843,7 +1818,6 @@
|
||||
"last_month": "Last month",
|
||||
"last_quarter": "Last quarter",
|
||||
"last_year": "Last year",
|
||||
"limit": "Limit",
|
||||
"no_responses_found": "No responses found",
|
||||
"other_values_found": "Other values found",
|
||||
"overall": "Overall",
|
||||
@@ -1852,8 +1826,6 @@
|
||||
"qr_code_download_failed": "QR code download failed",
|
||||
"qr_code_download_with_start_soon": "QR code download will start soon",
|
||||
"qr_code_generation_failed": "There was a problem, loading the survey QR Code. Please try again.",
|
||||
"quotas_completed": "Quotas completed",
|
||||
"quotas_completed_tooltip": "The number of quotas completed by the respondents.",
|
||||
"reset_survey": "Reset survey",
|
||||
"reset_survey_warning": "Resetting a survey removes all responses and displays associated with this survey. This cannot be undone.",
|
||||
"selected_responses_csv": "Selected responses (CSV)",
|
||||
@@ -1869,7 +1841,7 @@
|
||||
"this_quarter": "This quarter",
|
||||
"this_year": "This year",
|
||||
"time_to_complete": "Time to Complete",
|
||||
"ttc_tooltip": "Average time to complete the question.",
|
||||
"ttc_tooltip": "Average time to complete the survey.",
|
||||
"unknown_question_type": "Unknown Question Type",
|
||||
"use_personal_links": "Use personal links",
|
||||
"waiting_for_response": "Waiting for a response \uD83E\uDDD8♂️",
|
||||
@@ -1880,6 +1852,7 @@
|
||||
"survey_deleted_successfully": "Survey deleted successfully!",
|
||||
"survey_duplicated_successfully": "Survey duplicated successfully.",
|
||||
"survey_duplication_error": "Failed to duplicate the survey.",
|
||||
"survey_status_tooltip": "To update the survey status, update the schedule and close setting in the survey response options.",
|
||||
"templates": {
|
||||
"all_channels": "All channels",
|
||||
"all_industries": "All industries",
|
||||
|
||||
+65
-92
@@ -118,7 +118,6 @@
|
||||
"account_settings": "Paramètres du compte",
|
||||
"action": "Action",
|
||||
"actions": "Actions",
|
||||
"actions_description": "Les actions avec ou sans code sont utilisées pour déclencher des enquêtes d'interception dans les applications et sur les sites Web.",
|
||||
"active_surveys": "Sondages actifs",
|
||||
"activity": "Activité",
|
||||
"add": "Ajouter",
|
||||
@@ -126,7 +125,6 @@
|
||||
"add_filter": "Ajouter un filtre",
|
||||
"add_logo": "Ajouter un logo",
|
||||
"add_member": "Ajouter un membre",
|
||||
"add_new_project": "Ajouter un nouveau projet",
|
||||
"add_project": "Ajouter un projet",
|
||||
"add_to_team": "Ajouter à l'équipe",
|
||||
"all": "Tout",
|
||||
@@ -143,6 +141,7 @@
|
||||
"apply_filters": "Appliquer des filtres",
|
||||
"are_you_sure": "Es-tu sûr ?",
|
||||
"attributes": "Attributs",
|
||||
"avatar": "Avatar",
|
||||
"back": "Retour",
|
||||
"billing": "Facturation",
|
||||
"booked": "Réservé",
|
||||
@@ -151,9 +150,6 @@
|
||||
"cancel": "Annuler",
|
||||
"centered_modal": "Modal centré",
|
||||
"choices": "Choix",
|
||||
"choose_environment": "Choisir l'environnement",
|
||||
"choose_organization": "Choisir l'organisation",
|
||||
"choose_project": "Choisir projet",
|
||||
"clear_all": "Tout effacer",
|
||||
"clear_filters": "Effacer les filtres",
|
||||
"clear_selection": "Effacer la sélection",
|
||||
@@ -169,14 +165,11 @@
|
||||
"connect_formbricks": "Connecter Formbricks",
|
||||
"connected": "Connecté",
|
||||
"contacts": "Contacts",
|
||||
"continue": "Continuer",
|
||||
"copied": "Copié",
|
||||
"copied_to_clipboard": "Copié dans le presse-papiers",
|
||||
"copy": "Copier",
|
||||
"copy_code": "Copier le code",
|
||||
"copy_link": "Copier le lien",
|
||||
"count_contacts": "{value, plural, one {# contact} other {# contacts} }",
|
||||
"count_responses": "{value, plural, other {# réponses}}",
|
||||
"create_new_organization": "Créer une nouvelle organisation",
|
||||
"create_project": "Créer un projet",
|
||||
"create_segment": "Créer un segment",
|
||||
@@ -185,6 +178,7 @@
|
||||
"created_at": "Créé le",
|
||||
"created_by": "Créé par",
|
||||
"customer_success": "Succès Client",
|
||||
"danger_zone": "Zone de danger",
|
||||
"dark_overlay": "Superposition sombre",
|
||||
"date": "Date",
|
||||
"default": "Par défaut",
|
||||
@@ -204,15 +198,10 @@
|
||||
"e_commerce": "E-commerce",
|
||||
"edit": "Modifier",
|
||||
"email": "Email",
|
||||
"ending_card": "Carte de fin",
|
||||
"enterprise_license": "Licence d'entreprise",
|
||||
"environment_not_found": "Environnement non trouvé",
|
||||
"environment_notice": "Vous êtes actuellement dans l'environnement {environment}.",
|
||||
"error": "Erreur",
|
||||
"error_component_description": "Cette ressource n'existe pas ou vous n'avez pas les droits nécessaires pour y accéder.",
|
||||
"error_component_title": "Erreur de chargement des ressources",
|
||||
"error_rate_limit_description": "Nombre maximal de demandes atteint. Veuillez réessayer plus tard.",
|
||||
"error_rate_limit_title": "Limite de Taux Dépassée",
|
||||
"expand_rows": "Développer les lignes",
|
||||
"finish": "Terminer",
|
||||
"follow_these": "Suivez ceci",
|
||||
@@ -225,6 +214,7 @@
|
||||
"hidden": "Caché",
|
||||
"hidden_field": "Champ caché",
|
||||
"hidden_fields": "Champs cachés",
|
||||
"hide": "Cacher",
|
||||
"hide_column": "Cacher la colonne",
|
||||
"image": "Image",
|
||||
"images": "Images",
|
||||
@@ -244,9 +234,11 @@
|
||||
"label": "Étiquette",
|
||||
"language": "Langue",
|
||||
"learn_more": "En savoir plus",
|
||||
"license": "Licence",
|
||||
"light_overlay": "Superposition légère",
|
||||
"limits_reached": "Limites atteints",
|
||||
"link": "Lien",
|
||||
"link_and_email": "Liens et e-mail",
|
||||
"link_survey": "Enquête de lien",
|
||||
"link_surveys": "Sondages de lien",
|
||||
"load_more": "Charger plus",
|
||||
@@ -262,20 +254,18 @@
|
||||
"membership_not_found": "Abonnement non trouvé",
|
||||
"metadata": "Métadonnées",
|
||||
"minimum": "Min",
|
||||
"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é!",
|
||||
"mobile_overlay_text": "Formbricks n'est pas disponible pour les appareils avec des résolutions plus petites.",
|
||||
"move_down": "Déplacer vers le bas",
|
||||
"move_up": "Déplacer vers le haut",
|
||||
"multiple_languages": "Plusieurs langues",
|
||||
"name": "Nom",
|
||||
"new": "Nouveau",
|
||||
"new_survey": "Nouveau Sondage",
|
||||
"new_version_available": "Formbricks {version} est là. Mettez à jour maintenant !",
|
||||
"next": "Suivant",
|
||||
"no_background_image_found": "Aucune image de fond trouvée.",
|
||||
"no_code": "Pas de code",
|
||||
"no_files_uploaded": "Aucun fichier n'a été téléchargé.",
|
||||
"no_quotas_found": "Aucun quota trouvé",
|
||||
"no_result_found": "Aucun résultat trouvé",
|
||||
"no_results": "Aucun résultat",
|
||||
"no_surveys_found": "Aucun sondage trouvé.",
|
||||
@@ -283,6 +273,7 @@
|
||||
"not_authorized": "Non autorisé",
|
||||
"not_connected": "Non connecté",
|
||||
"note": "Remarque",
|
||||
"notes": "Notes",
|
||||
"notifications": "Notifications",
|
||||
"number": "Numéro",
|
||||
"off": "Éteint",
|
||||
@@ -295,7 +286,6 @@
|
||||
"organization": "Organisation",
|
||||
"organization_id": "ID de l'organisation",
|
||||
"organization_not_found": "Organisation non trouvée",
|
||||
"organization_settings": "Paramètres de l'organisation",
|
||||
"organization_teams_not_found": "Équipes d'organisation non trouvées",
|
||||
"other": "Autre",
|
||||
"others": "Autres",
|
||||
@@ -319,7 +309,6 @@
|
||||
"product_manager": "Chef de produit",
|
||||
"profile": "Profil",
|
||||
"profile_id": "Identifiant de profil",
|
||||
"progress": "Progression",
|
||||
"project_configuration": "Configuration du projet",
|
||||
"project_creation_description": "Organisez les enquêtes en projets pour un meilleur contrôle d'accès.",
|
||||
"project_id": "ID de projet",
|
||||
@@ -331,9 +320,6 @@
|
||||
"question": "Question",
|
||||
"question_id": "ID de la question",
|
||||
"questions": "Questions",
|
||||
"quota": "Quota",
|
||||
"quotas": "Quotas",
|
||||
"quotas_description": "Limitez le nombre de réponses que vous recevez de la part des participants répondant à certains critères.",
|
||||
"read_docs": "Lire les documents",
|
||||
"recipients": "Destinataires",
|
||||
"remove": "Retirer",
|
||||
@@ -351,7 +337,7 @@
|
||||
"sales": "Ventes",
|
||||
"save": "Enregistrer",
|
||||
"save_changes": "Enregistrer les modifications",
|
||||
"saving": "Sauvegarder",
|
||||
"scheduled": "Programmé",
|
||||
"search": "Recherche",
|
||||
"security": "Sécurité",
|
||||
"segment": "Segmenter",
|
||||
@@ -364,6 +350,7 @@
|
||||
"selected_questions": "Questions sélectionnées",
|
||||
"selection": "Sélection",
|
||||
"selections": "Sélections",
|
||||
"send": "Envoyer",
|
||||
"send_test_email": "Envoyer un e-mail de test",
|
||||
"session_not_found": "Session non trouvée",
|
||||
"settings": "Paramètres",
|
||||
@@ -381,7 +368,6 @@
|
||||
"start_free_trial": "Commencer l'essai gratuit",
|
||||
"status": "Statut",
|
||||
"step_by_step_manual": "Manuel étape par étape",
|
||||
"storage_not_configured": "Stockage de fichiers non configuré, les téléchargements risquent d'échouer",
|
||||
"styling": "Style",
|
||||
"submit": "Soumettre",
|
||||
"summary": "Résumé",
|
||||
@@ -392,8 +378,10 @@
|
||||
"survey_live": "Sondage en direct",
|
||||
"survey_not_found": "Sondage non trouvé",
|
||||
"survey_paused": "Sondage en pause.",
|
||||
"survey_scheduled": "Sondage programmé.",
|
||||
"survey_type": "Type de sondage",
|
||||
"surveys": "Enquêtes",
|
||||
"switch_organization": "Changer d'organisation",
|
||||
"switch_to": "Passer à {environment}",
|
||||
"table_items_deleted_successfully": "{type}s supprimés avec succès",
|
||||
"table_settings": "Réglages de table",
|
||||
@@ -591,7 +579,8 @@
|
||||
"contacts_table_refresh": "Rafraîchir les contacts",
|
||||
"contacts_table_refresh_success": "Contacts rafraîchis avec succès",
|
||||
"delete_contact_confirmation": "Cela supprimera toutes les réponses aux enquêtes et les attributs de contact associés à ce contact. Toute la personnalisation et le ciblage basés sur les données de ce contact seront perdus.",
|
||||
"delete_contact_confirmation_with_quotas": "{value, plural, other {Cela supprimera toutes les réponses aux enquêtes et les attributs de contact associés à ce contact. Toute la personnalisation et le ciblage basés sur les données de ce contact seront perdus. Si ce contact a des réponses qui comptent dans les quotas de l'enquête, les comptes de quotas seront réduits mais les limites de quota resteront inchangées.}}",
|
||||
"first_name": "Prénom",
|
||||
"last_name": "Nom de famille",
|
||||
"no_responses_found": "Aucune réponse trouvée",
|
||||
"not_provided": "Non fourni",
|
||||
"search_contact": "Rechercher un contact",
|
||||
@@ -752,6 +741,7 @@
|
||||
},
|
||||
"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",
|
||||
@@ -771,21 +761,43 @@
|
||||
"unable_to_delete_api_key": "Impossible de supprimer la clé API"
|
||||
},
|
||||
"app-connection": {
|
||||
"api_host_description": "Ceci est l'URL de votre backend Formbricks.",
|
||||
"app_connection": "Connexion d'application",
|
||||
"app_connection_description": "Connectez votre application à Formbricks.",
|
||||
"cache_update_delay_description": "Lorsque vous effectuez des mises à jour sur les sondages, contacts, actions ou autres données, cela peut prendre jusqu'à 5 minutes pour que ces modifications apparaissent dans votre application locale exécutant le SDK Formbricks. Ce délai est dû à une limitation de notre système de mise en cache actuel. Nous retravaillons activement le cache et publierons une correction dans Formbricks 4.0.",
|
||||
"cache_update_delay_title": "Les modifications seront reflétées après 5 minutes en raison de la mise en cache",
|
||||
"check_out_the_docs": "Consultez la documentation.",
|
||||
"dive_into_the_docs": "Plongez dans la documentation.",
|
||||
"does_your_widget_work": "Votre widget fonctionne-t-il ?",
|
||||
"environment_id": "Votre identifiant d'environnement",
|
||||
"environment_id_description": "Cet identifiant identifie de manière unique cet environnement Formbricks.",
|
||||
"environment_id_description_with_environment_id": "Utilisé pour identifier l'environnement correct : {environmentId} est le vôtre.",
|
||||
"formbricks_sdk": "SDK Formbricks",
|
||||
"formbricks_sdk_connected": "Le SDK Formbricks est connecté",
|
||||
"formbricks_sdk_not_connected": "Le SDK Formbricks n'est pas encore connecté.",
|
||||
"formbricks_sdk_not_connected_description": "Connectez votre site web ou votre application à Formbricks.",
|
||||
"have_a_problem": "Vous avez un problème ?",
|
||||
"how_to_setup": "Comment configurer",
|
||||
"how_to_setup_description": "Suivez ces étapes pour configurer le widget Formbricks dans votre application.",
|
||||
"identifying_your_users": "identifier vos utilisateurs",
|
||||
"if_you_are_planning_to": "Si vous prévoyez de",
|
||||
"insert_this_code_into_the": "Insérez ce code dans le",
|
||||
"need_a_more_detailed_setup_guide_for": "Besoin d'un guide d'installation plus détaillé pour",
|
||||
"not_working": "Ça ne fonctionne pas ?",
|
||||
"open_an_issue_on_github": "Ouvrir un problème sur GitHub",
|
||||
"open_the_browser_console_to_see_the_logs": "Ouvrez la console du navigateur pour voir les journaux.",
|
||||
"receiving_data": "Réception des données \uD83D\uDC83\uD83D\uDD7A",
|
||||
"recheck": "Re-vérifier",
|
||||
"setup_alert_description": "Suivez ce tutoriel étape par étape pour connecter votre application ou site web en moins de 5 minutes.",
|
||||
"setup_alert_title": "Comment connecter"
|
||||
"scroll_to_the_top": "Faites défiler vers le haut !",
|
||||
"step_1": "Étape 1 : Installer avec pnpm, npm ou yarn",
|
||||
"step_2": "Étape 2 : Initialiser le widget",
|
||||
"step_2_description": "Importez Formbricks et initialisez le widget dans votre composant (par exemple, App.tsx) :",
|
||||
"step_3": "Étape 3 : Mode débogage",
|
||||
"switch_on_the_debug_mode_by_appending": "Activez le mode débogage en ajoutant",
|
||||
"tag_of_your_app": "étiquette de votre application",
|
||||
"to_the_url_where_you_load_the": "vers l'URL où vous chargez le",
|
||||
"want_to_learn_how_to_add_user_attributes": "Vous voulez apprendre à ajouter des attributs utilisateur, des événements personnalisés et plus encore ?",
|
||||
"you_are_done": "Vous avez terminé \uD83C\uDF89",
|
||||
"you_can_set_the_user_id_with": "vous pouvez définir l'ID utilisateur avec",
|
||||
"your_app_now_communicates_with_formbricks": "Votre application communique désormais avec Formbricks - envoyant des événements et chargeant des enquêtes automatiquement !"
|
||||
},
|
||||
"general": {
|
||||
"cannot_delete_only_project": "Ceci est votre seul projet, il ne peut pas être supprimé. Créez d'abord un nouveau projet.",
|
||||
@@ -977,6 +989,7 @@
|
||||
"free": "Gratuit",
|
||||
"free_description": "Sondages illimités, membres d'équipe, et plus encore.",
|
||||
"get_2_months_free": "Obtenez 2 mois gratuits",
|
||||
"get_in_touch": "Prenez contact",
|
||||
"hosted_in_frankfurt": "Hébergé à Francfort",
|
||||
"ios_android_sdks": "SDK iOS et Android pour les sondages mobiles",
|
||||
"link_surveys": "Sondages par lien (partageables)",
|
||||
@@ -1098,7 +1111,9 @@
|
||||
},
|
||||
"profile": {
|
||||
"account_deletion_consequences_warning": "Conséquences de la suppression de compte",
|
||||
"avatar_update_failed": "La mise à jour de l'avatar a échoué. Veuillez réessayer.",
|
||||
"backup_code": "Code de sauvegarde",
|
||||
"change_image": "Changer l'image",
|
||||
"confirm_delete_account": "Supprimez votre compte avec toutes vos informations personnelles et données.",
|
||||
"confirm_delete_my_account": "Supprimer mon compte",
|
||||
"confirm_your_current_password_to_get_started": "Confirmez votre mot de passe actuel pour commencer.",
|
||||
@@ -1109,13 +1124,17 @@
|
||||
"email_change_initiated": "Votre demande de changement d'email a été initiée.",
|
||||
"enable_two_factor_authentication": "Activer l'authentification à deux facteurs",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Entrez le code de votre application d'authentification ci-dessous.",
|
||||
"file_size_must_be_less_than_10mb": "La taille du fichier doit être inférieure à 10 Mo.",
|
||||
"invalid_file_type": "Type de fichier invalide. Seuls les fichiers JPEG, PNG et WEBP sont autorisés.",
|
||||
"lost_access": "Accès perdu",
|
||||
"or_enter_the_following_code_manually": "Ou entrez le code suivant manuellement :",
|
||||
"organization_identification": "Aidez votre organisation à vous identifier sur Formbricks",
|
||||
"organizations_delete_message": "Tu es le seul propriétaire de ces organisations, elles <b>seront aussi supprimées.</b>",
|
||||
"permanent_removal_of_all_of_your_personal_information_and_data": "Suppression permanente de toutes vos informations et données personnelles.",
|
||||
"personal_information": "Informations personnelles",
|
||||
"please_enter_email_to_confirm_account_deletion": "Veuillez entrer {email} dans le champ suivant pour confirmer la suppression définitive de votre compte :",
|
||||
"profile_updated_successfully": "Votre profil a été mis à jour avec succès.",
|
||||
"remove_image": "Supprimer l'image",
|
||||
"save_the_following_backup_codes_in_a_safe_place": "Enregistrez les codes de sauvegarde suivants dans un endroit sûr.",
|
||||
"scan_the_qr_code_below_with_your_authenticator_app": "Scannez le code QR ci-dessous avec votre application d'authentification.",
|
||||
"security_description": "Gérez votre mot de passe et d'autres paramètres de sécurité comme l'authentification à deux facteurs (2FA).",
|
||||
@@ -1125,8 +1144,10 @@
|
||||
"two_factor_code": "Code à deux facteurs",
|
||||
"unlock_two_factor_authentication": "Débloquez l'authentification à deux facteurs avec une offre supérieure",
|
||||
"update_personal_info": "Mettez à jour vos informations personnelles",
|
||||
"upload_image": "Télécharger l'image",
|
||||
"warning_cannot_delete_account": "Tu es le seul propriétaire de cette organisation. Transfère la propriété à un autre membre d'abord.",
|
||||
"warning_cannot_undo": "Ceci ne peut pas être annulé"
|
||||
"warning_cannot_undo": "Ceci ne peut pas être annulé",
|
||||
"you_must_select_a_file": "Vous devez sélectionner un fichier."
|
||||
},
|
||||
"teams": {
|
||||
"add_members_description": "Ajoutez des membres à l'équipe et déterminez leur rôle.",
|
||||
@@ -1238,7 +1259,9 @@
|
||||
"automatically_close_survey_after": "Fermer automatiquement l'enquête après",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Fermer automatiquement l'enquête après un certain nombre de réponses.",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Fermer automatiquement l'enquête si l'utilisateur ne répond pas après un certain nombre de secondes.",
|
||||
"automatically_closes_the_survey_at_the_beginning_of_the_day_utc": "Ferme automatiquement l'enquête au début de la journée (UTC).",
|
||||
"automatically_mark_the_survey_as_complete_after": "Marquer automatiquement l'enquête comme terminée après",
|
||||
"automatically_release_the_survey_at_the_beginning_of_the_day_utc": "Libérer automatiquement l'enquête au début de la journée (UTC).",
|
||||
"back_button_label": "Label du bouton \"Retour''",
|
||||
"background_styling": "Style de fond",
|
||||
"brand_color": "Couleur de marque",
|
||||
@@ -1286,13 +1309,14 @@
|
||||
"choose_the_actions_which_trigger_the_survey": "Choisissez les actions qui déclenchent l'enquête.",
|
||||
"choose_where_to_run_the_survey": "Choisissez où réaliser l'enquête.",
|
||||
"city": "Ville",
|
||||
"close_survey_on_date": "Clôturer l'enquête à la date",
|
||||
"close_survey_on_response_limit": "Fermer l'enquête sur la limite de réponse",
|
||||
"color": "Couleur",
|
||||
"column_used_in_logic_error": "Cette colonne est utilisée dans la logique de la question {questionIndex}. Veuillez d'abord la supprimer de la logique.",
|
||||
"columns": "Colonnes",
|
||||
"company": "Société",
|
||||
"company_logo": "Logo de l'entreprise",
|
||||
"completed_responses": "Réponses terminées",
|
||||
"completed_responses": "des réponses partielles ou complètes.",
|
||||
"concat": "Concat +",
|
||||
"conditional_logic": "Logique conditionnelle",
|
||||
"confirm_default_language": "Confirmer la langue par défaut",
|
||||
@@ -1332,7 +1356,6 @@
|
||||
"end_screen_card": "Carte de fin d'écran",
|
||||
"ending_card": "Carte de fin",
|
||||
"ending_card_used_in_logic": "Cette carte de fin est utilisée dans la logique de la question '{'questionIndex'}'.",
|
||||
"ending_used_in_quota": "Cette fin est utilisée dans le quota \"{quotaName}\"",
|
||||
"ends_with": "Se termine par",
|
||||
"equals": "Égal",
|
||||
"equals_one_of": "Égal à l'un de",
|
||||
@@ -1343,7 +1366,6 @@
|
||||
"fallback_for": "Solution de repli pour ",
|
||||
"fallback_missing": "Fallback manquant",
|
||||
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} est utilisé dans la logique de la question {questionIndex}. Veuillez d'abord le supprimer de la logique.",
|
||||
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "Le champ masqué \"{fieldId}\" est utilisé dans le quota \"{quotaName}\"",
|
||||
"field_name_eg_score_price": "Nom du champ par exemple, score, prix",
|
||||
"first_name": "Prénom",
|
||||
"five_points_recommended": "5 points (recommandé)",
|
||||
@@ -1375,9 +1397,8 @@
|
||||
"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": "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_action_to_warning": "Aucun champ d'email détecté dans l'enquête",
|
||||
"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",
|
||||
@@ -1387,9 +1408,8 @@
|
||||
"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": "Veuillez sélectionner au moins une fin ou changer le type de déclencheur.",
|
||||
"follow_ups_modal_trigger_type_ending_warning": "Aucune fin trouvée dans l'enquête !",
|
||||
"follow_ups_modal_trigger_type_response": "Le répondant complète l'enquête",
|
||||
"follow_ups_modal_updated_successfull_toast": "\"Suivi mis à jour et sera enregistré une fois que vous sauvegarderez le sondage.\"",
|
||||
"follow_ups_new": "Nouveau suivi",
|
||||
"follow_ups_upgrade_button_text": "Passez à la version supérieure pour activer les relances",
|
||||
"form_styling": "Style de formulaire",
|
||||
@@ -1490,38 +1510,6 @@
|
||||
"question_duplicated": "Question dupliquée.",
|
||||
"question_id_updated": "ID de la question mis à jour",
|
||||
"question_used_in_logic": "Cette question est utilisée dans la logique de la question '{'questionIndex'}'.",
|
||||
"question_used_in_quota": "Cette question est utilisée dans le quota \"{quotaName}\"",
|
||||
"quotas": {
|
||||
"add_quota": "Ajouter un quota",
|
||||
"change_quota_for_public_survey": "Changer le quota pour le sondage public ?",
|
||||
"confirm_quota_changes": "Confirmer les modifications de quotas",
|
||||
"confirm_quota_changes_body": "Vous avez des modifications non enregistrées dans votre quota. Souhaitez-vous les enregistrer avant de partir ?",
|
||||
"continue_survey_normally": "Continuer le sondage normalement",
|
||||
"count_partial_submissions": "Compter les soumissions partielles",
|
||||
"count_partial_submissions_description": "Inclure les répondants qui correspondent aux critères de quota mais n'ont pas terminé le sondage",
|
||||
"create_quota_for_public_survey": "Créer un quota pour le sondage public ?",
|
||||
"create_quota_for_public_survey_description": "Seules les réponses futures seront filtrées dans le quota",
|
||||
"create_quota_for_public_survey_text": "Ce sondage est déjà public. Les réponses existantes ne seront pas prises en compte pour le nouveau quota.",
|
||||
"delete_quota_confirmation_text": "Cela supprimera définitivement le quota {quotaName}.",
|
||||
"duplicate_quota": "Dupliquer le quota",
|
||||
"edit_quota": "Modifier le quota",
|
||||
"end_survey_for_matching_participants": "Terminer l'enquête pour les participants correspondants",
|
||||
"inclusion_criteria": "Critères d'inclusion",
|
||||
"limit_must_be_greater_than_or_equal_to_the_number_of_responses": "{value, plural, other {La limite doit être supérieure ou égale au nombre de réponses}}",
|
||||
"limited_to_x_responses": "Limité à {limit}",
|
||||
"new_quota": "Nouveau Quota",
|
||||
"quota_created_successfull_toast": "Quota créé avec succès",
|
||||
"quota_deleted_successfull_toast": "Quota supprimé avec succès",
|
||||
"quota_duplicated_successfull_toast": "Quota dupliqué avec succès",
|
||||
"quota_name_placeholder": "par ex., Participants âgés de 18 à 25 ans",
|
||||
"quota_updated_successfull_toast": "Quota mis à jour avec succès",
|
||||
"response_limit": "Limites",
|
||||
"save_changes_confirmation_body": "Les modifications apportées aux critères d'inclusion n'affectent que les réponses futures. \nNous vous recommandons soit de dupliquer un quota existant, soit d'en créer un nouveau.",
|
||||
"save_changes_confirmation_text": "\"Les réponses existantes restent dans le quota\"",
|
||||
"select_ending_card": "Sélectionner la carte de fin",
|
||||
"upgrade_prompt_title": "Utilisez des quotas avec un plan supérieur",
|
||||
"when_quota_has_been_reached": "Quand le quota est atteint"
|
||||
},
|
||||
"randomize_all": "Randomiser tout",
|
||||
"randomize_all_except_last": "Randomiser tout sauf le dernier",
|
||||
"range": "Plage",
|
||||
@@ -1529,6 +1517,7 @@
|
||||
"redirect_thank_you_card": "Carte de remerciement de redirection",
|
||||
"redirect_to_url": "Rediriger vers l'URL",
|
||||
"redirect_to_url_not_available_on_free_plan": "La redirection vers l'URL n'est pas disponible sur le plan gratuit.",
|
||||
"release_survey_on_date": "Publier l'enquête à la date",
|
||||
"remove_description": "Supprimer la description",
|
||||
"remove_translations": "Supprimer les traductions",
|
||||
"require_answer": "Réponse requise",
|
||||
@@ -1554,7 +1543,6 @@
|
||||
"send_survey_to_audience_who_match": "Envoyer l'enquête au public qui correspond...",
|
||||
"send_your_respondents_to_a_page_of_your_choice": "Envoyez vos répondants vers une page de votre choix.",
|
||||
"set_the_global_placement_in_the_look_feel_settings": "Définissez le placement global dans les paramètres d'apparence.",
|
||||
"settings_saved_successfully": "Paramètres enregistrés avec succès",
|
||||
"seven_points": "7 points",
|
||||
"show_advanced_settings": "Afficher les paramètres avancés",
|
||||
"show_button": "Afficher le bouton",
|
||||
@@ -1615,7 +1603,6 @@
|
||||
"url_not_supported": "URL non supportée",
|
||||
"use_with_caution": "À utiliser avec précaution",
|
||||
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} est utilisé dans la logique de la question {questionIndex}. Veuillez d'abord le supprimer de la logique.",
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "La variable \"{variableName}\" est utilisée dans le quota \"{quotaName}\"",
|
||||
"variable_name_is_already_taken_please_choose_another": "Le nom de la variable est déjà pris, veuillez en choisir un autre.",
|
||||
"variable_name_must_start_with_a_letter": "Le nom de la variable doit commencer par une lettre.",
|
||||
"verify_email_before_submission": "Vérifiez l'email avant la soumission",
|
||||
@@ -1648,16 +1635,16 @@
|
||||
"responses": {
|
||||
"address_line_1": "Ligne d'adresse 1",
|
||||
"address_line_2": "Ligne d'adresse 2",
|
||||
"an_error_occurred_creating_a_new_note": "Une erreur est survenue lors de la création d'une nouvelle note.",
|
||||
"an_error_occurred_deleting_the_tag": "Une erreur est survenue lors de la suppression de l'étiquette.",
|
||||
"an_error_occurred_resolving_a_note": "Une erreur est survenue lors de la résolution d'une note.",
|
||||
"an_error_occurred_updating_a_note": "Une erreur est survenue lors de la mise à jour d'une note.",
|
||||
"browser": "Navigateur",
|
||||
"bulk_delete_response_quotas": "Les réponses font partie des quotas pour ce sondage. Comment voulez-vous gérer les quotas ?",
|
||||
"city": "Ville",
|
||||
"company": "Société",
|
||||
"completed": "Terminé ✅",
|
||||
"country": "Pays",
|
||||
"decrement_quotas": "Décrémentez toutes les limites des quotas y compris cette réponse",
|
||||
"delete_response_confirmation": "Cela supprimera la réponse au sondage, y compris toutes les réponses, les étiquettes, les documents joints et les métadonnées de réponse.",
|
||||
"delete_response_quotas": "La réponse fait partie des quotas pour ce sondage. Comment voulez-vous gérer les quotas ?",
|
||||
"delete_response_confirmation": "\"Cela supprimera la réponse au sondage, y compris toutes les réponses, notes, étiquettes, documents joints et métadonnées de réponse.\"",
|
||||
"device": "Dispositif",
|
||||
"device_info": "Informations sur l'appareil",
|
||||
"email": "Email",
|
||||
@@ -1669,6 +1656,7 @@
|
||||
"os": "Système d'exploitation",
|
||||
"person_attributes": "Attributs de la personne",
|
||||
"phone": "Téléphone",
|
||||
"resolve": "Résoudre",
|
||||
"respondent_skipped_questions": "Le répondant a sauté ces questions.",
|
||||
"response_deleted_successfully": "Réponse supprimée avec succès.",
|
||||
"single_use_id": "Identifiant à usage unique",
|
||||
@@ -1725,17 +1713,6 @@
|
||||
"embed_mode_description": "Intégrez votre enquête avec un design minimaliste, en supprimant les marges et l'arrière-plan.",
|
||||
"nav_title": "Incorporer sur le site web"
|
||||
},
|
||||
"link_settings": {
|
||||
"description": "Donnez aux liens un titre, une description et une image personnalisés pour le partage en public.",
|
||||
"language_help_text": "Les métadonnées sont chargées en fonction de la valeur « lang » dans l'URL.",
|
||||
"link_description": "Description du lien",
|
||||
"link_description_description": "« Les descriptions entre 55 et 200 caractères donnent les meilleurs résultats. »",
|
||||
"link_title": "Titre du lien",
|
||||
"link_title_description": "Les titres courts fonctionnent mieux comme titres méta.",
|
||||
"preview_image": "Aperçu de l'image",
|
||||
"preview_image_description": "Les images en paysage avec de petites tailles de fichier (<4MB) fonctionnent le mieux.",
|
||||
"title": "Paramètres de lien"
|
||||
},
|
||||
"personal_links": {
|
||||
"create_and_manage_segments": "Créez et gérez vos Segments sous Contacts > Segments",
|
||||
"description": "Générez des liens personnels pour un segment et associez les réponses du sondage à chaque contact.",
|
||||
@@ -1767,7 +1744,6 @@
|
||||
"send_preview": "Envoyer un aperçu",
|
||||
"send_preview_email": "Envoyer un e-mail d'aperçu"
|
||||
},
|
||||
"share_settings_title": "Partager les paramètres",
|
||||
"share_view_title": "Partager par",
|
||||
"social_media": {
|
||||
"description": "Obtenez des réponses de vos contacts sur divers réseaux sociaux.",
|
||||
@@ -1789,7 +1765,6 @@
|
||||
"configure_alerts": "Configurer les alertes",
|
||||
"congrats": "Félicitations ! Votre enquête est en ligne.",
|
||||
"connect_your_website_or_app_with_formbricks_to_get_started": "Connectez votre site web ou votre application à Formbricks pour commencer.",
|
||||
"current_count": "Nombre actuel",
|
||||
"custom_range": "Plage personnalisée...",
|
||||
"delete_all_existing_responses_and_displays": "Supprimer toutes les réponses existantes et les affichages",
|
||||
"download_qr_code": "Télécharger code QR",
|
||||
@@ -1843,7 +1818,6 @@
|
||||
"last_month": "Le mois dernier",
|
||||
"last_quarter": "dernier trimestre",
|
||||
"last_year": "l'année dernière",
|
||||
"limit": "Limite",
|
||||
"no_responses_found": "Aucune réponse trouvée",
|
||||
"other_values_found": "D'autres valeurs trouvées",
|
||||
"overall": "Globalement",
|
||||
@@ -1852,8 +1826,6 @@
|
||||
"qr_code_download_failed": "Échec du téléchargement du code QR",
|
||||
"qr_code_download_with_start_soon": "Le téléchargement du code QR débutera bientôt",
|
||||
"qr_code_generation_failed": "\"Un problème est survenu lors du chargement du code QR du sondage. Veuillez réessayer.\"",
|
||||
"quotas_completed": "Quotas terminés",
|
||||
"quotas_completed_tooltip": "Le nombre de quotas complétés par les répondants.",
|
||||
"reset_survey": "Réinitialiser l'enquête",
|
||||
"reset_survey_warning": "Réinitialiser un sondage supprime toutes les réponses et les affichages associés à ce sondage. Cela ne peut pas être annulé.",
|
||||
"selected_responses_csv": "Réponses sélectionnées (CSV)",
|
||||
@@ -1869,7 +1841,7 @@
|
||||
"this_quarter": "Ce trimestre",
|
||||
"this_year": "Cette année",
|
||||
"time_to_complete": "Temps à compléter",
|
||||
"ttc_tooltip": "Temps moyen pour compléter la question.",
|
||||
"ttc_tooltip": "Temps moyen pour compléter l'enquête.",
|
||||
"unknown_question_type": "Type de question inconnu",
|
||||
"use_personal_links": "Utilisez des liens personnels",
|
||||
"waiting_for_response": "En attente d'une réponse \uD83E\uDDD8♂️",
|
||||
@@ -1880,6 +1852,7 @@
|
||||
"survey_deleted_successfully": "Enquête supprimée avec succès !",
|
||||
"survey_duplicated_successfully": "Enquête dupliquée avec succès.",
|
||||
"survey_duplication_error": "Échec de la duplication de l'enquête.",
|
||||
"survey_status_tooltip": "Pour mettre à jour le statut de l'enquête, mettez à jour le calendrier et fermez les paramètres dans les options de réponse à l'enquête.",
|
||||
"templates": {
|
||||
"all_channels": "Tous les canaux",
|
||||
"all_industries": "Tous les secteurs",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+65
-92
@@ -118,7 +118,6 @@
|
||||
"account_settings": "Configurações da conta",
|
||||
"action": "Ação",
|
||||
"actions": "Ações",
|
||||
"actions_description": "Ações de Código e Sem Código são usadas para acionar interceptar pesquisas dentro de apps & em sites.",
|
||||
"active_surveys": "Pesquisas ativas",
|
||||
"activity": "Atividade",
|
||||
"add": "Adicionar",
|
||||
@@ -126,7 +125,6 @@
|
||||
"add_filter": "Adicionar filtro",
|
||||
"add_logo": "Adicionar logo",
|
||||
"add_member": "Adicionar membro",
|
||||
"add_new_project": "Adicionar novo projeto",
|
||||
"add_project": "Adicionar projeto",
|
||||
"add_to_team": "Adicionar à equipe",
|
||||
"all": "Todos",
|
||||
@@ -143,6 +141,7 @@
|
||||
"apply_filters": "Aplicar filtros",
|
||||
"are_you_sure": "Certeza?",
|
||||
"attributes": "atributos",
|
||||
"avatar": "Avatar",
|
||||
"back": "Voltar",
|
||||
"billing": "Faturamento",
|
||||
"booked": "Reservado",
|
||||
@@ -151,9 +150,6 @@
|
||||
"cancel": "Cancelar",
|
||||
"centered_modal": "Modal Centralizado",
|
||||
"choices": "Escolhas",
|
||||
"choose_environment": "Escolher ambiente",
|
||||
"choose_organization": "Escolher organização",
|
||||
"choose_project": "Escolher projeto",
|
||||
"clear_all": "Limpar tudo",
|
||||
"clear_filters": "Limpar filtros",
|
||||
"clear_selection": "Limpar seleção",
|
||||
@@ -169,14 +165,11 @@
|
||||
"connect_formbricks": "Conectar Formbricks",
|
||||
"connected": "conectado",
|
||||
"contacts": "Contatos",
|
||||
"continue": "Continuar",
|
||||
"copied": "Copiado",
|
||||
"copied_to_clipboard": "Copiado para a área de transferência",
|
||||
"copy": "Copiar",
|
||||
"copy_code": "Copiar código",
|
||||
"copy_link": "Copiar Link",
|
||||
"count_contacts": "{value, plural, one {# contato} other {# contatos} }",
|
||||
"count_responses": "{value, plural, other {# respostas}}",
|
||||
"create_new_organization": "Criar nova organização",
|
||||
"create_project": "Criar projeto",
|
||||
"create_segment": "Criar segmento",
|
||||
@@ -185,6 +178,7 @@
|
||||
"created_at": "Data de criação",
|
||||
"created_by": "Criado por",
|
||||
"customer_success": "Sucesso do Cliente",
|
||||
"danger_zone": "Zona de Perigo",
|
||||
"dark_overlay": "sobreposição escura",
|
||||
"date": "Encontro",
|
||||
"default": "Padrão",
|
||||
@@ -204,15 +198,10 @@
|
||||
"e_commerce": "comércio eletrônico",
|
||||
"edit": "Editar",
|
||||
"email": "Email",
|
||||
"ending_card": "Cartão de encerramento",
|
||||
"enterprise_license": "Licença Empresarial",
|
||||
"environment_not_found": "Ambiente não encontrado",
|
||||
"environment_notice": "Você está atualmente no ambiente {environment}.",
|
||||
"error": "Erro",
|
||||
"error_component_description": "Esse recurso não existe ou você não tem permissão para acessá-lo.",
|
||||
"error_component_title": "Erro ao carregar recursos",
|
||||
"error_rate_limit_description": "Número máximo de requisições atingido. Por favor, tente novamente mais tarde.",
|
||||
"error_rate_limit_title": "Limite de Taxa Excedido",
|
||||
"expand_rows": "Expandir linhas",
|
||||
"finish": "Terminar",
|
||||
"follow_these": "Siga esses",
|
||||
@@ -225,6 +214,7 @@
|
||||
"hidden": "Escondido",
|
||||
"hidden_field": "Campo oculto",
|
||||
"hidden_fields": "Campos ocultos",
|
||||
"hide": "esconder",
|
||||
"hide_column": "Ocultar coluna",
|
||||
"image": "imagem",
|
||||
"images": "Imagens",
|
||||
@@ -244,9 +234,11 @@
|
||||
"label": "Etiqueta",
|
||||
"language": "Língua",
|
||||
"learn_more": "Saiba mais",
|
||||
"license": "Licença",
|
||||
"light_overlay": "sobreposição leve",
|
||||
"limits_reached": "Limites Atingidos",
|
||||
"link": "link",
|
||||
"link_and_email": "Link & E-mail",
|
||||
"link_survey": "Pesquisa de Link",
|
||||
"link_surveys": "Link de Pesquisas",
|
||||
"load_more": "Carregar mais",
|
||||
@@ -262,20 +254,18 @@
|
||||
"membership_not_found": "Assinatura não encontrada",
|
||||
"metadata": "metadados",
|
||||
"minimum": "Mínimo",
|
||||
"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!",
|
||||
"mobile_overlay_text": "O Formbricks não está disponível para dispositivos com resoluções menores.",
|
||||
"move_down": "Descer",
|
||||
"move_up": "Subir",
|
||||
"multiple_languages": "Vários idiomas",
|
||||
"name": "Nome",
|
||||
"new": "Novo",
|
||||
"new_survey": "Nova Pesquisa",
|
||||
"new_version_available": "Formbricks {version} chegou. Atualize agora!",
|
||||
"next": "Próximo",
|
||||
"no_background_image_found": "Imagem de fundo não encontrada.",
|
||||
"no_code": "Sem código",
|
||||
"no_files_uploaded": "Nenhum arquivo foi enviado",
|
||||
"no_quotas_found": "Nenhuma cota encontrada",
|
||||
"no_result_found": "Nenhum resultado encontrado",
|
||||
"no_results": "Nenhum resultado",
|
||||
"no_surveys_found": "Não foram encontradas pesquisas.",
|
||||
@@ -283,6 +273,7 @@
|
||||
"not_authorized": "Não autorizado",
|
||||
"not_connected": "Desconectado",
|
||||
"note": "Nota",
|
||||
"notes": "Anotações",
|
||||
"notifications": "Notificações",
|
||||
"number": "Número",
|
||||
"off": "desligado",
|
||||
@@ -295,7 +286,6 @@
|
||||
"organization": "organização",
|
||||
"organization_id": "ID da Organização",
|
||||
"organization_not_found": "Organização não encontrada",
|
||||
"organization_settings": "Configurações da Organização",
|
||||
"organization_teams_not_found": "Equipes da organização não encontradas",
|
||||
"other": "outro",
|
||||
"others": "Outros",
|
||||
@@ -319,7 +309,6 @@
|
||||
"product_manager": "Gerente de Produto",
|
||||
"profile": "Perfil",
|
||||
"profile_id": "ID de Perfil",
|
||||
"progress": "Progresso",
|
||||
"project_configuration": "Configuração do Projeto",
|
||||
"project_creation_description": "Organize pesquisas em projetos para melhor controle de acesso.",
|
||||
"project_id": "ID do Projeto",
|
||||
@@ -331,9 +320,6 @@
|
||||
"question": "Pergunta",
|
||||
"question_id": "ID da Pergunta",
|
||||
"questions": "Perguntas",
|
||||
"quota": "Cota",
|
||||
"quotas": "Cotas",
|
||||
"quotas_description": "Limite a quantidade de respostas que você recebe de participantes que atendem a determinados critérios.",
|
||||
"read_docs": "Ler Documentação",
|
||||
"recipients": "Destinatários",
|
||||
"remove": "remover",
|
||||
@@ -351,7 +337,7 @@
|
||||
"sales": "vendas",
|
||||
"save": "Salvar",
|
||||
"save_changes": "Salvar alterações",
|
||||
"saving": "Salvando",
|
||||
"scheduled": "agendado",
|
||||
"search": "Buscar",
|
||||
"security": "Segurança",
|
||||
"segment": "segmento",
|
||||
@@ -364,6 +350,7 @@
|
||||
"selected_questions": "Perguntas selecionadas",
|
||||
"selection": "seleção",
|
||||
"selections": "seleções",
|
||||
"send": "Enviar",
|
||||
"send_test_email": "Enviar e-mail de teste",
|
||||
"session_not_found": "Sessão não encontrada",
|
||||
"settings": "Configurações",
|
||||
@@ -381,7 +368,6 @@
|
||||
"start_free_trial": "Iniciar Teste Grátis",
|
||||
"status": "status",
|
||||
"step_by_step_manual": "Manual passo a passo",
|
||||
"storage_not_configured": "Armazenamento de arquivos não configurado, uploads provavelmente falharão",
|
||||
"styling": "Estilização",
|
||||
"submit": "Enviar",
|
||||
"summary": "Resumo",
|
||||
@@ -392,8 +378,10 @@
|
||||
"survey_live": "Pesquisa ao vivo",
|
||||
"survey_not_found": "Pesquisa não encontrada",
|
||||
"survey_paused": "Pesquisa pausada.",
|
||||
"survey_scheduled": "Pesquisa agendada.",
|
||||
"survey_type": "Tipo de Pesquisa",
|
||||
"surveys": "Pesquisas",
|
||||
"switch_organization": "Mudar organização",
|
||||
"switch_to": "Mudar para {environment}",
|
||||
"table_items_deleted_successfully": "{type}s deletados com sucesso",
|
||||
"table_settings": "Arrumação da mesa",
|
||||
@@ -591,7 +579,8 @@
|
||||
"contacts_table_refresh": "Atualizar contatos",
|
||||
"contacts_table_refresh_success": "Contatos atualizados com sucesso",
|
||||
"delete_contact_confirmation": "Isso irá apagar todas as respostas da pesquisa e atributos de contato associados a este contato. Qualquer direcionamento e personalização baseados nos dados deste contato serão perdidos.",
|
||||
"delete_contact_confirmation_with_quotas": "{value, plural, other {Isso irá apagar todas as respostas da pesquisa e atributos de contato associados a este contato. Qualquer direcionamento e personalização baseados nos dados deste contato serão perdidos. Se este contato tiver respostas que contam para cotas da pesquisa, as contagens das cotas serão reduzidas, mas os limites das cotas permanecerão inalterados.}}",
|
||||
"first_name": "Primeiro Nome",
|
||||
"last_name": "Sobrenome",
|
||||
"no_responses_found": "Nenhuma resposta encontrada",
|
||||
"not_provided": "Não fornecido",
|
||||
"search_contact": "Buscar contato",
|
||||
@@ -752,6 +741,7 @@
|
||||
},
|
||||
"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",
|
||||
@@ -771,21 +761,43 @@
|
||||
"unable_to_delete_api_key": "Não foi possível deletar a Chave API"
|
||||
},
|
||||
"app-connection": {
|
||||
"api_host_description": "Essa é a URL do seu backend do Formbricks.",
|
||||
"app_connection": "Conexão do App",
|
||||
"app_connection_description": "Conecte seu app ao Formbricks.",
|
||||
"cache_update_delay_description": "Quando você faz atualizações em pesquisas, contatos, ações ou outros dados, pode levar até 5 minutos para que essas mudanças apareçam no seu app local rodando o SDK do Formbricks. Esse atraso é devido a uma limitação no nosso sistema de cache atual. Estamos ativamente retrabalhando o cache e planejamos lançar uma correção no Formbricks 4.0.",
|
||||
"cache_update_delay_title": "As mudanças serão refletidas após 5 minutos devido ao cache",
|
||||
"check_out_the_docs": "Confere a documentação.",
|
||||
"dive_into_the_docs": "Mergulha na documentação.",
|
||||
"does_your_widget_work": "Seu widget funciona?",
|
||||
"environment_id": "Seu Id do Ambiente",
|
||||
"environment_id_description": "Este ID identifica exclusivamente este ambiente do Formbricks.",
|
||||
"environment_id_description_with_environment_id": "Usado para identificar o ambiente correto: {environmentId} é o seu.",
|
||||
"formbricks_sdk": "SDK do Formbricks",
|
||||
"formbricks_sdk_connected": "O SDK do Formbricks está conectado",
|
||||
"formbricks_sdk_not_connected": "O SDK do Formbricks ainda não está conectado.",
|
||||
"formbricks_sdk_not_connected_description": "Conecte seu site ou app com o Formbricks",
|
||||
"have_a_problem": "Tá com problema?",
|
||||
"how_to_setup": "Como configurar",
|
||||
"how_to_setup_description": "Siga esses passos para configurar o widget do Formbricks no seu app.",
|
||||
"identifying_your_users": "identificando seus usuários",
|
||||
"if_you_are_planning_to": "Se você está planejando",
|
||||
"insert_this_code_into_the": "Insere esse código no",
|
||||
"need_a_more_detailed_setup_guide_for": "Preciso de um guia de configuração mais detalhado para",
|
||||
"not_working": "Não tá funcionando?",
|
||||
"open_an_issue_on_github": "Abre uma issue no GitHub",
|
||||
"open_the_browser_console_to_see_the_logs": "Abre o console do navegador pra ver os logs.",
|
||||
"receiving_data": "Recebendo dados \uD83D\uDC83\uD83D\uDD7A",
|
||||
"recheck": "Verificar novamente",
|
||||
"setup_alert_description": "Siga este tutorial passo a passo para conectar seu app ou site em menos de 5 minutos.",
|
||||
"setup_alert_title": "Como conectar"
|
||||
"scroll_to_the_top": "Rola pra cima!",
|
||||
"step_1": "Passo 1: Instale com pnpm, npm ou yarn",
|
||||
"step_2": "Passo 2: Iniciar widget",
|
||||
"step_2_description": "Importe o Formbricks e inicialize o widget no seu Componente (por exemplo, App.tsx):",
|
||||
"step_3": "Passo 3: Modo de depuração",
|
||||
"switch_on_the_debug_mode_by_appending": "Ative o modo de depuração adicionando",
|
||||
"tag_of_your_app": "etiqueta do seu app",
|
||||
"to_the_url_where_you_load_the": "para a URL onde você carrega o",
|
||||
"want_to_learn_how_to_add_user_attributes": "Quer aprender como adicionar atributos de usuário, eventos personalizados e mais?",
|
||||
"you_are_done": "Você terminou \uD83C\uDF89",
|
||||
"you_can_set_the_user_id_with": "você pode definir o id do usuário com",
|
||||
"your_app_now_communicates_with_formbricks": "Seu app agora se comunica com o Formbricks - enviando eventos e carregando pesquisas automaticamente!"
|
||||
},
|
||||
"general": {
|
||||
"cannot_delete_only_project": "Esse é seu único projeto, não pode ser deletado. Crie um novo projeto primeiro.",
|
||||
@@ -977,6 +989,7 @@
|
||||
"free": "grátis",
|
||||
"free_description": "Pesquisas ilimitadas, membros da equipe e mais.",
|
||||
"get_2_months_free": "Ganhe 2 meses grátis",
|
||||
"get_in_touch": "Entre em contato",
|
||||
"hosted_in_frankfurt": "Hospedado em Frankfurt",
|
||||
"ios_android_sdks": "SDK para iOS e Android para pesquisas móveis",
|
||||
"link_surveys": "Link de Pesquisas (Compartilhável)",
|
||||
@@ -1098,7 +1111,9 @@
|
||||
},
|
||||
"profile": {
|
||||
"account_deletion_consequences_warning": "Consequências da exclusão da conta",
|
||||
"avatar_update_failed": "Falha ao atualizar o avatar. Por favor, tente novamente.",
|
||||
"backup_code": "Código de Backup",
|
||||
"change_image": "Mudar imagem",
|
||||
"confirm_delete_account": "Apague sua conta com todas as suas informações pessoais e dados",
|
||||
"confirm_delete_my_account": "Excluir Minha Conta",
|
||||
"confirm_your_current_password_to_get_started": "Confirme sua senha atual para começar.",
|
||||
@@ -1109,13 +1124,17 @@
|
||||
"email_change_initiated": "Sua solicitação de alteração de e-mail foi iniciada.",
|
||||
"enable_two_factor_authentication": "Ativar autenticação de dois fatores",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Digite o código do seu app autenticador abaixo.",
|
||||
"file_size_must_be_less_than_10mb": "O tamanho do arquivo deve ser menor que 10MB.",
|
||||
"invalid_file_type": "Tipo de arquivo inválido. Só são permitidos arquivos JPEG, PNG e WEBP.",
|
||||
"lost_access": "Perdi o acesso",
|
||||
"or_enter_the_following_code_manually": "Ou insira o seguinte código manualmente:",
|
||||
"organization_identification": "Ajude sua organização a te identificar no Formbricks",
|
||||
"organizations_delete_message": "Você é o único dono dessas organizações, então elas <b>também serão apagadas.</b>",
|
||||
"permanent_removal_of_all_of_your_personal_information_and_data": "Remoção permanente de todas as suas informações e dados pessoais",
|
||||
"personal_information": "Informações pessoais",
|
||||
"please_enter_email_to_confirm_account_deletion": "Por favor, insira {email} no campo abaixo para confirmar a exclusão definitiva da sua conta:",
|
||||
"profile_updated_successfully": "Seu perfil foi atualizado com sucesso",
|
||||
"remove_image": "Remover imagem",
|
||||
"save_the_following_backup_codes_in_a_safe_place": "Guarde os seguintes códigos de backup em um lugar seguro.",
|
||||
"scan_the_qr_code_below_with_your_authenticator_app": "Escaneie o código QR abaixo com seu app autenticador.",
|
||||
"security_description": "Gerencie sua senha e outras configurações de segurança como a autenticação de dois fatores (2FA).",
|
||||
@@ -1125,8 +1144,10 @@
|
||||
"two_factor_code": "Código de Dois Fatores",
|
||||
"unlock_two_factor_authentication": "Desbloqueia a autenticação de dois fatores com um plano melhor",
|
||||
"update_personal_info": "Atualize suas informações pessoais",
|
||||
"upload_image": "Enviar imagem",
|
||||
"warning_cannot_delete_account": "Você é o único dono desta organização. Transfere a propriedade para outra pessoa primeiro.",
|
||||
"warning_cannot_undo": "Isso não pode ser desfeito"
|
||||
"warning_cannot_undo": "Isso não pode ser desfeito",
|
||||
"you_must_select_a_file": "Você tem que selecionar um arquivo."
|
||||
},
|
||||
"teams": {
|
||||
"add_members_description": "Adicione membros à equipe e determine sua função.",
|
||||
@@ -1238,7 +1259,9 @@
|
||||
"automatically_close_survey_after": "Fechar pesquisa automaticamente após",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Fechar automaticamente a pesquisa depois de um certo número de respostas.",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Feche automaticamente a pesquisa se o usuário não responder depois de alguns segundos.",
|
||||
"automatically_closes_the_survey_at_the_beginning_of_the_day_utc": "Fecha automaticamente a pesquisa no começo do dia (UTC).",
|
||||
"automatically_mark_the_survey_as_complete_after": "Marcar automaticamente a pesquisa como concluída após",
|
||||
"automatically_release_the_survey_at_the_beginning_of_the_day_utc": "Liberar automaticamente a pesquisa no começo do dia (UTC).",
|
||||
"back_button_label": "Voltar",
|
||||
"background_styling": "Estilo de Fundo",
|
||||
"brand_color": "Cor da marca",
|
||||
@@ -1286,13 +1309,14 @@
|
||||
"choose_the_actions_which_trigger_the_survey": "Escolha as ações que disparam a pesquisa.",
|
||||
"choose_where_to_run_the_survey": "Escolha onde realizar a pesquisa.",
|
||||
"city": "cidade",
|
||||
"close_survey_on_date": "Fechar pesquisa na data",
|
||||
"close_survey_on_response_limit": "Fechar pesquisa ao atingir limite de respostas",
|
||||
"color": "cor",
|
||||
"column_used_in_logic_error": "Esta coluna é usada na lógica da pergunta {questionIndex}. Por favor, remova-a da lógica primeiro.",
|
||||
"columns": "colunas",
|
||||
"company": "empresa",
|
||||
"company_logo": "Logo da empresa",
|
||||
"completed_responses": "Respostas concluídas.",
|
||||
"completed_responses": "respostas parciais ou completas.",
|
||||
"concat": "Concatenar +",
|
||||
"conditional_logic": "Lógica Condicional",
|
||||
"confirm_default_language": "Confirmar idioma padrão",
|
||||
@@ -1332,7 +1356,6 @@
|
||||
"end_screen_card": "cartão de tela final",
|
||||
"ending_card": "Cartão de encerramento",
|
||||
"ending_card_used_in_logic": "Esse cartão de encerramento é usado na lógica da pergunta {questionIndex}.",
|
||||
"ending_used_in_quota": "Este final está sendo usado na cota \"{quotaName}\"",
|
||||
"ends_with": "Termina com",
|
||||
"equals": "Igual",
|
||||
"equals_one_of": "É igual a um de",
|
||||
@@ -1343,7 +1366,6 @@
|
||||
"fallback_for": "Alternativa para",
|
||||
"fallback_missing": "Faltando alternativa",
|
||||
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} é usado na lógica da pergunta {questionIndex}. Por favor, remova-o da lógica primeiro.",
|
||||
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "Campo oculto \"{fieldId}\" está sendo usado na cota \"{quotaName}\"",
|
||||
"field_name_eg_score_price": "Nome do campo, por exemplo, pontuação, preço",
|
||||
"first_name": "Primeiro Nome",
|
||||
"five_points_recommended": "5 pontos (recomendado)",
|
||||
@@ -1375,9 +1397,8 @@
|
||||
"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": "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_action_to_warning": "Nenhum campo de e-mail detectado na pesquisa",
|
||||
"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",
|
||||
@@ -1387,9 +1408,8 @@
|
||||
"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": "Por favor, selecione pelo menos um encerramento ou altere o tipo de gatilho",
|
||||
"follow_ups_modal_trigger_type_ending_warning": "Nenhum final encontrado na pesquisa!",
|
||||
"follow_ups_modal_trigger_type_response": "Respondente completa a pesquisa",
|
||||
"follow_ups_modal_updated_successfull_toast": "Acompanhamento atualizado e será salvo assim que você salvar a pesquisa.",
|
||||
"follow_ups_new": "Novo acompanhamento",
|
||||
"follow_ups_upgrade_button_text": "Atualize para habilitar os Acompanhamentos",
|
||||
"form_styling": "Estilização de Formulários",
|
||||
@@ -1490,38 +1510,6 @@
|
||||
"question_duplicated": "Pergunta duplicada.",
|
||||
"question_id_updated": "ID da pergunta atualizado",
|
||||
"question_used_in_logic": "Essa pergunta é usada na lógica da pergunta {questionIndex}.",
|
||||
"question_used_in_quota": "Esta questão está sendo usada na cota \"{quotaName}\"",
|
||||
"quotas": {
|
||||
"add_quota": "Adicionar cota",
|
||||
"change_quota_for_public_survey": "Alterar cota para pesquisa pública?",
|
||||
"confirm_quota_changes": "Confirmar Alterações nas Cotas",
|
||||
"confirm_quota_changes_body": "Você tem alterações não salvas na sua cota. Quer salvar antes de sair?",
|
||||
"continue_survey_normally": "Continuar pesquisa normalmente",
|
||||
"count_partial_submissions": "Contar respostas parciais",
|
||||
"count_partial_submissions_description": "Incluir respondentes que atendem aos critérios de cota, mas não completaram a pesquisa",
|
||||
"create_quota_for_public_survey": "Criar cota para pesquisa pública?",
|
||||
"create_quota_for_public_survey_description": "Apenas respostas futuras serão filtradas para a cota",
|
||||
"create_quota_for_public_survey_text": "Esta pesquisa já é pública. Respostas existentes não serão consideradas para a nova cota.",
|
||||
"delete_quota_confirmation_text": "Isso irá apagar permanentemente a cota {quotaName}.",
|
||||
"duplicate_quota": "Duplicar cota",
|
||||
"edit_quota": "Editar cota",
|
||||
"end_survey_for_matching_participants": "Encerrar a pesquisa para participantes correspondentes",
|
||||
"inclusion_criteria": "Critérios de Inclusão",
|
||||
"limit_must_be_greater_than_or_equal_to_the_number_of_responses": "{value, plural, other {O limite deve ser maior ou igual ao número de respostas}}",
|
||||
"limited_to_x_responses": "Limitado a {limit}",
|
||||
"new_quota": "Nova Cota",
|
||||
"quota_created_successfull_toast": "Cota criada com sucesso",
|
||||
"quota_deleted_successfull_toast": "Cota deletada com sucesso",
|
||||
"quota_duplicated_successfull_toast": "Cota duplicada com sucesso",
|
||||
"quota_name_placeholder": "ex.: Participantes de 18-25 anos",
|
||||
"quota_updated_successfull_toast": "Cota atualizada com sucesso",
|
||||
"response_limit": "Limites",
|
||||
"save_changes_confirmation_body": "Quaisquer alterações nos critérios de inclusão afetam apenas respostas futuras. \nRecomendamos duplicar uma cota existente ou criar uma nova.",
|
||||
"save_changes_confirmation_text": "Respostas existentes permanecem na cota",
|
||||
"select_ending_card": "Selecione cartão de final",
|
||||
"upgrade_prompt_title": "Use cotas com um plano superior",
|
||||
"when_quota_has_been_reached": "Quando a cota for atingida"
|
||||
},
|
||||
"randomize_all": "Randomizar tudo",
|
||||
"randomize_all_except_last": "Randomizar tudo, exceto o último",
|
||||
"range": "alcance",
|
||||
@@ -1529,6 +1517,7 @@
|
||||
"redirect_thank_you_card": "Redirecionar cartão de agradecimento",
|
||||
"redirect_to_url": "Redirecionar para URL",
|
||||
"redirect_to_url_not_available_on_free_plan": "Redirecionar para URL não está disponível no plano gratuito",
|
||||
"release_survey_on_date": "Lançar pesquisa na data",
|
||||
"remove_description": "Remover descrição",
|
||||
"remove_translations": "Remover traduções",
|
||||
"require_answer": "Preciso de Resposta",
|
||||
@@ -1554,7 +1543,6 @@
|
||||
"send_survey_to_audience_who_match": "Enviar pesquisa para o público que corresponde...",
|
||||
"send_your_respondents_to_a_page_of_your_choice": "Envie seus respondentes para uma página de sua escolha.",
|
||||
"set_the_global_placement_in_the_look_feel_settings": "Defina o posicionamento global nas configurações de Aparência.",
|
||||
"settings_saved_successfully": "Configurações salvas com sucesso",
|
||||
"seven_points": "7 pontos",
|
||||
"show_advanced_settings": "Mostrar configurações avançadas",
|
||||
"show_button": "Mostrar Botão",
|
||||
@@ -1615,7 +1603,6 @@
|
||||
"url_not_supported": "URL não suportada",
|
||||
"use_with_caution": "Use com cuidado",
|
||||
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} está sendo usado na lógica da pergunta {questionIndex}. Por favor, remova-o da lógica primeiro.",
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variável \"{variableName}\" está sendo usada na cota \"{quotaName}\"",
|
||||
"variable_name_is_already_taken_please_choose_another": "O nome da variável já está em uso, por favor escolha outro.",
|
||||
"variable_name_must_start_with_a_letter": "O nome da variável deve começar com uma letra.",
|
||||
"verify_email_before_submission": "Verifique o e-mail antes de enviar",
|
||||
@@ -1648,16 +1635,16 @@
|
||||
"responses": {
|
||||
"address_line_1": "Endereço Linha 1",
|
||||
"address_line_2": "Complemento",
|
||||
"an_error_occurred_creating_a_new_note": "Deu erro ao criar uma nova nota",
|
||||
"an_error_occurred_deleting_the_tag": "Ocorreu um erro ao deletar a tag",
|
||||
"an_error_occurred_resolving_a_note": "Ocorreu um erro ao resolver uma nota",
|
||||
"an_error_occurred_updating_a_note": "Ocorreu um erro ao atualizar uma nota",
|
||||
"browser": "navegador",
|
||||
"bulk_delete_response_quotas": "As respostas fazem parte das cotas desta pesquisa. Como você quer gerenciar as cotas?",
|
||||
"city": "Cidade",
|
||||
"company": "empresa",
|
||||
"completed": "Concluído ✅",
|
||||
"country": "País",
|
||||
"decrement_quotas": "Diminua todos os limites de cotas, incluindo esta resposta",
|
||||
"delete_response_confirmation": "Isso irá excluir a resposta da pesquisa, incluindo todas as respostas, etiquetas, documentos anexados e metadados da resposta.",
|
||||
"delete_response_quotas": "A resposta faz parte das cotas desta pesquisa. Como você quer gerenciar as cotas?",
|
||||
"delete_response_confirmation": "Isso excluirá a resposta da pesquisa, incluindo todas as respostas, notas, etiquetas, documentos anexados e metadados da resposta.",
|
||||
"device": "dispositivo",
|
||||
"device_info": "Informações do dispositivo",
|
||||
"email": "Email",
|
||||
@@ -1669,6 +1656,7 @@
|
||||
"os": "sistema operacional",
|
||||
"person_attributes": "Atributos da pessoa",
|
||||
"phone": "Celular",
|
||||
"resolve": "resolver",
|
||||
"respondent_skipped_questions": "Respondente pulou essas perguntas.",
|
||||
"response_deleted_successfully": "Resposta deletada com sucesso.",
|
||||
"single_use_id": "ID de Uso Único",
|
||||
@@ -1725,17 +1713,6 @@
|
||||
"embed_mode_description": "Incorpore sua pesquisa com um design minimalista, sem preenchimento e fundo.",
|
||||
"nav_title": "Incorporar no site"
|
||||
},
|
||||
"link_settings": {
|
||||
"description": "Dê aos links um título, descrição e imagem personalizados para compartilhamento público.",
|
||||
"language_help_text": "Os metadados são carregados com base no valor `lang` na URL.",
|
||||
"link_description": "Descrição do link",
|
||||
"link_description_description": "\"Descrições entre 55-200 caracteres têm um melhor desempenho.\"",
|
||||
"link_title": "Título do link",
|
||||
"link_title_description": "Títulos curtos têm melhor desempenho como Meta Títulos.",
|
||||
"preview_image": "Imagem de prévia",
|
||||
"preview_image_description": "Imagens em paisagem com tamanhos de arquivo pequenos (<4MB) têm o melhor desempenho.",
|
||||
"title": "Configurações de link"
|
||||
},
|
||||
"personal_links": {
|
||||
"create_and_manage_segments": "Crie e gerencie seus Segmentos em Contatos > Segmentos",
|
||||
"description": "Gerar links pessoais para um segmento e associar respostas de pesquisa a cada contato.",
|
||||
@@ -1767,7 +1744,6 @@
|
||||
"send_preview": "Enviar prévia",
|
||||
"send_preview_email": "Enviar prévia de e-mail"
|
||||
},
|
||||
"share_settings_title": "Compartilhar configurações",
|
||||
"share_view_title": "Compartilhar via",
|
||||
"social_media": {
|
||||
"description": "Obtenha respostas de seus contatos em várias redes sociais.",
|
||||
@@ -1789,7 +1765,6 @@
|
||||
"configure_alerts": "Configurar alertas",
|
||||
"congrats": "Parabéns! Sua pesquisa está no ar.",
|
||||
"connect_your_website_or_app_with_formbricks_to_get_started": "Conecte seu site ou app com o Formbricks para começar.",
|
||||
"current_count": "Contagem Atual",
|
||||
"custom_range": "Intervalo personalizado...",
|
||||
"delete_all_existing_responses_and_displays": "Excluir todas as respostas e exibições existentes",
|
||||
"download_qr_code": "baixar código QR",
|
||||
@@ -1843,7 +1818,6 @@
|
||||
"last_month": "Último mês",
|
||||
"last_quarter": "Último trimestre",
|
||||
"last_year": "Último ano",
|
||||
"limit": "Limite",
|
||||
"no_responses_found": "Nenhuma resposta encontrada",
|
||||
"other_values_found": "Outros valores encontrados",
|
||||
"overall": "No geral",
|
||||
@@ -1852,8 +1826,6 @@
|
||||
"qr_code_download_failed": "falha no download do código QR",
|
||||
"qr_code_download_with_start_soon": "O download do código QR começará em breve",
|
||||
"qr_code_generation_failed": "Houve um problema ao carregar o Código QR do questionário. Por favor, tente novamente.",
|
||||
"quotas_completed": "Cotas concluídas",
|
||||
"quotas_completed_tooltip": "Número de cotas preenchidas pelos respondentes.",
|
||||
"reset_survey": "Redefinir pesquisa",
|
||||
"reset_survey_warning": "Redefinir uma pesquisa remove todas as respostas e exibições associadas a esta pesquisa. Isto não pode ser desfeito.",
|
||||
"selected_responses_csv": "Respostas selecionadas (CSV)",
|
||||
@@ -1869,7 +1841,7 @@
|
||||
"this_quarter": "Este trimestre",
|
||||
"this_year": "Este ano",
|
||||
"time_to_complete": "Tempo para Concluir",
|
||||
"ttc_tooltip": "Tempo médio para completar a pergunta.",
|
||||
"ttc_tooltip": "Tempo médio para completar a pesquisa.",
|
||||
"unknown_question_type": "Tipo de pergunta desconhecido",
|
||||
"use_personal_links": "Use links pessoais",
|
||||
"waiting_for_response": "Aguardando uma resposta \uD83E\uDDD8♂️",
|
||||
@@ -1880,6 +1852,7 @@
|
||||
"survey_deleted_successfully": "Pesquisa deletada com sucesso!",
|
||||
"survey_duplicated_successfully": "Pesquisa duplicada com sucesso.",
|
||||
"survey_duplication_error": "Falha ao duplicar a pesquisa.",
|
||||
"survey_status_tooltip": "Para atualizar o status da pesquisa, atualize o cronograma e feche a configuração nas opções de resposta da pesquisa.",
|
||||
"templates": {
|
||||
"all_channels": "Todos os canais",
|
||||
"all_industries": "Todas as indústrias",
|
||||
|
||||
+66
-93
@@ -118,7 +118,6 @@
|
||||
"account_settings": "Configurações da conta",
|
||||
"action": "Ação",
|
||||
"actions": "Ações",
|
||||
"actions_description": "Ações com Código e Sem Código são usadas para acionar inquéritos de intercepção dentro de apps e em websites.",
|
||||
"active_surveys": "Inquéritos ativos",
|
||||
"activity": "Atividade",
|
||||
"add": "Adicionar",
|
||||
@@ -126,7 +125,6 @@
|
||||
"add_filter": "Adicionar filtro",
|
||||
"add_logo": "Adicionar logótipo",
|
||||
"add_member": "Adicionar membro",
|
||||
"add_new_project": "Adicionar novo projeto",
|
||||
"add_project": "Adicionar projeto",
|
||||
"add_to_team": "Adicionar à equipa",
|
||||
"all": "Todos",
|
||||
@@ -143,6 +141,7 @@
|
||||
"apply_filters": "Aplicar filtros",
|
||||
"are_you_sure": "Tem a certeza?",
|
||||
"attributes": "Atributos",
|
||||
"avatar": "Avatar",
|
||||
"back": "Voltar",
|
||||
"billing": "Faturação",
|
||||
"booked": "Reservado",
|
||||
@@ -151,9 +150,6 @@
|
||||
"cancel": "Cancelar",
|
||||
"centered_modal": "Modal Centralizado",
|
||||
"choices": "Escolhas",
|
||||
"choose_environment": "Escolha o ambiente",
|
||||
"choose_organization": "Escolher organização",
|
||||
"choose_project": "Escolher projeto",
|
||||
"clear_all": "Limpar tudo",
|
||||
"clear_filters": "Limpar filtros",
|
||||
"clear_selection": "Limpar seleção",
|
||||
@@ -169,14 +165,11 @@
|
||||
"connect_formbricks": "Ligar Formbricks",
|
||||
"connected": "Conectado",
|
||||
"contacts": "Contactos",
|
||||
"continue": "Continuar",
|
||||
"copied": "Copiado",
|
||||
"copied_to_clipboard": "Copiado para a área de transferência",
|
||||
"copy": "Copiar",
|
||||
"copy_code": "Copiar código",
|
||||
"copy_link": "Copiar Link",
|
||||
"count_contacts": "{value, plural, one {# contacto} other {# contactos} }",
|
||||
"count_responses": "{value, plural, other {# respostas}}",
|
||||
"create_new_organization": "Criar nova organização",
|
||||
"create_project": "Criar projeto",
|
||||
"create_segment": "Criar segmento",
|
||||
@@ -185,6 +178,7 @@
|
||||
"created_at": "Criado em",
|
||||
"created_by": "Criado por",
|
||||
"customer_success": "Sucesso do Cliente",
|
||||
"danger_zone": "Zona de Perigo",
|
||||
"dark_overlay": "Sobreposição escura",
|
||||
"date": "Data",
|
||||
"default": "Padrão",
|
||||
@@ -204,15 +198,10 @@
|
||||
"e_commerce": "Comércio Eletrónico",
|
||||
"edit": "Editar",
|
||||
"email": "Email",
|
||||
"ending_card": "Cartão de encerramento",
|
||||
"enterprise_license": "Licença Enterprise",
|
||||
"environment_not_found": "Ambiente não encontrado",
|
||||
"environment_notice": "Está atualmente no ambiente {environment}.",
|
||||
"error": "Erro",
|
||||
"error_component_description": "Este recurso não existe ou não tem os direitos necessários para aceder a ele.",
|
||||
"error_component_title": "Erro ao carregar recursos",
|
||||
"error_rate_limit_description": "Número máximo de pedidos alcançado. Por favor, tente novamente mais tarde.",
|
||||
"error_rate_limit_title": "Limite de Taxa Excedido",
|
||||
"expand_rows": "Expandir linhas",
|
||||
"finish": "Concluir",
|
||||
"follow_these": "Siga estes",
|
||||
@@ -225,6 +214,7 @@
|
||||
"hidden": "Oculto",
|
||||
"hidden_field": "Campo oculto",
|
||||
"hidden_fields": "Campos ocultos",
|
||||
"hide": "Esconder",
|
||||
"hide_column": "Ocultar coluna",
|
||||
"image": "Imagem",
|
||||
"images": "Imagens",
|
||||
@@ -244,9 +234,11 @@
|
||||
"label": "Etiqueta",
|
||||
"language": "Idioma",
|
||||
"learn_more": "Saiba mais",
|
||||
"license": "Licença",
|
||||
"light_overlay": "Sobreposição leve",
|
||||
"limits_reached": "Limites Atingidos",
|
||||
"link": "Link",
|
||||
"link_and_email": "Link e Email",
|
||||
"link_survey": "Ligar Inquérito",
|
||||
"link_surveys": "Ligar Inquéritos",
|
||||
"load_more": "Carregar mais",
|
||||
@@ -262,20 +254,18 @@
|
||||
"membership_not_found": "Associação não encontrada",
|
||||
"metadata": "Metadados",
|
||||
"minimum": "Mínimo",
|
||||
"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!",
|
||||
"mobile_overlay_text": "O Formbricks não está disponível para dispositivos com resoluções menores.",
|
||||
"move_down": "Mover para baixo",
|
||||
"move_up": "Mover para cima",
|
||||
"multiple_languages": "Várias línguas",
|
||||
"name": "Nome",
|
||||
"new": "Novo",
|
||||
"new_survey": "Novo inquérito",
|
||||
"new_version_available": "Formbricks {version} está aqui. Atualize agora!",
|
||||
"next": "Seguinte",
|
||||
"no_background_image_found": "Nenhuma imagem de fundo encontrada.",
|
||||
"no_code": "Sem código",
|
||||
"no_files_uploaded": "Nenhum ficheiro foi carregado",
|
||||
"no_quotas_found": "Nenhum quota encontrado",
|
||||
"no_result_found": "Nenhum resultado encontrado",
|
||||
"no_results": "Nenhum resultado",
|
||||
"no_surveys_found": "Nenhum inquérito encontrado.",
|
||||
@@ -283,6 +273,7 @@
|
||||
"not_authorized": "Não autorizado",
|
||||
"not_connected": "Não Conectado",
|
||||
"note": "Nota",
|
||||
"notes": "Notas",
|
||||
"notifications": "Notificações",
|
||||
"number": "Número",
|
||||
"off": "Desligado",
|
||||
@@ -295,7 +286,6 @@
|
||||
"organization": "Organização",
|
||||
"organization_id": "ID da Organização",
|
||||
"organization_not_found": "Organização não encontrada",
|
||||
"organization_settings": "Configurações da Organização",
|
||||
"organization_teams_not_found": "Equipas da organização não encontradas",
|
||||
"other": "Outro",
|
||||
"others": "Outros",
|
||||
@@ -319,7 +309,6 @@
|
||||
"product_manager": "Gestor de Produto",
|
||||
"profile": "Perfil",
|
||||
"profile_id": "ID do Perfil",
|
||||
"progress": "Progresso",
|
||||
"project_configuration": "Configuração do Projeto",
|
||||
"project_creation_description": "Organize questionários em projetos para um melhor controlo de acesso.",
|
||||
"project_id": "ID do Projeto",
|
||||
@@ -331,9 +320,6 @@
|
||||
"question": "Pergunta",
|
||||
"question_id": "ID da pergunta",
|
||||
"questions": "Perguntas",
|
||||
"quota": "Quota",
|
||||
"quotas": "Quotas",
|
||||
"quotas_description": "Limitar a quantidade de respostas recebidas de participantes que atendem a certos critérios.",
|
||||
"read_docs": "Ler Documentos",
|
||||
"recipients": "Destinatários",
|
||||
"remove": "Remover",
|
||||
@@ -351,7 +337,7 @@
|
||||
"sales": "Vendas",
|
||||
"save": "Guardar",
|
||||
"save_changes": "Guardar alterações",
|
||||
"saving": "Guardando",
|
||||
"scheduled": "Agendado",
|
||||
"search": "Procurar",
|
||||
"security": "Segurança",
|
||||
"segment": "Segmento",
|
||||
@@ -364,6 +350,7 @@
|
||||
"selected_questions": "Perguntas selecionadas",
|
||||
"selection": "Seleção",
|
||||
"selections": "Seleções",
|
||||
"send": "Enviar",
|
||||
"send_test_email": "Enviar email de teste",
|
||||
"session_not_found": "Sessão não encontrada",
|
||||
"settings": "Configurações",
|
||||
@@ -381,7 +368,6 @@
|
||||
"start_free_trial": "Iniciar Teste Grátis",
|
||||
"status": "Estado",
|
||||
"step_by_step_manual": "Manual passo a passo",
|
||||
"storage_not_configured": "Armazenamento de ficheiros não configurado, uploads provavelmente falharão",
|
||||
"styling": "Estilo",
|
||||
"submit": "Submeter",
|
||||
"summary": "Resumo",
|
||||
@@ -392,8 +378,10 @@
|
||||
"survey_live": "Inquérito ao vivo",
|
||||
"survey_not_found": "Inquérito não encontrado",
|
||||
"survey_paused": "Inquérito pausado.",
|
||||
"survey_scheduled": "Inquérito agendado.",
|
||||
"survey_type": "Tipo de Inquérito",
|
||||
"surveys": "Inquéritos",
|
||||
"switch_organization": "Mudar de organização",
|
||||
"switch_to": "Mudar para {environment}",
|
||||
"table_items_deleted_successfully": "{type}s eliminados com sucesso",
|
||||
"table_settings": "Configurações da tabela",
|
||||
@@ -591,7 +579,8 @@
|
||||
"contacts_table_refresh": "Atualizar contactos",
|
||||
"contacts_table_refresh_success": "Contactos atualizados com sucesso",
|
||||
"delete_contact_confirmation": "Isto irá eliminar todas as respostas das pesquisas e os atributos de contato associados a este contato. Qualquer direcionamento e personalização baseados nos dados deste contato serão perdidos.",
|
||||
"delete_contact_confirmation_with_quotas": "{value, plural, other {Isto irá eliminar todas as respostas das pesquisas e os atributos de contacto associados a este contacto. Qualquer segmentação e personalização baseados nos dados deste contacto serão perdidos. Se este contacto tiver respostas que contribuam para as quotas das pesquisas, as contagens de quotas serão reduzidas, mas os limites das quotas permanecerão inalterados.}}",
|
||||
"first_name": "Primeiro Nome",
|
||||
"last_name": "Apelido",
|
||||
"no_responses_found": "Nenhuma resposta encontrada",
|
||||
"not_provided": "Não fornecido",
|
||||
"search_contact": "Procurar contacto",
|
||||
@@ -752,6 +741,7 @@
|
||||
},
|
||||
"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",
|
||||
@@ -771,21 +761,43 @@
|
||||
"unable_to_delete_api_key": "Não é possível eliminar a chave API"
|
||||
},
|
||||
"app-connection": {
|
||||
"api_host_description": "Este é o URL do seu backend Formbricks.",
|
||||
"app_connection": "Ligação de Aplicação",
|
||||
"app_connection_description": "Ligue a sua aplicação ao Formbricks",
|
||||
"cache_update_delay_description": "Quando fizer atualizações para inquéritos, contactos, ações ou outros dados, pode demorar até 5 minutos para que essas alterações apareçam na sua aplicação local a correr o SDK do Formbricks. Este atraso deve-se a uma limitação no nosso atual sistema de cache. Estamos a trabalhar ativamente na reformulação da cache e lançaremos uma correção no Formbricks 4.0.",
|
||||
"cache_update_delay_title": "As alterações serão refletidas após 5 minutos devido ao armazenamento em cache.",
|
||||
"environment_id": "O Seu ID de Ambiente",
|
||||
"check_out_the_docs": "Consulte a documentação.",
|
||||
"dive_into_the_docs": "Mergulhe na documentação.",
|
||||
"does_your_widget_work": "O seu widget funciona?",
|
||||
"environment_id": "O seu EnvironmentId",
|
||||
"environment_id_description": "Este id identifica de forma única este ambiente Formbricks.",
|
||||
"environment_id_description_with_environment_id": "Usado para identificar o ambiente correto: {environmentId} é o seu.",
|
||||
"formbricks_sdk": "SDK Formbricks",
|
||||
"formbricks_sdk_connected": "O SDK do Formbricks está conectado",
|
||||
"formbricks_sdk_not_connected": "O SDK do Formbricks ainda não está conectado",
|
||||
"formbricks_sdk_not_connected_description": "Ligue o seu website ou aplicação ao Formbricks",
|
||||
"have_a_problem": "Tem um problema?",
|
||||
"how_to_setup": "Como configurar",
|
||||
"how_to_setup_description": "Siga estes passos para configurar o widget Formbricks na sua aplicação.",
|
||||
"identifying_your_users": "identificar os seus utilizadores",
|
||||
"if_you_are_planning_to": "Se está a planear",
|
||||
"insert_this_code_into_the": "Insira este código no",
|
||||
"need_a_more_detailed_setup_guide_for": "Precisa de um guia de configuração mais detalhado para",
|
||||
"not_working": "Não está a funcionar?",
|
||||
"open_an_issue_on_github": "Abrir um problema no GitHub",
|
||||
"open_the_browser_console_to_see_the_logs": "Abra a consola do navegador para ver os registos.",
|
||||
"receiving_data": "A receber dados \uD83D\uDC83\uD83D\uDD7A",
|
||||
"recheck": "Verificar novamente",
|
||||
"setup_alert_description": "Siga este tutorial passo-a-passo para ligar a sua aplicação ou website em menos de 5 minutos",
|
||||
"setup_alert_title": "Como conectar"
|
||||
"scroll_to_the_top": "Rolar para o topo!",
|
||||
"step_1": "Passo 1: Instalar com pnpm, npm ou yarn",
|
||||
"step_2": "Passo 2: Inicializar widget",
|
||||
"step_2_description": "Importar Formbricks e inicializar o widget no seu Componente (por exemplo, App.tsx):",
|
||||
"step_3": "Passo 3: Modo de depuração",
|
||||
"switch_on_the_debug_mode_by_appending": "Ativar o modo de depuração adicionando",
|
||||
"tag_of_your_app": "tag da sua aplicação",
|
||||
"to_the_url_where_you_load_the": "para o URL onde carrega o",
|
||||
"want_to_learn_how_to_add_user_attributes": "Quer aprender a adicionar atributos de utilizador, eventos personalizados e mais?",
|
||||
"you_are_done": "Está concluído \uD83C\uDF89",
|
||||
"you_can_set_the_user_id_with": "pode definir o ID do utilizador com",
|
||||
"your_app_now_communicates_with_formbricks": "A sua aplicação agora comunica com o Formbricks - enviando eventos e carregando inquéritos automaticamente!"
|
||||
},
|
||||
"general": {
|
||||
"cannot_delete_only_project": "Este é o seu único projeto, não pode ser eliminado. Crie um novo projeto primeiro.",
|
||||
@@ -977,6 +989,7 @@
|
||||
"free": "Grátis",
|
||||
"free_description": "Inquéritos ilimitados, membros da equipa e mais.",
|
||||
"get_2_months_free": "Obtenha 2 meses grátis",
|
||||
"get_in_touch": "Entre em contacto",
|
||||
"hosted_in_frankfurt": "Hospedado em Frankfurt",
|
||||
"ios_android_sdks": "SDK iOS e Android para inquéritos móveis",
|
||||
"link_surveys": "Ligar Inquéritos (Partilhável)",
|
||||
@@ -1098,7 +1111,9 @@
|
||||
},
|
||||
"profile": {
|
||||
"account_deletion_consequences_warning": "Consequências da eliminação da conta",
|
||||
"avatar_update_failed": "Falha na atualização do avatar. Por favor, tente novamente.",
|
||||
"backup_code": "Código de Backup",
|
||||
"change_image": "Alterar imagem",
|
||||
"confirm_delete_account": "Eliminar a sua conta com todas as suas informações e dados pessoais",
|
||||
"confirm_delete_my_account": "Eliminar a Minha Conta",
|
||||
"confirm_your_current_password_to_get_started": "Confirme a sua palavra-passe atual para começar.",
|
||||
@@ -1109,13 +1124,17 @@
|
||||
"email_change_initiated": "O seu pedido de alteração de email foi iniciado.",
|
||||
"enable_two_factor_authentication": "Ativar autenticação de dois fatores",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Introduza o código da sua aplicação de autenticação abaixo.",
|
||||
"file_size_must_be_less_than_10mb": "O tamanho do ficheiro deve ser inferior a 10MB.",
|
||||
"invalid_file_type": "Tipo de ficheiro inválido. Apenas são permitidos ficheiros JPEG, PNG e WEBP.",
|
||||
"lost_access": "Perdeu o acesso",
|
||||
"or_enter_the_following_code_manually": "Ou insira o seguinte código manualmente:",
|
||||
"organization_identification": "Ajude a sua organização a identificá-lo no Formbricks",
|
||||
"organizations_delete_message": "É o único proprietário destas organizações, por isso <b>também serão eliminadas.</b>",
|
||||
"permanent_removal_of_all_of_your_personal_information_and_data": "Remoção permanente de todas as suas informações e dados pessoais",
|
||||
"personal_information": "Informações pessoais",
|
||||
"please_enter_email_to_confirm_account_deletion": "Por favor, insira {email} no campo seguinte para confirmar a eliminação definitiva da sua conta:",
|
||||
"profile_updated_successfully": "O seu perfil foi atualizado com sucesso",
|
||||
"remove_image": "Remover imagem",
|
||||
"save_the_following_backup_codes_in_a_safe_place": "Guarde os seguintes códigos de backup num local seguro.",
|
||||
"scan_the_qr_code_below_with_your_authenticator_app": "Digitalize o código QR abaixo com a sua aplicação de autenticação.",
|
||||
"security_description": "Gerir a sua palavra-passe e outras definições de segurança, como a autenticação de dois fatores (2FA).",
|
||||
@@ -1125,8 +1144,10 @@
|
||||
"two_factor_code": "Código de Dois Fatores",
|
||||
"unlock_two_factor_authentication": "Desbloqueie a autenticação de dois fatores com um plano superior",
|
||||
"update_personal_info": "Atualize as suas informações pessoais",
|
||||
"upload_image": "Carregar imagem",
|
||||
"warning_cannot_delete_account": "É o único proprietário desta organização. Transfira a propriedade para outro membro primeiro.",
|
||||
"warning_cannot_undo": "Isto não pode ser desfeito"
|
||||
"warning_cannot_undo": "Isto não pode ser desfeito",
|
||||
"you_must_select_a_file": "Deve selecionar um ficheiro."
|
||||
},
|
||||
"teams": {
|
||||
"add_members_description": "Adicionar membros à equipa e determinar o seu papel.",
|
||||
@@ -1238,7 +1259,9 @@
|
||||
"automatically_close_survey_after": "Fechar automaticamente o inquérito após",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Fechar automaticamente o inquérito após um certo número de respostas",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Fechar automaticamente o inquérito se o utilizador não responder após um certo número de segundos.",
|
||||
"automatically_closes_the_survey_at_the_beginning_of_the_day_utc": "Encerrar automaticamente o inquérito no início do dia (UTC).",
|
||||
"automatically_mark_the_survey_as_complete_after": "Marcar automaticamente o inquérito como concluído após",
|
||||
"automatically_release_the_survey_at_the_beginning_of_the_day_utc": "Lançar automaticamente o inquérito no início do dia (UTC).",
|
||||
"back_button_label": "Rótulo do botão \"Voltar\"",
|
||||
"background_styling": "Estilo de Fundo",
|
||||
"brand_color": "Cor da marca",
|
||||
@@ -1286,13 +1309,14 @@
|
||||
"choose_the_actions_which_trigger_the_survey": "Escolha as ações que desencadeiam o inquérito.",
|
||||
"choose_where_to_run_the_survey": "Escolha onde realizar o inquérito.",
|
||||
"city": "Cidade",
|
||||
"close_survey_on_date": "Encerrar inquérito na data",
|
||||
"close_survey_on_response_limit": "Fechar inquérito no limite de respostas",
|
||||
"color": "Cor",
|
||||
"column_used_in_logic_error": "Esta coluna é usada na lógica da pergunta {questionIndex}. Por favor, remova-a da lógica primeiro.",
|
||||
"columns": "Colunas",
|
||||
"company": "Empresa",
|
||||
"company_logo": "Logotipo da empresa",
|
||||
"completed_responses": "Respostas concluídas",
|
||||
"completed_responses": "respostas parciais ou completas",
|
||||
"concat": "Concatenar +",
|
||||
"conditional_logic": "Lógica Condicional",
|
||||
"confirm_default_language": "Confirmar idioma padrão",
|
||||
@@ -1332,7 +1356,6 @@
|
||||
"end_screen_card": "Cartão de ecrã final",
|
||||
"ending_card": "Cartão de encerramento",
|
||||
"ending_card_used_in_logic": "Este cartão final é usado na lógica da pergunta {questionIndex}.",
|
||||
"ending_used_in_quota": "Este final está a ser usado na quota \"{quotaName}\"",
|
||||
"ends_with": "Termina com",
|
||||
"equals": "Igual",
|
||||
"equals_one_of": "Igual a um de",
|
||||
@@ -1343,7 +1366,6 @@
|
||||
"fallback_for": "Alternativa para ",
|
||||
"fallback_missing": "Substituição em falta",
|
||||
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} é usado na lógica da pergunta {questionIndex}. Por favor, remova-o da lógica primeiro.",
|
||||
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "Campo oculto \"{fieldId}\" está a ser usado na quota \"{quotaName}\"",
|
||||
"field_name_eg_score_price": "Nome do campo, por exemplo, pontuação, preço",
|
||||
"first_name": "Primeiro Nome",
|
||||
"five_points_recommended": "5 pontos (recomendado)",
|
||||
@@ -1375,9 +1397,8 @@
|
||||
"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": "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_action_to_warning": "Nenhum campo de email detetado no inquérito",
|
||||
"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",
|
||||
@@ -1387,9 +1408,8 @@
|
||||
"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": "Por favor, selecione pelo menos um final ou mude o tipo de gatilho",
|
||||
"follow_ups_modal_trigger_type_ending_warning": "Não foram encontrados finais no inquérito!",
|
||||
"follow_ups_modal_trigger_type_response": "Respondente conclui inquérito",
|
||||
"follow_ups_modal_updated_successfull_toast": "Seguimento atualizado e será guardado assim que guardar o questionário.",
|
||||
"follow_ups_new": "Novo acompanhamento",
|
||||
"follow_ups_upgrade_button_text": "Atualize para ativar os acompanhamentos",
|
||||
"form_styling": "Estilo do formulário",
|
||||
@@ -1490,38 +1510,6 @@
|
||||
"question_duplicated": "Pergunta duplicada.",
|
||||
"question_id_updated": "ID da pergunta atualizado",
|
||||
"question_used_in_logic": "Esta pergunta é usada na lógica da pergunta {questionIndex}.",
|
||||
"question_used_in_quota": "Esta pergunta está a ser usada na quota \"{quotaName}\"",
|
||||
"quotas": {
|
||||
"add_quota": "Adicionar quota",
|
||||
"change_quota_for_public_survey": "Alterar quota para inquérito público?",
|
||||
"confirm_quota_changes": "Confirmar Alterações das Quotas",
|
||||
"confirm_quota_changes_body": "Tem alterações não guardadas na sua cota. Gostaria de as guardar antes de sair?",
|
||||
"continue_survey_normally": "Continua a pesquisa normalmente",
|
||||
"count_partial_submissions": "Contar submissões parciais",
|
||||
"count_partial_submissions_description": "Incluir respondentes que correspondem aos critérios de quota mas não completaram o inquérito",
|
||||
"create_quota_for_public_survey": "Criar quota para inquérito público?",
|
||||
"create_quota_for_public_survey_description": "Apenas respostas futuras serão controladas no limite",
|
||||
"create_quota_for_public_survey_text": "Este questionário já é público. As respostas existentes não serão consideradas na nova quota.",
|
||||
"delete_quota_confirmation_text": "Isto irá apagar permanentemente a quota {quotaName}.",
|
||||
"duplicate_quota": "Duplicar quota",
|
||||
"edit_quota": "Editar cota",
|
||||
"end_survey_for_matching_participants": "Encerrar inquérito para participantes correspondentes",
|
||||
"inclusion_criteria": "Critérios de Inclusão",
|
||||
"limit_must_be_greater_than_or_equal_to_the_number_of_responses": "{value, plural, other {Limite deve ser maior ou igual ao número de respostas}}",
|
||||
"limited_to_x_responses": "Limitado a {limit}",
|
||||
"new_quota": "Nova Cota",
|
||||
"quota_created_successfull_toast": "Quota criada com sucesso",
|
||||
"quota_deleted_successfull_toast": "Quota eliminada com sucesso",
|
||||
"quota_duplicated_successfull_toast": "Quota duplicada com sucesso",
|
||||
"quota_name_placeholder": "por exemplo, Participantes Idade 18-25",
|
||||
"quota_updated_successfull_toast": "Quota atualizada com sucesso",
|
||||
"response_limit": "Limites",
|
||||
"save_changes_confirmation_body": "Quaisquer alterações aos critérios de inclusão afetam apenas respostas futuras. \nRecomendamos duplicar uma cota existente ou criar uma nova.",
|
||||
"save_changes_confirmation_text": "As respostas existentes permanecem na cota",
|
||||
"select_ending_card": "Selecionar cartão de encerramento",
|
||||
"upgrade_prompt_title": "Utilize quotas com um plano superior",
|
||||
"when_quota_has_been_reached": "Quando a quota foi atingida"
|
||||
},
|
||||
"randomize_all": "Aleatorizar todos",
|
||||
"randomize_all_except_last": "Aleatorizar todos exceto o último",
|
||||
"range": "Intervalo",
|
||||
@@ -1529,6 +1517,7 @@
|
||||
"redirect_thank_you_card": "Redirecionar cartão de agradecimento",
|
||||
"redirect_to_url": "Redirecionar para Url",
|
||||
"redirect_to_url_not_available_on_free_plan": "Redirecionar para URL não está disponível no plano gratuito",
|
||||
"release_survey_on_date": "Lançar inquérito na data",
|
||||
"remove_description": "Remover descrição",
|
||||
"remove_translations": "Remover traduções",
|
||||
"require_answer": "Exigir Resposta",
|
||||
@@ -1554,7 +1543,6 @@
|
||||
"send_survey_to_audience_who_match": "Enviar inquérito para o público que corresponde...",
|
||||
"send_your_respondents_to_a_page_of_your_choice": "Envie os seus respondentes para uma página à sua escolha.",
|
||||
"set_the_global_placement_in_the_look_feel_settings": "Definir a colocação global nas definições de Aparência.",
|
||||
"settings_saved_successfully": "Definições guardadas com sucesso",
|
||||
"seven_points": "7 pontos",
|
||||
"show_advanced_settings": "Mostrar definições avançadas",
|
||||
"show_button": "Mostrar Botão",
|
||||
@@ -1615,7 +1603,6 @@
|
||||
"url_not_supported": "URL não suportado",
|
||||
"use_with_caution": "Usar com cautela",
|
||||
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} é usada na lógica da pergunta {questionIndex}. Por favor, remova-a da lógica primeiro.",
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variável \"{variableName}\" está a ser utilizada na quota \"{quotaName}\"",
|
||||
"variable_name_is_already_taken_please_choose_another": "O nome da variável já está em uso, por favor escolha outro.",
|
||||
"variable_name_must_start_with_a_letter": "O nome da variável deve começar com uma letra.",
|
||||
"verify_email_before_submission": "Verificar email antes da submissão",
|
||||
@@ -1648,16 +1635,16 @@
|
||||
"responses": {
|
||||
"address_line_1": "Endereço Linha 1",
|
||||
"address_line_2": "Endereço Linha 2",
|
||||
"an_error_occurred_creating_a_new_note": "Ocorreu um erro ao criar uma nova nota",
|
||||
"an_error_occurred_deleting_the_tag": "Ocorreu um erro ao eliminar a etiqueta",
|
||||
"an_error_occurred_resolving_a_note": "Ocorreu um erro ao resolver uma nota",
|
||||
"an_error_occurred_updating_a_note": "Ocorreu um erro ao atualizar uma nota",
|
||||
"browser": "Navegador",
|
||||
"bulk_delete_response_quotas": "As respostas são parte das quotas deste inquérito. Como deseja gerir as quotas?",
|
||||
"city": "Cidade",
|
||||
"company": "Empresa",
|
||||
"completed": "Concluído ✅",
|
||||
"country": "País",
|
||||
"decrement_quotas": "Decrementar todos os limites das cotas incluindo esta resposta",
|
||||
"delete_response_confirmation": "Isto irá apagar a resposta do inquérito, incluindo todas as respostas, etiquetas, documentos anexos e metadados da resposta.",
|
||||
"delete_response_quotas": "A resposta faz parte das quotas deste inquérito. Como deseja gerir as quotas?",
|
||||
"delete_response_confirmation": "Isto irá eliminar a resposta ao questionário, incluindo todas as respostas, notas, etiquetas, documentos anexados e metadados da resposta.",
|
||||
"device": "Dispositivo",
|
||||
"device_info": "Informações do dispositivo",
|
||||
"email": "Email",
|
||||
@@ -1669,6 +1656,7 @@
|
||||
"os": "SO",
|
||||
"person_attributes": "Atributos da pessoa",
|
||||
"phone": "Telefone",
|
||||
"resolve": "Resolver",
|
||||
"respondent_skipped_questions": "O respondente saltou estas perguntas.",
|
||||
"response_deleted_successfully": "Resposta eliminada com sucesso.",
|
||||
"single_use_id": "ID de Uso Único",
|
||||
@@ -1725,17 +1713,6 @@
|
||||
"embed_mode_description": "Incorpore o seu inquérito com um design minimalista, descartando o preenchimento e o fundo.",
|
||||
"nav_title": "Incorporar no site"
|
||||
},
|
||||
"link_settings": {
|
||||
"description": "Dê aos links um título, descrição e imagem personalizados para partilhar publicamente.",
|
||||
"language_help_text": "Os metadados são carregados com base no valor `lang` no URL.",
|
||||
"link_description": "Descrição do link",
|
||||
"link_description_description": "Descrições entre 55 a 200 caracteres têm melhor desempenho.",
|
||||
"link_title": "Título do Link",
|
||||
"link_title_description": "Títulos curtos têm melhor desempenho como Meta Titles.",
|
||||
"preview_image": "Pré-visualização da imagem",
|
||||
"preview_image_description": "Imagens de paisagem com tamanhos pequenos (<4MB) apresentam melhor desempenho.",
|
||||
"title": "Definições de ligação"
|
||||
},
|
||||
"personal_links": {
|
||||
"create_and_manage_segments": "Crie e gere os seus Segmentos em Contactos > Segmentos",
|
||||
"description": "Gerar links pessoais para um segmento e associar as respostas do inquérito a cada contacto.",
|
||||
@@ -1767,7 +1744,6 @@
|
||||
"send_preview": "Enviar pré-visualização",
|
||||
"send_preview_email": "Enviar pré-visualização de email"
|
||||
},
|
||||
"share_settings_title": "Partilhar Configurações",
|
||||
"share_view_title": "Partilhar via",
|
||||
"social_media": {
|
||||
"description": "Obtenha respostas dos seus contactos em várias redes sociais.",
|
||||
@@ -1789,7 +1765,6 @@
|
||||
"configure_alerts": "Configurar alertas",
|
||||
"congrats": "Parabéns! O seu inquérito está ativo.",
|
||||
"connect_your_website_or_app_with_formbricks_to_get_started": "Ligue o seu website ou aplicação ao Formbricks para começar.",
|
||||
"current_count": "Contagem atual",
|
||||
"custom_range": "Intervalo personalizado...",
|
||||
"delete_all_existing_responses_and_displays": "Excluir todas as respostas existentes e exibições",
|
||||
"download_qr_code": "Transferir código QR",
|
||||
@@ -1843,7 +1818,6 @@
|
||||
"last_month": "Último mês",
|
||||
"last_quarter": "Último trimestre",
|
||||
"last_year": "Ano passado",
|
||||
"limit": "Limite",
|
||||
"no_responses_found": "Nenhuma resposta encontrada",
|
||||
"other_values_found": "Outros valores encontrados",
|
||||
"overall": "Geral",
|
||||
@@ -1852,8 +1826,6 @@
|
||||
"qr_code_download_failed": "Falha ao transferir o código QR",
|
||||
"qr_code_download_with_start_soon": "O download do código QR começará em breve",
|
||||
"qr_code_generation_failed": "Ocorreu um problema ao carregar o Código QR do questionário. Por favor, tente novamente.",
|
||||
"quotas_completed": "Quotas concluídas",
|
||||
"quotas_completed_tooltip": "O número de quotas concluídas pelos respondentes.",
|
||||
"reset_survey": "Reiniciar inquérito",
|
||||
"reset_survey_warning": "Repor um inquérito remove todas as respostas e visualizações associadas a este inquérito. Isto não pode ser desfeito.",
|
||||
"selected_responses_csv": "Respostas selecionadas (CSV)",
|
||||
@@ -1869,7 +1841,7 @@
|
||||
"this_quarter": "Este trimestre",
|
||||
"this_year": "Este ano",
|
||||
"time_to_complete": "Tempo para Concluir",
|
||||
"ttc_tooltip": "Tempo médio para concluir a pergunta.",
|
||||
"ttc_tooltip": "Tempo médio para concluir o inquérito.",
|
||||
"unknown_question_type": "Tipo de Pergunta Desconhecido",
|
||||
"use_personal_links": "Utilize links pessoais",
|
||||
"waiting_for_response": "A aguardar uma resposta \uD83E\uDDD8♂️",
|
||||
@@ -1880,6 +1852,7 @@
|
||||
"survey_deleted_successfully": "Inquérito eliminado com sucesso!",
|
||||
"survey_duplicated_successfully": "Inquérito duplicado com sucesso.",
|
||||
"survey_duplication_error": "Falha ao duplicar o inquérito.",
|
||||
"survey_status_tooltip": "Para atualizar o estado do inquérito, atualize o agendamento e feche a configuração nas opções de resposta do inquérito.",
|
||||
"templates": {
|
||||
"all_channels": "Todos os canais",
|
||||
"all_industries": "Todas as indústrias",
|
||||
|
||||
+153
-180
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"auth": {
|
||||
"continue_with_azure": "Continuă cu Microsoft",
|
||||
"continue_with_email": "Continuă cu email",
|
||||
"continue_with_email": "Continuă cu Email",
|
||||
"continue_with_github": "Continuă cu GitHub",
|
||||
"continue_with_google": "Continuă cu Google",
|
||||
"continue_with_oidc": "Continuă cu {oidcDisplayName}",
|
||||
@@ -49,7 +49,7 @@
|
||||
"invite_not_found": "Invitația nu a fost găsită \uD83D\uDE25",
|
||||
"invite_not_found_description": "Codul de invitație nu poate fi găsit sau a fost deja utilizat.",
|
||||
"login": "Autentificare",
|
||||
"welcome_to_organization": "Ai fost acceptat \uD83C\uDF89",
|
||||
"welcome_to_organization": "Ești în \uD83C\uDF89",
|
||||
"welcome_to_organization_description": "Bun venit în organizație."
|
||||
},
|
||||
"last_used": "Ultima utilizare",
|
||||
@@ -65,7 +65,7 @@
|
||||
"new_to_formbricks": "Nou în Formbricks?",
|
||||
"use_a_backup_code": "Folosiți un cod de rezervă"
|
||||
},
|
||||
"saml_connection_error": "Ceva nu a mers. Vă rugăm să verificați consola aplicației pentru mai multe detalii.",
|
||||
"saml_connection_error": "Ceva a mers prost. Vă rugăm să verificați consola aplicației pentru mai multe detalii.",
|
||||
"signup": {
|
||||
"captcha_failed": "Captcha eșuat",
|
||||
"have_an_account": "Ai un cont?",
|
||||
@@ -73,16 +73,16 @@
|
||||
"password_validation_contain_at_least_1_number": "Conține cel puțin 1 număr",
|
||||
"password_validation_minimum_8_and_maximum_128_characters": "Minim 8 & Maxim 128 caractere",
|
||||
"password_validation_uppercase_and_lowercase": "Amestec de majuscule și minuscule",
|
||||
"please_verify_captcha": "Vă rugăm să verificați CAPTCHA",
|
||||
"privacy_policy": "Politica de confidențialitate",
|
||||
"terms_of_service": "Termeni de utilizare a serviciului",
|
||||
"please_verify_captcha": "Vă rugăm să verificați reCAPTCHA",
|
||||
"privacy_policy": "Politica de Confidențialitate",
|
||||
"terms_of_service": "Termeni de Serviciu",
|
||||
"title": "Creați-vă contul Formbricks"
|
||||
},
|
||||
"signup_without_verification_success": {
|
||||
"user_successfully_created": "Utilizator creat cu succes",
|
||||
"user_successfully_created_info": "Am verificat pentru un cont asociat cu {email}. Dacă nu a existat niciunul, am creat unul pentru tine. Dacă un cont deja exista, nu s-au făcut modificări. Vă rugăm să vă conectați mai jos pentru a continua."
|
||||
},
|
||||
"testimonial_1": "\"Măsurăm claritatea documentațiilor noastre și învățăm din greșeli în folosirea platformei. Produs grozav, echipă foarte receptivă!\"",
|
||||
"testimonial_1": "\"Măsurăm claritatea documentațiilor noastre și învățăm din pierderi în folosirea aceleași platforme. Produs grozav, echipă foarte receptivă!\"",
|
||||
"testimonial_all_features_included": "Toate funcționalitățile incluse",
|
||||
"testimonial_free_and_open_source": "Gratuit și open-source",
|
||||
"testimonial_no_credit_card_required": "Nu este necesar niciun card de credit",
|
||||
@@ -118,7 +118,6 @@
|
||||
"account_settings": "Setări cont",
|
||||
"action": "Acțiune",
|
||||
"actions": "Acțiuni",
|
||||
"actions_description": "Acțiunile Cod și No-Code sunt utilizate pentru a declanșa chestionare de interceptare în aplicații și pe site-uri web.",
|
||||
"active_surveys": "Sondaje active",
|
||||
"activity": "Activitate",
|
||||
"add": "Adaugă",
|
||||
@@ -126,7 +125,6 @@
|
||||
"add_filter": "Adăugați filtru",
|
||||
"add_logo": "Adaugă logo",
|
||||
"add_member": "Adaugă membru",
|
||||
"add_new_project": "Adaugă proiect nou",
|
||||
"add_project": "Adaugă proiect",
|
||||
"add_to_team": "Adaugă la echipă",
|
||||
"all": "Toate",
|
||||
@@ -139,10 +137,11 @@
|
||||
"anonymous": "Anonim",
|
||||
"api_keys": "Chei API",
|
||||
"app": "Aplicație",
|
||||
"app_survey": "Sondaj aplicație",
|
||||
"app_survey": "Sondaj Aplicație",
|
||||
"apply_filters": "Aplică filtre",
|
||||
"are_you_sure": "Ești sigur?",
|
||||
"attributes": "Atribute",
|
||||
"avatar": "Avatar",
|
||||
"back": "Înapoi",
|
||||
"billing": "Facturare",
|
||||
"booked": "Rezervat",
|
||||
@@ -151,9 +150,6 @@
|
||||
"cancel": "Anulare",
|
||||
"centered_modal": "Modală centralizată",
|
||||
"choices": "Alegeri",
|
||||
"choose_environment": "Alege mediul",
|
||||
"choose_organization": "Alege organizația",
|
||||
"choose_project": "Alege proiectul",
|
||||
"clear_all": "Șterge tot",
|
||||
"clear_filters": "Curăță filtrele",
|
||||
"clear_selection": "Șterge selecția",
|
||||
@@ -169,14 +165,11 @@
|
||||
"connect_formbricks": "Conectează Formbricks",
|
||||
"connected": "Conectat",
|
||||
"contacts": "Contacte",
|
||||
"continue": "Continuă",
|
||||
"copied": "Copiat",
|
||||
"copied_to_clipboard": "Copiat în clipboard",
|
||||
"copy": "Copiază",
|
||||
"copy_code": "Copiază codul",
|
||||
"copy_link": "Copiază legătura",
|
||||
"count_contacts": "{value, plural, one {# contact} other {# contacte} }",
|
||||
"count_responses": "{value, plural, one {# răspuns} other {# răspunsuri} }",
|
||||
"create_new_organization": "Creează organizație nouă",
|
||||
"create_project": "Creează proiect",
|
||||
"create_segment": "Creați segment",
|
||||
@@ -185,6 +178,7 @@
|
||||
"created_at": "Creat la",
|
||||
"created_by": "Creat de",
|
||||
"customer_success": "Succesul Clientului",
|
||||
"danger_zone": "Zonă periculoasă",
|
||||
"dark_overlay": "Suprapunere întunecată",
|
||||
"date": "Dată",
|
||||
"default": "Implicit",
|
||||
@@ -204,15 +198,10 @@
|
||||
"e_commerce": "Comerț electronic",
|
||||
"edit": "Editare",
|
||||
"email": "Email",
|
||||
"ending_card": "Cardul de finalizare",
|
||||
"enterprise_license": "Licență Întreprindere",
|
||||
"environment_not_found": "Mediul nu a fost găsit",
|
||||
"environment_notice": "Te afli în prezent în mediul {environment}",
|
||||
"error": "Eroare",
|
||||
"error_component_description": "Această resursă nu există sau nu aveți drepturile necesare pentru a o accesa.",
|
||||
"error_component_title": "Eroare la încărcarea resurselor",
|
||||
"error_rate_limit_description": "Numărul maxim de cereri atins. Vă rugăm să încercați din nou mai târziu.",
|
||||
"error_rate_limit_title": "Limită de cereri depășită",
|
||||
"expand_rows": "Extinde rândurile",
|
||||
"finish": "Finalizează",
|
||||
"follow_these": "Urmați acestea",
|
||||
@@ -225,6 +214,7 @@
|
||||
"hidden": "Ascuns",
|
||||
"hidden_field": "Câmp ascuns",
|
||||
"hidden_fields": "Câmpuri ascunse",
|
||||
"hide": "Ascunde",
|
||||
"hide_column": "Ascunde coloana",
|
||||
"image": "Imagine",
|
||||
"images": "Imagini",
|
||||
@@ -244,9 +234,11 @@
|
||||
"label": "Etichetă",
|
||||
"language": "Limba",
|
||||
"learn_more": "Află mai multe",
|
||||
"license": "Licență",
|
||||
"light_overlay": "Suprapunere ușoară",
|
||||
"limits_reached": "Limite atinse",
|
||||
"link": "Legătura",
|
||||
"link_and_email": "Link & email",
|
||||
"link_survey": "Conectează chestionarul",
|
||||
"link_surveys": "Conectează chestionarele",
|
||||
"load_more": "Încarcă mai multe",
|
||||
@@ -262,20 +254,18 @@
|
||||
"membership_not_found": "Apartenența nu a fost găsită",
|
||||
"metadata": "Metadate",
|
||||
"minimum": "Minim",
|
||||
"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!",
|
||||
"mobile_overlay_text": "Formbricks nu este disponibil pentru dispozitive cu rezoluții mai mici.",
|
||||
"move_down": "Mută în jos",
|
||||
"move_up": "Mută sus",
|
||||
"multiple_languages": "Mai multe limbi",
|
||||
"name": "Nume",
|
||||
"new": "Nou",
|
||||
"new_survey": "Chestionar Nou",
|
||||
"new_version_available": "Formbricks {version} este disponibil. Actualizați acum!",
|
||||
"next": "Următorul",
|
||||
"no_background_image_found": "Nu a fost găsită nicio imagine de fundal.",
|
||||
"no_code": "Fără Cod",
|
||||
"no_files_uploaded": "Nu au fost încărcate fișiere",
|
||||
"no_quotas_found": "Nicio cotă găsită",
|
||||
"no_result_found": "Niciun rezultat găsit",
|
||||
"no_results": "Nicio rezultat",
|
||||
"no_surveys_found": "Nu au fost găsite sondaje.",
|
||||
@@ -283,6 +273,7 @@
|
||||
"not_authorized": "Neautorizat",
|
||||
"not_connected": "Neconectat",
|
||||
"note": "Notă",
|
||||
"notes": "Notele",
|
||||
"notifications": "Notificări",
|
||||
"number": "Număr",
|
||||
"off": "Oprit",
|
||||
@@ -294,14 +285,13 @@
|
||||
"or": "sau",
|
||||
"organization": "Organizație",
|
||||
"organization_id": "ID Organizație",
|
||||
"organization_not_found": "Organizația nu a fost găsită",
|
||||
"organization_settings": "Setări Organizație",
|
||||
"organization_not_found": "Organizație nu a fost găsită",
|
||||
"organization_teams_not_found": "Echipele organizației nu au fost găsite",
|
||||
"other": "Altele",
|
||||
"others": "Altele",
|
||||
"overview": "Prezentare generală",
|
||||
"password": "Parolă",
|
||||
"paused": "Pauză",
|
||||
"paused": "Pauzat",
|
||||
"pending_downgrade": "Reducere în aşteptare",
|
||||
"people_manager": "Manager de persoane",
|
||||
"person": "Persoană",
|
||||
@@ -319,8 +309,7 @@
|
||||
"product_manager": "Manager de Produs",
|
||||
"profile": "Profil",
|
||||
"profile_id": "ID Profil",
|
||||
"progress": "Progres",
|
||||
"project_configuration": "Configurare proiect",
|
||||
"project_configuration": "Configurarea Proiectului",
|
||||
"project_creation_description": "Organizați sondajele în proiecte pentru un control mai bun al accesului.",
|
||||
"project_id": "ID proiect",
|
||||
"project_name": "Nume proiect",
|
||||
@@ -331,9 +320,6 @@
|
||||
"question": "Întrebare",
|
||||
"question_id": "ID întrebare",
|
||||
"questions": "Întrebări",
|
||||
"quota": "Cotă",
|
||||
"quotas": "Cote",
|
||||
"quotas_description": "Limitați numărul de răspunsuri primite de la participanții care îndeplinesc anumite criterii.",
|
||||
"read_docs": "Citește documentația",
|
||||
"recipients": "Destinatari",
|
||||
"remove": "Șterge",
|
||||
@@ -341,7 +327,7 @@
|
||||
"report_survey": "Raportează chestionarul",
|
||||
"request_pricing": "Solicită Prețuri",
|
||||
"request_trial_license": "Solicitați o licență de încercare",
|
||||
"reset_to_default": "Revino la implicit",
|
||||
"reset_to_default": "Revină la implicit",
|
||||
"response": "Răspuns",
|
||||
"responses": "Răspunsuri",
|
||||
"restart": "Repornește",
|
||||
@@ -351,7 +337,7 @@
|
||||
"sales": "Vânzări",
|
||||
"save": "Salvează",
|
||||
"save_changes": "Salvează modificările",
|
||||
"saving": "Salvare",
|
||||
"scheduled": "Programat",
|
||||
"search": "Căutare",
|
||||
"security": "Securitate",
|
||||
"segment": "Segment",
|
||||
@@ -364,13 +350,14 @@
|
||||
"selected_questions": "Întrebări selectate",
|
||||
"selection": "Selecție",
|
||||
"selections": "Selecții",
|
||||
"send": "Trimite",
|
||||
"send_test_email": "Trimite email de test",
|
||||
"session_not_found": "Sesiune inexistentă",
|
||||
"settings": "Setări",
|
||||
"share_feedback": "Împărtășește feedback",
|
||||
"show": "Afișează",
|
||||
"show_response_count": "Afișează numărul de răspunsuri",
|
||||
"shown": "Afișat",
|
||||
"shown": "Arătat",
|
||||
"size": "Mărime",
|
||||
"skipped": "Sărit",
|
||||
"skips": "Salturi",
|
||||
@@ -378,10 +365,9 @@
|
||||
"something_went_wrong": "Ceva nu a mers bine",
|
||||
"something_went_wrong_please_try_again": "Ceva nu a mers bine. Vă rugăm să încercați din nou.",
|
||||
"sort_by": "Sortare după",
|
||||
"start_free_trial": "Începe perioada de testare gratuită",
|
||||
"start_free_trial": "Începe Perioada de Testare Gratuită",
|
||||
"status": "Stare",
|
||||
"step_by_step_manual": "Manual pas cu pas",
|
||||
"storage_not_configured": "Stocarea fișierelor neconfigurată, upload-urile vor eșua probabil",
|
||||
"styling": "Stilizare",
|
||||
"submit": "Trimite",
|
||||
"summary": "Sumar",
|
||||
@@ -392,8 +378,10 @@
|
||||
"survey_live": "Chestionar activ",
|
||||
"survey_not_found": "Sondajul nu a fost găsit",
|
||||
"survey_paused": "Chestionar oprit.",
|
||||
"survey_scheduled": "Chestionar programat.",
|
||||
"survey_type": "Tip Chestionar",
|
||||
"surveys": "Sondaje",
|
||||
"switch_organization": "Comută organizația",
|
||||
"switch_to": "Comută la {environment}",
|
||||
"table_items_deleted_successfully": "\"{type} șterse cu succes\"",
|
||||
"table_settings": "Setări tabel",
|
||||
@@ -448,15 +436,15 @@
|
||||
"click_or_drag_to_upload_files": "Faceți clic sau trageți pentru a încărca fișiere.",
|
||||
"email_customization_preview_email_heading": "Salut {userName}",
|
||||
"email_customization_preview_email_subject": "Previzualizare Personalizare Email Formbricks",
|
||||
"email_customization_preview_email_text": "Acesta este o previzualizare a emailului pentru a vă arăta ce logo va fi afișat în emailurile viitoare.",
|
||||
"email_customization_preview_email_text": "Acesta este un previzualizare a e-mailului pentru a vă arăta ce logo va fi afișat în e-mailurile.",
|
||||
"email_footer_text_1": "O zi minunată!",
|
||||
"email_footer_text_2": "Echipa Formbricks",
|
||||
"email_template_text_1": "Acest email a fost trimis prin Formbricks.",
|
||||
"embed_survey_preview_email_didnt_request": "Nu ați solicitat asta?",
|
||||
"embed_survey_preview_email_environment_id": "ID de mediu",
|
||||
"embed_survey_preview_email_fight_spam": "Ajută-ne să combatem spam-ul și trimite acest e-mail la hola@formbricks.com",
|
||||
"embed_survey_preview_email_heading": "Previzualizare încorporare email",
|
||||
"embed_survey_preview_email_subject": "Previzualizare chestionar email Formbricks",
|
||||
"embed_survey_preview_email_heading": "Previzualizare Incorporare Email",
|
||||
"embed_survey_preview_email_subject": "Previzualizare Chestionar Email Formbricks",
|
||||
"embed_survey_preview_email_text": "Așa arată fragmentul de cod încorporat într-un email:",
|
||||
"forgot_password_email_change_password": "Schimbați parola",
|
||||
"forgot_password_email_did_not_request": "Dacă nu ați solicitat acest lucru, vă rugăm să ignorați acest email.",
|
||||
@@ -478,7 +466,7 @@
|
||||
"password_changed_email_heading": "Parola modificată",
|
||||
"password_changed_email_text": "Parola dumneavoastră a fost schimbată cu succes.",
|
||||
"password_reset_notify_email_subject": "Parola dumneavoastră Formbricks a fost schimbată",
|
||||
"privacy_policy": "Politica de confidențialitate",
|
||||
"privacy_policy": "Politica de Confidențialitate",
|
||||
"reject": "Respinge",
|
||||
"render_email_response_value_file_upload_response_link_not_included": "Linkul către fișierul încărcat nu este inclus din motive de confidențialitate a datelor",
|
||||
"response_finished_email_subject": "Un răspuns pentru {surveyName} a fost finalizat ✅",
|
||||
@@ -506,7 +494,7 @@
|
||||
"verification_email_to_fill_survey": "Pentru a completa sondajul, vă rugăm să faceți clic pe butonul de mai jos:",
|
||||
"verification_email_verify_email": "Verifică emailul",
|
||||
"verification_new_email_subject": "Verificare schimbare email",
|
||||
"verification_security_notice": "Dacă nu ați cerut această modificare a emailului, vă rugăm să ignorați acest email sau să contactați suportul imediat.",
|
||||
"verification_security_notice": "Dacă nu ați cerut această modificare a e-mailului, vă rugăm să ignorați acest e-mail sau să contactați suportul imediat.",
|
||||
"verified_link_survey_email_subject": "Chestionarul tău este gata să fie completat."
|
||||
},
|
||||
"environments": {
|
||||
@@ -515,7 +503,7 @@
|
||||
"action_copy_failed": "Copierea acțiunii a eșuat",
|
||||
"action_created_successfully": "Acțiune creată cu succes",
|
||||
"action_deleted_successfully": "Acțiune ștearsă cu succes.",
|
||||
"action_type": "Tip acțiune",
|
||||
"action_type": "Tip Acțiune",
|
||||
"action_updated_successfully": "Acțiune actualizată cu succes",
|
||||
"action_with_key_already_exists": "Acțiunea cu cheia {key} există deja",
|
||||
"action_with_name_already_exists": "Acțiunea cu numele {name} există deja",
|
||||
@@ -578,7 +566,7 @@
|
||||
"congrats": "Felicitări!",
|
||||
"connection_successful_message": "Bravo! Suntem conectați.",
|
||||
"do_it_later": "Am să o fac mai târziu",
|
||||
"finish_onboarding": "Încheie înregistrarea",
|
||||
"finish_onboarding": "Încheie Înregistrarea",
|
||||
"headline": "Conectați aplicația sau site-ul dvs.",
|
||||
"import_formbricks_and_initialize_the_widget_in_your_component": "Importați Formbricks și inițializați widgetul în componenta dumneavoastră (de exemplu, App.tsx):",
|
||||
"insert_this_code_into_the_head_tag_of_your_website": "Introduceți acest cod în eticheta head a site-ului dvs.:",
|
||||
@@ -591,11 +579,12 @@
|
||||
"contacts_table_refresh": "Reîmprospătare contacte",
|
||||
"contacts_table_refresh_success": "Contactele au fost actualizate cu succes",
|
||||
"delete_contact_confirmation": "Acest lucru va șterge toate răspunsurile la sondaj și atributele de contact asociate cu acest contact. Orice țintire și personalizare bazată pe datele acestui contact vor fi pierdute.",
|
||||
"delete_contact_confirmation_with_quotas": "{value, plural, one {Această acțiune va șterge toate răspunsurile chestionarului și atributele de contact asociate cu acest contact. Orice țintire și personalizare bazată pe datele acestui contact vor fi pierdute. Dacă acest contact are răspunsuri care contează pentru cotele chestionarului, numărul cotelor va fi redus, dar limitele cotelor vor rămâne neschimbate.} other {Aceste acțiuni vor șterge toate răspunsurile chestionarului și atributele de contact asociate cu acești contacți. Orice țintire și personalizare bazată pe datele acestor contacți vor fi pierdute. Dacă acești contacți au răspunsuri care contează pentru cotele chestionarului, numărul cotelor va fi redus, dar limitele cotelor vor rămâne neschimbate.} }",
|
||||
"first_name": "Prenume",
|
||||
"last_name": "Nume de familie",
|
||||
"no_responses_found": "Nu s-au găsit răspunsuri",
|
||||
"not_provided": "Nu a fost furnizat",
|
||||
"not_provided": "Neprovidat",
|
||||
"search_contact": "Căutați contact",
|
||||
"select_attribute": "Selectează atributul",
|
||||
"select_attribute": "Selectează Atributul",
|
||||
"unlock_contacts_description": "Gestionează contactele și trimite sondaje țintite",
|
||||
"unlock_contacts_title": "Deblocați contactele cu un plan superior.",
|
||||
"upload_contacts_modal_attributes_description": "Mapează coloanele din CSV-ul tău la atributele din Formbricks.",
|
||||
@@ -642,7 +631,7 @@
|
||||
"connected_with_email": "Conectat cu {email}",
|
||||
"connecting_integration_failed_please_try_again": "Conectarea integrării a eșuat. Vă rugăm să încercați din nou!",
|
||||
"create_survey_warning": "Trebuie să creezi un sondaj pentru a putea configura această integrare",
|
||||
"delete_integration": "Șterge integrarea",
|
||||
"delete_integration": "Șterge Integrarea",
|
||||
"delete_integration_confirmation": "Sigur doriți să ștergeți această integrare?",
|
||||
"google_sheet_integration_description": "Completați instantaneu foile de calcul cu datele chestionarului",
|
||||
"google_sheets": {
|
||||
@@ -660,7 +649,7 @@
|
||||
"no_integrations_yet": "Integrațiile tale Google Sheet vor apărea aici de îndată ce le vei adăuga. ⏲️",
|
||||
"spreadsheet_url": "URL foaie de calcul"
|
||||
},
|
||||
"include_created_at": "Include data creării",
|
||||
"include_created_at": "Include Data Creării",
|
||||
"include_hidden_fields": "Include câmpuri ascunse",
|
||||
"include_metadata": "Includere Metadata (Browser, Țară, etc.)",
|
||||
"include_variables": "Include Variabile",
|
||||
@@ -752,6 +741,7 @@
|
||||
},
|
||||
"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",
|
||||
@@ -771,21 +761,43 @@
|
||||
"unable_to_delete_api_key": "Imposibil de șters cheia API"
|
||||
},
|
||||
"app-connection": {
|
||||
"api_host_description": "Acesta este URL-ul backend-ului tău Formbricks.",
|
||||
"app_connection": "Conectare aplicație",
|
||||
"app_connection_description": "Conectează aplicația ta la Formbricks.",
|
||||
"cache_update_delay_description": "Când faci actualizări la sondaje, contacte, acțiuni sau alte date, poate dura până la 5 minute pentru ca aceste modificări să apară în aplicația locală care rulează SDK Formbricks. Această întârziere se datorează unei limitări în sistemul nostru actual de caching. Revedem activ cache-ul și vom lansa o soluție în Formbricks 4.0.",
|
||||
"cache_update_delay_title": "Modificările vor fi reflectate după 5 minute datorită memorării în cache",
|
||||
"environment_id": "ID-ul mediului tău",
|
||||
"check_out_the_docs": "Consultați documentația.",
|
||||
"dive_into_the_docs": "Accesați documentația.",
|
||||
"does_your_widget_work": "Funcționează widgetul dvs.?",
|
||||
"environment_id": "ID-ul Mediului Dvs.",
|
||||
"environment_id_description": "Acest id identifică în mod unic acest mediu Formbricks.",
|
||||
"environment_id_description_with_environment_id": "Folosit pentru a identifica mediul corect: {environmentId} este al tău.",
|
||||
"formbricks_sdk": "SDK Formbricks",
|
||||
"formbricks_sdk_connected": "SDK Formbricks este conectat",
|
||||
"formbricks_sdk_not_connected": "Formbricks SDK nu este încă conectat.",
|
||||
"formbricks_sdk_not_connected_description": "Conectează-ți site-ul sau aplicația cu Formbricks",
|
||||
"have_a_problem": "Aveți o problemă?",
|
||||
"how_to_setup": "Cum să configurezi",
|
||||
"how_to_setup_description": "Urmează acești pași pentru a configura widget-ul Formbricks în aplicația ta.",
|
||||
"identifying_your_users": "identificarea utilizatorilor tăi",
|
||||
"if_you_are_planning_to": "Dacă planifici să",
|
||||
"insert_this_code_into_the": "Insereză acest cod în",
|
||||
"need_a_more_detailed_setup_guide_for": "Aveți nevoie de un ghid de configurare mai detaliat pentru",
|
||||
"not_working": "Nu funcționează?",
|
||||
"open_an_issue_on_github": "Deschideți o problemă pe GitHub",
|
||||
"open_the_browser_console_to_see_the_logs": "Deschide consola browserului pentru a vedea jurnalele.",
|
||||
"receiving_data": "Recepționare date \uD83D\uDC83\uD83D\uDD7A",
|
||||
"recheck": "Re-verifică",
|
||||
"setup_alert_description": "Urmează acest tutorial pas cu pas pentru a-ți conecta aplicația sau site-ul în mai puțin de 5 minute.",
|
||||
"setup_alert_title": "Cum să conectezi"
|
||||
"scroll_to_the_top": "Derulați în partea de sus!",
|
||||
"step_1": "Pasul 1: Instalează cu pnpm, npm sau yarn",
|
||||
"step_2": "Pasul 2: Inițializează widget-ul",
|
||||
"step_2_description": "Importați Formbricks și inițializați widgetul în componenta dumneavoastră (de exemplu, App.tsx):",
|
||||
"step_3": "Pasul 3: Modul de depanare",
|
||||
"switch_on_the_debug_mode_by_appending": "Activează modul de depanare prin adăugare",
|
||||
"tag_of_your_app": "eticheta aplicației tale",
|
||||
"to_the_url_where_you_load_the": "la adresa URL de unde încarci",
|
||||
"want_to_learn_how_to_add_user_attributes": "Doriți să aflați cum să adăugați atribute ale utilizatorului, evenimente personalizate și altele?",
|
||||
"you_are_done": "Ai terminat \uD83C\uDF89",
|
||||
"you_can_set_the_user_id_with": "poți seta ID-ul utilizatorului cu",
|
||||
"your_app_now_communicates_with_formbricks": "Aplicația ta comunică acum cu Formbricks - trimite evenimente și încarcă automat sondajele!"
|
||||
},
|
||||
"general": {
|
||||
"cannot_delete_only_project": "Acesta este singurul tău proiect, nu poate fi șters. Creează mai întâi un proiect nou.",
|
||||
@@ -959,51 +971,52 @@
|
||||
"all_integrations": "Toate integrațiile",
|
||||
"annually": "Anual",
|
||||
"api_webhooks": "API & Webhook-uri",
|
||||
"app_surveys": "Sondaje în aplicație",
|
||||
"app_surveys": "Sondaje de Aplicație",
|
||||
"attribute_based_targeting": "Targetare bazată pe atribute",
|
||||
"current": "Curent",
|
||||
"current_plan": "Plan curent",
|
||||
"current_tier_limit": "Limită curentă a nivelului",
|
||||
"custom": "Personalizat & Scalare",
|
||||
"custom_contacts_limit": "Limită personalizată contacte",
|
||||
"custom_contacts_limit": "Limit Personalizat Contacte",
|
||||
"custom_project_limit": "Limit Personalizat Proiect",
|
||||
"custom_response_limit": "Limit Personalizat Răspunsuri",
|
||||
"email_embedded_surveys": "Sondaje încorporate în email",
|
||||
"email_follow_ups": "Email follow-up",
|
||||
"email_follow_ups": "Urmăriri Email",
|
||||
"enterprise_description": "Suport Premium și limite personalizate.",
|
||||
"everybody_has_the_free_plan_by_default": "Toată lumea are planul gratuit implicit!",
|
||||
"everything_in_free": "Totul în Gratuit",
|
||||
"everything_in_startup": "Totul în Startup",
|
||||
"free": "Gratuit",
|
||||
"free_description": "Sondaje nelimitate, membri în echipă și altele.",
|
||||
"free_description": "Sondaje Nelimitate, Membri În Echipă și altele.",
|
||||
"get_2_months_free": "Primește 2 luni gratuite",
|
||||
"get_in_touch": "Contactați-ne",
|
||||
"hosted_in_frankfurt": "Găzduit în Frankfurt",
|
||||
"ios_android_sdks": "SDK iOS & Android pentru sondaje mobile",
|
||||
"link_surveys": "Sondaje Link (Distribuibil)",
|
||||
"logic_jumps_hidden_fields_recurring_surveys": "Salturi Logice, Câmpuri Ascunse, Sondaje Recurente, etc.",
|
||||
"manage_card_details": "Gestionați detaliile cardului",
|
||||
"manage_subscription": "Gestionați abonamentul",
|
||||
"manage_card_details": "Gestionați Detaliile Cardului",
|
||||
"manage_subscription": "Gestionați Abonamentul",
|
||||
"monthly": "Lunar",
|
||||
"monthly_identified_users": "Utilizatori identificați lunar",
|
||||
"monthly_identified_users": "Utilizatori Identificați Lunar",
|
||||
"per_month": "pe lună",
|
||||
"per_year": "pe an",
|
||||
"plan_upgraded_successfully": "Planul a fost upgradat cu succes",
|
||||
"premium_support_with_slas": "Suport premium cu SLA-uri",
|
||||
"remove_branding": "Eliminare branding",
|
||||
"remove_branding": "Eliminare Branding",
|
||||
"startup": "Pornire",
|
||||
"startup_description": "Totul din versiunea gratuită cu funcții suplimentare.",
|
||||
"switch_plan": "Schimbă planul",
|
||||
"switch_plan": "Schimbă Planul",
|
||||
"switch_plan_confirmation_text": "Sigur doriți să treceți la planul {plan}? Vi se va percepe {price} {period}.",
|
||||
"team_access_roles": "Roluri acces echipă",
|
||||
"team_access_roles": "Roluri Acces Echipă",
|
||||
"unable_to_upgrade_plan": "Nu se poate upgrada planul",
|
||||
"unlimited_miu": "MIU Nelimitat",
|
||||
"unlimited_projects": "Proiecte nelimitate",
|
||||
"unlimited_projects": "Proiecte Nelimitate",
|
||||
"unlimited_responses": "Răspunsuri nelimitate",
|
||||
"unlimited_surveys": "Sondaje nelimitate",
|
||||
"unlimited_team_members": "Membri nelimitați în echipă",
|
||||
"unlimited_surveys": "Sondaje Nelimitate",
|
||||
"unlimited_team_members": "Membri Nelimitați În Echipă",
|
||||
"upgrade": "Actualizare",
|
||||
"uptime_sla_99": "Disponibilitate SLA (99%)",
|
||||
"website_surveys": "Sondaje ale site-ului"
|
||||
"website_surveys": "Sondaje ale Site-ului"
|
||||
},
|
||||
"enterprise": {
|
||||
"audit_logs": "Jurnale de audit",
|
||||
@@ -1035,11 +1048,11 @@
|
||||
"create_new_organization_description": "Creați o organizație nouă pentru a gestiona un alt set de proiecte.",
|
||||
"customize_email_with_a_higher_plan": "Personalizați emailul cu un plan superior",
|
||||
"delete_member_confirmation": "Membrii șterși vor pierde accesul la toate proiectele și sondajele organizației tale.",
|
||||
"delete_organization": "Șterge organizație",
|
||||
"delete_organization": "Șterge Organizație",
|
||||
"delete_organization_description": "Șterge organizația cu toate proiectele ei, incluzând toate sondajele, răspunsurile, persoanele, acțiunile și atributele.",
|
||||
"delete_organization_warning": "Înainte de a continua cu ștergerea acestei organizații, vă rugăm să fiți conștienți de următoarele consecințe:",
|
||||
"delete_organization_warning_1": "Ștergerea permanentă a tuturor proiectelor legate de această organizație.",
|
||||
"delete_organization_warning_2": "Această acțiune este ireversibilă",
|
||||
"delete_organization_warning_2": "Această acțiune nu poate fi anulată. Dacă e dispărută, e dispărută.",
|
||||
"delete_organization_warning_3": "Vă rugăm să introduceți {organizationName} în câmpul următor pentru a confirma ștergerea definitivă a acestei organizații:",
|
||||
"eliminate_branding_with_whitelabel": "Eliminați brandingul Formbricks și activați opțiuni suplimentare de personalizare white-label.",
|
||||
"email_customization_preview_email_heading": "Salut {userName}",
|
||||
@@ -1061,7 +1074,7 @@
|
||||
"manage_members_description": "Adăugați sau eliminați membri din organizația dvs.",
|
||||
"member_deleted_successfully": "Membru șters cu succes",
|
||||
"member_invited_successfully": "Membru invitat cu succes",
|
||||
"once_its_gone_its_gone": "Odată șters, nu va putea fi recuperat.",
|
||||
"once_its_gone_its_gone": "Odată ce a dispărut, a dispărut.",
|
||||
"only_org_owner_can_perform_action": "Doar proprietarii organizației pot accesa această setare.",
|
||||
"organization_created_successfully": "Organizație creată cu succes!",
|
||||
"organization_deleted_successfully": "Organizație ștearsă cu succes!",
|
||||
@@ -1077,7 +1090,7 @@
|
||||
"remove_logo": "Înlătură siglă",
|
||||
"replace_logo": "Înlocuiește sigla",
|
||||
"resend_invitation_email": "Retrimite emailul de invitație",
|
||||
"share_invite_link": "Distribuie link-ul de invitație",
|
||||
"share_invite_link": "Distribuie Link-ul de Invitație",
|
||||
"share_this_link_to_let_your_organization_member_join_your_organization": "Distribuie acest link pentru a permite membrului organizației să se alăture organizației tale:",
|
||||
"test_email_sent_successfully": "Email de test trimis cu succes",
|
||||
"use_multi_language_surveys_with_a_higher_plan": "Utilizați chestionare multilingve cu un plan superior",
|
||||
@@ -1098,35 +1111,43 @@
|
||||
},
|
||||
"profile": {
|
||||
"account_deletion_consequences_warning": "Consecințele ștergerii contului",
|
||||
"avatar_update_failed": "Actualizarea avatarului a eșuat. Vă rugăm să încercați din nou.",
|
||||
"backup_code": "Cod de rezervă",
|
||||
"change_image": "Schimbă imaginea",
|
||||
"confirm_delete_account": "Șterge contul tău cu toate informațiile personale și datele tale",
|
||||
"confirm_delete_my_account": "Șterge contul meu",
|
||||
"confirm_delete_my_account": "Șterge Contul Meu",
|
||||
"confirm_your_current_password_to_get_started": "Confirmaţi parola curentă pentru a începe.",
|
||||
"delete_account": "Șterge cont",
|
||||
"delete_account": "Șterge Cont",
|
||||
"disable_two_factor_authentication": "Dezactivează autentificarea în doi pași",
|
||||
"disable_two_factor_authentication_description": "Dacă este nevoie să dezactivați autentificarea în doi pași, vă recomandăm să o reactivați cât mai curând posibil.",
|
||||
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Fiecare cod de rezervă poate fi utilizat o singură dată pentru a acorda acces fără autentificatorul tău.",
|
||||
"email_change_initiated": "Cererea dvs. de schimbare a e-mailului a fost inițiată.",
|
||||
"enable_two_factor_authentication": "Activează autentificarea în doi pași",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Introduceți codul din aplicația dvs. de autentificare mai jos.",
|
||||
"file_size_must_be_less_than_10mb": "Dimensiunea fișierului trebuie să fie mai mică de 10MB.",
|
||||
"invalid_file_type": "Tip de fișier invalid. Sunt permise numai fișiere JPEG, PNG și WEBP.",
|
||||
"lost_access": "Acces pierdut",
|
||||
"or_enter_the_following_code_manually": "Sau introduceți manual următorul cod:",
|
||||
"organization_identification": "Ajutați organizația să vă identifice pe Formbricks",
|
||||
"organizations_delete_message": "Ești singurul proprietar al acestor organizații, deci ele <b>vor fi șterse și ele.</b>",
|
||||
"permanent_removal_of_all_of_your_personal_information_and_data": "Ștergerea permanentă a tuturor informațiilor și datelor tale personale",
|
||||
"personal_information": "Informații personale",
|
||||
"please_enter_email_to_confirm_account_deletion": "Vă rugăm să introduceți {email} în câmpul următor pentru a confirma ștergerea definitivă a contului dumneavoastră:",
|
||||
"profile_updated_successfully": "Profilul dvs. a fost actualizat cu succes",
|
||||
"remove_image": "Șterge imaginea",
|
||||
"save_the_following_backup_codes_in_a_safe_place": "Salvează următoarele coduri de rezervă într-un loc sigur.",
|
||||
"scan_the_qr_code_below_with_your_authenticator_app": "Scanați codul QR de mai jos cu aplicația dvs. de autentificare.",
|
||||
"security_description": "Gestionează parola și alte setări de securitate, precum autentificarea în doi pași (2FA).",
|
||||
"two_factor_authentication": "Autentificare în doi pași",
|
||||
"two_factor_authentication_description": "Adăugați un strat suplimentar de securitate la contul dvs. în cazul în care parola este furată.",
|
||||
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Autentificare în doi pași activată. Introduceți codul de șase cifre din aplicația dvs. de autentificare.",
|
||||
"two_factor_code": "Codul pentru dublă autentificare",
|
||||
"two_factor_code": "Codul cu doi factori",
|
||||
"unlock_two_factor_authentication": "Deblocați autentificarea în doi pași cu un plan superior",
|
||||
"update_personal_info": "Actualizează informațiile tale personale",
|
||||
"upload_image": "Încărcați imagine",
|
||||
"warning_cannot_delete_account": "Ești singurul proprietar al acestei organizații. Te rugăm să transferi proprietatea către un alt membru mai întâi.",
|
||||
"warning_cannot_undo": "Aceasta nu poate fi anulată"
|
||||
"warning_cannot_undo": "Aceasta nu poate fi anulată",
|
||||
"you_must_select_a_file": "Trebuie să selectați un fișier."
|
||||
},
|
||||
"teams": {
|
||||
"add_members_description": "Adaugă membri în echipă și stabilește rolul lor.",
|
||||
@@ -1175,7 +1196,7 @@
|
||||
}
|
||||
},
|
||||
"surveys": {
|
||||
"all_set_time_to_create_first_survey": "Ești gata! Este timpul să creezi primul tău chestionar",
|
||||
"all_set_time_to_create_first_survey": "Ești gata! Timp să creezi primul tău chestionar",
|
||||
"alphabetical": "Alfabetic",
|
||||
"copy_survey": "Copiază sondajul",
|
||||
"copy_survey_description": "Copiază acest sondaj într-un alt mediu",
|
||||
@@ -1238,7 +1259,9 @@
|
||||
"automatically_close_survey_after": "Închideți automat sondajul după",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Închideți automat sondajul după un număr anumit de răspunsuri.",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Închideți automat sondajul dacă utilizatorul nu răspunde după un anumit număr de secunde.",
|
||||
"automatically_closes_the_survey_at_the_beginning_of_the_day_utc": "Închide automat sondajul la începutul zilei (UTC).",
|
||||
"automatically_mark_the_survey_as_complete_after": "Marcați automat sondajul ca finalizat după",
|
||||
"automatically_release_the_survey_at_the_beginning_of_the_day_utc": "Eliberați automat sondajul la începutul zilei (UTC).",
|
||||
"back_button_label": "Etichetă buton \"Înapoi\"",
|
||||
"background_styling": "Stilizare fundal",
|
||||
"brand_color": "Culoarea brandului",
|
||||
@@ -1262,7 +1285,7 @@
|
||||
"caution_explanation_new_responses_separated": "Răspunsurile înainte de schimbare pot să nu fie sau să fie incluse doar parțial în rezumatul sondajului.",
|
||||
"caution_explanation_only_new_responses_in_summary": "Toate datele, inclusiv răspunsurile anterioare, rămân disponibile ca descărcare pe pagina de rezumat a sondajului.",
|
||||
"caution_explanation_responses_are_safe": "Răspunsurile mai vechi și mai noi se amestecă, ceea ce poate duce la rezumate de date înșelătoare.",
|
||||
"caution_recommendation": "Aceasta poate cauza inconsistențe de date în rezultatul sondajului. Vă recomandăm să duplicați sondajul în schimb.",
|
||||
"caution_recommendation": "Aceasta poate cauza inconsistențe de date în rezumatul sondajului. Vă recomandăm să duplicați sondajul în schimb.",
|
||||
"caution_text": "Schimbările vor duce la inconsecvențe",
|
||||
"centered_modal_overlay_color": "Culoare suprapunere modală centralizată",
|
||||
"change_anyway": "Schimbă oricum",
|
||||
@@ -1286,18 +1309,19 @@
|
||||
"choose_the_actions_which_trigger_the_survey": "Alegeți acțiunile care declanșează sondajul.",
|
||||
"choose_where_to_run_the_survey": "Alegeți unde să rulați chestionarul.",
|
||||
"city": "Oraș",
|
||||
"close_survey_on_date": "Închide sondajul la dată",
|
||||
"close_survey_on_response_limit": "Închideți sondajul la limită de răspunsuri",
|
||||
"color": "Culoare",
|
||||
"column_used_in_logic_error": "Această coloană este folosită în logica întrebării {questionIndex}. Vă rugăm să o eliminați din logică mai întâi.",
|
||||
"columns": "Coloane",
|
||||
"company": "Companie",
|
||||
"company_logo": "Sigla companiei",
|
||||
"completed_responses": "Răspunsuri completate",
|
||||
"completed_responses": "răspunsuri parțiale sau finalizate",
|
||||
"concat": "Concat +",
|
||||
"conditional_logic": "Logică condițională",
|
||||
"confirm_default_language": "Confirmați limba implicită",
|
||||
"confirm_survey_changes": "Confirmă modificările sondajului",
|
||||
"contact_fields": "Câmpuri de contact",
|
||||
"contact_fields": "C�mpuri de contact",
|
||||
"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.",
|
||||
@@ -1324,7 +1348,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_recall": "Editează Referințele",
|
||||
"edit_recall": "Editează Amintirea",
|
||||
"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.",
|
||||
"enable_recaptcha_to_protect_your_survey_from_spam": "Protecția împotriva spamului folosește reCAPTCHA v3 pentru a filtra răspunsurile de spam.",
|
||||
@@ -1332,7 +1356,6 @@
|
||||
"end_screen_card": "Ecran final card",
|
||||
"ending_card": "Cardul de finalizare",
|
||||
"ending_card_used_in_logic": "Această carte de încheiere este folosită în logica întrebării {questionIndex}.",
|
||||
"ending_used_in_quota": "Finalul acesta este folosit în cota \"{quotaName}\"",
|
||||
"ends_with": "Se termină cu",
|
||||
"equals": "Egal",
|
||||
"equals_one_of": "Egal unu dintre",
|
||||
@@ -1343,23 +1366,22 @@
|
||||
"fallback_for": "Varianta de rezervă pentru",
|
||||
"fallback_missing": "Rezerva lipsă",
|
||||
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} este folosit în logică întrebării {questionIndex}. Vă rugăm să-l eliminați din logică mai întâi.",
|
||||
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "Câmpul ascuns \"{fieldId}\" este folosit în cota \"{quotaName}\"",
|
||||
"field_name_eg_score_price": "Nume câmp, de exemplu, scor, preț",
|
||||
"first_name": "Prenume",
|
||||
"five_points_recommended": "5 puncte (recomandat)",
|
||||
"follow_ups": "Follow-up",
|
||||
"follow_ups": "Urmăriri",
|
||||
"follow_ups_delete_modal_text": "Sigur doriți să ștergeți acest follow-up?",
|
||||
"follow_ups_delete_modal_title": "Ștergeți follow-up-ul?",
|
||||
"follow_ups_delete_modal_title": "Ștergeți urmărirea?",
|
||||
"follow_ups_empty_description": "Trimite mesaje respondentilor, ție sau colegilor de echipă.",
|
||||
"follow_ups_empty_heading": "Trimitere automată de follow-up",
|
||||
"follow_ups_ending_card_delete_modal_text": "Această cartă de sfârșit este folosită în follow-up-uri ulterioare. Ștergerea sa o va elimina din toate follow-up-uri ulterioare. Ești sigur că vrei să o ștergi?",
|
||||
"follow_ups_empty_heading": "Trimitere automată de urmăriri",
|
||||
"follow_ups_ending_card_delete_modal_text": "Această cartă de sfârșit este folosită în urmăriri ulterioare. Ștergerea sa o va elimina din toate urmăriri ulterioare. Ești sigur că vrei să o ștergi?",
|
||||
"follow_ups_ending_card_delete_modal_title": "Șterge cardul de finalizare?",
|
||||
"follow_ups_hidden_field_error": "Câmpul ascuns este utilizat într-un follow-up. Vă rugăm să îl eliminați mai întâi din follow-up.",
|
||||
"follow_ups_item_ending_tag": "Finalizare",
|
||||
"follow_ups_item_issue_detected_tag": "Problemă detectată",
|
||||
"follow_ups_item_response_tag": "Orice răspuns",
|
||||
"follow_ups_item_send_email_tag": "Trimite email",
|
||||
"follow_ups_modal_action_attach_response_data_description": "Adăugați datele răspunsului la sondaj la follow-up",
|
||||
"follow_ups_modal_action_attach_response_data_description": "Adăugați datele răspunsului la sondaj la urmărire",
|
||||
"follow_ups_modal_action_attach_response_data_label": "Atașează datele răspunsului",
|
||||
"follow_ups_modal_action_body_label": "Corp",
|
||||
"follow_ups_modal_action_body_placeholder": "Corpul emailului",
|
||||
@@ -1375,22 +1397,20 @@
|
||||
"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-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_action_to_warning": "Nu s-a detectat niciun câmp de e-mail în sondaj",
|
||||
"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 ",
|
||||
"follow_ups_modal_name_placeholder": "Denumirea follow-up-ului tău",
|
||||
"follow_ups_modal_name_label": "Numele urmăririi",
|
||||
"follow_ups_modal_name_placeholder": "Denumirea urmăririi tale",
|
||||
"follow_ups_modal_subheading": "Trimite mesaje respondentilor, ție sau colegilor de echipă",
|
||||
"follow_ups_modal_trigger_description": "Când ar trebui să fie declanșat acest follow-up?",
|
||||
"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": "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_ending_warning": "Nu s-au găsit finalizări în sondaj!",
|
||||
"follow_ups_modal_trigger_type_response": "Respondent finalizează sondajul",
|
||||
"follow_ups_modal_updated_successfull_toast": "Urmărirea a fost actualizată și va fi salvată odată ce salvați sondajul.",
|
||||
"follow_ups_new": "Follow-up nou",
|
||||
"follow_ups_new": "Urmărire nouă",
|
||||
"follow_ups_upgrade_button_text": "Actualizați pentru a activa urmărările",
|
||||
"form_styling": "Stilizare formular",
|
||||
"formbricks_sdk_is_not_connected": "SDK Formbricks nu este conectat",
|
||||
@@ -1447,10 +1467,10 @@
|
||||
"logic_error_warning_text": "Schimbarea tipului de întrebare va elimina condițiile de logică din această întrebare",
|
||||
"long_answer": "Răspuns lung",
|
||||
"lower_label": "Etichetă inferioară",
|
||||
"manage_languages": "Gestionați limbile",
|
||||
"manage_languages": "Gestionați Limbile",
|
||||
"max_file_size": "Dimensiune maximă fișier",
|
||||
"max_file_size_limit_is": "Limita dimensiunii maxime a fișierului este",
|
||||
"multiply": "Multiplicare",
|
||||
"multiply": "Înmulțire *",
|
||||
"needed_for_self_hosted_cal_com_instance": "Necesar pentru un exemplu autogăzduit Cal.com",
|
||||
"next_button_label": "Etichetă buton \"Următorul\"",
|
||||
"next_question": "Întrebarea următoare",
|
||||
@@ -1490,38 +1510,6 @@
|
||||
"question_duplicated": "Întrebare duplicată.",
|
||||
"question_id_updated": "ID întrebare actualizat",
|
||||
"question_used_in_logic": "Această întrebare este folosită în logica întrebării {questionIndex}.",
|
||||
"question_used_in_quota": "Întrebarea aceasta este folosită în cota \"{quotaName}\"",
|
||||
"quotas": {
|
||||
"add_quota": "Adăugați cotă",
|
||||
"change_quota_for_public_survey": "Schimbați cota pentru sondaj public?",
|
||||
"confirm_quota_changes": "Confirmă modificările cotelor",
|
||||
"confirm_quota_changes_body": "Aveți modificări nesalvate în quota dumneavoastră. Doriți să le salvați înainte de a pleca?",
|
||||
"continue_survey_normally": "Continuă chestionarul în mod normal",
|
||||
"count_partial_submissions": "Număr contestații parțiale",
|
||||
"count_partial_submissions_description": "Includeți respondenții care îndeplinesc criteriile de cotă dar nu au completat sondajul",
|
||||
"create_quota_for_public_survey": "Creați cotă pentru sondaj public?",
|
||||
"create_quota_for_public_survey_description": "Doar răspunsurile viitoare vor fi încorporate în cotă",
|
||||
"create_quota_for_public_survey_text": "Acest sondaj este deja public. Răspunsurile actuale nu vor fi luate în considerare pentru noua cotă.",
|
||||
"delete_quota_confirmation_text": "Acest lucru va șterge definitiv cota {quotaName}.",
|
||||
"duplicate_quota": "Duplicare cotă",
|
||||
"edit_quota": "Editează cota",
|
||||
"end_survey_for_matching_participants": "Încheiere sondaj pentru participanții eligibili",
|
||||
"inclusion_criteria": "Criterii de includere",
|
||||
"limit_must_be_greater_than_or_equal_to_the_number_of_responses": "{value, plural, one {Deja aveți {value} răspuns pentru această cotă, astfel încât limita trebuie să fie mai mare decât {value}.} other {Deja aveți {value} răspunsuri pentru această cotă, astfel încât limita trebuie să fie mai mare decât {value}.} }",
|
||||
"limited_to_x_responses": "Limitat la {limit}",
|
||||
"new_quota": "Contingent Nou",
|
||||
"quota_created_successfull_toast": "\"Cota creată cu succes!\"",
|
||||
"quota_deleted_successfull_toast": "\"Cota ștearsă cu succes!\"",
|
||||
"quota_duplicated_successfull_toast": "\"Cota duplicată cu succes!\"",
|
||||
"quota_name_placeholder": "de exemplu, Participanți cu vârsta 18-25 ani",
|
||||
"quota_updated_successfull_toast": "\"Cota actualizată cu succes!\"",
|
||||
"response_limit": "Limitări",
|
||||
"save_changes_confirmation_body": "Orice modificări ale criteriilor de includere afectează doar răspunsurile viitoare. \nRecomandăm fie să duplicați un existent, fie să creați o nouă cotă.",
|
||||
"save_changes_confirmation_text": "Răspunsurile existente rămân în cotă",
|
||||
"select_ending_card": "Selectează cardul de finalizare",
|
||||
"upgrade_prompt_title": "Folosește cote cu un plan superior",
|
||||
"when_quota_has_been_reached": "Când cota a fost atinsă"
|
||||
},
|
||||
"randomize_all": "Randomizează tot",
|
||||
"randomize_all_except_last": "Randomizează tot cu excepția ultimului",
|
||||
"range": "Interval",
|
||||
@@ -1529,17 +1517,18 @@
|
||||
"redirect_thank_you_card": "Redirecționează cardul de mulțumire",
|
||||
"redirect_to_url": "Redirecționează către URL",
|
||||
"redirect_to_url_not_available_on_free_plan": "\"Redirecționarea către URL nu este disponibilă în planul gratuit\"",
|
||||
"release_survey_on_date": "Eliberați sondajul la dată",
|
||||
"remove_description": "Eliminați descrierea",
|
||||
"remove_translations": "Eliminați traducerile",
|
||||
"require_answer": "Cere răspuns",
|
||||
"require_answer": "Cere Răspuns",
|
||||
"required": "Obligatoriu",
|
||||
"reset_to_theme_styles": "Resetare la stilurile temei",
|
||||
"reset_to_theme_styles_main_text": "Sigur doriți să resetați stilul la stilurile de temă? Acest lucru va elimina toate stilizările personalizate.",
|
||||
"response_limit_can_t_be_set_to_0": "Limitul de răspunsuri nu poate fi setat la 0",
|
||||
"response_limit_needs_to_exceed_number_of_received_responses": "Limita răspunsurilor trebuie să depășească numărul de răspunsuri primite ({responseCount}).",
|
||||
"response_limits_redirections_and_more": "Limite de răspunsuri, redirecționări și altele.",
|
||||
"response_options": "Opțiuni răspuns",
|
||||
"roundness": "Rotunjire",
|
||||
"response_options": "Opțiuni Răspuns",
|
||||
"roundness": "Rotunjirea",
|
||||
"row_used_in_logic_error": "Această linie este folosită în logica întrebării {questionIndex}. Vă rugăm să-l eliminați din logică mai întâi.",
|
||||
"rows": "Rânduri",
|
||||
"save_and_close": "Salvează & Închide",
|
||||
@@ -1554,7 +1543,6 @@
|
||||
"send_survey_to_audience_who_match": "Trimiteți sondajul către publicul care se potrivește...",
|
||||
"send_your_respondents_to_a_page_of_your_choice": "Trimiteți respondenții către o pagină la alegerea dumneavoastră",
|
||||
"set_the_global_placement_in_the_look_feel_settings": "Setați amplasarea globală în setările Aspect & Stil.",
|
||||
"settings_saved_successfully": "Setările au fost salvate cu succes.",
|
||||
"seven_points": "7 puncte",
|
||||
"show_advanced_settings": "Afișați setările avansate",
|
||||
"show_button": "Afișează butonul",
|
||||
@@ -1599,7 +1587,7 @@
|
||||
"three_points": "3 puncte",
|
||||
"times": "ori",
|
||||
"to_keep_the_placement_over_all_surveys_consistent_you_can": "Pentru a menține amplasarea consecventă pentru toate sondajele, puteți",
|
||||
"trigger_survey_when_one_of_the_actions_is_fired": "Declanșați sondajul atunci când una dintre acțiuni este realizată...",
|
||||
"trigger_survey_when_one_of_the_actions_is_fired": "Declanșați sondajul atunci când una dintre acțiuni este declanșată...",
|
||||
"try_lollipop_or_mountain": "Încercați „lollipop” sau „mountain”...",
|
||||
"type_field_id": "ID câmp tip",
|
||||
"unlock_targeting_description": "Vizează grupuri specifice de utilizatori pe baza atributelor sau a informațiilor despre dispozitiv",
|
||||
@@ -1615,7 +1603,6 @@
|
||||
"url_not_supported": "URL nesuportat",
|
||||
"use_with_caution": "Folosește cu precauție",
|
||||
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} este folosit în logica întrebării {questionIndex}. Vă rugăm să-l eliminați din logică mai întâi.",
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variabila \"{variableName}\" este folosită în cota \"{quotaName}\"",
|
||||
"variable_name_is_already_taken_please_choose_another": "Numele variabilei este deja utilizat, vă rugăm să alegeți altul.",
|
||||
"variable_name_must_start_with_a_letter": "Numele variabilei trebuie să înceapă cu o literă.",
|
||||
"verify_email_before_submission": "Verifică emailul înainte de trimitere",
|
||||
@@ -1639,7 +1626,7 @@
|
||||
"complete_responses": "Răspunsuri complete",
|
||||
"partial_responses": "Răspunsuri parțiale"
|
||||
},
|
||||
"new_survey": "Chestionar nou",
|
||||
"new_survey": "Chestionar Nou",
|
||||
"no_surveys_created_yet": "Nu au fost create încă chestionare",
|
||||
"open_options": "Opțiuni deschise",
|
||||
"preview_survey_in_a_new_tab": "Previzualizare chestionar în alt tab",
|
||||
@@ -1648,16 +1635,16 @@
|
||||
"responses": {
|
||||
"address_line_1": "Adresă Linie 1",
|
||||
"address_line_2": "Adresă Linie 2",
|
||||
"an_error_occurred_creating_a_new_note": "A apărut o eroare la crearea unei noi note",
|
||||
"an_error_occurred_deleting_the_tag": "A apărut o eroare la ștergerea etichetei",
|
||||
"an_error_occurred_resolving_a_note": "A apărut o eroare la rezolvarea unei note",
|
||||
"an_error_occurred_updating_a_note": "A apărut o eroare la actualizarea unei note",
|
||||
"browser": "Browser",
|
||||
"bulk_delete_response_quotas": "Răspunsurile fac parte din cotele pentru acest sondaj. Cum doriți să gestionați cotele?",
|
||||
"city": "Oraș",
|
||||
"company": "Companie",
|
||||
"completed": "Finalizat ✅",
|
||||
"country": "Țară",
|
||||
"decrement_quotas": "Decrementați toate limitele cotelor, inclusiv acest răspuns",
|
||||
"delete_response_confirmation": "Aceasta va șterge răspunsul la sondaj, inclusiv toate răspunsurile, etichetele, documentele atașate și metadatele răspunsului.",
|
||||
"delete_response_quotas": "Răspunsul face parte din cotele pentru acest sondaj. Cum doriți să gestionați cotele?",
|
||||
"delete_response_confirmation": "Aceasta va șterge răspunsul la sondaj, inclusiv toate răspunsurile, notele, etichetele, documentele atașate și metadatele răspunsului.",
|
||||
"device": "Dispozitiv",
|
||||
"device_info": "Informații despre dispozitiv",
|
||||
"email": "Email",
|
||||
@@ -1669,6 +1656,7 @@
|
||||
"os": "SO",
|
||||
"person_attributes": "Atribute persoană",
|
||||
"phone": "Telefon",
|
||||
"resolve": "Rezolvă",
|
||||
"respondent_skipped_questions": "Respondenții au sărit peste aceste întrebări.",
|
||||
"response_deleted_successfully": "Răspuns șters cu succes.",
|
||||
"single_use_id": "IdentificatorUnicFolositOdată",
|
||||
@@ -1725,17 +1713,6 @@
|
||||
"embed_mode_description": "Incorporează sondajul tău cu un design minimalist, eliminând spațiul și fundalul.",
|
||||
"nav_title": "Incorporare pe site web"
|
||||
},
|
||||
"link_settings": {
|
||||
"description": "Atribuiți linkurilor un titlu, o descriere și o imagine personalizată pentru partajarea în public.",
|
||||
"language_help_text": "Meta datele sunt încărcate pe baza valorii `lang` din URL.",
|
||||
"link_description": "Descriere legătură",
|
||||
"link_description_description": "Descrierile între 55-200 de caractere au cele mai bune performanțe.",
|
||||
"link_title": "Titlu link",
|
||||
"link_title_description": "Titlurile scurte funcționează cel mai bine ca Meta Title-uri.",
|
||||
"preview_image": "Previzualizare imagine",
|
||||
"preview_image_description": "Imaginile panoramice cu dimensiuni de fișier mici (<4MB) au cel mai bun randament.",
|
||||
"title": "Setări link"
|
||||
},
|
||||
"personal_links": {
|
||||
"create_and_manage_segments": "Creați și gestionați segmentele dvs. sub Contacte > Segmente",
|
||||
"description": "Generează linkuri personale pentru un segment și corelează răspunsurile la sondaje cu fiecare contact.",
|
||||
@@ -1755,7 +1732,7 @@
|
||||
"send_email": {
|
||||
"copy_embed_code": "Copiază codul de inserare",
|
||||
"description": "Inserați sondajul dvs. într-un e-mail pentru a obține răspunsuri de la audiența dvs.",
|
||||
"email_preview_tab": "Previzualizare email",
|
||||
"email_preview_tab": "Previzualizare Email",
|
||||
"email_sent": "Email trimis!",
|
||||
"email_subject_label": "Subiect",
|
||||
"email_to_label": "Către",
|
||||
@@ -1767,7 +1744,6 @@
|
||||
"send_preview": "Trimite previzualizare",
|
||||
"send_preview_email": "Trimite email de previzualizare"
|
||||
},
|
||||
"share_settings_title": "Setări de partajare",
|
||||
"share_view_title": "Distribuie prin",
|
||||
"social_media": {
|
||||
"description": "Obține răspunsuri de la contactele tale pe diferite rețele sociale.",
|
||||
@@ -1789,7 +1765,6 @@
|
||||
"configure_alerts": "Configurează alertele",
|
||||
"congrats": "Felicitări! Sondajul dumneavoastră este activ.",
|
||||
"connect_your_website_or_app_with_formbricks_to_get_started": "Conectează-ți site-ul sau aplicația cu Formbricks pentru a începe.",
|
||||
"current_count": "Număr curent",
|
||||
"custom_range": "Interval personalizat...",
|
||||
"delete_all_existing_responses_and_displays": "Șterge toate răspunsurile și afișările existente",
|
||||
"download_qr_code": "Descărcare cod QR",
|
||||
@@ -1800,7 +1775,7 @@
|
||||
"filter_updated_successfully": "Filtru actualizat cu succes",
|
||||
"filtered_responses_csv": "Răspunsuri filtrate (CSV)",
|
||||
"filtered_responses_excel": "Răspunsuri filtrate (Excel)",
|
||||
"go_to_setup_checklist": "Mergi la lista de verificare a configurării \uD83D\uDC49",
|
||||
"go_to_setup_checklist": "Mergi la Lista de Verificare a Configurării \uD83D\uDC49",
|
||||
"impressions": "Impresii",
|
||||
"impressions_tooltip": "Număr de ori când sondajul a fost vizualizat.",
|
||||
"in_app": {
|
||||
@@ -1843,7 +1818,6 @@
|
||||
"last_month": "Ultima lună",
|
||||
"last_quarter": "Ultimul trimestru",
|
||||
"last_year": "Anul trecut",
|
||||
"limit": "Limită",
|
||||
"no_responses_found": "Nu s-au găsit răspunsuri",
|
||||
"other_values_found": "Alte valori găsite",
|
||||
"overall": "General",
|
||||
@@ -1852,8 +1826,6 @@
|
||||
"qr_code_download_failed": "Descărcarea codului QR a eșuat",
|
||||
"qr_code_download_with_start_soon": "Descărcarea codului QR va începe în curând",
|
||||
"qr_code_generation_failed": "A apărut o problemă la încărcarea codului QR al chestionarului. Vă rugăm să încercați din nou.",
|
||||
"quotas_completed": "Cote completate",
|
||||
"quotas_completed_tooltip": "Numărul de cote completate de respondenți.",
|
||||
"reset_survey": "Resetează chestionarul",
|
||||
"reset_survey_warning": "Resetarea unui sondaj elimină toate răspunsurile și afișajele asociate cu acest sondaj. Aceasta nu poate fi anulată.",
|
||||
"selected_responses_csv": "Răspunsuri selectate (CSV)",
|
||||
@@ -1869,7 +1841,7 @@
|
||||
"this_quarter": "Trimestrul acesta",
|
||||
"this_year": "Anul acesta",
|
||||
"time_to_complete": "Timp de finalizare",
|
||||
"ttc_tooltip": "Timp mediu pentru a completa întrebarea.",
|
||||
"ttc_tooltip": "Timp mediu pentru a completa sondajul.",
|
||||
"unknown_question_type": "Tip de întrebare necunoscut",
|
||||
"use_personal_links": "Folosește linkuri personale",
|
||||
"waiting_for_response": "Așteptând un răspuns \uD83E\uDDD8♂️",
|
||||
@@ -1880,6 +1852,7 @@
|
||||
"survey_deleted_successfully": "\"Sondaj șters cu succes!\"",
|
||||
"survey_duplicated_successfully": "\"Sondaj duplicat cu succes!\"",
|
||||
"survey_duplication_error": "Eșec la duplicarea sondajului.",
|
||||
"survey_status_tooltip": "Pentru a actualiza starea sondajului, actualizați programarea și setările de închidere în opțiunile de răspuns la sondaj.",
|
||||
"templates": {
|
||||
"all_channels": "Toate canalele",
|
||||
"all_industries": "Toate industriile",
|
||||
@@ -1969,7 +1942,7 @@
|
||||
"intro": {
|
||||
"get_started": "Începeți",
|
||||
"made_with_love_in_kiel": "Creat cu \uD83E\uDD0D în Germania",
|
||||
"paragraph_1": "Formbricks este o suită de management al experiențelor construită pe baza <b>platformei de sondaje open source care crește cel mai rapid</b> din lume.",
|
||||
"paragraph_1": "Formbricks este o Suită de Management al Experiențelor construită pe baza <b>platformei de sondaje open source care crește cel mai rapid</b> din lume.",
|
||||
"paragraph_2": "Rulați sondaje direcționate pe site-uri web, în aplicații sau oriunde online. Adunați informații valoroase pentru a <b>crea experiențe irezistibile</b> pentru clienți, utilizatori și angajați.",
|
||||
"paragraph_3": "Suntem angajați la cel mai înalt grad de confidențialitate a datelor. Găzduirea proprie vă oferă <b>control deplin asupra datelor dumneavoastră</b>.",
|
||||
"welcome_to_formbricks": "Bine ai venit la Formbricks!"
|
||||
@@ -2069,7 +2042,7 @@
|
||||
"career_development_survey_question_4_headline": "Sunt mulțumit de investiția pe care organizația mea o face în formare și educație.",
|
||||
"career_development_survey_question_4_lower_label": "Dezacord puternic",
|
||||
"career_development_survey_question_4_upper_label": "De acord cu tărie",
|
||||
"career_development_survey_question_5_choice_1": "Dezvoltare de produs",
|
||||
"career_development_survey_question_5_choice_1": "Dezvoltare de Produs",
|
||||
"career_development_survey_question_5_choice_2": "Marketing",
|
||||
"career_development_survey_question_5_choice_3": "Relații Publice",
|
||||
"career_development_survey_question_5_choice_4": "Contabilitate",
|
||||
@@ -2139,7 +2112,7 @@
|
||||
"collect_feedback_question_5_headline": "Mai dorești să împărtășești altceva cu echipa noastră?",
|
||||
"collect_feedback_question_5_placeholder": "Tastează răspunsul aici...",
|
||||
"collect_feedback_question_6_choice_1": "Google",
|
||||
"collect_feedback_question_6_choice_2": "Rețele sociale",
|
||||
"collect_feedback_question_6_choice_2": "Rețele Sociale",
|
||||
"collect_feedback_question_6_choice_3": "Prieteni",
|
||||
"collect_feedback_question_6_choice_4": "Podcast",
|
||||
"collect_feedback_question_6_choice_5": "Altele",
|
||||
@@ -2268,7 +2241,7 @@
|
||||
"earned_advocacy_score_question_5_headline": "Ce te-a făcut să îi descurajezi?",
|
||||
"earned_advocacy_score_question_5_placeholder": "Tastează răspunsul aici...",
|
||||
"employee_satisfaction_description": "Evaluează satisfacția angajaților și identifică domeniile de îmbunătățire.",
|
||||
"employee_satisfaction_name": "Satisfacție a angajatului",
|
||||
"employee_satisfaction_name": "Satisfacție a Angajatului",
|
||||
"employee_satisfaction_question_1_headline": "Cât de satisfăcut sunteți de rolul dvs. actual?",
|
||||
"employee_satisfaction_question_1_lower_label": "Nesatisfăcut",
|
||||
"employee_satisfaction_question_1_upper_label": "Foarte mulțumit",
|
||||
@@ -2292,7 +2265,7 @@
|
||||
"employee_satisfaction_question_7_choice_5": "Deloc probabil",
|
||||
"employee_satisfaction_question_7_headline": "Cât de probabil este să recomandați compania noastră unui prieten?",
|
||||
"employee_well_being_description": "Evaluează bunăstarea angajatului prin echilibrul între muncă și viață, volumul de muncă și mediul de lucru.",
|
||||
"employee_well_being_name": "Bunăstarea angajatului",
|
||||
"employee_well_being_name": "Bunăstarea Angajatului",
|
||||
"employee_well_being_question_1_headline": "Simt că am un echilibru bun între viața mea profesională și cea personală.",
|
||||
"employee_well_being_question_1_lower_label": "Echilibru foarte slab",
|
||||
"employee_well_being_question_1_upper_label": "Echilibru excelent",
|
||||
@@ -2354,7 +2327,7 @@
|
||||
"fake_door_follow_up_question_2_choice_4": "Aspectul 4",
|
||||
"fake_door_follow_up_question_2_headline": "Ce ar trebui să includem cu siguranță în construirea acestuia?",
|
||||
"feature_chaser_description": "Urmăriți utilizatorii care tocmai au folosit o funcție specifică.",
|
||||
"feature_chaser_name": "Urmăritor de funcționalități",
|
||||
"feature_chaser_name": "Urmăritor de Funcționalități",
|
||||
"feature_chaser_question_1_headline": "Cât de importantă este [ADD FEATURE] pentru tine?",
|
||||
"feature_chaser_question_1_lower_label": "Neimportant",
|
||||
"feature_chaser_question_1_upper_label": "Foarte important",
|
||||
@@ -2395,7 +2368,7 @@
|
||||
"identify_customer_goals_description": "Înțelegeți mai bine dacă mesajele voastre creează așteptările corecte privind valoarea pe care o oferă produsul vostru.",
|
||||
"identify_customer_goals_name": "Identifică Obiectivele Clienților",
|
||||
"identify_sign_up_barriers_description": "Oferiți o reducere pentru a obține informații despre barierele de înscriere.",
|
||||
"identify_sign_up_barriers_name": "Identificați barierele de înscriere",
|
||||
"identify_sign_up_barriers_name": "Identificați Barierele de Înscriere",
|
||||
"identify_sign_up_barriers_question_1_button_label": "Obține reducere de 10%",
|
||||
"identify_sign_up_barriers_question_1_dismiss_button_label": "Nu, mulţumesc",
|
||||
"identify_sign_up_barriers_question_1_headline": "Răspunde acestui scurt sondaj, primește 10% reducere!",
|
||||
@@ -2431,7 +2404,7 @@
|
||||
"identify_upsell_opportunities_question_1_choice_4": "5+ ore",
|
||||
"identify_upsell_opportunities_question_1_headline": "Câte ore economisește echipa dumneavoastră pe săptămână folosind $[projectName]?",
|
||||
"improve_activation_rate_description": "Identifică punctele slabe în fluxul de onboarding pentru a crește activarea utilizatorilor.",
|
||||
"improve_activation_rate_name": "Îmbunătățește rata de activare",
|
||||
"improve_activation_rate_name": "Îmbunătățește Rata de Activare",
|
||||
"improve_activation_rate_question_1_choice_1": "Nu părea util pentru mine",
|
||||
"improve_activation_rate_question_1_choice_2": "Dificil de configurat sau utilizat",
|
||||
"improve_activation_rate_question_1_choice_3": "Lipsit de funcții/funcționalități",
|
||||
@@ -2453,12 +2426,12 @@
|
||||
"improve_newsletter_content_name": "Îmbunătățește Conținutul Newsletterului",
|
||||
"improve_newsletter_content_question_1_headline": "Cum ați evalua newsletterul din această săptămână?",
|
||||
"improve_newsletter_content_question_1_lower_label": "Însă",
|
||||
"improve_newsletter_content_question_1_upper_label": "Grozav",
|
||||
"improve_newsletter_content_question_1_upper_label": "Groza",
|
||||
"improve_newsletter_content_question_2_headline": "Ce ar fi făcut ca newsletter-ul din această săptămână să fie mai util?",
|
||||
"improve_newsletter_content_question_2_placeholder": "Tastează răspunsul aici...",
|
||||
"improve_newsletter_content_question_3_button_label": "Bucuros să ajut!",
|
||||
"improve_newsletter_content_question_3_dismiss_button_label": "Găsește-ți proprii prieteni",
|
||||
"improve_newsletter_content_question_3_headline": "Mulțumim! ❤️ Răspândește iubirea către un prieten.",
|
||||
"improve_newsletter_content_question_3_headline": "Mulțumim! ❤️ Răspândește iubirea către UN prieten.",
|
||||
"improve_newsletter_content_question_3_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>Cine gândește ca tine? Ne-ai face o mare favoare dacă ai împărtăși episodul acestei săptămâni cu prietenul tău de creier!</span></p>",
|
||||
"improve_trial_conversion_description": "Află de ce oamenii au încetat perioada de încercare. Aceste informații te ajută să îți îmbunătățești procesul de achiziție.",
|
||||
"improve_trial_conversion_name": "Îmbunătățește Conversia În Proba",
|
||||
@@ -2490,7 +2463,7 @@
|
||||
"integration_setup_survey_question_3_headline": "Ce alte instrumente ați dori să utilizați cu $[projectName]?",
|
||||
"integration_setup_survey_question_3_subheader": "Continuăm să dezvoltăm integrări, a ta poate fi următoarea:",
|
||||
"interview_prompt_description": "Invită un subset specific de utilizatori să programeze un interviu cu echipa ta de produs.",
|
||||
"interview_prompt_name": "Întrebare interviu",
|
||||
"interview_prompt_name": "Întrebare Interviu",
|
||||
"interview_prompt_question_1_button_label": "Rezervă intervalul",
|
||||
"interview_prompt_question_1_headline": "Ai 15 minute să discuți cu noi? \uD83D\uDE4F",
|
||||
"interview_prompt_question_1_html": "Ești unul dintre utilizatorii noștri frecvenți. Ne-ar plăcea să te intervievăm pe scurt!",
|
||||
@@ -2529,16 +2502,16 @@
|
||||
"long_term_retention_check_in_question_9_lower_label": "Nemulțumit",
|
||||
"long_term_retention_check_in_question_9_upper_label": "Foarte mulțumit",
|
||||
"market_attribution_description": "Aflați cum au auzit utilizatorii pentru prima dată despre produsul dumneavoastră.",
|
||||
"market_attribution_name": "Atribuirea marketingului",
|
||||
"market_attribution_name": "Atribuirea Marketingului",
|
||||
"market_attribution_question_1_choice_1": "Recomandare",
|
||||
"market_attribution_question_1_choice_2": "Rețele sociale",
|
||||
"market_attribution_question_1_choice_2": "Rețele Sociale",
|
||||
"market_attribution_question_1_choice_3": "Reclame",
|
||||
"market_attribution_question_1_choice_4": "Căutare Google",
|
||||
"market_attribution_question_1_choice_5": "Într-un podcast",
|
||||
"market_attribution_question_1_choice_5": "Într-un Podcast",
|
||||
"market_attribution_question_1_headline": "Cum ați aflat pentru prima dată despre noi?",
|
||||
"market_attribution_question_1_subheader": "Vă rugăm să selectați una dintre următoarele opțiuni:",
|
||||
"market_site_clarity_description": "Identificați utilizatorii care părăsesc site-ul dvs. de marketing. Îmbunătățiți mesajele dvs.",
|
||||
"market_site_clarity_name": "Claritate site de marketing",
|
||||
"market_site_clarity_name": "Claritate Site de Marketing",
|
||||
"market_site_clarity_question_1_choice_1": "Da, complet",
|
||||
"market_site_clarity_question_1_choice_2": "Un fel de...",
|
||||
"market_site_clarity_question_1_choice_3": "Nu, deloc",
|
||||
@@ -2616,17 +2589,17 @@
|
||||
"onboarding_segmentation_question_2_headline": "Care este dimensiunea companiei dumneavoastră?",
|
||||
"onboarding_segmentation_question_2_subheader": "Vă rugăm să selectați una dintre următoarele opțiuni:",
|
||||
"onboarding_segmentation_question_3_choice_1": "Recomandare",
|
||||
"onboarding_segmentation_question_3_choice_2": "Rețele sociale",
|
||||
"onboarding_segmentation_question_3_choice_2": "Rețele Sociale",
|
||||
"onboarding_segmentation_question_3_choice_3": "Reclame",
|
||||
"onboarding_segmentation_question_3_choice_4": "Căutare Google",
|
||||
"onboarding_segmentation_question_3_choice_5": "Într-un Podcast",
|
||||
"onboarding_segmentation_question_3_headline": "Cum ai aflat pentru prima dată despre noi?",
|
||||
"onboarding_segmentation_question_3_subheader": "Vă rugăm să selectați una dintre următoarele opțiuni:",
|
||||
"picture_selection": "Selecție poze",
|
||||
"picture_selection": "Selecție Poze",
|
||||
"picture_selection_description": "Cereți respondenților să aleagă una sau mai multe imagini",
|
||||
"preview_survey_ending_card_description": "Vă rugăm să continuați onboarding-ul.",
|
||||
"preview_survey_ending_card_headline": "Ai reușit!",
|
||||
"preview_survey_name": "Previzualizare chestionar",
|
||||
"preview_survey_name": "Previzualizare Chestionar",
|
||||
"preview_survey_question_1_headline": "Cum ai evalua {projectName}?",
|
||||
"preview_survey_question_1_lower_label": "Nu este bine",
|
||||
"preview_survey_question_1_subheader": "Aceasta este o previzualizare a chestionarului.",
|
||||
@@ -2638,7 +2611,7 @@
|
||||
"preview_survey_welcome_card_headline": "Bun venit!",
|
||||
"preview_survey_welcome_card_html": "Mulțumesc pentru feedback-ul dvs - să începem!",
|
||||
"prioritize_features_description": "Identificați caracteristicile de care utilizatorii dumneavoastră au cel mai mult și cel mai puțin nevoie.",
|
||||
"prioritize_features_name": "Prioritizați caracteristicile",
|
||||
"prioritize_features_name": "Prioritizați Caracteristicile",
|
||||
"prioritize_features_question_1_choice_1": "Caracteristica 1",
|
||||
"prioritize_features_question_1_choice_2": "Caracteristica 2",
|
||||
"prioritize_features_question_1_choice_3": "Caracteristica 3",
|
||||
@@ -2682,7 +2655,7 @@
|
||||
"product_market_fit_superhuman_question_6_headline": "Cum putem îmbunătăți $[projectName] pentru dumneavoastră?",
|
||||
"product_market_fit_superhuman_question_6_subheader": "Vă rugăm să fiți cât mai specific posibil.",
|
||||
"professional_development_growth_survey_description": "Evaluează satisfacția angajaților cu privire la oportunitățile de dezvoltare și creștere profesională.",
|
||||
"professional_development_growth_survey_name": "Sondaj de creștere și dezvoltare profesională",
|
||||
"professional_development_growth_survey_name": "Sondaj de Creștere și Dezvoltare Profesională",
|
||||
"professional_development_growth_survey_question_1_headline": "Simt că am oportunități să cresc și să-mi dezvolt abilitățile la muncă.",
|
||||
"professional_development_growth_survey_question_1_lower_label": "Nicio oportunitate de creștere",
|
||||
"professional_development_growth_survey_question_1_upper_label": "Multe oportunități de creștere",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user