mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-15 20:40:59 -05:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cd8bba598f | |||
| d4856a9b32 | |||
| 785359955a | |||
| f6157d5109 | |||
| 070dd9f268 | |||
| 7a40d647d8 | |||
| 2186a1c60d | |||
| 2054de4a9d | |||
| e068955fbf | |||
| 4f5180ea8f | |||
| 093013e1d2 | |||
| 8b5b4b4172 | |||
| 36c5fc4a65 | |||
| df191de1b4 | |||
| 8bb5428548 | |||
| b78f8d0599 | |||
| 36535e1e50 | |||
| e26a188d1b | |||
| aaea129d4f | |||
| 18f4cd977d | |||
| 5468510f5a | |||
| 76213af5d7 | |||
| cdf0926c60 | |||
| 84b3c57087 | |||
| ed10069b39 |
@@ -1,152 +0,0 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
# EKS & ALB Optimization Guide for Error Reduction
|
||||
|
||||
## Infrastructure Overview
|
||||
|
||||
This project uses AWS EKS with Application Load Balancer (ALB) for the Formbricks application. The infrastructure has been optimized to minimize ELB 502/504 errors through careful configuration of connection handling, health checks, and pod lifecycle management.
|
||||
|
||||
## Key Infrastructure Files
|
||||
|
||||
### Terraform Configuration
|
||||
- **Main Infrastructure**: [infra/terraform/main.tf](mdc:infra/terraform/main.tf) - EKS cluster, VPC, Karpenter, and core AWS resources
|
||||
- **Monitoring**: [infra/terraform/cloudwatch.tf](mdc:infra/terraform/cloudwatch.tf) - CloudWatch alarms for 502/504 error tracking and alerting
|
||||
- **Database**: [infra/terraform/rds.tf](mdc:infra/terraform/rds.tf) - Aurora PostgreSQL configuration
|
||||
|
||||
### Helm Configuration
|
||||
- **Production**: [infra/formbricks-cloud-helm/values.yaml.gotmpl](mdc:infra/formbricks-cloud-helm/values.yaml.gotmpl) - Optimized ALB and pod configurations
|
||||
- **Staging**: [infra/formbricks-cloud-helm/values-staging.yaml.gotmpl](mdc:infra/formbricks-cloud-helm/values-staging.yaml.gotmpl) - Staging environment with spot instances
|
||||
- **Deployment**: [infra/formbricks-cloud-helm/helmfile.yaml.gotmpl](mdc:infra/formbricks-cloud-helm/helmfile.yaml.gotmpl) - Multi-environment Helm releases
|
||||
|
||||
## ALB Optimization Patterns
|
||||
|
||||
### Connection Handling Optimizations
|
||||
```yaml
|
||||
# Key ALB annotations for reducing 502/504 errors
|
||||
alb.ingress.kubernetes.io/load-balancer-attributes: |
|
||||
idle_timeout.timeout_seconds=120,
|
||||
connection_logs.s3.enabled=false,
|
||||
access_logs.s3.enabled=false
|
||||
|
||||
alb.ingress.kubernetes.io/target-group-attributes: |
|
||||
deregistration_delay.timeout_seconds=30,
|
||||
stickiness.enabled=false,
|
||||
load_balancing.algorithm.type=least_outstanding_requests,
|
||||
target_group_health.dns_failover.minimum_healthy_targets.count=1
|
||||
```
|
||||
|
||||
### Health Check Configuration
|
||||
- **Interval**: 15 seconds for faster detection of unhealthy targets
|
||||
- **Timeout**: 5 seconds to prevent false positives
|
||||
- **Thresholds**: 2 healthy, 3 unhealthy for balanced responsiveness
|
||||
- **Path**: `/health` endpoint optimized for < 100ms response time
|
||||
|
||||
## Pod Lifecycle Management
|
||||
|
||||
### Graceful Shutdown Pattern
|
||||
```yaml
|
||||
# PreStop hook to allow connection draining
|
||||
lifecycle:
|
||||
preStop:
|
||||
exec:
|
||||
command: ["/bin/sh", "-c", "sleep 15"]
|
||||
|
||||
# Termination grace period for complete cleanup
|
||||
terminationGracePeriodSeconds: 45
|
||||
```
|
||||
|
||||
### Health Probe Strategy
|
||||
- **Startup Probe**: 5s initial delay, 5s interval, max 60s startup time
|
||||
- **Readiness Probe**: 10s delay, 10s interval for traffic readiness
|
||||
- **Liveness Probe**: 30s delay, 30s interval for container health
|
||||
|
||||
### Rolling Update Configuration
|
||||
```yaml
|
||||
strategy:
|
||||
type: RollingUpdate
|
||||
rollingUpdate:
|
||||
maxUnavailable: 25% # Maintain capacity during updates
|
||||
maxSurge: 50% # Allow faster rollouts
|
||||
```
|
||||
|
||||
## Karpenter Node Management
|
||||
|
||||
### Node Lifecycle Optimization
|
||||
- **Startup Taints**: Prevent traffic during node initialization
|
||||
- **Graceful Shutdown**: 30s grace period for pod eviction
|
||||
- **Consolidation Delay**: 60s to reduce unnecessary churn
|
||||
- **Eviction Policies**: Configured for smooth pod migrations
|
||||
|
||||
### Instance Selection
|
||||
- **Families**: c8g, c7g, m8g, m7g, r8g, r7g (ARM64 Graviton)
|
||||
- **Sizes**: 2, 4, 8 vCPUs for cost optimization
|
||||
- **Bottlerocket AMI**: Enhanced security and performance
|
||||
|
||||
## Monitoring & Alerting
|
||||
|
||||
### Critical ALB Metrics
|
||||
1. **ELB 502 Errors**: Threshold 20 over 5 minutes
|
||||
2. **ELB 504 Errors**: Threshold 15 over 5 minutes
|
||||
3. **Target Connection Errors**: Threshold 50 over 5 minutes
|
||||
4. **4XX Errors**: Threshold 100 over 10 minutes (client issues)
|
||||
|
||||
### Expected Improvements
|
||||
- **60-80% reduction** in ELB 502 errors
|
||||
- **Faster recovery** during pod restarts
|
||||
- **Better connection reuse** efficiency
|
||||
- **Improved autoscaling** responsiveness
|
||||
|
||||
## Deployment Patterns
|
||||
|
||||
### Infrastructure Updates
|
||||
1. **Terraform First**: Apply infrastructure changes via [infra/deploy-improvements.sh](mdc:infra/deploy-improvements.sh)
|
||||
2. **Helm Second**: Deploy application configurations
|
||||
3. **Verification**: Check pod status, endpoints, and ALB health
|
||||
4. **Monitoring**: Watch CloudWatch metrics for 24-48 hours
|
||||
|
||||
### Environment-Specific Configurations
|
||||
- **Production**: On-demand instances, stricter resource limits
|
||||
- **Staging**: Spot instances, rate limiting disabled, relaxed resources
|
||||
|
||||
## Troubleshooting Patterns
|
||||
|
||||
### 502 Error Investigation
|
||||
1. Check pod readiness and health probe status
|
||||
2. Verify ALB target group health
|
||||
3. Review deregistration timing during deployments
|
||||
4. Monitor connection pool utilization
|
||||
|
||||
### 504 Error Analysis
|
||||
1. Check application response times
|
||||
2. Verify timeout configurations (ALB: 120s, App: aligned)
|
||||
3. Review database query performance
|
||||
4. Monitor resource utilization during traffic spikes
|
||||
|
||||
### Connection Error Patterns
|
||||
1. Verify Karpenter node lifecycle timing
|
||||
2. Check pod termination grace periods
|
||||
3. Review ALB connection draining settings
|
||||
4. Monitor cluster autoscaling events
|
||||
|
||||
## Best Practices
|
||||
|
||||
### When Making Changes
|
||||
- **Test in staging first** with same configurations
|
||||
- **Monitor metrics** for 24-48 hours after changes
|
||||
- **Use gradual rollouts** with proper health checks
|
||||
- **Maintain ALB timeout alignment** across all layers
|
||||
|
||||
### Performance Optimization
|
||||
- **Health endpoint** should respond < 100ms consistently
|
||||
- **Connection pooling** aligned with ALB idle timeouts
|
||||
- **Resource requests/limits** tuned for consistent performance
|
||||
- **Graceful shutdown** implemented in application code
|
||||
|
||||
### Monitoring Strategy
|
||||
- **Real-time alerts** for error rate spikes
|
||||
- **Trend analysis** for connection patterns
|
||||
- **Capacity planning** based on LCU usage
|
||||
- **4XX pattern analysis** for client behavior insights
|
||||
@@ -0,0 +1,165 @@
|
||||
name: PR Size Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
check-pr-size:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: Harden the runner
|
||||
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Check PR size
|
||||
id: check-size
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Fetch the base branch
|
||||
git fetch origin "${{ github.base_ref }}"
|
||||
|
||||
# Get diff stats
|
||||
diff_output=$(git diff --numstat "origin/${{ github.base_ref }}"...HEAD)
|
||||
|
||||
# Count lines, excluding:
|
||||
# - Test files (*.test.ts, *.spec.tsx, etc.)
|
||||
# - Locale files (locales/*.json, i18n/*.json)
|
||||
# - Lock files (pnpm-lock.yaml, package-lock.json, yarn.lock)
|
||||
# - Generated files (dist/, coverage/, build/, .next/)
|
||||
# - Storybook stories (*.stories.tsx)
|
||||
|
||||
total_additions=0
|
||||
total_deletions=0
|
||||
counted_files=0
|
||||
excluded_files=0
|
||||
|
||||
while IFS=$'\t' read -r additions deletions file; do
|
||||
# Skip if additions or deletions are "-" (binary files)
|
||||
if [ "$additions" = "-" ] || [ "$deletions" = "-" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Check if file should be excluded
|
||||
case "$file" in
|
||||
*.test.ts|*.test.tsx|*.spec.ts|*.spec.tsx|*.test.js|*.test.jsx|*.spec.js|*.spec.jsx)
|
||||
excluded_files=$((excluded_files + 1))
|
||||
continue
|
||||
;;
|
||||
*/locales/*.json|*/i18n/*.json)
|
||||
excluded_files=$((excluded_files + 1))
|
||||
continue
|
||||
;;
|
||||
pnpm-lock.yaml|package-lock.json|yarn.lock)
|
||||
excluded_files=$((excluded_files + 1))
|
||||
continue
|
||||
;;
|
||||
dist/*|coverage/*|build/*|node_modules/*|test-results/*|playwright-report/*|.next/*|*.tsbuildinfo)
|
||||
excluded_files=$((excluded_files + 1))
|
||||
continue
|
||||
;;
|
||||
*.stories.ts|*.stories.tsx|*.stories.js|*.stories.jsx)
|
||||
excluded_files=$((excluded_files + 1))
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
|
||||
total_additions=$((total_additions + additions))
|
||||
total_deletions=$((total_deletions + deletions))
|
||||
counted_files=$((counted_files + 1))
|
||||
done <<EOF
|
||||
${diff_output}
|
||||
EOF
|
||||
|
||||
total_changes=$((total_additions + total_deletions))
|
||||
|
||||
echo "counted_files=${counted_files}" >> "${GITHUB_OUTPUT}"
|
||||
echo "excluded_files=${excluded_files}" >> "${GITHUB_OUTPUT}"
|
||||
echo "total_additions=${total_additions}" >> "${GITHUB_OUTPUT}"
|
||||
echo "total_deletions=${total_deletions}" >> "${GITHUB_OUTPUT}"
|
||||
echo "total_changes=${total_changes}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
# Set flag if PR is too large (> 800 lines)
|
||||
if [ ${total_changes} -gt 800 ]; then
|
||||
echo "is_too_large=true" >> "${GITHUB_OUTPUT}"
|
||||
else
|
||||
echo "is_too_large=false" >> "${GITHUB_OUTPUT}"
|
||||
fi
|
||||
|
||||
- name: Comment on PR if too large
|
||||
if: steps.check-size.outputs.is_too_large == 'true'
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const totalChanges = ${{ steps.check-size.outputs.total_changes }};
|
||||
const countedFiles = ${{ steps.check-size.outputs.counted_files }};
|
||||
const excludedFiles = ${{ steps.check-size.outputs.excluded_files }};
|
||||
const additions = ${{ steps.check-size.outputs.total_additions }};
|
||||
const deletions = ${{ steps.check-size.outputs.total_deletions }};
|
||||
|
||||
const body = `## 🚨 PR Size Warning
|
||||
|
||||
This PR has approximately **${totalChanges} lines** of changes (${additions} additions, ${deletions} deletions across ${countedFiles} files).
|
||||
|
||||
Large PRs (>800 lines) are significantly harder to review and increase the chance of merge conflicts. Consider splitting this into smaller, self-contained PRs.
|
||||
|
||||
### 💡 Suggestions:
|
||||
- **Split by feature or module** - Break down into logical, independent pieces
|
||||
- **Create a sequence of PRs** - Each building on the previous one
|
||||
- **Branch off PR branches** - Don't wait for reviews to continue dependent work
|
||||
|
||||
### 📊 What was counted:
|
||||
- ✅ Source files, stylesheets, configuration files
|
||||
- ❌ Excluded ${excludedFiles} files (tests, locales, locks, generated files)
|
||||
|
||||
### 📚 Guidelines:
|
||||
- **Ideal:** 300-500 lines per PR
|
||||
- **Warning:** 500-800 lines
|
||||
- **Critical:** 800+ lines ⚠️
|
||||
|
||||
If this large PR is unavoidable (e.g., migration, dependency update, major refactor), please explain in the PR description why it couldn't be split.`;
|
||||
|
||||
// Check if we already commented
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
|
||||
const botComment = comments.find(comment =>
|
||||
comment.user.type === 'Bot' &&
|
||||
comment.body.includes('🚨 PR Size Warning')
|
||||
);
|
||||
|
||||
if (botComment) {
|
||||
// Update existing comment
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: botComment.id,
|
||||
body: body
|
||||
});
|
||||
} else {
|
||||
// Create new comment
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: body
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
name: "Terraform"
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
# TODO: enable it back when migration is completed.
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "infra/terraform/**"
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "infra/terraform/**"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
terraform:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
pull-requests: write
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Tailscale
|
||||
uses: tailscale/github-action@84a3f23bb4d843bcf4da6cf824ec1be473daf4de # v3.2.3
|
||||
with:
|
||||
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
|
||||
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
|
||||
tags: tag:github
|
||||
|
||||
- name: Configure AWS Credentials
|
||||
uses: aws-actions/configure-aws-credentials@f24d7193d98baebaeacc7e2227925dd47cc267f5 # v4.2.0
|
||||
with:
|
||||
role-to-assume: ${{ secrets.AWS_ASSUME_ROLE_ARN }}
|
||||
aws-region: "eu-central-1"
|
||||
|
||||
- name: Setup Terraform
|
||||
uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2
|
||||
|
||||
- name: Terraform Format
|
||||
id: fmt
|
||||
run: terraform fmt -check -recursive
|
||||
continue-on-error: true
|
||||
working-directory: infra/terraform
|
||||
|
||||
- name: Terraform Init
|
||||
id: init
|
||||
run: terraform init
|
||||
working-directory: infra/terraform
|
||||
|
||||
- name: Terraform Validate
|
||||
id: validate
|
||||
run: terraform validate
|
||||
working-directory: infra/terraform
|
||||
|
||||
- name: Terraform Plan
|
||||
id: plan
|
||||
run: terraform plan -out .planfile
|
||||
working-directory: infra/terraform
|
||||
|
||||
- name: Post PR comment
|
||||
uses: borchero/terraform-plan-comment@434458316f8f24dd073cd2561c436cce41dc8f34 # v2.4.1
|
||||
if: always() && github.ref != 'refs/heads/main' && (steps.plan.outcome == 'success' || steps.plan.outcome == 'failure')
|
||||
with:
|
||||
token: ${{ github.token }}
|
||||
planfile: .planfile
|
||||
working-directory: "infra/terraform"
|
||||
|
||||
- name: Terraform Apply
|
||||
id: apply
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
run: terraform apply .planfile
|
||||
working-directory: "infra/terraform"
|
||||
-13
@@ -56,19 +56,6 @@ packages/database/migrations
|
||||
branch.json
|
||||
.vercel
|
||||
|
||||
# Terraform
|
||||
infra/terraform/.terraform/
|
||||
**/.terraform.lock.hcl
|
||||
**/terraform.tfstate
|
||||
**/terraform.tfstate.*
|
||||
**/crash.log
|
||||
**/override.tf
|
||||
**/override.tf.json
|
||||
**/*.tfvars
|
||||
**/*.tfvars.json
|
||||
**/.terraformrc
|
||||
**/terraform.rc
|
||||
|
||||
# IntelliJ IDEA
|
||||
/.idea/
|
||||
/*.iml
|
||||
|
||||
+6
-6
@@ -1,3 +1,7 @@
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { TFnType } from "@tolgee/react";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TXMTemplate } from "@formbricks/types/templates";
|
||||
import {
|
||||
buildCTAQuestion,
|
||||
buildNPSQuestion,
|
||||
@@ -5,10 +9,6 @@ import {
|
||||
buildRatingQuestion,
|
||||
getDefaultEndingCard,
|
||||
} from "@/app/lib/survey-builder";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { TFnType } from "@tolgee/react";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TXMTemplate } from "@formbricks/types/templates";
|
||||
|
||||
export const getXMSurveyDefault = (t: TFnType): TXMTemplate => {
|
||||
try {
|
||||
@@ -105,7 +105,7 @@ const starRatingSurvey = (t: TFnType): TXMTemplate => {
|
||||
}),
|
||||
buildCTAQuestion({
|
||||
id: reusableQuestionIds[1],
|
||||
html: t("templates.star_rating_survey_question_2_html"),
|
||||
subheader: t("templates.star_rating_survey_question_2_html"),
|
||||
logic: [
|
||||
{
|
||||
id: createId(),
|
||||
@@ -322,7 +322,7 @@ const smileysRatingSurvey = (t: TFnType): TXMTemplate => {
|
||||
}),
|
||||
buildCTAQuestion({
|
||||
id: reusableQuestionIds[1],
|
||||
html: t("templates.smileys_survey_question_2_html"),
|
||||
subheader: t("templates.smileys_survey_question_2_html"),
|
||||
logic: [
|
||||
{
|
||||
id: createId(),
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
||||
import { getLatestStableFbReleaseAction } from "@/modules/projects/settings/(setup)/app-connection/actions";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
@@ -8,6 +6,8 @@ import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TProject } from "@formbricks/types/project";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
||||
import { getLatestStableFbReleaseAction } from "@/modules/projects/settings/(setup)/app-connection/actions";
|
||||
import { MainNavigation } from "./MainNavigation";
|
||||
|
||||
// Mock constants that this test needs
|
||||
@@ -210,9 +210,10 @@ describe("MainNavigation", () => {
|
||||
expect(userTrigger).toBeInTheDocument(); // Ensure the trigger element is found
|
||||
await userEvent.click(userTrigger);
|
||||
|
||||
// Wait for the dropdown content to appear
|
||||
// Wait for the dropdown content to appear - using getAllByText to handle multiple instances
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("common.account")).toBeInTheDocument();
|
||||
const accountElements = screen.getAllByText("common.account");
|
||||
expect(accountElements).toHaveLength(2);
|
||||
});
|
||||
|
||||
expect(screen.getByText("common.documentation")).toBeInTheDocument();
|
||||
|
||||
@@ -1,20 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { NavigationLink } from "@/app/(app)/environments/[environmentId]/components/NavigationLink";
|
||||
import { isNewerVersion } from "@/app/(app)/environments/[environmentId]/lib/utils";
|
||||
import FBLogo from "@/images/formbricks-wordmark.svg";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
||||
import { getLatestStableFbReleaseAction } from "@/modules/projects/settings/(setup)/app-connection/actions";
|
||||
import { ProfileAvatar } from "@/modules/ui/components/avatars";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import {
|
||||
ArrowUpRightIcon,
|
||||
@@ -36,6 +21,21 @@ import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { NavigationLink } from "@/app/(app)/environments/[environmentId]/components/NavigationLink";
|
||||
import { isNewerVersion } from "@/app/(app)/environments/[environmentId]/lib/utils";
|
||||
import FBLogo from "@/images/formbricks-wordmark.svg";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
||||
import { getLatestStableFbReleaseAction } from "@/modules/projects/settings/(setup)/app-connection/actions";
|
||||
import { ProfileAvatar } from "@/modules/ui/components/avatars";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import packageJson from "../../../../../package.json";
|
||||
|
||||
interface NavigationProps {
|
||||
@@ -60,7 +60,7 @@ export const MainNavigation = ({
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { t } = useTranslate();
|
||||
const [isCollapsed, setIsCollapsed] = useState(true);
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const [isTextVisible, setIsTextVisible] = useState(true);
|
||||
const [latestVersion, setLatestVersion] = useState("");
|
||||
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
|
||||
|
||||
+18
-15
@@ -1,5 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { TFnType, useTranslate } from "@tolgee/react";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Control, Controller, useForm } from "react-hook-form";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { TIntegrationItem } from "@formbricks/types/integration";
|
||||
import {
|
||||
TIntegrationAirtable,
|
||||
TIntegrationAirtableConfigData,
|
||||
TIntegrationAirtableInput,
|
||||
TIntegrationAirtableTables,
|
||||
} from "@formbricks/types/integration/airtable";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
||||
import { BaseSelectDropdown } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/components/BaseSelectDropdown";
|
||||
import { fetchTables } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/lib/airtable";
|
||||
@@ -27,20 +42,6 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
import { TFnType, useTranslate } from "@tolgee/react";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Control, Controller, useForm } from "react-hook-form";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { TIntegrationItem } from "@formbricks/types/integration";
|
||||
import {
|
||||
TIntegrationAirtable,
|
||||
TIntegrationAirtableConfigData,
|
||||
TIntegrationAirtableInput,
|
||||
TIntegrationAirtableTables,
|
||||
} from "@formbricks/types/integration/airtable";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { IntegrationModalInputs } from "../lib/types";
|
||||
|
||||
type EditModeProps =
|
||||
@@ -117,7 +118,9 @@ const renderQuestionSelection = ({
|
||||
: field.onChange(field.value?.filter((value) => value !== question.id));
|
||||
}}
|
||||
/>
|
||||
<span className="ml-2">{getLocalizedValue(question.headline, "default")}</span>
|
||||
<span className="ml-2">
|
||||
{getTextContent(getLocalizedValue(question.headline, "default"))}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
+13
-12
@@ -1,5 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import Image from "next/image";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import {
|
||||
TIntegrationGoogleSheets,
|
||||
TIntegrationGoogleSheetsConfigData,
|
||||
TIntegrationGoogleSheetsInput,
|
||||
} from "@formbricks/types/integration/google-sheet";
|
||||
import { TSurvey, TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
||||
import { getSpreadsheetNameByIdAction } from "@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/actions";
|
||||
import {
|
||||
@@ -26,17 +38,6 @@ import {
|
||||
import { DropdownSelector } from "@/modules/ui/components/dropdown-selector";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import Image from "next/image";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import {
|
||||
TIntegrationGoogleSheets,
|
||||
TIntegrationGoogleSheetsConfigData,
|
||||
TIntegrationGoogleSheetsInput,
|
||||
} from "@formbricks/types/integration/google-sheet";
|
||||
import { TSurvey, TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||
|
||||
interface AddIntegrationModalProps {
|
||||
environmentId: string;
|
||||
@@ -276,7 +277,7 @@ export const AddIntegrationModal = ({
|
||||
}}
|
||||
/>
|
||||
<span className="ml-2 w-[30rem] truncate">
|
||||
{getLocalizedValue(question.headline, "default")}
|
||||
{getTextContent(getLocalizedValue(question.headline, "default"))}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
+18
-15
@@ -1,5 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { CircleHelpIcon } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { TIntegrationItem } from "@formbricks/types/integration";
|
||||
import {
|
||||
TIntegrationSlack,
|
||||
TIntegrationSlackConfigData,
|
||||
TIntegrationSlackInput,
|
||||
} from "@formbricks/types/integration/slack";
|
||||
import { TSurvey, TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
||||
import SlackLogo from "@/images/slacklogo.png";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
@@ -18,20 +33,6 @@ import {
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { DropdownSelector } from "@/modules/ui/components/dropdown-selector";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { CircleHelpIcon } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { TIntegrationItem } from "@formbricks/types/integration";
|
||||
import {
|
||||
TIntegrationSlack,
|
||||
TIntegrationSlackConfigData,
|
||||
TIntegrationSlackInput,
|
||||
} from "@formbricks/types/integration/slack";
|
||||
import { TSurvey, TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||
|
||||
interface AddChannelMappingModalProps {
|
||||
environmentId: string;
|
||||
@@ -281,7 +282,9 @@ export const AddChannelMappingModal = ({
|
||||
handleCheckboxChange(question.id);
|
||||
}}
|
||||
/>
|
||||
<span className="ml-2">{getLocalizedValue(question.headline, "default")}</span>
|
||||
<span className="ml-2">
|
||||
{getTextContent(getLocalizedValue(question.headline, "default"))}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
|
||||
+16
-11
@@ -1,5 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { TFnType } from "@tolgee/react";
|
||||
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 { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { extractChoiceIdsFromResponse } from "@/lib/response/utils";
|
||||
import { getContactIdentifier } from "@/lib/utils/contact";
|
||||
@@ -12,12 +19,6 @@ import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||
import { ResponseBadges } from "@/modules/ui/components/response-badges";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||
import { cn } from "@/modules/ui/lib/utils";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { TFnType } from "@tolgee/react";
|
||||
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,
|
||||
@@ -54,7 +55,9 @@ const getQuestionColumnsData = (
|
||||
|
||||
// Helper function to get localized question headline
|
||||
const getQuestionHeadline = (question: TSurveyQuestion, survey: TSurvey) => {
|
||||
return getLocalizedValue(recallToHeadline(question.headline, survey, false, "default"), "default");
|
||||
return getTextContent(
|
||||
getLocalizedValue(recallToHeadline(question.headline, survey, false, "default"), "default")
|
||||
);
|
||||
};
|
||||
|
||||
// Helper function to render choice ID badges
|
||||
@@ -83,7 +86,7 @@ const getQuestionColumnsData = (
|
||||
<div className="flex items-center space-x-2 overflow-hidden">
|
||||
<span className="h-4 w-4">{QUESTIONS_ICON_MAP["matrix"]}</span>
|
||||
<span className="truncate">
|
||||
{getLocalizedValue(question.headline, "default") +
|
||||
{getTextContent(getLocalizedValue(question.headline, "default")) +
|
||||
" - " +
|
||||
getLocalizedValue(matrixRow.label, "default")}
|
||||
</span>
|
||||
@@ -199,9 +202,11 @@ const getQuestionColumnsData = (
|
||||
<div className="flex items-center space-x-2 overflow-hidden">
|
||||
<span className="h-4 w-4">{QUESTIONS_ICON_MAP[question.type]}</span>
|
||||
<span className="truncate">
|
||||
{getLocalizedValue(
|
||||
recallToHeadline(question.headline, survey, false, "default"),
|
||||
"default"
|
||||
{getTextContent(
|
||||
getLocalizedValue(
|
||||
recallToHeadline(question.headline, survey, false, "default"),
|
||||
"default"
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
+8
-5
@@ -1,13 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { recallToHeadline } from "@/lib/utils/recall";
|
||||
import { formatTextWithSlashes } from "@/modules/survey/editor/lib/utils";
|
||||
import { getQuestionTypes } from "@/modules/survey/lib/questions";
|
||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { InboxIcon } from "lucide-react";
|
||||
import type { JSX } from "react";
|
||||
import { TSurvey, TSurveyQuestionSummary } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { recallToHeadline } from "@/lib/utils/recall";
|
||||
import { formatTextWithSlashes } from "@/modules/survey/editor/lib/utils";
|
||||
import { getQuestionTypes } from "@/modules/survey/lib/questions";
|
||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||
|
||||
interface HeadProps {
|
||||
questionSummary: TSurveyQuestionSummary;
|
||||
@@ -30,7 +31,9 @@ export const QuestionSummaryHeader = ({
|
||||
<div className={"align-center flex justify-between gap-4"}>
|
||||
<h3 className="pb-1 text-lg font-semibold text-slate-900 md:text-xl">
|
||||
{formatTextWithSlashes(
|
||||
recallToHeadline(questionSummary.question.headline, survey, true, "default")["default"],
|
||||
getTextContent(
|
||||
recallToHeadline(questionSummary.question.headline, survey, true, "default")["default"]
|
||||
),
|
||||
"@",
|
||||
["text-lg"]
|
||||
)}
|
||||
|
||||
+3
-3
@@ -1,12 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { TimerIcon } from "lucide-react";
|
||||
import { TSurvey, TSurveyQuestionType, TSurveySummary } from "@formbricks/types/surveys/types";
|
||||
import { recallToHeadline } from "@/lib/utils/recall";
|
||||
import { formatTextWithSlashes } from "@/modules/survey/editor/lib/utils";
|
||||
import { getQuestionIcon } from "@/modules/survey/lib/questions";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { TimerIcon } from "lucide-react";
|
||||
import { TSurvey, TSurveyQuestionType, TSurveySummary } from "@formbricks/types/surveys/types";
|
||||
|
||||
interface SummaryDropOffsProps {
|
||||
dropOff: TSurveySummary["dropOff"];
|
||||
|
||||
+102
-6
@@ -1,9 +1,3 @@
|
||||
import { getQuotasSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/survey";
|
||||
import { getDisplayCountBySurveyId } from "@/lib/display/service";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { evaluateLogic, performActions } from "@/lib/surveyLogic/utils";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
@@ -16,6 +10,12 @@ import {
|
||||
TSurveyQuestionTypeEnum,
|
||||
TSurveySummary,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { getQuotasSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/survey";
|
||||
import { getDisplayCountBySurveyId } from "@/lib/display/service";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { evaluateLogic, performActions } from "@/lib/surveyLogic/utils";
|
||||
import {
|
||||
getQuestionSummary,
|
||||
getResponsesForSummary,
|
||||
@@ -376,6 +376,102 @@ describe("getQuestionSummary", () => {
|
||||
expect(openTextSummary?.samples[0].value).toBe("Open answer");
|
||||
});
|
||||
|
||||
test("summarizes OpenText questions with array answer format", async () => {
|
||||
const responsesWithArray = [
|
||||
{
|
||||
id: "r1",
|
||||
data: { q_open: ["Answer 1", "Answer 2", "Answer 3"] },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: "en",
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
];
|
||||
const summary = await getQuestionSummary(survey, responsesWithArray, mockDropOff);
|
||||
const openTextSummary = summary.find((s: any) => s.question?.id === "q_open");
|
||||
expect(openTextSummary?.type).toBe(TSurveyQuestionTypeEnum.OpenText);
|
||||
expect(openTextSummary?.responseCount).toBe(1);
|
||||
// @ts-expect-error
|
||||
expect(openTextSummary?.samples[0].value).toBe("Answer 1, Answer 2, Answer 3");
|
||||
});
|
||||
|
||||
test("summarizes OpenText questions with array containing null/empty values", async () => {
|
||||
const responsesWithPartialArray = [
|
||||
{
|
||||
id: "r1",
|
||||
data: { q_open: ["Valid answer", null, "", "Another answer", undefined] },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: "en",
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
];
|
||||
const summary = await getQuestionSummary(survey, responsesWithPartialArray, mockDropOff);
|
||||
const openTextSummary = summary.find((s: any) => s.question?.id === "q_open");
|
||||
expect(openTextSummary?.type).toBe(TSurveyQuestionTypeEnum.OpenText);
|
||||
expect(openTextSummary?.responseCount).toBe(1);
|
||||
// @ts-expect-error - filters out null, empty string, and undefined
|
||||
expect(openTextSummary?.samples[0].value).toBe("Valid answer, Another answer");
|
||||
});
|
||||
|
||||
test("summarizes OpenText questions ignoring empty arrays", async () => {
|
||||
const responsesWithEmptyArray = [
|
||||
{
|
||||
id: "r1",
|
||||
data: { q_open: [] },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: "en",
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
];
|
||||
const summary = await getQuestionSummary(survey, responsesWithEmptyArray, mockDropOff);
|
||||
const openTextSummary = summary.find((s: any) => s.question?.id === "q_open");
|
||||
expect(openTextSummary?.type).toBe(TSurveyQuestionTypeEnum.OpenText);
|
||||
expect(openTextSummary?.responseCount).toBe(0);
|
||||
// @ts-expect-error
|
||||
expect(openTextSummary?.samples).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("summarizes OpenText questions with mixed string and array responses", async () => {
|
||||
const mixedResponses = [
|
||||
{
|
||||
id: "r1",
|
||||
data: { q_open: "String answer" },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: "en",
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
{
|
||||
id: "r2",
|
||||
data: { q_open: ["Array", "answer"] },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: "en",
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
];
|
||||
const summary = await getQuestionSummary(survey, mixedResponses, mockDropOff);
|
||||
const openTextSummary = summary.find((s: any) => s.question?.id === "q_open");
|
||||
expect(openTextSummary?.type).toBe(TSurveyQuestionTypeEnum.OpenText);
|
||||
expect(openTextSummary?.responseCount).toBe(2);
|
||||
// @ts-expect-error
|
||||
expect(openTextSummary?.samples[0].value).toBe("String answer");
|
||||
// @ts-expect-error
|
||||
expect(openTextSummary?.samples[1].value).toBe("Array, answer");
|
||||
});
|
||||
|
||||
test("summarizes MultipleChoiceSingle questions", async () => {
|
||||
const summary = await getQuestionSummary(survey, responses, mockDropOff);
|
||||
const multiSingleSummary = summary.find((s: any) => s.question?.id === "q_multi_single");
|
||||
|
||||
+48
-22
@@ -1,12 +1,4 @@
|
||||
import "server-only";
|
||||
import { getQuotasSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/survey";
|
||||
import { RESPONSES_PER_PAGE } from "@/lib/constants";
|
||||
import { getDisplayCountBySurveyId } from "@/lib/display/service";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { buildWhereClause } from "@/lib/response/utils";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { evaluateLogic, performActions } from "@/lib/surveyLogic/utils";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { z } from "zod";
|
||||
@@ -41,6 +33,15 @@ import {
|
||||
TSurveyQuestionTypeEnum,
|
||||
TSurveySummary,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { getQuotasSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/survey";
|
||||
import { RESPONSES_PER_PAGE } from "@/lib/constants";
|
||||
import { getDisplayCountBySurveyId } from "@/lib/display/service";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { buildWhereClause } from "@/lib/response/utils";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { evaluateLogic, performActions } from "@/lib/surveyLogic/utils";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { convertFloatTo2Decimal } from "./utils";
|
||||
|
||||
interface TSurveySummaryResponse {
|
||||
@@ -259,7 +260,7 @@ export const getSurveySummaryDropOff = (
|
||||
return {
|
||||
questionId: question.id,
|
||||
questionType: question.type,
|
||||
headline: getLocalizedValue(question.headline, "default"),
|
||||
headline: getTextContent(getLocalizedValue(question.headline, "default")),
|
||||
ttc: convertFloatTo2Decimal(totalTtc[question.id]) || 0,
|
||||
impressions: impressionsArr[index] || 0,
|
||||
dropOffCount: dropOffArr[index] || 0,
|
||||
@@ -321,11 +322,18 @@ export const getQuestionSummary = async (
|
||||
let values: TSurveyQuestionSummaryOpenText["samples"] = [];
|
||||
responses.forEach((response) => {
|
||||
const answer = response.data[question.id];
|
||||
if (answer && typeof answer === "string") {
|
||||
let normalizedAnswer: string | null = null;
|
||||
if (typeof answer === "string" && answer) {
|
||||
normalizedAnswer = answer;
|
||||
} else if (Array.isArray(answer) && answer.length > 0) {
|
||||
normalizedAnswer = answer.filter((v) => v != null && v !== "").join(", ");
|
||||
}
|
||||
|
||||
if (normalizedAnswer) {
|
||||
values.push({
|
||||
id: response.id,
|
||||
updatedAt: response.updatedAt,
|
||||
value: answer,
|
||||
value: normalizedAnswer,
|
||||
contact: response.contact,
|
||||
contactAttributes: response.contactAttributes,
|
||||
});
|
||||
@@ -345,20 +353,23 @@ export const getQuestionSummary = async (
|
||||
case TSurveyQuestionTypeEnum.MultipleChoiceSingle:
|
||||
case TSurveyQuestionTypeEnum.MultipleChoiceMulti: {
|
||||
let values: TSurveyQuestionSummaryMultipleChoice["choices"] = [];
|
||||
// check last choice is others or not
|
||||
const lastChoice = question.choices[question.choices.length - 1];
|
||||
const isOthersEnabled = lastChoice.id === "other";
|
||||
|
||||
const questionChoices = question.choices.map((choice) => getLocalizedValue(choice.label, "default"));
|
||||
if (isOthersEnabled) {
|
||||
questionChoices.pop();
|
||||
}
|
||||
const otherOption = question.choices.find((choice) => choice.id === "other");
|
||||
const noneOption = question.choices.find((choice) => choice.id === "none");
|
||||
|
||||
const questionChoices = question.choices
|
||||
.filter((choice) => choice.id !== "other" && choice.id !== "none")
|
||||
.map((choice) => getLocalizedValue(choice.label, "default"));
|
||||
|
||||
const choiceCountMap = questionChoices.reduce((acc: Record<string, number>, choice) => {
|
||||
acc[choice] = 0;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// Track "none" count separately
|
||||
const noneLabel = noneOption ? getLocalizedValue(noneOption.label, "default") : null;
|
||||
let noneCount = 0;
|
||||
|
||||
const otherValues: TSurveyQuestionSummaryMultipleChoice["choices"][number]["others"] = [];
|
||||
let totalSelectionCount = 0;
|
||||
let totalResponseCount = 0;
|
||||
@@ -378,7 +389,9 @@ export const getQuestionSummary = async (
|
||||
totalSelectionCount++;
|
||||
if (questionChoices.includes(value)) {
|
||||
choiceCountMap[value]++;
|
||||
} else if (isOthersEnabled) {
|
||||
} else if (noneLabel && value === noneLabel) {
|
||||
noneCount++;
|
||||
} else if (otherOption) {
|
||||
otherValues.push({
|
||||
value,
|
||||
contact: response.contact,
|
||||
@@ -396,7 +409,9 @@ export const getQuestionSummary = async (
|
||||
totalSelectionCount++;
|
||||
if (questionChoices.includes(answer)) {
|
||||
choiceCountMap[answer]++;
|
||||
} else if (isOthersEnabled) {
|
||||
} else if (noneLabel && answer === noneLabel) {
|
||||
noneCount++;
|
||||
} else if (otherOption) {
|
||||
otherValues.push({
|
||||
value: answer,
|
||||
contact: response.contact,
|
||||
@@ -421,9 +436,9 @@ export const getQuestionSummary = async (
|
||||
});
|
||||
});
|
||||
|
||||
if (isOthersEnabled) {
|
||||
if (otherOption) {
|
||||
values.push({
|
||||
value: getLocalizedValue(lastChoice.label, "default") || "Other",
|
||||
value: getLocalizedValue(otherOption.label, "default") || "Other",
|
||||
count: otherValues.length,
|
||||
percentage:
|
||||
totalResponseCount > 0
|
||||
@@ -432,6 +447,17 @@ export const getQuestionSummary = async (
|
||||
others: otherValues.slice(0, VALUES_LIMIT),
|
||||
});
|
||||
}
|
||||
|
||||
// Add "none" option at the end if it exists
|
||||
if (noneOption && noneLabel) {
|
||||
values.push({
|
||||
value: noneLabel,
|
||||
count: noneCount,
|
||||
percentage:
|
||||
totalResponseCount > 0 ? convertFloatTo2Decimal((noneCount / totalResponseCount) * 100) : 0,
|
||||
});
|
||||
}
|
||||
|
||||
summary.push({
|
||||
type: question.type,
|
||||
question,
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ const mockSurvey: TSurvey = {
|
||||
welcomeCard: {
|
||||
enabled: false,
|
||||
headline: { default: "Welcome" },
|
||||
html: { default: "" },
|
||||
subheader: { default: "" },
|
||||
timeToFinish: false,
|
||||
showResponseCount: false,
|
||||
buttonLabel: { default: "Start" },
|
||||
|
||||
@@ -91,7 +91,7 @@ export const mockSurvey: TSurvey = {
|
||||
createdBy: "cm98dg3xm000019hpubj39vfi",
|
||||
status: "inProgress",
|
||||
welcomeCard: {
|
||||
html: {
|
||||
subheader: {
|
||||
default: "Thanks for providing your feedback - let's go!",
|
||||
},
|
||||
enabled: false,
|
||||
@@ -168,6 +168,7 @@ export const mockSurvey: TSurvey = {
|
||||
triggers: [],
|
||||
segment: null,
|
||||
followUps: mockFollowUps,
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
export const mockContactQuestion: TSurveyContactInfoQuestion = {
|
||||
|
||||
@@ -1,22 +1,12 @@
|
||||
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
|
||||
import { NextRequest } from "next/server";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { TAPIKeyEnvironmentPermission } from "@formbricks/types/auth";
|
||||
import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TAPIKeyEnvironmentPermission } from "@formbricks/types/auth";
|
||||
import { authenticateRequest } from "./auth";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
apiKey: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/api/v2/management/lib/utils", () => ({
|
||||
hashApiKey: vi.fn(),
|
||||
vi.mock("@/modules/organization/settings/api-keys/lib/api-key", () => ({
|
||||
getApiKeyWithPermissions: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("getApiKeyWithPermissions", () => {
|
||||
@@ -24,6 +14,7 @@ describe("getApiKeyWithPermissions", () => {
|
||||
const mockApiKeyData = {
|
||||
id: "api-key-id",
|
||||
organizationId: "org-id",
|
||||
organizationAccess: "all" as const,
|
||||
hashedKey: "hashed-key",
|
||||
createdAt: new Date(),
|
||||
createdBy: "user-id",
|
||||
@@ -33,26 +24,29 @@ describe("getApiKeyWithPermissions", () => {
|
||||
{
|
||||
environmentId: "env-1",
|
||||
permission: "manage" as const,
|
||||
environment: { id: "env-1" },
|
||||
environment: {
|
||||
id: "env-1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
type: "development" as const,
|
||||
projectId: "project-1",
|
||||
appSetupCompleted: true,
|
||||
project: { id: "project-1", name: "Project 1" },
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(hashApiKey).mockReturnValue("hashed-key");
|
||||
vi.mocked(prisma.apiKey.findUnique).mockResolvedValue(mockApiKeyData);
|
||||
vi.mocked(prisma.apiKey.update).mockResolvedValue(mockApiKeyData);
|
||||
vi.mocked(getApiKeyWithPermissions).mockResolvedValue(mockApiKeyData as any);
|
||||
|
||||
const result = await getApiKeyWithPermissions("test-api-key");
|
||||
|
||||
expect(result).toEqual(mockApiKeyData);
|
||||
expect(prisma.apiKey.update).toHaveBeenCalledWith({
|
||||
where: { id: "api-key-id" },
|
||||
data: { lastUsedAt: expect.any(Date) },
|
||||
});
|
||||
expect(getApiKeyWithPermissions).toHaveBeenCalledWith("test-api-key");
|
||||
});
|
||||
|
||||
test("returns null when API key is not found", async () => {
|
||||
vi.mocked(prisma.apiKey.findUnique).mockResolvedValue(null);
|
||||
vi.mocked(getApiKeyWithPermissions).mockResolvedValue(null);
|
||||
|
||||
const result = await getApiKeyWithPermissions("invalid-key");
|
||||
|
||||
@@ -110,14 +104,14 @@ describe("hasPermission", () => {
|
||||
|
||||
describe("authenticateRequest", () => {
|
||||
test("should return authentication data for valid API key", async () => {
|
||||
const request = new Request("http://localhost", {
|
||||
const request = new NextRequest("http://localhost", {
|
||||
headers: { "x-api-key": "valid-api-key" },
|
||||
});
|
||||
|
||||
const mockApiKeyData = {
|
||||
id: "api-key-id",
|
||||
organizationId: "org-id",
|
||||
hashedKey: "hashed-key",
|
||||
organizationAccess: "all" as const,
|
||||
createdAt: new Date(),
|
||||
createdBy: "user-id",
|
||||
lastUsedAt: null,
|
||||
@@ -128,18 +122,18 @@ describe("authenticateRequest", () => {
|
||||
permission: "manage" as const,
|
||||
environment: {
|
||||
id: "env-1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
type: "development" as const,
|
||||
projectId: "project-1",
|
||||
project: { name: "Project 1" },
|
||||
type: "development",
|
||||
appSetupCompleted: true,
|
||||
project: { id: "project-1", name: "Project 1" },
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(hashApiKey).mockReturnValue("hashed-key");
|
||||
vi.mocked(prisma.apiKey.findUnique).mockResolvedValue(mockApiKeyData);
|
||||
vi.mocked(prisma.apiKey.update).mockResolvedValue(mockApiKeyData);
|
||||
|
||||
vi.mocked(getApiKeyWithPermissions).mockResolvedValue(mockApiKeyData as any);
|
||||
const result = await authenticateRequest(request);
|
||||
|
||||
expect(result).toEqual({
|
||||
@@ -153,24 +147,47 @@ describe("authenticateRequest", () => {
|
||||
projectName: "Project 1",
|
||||
},
|
||||
],
|
||||
hashedApiKey: "hashed-key",
|
||||
apiKeyId: "api-key-id",
|
||||
organizationId: "org-id",
|
||||
organizationAccess: "all",
|
||||
});
|
||||
expect(getApiKeyWithPermissions).toHaveBeenCalledWith("valid-api-key");
|
||||
});
|
||||
|
||||
test("returns null when no API key is provided", async () => {
|
||||
const request = new Request("http://localhost");
|
||||
const request = new NextRequest("http://localhost");
|
||||
const result = await authenticateRequest(request);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null when API key is invalid", async () => {
|
||||
const request = new Request("http://localhost", {
|
||||
const request = new NextRequest("http://localhost", {
|
||||
headers: { "x-api-key": "invalid-api-key" },
|
||||
});
|
||||
|
||||
vi.mocked(prisma.apiKey.findUnique).mockResolvedValue(null);
|
||||
vi.mocked(getApiKeyWithPermissions).mockResolvedValue(null);
|
||||
|
||||
const result = await authenticateRequest(request);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null when API key has no environment permissions", async () => {
|
||||
const request = new NextRequest("http://localhost", {
|
||||
headers: { "x-api-key": "valid-api-key" },
|
||||
});
|
||||
|
||||
const mockApiKeyData = {
|
||||
id: "api-key-id",
|
||||
organizationId: "org-id",
|
||||
organizationAccess: "all" as const,
|
||||
createdAt: new Date(),
|
||||
createdBy: "user-id",
|
||||
lastUsedAt: null,
|
||||
label: "Test API Key",
|
||||
apiKeyEnvironments: [],
|
||||
};
|
||||
|
||||
vi.mocked(getApiKeyWithPermissions).mockResolvedValue(mockApiKeyData as any);
|
||||
|
||||
const result = await authenticateRequest(request);
|
||||
expect(result).toBeNull();
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
|
||||
import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key";
|
||||
import { NextRequest } from "next/server";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key";
|
||||
|
||||
export const authenticateRequest = async (request: NextRequest): Promise<TAuthenticationApiKey | null> => {
|
||||
const apiKey = request.headers.get("x-api-key");
|
||||
@@ -17,7 +16,6 @@ export const authenticateRequest = async (request: NextRequest): Promise<TAuthen
|
||||
const environmentIds = apiKeyData.apiKeyEnvironments.map((env) => env.environmentId);
|
||||
if (environmentIds.length === 0) return null;
|
||||
|
||||
const hashedApiKey = hashApiKey(apiKey);
|
||||
const authentication: TAuthenticationApiKey = {
|
||||
type: "apiKey",
|
||||
environmentPermissions: apiKeyData.apiKeyEnvironments.map((env) => ({
|
||||
@@ -27,7 +25,6 @@ export const authenticateRequest = async (request: NextRequest): Promise<TAuthen
|
||||
projectId: env.environment.projectId,
|
||||
projectName: env.environment.project.name,
|
||||
})),
|
||||
hashedApiKey,
|
||||
apiKeyId: apiKeyData.id,
|
||||
organizationId: apiKeyData.organizationId,
|
||||
organizationAccess: apiKeyData.organizationAccess,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { parseRecallInfo } from "@/lib/utils/recall";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { TAttributes } from "@formbricks/types/attributes";
|
||||
import { TLanguage } from "@formbricks/types/project";
|
||||
@@ -8,6 +7,7 @@ import {
|
||||
TSurveyQuestion,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { parseRecallInfo } from "@/lib/utils/recall";
|
||||
import { replaceAttributeRecall } from "./utils";
|
||||
|
||||
vi.mock("@/lib/utils/recall", () => ({
|
||||
@@ -62,6 +62,7 @@ const baseSurvey: TSurvey = {
|
||||
autoComplete: null,
|
||||
segment: null,
|
||||
pin: null,
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
const attributes: TAttributes = {
|
||||
@@ -102,7 +103,7 @@ describe("replaceAttributeRecall", () => {
|
||||
welcomeCard: {
|
||||
enabled: true,
|
||||
headline: { default: "Welcome, recall:name!" },
|
||||
html: { default: "<p>Some content</p>" },
|
||||
subheader: { default: "<p>Some content</p>" },
|
||||
buttonLabel: { default: "Start" },
|
||||
timeToFinish: false,
|
||||
showResponseCount: false,
|
||||
@@ -206,7 +207,7 @@ describe("replaceAttributeRecall", () => {
|
||||
welcomeCard: {
|
||||
enabled: true,
|
||||
headline: { default: "Welcome!" },
|
||||
html: { default: "<p>Some content</p>" },
|
||||
subheader: { default: "<p>Some content</p>" },
|
||||
buttonLabel: { default: "Start" },
|
||||
timeToFinish: false,
|
||||
showResponseCount: false,
|
||||
|
||||
@@ -1,94 +1,191 @@
|
||||
import { getSessionUser } from "@/app/api/v1/management/me/lib/utils";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
|
||||
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import { headers } from "next/headers";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { getSessionUser } from "@/app/api/v1/management/me/lib/utils";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { CONTROL_HASH } from "@/lib/constants";
|
||||
import { hashSha256, parseApiKeyV2, verifySecret } from "@/lib/crypto";
|
||||
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
|
||||
const ALLOWED_PERMISSIONS = ["manage", "read", "write"] as const;
|
||||
|
||||
const apiKeySelect = {
|
||||
id: true,
|
||||
organizationId: true,
|
||||
lastUsedAt: true,
|
||||
apiKeyEnvironments: {
|
||||
select: {
|
||||
environment: {
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
projectId: true,
|
||||
appSetupCompleted: true,
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
permission: true,
|
||||
},
|
||||
},
|
||||
hashedKey: true,
|
||||
};
|
||||
|
||||
type ApiKeyData = {
|
||||
id: string;
|
||||
hashedKey: string;
|
||||
organizationId: string;
|
||||
lastUsedAt: Date | null;
|
||||
apiKeyEnvironments: Array<{
|
||||
permission: string;
|
||||
environment: {
|
||||
id: string;
|
||||
type: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
projectId: string;
|
||||
appSetupCompleted: boolean;
|
||||
project: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
}>;
|
||||
};
|
||||
|
||||
const validateApiKey = async (apiKey: string): Promise<ApiKeyData | null> => {
|
||||
const v2Parsed = parseApiKeyV2(apiKey);
|
||||
|
||||
if (v2Parsed) {
|
||||
return validateV2ApiKey(v2Parsed);
|
||||
}
|
||||
|
||||
return validateLegacyApiKey(apiKey);
|
||||
};
|
||||
|
||||
const validateV2ApiKey = async (v2Parsed: { secret: string }): Promise<ApiKeyData | null> => {
|
||||
// Step 1: Fast SHA-256 lookup by indexed lookupHash
|
||||
const lookupHash = hashSha256(v2Parsed.secret);
|
||||
|
||||
const apiKeyData = await prisma.apiKey.findUnique({
|
||||
where: { lookupHash },
|
||||
select: apiKeySelect,
|
||||
});
|
||||
|
||||
// Step 2: Security verification with bcrypt
|
||||
// Always perform bcrypt verification to prevent timing attacks
|
||||
// Use a control hash when API key doesn't exist to maintain constant timing
|
||||
const hashToVerify = apiKeyData?.hashedKey || CONTROL_HASH;
|
||||
const isValid = await verifySecret(v2Parsed.secret, hashToVerify);
|
||||
|
||||
if (!apiKeyData || !isValid) return null;
|
||||
|
||||
return apiKeyData;
|
||||
};
|
||||
|
||||
const validateLegacyApiKey = async (apiKey: string): Promise<ApiKeyData | null> => {
|
||||
const hashedKey = hashSha256(apiKey);
|
||||
const result = await prisma.apiKey.findFirst({
|
||||
where: { hashedKey },
|
||||
select: apiKeySelect,
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
const checkRateLimit = async (userId: string) => {
|
||||
try {
|
||||
await applyRateLimit(rateLimitConfigs.api.v1, userId);
|
||||
} catch (error) {
|
||||
return responses.tooManyRequestsResponse(error.message);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const updateApiKeyUsage = async (apiKeyId: string) => {
|
||||
await prisma.apiKey.update({
|
||||
where: { id: apiKeyId },
|
||||
data: { lastUsedAt: new Date() },
|
||||
});
|
||||
};
|
||||
|
||||
const buildEnvironmentResponse = (apiKeyData: ApiKeyData) => {
|
||||
const env = apiKeyData.apiKeyEnvironments[0].environment;
|
||||
return Response.json({
|
||||
id: env.id,
|
||||
type: env.type,
|
||||
createdAt: env.createdAt,
|
||||
updatedAt: env.updatedAt,
|
||||
appSetupCompleted: env.appSetupCompleted,
|
||||
project: {
|
||||
id: env.projectId,
|
||||
name: env.project.name,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const isValidApiKeyEnvironment = (apiKeyData: ApiKeyData): boolean => {
|
||||
return (
|
||||
apiKeyData.apiKeyEnvironments.length === 1 &&
|
||||
ALLOWED_PERMISSIONS.includes(
|
||||
apiKeyData.apiKeyEnvironments[0].permission as (typeof ALLOWED_PERMISSIONS)[number]
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const handleApiKeyAuthentication = async (apiKey: string) => {
|
||||
const apiKeyData = await validateApiKey(apiKey);
|
||||
|
||||
if (!apiKeyData) {
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
|
||||
if (!apiKeyData.lastUsedAt || apiKeyData.lastUsedAt <= new Date(Date.now() - 1000 * 30)) {
|
||||
// Fire-and-forget: update lastUsedAt in the background without blocking the response
|
||||
updateApiKeyUsage(apiKeyData.id).catch((error) => {
|
||||
console.error("Failed to update API key usage:", error);
|
||||
});
|
||||
}
|
||||
|
||||
const rateLimitError = await checkRateLimit(apiKeyData.id);
|
||||
if (rateLimitError) return rateLimitError;
|
||||
|
||||
if (!isValidApiKeyEnvironment(apiKeyData)) {
|
||||
return responses.badRequestResponse("You can't use this method with this API key");
|
||||
}
|
||||
|
||||
return buildEnvironmentResponse(apiKeyData);
|
||||
};
|
||||
|
||||
const handleSessionAuthentication = async () => {
|
||||
const sessionUser = await getSessionUser();
|
||||
|
||||
if (!sessionUser) {
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
|
||||
const rateLimitError = await checkRateLimit(sessionUser.id);
|
||||
if (rateLimitError) return rateLimitError;
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: sessionUser.id },
|
||||
});
|
||||
|
||||
return Response.json(user);
|
||||
};
|
||||
|
||||
export const GET = async () => {
|
||||
const headersList = await headers();
|
||||
const apiKey = headersList.get("x-api-key");
|
||||
|
||||
if (apiKey) {
|
||||
const hashedApiKey = hashApiKey(apiKey);
|
||||
|
||||
const apiKeyData = await prisma.apiKey.findUnique({
|
||||
where: {
|
||||
hashedKey: hashedApiKey,
|
||||
},
|
||||
select: {
|
||||
apiKeyEnvironments: {
|
||||
select: {
|
||||
environment: {
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
projectId: true,
|
||||
appSetupCompleted: true,
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
permission: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!apiKeyData) {
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
|
||||
try {
|
||||
await applyRateLimit(rateLimitConfigs.api.v1, hashedApiKey);
|
||||
} catch (error) {
|
||||
return responses.tooManyRequestsResponse(error.message);
|
||||
}
|
||||
|
||||
if (
|
||||
apiKeyData.apiKeyEnvironments.length === 1 &&
|
||||
ALLOWED_PERMISSIONS.includes(apiKeyData.apiKeyEnvironments[0].permission)
|
||||
) {
|
||||
return Response.json({
|
||||
id: apiKeyData.apiKeyEnvironments[0].environment.id,
|
||||
type: apiKeyData.apiKeyEnvironments[0].environment.type,
|
||||
createdAt: apiKeyData.apiKeyEnvironments[0].environment.createdAt,
|
||||
updatedAt: apiKeyData.apiKeyEnvironments[0].environment.updatedAt,
|
||||
appSetupCompleted: apiKeyData.apiKeyEnvironments[0].environment.appSetupCompleted,
|
||||
project: {
|
||||
id: apiKeyData.apiKeyEnvironments[0].environment.projectId,
|
||||
name: apiKeyData.apiKeyEnvironments[0].environment.project.name,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
return responses.badRequestResponse("You can't use this method with this API key");
|
||||
}
|
||||
} else {
|
||||
const sessionUser = await getSessionUser();
|
||||
if (!sessionUser) {
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
|
||||
try {
|
||||
await applyRateLimit(rateLimitConfigs.api.v1, sessionUser.id);
|
||||
} catch (error) {
|
||||
return responses.tooManyRequestsResponse(error.message);
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: sessionUser.id,
|
||||
},
|
||||
});
|
||||
|
||||
return Response.json(user);
|
||||
return handleApiKeyAuthentication(apiKey);
|
||||
}
|
||||
|
||||
return handleSessionAuthentication();
|
||||
};
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { Session } from "next-auth";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { checkAuth } from "./utils";
|
||||
|
||||
// Create mock response objects
|
||||
@@ -56,8 +56,7 @@ describe("checkAuth", () => {
|
||||
projectName: "Project 1",
|
||||
},
|
||||
],
|
||||
hashedApiKey: "hashed-key",
|
||||
apiKeyId: "api-key-id",
|
||||
apiKeyId: "hashed-key",
|
||||
organizationId: "org-id",
|
||||
organizationAccess: {
|
||||
accessControl: {},
|
||||
@@ -89,8 +88,7 @@ describe("checkAuth", () => {
|
||||
projectName: "Project 1",
|
||||
},
|
||||
],
|
||||
hashedApiKey: "hashed-key",
|
||||
apiKeyId: "api-key-id",
|
||||
apiKeyId: "hashed-key",
|
||||
organizationId: "org-id",
|
||||
organizationAccess: {
|
||||
accessControl: {},
|
||||
|
||||
@@ -13,7 +13,7 @@ export const checkAuth = async (authentication: TApiV1Authentication, environmen
|
||||
if (!isUserAuthorized) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
} else if ("hashedApiKey" in authentication) {
|
||||
} else if ("apiKeyId" in authentication) {
|
||||
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TUploadPublicFileRequest, ZUploadPublicFileRequest } from "@formbricks/types/storage";
|
||||
import { checkAuth } from "@/app/api/v1/management/storage/lib/utils";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
@@ -5,9 +8,6 @@ import { TApiV1Authentication, withV1ApiWrapper } from "@/app/lib/api/with-api-l
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import { getSignedUrlForUpload } from "@/modules/storage/service";
|
||||
import { getErrorResponseFromStorageError } from "@/modules/storage/utils";
|
||||
import { NextRequest } from "next/server";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TUploadPublicFileRequest, ZUploadPublicFileRequest } from "@formbricks/types/storage";
|
||||
|
||||
// api endpoint for getting a signed url for uploading a public file
|
||||
// uploaded files will be public, anyone can access the file
|
||||
@@ -52,7 +52,16 @@ export const POST = withV1ApiWrapper({
|
||||
};
|
||||
}
|
||||
|
||||
const signedUrlResponse = await getSignedUrlForUpload(fileName, environmentId, fileType, "public");
|
||||
const MAX_PUBLIC_FILE_SIZE_MB = 5;
|
||||
const maxFileUploadSize = MAX_PUBLIC_FILE_SIZE_MB * 1024 * 1024;
|
||||
|
||||
const signedUrlResponse = await getSignedUrlForUpload(
|
||||
fileName,
|
||||
environmentId,
|
||||
fileType,
|
||||
"public",
|
||||
maxFileUploadSize
|
||||
);
|
||||
|
||||
if (!signedUrlResponse.ok) {
|
||||
logger.error({ error: signedUrlResponse.error }, "Error getting signed url for upload");
|
||||
|
||||
@@ -104,10 +104,12 @@ function createMockRequest({ method = "GET", url = "https://api.test/endpoint",
|
||||
}
|
||||
|
||||
const mockApiAuthentication = {
|
||||
hashedApiKey: "test-api-key",
|
||||
type: "apiKey" as const,
|
||||
environmentPermissions: [],
|
||||
apiKeyId: "api-key-1",
|
||||
organizationId: "org-1",
|
||||
} as TAuthenticationApiKey;
|
||||
organizationAccess: "all" as const,
|
||||
} as unknown as TAuthenticationApiKey;
|
||||
|
||||
describe("withV1ApiWrapper", () => {
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -74,9 +74,9 @@ const handleRateLimiting = async (
|
||||
if ("user" in authentication) {
|
||||
// Session-based authentication for integration routes
|
||||
await applyRateLimit(customRateLimitConfig ?? rateLimitConfigs.api.v1, authentication.user.id);
|
||||
} else if ("hashedApiKey" in authentication) {
|
||||
} else if ("apiKeyId" in authentication) {
|
||||
// API key authentication for general routes
|
||||
await applyRateLimit(customRateLimitConfig ?? rateLimitConfigs.api.v1, authentication.hashedApiKey);
|
||||
await applyRateLimit(customRateLimitConfig ?? rateLimitConfigs.api.v1, authentication.apiKeyId);
|
||||
} else {
|
||||
logger.error({ authentication }, "Unknown authentication type");
|
||||
return responses.internalServerErrorResponse("Invalid authentication configuration");
|
||||
|
||||
@@ -313,6 +313,7 @@ describe("Survey Builder", () => {
|
||||
test("creates a consent question with required fields", () => {
|
||||
const question = buildConsentQuestion({
|
||||
headline: "Consent Question",
|
||||
subheader: "",
|
||||
label: "I agree to terms",
|
||||
t: mockT,
|
||||
});
|
||||
@@ -320,6 +321,7 @@ describe("Survey Builder", () => {
|
||||
expect(question).toMatchObject({
|
||||
type: TSurveyQuestionTypeEnum.Consent,
|
||||
headline: { default: "Consent Question" },
|
||||
subheader: { default: "" },
|
||||
label: { default: "I agree to terms" },
|
||||
buttonLabel: { default: "common.next" },
|
||||
backButtonLabel: { default: "common.back" },
|
||||
@@ -367,6 +369,7 @@ describe("Survey Builder", () => {
|
||||
test("creates a CTA question with required fields", () => {
|
||||
const question = buildCTAQuestion({
|
||||
headline: "CTA Question",
|
||||
subheader: "",
|
||||
buttonExternal: false,
|
||||
t: mockT,
|
||||
});
|
||||
@@ -374,6 +377,7 @@ describe("Survey Builder", () => {
|
||||
expect(question).toMatchObject({
|
||||
type: TSurveyQuestionTypeEnum.CTA,
|
||||
headline: { default: "CTA Question" },
|
||||
subheader: { default: "" },
|
||||
buttonLabel: { default: "common.next" },
|
||||
backButtonLabel: { default: "common.back" },
|
||||
required: false,
|
||||
@@ -398,7 +402,7 @@ describe("Survey Builder", () => {
|
||||
const question = buildCTAQuestion({
|
||||
id: "custom-id",
|
||||
headline: "CTA Question",
|
||||
html: "<p>Click the button</p>",
|
||||
subheader: "<p>Click the button</p>",
|
||||
buttonLabel: "Click me",
|
||||
buttonExternal: true,
|
||||
buttonUrl: "https://example.com",
|
||||
@@ -410,7 +414,7 @@ describe("Survey Builder", () => {
|
||||
});
|
||||
|
||||
expect(question.id).toBe("custom-id");
|
||||
expect(question.html).toEqual({ default: "<p>Click the button</p>" });
|
||||
expect(question.subheader).toEqual({ default: "<p>Click the button</p>" });
|
||||
expect(question.buttonLabel).toEqual({ default: "Click me" });
|
||||
expect(question.buttonExternal).toBe(true);
|
||||
expect(question.buttonUrl).toBe("https://example.com");
|
||||
@@ -423,6 +427,7 @@ describe("Survey Builder", () => {
|
||||
test("handles external button with URL", () => {
|
||||
const question = buildCTAQuestion({
|
||||
headline: "CTA Question",
|
||||
subheader: "",
|
||||
buttonExternal: true,
|
||||
buttonUrl: "https://formbricks.com",
|
||||
t: mockT,
|
||||
@@ -533,7 +538,7 @@ describe("Helper Functions", () => {
|
||||
const card = getDefaultWelcomeCard(mockT);
|
||||
expect(card.enabled).toBe(false);
|
||||
expect(card.headline).toEqual({ default: "templates.default_welcome_card_headline" });
|
||||
expect(card.html).toEqual({ default: "templates.default_welcome_card_html" });
|
||||
expect(card.subheader).toEqual({ default: "templates.default_welcome_card_html" });
|
||||
expect(card.buttonLabel).toEqual({ default: "templates.default_welcome_card_button_label" });
|
||||
// boolean flags
|
||||
expect(card.timeToFinish).toBe(false);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { TFnType } from "@tolgee/react";
|
||||
import {
|
||||
@@ -20,6 +19,7 @@ import {
|
||||
TSurveyWelcomeCard,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { TTemplate, TTemplateRole } from "@formbricks/types/templates";
|
||||
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
|
||||
|
||||
const getDefaultButtonLabel = (label: string | undefined, t: TFnType) =>
|
||||
createI18nString(label || t("common.next"), []);
|
||||
@@ -218,7 +218,7 @@ export const buildConsentQuestion = ({
|
||||
}: {
|
||||
id?: string;
|
||||
headline: string;
|
||||
subheader?: string;
|
||||
subheader: string;
|
||||
buttonLabel?: string;
|
||||
backButtonLabel?: string;
|
||||
required?: boolean;
|
||||
@@ -229,7 +229,7 @@ export const buildConsentQuestion = ({
|
||||
return {
|
||||
id: id ?? createId(),
|
||||
type: TSurveyQuestionTypeEnum.Consent,
|
||||
subheader: subheader ? createI18nString(subheader, []) : undefined,
|
||||
subheader: createI18nString(subheader, []),
|
||||
headline: createI18nString(headline, []),
|
||||
buttonLabel: getDefaultButtonLabel(buttonLabel, t),
|
||||
backButtonLabel: getDefaultBackButtonLabel(backButtonLabel, t),
|
||||
@@ -242,7 +242,7 @@ export const buildConsentQuestion = ({
|
||||
export const buildCTAQuestion = ({
|
||||
id,
|
||||
headline,
|
||||
html,
|
||||
subheader,
|
||||
buttonLabel,
|
||||
buttonExternal,
|
||||
backButtonLabel,
|
||||
@@ -255,7 +255,7 @@ export const buildCTAQuestion = ({
|
||||
id?: string;
|
||||
headline: string;
|
||||
buttonExternal: boolean;
|
||||
html?: string;
|
||||
subheader: string;
|
||||
buttonLabel?: string;
|
||||
backButtonLabel?: string;
|
||||
required?: boolean;
|
||||
@@ -267,7 +267,7 @@ export const buildCTAQuestion = ({
|
||||
return {
|
||||
id: id ?? createId(),
|
||||
type: TSurveyQuestionTypeEnum.CTA,
|
||||
html: html ? createI18nString(html, []) : undefined,
|
||||
subheader: createI18nString(subheader, []),
|
||||
headline: createI18nString(headline, []),
|
||||
buttonLabel: getDefaultButtonLabel(buttonLabel, t),
|
||||
backButtonLabel: getDefaultBackButtonLabel(backButtonLabel, t),
|
||||
@@ -364,7 +364,7 @@ export const getDefaultWelcomeCard = (t: TFnType): TSurveyWelcomeCard => {
|
||||
return {
|
||||
enabled: false,
|
||||
headline: createI18nString(t("templates.default_welcome_card_headline"), []),
|
||||
html: createI18nString(t("templates.default_welcome_card_html"), []),
|
||||
subheader: createI18nString(t("templates.default_welcome_card_html"), []),
|
||||
buttonLabel: createI18nString(t("templates.default_welcome_card_button_label"), []),
|
||||
timeToFinish: false,
|
||||
showResponseCount: false,
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
import { TSurveyQuota } from "@formbricks/types/quota";
|
||||
import {
|
||||
TResponseFilterCriteria,
|
||||
TResponseHiddenFieldsFilter,
|
||||
TSurveyContactAttributes,
|
||||
TSurveyMetaFieldFilter,
|
||||
} from "@formbricks/types/responses";
|
||||
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import {
|
||||
DateRange,
|
||||
FilterValue,
|
||||
@@ -9,15 +19,8 @@ import {
|
||||
QuestionOptions,
|
||||
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox";
|
||||
import { QuestionFilterOptions } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResponseFilter";
|
||||
import { TSurveyQuota } from "@formbricks/types/quota";
|
||||
import {
|
||||
TResponseFilterCriteria,
|
||||
TResponseHiddenFieldsFilter,
|
||||
TSurveyContactAttributes,
|
||||
TSurveyMetaFieldFilter,
|
||||
} from "@formbricks/types/responses";
|
||||
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { recallToHeadline } from "@/lib/utils/recall";
|
||||
|
||||
const conditionOptions = {
|
||||
openText: ["is"],
|
||||
@@ -80,7 +83,9 @@ export const generateQuestionAndFilterOptions = (
|
||||
survey.questions.forEach((q) => {
|
||||
if (Object.keys(conditionOptions).includes(q.type)) {
|
||||
questionsOptions.push({
|
||||
label: q.headline,
|
||||
label: getTextContent(
|
||||
getLocalizedValue(recallToHeadline(q.headline, survey, false, "default"), "default")
|
||||
),
|
||||
questionType: q.type,
|
||||
type: OptionsType.QUESTIONS,
|
||||
id: q.id,
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { TFnType } from "@tolgee/react";
|
||||
import { TSurvey, TSurveyOpenTextQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { TTemplate } from "@formbricks/types/templates";
|
||||
import {
|
||||
buildCTAQuestion,
|
||||
buildConsentQuestion,
|
||||
@@ -13,10 +17,6 @@ import {
|
||||
hiddenFieldsDefault,
|
||||
} from "@/app/lib/survey-builder";
|
||||
import { createI18nString } from "@/lib/i18n/utils";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { TFnType } from "@tolgee/react";
|
||||
import { TSurvey, TSurveyOpenTextQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { TTemplate } from "@formbricks/types/templates";
|
||||
|
||||
const cartAbandonmentSurvey = (t: TFnType): TTemplate => {
|
||||
const reusableQuestionIds = [createId(), createId(), createId()];
|
||||
@@ -32,7 +32,7 @@ const cartAbandonmentSurvey = (t: TFnType): TTemplate => {
|
||||
questions: [
|
||||
buildCTAQuestion({
|
||||
id: reusableQuestionIds[0],
|
||||
html: t("templates.card_abandonment_survey_question_1_html"),
|
||||
subheader: t("templates.card_abandonment_survey_question_1_html"),
|
||||
logic: [createJumpLogic(reusableQuestionIds[0], localSurvey.endings[0].id, "isSkipped")],
|
||||
headline: t("templates.card_abandonment_survey_question_1_headline"),
|
||||
required: false,
|
||||
@@ -92,6 +92,7 @@ const cartAbandonmentSurvey = (t: TFnType): TTemplate => {
|
||||
id: reusableQuestionIds[1],
|
||||
logic: [createJumpLogic(reusableQuestionIds[1], reusableQuestionIds[2], "isSkipped")],
|
||||
headline: t("templates.card_abandonment_survey_question_6_headline"),
|
||||
subheader: "",
|
||||
required: false,
|
||||
label: t("templates.card_abandonment_survey_question_6_label"),
|
||||
t,
|
||||
@@ -133,7 +134,7 @@ const siteAbandonmentSurvey = (t: TFnType): TTemplate => {
|
||||
questions: [
|
||||
buildCTAQuestion({
|
||||
id: reusableQuestionIds[0],
|
||||
html: t("templates.site_abandonment_survey_question_1_html"),
|
||||
subheader: t("templates.site_abandonment_survey_question_1_html"),
|
||||
logic: [createJumpLogic(reusableQuestionIds[0], localSurvey.endings[0].id, "isSkipped")],
|
||||
headline: t("templates.site_abandonment_survey_question_2_headline"),
|
||||
required: false,
|
||||
@@ -192,6 +193,7 @@ const siteAbandonmentSurvey = (t: TFnType): TTemplate => {
|
||||
id: reusableQuestionIds[1],
|
||||
logic: [createJumpLogic(reusableQuestionIds[1], reusableQuestionIds[2], "isSkipped")],
|
||||
headline: t("templates.site_abandonment_survey_question_7_headline"),
|
||||
subheader: "",
|
||||
required: false,
|
||||
label: t("templates.site_abandonment_survey_question_7_label"),
|
||||
t,
|
||||
@@ -231,7 +233,7 @@ const productMarketFitSuperhuman = (t: TFnType): TTemplate => {
|
||||
questions: [
|
||||
buildCTAQuestion({
|
||||
id: reusableQuestionIds[0],
|
||||
html: t("templates.product_market_fit_superhuman_question_1_html"),
|
||||
subheader: t("templates.product_market_fit_superhuman_question_1_html"),
|
||||
logic: [createJumpLogic(reusableQuestionIds[0], localSurvey.endings[0].id, "isSkipped")],
|
||||
headline: t("templates.product_market_fit_superhuman_question_1_headline"),
|
||||
required: false,
|
||||
@@ -409,7 +411,7 @@ const churnSurvey = (t: TFnType): TTemplate => {
|
||||
}),
|
||||
buildCTAQuestion({
|
||||
id: reusableQuestionIds[2],
|
||||
html: t("templates.churn_survey_question_3_html"),
|
||||
subheader: t("templates.churn_survey_question_3_html"),
|
||||
logic: [createJumpLogic(reusableQuestionIds[2], localSurvey.endings[0].id, "isClicked")],
|
||||
headline: t("templates.churn_survey_question_3_headline"),
|
||||
required: true,
|
||||
@@ -429,7 +431,7 @@ const churnSurvey = (t: TFnType): TTemplate => {
|
||||
}),
|
||||
buildCTAQuestion({
|
||||
id: reusableQuestionIds[4],
|
||||
html: t("templates.churn_survey_question_5_html"),
|
||||
subheader: t("templates.churn_survey_question_5_html"),
|
||||
logic: [createJumpLogic(reusableQuestionIds[4], localSurvey.endings[0].id, "isClicked")],
|
||||
headline: t("templates.churn_survey_question_5_headline"),
|
||||
required: true,
|
||||
@@ -707,7 +709,7 @@ const improveTrialConversion = (t: TFnType): TTemplate => {
|
||||
}),
|
||||
buildCTAQuestion({
|
||||
id: reusableQuestionIds[3],
|
||||
html: t("templates.improve_trial_conversion_question_4_html"),
|
||||
subheader: t("templates.improve_trial_conversion_question_4_html"),
|
||||
logic: [createJumpLogic(reusableQuestionIds[3], localSurvey.endings[0].id, "isClicked")],
|
||||
headline: t("templates.improve_trial_conversion_question_4_headline"),
|
||||
required: true,
|
||||
@@ -802,7 +804,7 @@ const reviewPrompt = (t: TFnType): TTemplate => {
|
||||
}),
|
||||
buildCTAQuestion({
|
||||
id: reusableQuestionIds[1],
|
||||
html: t("templates.review_prompt_question_2_html"),
|
||||
subheader: t("templates.review_prompt_question_2_html"),
|
||||
logic: [createJumpLogic(reusableQuestionIds[1], localSurvey.endings[0].id, "isClicked")],
|
||||
headline: t("templates.review_prompt_question_2_headline"),
|
||||
required: true,
|
||||
@@ -840,7 +842,7 @@ const interviewPrompt = (t: TFnType): TTemplate => {
|
||||
buildCTAQuestion({
|
||||
id: createId(),
|
||||
headline: t("templates.interview_prompt_question_1_headline"),
|
||||
html: t("templates.interview_prompt_question_1_html"),
|
||||
subheader: t("templates.interview_prompt_question_1_html"),
|
||||
buttonLabel: t("templates.interview_prompt_question_1_button_label"),
|
||||
buttonUrl: "https://cal.com/johannes",
|
||||
buttonExternal: true,
|
||||
@@ -1343,7 +1345,7 @@ const feedbackBox = (t: TFnType): TTemplate => {
|
||||
}),
|
||||
buildCTAQuestion({
|
||||
id: reusableQuestionIds[2],
|
||||
html: t("templates.feedback_box_question_3_html"),
|
||||
subheader: t("templates.feedback_box_question_3_html"),
|
||||
logic: [
|
||||
createJumpLogic(reusableQuestionIds[2], localSurvey.endings[0].id, "isClicked"),
|
||||
createJumpLogic(reusableQuestionIds[2], localSurvey.endings[0].id, "isSkipped"),
|
||||
@@ -2022,6 +2024,7 @@ const marketSiteClarity = (t: TFnType): TTemplate => {
|
||||
}),
|
||||
buildCTAQuestion({
|
||||
headline: t("templates.market_site_clarity_question_3_headline"),
|
||||
subheader: "",
|
||||
required: false,
|
||||
buttonLabel: t("templates.market_site_clarity_question_3_button_label"),
|
||||
buttonUrl: "https://app.formbricks.com/auth/signup",
|
||||
@@ -2668,7 +2671,7 @@ const identifySignUpBarriers = (t: TFnType): TTemplate => {
|
||||
questions: [
|
||||
buildCTAQuestion({
|
||||
id: reusableQuestionIds[0],
|
||||
html: t("templates.identify_sign_up_barriers_question_1_html"),
|
||||
subheader: t("templates.identify_sign_up_barriers_question_1_html"),
|
||||
logic: [createJumpLogic(reusableQuestionIds[0], localSurvey.endings[0].id, "isSkipped")],
|
||||
headline: t("templates.identify_sign_up_barriers_question_1_headline"),
|
||||
required: false,
|
||||
@@ -2793,7 +2796,7 @@ const identifySignUpBarriers = (t: TFnType): TTemplate => {
|
||||
}),
|
||||
buildCTAQuestion({
|
||||
id: reusableQuestionIds[8],
|
||||
html: t("templates.identify_sign_up_barriers_question_9_html"),
|
||||
subheader: t("templates.identify_sign_up_barriers_question_9_html"),
|
||||
headline: t("templates.identify_sign_up_barriers_question_9_headline"),
|
||||
required: false,
|
||||
buttonUrl: "https://app.formbricks.com/auth/signup",
|
||||
@@ -2965,7 +2968,7 @@ const improveNewsletterContent = (t: TFnType): TTemplate => {
|
||||
}),
|
||||
buildCTAQuestion({
|
||||
id: reusableQuestionIds[2],
|
||||
html: t("templates.improve_newsletter_content_question_3_html"),
|
||||
subheader: t("templates.improve_newsletter_content_question_3_html"),
|
||||
headline: t("templates.improve_newsletter_content_question_3_headline"),
|
||||
required: false,
|
||||
buttonUrl: "https://formbricks.com",
|
||||
@@ -3001,7 +3004,7 @@ const evaluateAProductIdea = (t: TFnType): TTemplate => {
|
||||
questions: [
|
||||
buildCTAQuestion({
|
||||
id: reusableQuestionIds[0],
|
||||
html: t("templates.evaluate_a_product_idea_question_1_html"),
|
||||
subheader: t("templates.evaluate_a_product_idea_question_1_html"),
|
||||
headline: t("templates.evaluate_a_product_idea_question_1_headline"),
|
||||
required: true,
|
||||
buttonLabel: t("templates.evaluate_a_product_idea_question_1_button_label"),
|
||||
@@ -3034,7 +3037,7 @@ const evaluateAProductIdea = (t: TFnType): TTemplate => {
|
||||
}),
|
||||
buildCTAQuestion({
|
||||
id: reusableQuestionIds[3],
|
||||
html: t("templates.evaluate_a_product_idea_question_4_html"),
|
||||
subheader: t("templates.evaluate_a_product_idea_question_4_html"),
|
||||
headline: t("templates.evaluate_a_product_idea_question_4_headline"),
|
||||
required: true,
|
||||
buttonLabel: t("templates.evaluate_a_product_idea_question_4_button_label"),
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { NextRequest } from "next/server";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
import { authenticateRequest } from "@/app/api/v1/auth";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { NextRequest } from "next/server";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
|
||||
export const authorizePrivateDownload = async (
|
||||
request: NextRequest,
|
||||
@@ -12,7 +12,7 @@ export const authorizePrivateDownload = async (
|
||||
action: "GET" | "DELETE"
|
||||
): Promise<
|
||||
Result<
|
||||
{ authType: "session"; userId: string } | { authType: "apiKey"; hashedApiKey: string },
|
||||
{ authType: "session"; userId: string } | { authType: "apiKey"; apiKeyId: string },
|
||||
{
|
||||
unauthorized: boolean;
|
||||
}
|
||||
@@ -49,6 +49,6 @@ export const authorizePrivateDownload = async (
|
||||
|
||||
return ok({
|
||||
authType: "apiKey",
|
||||
hashedApiKey: auth.hashedApiKey,
|
||||
apiKeyId: auth.apiKeyId,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { type NextRequest } from "next/server";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TAccessType, ZDeleteFileRequest, ZDownloadFileRequest } from "@formbricks/types/storage";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { authorizePrivateDownload } from "@/app/storage/[environmentId]/[accessType]/[fileName]/lib/auth";
|
||||
@@ -6,10 +10,6 @@ import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import { deleteFile, getSignedUrlForDownload } from "@/modules/storage/service";
|
||||
import { getErrorResponseFromStorageError } from "@/modules/storage/utils";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { type NextRequest } from "next/server";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TAccessType, ZDeleteFileRequest, ZDownloadFileRequest } from "@formbricks/types/storage";
|
||||
import { logFileDeletion } from "./lib/audit-logs";
|
||||
|
||||
export const GET = async (
|
||||
@@ -100,7 +100,7 @@ export const DELETE = async (
|
||||
if (authResult.ok) {
|
||||
try {
|
||||
if (authResult.data.authType === "apiKey") {
|
||||
await applyRateLimit(rateLimitConfigs.storage.delete, authResult.data.hashedApiKey);
|
||||
await applyRateLimit(rateLimitConfigs.storage.delete, authResult.data.apiKeyId);
|
||||
} else {
|
||||
await applyRateLimit(rateLimitConfigs.storage.delete, authResult.data.userId);
|
||||
}
|
||||
|
||||
@@ -100,10 +100,13 @@ export const getAirtableToken = async (environmentId: string) => {
|
||||
});
|
||||
|
||||
if (!newToken) {
|
||||
logger.error("Failed to fetch new Airtable token", {
|
||||
environmentId,
|
||||
airtableIntegration,
|
||||
});
|
||||
logger.error(
|
||||
{
|
||||
environmentId,
|
||||
airtableIntegration,
|
||||
},
|
||||
"Failed to fetch new Airtable token"
|
||||
);
|
||||
throw new Error("Failed to fetch new Airtable token");
|
||||
}
|
||||
|
||||
@@ -121,10 +124,13 @@ export const getAirtableToken = async (environmentId: string) => {
|
||||
|
||||
return access_token;
|
||||
} catch (error) {
|
||||
logger.error("Failed to get Airtable token", {
|
||||
environmentId,
|
||||
error,
|
||||
});
|
||||
logger.error(
|
||||
{
|
||||
environmentId,
|
||||
error,
|
||||
},
|
||||
"Failed to get Airtable token"
|
||||
);
|
||||
throw new Error("Failed to get Airtable token");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -260,3 +260,6 @@ export const USER_MANAGEMENT_MINIMUM_ROLE = env.USER_MANAGEMENT_MINIMUM_ROLE ??
|
||||
export const AUDIT_LOG_ENABLED = env.AUDIT_LOG_ENABLED === "1";
|
||||
export const AUDIT_LOG_GET_USER_IP = env.AUDIT_LOG_GET_USER_IP === "1";
|
||||
export const SESSION_MAX_AGE = Number(env.SESSION_MAX_AGE) || 86400;
|
||||
|
||||
// Control hash for constant-time password verification to prevent timing attacks. Used when user doesn't exist to maintain consistent verification timing
|
||||
export const CONTROL_HASH = "$2b$12$fzHf9le13Ss9UJ04xzmsjODXpFJxz6vsnupoepF5FiqDECkX2BH5q";
|
||||
|
||||
+365
-30
@@ -1,41 +1,376 @@
|
||||
import { createCipheriv, randomBytes } from "crypto";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { getHash, symmetricDecrypt, symmetricEncrypt } from "./crypto";
|
||||
import * as crypto from "crypto";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { logger } from "@formbricks/logger";
|
||||
// Import after unmocking
|
||||
import {
|
||||
hashSecret,
|
||||
hashSha256,
|
||||
parseApiKeyV2,
|
||||
symmetricDecrypt,
|
||||
symmetricEncrypt,
|
||||
verifySecret,
|
||||
} from "./crypto";
|
||||
|
||||
vi.mock("./constants", () => ({ ENCRYPTION_KEY: "0".repeat(32) }));
|
||||
// Unmock crypto for these tests since we want to test the actual crypto functions
|
||||
vi.unmock("crypto");
|
||||
|
||||
const key = "0".repeat(32);
|
||||
const plain = "hello";
|
||||
// Mock the logger
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
warn: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("crypto", () => {
|
||||
test("encrypt + decrypt roundtrip", () => {
|
||||
const cipher = symmetricEncrypt(plain, key);
|
||||
expect(symmetricDecrypt(cipher, key)).toBe(plain);
|
||||
describe("Crypto Utils", () => {
|
||||
describe("hashSecret and verifySecret", () => {
|
||||
test("should hash and verify secrets correctly", async () => {
|
||||
const secret = "test-secret-123";
|
||||
const hash = await hashSecret(secret);
|
||||
|
||||
expect(hash).toMatch(/^\$2[aby]\$\d+\$[./A-Za-z0-9]{53}$/);
|
||||
|
||||
const isValid = await verifySecret(secret, hash);
|
||||
expect(isValid).toBe(true);
|
||||
});
|
||||
|
||||
test("should reject wrong secrets", async () => {
|
||||
const secret = "test-secret-123";
|
||||
const wrongSecret = "wrong-secret";
|
||||
const hash = await hashSecret(secret);
|
||||
|
||||
const isValid = await verifySecret(wrongSecret, hash);
|
||||
expect(isValid).toBe(false);
|
||||
});
|
||||
|
||||
test("should generate different hashes for the same secret (due to salt)", async () => {
|
||||
const secret = "test-secret-123";
|
||||
const hash1 = await hashSecret(secret);
|
||||
const hash2 = await hashSecret(secret);
|
||||
|
||||
expect(hash1).not.toBe(hash2);
|
||||
|
||||
// But both should verify correctly
|
||||
expect(await verifySecret(secret, hash1)).toBe(true);
|
||||
expect(await verifySecret(secret, hash2)).toBe(true);
|
||||
});
|
||||
|
||||
test("should use custom cost factor", async () => {
|
||||
const secret = "test-secret-123";
|
||||
const hash = await hashSecret(secret, 10);
|
||||
|
||||
// Verify the cost factor is in the hash
|
||||
expect(hash).toMatch(/^\$2[aby]\$10\$/);
|
||||
expect(await verifySecret(secret, hash)).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false for invalid hash format", async () => {
|
||||
const secret = "test-secret-123";
|
||||
const invalidHash = "not-a-bcrypt-hash";
|
||||
|
||||
const isValid = await verifySecret(secret, invalidHash);
|
||||
expect(isValid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("decrypt V2 GCM payload", () => {
|
||||
const iv = randomBytes(16);
|
||||
const bufKey = Buffer.from(key, "utf8");
|
||||
const cipher = createCipheriv("aes-256-gcm", bufKey, iv);
|
||||
let enc = cipher.update(plain, "utf8", "hex");
|
||||
enc += cipher.final("hex");
|
||||
const tag = cipher.getAuthTag().toString("hex");
|
||||
const payload = `${iv.toString("hex")}:${enc}:${tag}`;
|
||||
expect(symmetricDecrypt(payload, key)).toBe(plain);
|
||||
describe("hashSha256", () => {
|
||||
test("should generate deterministic SHA-256 hashes", () => {
|
||||
const input = "test-input-123";
|
||||
const hash1 = hashSha256(input);
|
||||
const hash2 = hashSha256(input);
|
||||
|
||||
expect(hash1).toBe(hash2);
|
||||
expect(hash1).toMatch(/^[a-f0-9]{64}$/);
|
||||
});
|
||||
|
||||
test("should generate different hashes for different inputs", () => {
|
||||
const hash1 = hashSha256("input1");
|
||||
const hash2 = hashSha256("input2");
|
||||
|
||||
expect(hash1).not.toBe(hash2);
|
||||
});
|
||||
|
||||
test("should generate correct SHA-256 hash", () => {
|
||||
// Known SHA-256 hash for "hello"
|
||||
const input = "hello";
|
||||
const expectedHash = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824";
|
||||
|
||||
expect(hashSha256(input)).toBe(expectedHash);
|
||||
});
|
||||
});
|
||||
|
||||
test("decrypt legacy (single-colon) payload", () => {
|
||||
const iv = randomBytes(16);
|
||||
const cipher = createCipheriv("aes256", Buffer.from(key, "utf8"), iv); // NOSONAR typescript:S5542 // We are testing backwards compatibility
|
||||
let enc = cipher.update(plain, "utf8", "hex");
|
||||
enc += cipher.final("hex");
|
||||
const legacy = `${iv.toString("hex")}:${enc}`;
|
||||
expect(symmetricDecrypt(legacy, key)).toBe(plain);
|
||||
describe("parseApiKeyV2", () => {
|
||||
test("should parse valid v2 format keys (fbk_secret)", () => {
|
||||
const secret = "secret456";
|
||||
const key = `fbk_${secret}`;
|
||||
const parsed = parseApiKeyV2(key);
|
||||
|
||||
expect(parsed).toEqual({
|
||||
secret: "secret456",
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle keys with underscores in secrets", () => {
|
||||
// Valid - secrets can contain underscores (base64url-encoded)
|
||||
const key1 = "fbk_secret_with_underscores";
|
||||
const parsed1 = parseApiKeyV2(key1);
|
||||
expect(parsed1).toEqual({
|
||||
secret: "secret_with_underscores",
|
||||
});
|
||||
|
||||
// Valid - multiple underscores in secret
|
||||
const key2 = "fbk_secret_with_many_underscores_allowed";
|
||||
const parsed2 = parseApiKeyV2(key2);
|
||||
expect(parsed2).toEqual({
|
||||
secret: "secret_with_many_underscores_allowed",
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle keys with hyphens in secret", () => {
|
||||
const key = "fbk_secret-with-hyphens";
|
||||
const parsed = parseApiKeyV2(key);
|
||||
|
||||
expect(parsed).toEqual({
|
||||
secret: "secret-with-hyphens",
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle base64url-encoded secrets with all valid characters", () => {
|
||||
// Base64url alphabet includes: A-Z, a-z, 0-9, - (hyphen), _ (underscore)
|
||||
const key1 = "fbk_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
||||
const parsed1 = parseApiKeyV2(key1);
|
||||
expect(parsed1).toEqual({
|
||||
secret: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_",
|
||||
});
|
||||
|
||||
// Realistic base64url secret with underscores and hyphens
|
||||
const key2 = "fbk_a1B2c3D4e5F6g7H8i9J0-_K1L2M3N4O5P6";
|
||||
const parsed2 = parseApiKeyV2(key2);
|
||||
expect(parsed2).toEqual({
|
||||
secret: "a1B2c3D4e5F6g7H8i9J0-_K1L2M3N4O5P6",
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle long secrets (GitHub-style PATs)", () => {
|
||||
// Simulating a 32-byte base64url-encoded secret (43 chars)
|
||||
const longSecret = "a".repeat(43);
|
||||
const key = `fbk_${longSecret}`;
|
||||
const parsed = parseApiKeyV2(key);
|
||||
|
||||
expect(parsed).toEqual({
|
||||
secret: longSecret,
|
||||
});
|
||||
});
|
||||
|
||||
test("should return null for invalid formats", () => {
|
||||
const invalidKeys = [
|
||||
"invalid-key", // No fbk_ prefix
|
||||
"fbk_", // No secret
|
||||
"not_fbk_secret", // Wrong prefix
|
||||
"", // Empty string
|
||||
];
|
||||
|
||||
invalidKeys.forEach((key) => {
|
||||
expect(parseApiKeyV2(key)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test("should reject secrets with invalid characters", () => {
|
||||
// Secrets should only contain base64url characters: [A-Za-z0-9_-]
|
||||
const invalidKeys = [
|
||||
"fbk_secret+with+plus", // + is not base64url (it's base64)
|
||||
"fbk_secret/with/slash", // / is not base64url (it's base64)
|
||||
"fbk_secret=with=equals", // = is padding, not in base64url alphabet
|
||||
"fbk_secret with space", // spaces not allowed
|
||||
"fbk_secret!special", // special chars not allowed
|
||||
"fbk_secret@email", // @ not allowed
|
||||
"fbk_secret#hash", // # not allowed
|
||||
"fbk_secret$dollar", // $ not allowed
|
||||
];
|
||||
|
||||
invalidKeys.forEach((key) => {
|
||||
expect(parseApiKeyV2(key)).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("getHash returns a non-empty string", () => {
|
||||
const h = getHash("abc");
|
||||
expect(typeof h).toBe("string");
|
||||
expect(h.length).toBeGreaterThan(0);
|
||||
describe("symmetricEncrypt and symmetricDecrypt", () => {
|
||||
// 64 hex characters = 32 bytes when decoded
|
||||
const testKey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
test("should encrypt and decrypt data correctly (V2 format)", () => {
|
||||
const plaintext = "sensitive data to encrypt";
|
||||
const encrypted = symmetricEncrypt(plaintext, testKey);
|
||||
|
||||
// V2 format should have 3 parts: iv:ciphertext:tag
|
||||
const parts = encrypted.split(":");
|
||||
expect(parts).toHaveLength(3);
|
||||
|
||||
const decrypted = symmetricDecrypt(encrypted, testKey);
|
||||
expect(decrypted).toBe(plaintext);
|
||||
});
|
||||
|
||||
test("should produce different encrypted values for the same plaintext (due to random IV)", () => {
|
||||
const plaintext = "same data";
|
||||
const encrypted1 = symmetricEncrypt(plaintext, testKey);
|
||||
const encrypted2 = symmetricEncrypt(plaintext, testKey);
|
||||
|
||||
expect(encrypted1).not.toBe(encrypted2);
|
||||
|
||||
// But both should decrypt to the same value
|
||||
expect(symmetricDecrypt(encrypted1, testKey)).toBe(plaintext);
|
||||
expect(symmetricDecrypt(encrypted2, testKey)).toBe(plaintext);
|
||||
});
|
||||
|
||||
test("should handle various data types and special characters", () => {
|
||||
const testCases = [
|
||||
"simple text",
|
||||
"text with spaces and special chars: !@#$%^&*()",
|
||||
'{"json": "data", "number": 123}',
|
||||
"unicode: 你好世界 🚀",
|
||||
"",
|
||||
"a".repeat(1000), // long text
|
||||
];
|
||||
|
||||
testCases.forEach((text) => {
|
||||
const encrypted = symmetricEncrypt(text, testKey);
|
||||
const decrypted = symmetricDecrypt(encrypted, testKey);
|
||||
expect(decrypted).toBe(text);
|
||||
});
|
||||
});
|
||||
|
||||
test("should decrypt legacy V1 format (with only one colon)", () => {
|
||||
// Simulate a V1 encrypted value (only has one colon: iv:ciphertext)
|
||||
// This test verifies backward compatibility
|
||||
const plaintext = "legacy data";
|
||||
|
||||
// Since we can't easily create a V1 format without the old code,
|
||||
// we'll just verify that a payload with 2 parts triggers the V1 path
|
||||
// For a real test, you'd need a known V1 encrypted value
|
||||
|
||||
// Skip this test or use a known V1 encrypted string if available
|
||||
// For now, we'll test that the logic correctly identifies the format
|
||||
const v2Encrypted = symmetricEncrypt(plaintext, testKey);
|
||||
expect(v2Encrypted.split(":")).toHaveLength(3); // V2 has 3 parts
|
||||
});
|
||||
|
||||
test("should throw error for invalid encrypted data", () => {
|
||||
const invalidEncrypted = "invalid:encrypted:data:extra";
|
||||
|
||||
expect(() => {
|
||||
symmetricDecrypt(invalidEncrypted, testKey);
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
test("should throw error when decryption key is wrong", () => {
|
||||
const plaintext = "secret message";
|
||||
const correctKey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
const wrongKey = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff";
|
||||
|
||||
const encrypted = symmetricEncrypt(plaintext, correctKey);
|
||||
|
||||
expect(() => {
|
||||
symmetricDecrypt(encrypted, wrongKey);
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
test("should handle empty string encryption and decryption", () => {
|
||||
const plaintext = "";
|
||||
const encrypted = symmetricEncrypt(plaintext, testKey);
|
||||
const decrypted = symmetricDecrypt(encrypted, testKey);
|
||||
|
||||
expect(decrypted).toBe(plaintext);
|
||||
expect(decrypted).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GCM decryption failure logging", () => {
|
||||
// Test key - 32 bytes for AES-256
|
||||
const testKey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
const plaintext = "test message";
|
||||
|
||||
beforeEach(() => {
|
||||
// Clear mock calls before each test
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("logs warning and throws when GCM decryption fails with invalid auth tag", () => {
|
||||
// Create a valid GCM payload but corrupt the auth tag
|
||||
const iv = crypto.randomBytes(16);
|
||||
const bufKey = Buffer.from(testKey, "hex");
|
||||
const cipher = crypto.createCipheriv("aes-256-gcm", bufKey, iv);
|
||||
let enc = cipher.update(plaintext, "utf8", "hex");
|
||||
enc += cipher.final("hex");
|
||||
const validTag = cipher.getAuthTag().toString("hex");
|
||||
|
||||
// Corrupt the auth tag by flipping some bits
|
||||
const corruptedTag = validTag
|
||||
.split("")
|
||||
.map((c, i) => (i < 4 ? (parseInt(c, 16) ^ 0xf).toString(16) : c))
|
||||
.join("");
|
||||
|
||||
const corruptedPayload = `${iv.toString("hex")}:${enc}:${corruptedTag}`;
|
||||
|
||||
// Should throw an error and log a warning
|
||||
expect(() => symmetricDecrypt(corruptedPayload, testKey)).toThrow();
|
||||
|
||||
// Verify logger.warn was called with the correct format (object first, message second)
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: expect.any(Error) },
|
||||
"AES-GCM decryption failed; refusing to fall back to insecure CBC"
|
||||
);
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("logs warning and throws when GCM decryption fails with corrupted encrypted data", () => {
|
||||
// Create a payload with valid structure but corrupted encrypted data
|
||||
const iv = crypto.randomBytes(16);
|
||||
const bufKey = Buffer.from(testKey, "hex");
|
||||
const cipher = crypto.createCipheriv("aes-256-gcm", bufKey, iv);
|
||||
let enc = cipher.update(plaintext, "utf8", "hex");
|
||||
enc += cipher.final("hex");
|
||||
const tag = cipher.getAuthTag().toString("hex");
|
||||
|
||||
// Corrupt the encrypted data
|
||||
const corruptedEnc = enc
|
||||
.split("")
|
||||
.map((c, i) => (i < 4 ? (parseInt(c, 16) ^ 0xa).toString(16) : c))
|
||||
.join("");
|
||||
|
||||
const corruptedPayload = `${iv.toString("hex")}:${corruptedEnc}:${tag}`;
|
||||
|
||||
// Should throw an error and log a warning
|
||||
expect(() => symmetricDecrypt(corruptedPayload, testKey)).toThrow();
|
||||
|
||||
// Verify logger.warn was called
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: expect.any(Error) },
|
||||
"AES-GCM decryption failed; refusing to fall back to insecure CBC"
|
||||
);
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("logs warning and throws when GCM decryption fails with wrong key", () => {
|
||||
// Create a valid GCM payload with one key
|
||||
const iv = crypto.randomBytes(16);
|
||||
const bufKey = Buffer.from(testKey, "hex");
|
||||
const cipher = crypto.createCipheriv("aes-256-gcm", bufKey, iv);
|
||||
let enc = cipher.update(plaintext, "utf8", "hex");
|
||||
enc += cipher.final("hex");
|
||||
const tag = cipher.getAuthTag().toString("hex");
|
||||
const payload = `${iv.toString("hex")}:${enc}:${tag}`;
|
||||
|
||||
// Try to decrypt with a different key (32 bytes)
|
||||
const wrongKey = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff";
|
||||
|
||||
// Should throw an error and log a warning
|
||||
expect(() => symmetricDecrypt(payload, wrongKey)).toThrow();
|
||||
|
||||
// Verify logger.warn was called
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: expect.any(Error) },
|
||||
"AES-GCM decryption failed; refusing to fall back to insecure CBC"
|
||||
);
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+52
-3
@@ -1,6 +1,7 @@
|
||||
import { compare, hash } from "bcryptjs";
|
||||
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "crypto";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ENCRYPTION_KEY } from "./constants";
|
||||
import { ENCRYPTION_KEY } from "@/lib/constants";
|
||||
|
||||
const ALGORITHM_V1 = "aes256";
|
||||
const ALGORITHM_V2 = "aes-256-gcm";
|
||||
@@ -85,10 +86,58 @@ export function symmetricDecrypt(payload: string, key: string): string {
|
||||
try {
|
||||
return symmetricDecryptV2(payload, key);
|
||||
} catch (err) {
|
||||
logger.warn(err, "AES-GCM decryption failed; refusing to fall back to insecure CBC");
|
||||
logger.warn({ err }, "AES-GCM decryption failed; refusing to fall back to insecure CBC");
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export const getHash = (key: string): string => createHash("sha256").update(key).digest("hex");
|
||||
/**
|
||||
* General bcrypt hashing utility for secrets (passwords, API keys, etc.)
|
||||
*/
|
||||
export const hashSecret = async (secret: string, cost: number = 12): Promise<string> => {
|
||||
return await hash(secret, cost);
|
||||
};
|
||||
|
||||
/**
|
||||
* General bcrypt verification utility for secrets (passwords, API keys, etc.)
|
||||
*/
|
||||
export const verifySecret = async (secret: string, hashedSecret: string): Promise<boolean> => {
|
||||
try {
|
||||
const isValid = await compare(secret, hashedSecret);
|
||||
return isValid;
|
||||
} catch (error) {
|
||||
// Log warning for debugging purposes, but don't throw to maintain security
|
||||
logger.warn({ error }, "Secret verification failed due to invalid hash format");
|
||||
// Return false for invalid hashes or other bcrypt errors
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* SHA-256 hashing utility (deterministic, for legacy support)
|
||||
*/
|
||||
export const hashSha256 = (input: string): string => {
|
||||
return createHash("sha256").update(input).digest("hex");
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse a v2 API key format: fbk_{secret}
|
||||
* Returns null if the key doesn't match the expected format
|
||||
*/
|
||||
export const parseApiKeyV2 = (key: string): { secret: string } | null => {
|
||||
// Check if it starts with fbk_
|
||||
if (!key.startsWith("fbk_")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const secret = key.slice(4); // Skip 'fbk_' prefix
|
||||
|
||||
// Validate that secret contains only allowed characters and is not empty
|
||||
// Secrets are base64url-encoded and can contain underscores, hyphens, and alphanumeric chars
|
||||
if (!secret || !/^[A-Za-z0-9_-]+$/.test(secret)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { secret };
|
||||
};
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { mockSurveyLanguages } from "@/lib/survey/__mock__/survey.mock";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyCTAQuestion,
|
||||
@@ -15,6 +14,7 @@ import {
|
||||
TSurveyRatingQuestion,
|
||||
TSurveyWelcomeCard,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { mockSurveyLanguages } from "@/lib/survey/__mock__/survey.mock";
|
||||
|
||||
export const mockWelcomeCard: TSurveyWelcomeCard = {
|
||||
html: {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { INVISIBLE_REGEX } from "@/lib/i18n/constants";
|
||||
import { structuredClone } from "@/lib/pollyfills/structuredClone";
|
||||
import { iso639Languages } from "@formbricks/i18n-utils/src/utils";
|
||||
import { TLanguage } from "@formbricks/types/project";
|
||||
import { TI18nString, TSurveyLanguage } from "@formbricks/types/surveys/types";
|
||||
import { INVISIBLE_REGEX } from "@/lib/i18n/constants";
|
||||
import { structuredClone } from "@/lib/pollyfills/structuredClone";
|
||||
|
||||
// https://github.com/tolgee/tolgee-js/blob/main/packages/web/src/package/observers/invisible/secret.ts
|
||||
const removeTolgeeInvisibleMarks = (str: string) => {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import {
|
||||
TResponse,
|
||||
@@ -17,6 +16,9 @@ import {
|
||||
TSurveyQuestion,
|
||||
TSurveyRankingQuestion,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
||||
import { processResponseData } from "../responses";
|
||||
import { getTodaysDateTimeFormatted } from "../time";
|
||||
import { getFormattedDateTimeString } from "../utils/datetime";
|
||||
@@ -659,11 +661,13 @@ export const extracMetadataKeys = (obj: TResponse["meta"]) => {
|
||||
|
||||
export const extractSurveyDetails = (survey: TSurvey, responses: TResponse[]) => {
|
||||
const metaDataFields = responses.length > 0 ? extracMetadataKeys(responses[0].meta) : [];
|
||||
const questions = survey.questions.map((question, idx) => {
|
||||
const headline = getLocalizedValue(question.headline, "default") ?? question.id;
|
||||
const modifiedSurvey = replaceHeadlineRecall(survey, "default");
|
||||
|
||||
const questions = modifiedSurvey.questions.map((question, idx) => {
|
||||
const headline = getTextContent(getLocalizedValue(question.headline, "default")) ?? question.id;
|
||||
if (question.type === "matrix") {
|
||||
return question.rows.map((row) => {
|
||||
return `${idx + 1}. ${headline} - ${getLocalizedValue(row.label, "default")}`;
|
||||
return `${idx + 1}. ${headline} - ${getTextContent(getLocalizedValue(row.label, "default"))}`;
|
||||
});
|
||||
} else if (
|
||||
question.type === "multipleChoiceMulti" ||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { parseRecallInfo } from "@/lib/utils/recall";
|
||||
import { TResponse, TResponseDataValue } from "@formbricks/types/responses";
|
||||
import { TSurvey, TSurveyQuestion, TSurveyQuestionType } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { parseRecallInfo } from "@/lib/utils/recall";
|
||||
import { getLanguageCode, getLocalizedValue } from "./i18n/utils";
|
||||
|
||||
// function to convert response value of type string | number | string[] or Record<string, string> to string | string[]
|
||||
@@ -45,9 +46,11 @@ export const getQuestionResponseMapping = (
|
||||
const answer = response.data[question.id];
|
||||
|
||||
questionResponseMapping.push({
|
||||
question: parseRecallInfo(
|
||||
getLocalizedValue(question.headline, responseLanguageCode ?? "default"),
|
||||
response.data
|
||||
question: getTextContent(
|
||||
parseRecallInfo(
|
||||
getLocalizedValue(question.headline, responseLanguageCode ?? "default"),
|
||||
response.data
|
||||
)
|
||||
),
|
||||
response: convertResponseValue(answer, question),
|
||||
type: question.type,
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { structuredClone } from "@/lib/pollyfills/structuredClone";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { TResponseData, TResponseVariables } from "@formbricks/types/responses";
|
||||
import { TSurvey, TSurveyQuestion, TSurveyRecallItem } from "@formbricks/types/surveys/types";
|
||||
import { structuredClone } from "@/lib/pollyfills/structuredClone";
|
||||
import {
|
||||
checkForEmptyFallBackValue,
|
||||
extractFallbackValue,
|
||||
@@ -22,9 +21,11 @@ import {
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/lib/i18n/utils", () => ({
|
||||
getLocalizedValue: vi.fn().mockImplementation((obj, lang) => {
|
||||
return typeof obj === "string" ? obj : obj[lang] || obj["default"] || "";
|
||||
}),
|
||||
getLocalizedValue: (obj: any, lang: string) => {
|
||||
if (typeof obj === "string") return obj;
|
||||
if (!obj) return "";
|
||||
return obj[lang] || obj["default"] || "";
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/pollyfills/structuredClone", () => ({
|
||||
@@ -142,12 +143,12 @@ describe("recall utility functions", () => {
|
||||
describe("recallToHeadline", () => {
|
||||
test("converts recall pattern to headline format without slash", () => {
|
||||
const headline = { en: "How do you like #recall:product/fallback:ournbspproduct#?" };
|
||||
const survey: TSurvey = {
|
||||
const survey = {
|
||||
id: "test-survey",
|
||||
questions: [{ id: "product", headline: { en: "Product Question" } }] as unknown as TSurveyQuestion[],
|
||||
questions: [{ id: "product", headline: { en: "Product Question" } }],
|
||||
hiddenFields: { fieldIds: [] },
|
||||
variables: [],
|
||||
} as unknown as TSurvey;
|
||||
} as any;
|
||||
|
||||
const result = recallToHeadline(headline, survey, false, "en");
|
||||
expect(result.en).toBe("How do you like @Product Question?");
|
||||
@@ -155,12 +156,12 @@ describe("recall utility functions", () => {
|
||||
|
||||
test("converts recall pattern to headline format with slash", () => {
|
||||
const headline = { en: "Rate #recall:product/fallback:ournbspproduct#" };
|
||||
const survey: TSurvey = {
|
||||
const survey = {
|
||||
id: "test-survey",
|
||||
questions: [{ id: "product", headline: { en: "Product Question" } }] as unknown as TSurveyQuestion[],
|
||||
questions: [{ id: "product", headline: { en: "Product Question" } }],
|
||||
hiddenFields: { fieldIds: [] },
|
||||
variables: [],
|
||||
} as unknown as TSurvey;
|
||||
} as any;
|
||||
|
||||
const result = recallToHeadline(headline, survey, true, "en");
|
||||
expect(result.en).toBe("Rate /Product Question\\");
|
||||
@@ -204,15 +205,12 @@ describe("recall utility functions", () => {
|
||||
const headline = {
|
||||
en: "This is #recall:inner/fallback:fallback2#",
|
||||
};
|
||||
const survey: TSurvey = {
|
||||
const survey = {
|
||||
id: "test-survey",
|
||||
questions: [
|
||||
{ id: "inner", headline: { en: "Inner with @outer" } },
|
||||
{ id: "inner", headline: { en: "Inner value" } },
|
||||
] as unknown as TSurveyQuestion[],
|
||||
questions: [{ id: "inner", headline: { en: "Inner with @outer" } }],
|
||||
hiddenFields: { fieldIds: [] },
|
||||
variables: [],
|
||||
} as unknown as TSurvey;
|
||||
} as any;
|
||||
|
||||
const result = recallToHeadline(headline, survey, false, "en");
|
||||
expect(result.en).toBe("This is @Inner with @outer");
|
||||
@@ -242,16 +240,14 @@ describe("recall utility functions", () => {
|
||||
describe("checkForEmptyFallBackValue", () => {
|
||||
test("identifies question with empty fallback value", () => {
|
||||
const questionHeadline = { en: "Question with #recall:id1/fallback:# empty fallback" };
|
||||
const survey: TSurvey = {
|
||||
const survey = {
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
headline: questionHeadline,
|
||||
},
|
||||
] as unknown as TSurveyQuestion[],
|
||||
} as unknown as TSurvey;
|
||||
|
||||
vi.mocked(getLocalizedValue).mockReturnValueOnce(questionHeadline.en);
|
||||
],
|
||||
} as any;
|
||||
|
||||
const result = checkForEmptyFallBackValue(survey, "en");
|
||||
expect(result).toBe(survey.questions[0]);
|
||||
@@ -259,17 +255,15 @@ describe("recall utility functions", () => {
|
||||
|
||||
test("identifies question with empty fallback in subheader", () => {
|
||||
const questionSubheader = { en: "Subheader with #recall:id1/fallback:# empty fallback" };
|
||||
const survey: TSurvey = {
|
||||
const survey = {
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
headline: { en: "Normal question" },
|
||||
subheader: questionSubheader,
|
||||
},
|
||||
] as unknown as TSurveyQuestion[],
|
||||
} as unknown as TSurvey;
|
||||
|
||||
vi.mocked(getLocalizedValue).mockReturnValueOnce(questionSubheader.en);
|
||||
],
|
||||
} as any;
|
||||
|
||||
const result = checkForEmptyFallBackValue(survey, "en");
|
||||
expect(result).toBe(survey.questions[0]);
|
||||
@@ -277,16 +271,14 @@ describe("recall utility functions", () => {
|
||||
|
||||
test("returns null when no empty fallback values are found", () => {
|
||||
const questionHeadline = { en: "Question with #recall:id1/fallback:default# valid fallback" };
|
||||
const survey: TSurvey = {
|
||||
const survey = {
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
headline: questionHeadline,
|
||||
},
|
||||
] as unknown as TSurveyQuestion[],
|
||||
} as unknown as TSurvey;
|
||||
|
||||
vi.mocked(getLocalizedValue).mockReturnValueOnce(questionHeadline.en);
|
||||
],
|
||||
} as any;
|
||||
|
||||
const result = checkForEmptyFallBackValue(survey, "en");
|
||||
expect(result).toBeNull();
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { structuredClone } from "@/lib/pollyfills/structuredClone";
|
||||
import { TResponseData, TResponseDataValue, TResponseVariables } from "@formbricks/types/responses";
|
||||
import { TI18nString, TSurvey, TSurveyQuestion, TSurveyRecallItem } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { structuredClone } from "@/lib/pollyfills/structuredClone";
|
||||
import { formatDateWithOrdinal, isValidDateString } from "./datetime";
|
||||
|
||||
export interface fallbacks {
|
||||
@@ -59,7 +60,11 @@ const getRecallItemLabel = <T extends TSurvey>(
|
||||
if (isHiddenField) return recallItemId;
|
||||
|
||||
const surveyQuestion = survey.questions.find((question) => question.id === recallItemId);
|
||||
if (surveyQuestion) return surveyQuestion.headline[languageCode];
|
||||
if (surveyQuestion) {
|
||||
const headline = getLocalizedValue(surveyQuestion.headline, languageCode);
|
||||
// Strip HTML tags to prevent raw HTML from showing in nested recalls
|
||||
return headline ? getTextContent(headline) : headline;
|
||||
}
|
||||
|
||||
const variable = survey.variables?.find((variable) => variable.id === recallItemId);
|
||||
if (variable) return variable.name;
|
||||
@@ -118,15 +123,15 @@ export const replaceRecallInfoWithUnderline = (label: string): string => {
|
||||
|
||||
// Checks for survey questions with a "recall" pattern but no fallback value.
|
||||
export const checkForEmptyFallBackValue = (survey: TSurvey, language: string): TSurveyQuestion | null => {
|
||||
const findRecalls = (text: string) => {
|
||||
const doesTextHaveRecall = (text: string) => {
|
||||
const recalls = text.match(/#recall:[^ ]+/g);
|
||||
return recalls && recalls.some((recall) => !extractFallbackValue(recall));
|
||||
return recalls?.some((recall) => !extractFallbackValue(recall));
|
||||
};
|
||||
|
||||
for (const question of survey.questions) {
|
||||
if (
|
||||
findRecalls(getLocalizedValue(question.headline, language)) ||
|
||||
(question.subheader && findRecalls(getLocalizedValue(question.subheader, language)))
|
||||
doesTextHaveRecall(getLocalizedValue(question.headline, language)) ||
|
||||
(question.subheader && doesTextHaveRecall(getLocalizedValue(question.subheader, language)))
|
||||
) {
|
||||
return question;
|
||||
}
|
||||
@@ -266,3 +271,18 @@ export const parseRecallInfo = (
|
||||
|
||||
return modifiedText;
|
||||
};
|
||||
|
||||
export const getTextContentWithRecallTruncated = (text: string, maxLength: number = 25): string => {
|
||||
const cleanText = getTextContent(text).replaceAll(/\s+/g, " ").trim();
|
||||
|
||||
if (cleanText.length <= maxLength) {
|
||||
return replaceRecallInfoWithUnderline(cleanText);
|
||||
}
|
||||
|
||||
const recalledCleanText = replaceRecallInfoWithUnderline(cleanText);
|
||||
|
||||
const start = recalledCleanText.slice(0, 10);
|
||||
const end = recalledCleanText.slice(-10);
|
||||
|
||||
return `${start}...${end}`;
|
||||
};
|
||||
|
||||
@@ -279,6 +279,7 @@
|
||||
"no_result_found": "Kein Ergebnis gefunden",
|
||||
"no_results": "Keine Ergebnisse",
|
||||
"no_surveys_found": "Keine Umfragen gefunden.",
|
||||
"none_of_the_above": "Keine der oben genannten Optionen",
|
||||
"not_authenticated": "Du bist nicht authentifiziert, um diese Aktion durchzuführen.",
|
||||
"not_authorized": "Nicht berechtigt",
|
||||
"not_connected": "Nicht verbunden",
|
||||
@@ -1203,12 +1204,12 @@
|
||||
"add_description": "Beschreibung hinzufügen",
|
||||
"add_ending": "Abschluss hinzufügen",
|
||||
"add_ending_below": "Abschluss unten hinzufügen",
|
||||
"add_fallback": "Hinzufügen",
|
||||
"add_fallback_placeholder": "Platzhalter hinzufügen, falls kein Wert zur Verfügung steht.",
|
||||
"add_hidden_field_id": "Verstecktes Feld ID hinzufügen",
|
||||
"add_highlight_border": "Rahmen hinzufügen",
|
||||
"add_highlight_border_description": "Füge deiner Umfragekarte einen äußeren Rahmen hinzu.",
|
||||
"add_logic": "Logik hinzufügen",
|
||||
"add_none_of_the_above": "Füge \"Keine der oben genannten Optionen\" hinzu",
|
||||
"add_option": "Option hinzufügen",
|
||||
"add_other": "Anderes hinzufügen",
|
||||
"add_photo_or_video": "Foto oder Video hinzufügen",
|
||||
@@ -1241,6 +1242,7 @@
|
||||
"automatically_mark_the_survey_as_complete_after": "Umfrage automatisch als abgeschlossen markieren nach",
|
||||
"back_button_label": "Zurück\"- Button ",
|
||||
"background_styling": "Hintergründe",
|
||||
"bold": "Fett",
|
||||
"brand_color": "Markenfarbe",
|
||||
"brightness": "Helligkeit",
|
||||
"button_label": "Beschriftung",
|
||||
@@ -1313,7 +1315,6 @@
|
||||
"days_before_showing_this_survey_again": "Tage, bevor diese Umfrage erneut angezeigt wird.",
|
||||
"decide_how_often_people_can_answer_this_survey": "Entscheide, wie oft Leute diese Umfrage beantworten können.",
|
||||
"delete_choice": "Auswahl löschen",
|
||||
"description": "Beschreibung",
|
||||
"disable_the_visibility_of_survey_progress": "Deaktiviere die Sichtbarkeit des Umfragefortschritts.",
|
||||
"display_an_estimate_of_completion_time_for_survey": "Zeige eine Schätzung der Fertigstellungszeit für die Umfrage an",
|
||||
"display_number_of_responses_for_survey": "Anzahl der Antworten für Umfrage anzeigen",
|
||||
@@ -1324,6 +1325,7 @@
|
||||
"does_not_include_all_of": "Enthält nicht alle von",
|
||||
"does_not_include_one_of": "Enthält nicht eines von",
|
||||
"does_not_start_with": "Fängt nicht an mit",
|
||||
"edit_link": "Bearbeitungslink",
|
||||
"edit_recall": "Erinnerung bearbeiten",
|
||||
"edit_translations": "{lang} -Übersetzungen bearbeiten",
|
||||
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Teilnehmer können die Umfragesprache jederzeit während der Umfrage ändern.",
|
||||
@@ -1334,13 +1336,14 @@
|
||||
"ending_card_used_in_logic": "Diese Abschlusskarte wird in der Logik der Frage {questionIndex} verwendet.",
|
||||
"ending_used_in_quota": "Dieses Ende wird in der \"{quotaName}\" Quote verwendet",
|
||||
"ends_with": "endet mit",
|
||||
"enter_fallback_value": "Ersatzwert eingeben",
|
||||
"equals": "Gleich",
|
||||
"equals_one_of": "Entspricht einem von",
|
||||
"error_publishing_survey": "Beim Veröffentlichen der Umfrage ist ein Fehler aufgetreten.",
|
||||
"error_saving_changes": "Fehler beim Speichern der Änderungen",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "Sogar nachdem sie eine Antwort eingereicht haben (z.B. Feedback-Box)",
|
||||
"everyone": "Jeder",
|
||||
"fallback_for": "Ersatz für",
|
||||
"external_urls_paywall_tooltip": "Bitte aktualisieren, um die externe URL anzupassen. Phishing-Prävention.",
|
||||
"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",
|
||||
@@ -1397,6 +1400,9 @@
|
||||
"four_points": "4 Punkte",
|
||||
"heading": "Überschrift",
|
||||
"hidden_field_added_successfully": "Verstecktes Feld erfolgreich hinzugefügt",
|
||||
"hidden_field_used_in_recall": "Verstecktes Feld \"{hiddenField}\" wird in Frage {questionIndex} abgerufen.",
|
||||
"hidden_field_used_in_recall_ending_card": "Verstecktes Feld \"{hiddenField}\" wird in der Abschlusskarte abgerufen.",
|
||||
"hidden_field_used_in_recall_welcome": "Verstecktes Feld \"{hiddenField}\" wird in der Willkommenskarte abgerufen.",
|
||||
"hide_advanced_settings": "Erweiterte Einstellungen ausblenden",
|
||||
"hide_back_button": "'Zurück'-Button ausblenden",
|
||||
"hide_back_button_description": "Den Zurück-Button in der Umfrage nicht anzeigen",
|
||||
@@ -1415,6 +1421,7 @@
|
||||
"inner_text": "Innerer Text",
|
||||
"input_border_color": "Randfarbe des Eingabefelds",
|
||||
"input_color": "Farbe des Eingabefelds",
|
||||
"insert_link": "Link einfügen",
|
||||
"invalid_targeting": "Ungültiges Targeting: Bitte überprüfe deine Zielgruppenfilter",
|
||||
"invalid_video_url_warning": "Bitte gib eine gültige YouTube-, Vimeo- oder Loom-URL ein. Andere Video-Plattformen werden derzeit nicht unterstützt.",
|
||||
"invalid_youtube_url": "Ungültige YouTube-URL",
|
||||
@@ -1432,6 +1439,7 @@
|
||||
"is_set": "Ist festgelegt",
|
||||
"is_skipped": "Wird übersprungen",
|
||||
"is_submitted": "Wird eingereicht",
|
||||
"italic": "Kursiv",
|
||||
"jump_to_question": "Zur Frage springen",
|
||||
"keep_current_order": "Bestehende Anordnung beibehalten",
|
||||
"keep_showing_while_conditions_match": "Zeige weiter, solange die Bedingungen übereinstimmen",
|
||||
@@ -1458,6 +1466,7 @@
|
||||
"no_images_found_for": "Keine Bilder gefunden für ''{query}\"",
|
||||
"no_languages_found_add_first_one_to_get_started": "Keine Sprachen gefunden. Füge die erste hinzu, um loszulegen.",
|
||||
"no_option_found": "Keine Option gefunden",
|
||||
"no_recall_items_found": "Keine Erinnerungsstücke gefunden",
|
||||
"no_variables_yet_add_first_one_below": "Noch keine Variablen. Füge die erste hinzu.",
|
||||
"number": "Nummer",
|
||||
"once_set_the_default_language_for_this_survey_can_only_be_changed_by_disabling_the_multi_language_option_and_deleting_all_translations": "Sobald die Standardsprache für diese Umfrage festgelegt ist, kann sie nur geändert werden, indem die Mehrsprachigkeitsoption deaktiviert und alle Übersetzungen gelöscht werden.",
|
||||
@@ -1477,6 +1486,7 @@
|
||||
"pin_can_only_contain_numbers": "PIN darf nur Zahlen enthalten.",
|
||||
"pin_must_be_a_four_digit_number": "Die PIN muss eine vierstellige Zahl sein.",
|
||||
"please_enter_a_file_extension": "Bitte gib eine Dateierweiterung ein.",
|
||||
"please_enter_a_valid_url": "Bitte geben Sie eine gültige URL ein (z. B. https://beispiel.de)",
|
||||
"please_set_a_survey_trigger": "Bitte richte einen Umfrage-Trigger ein",
|
||||
"please_specify": "Bitte angeben",
|
||||
"prevent_double_submission": "Doppeltes Anbschicken verhindern",
|
||||
@@ -1491,6 +1501,8 @@
|
||||
"question_id_updated": "Frage-ID aktualisiert",
|
||||
"question_used_in_logic": "Diese Frage wird in der Logik der Frage {questionIndex} verwendet.",
|
||||
"question_used_in_quota": "Diese Frage wird in der \"{quotaName}\" Quote verwendet",
|
||||
"question_used_in_recall": "Diese Frage wird in Frage {questionIndex} abgerufen.",
|
||||
"question_used_in_recall_ending_card": "Diese Frage wird in der Abschlusskarte abgerufen.",
|
||||
"quotas": {
|
||||
"add_quota": "Quote hinzufügen",
|
||||
"change_quota_for_public_survey": "Quote für öffentliche Umfrage ändern?",
|
||||
@@ -1525,6 +1537,8 @@
|
||||
"randomize_all": "Alle Optionen zufällig anordnen",
|
||||
"randomize_all_except_last": "Alle Optionen zufällig anordnen außer der letzten",
|
||||
"range": "Reichweite",
|
||||
"recall_data": "Daten abrufen",
|
||||
"recall_information_from": "Information abrufen von ...",
|
||||
"recontact_options": "Optionen zur erneuten Kontaktaufnahme",
|
||||
"redirect_thank_you_card": "Weiterleitung anlegen",
|
||||
"redirect_to_url": "Zu URL weiterleiten",
|
||||
@@ -1602,6 +1616,7 @@
|
||||
"trigger_survey_when_one_of_the_actions_is_fired": "Umfrage auslösen, wenn eine der Aktionen ausgeführt wird...",
|
||||
"try_lollipop_or_mountain": "Versuch 'Lolli' oder 'Berge'...",
|
||||
"type_field_id": "Feld-ID eingeben",
|
||||
"underline": "Unterstreichen",
|
||||
"unlock_targeting_description": "Spezifische Nutzergruppen basierend auf Attributen oder Geräteinformationen ansprechen",
|
||||
"unlock_targeting_title": "Targeting mit einem höheren Plan freischalten",
|
||||
"unsaved_changes_warning": "Du hast ungespeicherte Änderungen in deiner Umfrage. Möchtest Du sie speichern, bevor Du gehst?",
|
||||
@@ -1618,6 +1633,9 @@
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variable \"{variableName}\" wird in der \"{quotaName}\" Quote verwendet",
|
||||
"variable_name_is_already_taken_please_choose_another": "Variablenname ist bereits vergeben, bitte wähle einen anderen.",
|
||||
"variable_name_must_start_with_a_letter": "Variablenname muss mit einem Buchstaben beginnen.",
|
||||
"variable_used_in_recall": "Variable \"{variable}\" wird in Frage {questionIndex} abgerufen.",
|
||||
"variable_used_in_recall_ending_card": "Variable \"{variable}\" wird in der Abschlusskarte abgerufen.",
|
||||
"variable_used_in_recall_welcome": "Variable \"{variable}\" wird in der Willkommenskarte abgerufen.",
|
||||
"verify_email_before_submission": "E-Mail vor dem Absenden überprüfen",
|
||||
"verify_email_before_submission_description": "Lass nur Leute mit einer echten E-Mail antworten.",
|
||||
"wait": "Warte",
|
||||
|
||||
@@ -279,6 +279,7 @@
|
||||
"no_result_found": "No result found",
|
||||
"no_results": "No results",
|
||||
"no_surveys_found": "No surveys found.",
|
||||
"none_of_the_above": "None of the above",
|
||||
"not_authenticated": "You are not authenticated to perform this action.",
|
||||
"not_authorized": "Not authorized",
|
||||
"not_connected": "Not Connected",
|
||||
@@ -1203,12 +1204,12 @@
|
||||
"add_description": "Add description",
|
||||
"add_ending": "Add ending",
|
||||
"add_ending_below": "Add ending below",
|
||||
"add_fallback": "Add",
|
||||
"add_fallback_placeholder": "Add a placeholder to show if there is no value to recall.",
|
||||
"add_hidden_field_id": "Add hidden field ID",
|
||||
"add_highlight_border": "Add highlight border",
|
||||
"add_highlight_border_description": "Add an outer border to your survey card.",
|
||||
"add_logic": "Add logic",
|
||||
"add_none_of_the_above": "Add \"None of the Above\"",
|
||||
"add_option": "Add option",
|
||||
"add_other": "Add \"Other\"",
|
||||
"add_photo_or_video": "Add photo or video",
|
||||
@@ -1241,6 +1242,7 @@
|
||||
"automatically_mark_the_survey_as_complete_after": "Automatically mark the survey as complete after",
|
||||
"back_button_label": "\"Back\" Button Label",
|
||||
"background_styling": "Background Styling",
|
||||
"bold": "Bold",
|
||||
"brand_color": "Brand color",
|
||||
"brightness": "Brightness",
|
||||
"button_label": "Button Label",
|
||||
@@ -1313,7 +1315,6 @@
|
||||
"days_before_showing_this_survey_again": "days before showing this survey again.",
|
||||
"decide_how_often_people_can_answer_this_survey": "Decide how often people can answer this survey.",
|
||||
"delete_choice": "Delete choice",
|
||||
"description": "Description",
|
||||
"disable_the_visibility_of_survey_progress": "Disable the visibility of survey progress.",
|
||||
"display_an_estimate_of_completion_time_for_survey": "Display an estimate of completion time for survey",
|
||||
"display_number_of_responses_for_survey": "Display number of responses for survey",
|
||||
@@ -1324,6 +1325,7 @@
|
||||
"does_not_include_all_of": "Does not include all of",
|
||||
"does_not_include_one_of": "Does not include one of",
|
||||
"does_not_start_with": "Does not start with",
|
||||
"edit_link": "Edit link",
|
||||
"edit_recall": "Edit Recall",
|
||||
"edit_translations": "Edit {lang} translations",
|
||||
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Enable participants to switch the survey language at any point during the survey.",
|
||||
@@ -1334,13 +1336,14 @@
|
||||
"ending_card_used_in_logic": "This ending card is used in logic of question {questionIndex}.",
|
||||
"ending_used_in_quota": "This ending is being used in \"{quotaName}\" quota",
|
||||
"ends_with": "Ends with",
|
||||
"enter_fallback_value": "Enter fallback value",
|
||||
"equals": "Equals",
|
||||
"equals_one_of": "Equals one of",
|
||||
"error_publishing_survey": "An error occured while publishing the survey.",
|
||||
"error_saving_changes": "Error saving changes",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "Even after they submitted a response (e.g. Feedback Box)",
|
||||
"everyone": "Everyone",
|
||||
"fallback_for": "Fallback for ",
|
||||
"external_urls_paywall_tooltip": "Please upgrade to customize external URL. Phishing prevention.",
|
||||
"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",
|
||||
@@ -1397,6 +1400,9 @@
|
||||
"four_points": "4 points",
|
||||
"heading": "Heading",
|
||||
"hidden_field_added_successfully": "Hidden field added successfully",
|
||||
"hidden_field_used_in_recall": "Hidden field \"{hiddenField}\" is being recalled in question {questionIndex}.",
|
||||
"hidden_field_used_in_recall_ending_card": "Hidden field \"{hiddenField}\" is being recalled in Ending Card",
|
||||
"hidden_field_used_in_recall_welcome": "Hidden field \"{hiddenField}\" is being recalled in Welcome card.",
|
||||
"hide_advanced_settings": "Hide advanced settings",
|
||||
"hide_back_button": "Hide 'Back' button",
|
||||
"hide_back_button_description": "Do not display the back button in the survey",
|
||||
@@ -1415,6 +1421,7 @@
|
||||
"inner_text": "Inner Text",
|
||||
"input_border_color": "Input border color",
|
||||
"input_color": "Input color",
|
||||
"insert_link": "Insert link",
|
||||
"invalid_targeting": "Invalid targeting: Please check your audience filters",
|
||||
"invalid_video_url_warning": "Please enter a valid YouTube, Vimeo, or Loom URL. We currently do not support other video hosting providers.",
|
||||
"invalid_youtube_url": "Invalid YouTube URL",
|
||||
@@ -1432,6 +1439,7 @@
|
||||
"is_set": "Is set",
|
||||
"is_skipped": "Is skipped",
|
||||
"is_submitted": "Is submitted",
|
||||
"italic": "Italic",
|
||||
"jump_to_question": "Jump to question",
|
||||
"keep_current_order": "Keep current order",
|
||||
"keep_showing_while_conditions_match": "Keep showing while conditions match",
|
||||
@@ -1458,6 +1466,7 @@
|
||||
"no_images_found_for": "No images found for ''{query}\"",
|
||||
"no_languages_found_add_first_one_to_get_started": "No languages found. Add the first one to get started.",
|
||||
"no_option_found": "No option found",
|
||||
"no_recall_items_found": "No recall items found ",
|
||||
"no_variables_yet_add_first_one_below": "No variables yet. Add the first one below.",
|
||||
"number": "Number",
|
||||
"once_set_the_default_language_for_this_survey_can_only_be_changed_by_disabling_the_multi_language_option_and_deleting_all_translations": "Once set, the default language for this survey can only be changed by disabling the multi-language option and deleting all translations.",
|
||||
@@ -1477,6 +1486,7 @@
|
||||
"pin_can_only_contain_numbers": "PIN can only contain numbers.",
|
||||
"pin_must_be_a_four_digit_number": "PIN must be a four digit number.",
|
||||
"please_enter_a_file_extension": "Please enter a file extension.",
|
||||
"please_enter_a_valid_url": "Please enter a valid URL (e.g., https://example.com)",
|
||||
"please_set_a_survey_trigger": "Please set a survey trigger",
|
||||
"please_specify": "Please specify",
|
||||
"prevent_double_submission": "Prevent double submission",
|
||||
@@ -1491,6 +1501,8 @@
|
||||
"question_id_updated": "Question ID updated",
|
||||
"question_used_in_logic": "This question is used in logic of question {questionIndex}.",
|
||||
"question_used_in_quota": "This question is being used in \"{quotaName}\" quota",
|
||||
"question_used_in_recall": "This question is being recalled in question {questionIndex}.",
|
||||
"question_used_in_recall_ending_card": "This question is being recalled in Ending Card",
|
||||
"quotas": {
|
||||
"add_quota": "Add quota",
|
||||
"change_quota_for_public_survey": "Change quota for public survey?",
|
||||
@@ -1525,6 +1537,8 @@
|
||||
"randomize_all": "Randomize all",
|
||||
"randomize_all_except_last": "Randomize all except last",
|
||||
"range": "Range",
|
||||
"recall_data": "Recall data",
|
||||
"recall_information_from": "Recall information from ...",
|
||||
"recontact_options": "Recontact Options",
|
||||
"redirect_thank_you_card": "Redirect thank you card",
|
||||
"redirect_to_url": "Redirect to Url",
|
||||
@@ -1602,6 +1616,7 @@
|
||||
"trigger_survey_when_one_of_the_actions_is_fired": "Trigger survey when one of the actions is fired...",
|
||||
"try_lollipop_or_mountain": "Try 'lollipop' or 'mountain'...",
|
||||
"type_field_id": "Type field id",
|
||||
"underline": "Underline",
|
||||
"unlock_targeting_description": "Target specific user groups based on attributes or device information",
|
||||
"unlock_targeting_title": "Unlock targeting with a higher plan",
|
||||
"unsaved_changes_warning": "You have unsaved changes in your survey. Would you like to save them before leaving?",
|
||||
@@ -1618,6 +1633,9 @@
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variable \"{variableName}\" is being used in \"{quotaName}\" quota",
|
||||
"variable_name_is_already_taken_please_choose_another": "Variable name is already taken, please choose another.",
|
||||
"variable_name_must_start_with_a_letter": "Variable name must start with a letter.",
|
||||
"variable_used_in_recall": "Variable \"{variable}\" is being recalled in question {questionIndex}.",
|
||||
"variable_used_in_recall_ending_card": "Variable {variable} is being recalled in Ending Card",
|
||||
"variable_used_in_recall_welcome": "Variable \"{variable}\" is being recalled in Welcome Card.",
|
||||
"verify_email_before_submission": "Verify email before submission",
|
||||
"verify_email_before_submission_description": "Only let people with a real email respond.",
|
||||
"wait": "Wait",
|
||||
|
||||
@@ -279,6 +279,7 @@
|
||||
"no_result_found": "Aucun résultat trouvé",
|
||||
"no_results": "Aucun résultat",
|
||||
"no_surveys_found": "Aucun sondage trouvé.",
|
||||
"none_of_the_above": "Aucun des éléments ci-dessus",
|
||||
"not_authenticated": "Vous n'êtes pas authentifié pour effectuer cette action.",
|
||||
"not_authorized": "Non autorisé",
|
||||
"not_connected": "Non connecté",
|
||||
@@ -1203,12 +1204,12 @@
|
||||
"add_description": "Ajouter une description",
|
||||
"add_ending": "Ajouter une fin",
|
||||
"add_ending_below": "Ajouter une fin ci-dessous",
|
||||
"add_fallback": "Ajouter",
|
||||
"add_fallback_placeholder": "Ajouter un espace réservé à afficher s'il n'y a pas de valeur à rappeler.",
|
||||
"add_hidden_field_id": "Ajouter un champ caché ID",
|
||||
"add_highlight_border": "Ajouter une bordure de surlignage",
|
||||
"add_highlight_border_description": "Ajoutez une bordure extérieure à votre carte d'enquête.",
|
||||
"add_logic": "Ajouter de la logique",
|
||||
"add_none_of_the_above": "Ajouter \"Aucun des éléments ci-dessus\"",
|
||||
"add_option": "Ajouter une option",
|
||||
"add_other": "Ajouter \"Autre",
|
||||
"add_photo_or_video": "Ajouter une photo ou une vidéo",
|
||||
@@ -1241,6 +1242,7 @@
|
||||
"automatically_mark_the_survey_as_complete_after": "Marquer automatiquement l'enquête comme terminée après",
|
||||
"back_button_label": "Label du bouton \"Retour''",
|
||||
"background_styling": "Style de fond",
|
||||
"bold": "Gras",
|
||||
"brand_color": "Couleur de marque",
|
||||
"brightness": "Luminosité",
|
||||
"button_label": "Label du bouton",
|
||||
@@ -1313,7 +1315,6 @@
|
||||
"days_before_showing_this_survey_again": "jours avant de montrer à nouveau cette enquête.",
|
||||
"decide_how_often_people_can_answer_this_survey": "Décidez à quelle fréquence les gens peuvent répondre à cette enquête.",
|
||||
"delete_choice": "Supprimer l'option",
|
||||
"description": "Description",
|
||||
"disable_the_visibility_of_survey_progress": "Désactiver la visibilité de la progression du sondage.",
|
||||
"display_an_estimate_of_completion_time_for_survey": "Afficher une estimation du temps de complétion pour l'enquête.",
|
||||
"display_number_of_responses_for_survey": "Afficher le nombre de réponses pour l'enquête",
|
||||
@@ -1324,6 +1325,7 @@
|
||||
"does_not_include_all_of": "n'inclut pas tout",
|
||||
"does_not_include_one_of": "n'inclut pas un de",
|
||||
"does_not_start_with": "Ne commence pas par",
|
||||
"edit_link": "Modifier le lien",
|
||||
"edit_recall": "Modifier le rappel",
|
||||
"edit_translations": "Modifier les traductions {lang}",
|
||||
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permettre aux participants de changer la langue de l'enquête à tout moment pendant celle-ci.",
|
||||
@@ -1334,13 +1336,14 @@
|
||||
"ending_card_used_in_logic": "Cette carte de fin est utilisée dans la logique de la question '{'questionIndex'}'.",
|
||||
"ending_used_in_quota": "Cette fin est utilisée dans le quota \"{quotaName}\"",
|
||||
"ends_with": "Se termine par",
|
||||
"enter_fallback_value": "Saisir une valeur de secours",
|
||||
"equals": "Égal",
|
||||
"equals_one_of": "Égal à l'un de",
|
||||
"error_publishing_survey": "Une erreur est survenue lors de la publication de l'enquête.",
|
||||
"error_saving_changes": "Erreur lors de l'enregistrement des modifications",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "Même après avoir soumis une réponse (par exemple, la boîte de feedback)",
|
||||
"everyone": "Tout le monde",
|
||||
"fallback_for": "Solution de repli pour ",
|
||||
"external_urls_paywall_tooltip": "Veuillez passer à la version supérieure pour personnaliser l'URL externe. Prévention contre l'hameçonnage.",
|
||||
"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}\"",
|
||||
@@ -1397,6 +1400,9 @@
|
||||
"four_points": "4 points",
|
||||
"heading": "En-tête",
|
||||
"hidden_field_added_successfully": "Champ caché ajouté avec succès",
|
||||
"hidden_field_used_in_recall": "Le champ caché \"{hiddenField}\" est rappelé dans la question {questionIndex}.",
|
||||
"hidden_field_used_in_recall_ending_card": "Le champ caché \"{hiddenField}\" est rappelé dans la carte de fin.",
|
||||
"hidden_field_used_in_recall_welcome": "Le champ caché \"{hiddenField}\" est rappelé dans la carte de bienvenue.",
|
||||
"hide_advanced_settings": "Cacher les paramètres avancés",
|
||||
"hide_back_button": "Masquer le bouton 'Retour'",
|
||||
"hide_back_button_description": "Ne pas afficher le bouton retour dans l'enquête",
|
||||
@@ -1415,6 +1421,7 @@
|
||||
"inner_text": "Texte interne",
|
||||
"input_border_color": "Couleur de bordure d'entrée",
|
||||
"input_color": "Couleur d'entrée",
|
||||
"insert_link": "Insérer un lien",
|
||||
"invalid_targeting": "Ciblage invalide : Veuillez vérifier vos filtres d'audience",
|
||||
"invalid_video_url_warning": "Merci d'entrer une URL YouTube, Vimeo ou Loom valide. Les autres plateformes vidéo ne sont pas encore supportées.",
|
||||
"invalid_youtube_url": "URL YouTube invalide",
|
||||
@@ -1432,6 +1439,7 @@
|
||||
"is_set": "Est défini",
|
||||
"is_skipped": "Est ignoré",
|
||||
"is_submitted": "Est soumis",
|
||||
"italic": "Italique",
|
||||
"jump_to_question": "Passer à la question",
|
||||
"keep_current_order": "Conserver la commande actuelle",
|
||||
"keep_showing_while_conditions_match": "Continuer à afficher tant que les conditions correspondent",
|
||||
@@ -1458,6 +1466,7 @@
|
||||
"no_images_found_for": "Aucune image trouvée pour ''{query}\"",
|
||||
"no_languages_found_add_first_one_to_get_started": "Aucune langue trouvée. Ajoutez la première pour commencer.",
|
||||
"no_option_found": "Aucune option trouvée",
|
||||
"no_recall_items_found": "Aucun élément de rappel trouvé",
|
||||
"no_variables_yet_add_first_one_below": "Aucune variable pour le moment. Ajoutez la première ci-dessous.",
|
||||
"number": "Numéro",
|
||||
"once_set_the_default_language_for_this_survey_can_only_be_changed_by_disabling_the_multi_language_option_and_deleting_all_translations": "Une fois défini, la langue par défaut de cette enquête ne peut être changée qu'en désactivant l'option multilingue et en supprimant toutes les traductions.",
|
||||
@@ -1477,6 +1486,7 @@
|
||||
"pin_can_only_contain_numbers": "Le code PIN ne peut contenir que des chiffres.",
|
||||
"pin_must_be_a_four_digit_number": "Le code PIN doit être un numéro à quatre chiffres.",
|
||||
"please_enter_a_file_extension": "Veuillez entrer une extension de fichier.",
|
||||
"please_enter_a_valid_url": "Veuillez entrer une URL valide (par exemple, https://example.com)",
|
||||
"please_set_a_survey_trigger": "Veuillez définir un déclencheur d'enquête.",
|
||||
"please_specify": "Veuillez préciser",
|
||||
"prevent_double_submission": "Empêcher la double soumission",
|
||||
@@ -1491,6 +1501,8 @@
|
||||
"question_id_updated": "ID de la question mis à jour",
|
||||
"question_used_in_logic": "Cette question est utilisée dans la logique de la question '{'questionIndex'}'.",
|
||||
"question_used_in_quota": "Cette question est utilisée dans le quota \"{quotaName}\"",
|
||||
"question_used_in_recall": "Cette question est rappelée dans la question {questionIndex}.",
|
||||
"question_used_in_recall_ending_card": "Cette question est rappelée dans la carte de fin.",
|
||||
"quotas": {
|
||||
"add_quota": "Ajouter un quota",
|
||||
"change_quota_for_public_survey": "Changer le quota pour le sondage public ?",
|
||||
@@ -1525,6 +1537,8 @@
|
||||
"randomize_all": "Randomiser tout",
|
||||
"randomize_all_except_last": "Randomiser tout sauf le dernier",
|
||||
"range": "Plage",
|
||||
"recall_data": "Rappel des données",
|
||||
"recall_information_from": "Rappeler les informations de ...",
|
||||
"recontact_options": "Options de recontact",
|
||||
"redirect_thank_you_card": "Carte de remerciement de redirection",
|
||||
"redirect_to_url": "Rediriger vers l'URL",
|
||||
@@ -1602,6 +1616,7 @@
|
||||
"trigger_survey_when_one_of_the_actions_is_fired": "Déclencher l'enquête lorsqu'une des actions est déclenchée...",
|
||||
"try_lollipop_or_mountain": "Essayez 'sucette' ou 'montagne'...",
|
||||
"type_field_id": "Identifiant de champ de type",
|
||||
"underline": "Souligner",
|
||||
"unlock_targeting_description": "Cibler des groupes d'utilisateurs spécifiques en fonction des attributs ou des informations sur l'appareil",
|
||||
"unlock_targeting_title": "Débloquez le ciblage avec un plan supérieur.",
|
||||
"unsaved_changes_warning": "Vous avez des modifications non enregistrées dans votre enquête. Souhaitez-vous les enregistrer avant de partir ?",
|
||||
@@ -1618,6 +1633,9 @@
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "La variable \"{variableName}\" est utilisée dans le quota \"{quotaName}\"",
|
||||
"variable_name_is_already_taken_please_choose_another": "Le nom de la variable est déjà pris, veuillez en choisir un autre.",
|
||||
"variable_name_must_start_with_a_letter": "Le nom de la variable doit commencer par une lettre.",
|
||||
"variable_used_in_recall": "La variable \"{variable}\" est rappelée dans la question {questionIndex}.",
|
||||
"variable_used_in_recall_ending_card": "La variable {variable} est rappelée dans la carte de fin.",
|
||||
"variable_used_in_recall_welcome": "La variable \"{variable}\" est rappelée dans la carte de bienvenue.",
|
||||
"verify_email_before_submission": "Vérifiez l'email avant la soumission",
|
||||
"verify_email_before_submission_description": "Ne laissez répondre que les personnes ayant une véritable adresse e-mail.",
|
||||
"wait": "Attendre",
|
||||
|
||||
@@ -279,6 +279,7 @@
|
||||
"no_result_found": "結果が見つかりません",
|
||||
"no_results": "結果なし",
|
||||
"no_surveys_found": "フォームが見つかりません。",
|
||||
"none_of_the_above": "いずれも該当しません",
|
||||
"not_authenticated": "このアクションを実行するための認証がされていません。",
|
||||
"not_authorized": "権限がありません",
|
||||
"not_connected": "未接続",
|
||||
@@ -1203,12 +1204,12 @@
|
||||
"add_description": "説明を追加",
|
||||
"add_ending": "終了を追加",
|
||||
"add_ending_below": "以下に終了を追加",
|
||||
"add_fallback": "追加",
|
||||
"add_fallback_placeholder": "質問がスキップされた場合に表示するプレースホルダーを追加:",
|
||||
"add_hidden_field_id": "非表示フィールドIDを追加",
|
||||
"add_highlight_border": "ハイライトボーダーを追加",
|
||||
"add_highlight_border_description": "フォームカードに外側のボーダーを追加します。",
|
||||
"add_logic": "ロジックを追加",
|
||||
"add_none_of_the_above": "\"いずれも該当しません\" を追加",
|
||||
"add_option": "オプションを追加",
|
||||
"add_other": "「その他」を追加",
|
||||
"add_photo_or_video": "写真または動画を追加",
|
||||
@@ -1241,6 +1242,7 @@
|
||||
"automatically_mark_the_survey_as_complete_after": "フォームを自動的に完了としてマークする",
|
||||
"back_button_label": "「戻る」ボタンのラベル",
|
||||
"background_styling": "背景のスタイル",
|
||||
"bold": "太字",
|
||||
"brand_color": "ブランドカラー",
|
||||
"brightness": "明るさ",
|
||||
"button_label": "ボタンのラベル",
|
||||
@@ -1313,7 +1315,6 @@
|
||||
"days_before_showing_this_survey_again": "日後にこのフォームを再度表示します。",
|
||||
"decide_how_often_people_can_answer_this_survey": "このフォームに人々が何回回答できるかを決定します。",
|
||||
"delete_choice": "選択肢を削除",
|
||||
"description": "説明",
|
||||
"disable_the_visibility_of_survey_progress": "フォームの進捗状況の表示を無効にする。",
|
||||
"display_an_estimate_of_completion_time_for_survey": "フォームの完了時間の目安を表示",
|
||||
"display_number_of_responses_for_survey": "フォームの回答数を表示",
|
||||
@@ -1324,6 +1325,7 @@
|
||||
"does_not_include_all_of": "のすべてを含まない",
|
||||
"does_not_include_one_of": "のいずれも含まない",
|
||||
"does_not_start_with": "で始まらない",
|
||||
"edit_link": "編集 リンク",
|
||||
"edit_recall": "リコールを編集",
|
||||
"edit_translations": "{lang} 翻訳を編集",
|
||||
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "回答者がフォームの途中でいつでも言語を切り替えられるようにします。",
|
||||
@@ -1334,13 +1336,14 @@
|
||||
"ending_card_used_in_logic": "この終了カードは質問 {questionIndex} のロジックで使用されています。",
|
||||
"ending_used_in_quota": "この 終了 は \"{quotaName}\" クォータ で使用されています",
|
||||
"ends_with": "で終わる",
|
||||
"enter_fallback_value": "フォールバック値を入力",
|
||||
"equals": "と等しい",
|
||||
"equals_one_of": "のいずれかと等しい",
|
||||
"error_publishing_survey": "フォームの公開中にエラーが発生しました。",
|
||||
"error_saving_changes": "変更の保存中にエラーが発生しました",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "回答を送信した後でも(例:フィードバックボックス)",
|
||||
"everyone": "全員",
|
||||
"fallback_for": "のフォールバック",
|
||||
"external_urls_paywall_tooltip": "外部 URL をカスタマイズするにはアップグレードしてください 。 フィッシング防止 。",
|
||||
"fallback_missing": "フォールバックがありません",
|
||||
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} は質問 {questionIndex} のロジックで使用されています。まず、ロジックから削除してください。",
|
||||
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "隠しフィールド \"{fieldId}\" は \"{quotaName}\" クォータ で使用されています",
|
||||
@@ -1397,6 +1400,9 @@
|
||||
"four_points": "4点",
|
||||
"heading": "見出し",
|
||||
"hidden_field_added_successfully": "非表示フィールドを正常に追加しました",
|
||||
"hidden_field_used_in_recall": "隠し フィールド \"{hiddenField}\" が 質問 {questionIndex} で 呼び出され て います 。",
|
||||
"hidden_field_used_in_recall_ending_card": "隠し フィールド \"{hiddenField}\" が エンディング カード で 呼び出され て います。",
|
||||
"hidden_field_used_in_recall_welcome": "隠し フィールド \"{hiddenField}\" が ウェルカム カード で 呼び出され て います。",
|
||||
"hide_advanced_settings": "詳細設定を非表示",
|
||||
"hide_back_button": "「戻る」ボタンを非表示",
|
||||
"hide_back_button_description": "フォームに「戻る」ボタンを表示しない",
|
||||
@@ -1415,6 +1421,7 @@
|
||||
"inner_text": "内部テキスト",
|
||||
"input_border_color": "入力の枠線の色",
|
||||
"input_color": "入力の色",
|
||||
"insert_link": "リンク を 挿入",
|
||||
"invalid_targeting": "無効なターゲティング: オーディエンスフィルターを確認してください",
|
||||
"invalid_video_url_warning": "有効なYouTube、Vimeo、またはLoomのURLを入力してください。現在、他の動画ホスティングプロバイダーはサポートしていません。",
|
||||
"invalid_youtube_url": "無効なYouTube URL",
|
||||
@@ -1432,6 +1439,7 @@
|
||||
"is_set": "設定されている",
|
||||
"is_skipped": "スキップ済み",
|
||||
"is_submitted": "送信済み",
|
||||
"italic": "イタリック",
|
||||
"jump_to_question": "質問にジャンプ",
|
||||
"keep_current_order": "現在の順序を維持",
|
||||
"keep_showing_while_conditions_match": "条件が一致する間、表示し続ける",
|
||||
@@ -1458,6 +1466,7 @@
|
||||
"no_images_found_for": "''{query}'' の画像が見つかりません",
|
||||
"no_languages_found_add_first_one_to_get_started": "言語が見つかりません。始めるには、最初のものを追加してください。",
|
||||
"no_option_found": "オプションが見つかりません",
|
||||
"no_recall_items_found": "リコールアイテムが見つかりません ",
|
||||
"no_variables_yet_add_first_one_below": "まだ変数がありません。以下で最初のものを追加してください。",
|
||||
"number": "数値",
|
||||
"once_set_the_default_language_for_this_survey_can_only_be_changed_by_disabling_the_multi_language_option_and_deleting_all_translations": "一度設定すると、このフォームのデフォルト言語は、多言語オプションを無効にしてすべての翻訳を削除することによってのみ変更できます。",
|
||||
@@ -1477,6 +1486,7 @@
|
||||
"pin_can_only_contain_numbers": "PINは数字のみでなければなりません。",
|
||||
"pin_must_be_a_four_digit_number": "PINは4桁の数字でなければなりません。",
|
||||
"please_enter_a_file_extension": "ファイル拡張子を入力してください。",
|
||||
"please_enter_a_valid_url": "有効な URL を入力してください (例:https://example.com)",
|
||||
"please_set_a_survey_trigger": "フォームのトリガーを設定してください",
|
||||
"please_specify": "具体的に指定してください",
|
||||
"prevent_double_submission": "二重送信を防ぐ",
|
||||
@@ -1491,6 +1501,8 @@
|
||||
"question_id_updated": "質問IDを更新しました",
|
||||
"question_used_in_logic": "この質問は質問 {questionIndex} のロジックで使用されています。",
|
||||
"question_used_in_quota": "この 質問 は \"{quotaName}\" の クオータ に使用されています",
|
||||
"question_used_in_recall": "この 質問 は 質問 {questionIndex} で 呼び出され て います 。",
|
||||
"question_used_in_recall_ending_card": "この 質問 は エンディング カード で 呼び出され て います。",
|
||||
"quotas": {
|
||||
"add_quota": "クォータを追加",
|
||||
"change_quota_for_public_survey": "パブリック フォームのクォータを変更しますか?",
|
||||
@@ -1525,6 +1537,8 @@
|
||||
"randomize_all": "すべてをランダム化",
|
||||
"randomize_all_except_last": "最後を除くすべてをランダム化",
|
||||
"range": "範囲",
|
||||
"recall_data": "データを呼び出す",
|
||||
"recall_information_from": "... からの情報を呼び戻す",
|
||||
"recontact_options": "再接触オプション",
|
||||
"redirect_thank_you_card": "サンクスクカードをリダイレクト",
|
||||
"redirect_to_url": "URLにリダイレクト",
|
||||
@@ -1602,6 +1616,7 @@
|
||||
"trigger_survey_when_one_of_the_actions_is_fired": "以下のアクションのいずれかが発火したときにフォームをトリガーします...",
|
||||
"try_lollipop_or_mountain": "「lollipop」や「mountain」を試してみてください...",
|
||||
"type_field_id": "フィールドIDを入力",
|
||||
"underline": "下線",
|
||||
"unlock_targeting_description": "属性またはデバイス情報に基づいて、特定のユーザーグループをターゲットにします",
|
||||
"unlock_targeting_title": "上位プランでターゲティングをアンロック",
|
||||
"unsaved_changes_warning": "フォームに未保存の変更があります。離れる前に保存しますか?",
|
||||
@@ -1618,6 +1633,9 @@
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "変数 \"{variableName}\" は \"{quotaName}\" クォータ で使用されています",
|
||||
"variable_name_is_already_taken_please_choose_another": "変数名はすでに使用されています。別の名前を選択してください。",
|
||||
"variable_name_must_start_with_a_letter": "変数名はアルファベットで始まらなければなりません。",
|
||||
"variable_used_in_recall": "変数 \"{variable}\" が 質問 {questionIndex} で 呼び出され て います 。",
|
||||
"variable_used_in_recall_ending_card": "変数 {variable} が エンディング カード で 呼び出され て います。",
|
||||
"variable_used_in_recall_welcome": "変数 \"{variable}\" が ウェルカム カード で 呼び出され て います。",
|
||||
"verify_email_before_submission": "送信前にメールアドレスを認証",
|
||||
"verify_email_before_submission_description": "有効なメールアドレスを持つ人のみが回答できるようにする",
|
||||
"wait": "待つ",
|
||||
|
||||
@@ -279,6 +279,7 @@
|
||||
"no_result_found": "Nenhum resultado encontrado",
|
||||
"no_results": "Nenhum resultado",
|
||||
"no_surveys_found": "Não foram encontradas pesquisas.",
|
||||
"none_of_the_above": "Nenhuma das opções acima",
|
||||
"not_authenticated": "Você não está autenticado para realizar essa ação.",
|
||||
"not_authorized": "Não autorizado",
|
||||
"not_connected": "Desconectado",
|
||||
@@ -1203,12 +1204,12 @@
|
||||
"add_description": "Adicionar Descrição",
|
||||
"add_ending": "Adicionar final",
|
||||
"add_ending_below": "Adicione o final abaixo",
|
||||
"add_fallback": "Adicionar",
|
||||
"add_fallback_placeholder": "Adicionar um texto padrão para mostrar se a pergunta for ignorada:",
|
||||
"add_hidden_field_id": "Adicionar campo oculto ID",
|
||||
"add_highlight_border": "Adicionar borda de destaque",
|
||||
"add_highlight_border_description": "Adicione uma borda externa ao seu cartão de pesquisa.",
|
||||
"add_logic": "Adicionar lógica",
|
||||
"add_none_of_the_above": "Adicionar \"Nenhuma das opções acima\"",
|
||||
"add_option": "Adicionar opção",
|
||||
"add_other": "Adicionar \"Outro",
|
||||
"add_photo_or_video": "Adicionar foto ou video",
|
||||
@@ -1241,6 +1242,7 @@
|
||||
"automatically_mark_the_survey_as_complete_after": "Marcar automaticamente a pesquisa como concluída após",
|
||||
"back_button_label": "Voltar",
|
||||
"background_styling": "Estilo de Fundo",
|
||||
"bold": "Negrito",
|
||||
"brand_color": "Cor da marca",
|
||||
"brightness": "brilho",
|
||||
"button_label": "Rótulo do Botão",
|
||||
@@ -1313,7 +1315,6 @@
|
||||
"days_before_showing_this_survey_again": "dias antes de mostrar essa pesquisa de novo.",
|
||||
"decide_how_often_people_can_answer_this_survey": "Decida com que frequência as pessoas podem responder a essa pesquisa.",
|
||||
"delete_choice": "Deletar opção",
|
||||
"description": "Descrição",
|
||||
"disable_the_visibility_of_survey_progress": "Desativar a visibilidade do progresso da pesquisa.",
|
||||
"display_an_estimate_of_completion_time_for_survey": "Mostrar uma estimativa de tempo de conclusão da pesquisa",
|
||||
"display_number_of_responses_for_survey": "Mostrar número de respostas da pesquisa",
|
||||
@@ -1324,6 +1325,7 @@
|
||||
"does_not_include_all_of": "Não inclui todos de",
|
||||
"does_not_include_one_of": "Não inclui um de",
|
||||
"does_not_start_with": "Não começa com",
|
||||
"edit_link": "Editar link",
|
||||
"edit_recall": "Editar Lembrete",
|
||||
"edit_translations": "Editar traduções de {lang}",
|
||||
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permitir que os participantes mudem o idioma da pesquisa a qualquer momento durante a pesquisa.",
|
||||
@@ -1334,13 +1336,14 @@
|
||||
"ending_card_used_in_logic": "Esse cartão de encerramento é usado na lógica da pergunta {questionIndex}.",
|
||||
"ending_used_in_quota": "Este final está sendo usado na cota \"{quotaName}\"",
|
||||
"ends_with": "Termina com",
|
||||
"enter_fallback_value": "Insira o valor de fallback",
|
||||
"equals": "Igual",
|
||||
"equals_one_of": "É igual a um de",
|
||||
"error_publishing_survey": "Ocorreu um erro ao publicar a pesquisa.",
|
||||
"error_saving_changes": "Erro ao salvar alterações",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "Mesmo depois de eles enviarem uma resposta (por exemplo, Caixa de Feedback)",
|
||||
"everyone": "Todo mundo",
|
||||
"fallback_for": "Alternativa para",
|
||||
"external_urls_paywall_tooltip": "Por favor, faça upgrade para personalizar o URL externo. Prevenção de phishing.",
|
||||
"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}\"",
|
||||
@@ -1397,6 +1400,9 @@
|
||||
"four_points": "4 pontos",
|
||||
"heading": "Título",
|
||||
"hidden_field_added_successfully": "Campo oculto adicionado com sucesso",
|
||||
"hidden_field_used_in_recall": "Campo oculto \"{hiddenField}\" está sendo recordado na pergunta {questionIndex}.",
|
||||
"hidden_field_used_in_recall_ending_card": "Campo oculto \"{hiddenField}\" está sendo recordado no card de Encerramento.",
|
||||
"hidden_field_used_in_recall_welcome": "Campo oculto \"{hiddenField}\" está sendo recordado no card de Boas-Vindas.",
|
||||
"hide_advanced_settings": "Ocultar configurações avançadas",
|
||||
"hide_back_button": "Ocultar botão 'Voltar'",
|
||||
"hide_back_button_description": "Não exibir o botão de voltar na pesquisa",
|
||||
@@ -1415,6 +1421,7 @@
|
||||
"inner_text": "Texto Interno",
|
||||
"input_border_color": "Cor da borda de entrada",
|
||||
"input_color": "Cor de entrada",
|
||||
"insert_link": "Inserir link",
|
||||
"invalid_targeting": "Segmentação inválida: Por favor, verifique os filtros do seu público",
|
||||
"invalid_video_url_warning": "Por favor, insira uma URL válida do YouTube, Vimeo ou Loom. No momento, não suportamos outros provedores de vídeo.",
|
||||
"invalid_youtube_url": "URL do YouTube inválida",
|
||||
@@ -1432,6 +1439,7 @@
|
||||
"is_set": "Está definido",
|
||||
"is_skipped": "é pulado",
|
||||
"is_submitted": "é submetido",
|
||||
"italic": "Itálico",
|
||||
"jump_to_question": "Pular para a pergunta",
|
||||
"keep_current_order": "Manter pedido atual",
|
||||
"keep_showing_while_conditions_match": "Continue mostrando enquanto as condições corresponderem",
|
||||
@@ -1458,6 +1466,7 @@
|
||||
"no_images_found_for": "Nenhuma imagem encontrada para ''{query}\"",
|
||||
"no_languages_found_add_first_one_to_get_started": "Nenhum idioma encontrado. Adicione o primeiro para começar.",
|
||||
"no_option_found": "Nenhuma opção encontrada",
|
||||
"no_recall_items_found": "Nenhum item de recordação encontrado",
|
||||
"no_variables_yet_add_first_one_below": "Ainda não há variáveis. Adicione a primeira abaixo.",
|
||||
"number": "Número",
|
||||
"once_set_the_default_language_for_this_survey_can_only_be_changed_by_disabling_the_multi_language_option_and_deleting_all_translations": "Depois de definido, o idioma padrão desta pesquisa só pode ser alterado desativando a opção de vários idiomas e excluindo todas as traduções.",
|
||||
@@ -1477,6 +1486,7 @@
|
||||
"pin_can_only_contain_numbers": "O PIN só pode conter números.",
|
||||
"pin_must_be_a_four_digit_number": "O PIN deve ser um número de quatro dígitos.",
|
||||
"please_enter_a_file_extension": "Por favor, insira uma extensão de arquivo.",
|
||||
"please_enter_a_valid_url": "Por favor, insira uma URL válida (ex.: https://example.com)",
|
||||
"please_set_a_survey_trigger": "Por favor, configure um gatilho para a pesquisa",
|
||||
"please_specify": "Por favor, especifique",
|
||||
"prevent_double_submission": "Evitar envio duplicado",
|
||||
@@ -1491,6 +1501,8 @@
|
||||
"question_id_updated": "ID da pergunta atualizado",
|
||||
"question_used_in_logic": "Essa pergunta é usada na lógica da pergunta {questionIndex}.",
|
||||
"question_used_in_quota": "Esta questão está sendo usada na cota \"{quotaName}\"",
|
||||
"question_used_in_recall": "Esta pergunta está sendo recordada na pergunta {questionIndex}.",
|
||||
"question_used_in_recall_ending_card": "Esta pergunta está sendo recordada no card de Encerramento",
|
||||
"quotas": {
|
||||
"add_quota": "Adicionar cota",
|
||||
"change_quota_for_public_survey": "Alterar cota para pesquisa pública?",
|
||||
@@ -1525,6 +1537,8 @@
|
||||
"randomize_all": "Randomizar tudo",
|
||||
"randomize_all_except_last": "Randomizar tudo, exceto o último",
|
||||
"range": "alcance",
|
||||
"recall_data": "Lembrar dados",
|
||||
"recall_information_from": "Recuperar informações de ...",
|
||||
"recontact_options": "Opções de Recontato",
|
||||
"redirect_thank_you_card": "Redirecionar cartão de agradecimento",
|
||||
"redirect_to_url": "Redirecionar para URL",
|
||||
@@ -1602,6 +1616,7 @@
|
||||
"trigger_survey_when_one_of_the_actions_is_fired": "Disparar pesquisa quando uma das ações for executada...",
|
||||
"try_lollipop_or_mountain": "Tenta 'pirulito' ou 'montanha'...",
|
||||
"type_field_id": "Digite o id do campo",
|
||||
"underline": "Sublinhar",
|
||||
"unlock_targeting_description": "Direcione grupos específicos de usuários com base em atributos ou informações do dispositivo",
|
||||
"unlock_targeting_title": "Desbloqueie o direcionamento com um plano superior",
|
||||
"unsaved_changes_warning": "Você tem alterações não salvas na sua pesquisa. Quer salvar antes de sair?",
|
||||
@@ -1618,6 +1633,9 @@
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variável \"{variableName}\" está sendo usada na cota \"{quotaName}\"",
|
||||
"variable_name_is_already_taken_please_choose_another": "O nome da variável já está em uso, por favor escolha outro.",
|
||||
"variable_name_must_start_with_a_letter": "O nome da variável deve começar com uma letra.",
|
||||
"variable_used_in_recall": "Variável \"{variable}\" está sendo recordada na pergunta {questionIndex}.",
|
||||
"variable_used_in_recall_ending_card": "Variável {variable} está sendo recordada no card de Encerramento",
|
||||
"variable_used_in_recall_welcome": "Variável \"{variable}\" está sendo recordada no Card de Boas-Vindas.",
|
||||
"verify_email_before_submission": "Verifique o e-mail antes de enviar",
|
||||
"verify_email_before_submission_description": "Deixe só quem tem um email real responder.",
|
||||
"wait": "Espera",
|
||||
|
||||
@@ -279,6 +279,7 @@
|
||||
"no_result_found": "Nenhum resultado encontrado",
|
||||
"no_results": "Nenhum resultado",
|
||||
"no_surveys_found": "Nenhum inquérito encontrado.",
|
||||
"none_of_the_above": "Nenhuma das opções acima",
|
||||
"not_authenticated": "Não está autenticado para realizar esta ação.",
|
||||
"not_authorized": "Não autorizado",
|
||||
"not_connected": "Não Conectado",
|
||||
@@ -1203,12 +1204,12 @@
|
||||
"add_description": "Adicionar descrição",
|
||||
"add_ending": "Adicionar encerramento",
|
||||
"add_ending_below": "Adicionar encerramento abaixo",
|
||||
"add_fallback": "Adicionar",
|
||||
"add_fallback_placeholder": "Adicionar um espaço reservado para mostrar se não houver valor para recordar.",
|
||||
"add_hidden_field_id": "Adicionar ID do campo oculto",
|
||||
"add_highlight_border": "Adicionar borda de destaque",
|
||||
"add_highlight_border_description": "Adicione uma borda externa ao seu cartão de inquérito.",
|
||||
"add_logic": "Adicionar lógica",
|
||||
"add_none_of_the_above": "Adicionar \"Nenhuma das Opções Acima\"",
|
||||
"add_option": "Adicionar opção",
|
||||
"add_other": "Adicionar \"Outro\"",
|
||||
"add_photo_or_video": "Adicionar foto ou vídeo",
|
||||
@@ -1241,6 +1242,7 @@
|
||||
"automatically_mark_the_survey_as_complete_after": "Marcar automaticamente o inquérito como concluído após",
|
||||
"back_button_label": "Rótulo do botão \"Voltar\"",
|
||||
"background_styling": "Estilo de Fundo",
|
||||
"bold": "Negrito",
|
||||
"brand_color": "Cor da marca",
|
||||
"brightness": "Brilho",
|
||||
"button_label": "Rótulo do botão",
|
||||
@@ -1313,7 +1315,6 @@
|
||||
"days_before_showing_this_survey_again": "dias antes de mostrar este inquérito novamente.",
|
||||
"decide_how_often_people_can_answer_this_survey": "Decida com que frequência as pessoas podem responder a este inquérito.",
|
||||
"delete_choice": "Eliminar escolha",
|
||||
"description": "Descrição",
|
||||
"disable_the_visibility_of_survey_progress": "Desativar a visibilidade do progresso da pesquisa.",
|
||||
"display_an_estimate_of_completion_time_for_survey": "Mostrar uma estimativa do tempo de conclusão do inquérito",
|
||||
"display_number_of_responses_for_survey": "Mostrar número de respostas do inquérito",
|
||||
@@ -1324,6 +1325,7 @@
|
||||
"does_not_include_all_of": "Não inclui todos de",
|
||||
"does_not_include_one_of": "Não inclui um de",
|
||||
"does_not_start_with": "Não começa com",
|
||||
"edit_link": "Editar link",
|
||||
"edit_recall": "Editar Lembrete",
|
||||
"edit_translations": "Editar traduções {lang}",
|
||||
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permitir aos participantes mudar a língua do inquérito a qualquer momento durante o inquérito.",
|
||||
@@ -1334,13 +1336,14 @@
|
||||
"ending_card_used_in_logic": "Este cartão final é usado na lógica da pergunta {questionIndex}.",
|
||||
"ending_used_in_quota": "Este final está a ser usado na quota \"{quotaName}\"",
|
||||
"ends_with": "Termina com",
|
||||
"enter_fallback_value": "Inserir valor de substituição",
|
||||
"equals": "Igual",
|
||||
"equals_one_of": "Igual a um de",
|
||||
"error_publishing_survey": "Ocorreu um erro ao publicar o questionário.",
|
||||
"error_saving_changes": "Erro ao guardar alterações",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "Mesmo depois de terem enviado uma resposta (por exemplo, Caixa de Feedback)",
|
||||
"everyone": "Todos",
|
||||
"fallback_for": "Alternativa para ",
|
||||
"external_urls_paywall_tooltip": "Por favor, atualize para personalizar o URL externo. Prevenção contra phishing.",
|
||||
"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}\"",
|
||||
@@ -1397,6 +1400,9 @@
|
||||
"four_points": "4 pontos",
|
||||
"heading": "Cabeçalho",
|
||||
"hidden_field_added_successfully": "Campo oculto adicionado com sucesso",
|
||||
"hidden_field_used_in_recall": "Campo oculto \"{hiddenField}\" está a ser recordado na pergunta {questionIndex}.",
|
||||
"hidden_field_used_in_recall_ending_card": "Campo oculto \"{hiddenField}\" está a ser recordado no Cartão de Conclusão",
|
||||
"hidden_field_used_in_recall_welcome": "Campo oculto \"{hiddenField}\" está a ser recordado no cartão de boas-vindas.",
|
||||
"hide_advanced_settings": "Ocultar definições avançadas",
|
||||
"hide_back_button": "Ocultar botão 'Retroceder'",
|
||||
"hide_back_button_description": "Não mostrar o botão de retroceder no inquérito",
|
||||
@@ -1415,6 +1421,7 @@
|
||||
"inner_text": "Texto Interno",
|
||||
"input_border_color": "Cor da borda do campo de entrada",
|
||||
"input_color": "Cor do campo de entrada",
|
||||
"insert_link": "Inserir ligação",
|
||||
"invalid_targeting": "Segmentação inválida: Por favor, verifique os seus filtros de audiência",
|
||||
"invalid_video_url_warning": "Por favor, insira um URL válido do YouTube, Vimeo ou Loom. Atualmente, não suportamos outros fornecedores de hospedagem de vídeo.",
|
||||
"invalid_youtube_url": "URL do YouTube inválido",
|
||||
@@ -1432,6 +1439,7 @@
|
||||
"is_set": "Está definido",
|
||||
"is_skipped": "É ignorado",
|
||||
"is_submitted": "Está submetido",
|
||||
"italic": "Itálico",
|
||||
"jump_to_question": "Saltar para a pergunta",
|
||||
"keep_current_order": "Manter ordem atual",
|
||||
"keep_showing_while_conditions_match": "Continuar a mostrar enquanto as condições corresponderem",
|
||||
@@ -1458,6 +1466,7 @@
|
||||
"no_images_found_for": "Não foram encontradas imagens para ''{query}\"",
|
||||
"no_languages_found_add_first_one_to_get_started": "Nenhuma língua encontrada. Adicione a primeira para começar.",
|
||||
"no_option_found": "Nenhuma opção encontrada",
|
||||
"no_recall_items_found": "Nenhum item de recordação encontrado",
|
||||
"no_variables_yet_add_first_one_below": "Ainda não há variáveis. Adicione a primeira abaixo.",
|
||||
"number": "Número",
|
||||
"once_set_the_default_language_for_this_survey_can_only_be_changed_by_disabling_the_multi_language_option_and_deleting_all_translations": "Depois de definido, o idioma padrão desta pesquisa só pode ser alterado desativando a opção de vários idiomas e eliminando todas as traduções.",
|
||||
@@ -1477,6 +1486,7 @@
|
||||
"pin_can_only_contain_numbers": "O PIN só pode conter números.",
|
||||
"pin_must_be_a_four_digit_number": "O PIN deve ser um número de quatro dígitos.",
|
||||
"please_enter_a_file_extension": "Por favor, insira uma extensão de ficheiro.",
|
||||
"please_enter_a_valid_url": "Por favor, insira um URL válido (por exemplo, https://example.com)",
|
||||
"please_set_a_survey_trigger": "Por favor, defina um desencadeador de inquérito",
|
||||
"please_specify": "Por favor, especifique",
|
||||
"prevent_double_submission": "Impedir submissão dupla",
|
||||
@@ -1491,6 +1501,8 @@
|
||||
"question_id_updated": "ID da pergunta atualizado",
|
||||
"question_used_in_logic": "Esta pergunta é usada na lógica da pergunta {questionIndex}.",
|
||||
"question_used_in_quota": "Esta pergunta está a ser usada na quota \"{quotaName}\"",
|
||||
"question_used_in_recall": "Esta pergunta está a ser recordada na pergunta {questionIndex}.",
|
||||
"question_used_in_recall_ending_card": "Esta pergunta está a ser recordada no Cartão de Conclusão",
|
||||
"quotas": {
|
||||
"add_quota": "Adicionar quota",
|
||||
"change_quota_for_public_survey": "Alterar quota para inquérito público?",
|
||||
@@ -1525,6 +1537,8 @@
|
||||
"randomize_all": "Aleatorizar todos",
|
||||
"randomize_all_except_last": "Aleatorizar todos exceto o último",
|
||||
"range": "Intervalo",
|
||||
"recall_data": "Recuperar dados",
|
||||
"recall_information_from": "Recordar informação de ...",
|
||||
"recontact_options": "Opções de Recontacto",
|
||||
"redirect_thank_you_card": "Redirecionar cartão de agradecimento",
|
||||
"redirect_to_url": "Redirecionar para Url",
|
||||
@@ -1602,6 +1616,7 @@
|
||||
"trigger_survey_when_one_of_the_actions_is_fired": "Desencadear inquérito quando uma das ações for disparada...",
|
||||
"try_lollipop_or_mountain": "Experimente 'lollipop' ou 'mountain'...",
|
||||
"type_field_id": "Escreva o id do campo",
|
||||
"underline": "Sublinhar",
|
||||
"unlock_targeting_description": "Alvo de grupos de utilizadores específicos com base em atributos ou informações do dispositivo",
|
||||
"unlock_targeting_title": "Desbloqueie a segmentação com um plano superior",
|
||||
"unsaved_changes_warning": "Tem alterações não guardadas no seu inquérito. Gostaria de as guardar antes de sair?",
|
||||
@@ -1618,6 +1633,9 @@
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variável \"{variableName}\" está a ser utilizada na quota \"{quotaName}\"",
|
||||
"variable_name_is_already_taken_please_choose_another": "O nome da variável já está em uso, por favor escolha outro.",
|
||||
"variable_name_must_start_with_a_letter": "O nome da variável deve começar com uma letra.",
|
||||
"variable_used_in_recall": "Variável \"{variable}\" está a ser recordada na pergunta {questionIndex}.",
|
||||
"variable_used_in_recall_ending_card": "Variável {variable} está a ser recordada no Cartão de Conclusão",
|
||||
"variable_used_in_recall_welcome": "Variável \"{variable}\" está a ser recordada no cartão de boas-vindas.",
|
||||
"verify_email_before_submission": "Verificar email antes da submissão",
|
||||
"verify_email_before_submission_description": "Permitir apenas que pessoas com um email real respondam.",
|
||||
"wait": "Aguardar",
|
||||
|
||||
@@ -279,6 +279,7 @@
|
||||
"no_result_found": "Niciun rezultat găsit",
|
||||
"no_results": "Nicio rezultat",
|
||||
"no_surveys_found": "Nu au fost găsite sondaje.",
|
||||
"none_of_the_above": "Niciuna dintre cele de mai sus",
|
||||
"not_authenticated": "Nu sunteți autentificat pentru a efectua această acțiune.",
|
||||
"not_authorized": "Neautorizat",
|
||||
"not_connected": "Neconectat",
|
||||
@@ -1203,12 +1204,12 @@
|
||||
"add_description": "Adăugați descriere",
|
||||
"add_ending": "Adaugă finalizare",
|
||||
"add_ending_below": "Adaugă finalizare mai jos",
|
||||
"add_fallback": "Adaugă",
|
||||
"add_fallback_placeholder": "Adaugă un placeholder pentru a afișa dacă nu există valoare de reamintit",
|
||||
"add_hidden_field_id": "Adăugați ID câmp ascuns",
|
||||
"add_highlight_border": "Adaugă bordură evidențiată",
|
||||
"add_highlight_border_description": "Adaugă o margine exterioară cardului tău de sondaj.",
|
||||
"add_logic": "Adaugă logică",
|
||||
"add_none_of_the_above": "Adăugați \"Niciuna dintre cele de mai sus\"",
|
||||
"add_option": "Adăugați opțiune",
|
||||
"add_other": "Adăugați \"Altele\"",
|
||||
"add_photo_or_video": "Adaugă fotografie sau video",
|
||||
@@ -1241,6 +1242,7 @@
|
||||
"automatically_mark_the_survey_as_complete_after": "Marcați automat sondajul ca finalizat după",
|
||||
"back_button_label": "Etichetă buton \"Înapoi\"",
|
||||
"background_styling": "Stilizare fundal",
|
||||
"bold": "Îngroșat",
|
||||
"brand_color": "Culoarea brandului",
|
||||
"brightness": "Luminozitate",
|
||||
"button_label": "Etichetă buton",
|
||||
@@ -1313,7 +1315,6 @@
|
||||
"days_before_showing_this_survey_again": "zile înainte de a afișa din nou acest sondaj.",
|
||||
"decide_how_often_people_can_answer_this_survey": "Decide cât de des pot răspunde oamenii la acest sondaj",
|
||||
"delete_choice": "Șterge alegerea",
|
||||
"description": "Descriere",
|
||||
"disable_the_visibility_of_survey_progress": "Dezactivați vizibilitatea progresului sondajului",
|
||||
"display_an_estimate_of_completion_time_for_survey": "Afișează o estimare a timpului de finalizare pentru sondaj",
|
||||
"display_number_of_responses_for_survey": "Afișează numărul de răspunsuri pentru sondaj",
|
||||
@@ -1324,6 +1325,7 @@
|
||||
"does_not_include_all_of": "Nu include toate",
|
||||
"does_not_include_one_of": "Nu include una dintre",
|
||||
"does_not_start_with": "Nu începe cu",
|
||||
"edit_link": "Editare legătură",
|
||||
"edit_recall": "Editează Referințele",
|
||||
"edit_translations": "Editează traducerile {lang}",
|
||||
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permite participanților să schimbe limba sondajului în orice moment în timpul sondajului.",
|
||||
@@ -1334,13 +1336,14 @@
|
||||
"ending_card_used_in_logic": "Această carte de încheiere este folosită în logica întrebării {questionIndex}.",
|
||||
"ending_used_in_quota": "Finalul acesta este folosit în cota \"{quotaName}\"",
|
||||
"ends_with": "Se termină cu",
|
||||
"enter_fallback_value": "Introduceți valoarea implicită",
|
||||
"equals": "Egal",
|
||||
"equals_one_of": "Egal unu dintre",
|
||||
"error_publishing_survey": "A apărut o eroare în timpul publicării sondajului.",
|
||||
"error_saving_changes": "Eroare la salvarea modificărilor",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "Chiar și după ce au furnizat un răspuns (de ex. Cutia de Feedback)",
|
||||
"everyone": "Toată lumea",
|
||||
"fallback_for": "Varianta de rezervă pentru",
|
||||
"external_urls_paywall_tooltip": "Vă rugăm să faceți upgrade pentru a personaliza URL-ul extern. Prevenire phishing.",
|
||||
"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}\"",
|
||||
@@ -1397,6 +1400,9 @@
|
||||
"four_points": "4 puncte",
|
||||
"heading": "Titlu",
|
||||
"hidden_field_added_successfully": "Câmp ascuns adăugat cu succes",
|
||||
"hidden_field_used_in_recall": "Câmpul ascuns \"{hiddenField}\" este reamintit în întrebarea {questionIndex}.",
|
||||
"hidden_field_used_in_recall_ending_card": "Câmpul ascuns \"{hiddenField}\" este reamintit în Cardul de Încheiere.",
|
||||
"hidden_field_used_in_recall_welcome": "Câmpul ascuns \"{hiddenField}\" este reamintit în cardul de bun venit.",
|
||||
"hide_advanced_settings": "Ascunde setări avansate",
|
||||
"hide_back_button": "Ascunde butonul 'Înapoi'",
|
||||
"hide_back_button_description": "Nu afișa butonul Înapoi în sondaj",
|
||||
@@ -1415,6 +1421,7 @@
|
||||
"inner_text": "Text Interior",
|
||||
"input_border_color": "Culoarea graniței câmpului de introducere",
|
||||
"input_color": "Culoarea câmpului de introducere",
|
||||
"insert_link": "Inserează link",
|
||||
"invalid_targeting": "\"Targetare nevalidă: Vă rugăm să verificați filtrele pentru audiență\"",
|
||||
"invalid_video_url_warning": "Vă rugăm să introduceți un URL valid de YouTube, Vimeo sau Loom. În prezent nu susținem alți furnizori de găzduire video.",
|
||||
"invalid_youtube_url": "URL YouTube invalid",
|
||||
@@ -1432,6 +1439,7 @@
|
||||
"is_set": "Este setat",
|
||||
"is_skipped": "Este sărit",
|
||||
"is_submitted": "Este trimis",
|
||||
"italic": "Cursiv",
|
||||
"jump_to_question": "Sări la întrebare",
|
||||
"keep_current_order": "Păstrați ordinea actuală",
|
||||
"keep_showing_while_conditions_match": "Continuă să afișezi cât timp condițiile se potrivesc",
|
||||
@@ -1458,6 +1466,7 @@
|
||||
"no_images_found_for": "Nicio imagine găsită pentru ''{query}\"",
|
||||
"no_languages_found_add_first_one_to_get_started": "Nu s-au găsit limbi. Adaugă prima pentru a începe.",
|
||||
"no_option_found": "Nicio opțiune găsită",
|
||||
"no_recall_items_found": "Nu s-au găsit elemente de reamintire",
|
||||
"no_variables_yet_add_first_one_below": "Nu există variabile încă. Adăugați prima mai jos.",
|
||||
"number": "Număr",
|
||||
"once_set_the_default_language_for_this_survey_can_only_be_changed_by_disabling_the_multi_language_option_and_deleting_all_translations": "Odată setată, limba implicită pentru acest sondaj poate fi schimbată doar dezactivând opțiunea multi-limbă și ștergând toate traducerile.",
|
||||
@@ -1477,6 +1486,7 @@
|
||||
"pin_can_only_contain_numbers": "PIN-ul poate conține doar numere.",
|
||||
"pin_must_be_a_four_digit_number": "PIN-ul trebuie să fie un număr de patru cifre",
|
||||
"please_enter_a_file_extension": "Vă rugăm să introduceți o extensie de fișier.",
|
||||
"please_enter_a_valid_url": "Vă rugăm să introduceți un URL valid (de exemplu, https://example.com)",
|
||||
"please_set_a_survey_trigger": "Vă rugăm să setați un declanșator sondaj",
|
||||
"please_specify": "Vă rugăm să specificați",
|
||||
"prevent_double_submission": "Prevenire trimitere dublă",
|
||||
@@ -1491,6 +1501,8 @@
|
||||
"question_id_updated": "ID întrebare actualizat",
|
||||
"question_used_in_logic": "Această întrebare este folosită în logica întrebării {questionIndex}.",
|
||||
"question_used_in_quota": "Întrebarea aceasta este folosită în cota \"{quotaName}\"",
|
||||
"question_used_in_recall": "Această întrebare este reamintită în întrebarea {questionIndex}.",
|
||||
"question_used_in_recall_ending_card": "Această întrebare este reamintită în Cardul de Încheiere.",
|
||||
"quotas": {
|
||||
"add_quota": "Adăugați cotă",
|
||||
"change_quota_for_public_survey": "Schimbați cota pentru sondaj public?",
|
||||
@@ -1525,6 +1537,8 @@
|
||||
"randomize_all": "Randomizează tot",
|
||||
"randomize_all_except_last": "Randomizează tot cu excepția ultimului",
|
||||
"range": "Interval",
|
||||
"recall_data": "Reamintiți datele",
|
||||
"recall_information_from": "Reamintiți informațiile din ...",
|
||||
"recontact_options": "Opțiuni de recontactare",
|
||||
"redirect_thank_you_card": "Redirecționează cardul de mulțumire",
|
||||
"redirect_to_url": "Redirecționează către URL",
|
||||
@@ -1602,6 +1616,7 @@
|
||||
"trigger_survey_when_one_of_the_actions_is_fired": "Declanșați sondajul atunci când una dintre acțiuni este realizată...",
|
||||
"try_lollipop_or_mountain": "Încercați „lollipop” sau „mountain”...",
|
||||
"type_field_id": "ID câmp tip",
|
||||
"underline": "Subliniază",
|
||||
"unlock_targeting_description": "Vizează grupuri specifice de utilizatori pe baza atributelor sau a informațiilor despre dispozitiv",
|
||||
"unlock_targeting_title": "Deblocați țintirea cu un plan superior",
|
||||
"unsaved_changes_warning": "Aveți modificări nesalvate în sondajul dumneavoastră. Doriți să le salvați înainte de a pleca?",
|
||||
@@ -1618,6 +1633,9 @@
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variabila \"{variableName}\" este folosită în cota \"{quotaName}\"",
|
||||
"variable_name_is_already_taken_please_choose_another": "Numele variabilei este deja utilizat, vă rugăm să alegeți altul.",
|
||||
"variable_name_must_start_with_a_letter": "Numele variabilei trebuie să înceapă cu o literă.",
|
||||
"variable_used_in_recall": "Variabila \"{variable}\" este reamintită în întrebarea {questionIndex}.",
|
||||
"variable_used_in_recall_ending_card": "Variabila {variable} este reamintită în Cardul de Încheiere.",
|
||||
"variable_used_in_recall_welcome": "Variabila \"{variable}\" este reamintită în cardul de bun venit.",
|
||||
"verify_email_before_submission": "Verifică emailul înainte de trimitere",
|
||||
"verify_email_before_submission_description": "Permite doar persoanelor cu un email real să răspundă.",
|
||||
"wait": "Așteptați",
|
||||
|
||||
@@ -279,6 +279,7 @@
|
||||
"no_result_found": "没有 结果",
|
||||
"no_results": "没有 结果",
|
||||
"no_surveys_found": "未找到 调查",
|
||||
"none_of_the_above": "以上 都 不 是",
|
||||
"not_authenticated": "您 未 认证 以 执行 该 操作。",
|
||||
"not_authorized": "未授权",
|
||||
"not_connected": "未连接",
|
||||
@@ -1203,12 +1204,12 @@
|
||||
"add_description": "添加 描述",
|
||||
"add_ending": "添加结尾",
|
||||
"add_ending_below": "在下方 添加 结尾",
|
||||
"add_fallback": "添加",
|
||||
"add_fallback_placeholder": "添加 占位符 显示 如果 没有 值以 回忆",
|
||||
"add_hidden_field_id": "添加 隐藏 字段 ID",
|
||||
"add_highlight_border": "添加 高亮 边框",
|
||||
"add_highlight_border_description": "在 你的 调查 卡片 添加 外 边框。",
|
||||
"add_logic": "添加逻辑",
|
||||
"add_none_of_the_above": "添加 “以上 都 不 是”",
|
||||
"add_option": "添加 选项",
|
||||
"add_other": "添加 \"其他\"",
|
||||
"add_photo_or_video": "添加 照片 或 视频",
|
||||
@@ -1241,6 +1242,7 @@
|
||||
"automatically_mark_the_survey_as_complete_after": "自动 标记 调查 为 完成 在",
|
||||
"back_button_label": "\"返回\" 按钮标签",
|
||||
"background_styling": "背景 样式",
|
||||
"bold": "粗体",
|
||||
"brand_color": "品牌 颜色",
|
||||
"brightness": "亮度",
|
||||
"button_label": "按钮标签",
|
||||
@@ -1313,7 +1315,6 @@
|
||||
"days_before_showing_this_survey_again": "显示 此 调查 之前 的 天数。",
|
||||
"decide_how_often_people_can_answer_this_survey": "决定 人 可以 回答 这份 调查 的 频率 。",
|
||||
"delete_choice": "删除 选择",
|
||||
"description": "描述",
|
||||
"disable_the_visibility_of_survey_progress": "禁用问卷 进度 的可见性。",
|
||||
"display_an_estimate_of_completion_time_for_survey": "显示 调查 预计 完成 时间",
|
||||
"display_number_of_responses_for_survey": "显示 调查 响应 数量",
|
||||
@@ -1324,6 +1325,7 @@
|
||||
"does_not_include_all_of": "不包括所有 ",
|
||||
"does_not_include_one_of": "不包括一 个",
|
||||
"does_not_start_with": "不 以 开头",
|
||||
"edit_link": "编辑 链接",
|
||||
"edit_recall": "编辑 调用",
|
||||
"edit_translations": "编辑 {lang} 翻译",
|
||||
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "启用 参与者 在 调查 过程中 的 任何 时间 点 切换 调查 语言。",
|
||||
@@ -1334,13 +1336,14 @@
|
||||
"ending_card_used_in_logic": "\"这个 结束卡片 在 问题 {questionIndex} 的 逻辑 中 使用。\"",
|
||||
"ending_used_in_quota": "此 结尾 正在 被 \"{quotaName}\" 配额 使用",
|
||||
"ends_with": "以...结束",
|
||||
"enter_fallback_value": "输入 后备 值",
|
||||
"equals": "等于",
|
||||
"equals_one_of": "等于 其中 一个",
|
||||
"error_publishing_survey": "发布调查时发生了错误",
|
||||
"error_saving_changes": "保存 更改 时 出错",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "即使 他们 提交 了 回复(例如 反馈框)",
|
||||
"everyone": "所有 人",
|
||||
"fallback_for": "后备 用于",
|
||||
"external_urls_paywall_tooltip": "请升级 以自定义 外部 URL 。 网络钓鱼 预防 。",
|
||||
"fallback_missing": "备用 缺失",
|
||||
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "\"{fieldId} 在 问题 {questionIndex} 的 逻辑 中 使用。请 先 从 逻辑 中 删除 它。\"",
|
||||
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "隐藏 字段 \"{fieldId}\" 正在 被 \"{quotaName}\" 配额 使用",
|
||||
@@ -1397,6 +1400,9 @@
|
||||
"four_points": "4 分",
|
||||
"heading": "标题",
|
||||
"hidden_field_added_successfully": "隐藏字段 添加成功",
|
||||
"hidden_field_used_in_recall": "隐藏 字段 \"{hiddenField}\" 正在召回于问题 {questionIndex}。",
|
||||
"hidden_field_used_in_recall_ending_card": "隐藏 字段 \"{hiddenField}\" 正在召回于结束 卡",
|
||||
"hidden_field_used_in_recall_welcome": "隐藏 字段 \"{hiddenField}\" 正在召回于欢迎 卡 。",
|
||||
"hide_advanced_settings": "隐藏 高级设置",
|
||||
"hide_back_button": "隐藏 \"返回\" 按钮",
|
||||
"hide_back_button_description": "不 显示 调查 中 的 返回 按钮",
|
||||
@@ -1415,6 +1421,7 @@
|
||||
"inner_text": "内文",
|
||||
"input_border_color": "输入 边框 颜色",
|
||||
"input_color": "输入颜色",
|
||||
"insert_link": "插入 链接",
|
||||
"invalid_targeting": "无效的目标: 请检查 您 的受众过滤器",
|
||||
"invalid_video_url_warning": "请输入有效的 YouTube、Vimeo 或 Loom URL 。我们目前不支持其他 视频 托管服务提供商。",
|
||||
"invalid_youtube_url": "无效的 YouTube URL",
|
||||
@@ -1432,6 +1439,7 @@
|
||||
"is_set": "已设置",
|
||||
"is_skipped": "已跳过",
|
||||
"is_submitted": "已提交",
|
||||
"italic": "斜体",
|
||||
"jump_to_question": "跳 转 到 问题",
|
||||
"keep_current_order": "保持 当前 顺序",
|
||||
"keep_showing_while_conditions_match": "条件 符合 时 保持 显示",
|
||||
@@ -1458,6 +1466,7 @@
|
||||
"no_images_found_for": "未找到与 \"{query}\" 相关的图片",
|
||||
"no_languages_found_add_first_one_to_get_started": "没有找到语言。添加第一个以开始。",
|
||||
"no_option_found": "找不到选择",
|
||||
"no_recall_items_found": "未 找到 召回 项目",
|
||||
"no_variables_yet_add_first_one_below": "还没有变量。 在下面添加第一个。",
|
||||
"number": "数字",
|
||||
"once_set_the_default_language_for_this_survey_can_only_be_changed_by_disabling_the_multi_language_option_and_deleting_all_translations": "一旦设置,此调查的默认语言只能通过禁用多语言选项并删除所有翻译来更改。",
|
||||
@@ -1477,6 +1486,7 @@
|
||||
"pin_can_only_contain_numbers": "PIN 只能包含数字。",
|
||||
"pin_must_be_a_four_digit_number": "PIN 必须是 四 位数字。",
|
||||
"please_enter_a_file_extension": "请输入 文件 扩展名。",
|
||||
"please_enter_a_valid_url": "请输入有效的 URL(例如, https://example.com )",
|
||||
"please_set_a_survey_trigger": "请 设置 一个 调查 触发",
|
||||
"please_specify": "请 指定",
|
||||
"prevent_double_submission": "防止 重复 提交",
|
||||
@@ -1491,6 +1501,8 @@
|
||||
"question_id_updated": "问题 ID 更新",
|
||||
"question_used_in_logic": "\"这个 问题 在 问题 {questionIndex} 的 逻辑 中 使用。\"",
|
||||
"question_used_in_quota": "此 问题 正在 被 \"{quotaName}\" 配额 使用",
|
||||
"question_used_in_recall": "此问题正在召回于问题 {questionIndex}。",
|
||||
"question_used_in_recall_ending_card": "此 问题 正在召回于结束 卡片。",
|
||||
"quotas": {
|
||||
"add_quota": "添加 配额",
|
||||
"change_quota_for_public_survey": "更改 公共调查 的配额?",
|
||||
@@ -1525,6 +1537,8 @@
|
||||
"randomize_all": "随机排列",
|
||||
"randomize_all_except_last": "随机排列,最后一个除外",
|
||||
"range": "范围",
|
||||
"recall_data": "调用 数据",
|
||||
"recall_information_from": "从 ... 召回信息",
|
||||
"recontact_options": "重新 联系 选项",
|
||||
"redirect_thank_you_card": "重定向感谢卡",
|
||||
"redirect_to_url": "重定向到 URL",
|
||||
@@ -1602,6 +1616,7 @@
|
||||
"trigger_survey_when_one_of_the_actions_is_fired": "当 其中 一个 动作 被 触发 时 启动 调查…",
|
||||
"try_lollipop_or_mountain": "尝试 'lollipop' 或 'mountain' ...",
|
||||
"type_field_id": "类型 字段 ID",
|
||||
"underline": "下划线",
|
||||
"unlock_targeting_description": "根据 属性 或 设备信息 定位 特定 用户组",
|
||||
"unlock_targeting_title": "通过 更 高级 划解锁 定位",
|
||||
"unsaved_changes_warning": "您在调查中有未保存的更改。离开前是否要保存?",
|
||||
@@ -1618,6 +1633,9 @@
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "变量 \"{variableName}\" 正在 被 \"{quotaName}\" 配额 使用",
|
||||
"variable_name_is_already_taken_please_choose_another": "变量名已被占用,请选择其他。",
|
||||
"variable_name_must_start_with_a_letter": "变量名 必须 以字母开头。",
|
||||
"variable_used_in_recall": "变量 \"{variable}\" 正在召回于问题 {questionIndex}。",
|
||||
"variable_used_in_recall_ending_card": "变量 {variable} 正在召回于结束 卡片",
|
||||
"variable_used_in_recall_welcome": "变量 \"{variable}\" 正在召回于欢迎 卡 。",
|
||||
"verify_email_before_submission": "提交 之前 验证电子邮件",
|
||||
"verify_email_before_submission_description": "仅允许 拥有 有效 电子邮件 的 人 回应。",
|
||||
"wait": "等待",
|
||||
|
||||
@@ -279,6 +279,7 @@
|
||||
"no_result_found": "找不到結果",
|
||||
"no_results": "沒有結果",
|
||||
"no_surveys_found": "找不到問卷。",
|
||||
"none_of_the_above": "以上皆非",
|
||||
"not_authenticated": "您未經授權執行此操作。",
|
||||
"not_authorized": "未授權",
|
||||
"not_connected": "未連線",
|
||||
@@ -1203,12 +1204,12 @@
|
||||
"add_description": "新增描述",
|
||||
"add_ending": "新增結尾",
|
||||
"add_ending_below": "在下方新增結尾",
|
||||
"add_fallback": "新增",
|
||||
"add_fallback_placeholder": "新增 預設 以顯示是否沒 有 值 可 回憶 。",
|
||||
"add_hidden_field_id": "新增隱藏欄位 ID",
|
||||
"add_highlight_border": "新增醒目提示邊框",
|
||||
"add_highlight_border_description": "在您的問卷卡片新增外邊框。",
|
||||
"add_logic": "新增邏輯",
|
||||
"add_none_of_the_above": "新增 \"以上皆非\"",
|
||||
"add_option": "新增選項",
|
||||
"add_other": "新增「其他」",
|
||||
"add_photo_or_video": "新增照片或影片",
|
||||
@@ -1241,6 +1242,7 @@
|
||||
"automatically_mark_the_survey_as_complete_after": "在指定時間後自動將問卷標記為完成",
|
||||
"back_button_label": "「返回」按鈕標籤",
|
||||
"background_styling": "背景樣式設定",
|
||||
"bold": "粗體",
|
||||
"brand_color": "品牌顏色",
|
||||
"brightness": "亮度",
|
||||
"button_label": "按鈕標籤",
|
||||
@@ -1313,7 +1315,6 @@
|
||||
"days_before_showing_this_survey_again": "天後再次顯示此問卷。",
|
||||
"decide_how_often_people_can_answer_this_survey": "決定人們可以回答此問卷的頻率。",
|
||||
"delete_choice": "刪除選項",
|
||||
"description": "描述",
|
||||
"disable_the_visibility_of_survey_progress": "停用問卷進度的可見性。",
|
||||
"display_an_estimate_of_completion_time_for_survey": "顯示問卷的估計完成時間",
|
||||
"display_number_of_responses_for_survey": "顯示問卷的回應數",
|
||||
@@ -1324,6 +1325,7 @@
|
||||
"does_not_include_all_of": "不包含全部",
|
||||
"does_not_include_one_of": "不包含其中之一",
|
||||
"does_not_start_with": "不以...開頭",
|
||||
"edit_link": "編輯 連結",
|
||||
"edit_recall": "編輯回憶",
|
||||
"edit_translations": "編輯 '{'language'}' 翻譯",
|
||||
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "允許參與者在問卷中的任何時間點切換問卷語言。",
|
||||
@@ -1334,13 +1336,14 @@
|
||||
"ending_card_used_in_logic": "此結尾卡片用於問題 '{'questionIndex'}' 的邏輯中。",
|
||||
"ending_used_in_quota": "此 結尾 正被使用於 \"{quotaName}\" 配額中",
|
||||
"ends_with": "結尾為",
|
||||
"enter_fallback_value": "輸入 預設 值",
|
||||
"equals": "等於",
|
||||
"equals_one_of": "等於其中之一",
|
||||
"error_publishing_survey": "發布問卷時發生錯誤。",
|
||||
"error_saving_changes": "儲存變更時發生錯誤",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "即使他們提交回應之後(例如,意見反應方塊)",
|
||||
"everyone": "所有人",
|
||||
"fallback_for": "備用 用於 ",
|
||||
"external_urls_paywall_tooltip": "請升級以自訂 external URL 。 Phishing 預防。",
|
||||
"fallback_missing": "遺失的回退",
|
||||
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "'{'fieldId'}' 用於問題 '{'questionIndex'}' 的邏輯中。請先從邏輯中移除。",
|
||||
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "隱藏欄位 \"{fieldId}\" 正被使用於 \"{quotaName}\" 配額中",
|
||||
@@ -1397,6 +1400,9 @@
|
||||
"four_points": "4 分",
|
||||
"heading": "標題",
|
||||
"hidden_field_added_successfully": "隱藏欄位已成功新增",
|
||||
"hidden_field_used_in_recall": "隱藏欄位 \"{hiddenField}\" 於問題 {questionIndex} 中被召回。",
|
||||
"hidden_field_used_in_recall_ending_card": "隱藏欄位 \"{hiddenField}\" 於結束卡中被召回。",
|
||||
"hidden_field_used_in_recall_welcome": "隱藏欄位 \"{hiddenField}\" 於歡迎卡中被召回。",
|
||||
"hide_advanced_settings": "隱藏進階設定",
|
||||
"hide_back_button": "隱藏「Back」按鈕",
|
||||
"hide_back_button_description": "不要在問卷中顯示返回按鈕",
|
||||
@@ -1415,6 +1421,7 @@
|
||||
"inner_text": "內部文字",
|
||||
"input_border_color": "輸入邊框顏色",
|
||||
"input_color": "輸入顏色",
|
||||
"insert_link": "插入 連結",
|
||||
"invalid_targeting": "目標設定無效:請檢查您的受眾篩選器",
|
||||
"invalid_video_url_warning": "請輸入有效的 YouTube、Vimeo 或 Loom 網址。我們目前不支援其他影片託管提供者。",
|
||||
"invalid_youtube_url": "無效的 YouTube 網址",
|
||||
@@ -1432,6 +1439,7 @@
|
||||
"is_set": "已設定",
|
||||
"is_skipped": "已跳過",
|
||||
"is_submitted": "已提交",
|
||||
"italic": "斜體",
|
||||
"jump_to_question": "跳至問題",
|
||||
"keep_current_order": "保留目前順序",
|
||||
"keep_showing_while_conditions_match": "在條件符合時持續顯示",
|
||||
@@ -1458,6 +1466,7 @@
|
||||
"no_images_found_for": "找不到「'{'query'}'」的圖片",
|
||||
"no_languages_found_add_first_one_to_get_started": "找不到語言。新增第一個語言以開始使用。",
|
||||
"no_option_found": "找不到選項",
|
||||
"no_recall_items_found": "找不到 召回 項目",
|
||||
"no_variables_yet_add_first_one_below": "尚無變數。在下方新增第一個變數。",
|
||||
"number": "數字",
|
||||
"once_set_the_default_language_for_this_survey_can_only_be_changed_by_disabling_the_multi_language_option_and_deleting_all_translations": "設定後,此問卷的預設語言只能藉由停用多語言選項並刪除所有翻譯來變更。",
|
||||
@@ -1477,6 +1486,7 @@
|
||||
"pin_can_only_contain_numbers": "PIN 碼只能包含數字。",
|
||||
"pin_must_be_a_four_digit_number": "PIN 碼必須是四位數的數字。",
|
||||
"please_enter_a_file_extension": "請輸入檔案副檔名。",
|
||||
"please_enter_a_valid_url": "請輸入有效的 URL(例如:https://example.com)",
|
||||
"please_set_a_survey_trigger": "請設定問卷觸發器",
|
||||
"please_specify": "請指定",
|
||||
"prevent_double_submission": "防止重複提交",
|
||||
@@ -1491,6 +1501,8 @@
|
||||
"question_id_updated": "問題 ID 已更新",
|
||||
"question_used_in_logic": "此問題用於問題 '{'questionIndex'}' 的邏輯中。",
|
||||
"question_used_in_quota": "此問題 正被使用於 \"{quotaName}\" 配額中",
|
||||
"question_used_in_recall": "此問題於問題 {questionIndex} 中被召回。",
|
||||
"question_used_in_recall_ending_card": "此問題於結尾卡中被召回。",
|
||||
"quotas": {
|
||||
"add_quota": "新增額度",
|
||||
"change_quota_for_public_survey": "更改 公開 問卷 的 額度?",
|
||||
@@ -1525,6 +1537,8 @@
|
||||
"randomize_all": "全部隨機排序",
|
||||
"randomize_all_except_last": "全部隨機排序(最後一項除外)",
|
||||
"range": "範圍",
|
||||
"recall_data": "回憶數據",
|
||||
"recall_information_from": "從 ... 獲取 信息",
|
||||
"recontact_options": "重新聯絡選項",
|
||||
"redirect_thank_you_card": "重新導向感謝卡片",
|
||||
"redirect_to_url": "重新導向至網址",
|
||||
@@ -1602,6 +1616,7 @@
|
||||
"trigger_survey_when_one_of_the_actions_is_fired": "當觸發其中一個操作時,觸發問卷...",
|
||||
"try_lollipop_or_mountain": "嘗試「棒棒糖」或「山峰」...",
|
||||
"type_field_id": "輸入欄位 ID",
|
||||
"underline": "下 劃 線",
|
||||
"unlock_targeting_description": "根據屬性或裝置資訊鎖定特定使用者群組",
|
||||
"unlock_targeting_title": "使用更高等級的方案解鎖目標設定",
|
||||
"unsaved_changes_warning": "您的問卷中有未儲存的變更。您要先儲存它們再離開嗎?",
|
||||
@@ -1618,6 +1633,9 @@
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "變數 \"{variableName}\" 正被使用於 \"{quotaName}\" 配額中",
|
||||
"variable_name_is_already_taken_please_choose_another": "已使用此變數名稱,請選擇另一個名稱。",
|
||||
"variable_name_must_start_with_a_letter": "變數名稱必須以字母開頭。",
|
||||
"variable_used_in_recall": "變數 \"{variable}\" 於問題 {questionIndex} 中被召回。",
|
||||
"variable_used_in_recall_ending_card": "變數 {variable} 於 結束 卡 中被召回。",
|
||||
"variable_used_in_recall_welcome": "變數 \"{variable}\" 於 歡迎 Card 中被召回。",
|
||||
"verify_email_before_submission": "提交前驗證電子郵件",
|
||||
"verify_email_before_submission_description": "僅允許擁有真實電子郵件的人員回應。",
|
||||
"wait": "等待",
|
||||
|
||||
+24
-15
@@ -1,12 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { parseRecallInfo } from "@/lib/utils/recall";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { CheckCircle2Icon, ChevronsDownIcon, XCircleIcon } from "lucide-react";
|
||||
import { TResponseData } from "@formbricks/types/responses";
|
||||
import { TSurveyQuestion } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { parseRecallInfo } from "@/lib/utils/recall";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||
|
||||
interface QuestionSkipProps {
|
||||
skippedQuestions: string[] | undefined;
|
||||
@@ -72,12 +73,16 @@ export const QuestionSkip = ({
|
||||
{skippedQuestions?.map((questionId) => {
|
||||
return (
|
||||
<p className="my-2" key={questionId}>
|
||||
{parseRecallInfo(
|
||||
getLocalizedValue(
|
||||
questions.find((question) => question.id === questionId)!.headline,
|
||||
"default"
|
||||
),
|
||||
responseData
|
||||
{getTextContent(
|
||||
parseRecallInfo(
|
||||
getLocalizedValue(
|
||||
questions.find((question) => question.id === questionId)?.headline ?? {
|
||||
default: "",
|
||||
},
|
||||
"default"
|
||||
),
|
||||
responseData
|
||||
)
|
||||
)}
|
||||
</p>
|
||||
);
|
||||
@@ -107,12 +112,16 @@ export const QuestionSkip = ({
|
||||
skippedQuestions.map((questionId) => {
|
||||
return (
|
||||
<p className="my-2" key={questionId}>
|
||||
{parseRecallInfo(
|
||||
getLocalizedValue(
|
||||
questions.find((question) => question.id === questionId)!.headline,
|
||||
"default"
|
||||
),
|
||||
responseData
|
||||
{getTextContent(
|
||||
parseRecallInfo(
|
||||
getLocalizedValue(
|
||||
questions.find((question) => question.id === questionId)?.headline ?? {
|
||||
default: "",
|
||||
},
|
||||
"default"
|
||||
),
|
||||
responseData
|
||||
)
|
||||
)}
|
||||
</p>
|
||||
);
|
||||
|
||||
+9
-6
@@ -4,6 +4,7 @@ import { useTranslate } from "@tolgee/react";
|
||||
import { CheckCircle2Icon } from "lucide-react";
|
||||
import { TResponseWithQuotas } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { parseRecallInfo } from "@/lib/utils/recall";
|
||||
import { ResponseCardQuotas } from "@/modules/ee/quotas/components/single-response-card-quotas";
|
||||
@@ -77,13 +78,15 @@ export const SingleResponseCardBody = ({
|
||||
<div key={`${question.id}`}>
|
||||
{isValidValue(response.data[question.id]) ? (
|
||||
<div>
|
||||
<p className="mb-1 text-sm text-slate-500">
|
||||
<p className="mb-1 text-sm font-semibold text-slate-600">
|
||||
{formatTextWithSlashes(
|
||||
parseRecallInfo(
|
||||
getLocalizedValue(question.headline, "default"),
|
||||
response.data,
|
||||
response.variables,
|
||||
true
|
||||
getTextContent(
|
||||
parseRecallInfo(
|
||||
getLocalizedValue(question.headline, "default"),
|
||||
response.data,
|
||||
response.variables,
|
||||
true
|
||||
)
|
||||
)
|
||||
)}
|
||||
</p>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { ZodRawShape, z } from "zod";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { TApiAuditLog } from "@/app/lib/api/with-api-logging";
|
||||
import { formatZodError, handleApiError } from "@/modules/api/v2/lib/utils";
|
||||
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import { ZodRawShape, z } from "zod";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { authenticateRequest } from "./authenticate-request";
|
||||
|
||||
export type HandlerFn<TInput = Record<string, unknown>> = ({
|
||||
@@ -106,7 +106,7 @@ export const apiWrapper = async <S extends ExtendedSchemas>({
|
||||
|
||||
if (rateLimit) {
|
||||
try {
|
||||
await applyRateLimit(rateLimitConfigs.api.v2, authentication.data.hashedApiKey);
|
||||
await applyRateLimit(rateLimitConfigs.api.v2, authentication.data.apiKeyId);
|
||||
} catch (error) {
|
||||
return handleApiError(request, { type: "too_many_requests", details: error.message });
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key";
|
||||
|
||||
export const authenticateRequest = async (
|
||||
request: Request
|
||||
@@ -14,8 +13,6 @@ export const authenticateRequest = async (
|
||||
|
||||
if (!apiKeyData) return err({ type: "unauthorized" });
|
||||
|
||||
const hashedApiKey = hashApiKey(apiKey);
|
||||
|
||||
const authentication: TAuthenticationApiKey = {
|
||||
type: "apiKey",
|
||||
environmentPermissions: apiKeyData.apiKeyEnvironments.map((env) => ({
|
||||
@@ -25,7 +22,6 @@ export const authenticateRequest = async (
|
||||
projectId: env.environment.projectId,
|
||||
projectName: env.environment.project.name,
|
||||
})),
|
||||
hashedApiKey,
|
||||
apiKeyId: apiKeyData.id,
|
||||
organizationId: apiKeyData.organizationId,
|
||||
organizationAccess: apiKeyData.organizationAccess,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { err, ok } from "@formbricks/types/error-handlers";
|
||||
import { apiWrapper } from "@/modules/api/v2/auth/api-wrapper";
|
||||
import { authenticateRequest } from "@/modules/api/v2/auth/authenticate-request";
|
||||
import { handleApiError } from "@/modules/api/v2/lib/utils";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { checkRateLimit } from "@/modules/core/rate-limit/rate-limit";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { err, ok } from "@formbricks/types/error-handlers";
|
||||
|
||||
vi.mock("../authenticate-request", () => ({
|
||||
authenticateRequest: vi.fn(),
|
||||
@@ -39,8 +39,7 @@ const mockAuthentication = {
|
||||
permission: "manage" as const,
|
||||
},
|
||||
],
|
||||
hashedApiKey: "hashed-api-key",
|
||||
apiKeyId: "api-key-id",
|
||||
apiKeyId: "hashed-api-key",
|
||||
organizationId: "org-id",
|
||||
organizationAccess: {} as any,
|
||||
} as any;
|
||||
|
||||
@@ -1,25 +1,17 @@
|
||||
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key";
|
||||
import { TApiKeyWithEnvironmentAndProject } from "@/modules/organization/settings/api-keys/types/api-keys";
|
||||
import { authenticateRequest } from "../authenticate-request";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
apiKey: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/api/v2/management/lib/utils", () => ({
|
||||
hashApiKey: vi.fn(),
|
||||
// Mock the getApiKeyWithPermissions function
|
||||
vi.mock("@/modules/organization/settings/api-keys/lib/api-key", () => ({
|
||||
getApiKeyWithPermissions: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("authenticateRequest", () => {
|
||||
test("should return authentication data if apiKey is valid", async () => {
|
||||
test("should return authentication data if apiKey is valid with environment permissions", async () => {
|
||||
const request = new Request("http://localhost", {
|
||||
headers: { "x-api-key": "valid-api-key" },
|
||||
headers: { "x-api-key": "fbk_validApiKeySecret123" },
|
||||
});
|
||||
|
||||
const mockApiKeyData = {
|
||||
@@ -29,34 +21,52 @@ describe("authenticateRequest", () => {
|
||||
createdBy: "user-id",
|
||||
lastUsedAt: null,
|
||||
label: "Test API Key",
|
||||
hashedKey: "hashed-api-key",
|
||||
hashedKey: "hashed-key",
|
||||
organizationAccess: {
|
||||
accessControl: {
|
||||
read: true,
|
||||
write: false,
|
||||
},
|
||||
},
|
||||
apiKeyEnvironments: [
|
||||
{
|
||||
environmentId: "env-id-1",
|
||||
permission: "manage",
|
||||
apiKeyId: "api-key-id",
|
||||
environment: {
|
||||
id: "env-id-1",
|
||||
projectId: "project-id-1",
|
||||
type: "development",
|
||||
project: { name: "Project 1" },
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
appSetupCompleted: false,
|
||||
project: {
|
||||
id: "project-id-1",
|
||||
name: "Project 1",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
environmentId: "env-id-2",
|
||||
permission: "read",
|
||||
apiKeyId: "api-key-id",
|
||||
environment: {
|
||||
id: "env-id-2",
|
||||
projectId: "project-id-2",
|
||||
type: "production",
|
||||
project: { name: "Project 2" },
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
appSetupCompleted: false,
|
||||
project: {
|
||||
id: "project-id-2",
|
||||
name: "Project 2",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
} as unknown as TApiKeyWithEnvironmentAndProject;
|
||||
|
||||
vi.mocked(hashApiKey).mockReturnValue("hashed-api-key");
|
||||
vi.mocked(prisma.apiKey.findUnique).mockResolvedValue(mockApiKeyData);
|
||||
vi.mocked(prisma.apiKey.update).mockResolvedValue(mockApiKeyData);
|
||||
vi.mocked(getApiKeyWithPermissions).mockResolvedValue(mockApiKeyData);
|
||||
|
||||
const result = await authenticateRequest(request);
|
||||
|
||||
@@ -80,18 +90,70 @@ describe("authenticateRequest", () => {
|
||||
projectName: "Project 2",
|
||||
},
|
||||
],
|
||||
hashedApiKey: "hashed-api-key",
|
||||
apiKeyId: "api-key-id",
|
||||
organizationId: "org-id",
|
||||
organizationAccess: {
|
||||
accessControl: {
|
||||
read: true,
|
||||
write: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
expect(getApiKeyWithPermissions).toHaveBeenCalledWith("fbk_validApiKeySecret123");
|
||||
});
|
||||
|
||||
test("should return authentication data if apiKey is valid with organization-level access only", async () => {
|
||||
const request = new Request("http://localhost", {
|
||||
headers: { "x-api-key": "fbk_orgLevelApiKey456" },
|
||||
});
|
||||
|
||||
const mockApiKeyData = {
|
||||
id: "org-api-key-id",
|
||||
organizationId: "org-id",
|
||||
createdAt: new Date(),
|
||||
createdBy: "user-id",
|
||||
lastUsedAt: null,
|
||||
label: "Organization Level API Key",
|
||||
hashedKey: "hashed-key-org",
|
||||
organizationAccess: {
|
||||
accessControl: {
|
||||
read: true,
|
||||
write: true,
|
||||
},
|
||||
},
|
||||
apiKeyEnvironments: [], // No environment-specific permissions
|
||||
} as unknown as TApiKeyWithEnvironmentAndProject;
|
||||
|
||||
vi.mocked(getApiKeyWithPermissions).mockResolvedValue(mockApiKeyData);
|
||||
|
||||
const result = await authenticateRequest(request);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toEqual({
|
||||
type: "apiKey",
|
||||
environmentPermissions: [],
|
||||
apiKeyId: "org-api-key-id",
|
||||
organizationId: "org-id",
|
||||
organizationAccess: {
|
||||
accessControl: {
|
||||
read: true,
|
||||
write: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
expect(getApiKeyWithPermissions).toHaveBeenCalledWith("fbk_orgLevelApiKey456");
|
||||
});
|
||||
|
||||
test("should return unauthorized error if apiKey is not found", async () => {
|
||||
const request = new Request("http://localhost", {
|
||||
headers: { "x-api-key": "invalid-api-key" },
|
||||
headers: { "x-api-key": "fbk_invalidApiKeySecret" },
|
||||
});
|
||||
vi.mocked(prisma.apiKey.findUnique).mockResolvedValue(null);
|
||||
vi.mocked(getApiKeyWithPermissions).mockResolvedValue(null);
|
||||
|
||||
const result = await authenticateRequest(request);
|
||||
|
||||
@@ -99,9 +161,11 @@ describe("authenticateRequest", () => {
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({ type: "unauthorized" });
|
||||
}
|
||||
|
||||
expect(getApiKeyWithPermissions).toHaveBeenCalledWith("fbk_invalidApiKeySecret");
|
||||
});
|
||||
|
||||
test("should return unauthorized error if apiKey is missing", async () => {
|
||||
test("should return unauthorized error if apiKey is missing from headers", async () => {
|
||||
const request = new Request("http://localhost");
|
||||
|
||||
const result = await authenticateRequest(request);
|
||||
@@ -110,5 +174,24 @@ describe("authenticateRequest", () => {
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({ type: "unauthorized" });
|
||||
}
|
||||
|
||||
// Should not call getApiKeyWithPermissions if header is missing
|
||||
expect(getApiKeyWithPermissions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should return unauthorized error if apiKey header is empty string", async () => {
|
||||
const request = new Request("http://localhost", {
|
||||
headers: { "x-api-key": "" },
|
||||
});
|
||||
|
||||
const result = await authenticateRequest(request);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({ type: "unauthorized" });
|
||||
}
|
||||
|
||||
// Should not call getApiKeyWithPermissions for empty string
|
||||
expect(getApiKeyWithPermissions).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,22 +1,7 @@
|
||||
import { TGetFilter } from "@/modules/api/v2/types/api-filter";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { buildCommonFilterQuery, hashApiKey, pickCommonFilter } from "../utils";
|
||||
|
||||
describe("hashApiKey", () => {
|
||||
test("generate the correct sha256 hash for a given input", () => {
|
||||
const input = "test";
|
||||
const expectedHash = "fake-hash"; // mocked on the vitestSetup.ts file;
|
||||
const result = hashApiKey(input);
|
||||
expect(result).toEqual(expectedHash);
|
||||
});
|
||||
|
||||
test("return a string with length 64", () => {
|
||||
const input = "another-api-key";
|
||||
const result = hashApiKey(input);
|
||||
expect(result).toHaveLength(9); // mocked on the vitestSetup.ts file;;
|
||||
});
|
||||
});
|
||||
import { TGetFilter } from "@/modules/api/v2/types/api-filter";
|
||||
import { buildCommonFilterQuery, pickCommonFilter } from "../utils";
|
||||
|
||||
describe("pickCommonFilter", () => {
|
||||
test("picks the common filter fields correctly", () => {
|
||||
@@ -53,8 +38,9 @@ describe("pickCommonFilter", () => {
|
||||
endDate: new Date("2023-12-31"),
|
||||
} as TGetFilter;
|
||||
const result = buildCommonFilterQuery(query, params);
|
||||
expect(result.where?.createdAt?.gte).toEqual(params.startDate);
|
||||
expect(result.where?.createdAt?.lte).toEqual(params.endDate);
|
||||
const createdAt = result.where?.createdAt as Prisma.DateTimeFilter | undefined;
|
||||
expect(createdAt?.gte).toEqual(params.startDate);
|
||||
expect(createdAt?.lte).toEqual(params.endDate);
|
||||
});
|
||||
|
||||
test("applies sortBy and order when provided", () => {
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { TGetFilter } from "@/modules/api/v2/types/api-filter";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { createHash } from "crypto";
|
||||
|
||||
export const hashApiKey = (key: string): string => createHash("sha256").update(key).digest("hex");
|
||||
import { TGetFilter } from "@/modules/api/v2/types/api-filter";
|
||||
|
||||
export function pickCommonFilter<T extends TGetFilter>(params: T) {
|
||||
const { limit, skip, sortBy, order, startDate, endDate } = params;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { logSignOut } from "@/modules/auth/lib/utils";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { logSignOut } from "@/modules/auth/lib/utils";
|
||||
import { logSignOutAction } from "./sign-out";
|
||||
|
||||
// Mock the dependencies
|
||||
@@ -80,6 +80,7 @@ describe("logSignOutAction", () => {
|
||||
"email_change",
|
||||
"session_timeout",
|
||||
"forced_logout",
|
||||
"password_reset",
|
||||
] as const;
|
||||
|
||||
for (const reason of reasons) {
|
||||
@@ -100,11 +101,14 @@ describe("logSignOutAction", () => {
|
||||
|
||||
await expect(() => logSignOutAction(mockUserId, mockUserEmail, mockContext)).rejects.toThrow(mockError);
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith("Failed to log sign out event", {
|
||||
userId: mockUserId,
|
||||
context: mockContext,
|
||||
error: mockError.message,
|
||||
});
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
{
|
||||
userId: mockUserId,
|
||||
context: mockContext,
|
||||
error: mockError.message,
|
||||
},
|
||||
"Failed to log sign out event"
|
||||
);
|
||||
expect(logger.error).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -116,11 +120,14 @@ describe("logSignOutAction", () => {
|
||||
|
||||
await expect(() => logSignOutAction(mockUserId, mockUserEmail, mockContext)).rejects.toThrow(mockError);
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith("Failed to log sign out event", {
|
||||
userId: mockUserId,
|
||||
context: mockContext,
|
||||
error: mockError,
|
||||
});
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
{
|
||||
userId: mockUserId,
|
||||
context: mockContext,
|
||||
error: mockError,
|
||||
},
|
||||
"Failed to log sign out event"
|
||||
);
|
||||
expect(logger.error).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -133,11 +140,14 @@ describe("logSignOutAction", () => {
|
||||
|
||||
await expect(() => logSignOutAction(mockUserId, mockUserEmail, emptyContext)).rejects.toThrow(mockError);
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith("Failed to log sign out event", {
|
||||
userId: mockUserId,
|
||||
context: emptyContext,
|
||||
error: mockError.message,
|
||||
});
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
{
|
||||
userId: mockUserId,
|
||||
context: emptyContext,
|
||||
error: mockError.message,
|
||||
},
|
||||
"Failed to log sign out event"
|
||||
);
|
||||
expect(logger.error).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { logSignOut } from "@/modules/auth/lib/utils";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { logSignOut } from "@/modules/auth/lib/utils";
|
||||
|
||||
/**
|
||||
* Logs a sign out event
|
||||
@@ -27,11 +27,14 @@ export const logSignOutAction = async (
|
||||
try {
|
||||
logSignOut(userId, userEmail, context);
|
||||
} catch (error) {
|
||||
logger.error("Failed to log sign out event", {
|
||||
userId,
|
||||
context,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
logger.error(
|
||||
{
|
||||
userId,
|
||||
context,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
"Failed to log sign out event"
|
||||
);
|
||||
// Re-throw to ensure callers are aware of the failure
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Provider } from "next-auth/providers/index";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { EMAIL_VERIFICATION_DISABLED } from "@/lib/constants";
|
||||
import { createToken } from "@/lib/jwt";
|
||||
// Import mocked rate limiting functions
|
||||
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
@@ -11,6 +10,15 @@ import { authOptions } from "./authOptions";
|
||||
import { mockUser } from "./mock-data";
|
||||
import { hashPassword } from "./utils";
|
||||
|
||||
// Mock encryption utilities
|
||||
vi.mock("@/lib/encryption", () => ({
|
||||
symmetricEncrypt: vi.fn((value: string) => `encrypted_${value}`),
|
||||
symmetricDecrypt: vi.fn((value: string) => value.replace("encrypted_", "")),
|
||||
}));
|
||||
|
||||
// Mock JWT
|
||||
vi.mock("@/lib/jwt");
|
||||
|
||||
// Mock rate limiting dependencies
|
||||
vi.mock("@/modules/core/rate-limit/helpers", () => ({
|
||||
applyIPRateLimit: vi.fn(),
|
||||
@@ -39,6 +47,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
SENTRY_DSN: undefined,
|
||||
BREVO_API_KEY: undefined,
|
||||
RATE_LIMITING_DISABLED: false,
|
||||
CONTROL_HASH: "$2b$12$fzHf9le13Ss9UJ04xzmsjODXpFJxz6vsnupoepF5FiqDECkX2BH5q",
|
||||
}));
|
||||
|
||||
// Mock next/headers
|
||||
@@ -257,55 +266,13 @@ describe("authOptions", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("should throw error if email is already verified", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
|
||||
vi.spyOn(prisma.user, "findUnique").mockResolvedValue(mockUser as any);
|
||||
|
||||
const credentials = { token: createToken(mockUser.id) };
|
||||
|
||||
await expect(tokenProvider.options.authorize(credentials, {})).rejects.toThrow(
|
||||
"Email already verified"
|
||||
);
|
||||
});
|
||||
|
||||
test("should update user and verify email when token is valid", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
|
||||
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({ id: mockUser.id, emailVerified: null } as any);
|
||||
vi.spyOn(prisma.user, "update").mockResolvedValue({
|
||||
...mockUser,
|
||||
password: mockHashedPassword,
|
||||
backupCodes: null,
|
||||
twoFactorSecret: null,
|
||||
identityProviderAccountId: null,
|
||||
groupId: null,
|
||||
} as any);
|
||||
|
||||
const credentials = { token: createToken(mockUserId) };
|
||||
|
||||
const result = await tokenProvider.options.authorize(credentials, {});
|
||||
expect(result.email).toBe(mockUser.email);
|
||||
expect(result.emailVerified).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
describe("Rate Limiting", () => {
|
||||
test("should apply rate limiting before token verification", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue();
|
||||
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({
|
||||
id: mockUser.id,
|
||||
emailVerified: null,
|
||||
} as any);
|
||||
vi.spyOn(prisma.user, "update").mockResolvedValue({
|
||||
...mockUser,
|
||||
password: mockHashedPassword,
|
||||
backupCodes: null,
|
||||
twoFactorSecret: null,
|
||||
identityProviderAccountId: null,
|
||||
groupId: null,
|
||||
} as any);
|
||||
|
||||
const credentials = { token: createToken(mockUserId) };
|
||||
const credentials = { token: "sometoken" };
|
||||
|
||||
await tokenProvider.options.authorize(credentials, {});
|
||||
await expect(tokenProvider.options.authorize(credentials, {})).rejects.toThrow();
|
||||
|
||||
expect(applyIPRateLimit).toHaveBeenCalledWith(rateLimitConfigs.auth.verifyEmail);
|
||||
});
|
||||
@@ -315,7 +282,7 @@ describe("authOptions", () => {
|
||||
new Error("Maximum number of requests reached. Please try again later.")
|
||||
);
|
||||
|
||||
const credentials = { token: createToken(mockUserId) };
|
||||
const credentials = { token: "sometoken" };
|
||||
|
||||
await expect(tokenProvider.options.authorize(credentials, {})).rejects.toThrow(
|
||||
"Maximum number of requests reached. Please try again later."
|
||||
@@ -323,32 +290,6 @@ describe("authOptions", () => {
|
||||
|
||||
expect(prisma.user.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should use correct rate limit configuration", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue();
|
||||
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({
|
||||
id: mockUser.id,
|
||||
emailVerified: null,
|
||||
} as any);
|
||||
vi.spyOn(prisma.user, "update").mockResolvedValue({
|
||||
...mockUser,
|
||||
password: mockHashedPassword,
|
||||
backupCodes: null,
|
||||
twoFactorSecret: null,
|
||||
identityProviderAccountId: null,
|
||||
groupId: null,
|
||||
} as any);
|
||||
|
||||
const credentials = { token: createToken(mockUserId) };
|
||||
|
||||
await tokenProvider.options.authorize(credentials, {});
|
||||
|
||||
expect(applyIPRateLimit).toHaveBeenCalledWith({
|
||||
interval: 3600,
|
||||
allowedPerInterval: 10,
|
||||
namespace: "auth:verify",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import type { Account, NextAuthOptions } from "next-auth";
|
||||
import CredentialsProvider from "next-auth/providers/credentials";
|
||||
import { cookies } from "next/headers";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import {
|
||||
CONTROL_HASH,
|
||||
EMAIL_VERIFICATION_DISABLED,
|
||||
ENCRYPTION_KEY,
|
||||
ENTERPRISE_LICENSE_KEY,
|
||||
@@ -21,12 +28,6 @@ import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
|
||||
import { getSSOProviders } from "@/modules/ee/sso/lib/providers";
|
||||
import { handleSsoCallback } from "@/modules/ee/sso/lib/sso-handlers";
|
||||
import type { Account, NextAuthOptions } from "next-auth";
|
||||
import CredentialsProvider from "next-auth/providers/credentials";
|
||||
import { cookies } from "next/headers";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { createBrevoCustomer } from "./brevo";
|
||||
|
||||
export const authOptions: NextAuthOptions = {
|
||||
@@ -70,14 +71,17 @@ export const authOptions: NextAuthOptions = {
|
||||
// bcrypt processes passwords up to 72 bytes, but we limit to 128 characters for security
|
||||
if (credentials.password && credentials.password.length > 128) {
|
||||
if (await shouldLogAuthFailure(identifier)) {
|
||||
logAuthAttempt("password_too_long", "credentials", "password_validation", UNKNOWN_DATA, credentials?.email);
|
||||
logAuthAttempt(
|
||||
"password_too_long",
|
||||
"credentials",
|
||||
"password_validation",
|
||||
UNKNOWN_DATA,
|
||||
credentials?.email
|
||||
);
|
||||
}
|
||||
throw new Error("Invalid credentials");
|
||||
}
|
||||
|
||||
// Use a control hash when user doesn't exist to maintain constant timing.
|
||||
const controlHash = "$2b$12$fzHf9le13Ss9UJ04xzmsjODXpFJxz6vsnupoepF5FiqDECkX2BH5q";
|
||||
|
||||
let user;
|
||||
try {
|
||||
// Perform database lookup
|
||||
@@ -94,7 +98,7 @@ export const authOptions: NextAuthOptions = {
|
||||
|
||||
// Always perform password verification to maintain constant timing. This is important to prevent timing attacks for user enumeration.
|
||||
// Use actual hash if user exists, control hash if user doesn't exist
|
||||
const hashToVerify = user?.password || controlHash;
|
||||
const hashToVerify = user?.password || CONTROL_HASH;
|
||||
const isValid = await verifyPassword(credentials.password, hashToVerify);
|
||||
|
||||
// Now check all conditions after constant-time operations are complete
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
|
||||
import {
|
||||
createAuditIdentifier,
|
||||
hashPassword,
|
||||
@@ -40,19 +40,30 @@ vi.mock("@/lib/constants", () => ({
|
||||
SENTRY_DSN: "test-sentry-dsn",
|
||||
IS_PRODUCTION: true,
|
||||
REDIS_URL: "redis://localhost:6379",
|
||||
ENCRYPTION_KEY: "test-encryption-key",
|
||||
}));
|
||||
|
||||
// Mock cache module
|
||||
const { mockCache } = vi.hoisted(() => ({
|
||||
const { mockCache, mockLogger } = vi.hoisted(() => ({
|
||||
mockCache: {
|
||||
getRedisClient: vi.fn(),
|
||||
},
|
||||
mockLogger: {
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/cache", () => ({
|
||||
cache: mockCache,
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: mockLogger,
|
||||
}));
|
||||
|
||||
// Mock @formbricks/cache
|
||||
vi.mock("@formbricks/cache", () => ({
|
||||
createCacheKey: {
|
||||
@@ -125,6 +136,38 @@ describe("Auth Utils", () => {
|
||||
expect(await verifyPassword(complexPassword, hashedComplex)).toBe(true);
|
||||
expect(await verifyPassword("wrong", hashedComplex)).toBe(false);
|
||||
});
|
||||
|
||||
test("should handle bcrypt errors gracefully and log warning", async () => {
|
||||
// Save the original bcryptjs implementation
|
||||
const originalModule = await import("bcryptjs");
|
||||
|
||||
// Mock bcryptjs to throw an error on compare
|
||||
vi.doMock("bcryptjs", () => ({
|
||||
...originalModule,
|
||||
compare: vi.fn().mockRejectedValue(new Error("Invalid salt version")),
|
||||
hash: originalModule.hash, // Keep hash working
|
||||
}));
|
||||
|
||||
// Re-import the utils module to use the mocked bcryptjs
|
||||
const { verifyPassword: verifyPasswordMocked } = await import("./utils?t=" + Date.now());
|
||||
|
||||
const password = "testPassword";
|
||||
const invalidHash = "invalid-hash-format";
|
||||
|
||||
const result = await verifyPasswordMocked(password, invalidHash);
|
||||
|
||||
// Should return false for security
|
||||
expect(result).toBe(false);
|
||||
|
||||
// Should log warning with correct signature (Pino format: object first, then message)
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
{ error: expect.any(Error) },
|
||||
"Secret verification failed due to invalid hash format"
|
||||
);
|
||||
|
||||
// Restore the module
|
||||
vi.doUnmock("bcryptjs");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Audit Identifier Utils", () => {
|
||||
|
||||
@@ -1,28 +1,19 @@
|
||||
import { cache } from "@/lib/cache";
|
||||
import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
|
||||
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { TAuditAction, TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { compare, hash } from "bcryptjs";
|
||||
import { createHash, randomUUID } from "crypto";
|
||||
import { createCacheKey } from "@formbricks/cache";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { cache } from "@/lib/cache";
|
||||
import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
|
||||
import { hashSecret, verifySecret } from "@/lib/crypto";
|
||||
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { TAuditAction, TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
|
||||
|
||||
export const hashPassword = async (password: string) => {
|
||||
const hashedPassword = await hash(password, 12);
|
||||
return hashedPassword;
|
||||
return await hashSecret(password, 12);
|
||||
};
|
||||
|
||||
export const verifyPassword = async (password: string, hashedPassword: string) => {
|
||||
try {
|
||||
const isValid = await compare(password, hashedPassword);
|
||||
return isValid;
|
||||
} catch (error) {
|
||||
// Log warning for debugging purposes, but don't throw to maintain security
|
||||
logger.warn("Password verification failed due to invalid hash format", { error });
|
||||
// Return false for invalid hashes or other bcrypt errors
|
||||
return false;
|
||||
}
|
||||
return await verifySecret(password, hashedPassword);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -279,7 +270,7 @@ export const shouldLogAuthFailure = async (
|
||||
|
||||
return currentCount % 10 === 0 || timeSinceLastLog > 60000;
|
||||
} catch (error) {
|
||||
logger.warn("Redis rate limiting failed, not logging due to Redis requirement", { error });
|
||||
logger.warn({ error }, "Redis rate limiting failed, not logging due to Redis requirement");
|
||||
// If Redis fails, do not log as Redis is required for audit logs
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { hashString } from "@/lib/hash-string";
|
||||
// Import modules after mocking
|
||||
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { err, ok } from "@formbricks/types/error-handlers";
|
||||
import { hashString } from "@/lib/hash-string";
|
||||
// Import modules after mocking
|
||||
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
|
||||
import { applyIPRateLimit, applyRateLimit, getClientIdentifier } from "./helpers";
|
||||
import { checkRateLimit } from "./rate-limit";
|
||||
|
||||
@@ -67,8 +67,8 @@ describe("helpers", () => {
|
||||
|
||||
await expect(getClientIdentifier()).rejects.toThrow("Failed to hash IP");
|
||||
|
||||
// Verify that the error was logged with proper context
|
||||
expect(logger.error).toHaveBeenCalledWith("Failed to hash IP", { error: originalError });
|
||||
// Verify that the error was logged with proper context (pino 10 format: object first, message second)
|
||||
expect(logger.error).toHaveBeenCalledWith({ error: originalError }, "Failed to hash IP");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { hashString } from "@/lib/hash-string";
|
||||
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TooManyRequestsError } from "@formbricks/types/errors";
|
||||
import { hashString } from "@/lib/hash-string";
|
||||
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
|
||||
import { checkRateLimit } from "./rate-limit";
|
||||
import { type TRateLimitConfig } from "./types/rate-limit";
|
||||
|
||||
@@ -19,7 +19,7 @@ export const getClientIdentifier = async (): Promise<string> => {
|
||||
return hashString(ip);
|
||||
} catch (error) {
|
||||
const errorMessage = "Failed to hash IP";
|
||||
logger.error(errorMessage, { error });
|
||||
logger.error({ error }, errorMessage);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { TTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
@@ -7,6 +6,7 @@ import { TResponse } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import { TUser, TUserLocale } from "@formbricks/types/user";
|
||||
import { TTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
|
||||
import { ResponseFeed } from "./response-feed";
|
||||
|
||||
// Mock the hooks and components
|
||||
@@ -54,7 +54,7 @@ describe("ResponseFeed", () => {
|
||||
welcomeCard: {
|
||||
enabled: false,
|
||||
headline: "",
|
||||
html: "",
|
||||
subheader: "",
|
||||
},
|
||||
displayLimit: null,
|
||||
autoComplete: null,
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { parse } from "csv-parse/sync";
|
||||
import { ArrowUpFromLineIcon, FileUpIcon, PlusIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { isStringMatch } from "@/lib/utils/helper";
|
||||
import { createContactsFromCSVAction } from "@/modules/ee/contacts/actions";
|
||||
@@ -18,12 +24,6 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { StylingTabs } from "@/modules/ui/components/styling-tabs";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { parse } from "csv-parse/sync";
|
||||
import { ArrowUpFromLineIcon, FileUpIcon, PlusIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
|
||||
interface UploadContactsCSVButtonProps {
|
||||
environmentId: string;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import type { Mock } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import {
|
||||
TEnterpriseLicenseDetails,
|
||||
TEnterpriseLicenseFeatures,
|
||||
} from "@/modules/ee/license-check/types/enterprise-license";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import type { Mock } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
|
||||
// Mock declarations must be at the top level
|
||||
vi.mock("@/lib/env", () => ({
|
||||
@@ -59,6 +59,17 @@ vi.mock("@formbricks/database", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const mockLogger = {
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: mockLogger,
|
||||
}));
|
||||
|
||||
// Mock constants as they are used in the original license.ts indirectly
|
||||
vi.mock("@/lib/constants", async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
@@ -80,6 +91,10 @@ describe("License Core Logic", () => {
|
||||
mockCache.set.mockReset();
|
||||
mockCache.del.mockReset();
|
||||
mockCache.withCache.mockReset();
|
||||
mockLogger.error.mockReset();
|
||||
mockLogger.warn.mockReset();
|
||||
mockLogger.info.mockReset();
|
||||
mockLogger.debug.mockReset();
|
||||
|
||||
// Set up default mock implementations for Result types
|
||||
mockCache.get.mockResolvedValue({ ok: true, data: null });
|
||||
@@ -527,4 +542,136 @@ describe("License Core Logic", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error and Warning Logging", () => {
|
||||
test("should log warning when setPreviousResult cache.set fails (line 176-178)", async () => {
|
||||
const { getEnterpriseLicense } = await import("./license");
|
||||
const fetch = (await import("node-fetch")).default as Mock;
|
||||
|
||||
const mockFetchedLicenseDetails: TEnterpriseLicenseDetails = {
|
||||
status: "active",
|
||||
features: {
|
||||
isMultiOrgEnabled: true,
|
||||
contacts: true,
|
||||
projects: 10,
|
||||
whitelabel: true,
|
||||
removeBranding: true,
|
||||
twoFactorAuth: true,
|
||||
sso: true,
|
||||
saml: true,
|
||||
spamProtection: true,
|
||||
ai: false,
|
||||
auditLogs: true,
|
||||
multiLanguageSurveys: true,
|
||||
accessControl: true,
|
||||
quotas: true,
|
||||
},
|
||||
};
|
||||
|
||||
// Mock successful fetch from API
|
||||
mockCache.withCache.mockResolvedValue(mockFetchedLicenseDetails);
|
||||
|
||||
// Mock cache.set to fail when saving previous result
|
||||
mockCache.set.mockResolvedValue({
|
||||
ok: false,
|
||||
error: new Error("Redis connection failed"),
|
||||
});
|
||||
|
||||
await getEnterpriseLicense();
|
||||
|
||||
// Verify that the warning was logged
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
{ error: new Error("Redis connection failed") },
|
||||
"Failed to cache previous result"
|
||||
);
|
||||
});
|
||||
|
||||
test("should log error when trackApiError is called (line 196-203)", async () => {
|
||||
const { getEnterpriseLicense } = await import("./license");
|
||||
const fetch = (await import("node-fetch")).default as Mock;
|
||||
|
||||
// Mock cache.withCache to execute the function (simulating cache miss)
|
||||
mockCache.withCache.mockImplementation(async (fn) => await fn());
|
||||
|
||||
// Mock API response with 500 status
|
||||
const mockStatus = 500;
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: mockStatus,
|
||||
json: async () => ({ error: "Internal Server Error" }),
|
||||
} as any);
|
||||
|
||||
await getEnterpriseLicense();
|
||||
|
||||
// Verify that the API error was logged with correct structure
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
status: mockStatus,
|
||||
code: "API_ERROR",
|
||||
timestamp: expect.any(String),
|
||||
}),
|
||||
expect.stringContaining("License API error:")
|
||||
);
|
||||
});
|
||||
|
||||
test("should log error when trackApiError is called with different status codes (line 196-203)", async () => {
|
||||
const { getEnterpriseLicense } = await import("./license");
|
||||
const fetch = (await import("node-fetch")).default as Mock;
|
||||
|
||||
// Test with 403 Forbidden
|
||||
mockCache.withCache.mockImplementation(async (fn) => await fn());
|
||||
const mockStatus = 403;
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: mockStatus,
|
||||
json: async () => ({ error: "Forbidden" }),
|
||||
} as any);
|
||||
|
||||
await getEnterpriseLicense();
|
||||
|
||||
// Verify that the API error was logged with correct structure
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
status: mockStatus,
|
||||
code: "API_ERROR",
|
||||
timestamp: expect.any(String),
|
||||
}),
|
||||
expect.stringContaining("License API error:")
|
||||
);
|
||||
});
|
||||
|
||||
test("should log info when trackFallbackUsage is called during grace period", async () => {
|
||||
const { getEnterpriseLicense } = await import("./license");
|
||||
const fetch = (await import("node-fetch")).default as Mock;
|
||||
|
||||
const previousTime = new Date(Date.now() - 1 * 24 * 60 * 60 * 1000); // 1 day ago
|
||||
const mockPreviousResult = {
|
||||
active: true,
|
||||
features: { removeBranding: true, projects: 5 },
|
||||
lastChecked: previousTime,
|
||||
version: 1,
|
||||
};
|
||||
|
||||
mockCache.withCache.mockResolvedValue(null);
|
||||
mockCache.get.mockImplementation(async (key) => {
|
||||
if (key.includes(":previous_result")) {
|
||||
return { ok: true, data: mockPreviousResult };
|
||||
}
|
||||
return { ok: true, data: null };
|
||||
});
|
||||
|
||||
fetch.mockResolvedValueOnce({ ok: false, status: 500 } as any);
|
||||
|
||||
await getEnterpriseLicense();
|
||||
|
||||
// Verify that the fallback info was logged
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
fallbackLevel: "grace",
|
||||
timestamp: expect.any(String),
|
||||
}),
|
||||
expect.stringContaining("Using license fallback level: grace")
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
import "server-only";
|
||||
import { cache } from "@/lib/cache";
|
||||
import { env } from "@/lib/env";
|
||||
import { hashString } from "@/lib/hash-string";
|
||||
import {
|
||||
TEnterpriseLicenseDetails,
|
||||
TEnterpriseLicenseFeatures,
|
||||
} from "@/modules/ee/license-check/types/enterprise-license";
|
||||
import { HttpsProxyAgent } from "https-proxy-agent";
|
||||
import fetch from "node-fetch";
|
||||
import { cache as reactCache } from "react";
|
||||
@@ -13,6 +6,13 @@ import { z } from "zod";
|
||||
import { createCacheKey } from "@formbricks/cache";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { cache } from "@/lib/cache";
|
||||
import { env } from "@/lib/env";
|
||||
import { hashString } from "@/lib/hash-string";
|
||||
import {
|
||||
TEnterpriseLicenseDetails,
|
||||
TEnterpriseLicenseFeatures,
|
||||
} from "@/modules/ee/license-check/types/enterprise-license";
|
||||
|
||||
// Configuration
|
||||
const CONFIG = {
|
||||
@@ -154,7 +154,7 @@ const getPreviousResult = async (): Promise<TPreviousResult> => {
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Failed to get previous result from cache", { error });
|
||||
logger.error({ error }, "Failed to get previous result from cache");
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -174,27 +174,33 @@ const setPreviousResult = async (previousResult: TPreviousResult) => {
|
||||
CONFIG.CACHE.PREVIOUS_RESULT_TTL_MS
|
||||
);
|
||||
if (!result.ok) {
|
||||
logger.warn("Failed to cache previous result", { error: result.error });
|
||||
logger.warn({ error: result.error }, "Failed to cache previous result");
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Failed to set previous result in cache", { error });
|
||||
logger.error({ error }, "Failed to set previous result in cache");
|
||||
}
|
||||
};
|
||||
|
||||
// Monitoring functions
|
||||
const trackFallbackUsage = (level: FallbackLevel) => {
|
||||
logger.info(`Using license fallback level: ${level}`, {
|
||||
fallbackLevel: level,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
logger.info(
|
||||
{
|
||||
fallbackLevel: level,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
`Using license fallback level: ${level}`
|
||||
);
|
||||
};
|
||||
|
||||
const trackApiError = (error: LicenseApiError) => {
|
||||
logger.error(`License API error: ${error.message}`, {
|
||||
status: error.status,
|
||||
code: error.code,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
logger.error(
|
||||
{
|
||||
status: error.status,
|
||||
code: error.code,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
`License API error: ${error.message}`
|
||||
);
|
||||
};
|
||||
|
||||
// Validation functions
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { extractLanguageCodes, isLabelValidForAllLanguages } from "@/lib/i18n/utils";
|
||||
import { md } from "@/lib/markdownIt";
|
||||
import { recallToHeadline } from "@/lib/utils/recall";
|
||||
import { Editor } from "@/modules/ui/components/editor";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import DOMPurify from "dompurify";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useMemo } from "react";
|
||||
import type { TI18nString, TSurvey } from "@formbricks/types/surveys/types";
|
||||
import type { TI18nString, TSurvey, TSurveyLanguage } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent, isValidHTML } from "@formbricks/types/surveys/validation";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { md } from "@/lib/markdownIt";
|
||||
import { recallToHeadline } from "@/lib/utils/recall";
|
||||
import { isLabelValidForAllLanguages } from "@/modules/survey/editor/lib/validation";
|
||||
import { Editor } from "@/modules/ui/components/editor";
|
||||
import { LanguageIndicator } from "./language-indicator";
|
||||
|
||||
interface LocalizedEditorProps {
|
||||
@@ -24,17 +24,23 @@ interface LocalizedEditorProps {
|
||||
firstRender: boolean;
|
||||
setFirstRender?: Dispatch<SetStateAction<boolean>>;
|
||||
locale: TUserLocale;
|
||||
questionId: string;
|
||||
isCard?: boolean; // Flag to indicate if this is a welcome/ending card
|
||||
autoFocus?: boolean;
|
||||
isExternalUrlsAllowed?: boolean;
|
||||
}
|
||||
|
||||
const checkIfValueIsIncomplete = (
|
||||
id: string,
|
||||
isInvalid: boolean,
|
||||
surveyLanguageCodes: string[],
|
||||
surveyLanguageCodes: TSurveyLanguage[],
|
||||
value?: TI18nString
|
||||
) => {
|
||||
const labelIds = ["subheader"];
|
||||
const labelIds = ["subheader", "headline", "html"];
|
||||
if (value === undefined) return false;
|
||||
const isDefaultIncomplete = labelIds.includes(id) ? value.default.trim() !== "" : false;
|
||||
const isDefaultIncomplete = labelIds.includes(id)
|
||||
? getTextContent(value.default ?? "").trim() !== ""
|
||||
: false;
|
||||
return isInvalid && !isLabelValidForAllLanguages(value, surveyLanguageCodes) && isDefaultIncomplete;
|
||||
};
|
||||
|
||||
@@ -50,40 +56,89 @@ export function LocalizedEditor({
|
||||
firstRender,
|
||||
setFirstRender,
|
||||
locale,
|
||||
}: LocalizedEditorProps) {
|
||||
questionId,
|
||||
isCard,
|
||||
autoFocus,
|
||||
isExternalUrlsAllowed,
|
||||
}: Readonly<LocalizedEditorProps>) {
|
||||
const { t } = useTranslate();
|
||||
const surveyLanguageCodes = useMemo(
|
||||
() => extractLanguageCodes(localSurvey.languages),
|
||||
[localSurvey.languages]
|
||||
);
|
||||
|
||||
const isInComplete = useMemo(
|
||||
() => checkIfValueIsIncomplete(id, isInvalid, surveyLanguageCodes, value),
|
||||
[id, isInvalid, surveyLanguageCodes, value]
|
||||
() => checkIfValueIsIncomplete(id, isInvalid, localSurvey.languages, value),
|
||||
[id, isInvalid, localSurvey.languages, value]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<Editor
|
||||
id={id}
|
||||
disableLists
|
||||
excludedToolbarItems={["blockType"]}
|
||||
firstRender={firstRender}
|
||||
getText={() => md.render(value ? (value[selectedLanguageCode] ?? "") : "")}
|
||||
key={`${questionIdx}-${selectedLanguageCode}`}
|
||||
autoFocus={autoFocus}
|
||||
getText={() => {
|
||||
const text = value ? (value[selectedLanguageCode] ?? "") : "";
|
||||
let html = md.render(text);
|
||||
|
||||
// For backwards compatibility: wrap plain text headlines in <strong> tags
|
||||
// This ensures old surveys maintain semibold styling when converted to HTML
|
||||
if (id === "headline" && text && !isValidHTML(text)) {
|
||||
// Use [\s\S]*? to match any character including newlines
|
||||
html = html.replaceAll(/<p>([\s\S]*?)<\/p>/g, "<p><strong>$1</strong></p>");
|
||||
}
|
||||
|
||||
return html;
|
||||
}}
|
||||
key={`${questionId}-${id}-${selectedLanguageCode}`}
|
||||
setFirstRender={setFirstRender}
|
||||
setText={(v: string) => {
|
||||
if (localSurvey.questions[questionIdx] || questionIdx === -1) {
|
||||
const translatedHtml = {
|
||||
...value,
|
||||
[selectedLanguageCode]: v,
|
||||
};
|
||||
if (questionIdx === -1) {
|
||||
// welcome card
|
||||
updateQuestion({ html: translatedHtml });
|
||||
let sanitizedContent = v;
|
||||
if (!isExternalUrlsAllowed) {
|
||||
sanitizedContent = v.replaceAll(/<a[^>]*>(.*?)<\/a>/gi, "$1");
|
||||
}
|
||||
|
||||
// Check if the question still exists before updating
|
||||
const currentQuestion = localSurvey.questions[questionIdx];
|
||||
|
||||
// if this is a card, we wanna check if the card exists in the localSurvey
|
||||
if (isCard) {
|
||||
const isWelcomeCard = questionIdx === -1;
|
||||
const isEndingCard = questionIdx >= localSurvey.questions.length;
|
||||
|
||||
// For ending cards, check if the field exists before updating
|
||||
if (isEndingCard) {
|
||||
const ending = localSurvey.endings.find((ending) => ending.id === questionId);
|
||||
// If the field doesn't exist on the ending card, don't create it
|
||||
if (!ending || ending[id] === undefined) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// For welcome cards, check if it exists
|
||||
if (isWelcomeCard && !localSurvey.welcomeCard) {
|
||||
return;
|
||||
}
|
||||
updateQuestion(questionIdx, { html: translatedHtml });
|
||||
|
||||
const translatedContent = {
|
||||
...value,
|
||||
[selectedLanguageCode]: sanitizedContent,
|
||||
};
|
||||
updateQuestion({ [id]: translatedContent });
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentQuestion && currentQuestion[id] !== undefined) {
|
||||
const translatedContent = {
|
||||
...value,
|
||||
[selectedLanguageCode]: sanitizedContent,
|
||||
};
|
||||
updateQuestion(questionIdx, { [id]: translatedContent });
|
||||
}
|
||||
}}
|
||||
localSurvey={localSurvey}
|
||||
questionId={questionId}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
{localSurvey.languages.length > 1 && (
|
||||
<div>
|
||||
@@ -98,14 +153,9 @@ export function LocalizedEditor({
|
||||
{value && selectedLanguageCode !== "default" && value.default ? (
|
||||
<div className="mt-1 flex text-xs text-gray-500">
|
||||
<strong>{t("environments.project.languages.translate")}:</strong>
|
||||
<span
|
||||
className="fb-htmlbody ml-1" // styles are in global.css
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: DOMPurify.sanitize(
|
||||
recallToHeadline(value, localSurvey, false, "default").default ?? ""
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<span className="ml-1">
|
||||
{getTextContent(recallToHeadline(value, localSurvey, false, "default").default ?? "")}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
+6
-7
@@ -1,15 +1,15 @@
|
||||
import {
|
||||
removeOrganizationEmailLogoUrlAction,
|
||||
sendTestEmailAction,
|
||||
updateOrganizationEmailLogoUrlAction,
|
||||
} from "@/modules/ee/whitelabel/email-customization/actions";
|
||||
import { handleFileUpload } from "@/modules/storage/file-upload";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import {
|
||||
removeOrganizationEmailLogoUrlAction,
|
||||
sendTestEmailAction,
|
||||
updateOrganizationEmailLogoUrlAction,
|
||||
} from "@/modules/ee/whitelabel/email-customization/actions";
|
||||
import { handleFileUpload } from "@/modules/storage/file-upload";
|
||||
import { EmailCustomizationSettings } from "./email-customization-settings";
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
@@ -107,7 +107,6 @@ describe("EmailCustomizationSettings", () => {
|
||||
const saveButton = screen.getAllByRole("button", { name: /save/i });
|
||||
await user.click(saveButton[0]);
|
||||
|
||||
// The component calls `uploadFile` then `updateOrganizationEmailLogoUrlAction`
|
||||
expect(handleFileUpload).toHaveBeenCalledWith(testFile, "env-123", ["jpeg", "png", "jpg", "webp"]);
|
||||
expect(updateOrganizationEmailLogoUrlAction).toHaveBeenCalledWith({
|
||||
organizationId: "org-123",
|
||||
|
||||
+9
-9
@@ -1,5 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { RepeatIcon, Trash2Icon } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import React, { useRef, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TAllowedFileExtension } from "@formbricks/types/storage";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
@@ -15,15 +24,6 @@ import { Uploader } from "@/modules/ui/components/file-input/components/uploader
|
||||
import { showStorageNotConfiguredToast } from "@/modules/ui/components/storage-not-configured-toast/lib/utils";
|
||||
import { Muted, P, Small } from "@/modules/ui/components/typography";
|
||||
import { ModalButton, UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { RepeatIcon, Trash2Icon } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import React, { useRef, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TAllowedFileExtension } from "@formbricks/types/storage";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
|
||||
const allowedFileExtensions: TAllowedFileExtension[] = ["jpeg", "png", "jpg", "webp"];
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Container } from "@react-email/components";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { Text } from "@react-email/components";
|
||||
|
||||
interface QuestionHeaderProps {
|
||||
headline: string;
|
||||
@@ -10,11 +10,13 @@ interface QuestionHeaderProps {
|
||||
export function QuestionHeader({ headline, subheader, className }: QuestionHeaderProps): React.JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<Text className={cn("text-question-color m-0 block text-base font-semibold leading-6", className)}>
|
||||
{headline}
|
||||
</Text>
|
||||
<Container className={cn("text-question-color m-0 block text-base font-semibold leading-6", className)}>
|
||||
<div dangerouslySetInnerHTML={{ __html: headline }} />
|
||||
</Container>
|
||||
{subheader && (
|
||||
<Text className="text-question-color m-0 block p-0 text-sm font-normal leading-6">{subheader}</Text>
|
||||
<Container className="text-question-color m-0 mt-2 block p-0 text-sm font-normal leading-6">
|
||||
<div dangerouslySetInnerHTML={{ __html: subheader }} />
|
||||
</Container>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,3 @@
|
||||
import { cn } from "@/lib/cn";
|
||||
import { WEBAPP_URL } from "@/lib/constants";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { COLOR_DEFAULTS } from "@/lib/styling/constants";
|
||||
import { isLight, mixColor } from "@/lib/utils/colors";
|
||||
import { parseRecallInfo } from "@/lib/utils/recall";
|
||||
import { RatingSmiley } from "@/modules/analysis/components/RatingSmiley";
|
||||
import {
|
||||
Column,
|
||||
Container,
|
||||
@@ -21,6 +14,13 @@ import { TFnType } from "@tolgee/react";
|
||||
import { CalendarDaysIcon, UploadIcon } from "lucide-react";
|
||||
import React from "react";
|
||||
import { type TSurvey, TSurveyQuestionTypeEnum, type TSurveyStyling } from "@formbricks/types/surveys/types";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { WEBAPP_URL } from "@/lib/constants";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { COLOR_DEFAULTS } from "@/lib/styling/constants";
|
||||
import { isLight, mixColor } from "@/lib/utils/colors";
|
||||
import { parseRecallInfo } from "@/lib/utils/recall";
|
||||
import { RatingSmiley } from "@/modules/analysis/components/RatingSmiley";
|
||||
import { getNPSOptionColor, getRatingNumberOptionColor } from "../lib/utils";
|
||||
import { QuestionHeader } from "./email-question-header";
|
||||
|
||||
@@ -94,16 +94,7 @@ export async function PreviewEmailTemplate({
|
||||
case TSurveyQuestionTypeEnum.Consent:
|
||||
return (
|
||||
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
|
||||
<Text className="text-question-color m-0 block text-base font-semibold leading-6">{headline}</Text>
|
||||
<Container className="text-question-color m-0 text-sm font-normal leading-6">
|
||||
<div
|
||||
className="m-0 p-0"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: getLocalizedValue(firstQuestion.html, defaultLanguageCode) || "",
|
||||
}}
|
||||
/>
|
||||
</Container>
|
||||
|
||||
<QuestionHeader headline={headline} subheader={subheader} className="mr-8" />
|
||||
<Container className="border-input-border-color bg-input-color rounded-custom m-0 mt-4 block w-full max-w-none border border-solid p-4 font-medium text-slate-800">
|
||||
<Text className="text-question-color m-0 inline-block">
|
||||
{getLocalizedValue(firstQuestion.label, defaultLanguageCode)}
|
||||
@@ -181,16 +172,7 @@ export async function PreviewEmailTemplate({
|
||||
case TSurveyQuestionTypeEnum.CTA:
|
||||
return (
|
||||
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
|
||||
<Text className="text-question-color m-0 block text-base font-semibold leading-6">{headline}</Text>
|
||||
<Container className="text-question-color ml-0 mt-2 text-sm font-normal leading-6">
|
||||
<div
|
||||
className="m-0 p-0"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: getLocalizedValue(firstQuestion.html, defaultLanguageCode) || "",
|
||||
}}
|
||||
/>
|
||||
</Container>
|
||||
|
||||
<QuestionHeader headline={headline} subheader={subheader} className="mr-8" />
|
||||
<Container className="mx-0 mt-4 max-w-none">
|
||||
{!firstQuestion.required && (
|
||||
<EmailButton
|
||||
|
||||
@@ -31,6 +31,11 @@ vi.mock("@tolgee/react", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock the timeSince function
|
||||
vi.mock("@/lib/time", () => ({
|
||||
timeSince: vi.fn(() => "2 days ago"),
|
||||
}));
|
||||
|
||||
// Mock the Dialog components
|
||||
vi.mock("@/modules/ui/components/dialog", () => ({
|
||||
Dialog: ({ children, open, onOpenChange }: any) =>
|
||||
@@ -323,4 +328,40 @@ describe("EditAPIKeys", () => {
|
||||
expect(writeText).toHaveBeenCalledWith("test-api-key-123");
|
||||
expect(toast.success).toHaveBeenCalledWith("environments.project.api_keys.api_key_copied_to_clipboard");
|
||||
});
|
||||
|
||||
test("displays 'secret' when no actualKey is provided", () => {
|
||||
render(<EditAPIKeys {...defaultProps} />);
|
||||
|
||||
// The API keys in mockApiKeys don't have actualKey, so they should display "secret"
|
||||
expect(screen.getAllByText("environments.project.api_keys.secret")).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("stops propagation when clicking copy button", async () => {
|
||||
const writeText = vi.fn();
|
||||
Object.assign(navigator, {
|
||||
clipboard: {
|
||||
writeText,
|
||||
},
|
||||
});
|
||||
|
||||
const apiKeyWithActual = {
|
||||
...mockApiKeys[0],
|
||||
actualKey: "test-api-key-123",
|
||||
} as TApiKeyWithEnvironmentPermission & { actualKey: string };
|
||||
|
||||
render(<EditAPIKeys {...defaultProps} apiKeys={[apiKeyWithActual]} />);
|
||||
|
||||
const copyButton = screen.getByTestId("copy-button");
|
||||
await userEvent.click(copyButton);
|
||||
|
||||
// View permission modal should not open when clicking copy button
|
||||
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("displays created at time for each API key", () => {
|
||||
render(<EditAPIKeys {...defaultProps} />);
|
||||
|
||||
// Should show "2 days ago" for both API keys (mocked)
|
||||
expect(screen.getAllByText("2 days ago")).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { ApiKeyPermission } from "@prisma/client";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { FilesIcon, TrashIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { TOrganizationAccess } from "@formbricks/types/api-key";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { timeSince } from "@/lib/time";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { ViewPermissionModal } from "@/modules/organization/settings/api-keys/components/view-permission-modal";
|
||||
@@ -10,13 +17,6 @@ import {
|
||||
} from "@/modules/organization/settings/api-keys/types/api-keys";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import { ApiKeyPermission } from "@prisma/client";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { FilesIcon, TrashIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { TOrganizationAccess } from "@formbricks/types/api-key";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { createApiKeyAction, deleteApiKeyAction, updateApiKeyAction } from "../actions";
|
||||
import { AddApiKeyModal } from "./add-api-key-modal";
|
||||
|
||||
@@ -133,11 +133,11 @@ export const EditAPIKeys = ({ organizationId, apiKeys, locale, isReadOnly, proje
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<span>{apiKey}</span>
|
||||
<div className="copyApiKeyIcon">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="whitespace-pre-line break-all">{apiKey}</span>
|
||||
<div className="copyApiKeyIcon flex-shrink-0">
|
||||
<FilesIcon
|
||||
className="mx-2 h-4 w-4 cursor-pointer"
|
||||
className="h-4 w-4 cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
copyToClipboard();
|
||||
@@ -185,7 +185,7 @@ export const EditAPIKeys = ({ organizationId, apiKeys, locale, isReadOnly, proje
|
||||
data-testid="api-key-row"
|
||||
key={apiKey.id}>
|
||||
<div className="col-span-4 font-semibold sm:col-span-2">{apiKey.label}</div>
|
||||
<div className="col-span-4 hidden sm:col-span-5 sm:block">
|
||||
<div className="col-span-4 hidden pr-4 sm:col-span-5 sm:block">
|
||||
<ApiKeyDisplay apiKey={apiKey.actualKey} />
|
||||
</div>
|
||||
<div className="col-span-4 sm:col-span-2">
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
import "server-only";
|
||||
import { ApiKey, ApiKeyPermission, Prisma } from "@prisma/client";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TOrganizationAccess } from "@formbricks/types/api-key";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { CONTROL_HASH } from "@/lib/constants";
|
||||
import { hashSecret, hashSha256, parseApiKeyV2, verifySecret } from "@/lib/crypto";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import {
|
||||
TApiKeyCreateInput,
|
||||
TApiKeyUpdateInput,
|
||||
TApiKeyWithEnvironmentAndProject,
|
||||
TApiKeyWithEnvironmentPermission,
|
||||
ZApiKeyCreateInput,
|
||||
} from "@/modules/organization/settings/api-keys/types/api-keys";
|
||||
import { ApiKey, ApiKeyPermission, Prisma } from "@prisma/client";
|
||||
import { createHash, randomBytes } from "crypto";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TOrganizationAccess } from "@formbricks/types/api-key";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
|
||||
export const getApiKeysWithEnvironmentPermissions = reactCache(
|
||||
async (organizationId: string): Promise<TApiKeyWithEnvironmentPermission[]> => {
|
||||
@@ -47,15 +51,10 @@ export const getApiKeysWithEnvironmentPermissions = reactCache(
|
||||
);
|
||||
|
||||
// Get API key with its permissions from a raw API key
|
||||
export const getApiKeyWithPermissions = reactCache(async (apiKey: string) => {
|
||||
const hashedKey = hashApiKey(apiKey);
|
||||
try {
|
||||
// Look up the API key in the new structure
|
||||
const apiKeyData = await prisma.apiKey.findUnique({
|
||||
where: {
|
||||
hashedKey,
|
||||
},
|
||||
include: {
|
||||
export const getApiKeyWithPermissions = reactCache(
|
||||
async (apiKey: string): Promise<TApiKeyWithEnvironmentAndProject | null> => {
|
||||
try {
|
||||
const includeQuery = {
|
||||
apiKeyEnvironments: {
|
||||
include: {
|
||||
environment: {
|
||||
@@ -70,30 +69,68 @@ export const getApiKeyWithPermissions = reactCache(async (apiKey: string) => {
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (!apiKeyData) return null;
|
||||
// Try v2 format first (fbk_{secret})
|
||||
const v2Parsed = parseApiKeyV2(apiKey);
|
||||
|
||||
// Update the last used timestamp
|
||||
await prisma.apiKey.update({
|
||||
where: {
|
||||
id: apiKeyData.id,
|
||||
},
|
||||
data: {
|
||||
lastUsedAt: new Date(),
|
||||
},
|
||||
});
|
||||
let apiKeyData;
|
||||
|
||||
return apiKeyData;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
if (v2Parsed) {
|
||||
// New v2 format (fbk_{secret}): Hybrid approach
|
||||
// Step 1: Fast SHA-256 lookup by indexed lookupHash
|
||||
const lookupHash = hashSha256(v2Parsed.secret);
|
||||
apiKeyData = await prisma.apiKey.findUnique({
|
||||
where: { lookupHash },
|
||||
include: includeQuery,
|
||||
});
|
||||
|
||||
// Step 2: Security verification with bcrypt
|
||||
// Always perform bcrypt verification to prevent timing attacks
|
||||
// Use a control hash when API key doesn't exist to maintain constant timing
|
||||
const hashToVerify = apiKeyData?.hashedKey || CONTROL_HASH;
|
||||
const isValid = await verifySecret(v2Parsed.secret, hashToVerify);
|
||||
|
||||
if (!apiKeyData || !isValid) {
|
||||
if (apiKeyData && !isValid) {
|
||||
logger.warn({ apiKeyId: apiKeyData.id }, "API key bcrypt verification failed");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
// Legacy format: compute SHA-256 and lookup by hashedKey
|
||||
const hashedKey = hashSha256(apiKey);
|
||||
apiKeyData = await prisma.apiKey.findFirst({
|
||||
where: { hashedKey: hashedKey },
|
||||
include: includeQuery,
|
||||
});
|
||||
|
||||
if (!apiKeyData) return null;
|
||||
}
|
||||
|
||||
if (!apiKeyData.lastUsedAt || apiKeyData.lastUsedAt <= new Date(Date.now() - 1000 * 30)) {
|
||||
// Fire-and-forget: update lastUsedAt in the background without blocking the response
|
||||
// Update on first use (null) or if last used more than 30 seconds ago
|
||||
prisma.apiKey
|
||||
.update({
|
||||
where: { id: apiKeyData.id },
|
||||
data: { lastUsedAt: new Date() },
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error({ error }, "Failed to update API key usage");
|
||||
});
|
||||
}
|
||||
|
||||
return apiKeyData;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
export const deleteApiKey = async (id: string): Promise<ApiKey | null> => {
|
||||
validateInputs([id, ZId]);
|
||||
@@ -115,8 +152,6 @@ export const deleteApiKey = async (id: string): Promise<ApiKey | null> => {
|
||||
}
|
||||
};
|
||||
|
||||
const hashApiKey = (key: string): string => createHash("sha256").update(key).digest("hex");
|
||||
|
||||
export const createApiKey = async (
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
@@ -127,8 +162,15 @@ export const createApiKey = async (
|
||||
): Promise<TApiKeyWithEnvironmentPermission & { actualKey: string }> => {
|
||||
validateInputs([organizationId, ZId], [apiKeyData, ZApiKeyCreateInput]);
|
||||
try {
|
||||
const key = randomBytes(16).toString("hex");
|
||||
const hashedKey = hashApiKey(key);
|
||||
// Generate a secure random secret (32 bytes base64url)
|
||||
const secret = randomBytes(32).toString("base64url");
|
||||
|
||||
// Hybrid approach for security + performance:
|
||||
// 1. SHA-256 lookup hash
|
||||
const lookupHash = hashSha256(secret);
|
||||
|
||||
// 2. bcrypt hash
|
||||
const hashedKey = await hashSecret(secret, 12);
|
||||
|
||||
// Extract environmentPermissions from apiKeyData
|
||||
const { environmentPermissions, organizationAccess, ...apiKeyDataWithoutPermissions } = apiKeyData;
|
||||
@@ -138,6 +180,7 @@ export const createApiKey = async (
|
||||
data: {
|
||||
...apiKeyDataWithoutPermissions,
|
||||
hashedKey,
|
||||
lookupHash,
|
||||
createdBy: userId,
|
||||
organization: { connect: { id: organizationId } },
|
||||
organizationAccess,
|
||||
@@ -157,7 +200,8 @@ export const createApiKey = async (
|
||||
},
|
||||
});
|
||||
|
||||
return { ...result, actualKey: key };
|
||||
// Return the new v2 format: fbk_{secret}
|
||||
return { ...result, actualKey: `fbk_${secret}` };
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
|
||||
@@ -14,7 +14,8 @@ import {
|
||||
const mockApiKey: ApiKey = {
|
||||
id: "apikey123",
|
||||
label: "Test API Key",
|
||||
hashedKey: "hashed_key_value",
|
||||
hashedKey: "$2a$12$mockBcryptHashFortestSecret123", // bcrypt hash for hybrid approach
|
||||
lookupHash: "sha256LookupHashValue",
|
||||
createdAt: new Date(),
|
||||
createdBy: "user123",
|
||||
organizationId: "org123",
|
||||
@@ -51,13 +52,43 @@ vi.mock("@formbricks/database", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("crypto", () => ({
|
||||
randomBytes: () => ({
|
||||
toString: () => "generated_key",
|
||||
vi.mock("crypto", async () => {
|
||||
const actual = await vi.importActual<typeof import("crypto")>("crypto");
|
||||
return {
|
||||
...actual,
|
||||
randomBytes: vi.fn((_size: number) => ({
|
||||
toString: (_encoding: string) => "testSecret123",
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
hashSha256: vi.fn((input: string) => {
|
||||
// Return different hashes for lookup vs legacy
|
||||
if (input === "testSecret123") {
|
||||
return "sha256LookupHashValue";
|
||||
}
|
||||
return "sha256HashValue";
|
||||
}),
|
||||
createHash: () => ({
|
||||
update: vi.fn().mockReturnThis(),
|
||||
digest: vi.fn().mockReturnValue("hashed_key_value"),
|
||||
parseApiKeyV2: vi.fn((key: string) => {
|
||||
if (key.startsWith("fbk_")) {
|
||||
const secret = key.slice(4);
|
||||
return { secret };
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
hashSecret: vi.fn(async (secret: string, _cost: number) => {
|
||||
// Return a mock bcrypt hash
|
||||
return `$2a$12$mockBcryptHashFor${secret}`;
|
||||
}),
|
||||
verifySecret: vi.fn(async (secret: string, hash: string) => {
|
||||
// Control hash for timing attack prevention (should always return false)
|
||||
const controlHash = "$2b$12$fzHf9le13Ss9UJ04xzmsjODXpFJxz6vsnupoepF5FiqDECkX2BH5q";
|
||||
if (hash === controlHash) {
|
||||
return false;
|
||||
}
|
||||
// Simple mock verification - just check if hash contains the secret
|
||||
return hash.includes(secret) || hash === "sha256HashValue";
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -68,7 +99,7 @@ describe("API Key Management", () => {
|
||||
|
||||
describe("getApiKeysWithEnvironmentPermissions", () => {
|
||||
test("retrieves API keys successfully", async () => {
|
||||
vi.mocked(prisma.apiKey.findMany).mockResolvedValueOnce([mockApiKeyWithEnvironments]);
|
||||
vi.mocked(prisma.apiKey.findMany).mockResolvedValueOnce([mockApiKeyWithEnvironments] as any);
|
||||
|
||||
const result = await getApiKeysWithEnvironmentPermissions("clj28r6va000409j3ep7h8xzk");
|
||||
|
||||
@@ -115,52 +146,188 @@ describe("API Key Management", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("returns api key with permissions if found", async () => {
|
||||
vi.mocked(prisma.apiKey.findUnique).mockResolvedValue({ ...mockApiKey });
|
||||
const result = await getApiKeyWithPermissions("apikey123");
|
||||
test("returns api key with permissions for v2 format (fbk_secret) but does NOT update lastUsedAt when within 30s", async () => {
|
||||
const { verifySecret } = await import("@/lib/crypto");
|
||||
const recentDate = new Date(Date.now() - 1000 * 10); // 10 seconds ago (too recent)
|
||||
vi.mocked(prisma.apiKey.findUnique).mockResolvedValueOnce({
|
||||
...mockApiKey,
|
||||
lastUsedAt: recentDate,
|
||||
} as any);
|
||||
|
||||
const result = await getApiKeyWithPermissions("fbk_testSecret123");
|
||||
|
||||
expect(result).toMatchObject({
|
||||
...mockApiKey,
|
||||
lastUsedAt: recentDate,
|
||||
});
|
||||
expect(prisma.apiKey.findUnique).toHaveBeenCalledWith({
|
||||
where: { hashedKey: "hashed_key_value" },
|
||||
include: {
|
||||
apiKeyEnvironments: {
|
||||
include: {
|
||||
environment: {
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
where: { lookupHash: "sha256LookupHashValue" },
|
||||
include: expect.any(Object),
|
||||
});
|
||||
// Verify hybrid approach: bcrypt verification is called
|
||||
expect(verifySecret).toHaveBeenCalledWith("testSecret123", mockApiKey.hashedKey);
|
||||
// Should NOT update because lastUsedAt is too recent (< 30s)
|
||||
expect(prisma.apiKey.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns api key with permissions for v2 format and DOES update lastUsedAt when null (first use)", async () => {
|
||||
const { verifySecret } = await import("@/lib/crypto");
|
||||
const mockUpdatePromise = {
|
||||
catch: vi.fn().mockReturnThis(),
|
||||
};
|
||||
vi.mocked(prisma.apiKey.findUnique).mockResolvedValueOnce({
|
||||
...mockApiKey,
|
||||
lastUsedAt: null,
|
||||
} as any);
|
||||
vi.mocked(prisma.apiKey.update).mockReturnValueOnce(mockUpdatePromise as any);
|
||||
|
||||
const result = await getApiKeyWithPermissions("fbk_testSecret123");
|
||||
|
||||
expect(result).toMatchObject({
|
||||
...mockApiKey,
|
||||
lastUsedAt: null,
|
||||
});
|
||||
expect(prisma.apiKey.findUnique).toHaveBeenCalledWith({
|
||||
where: { lookupHash: "sha256LookupHashValue" },
|
||||
include: expect.any(Object),
|
||||
});
|
||||
// Verify hybrid approach: bcrypt verification is called
|
||||
expect(verifySecret).toHaveBeenCalledWith("testSecret123", mockApiKey.hashedKey);
|
||||
// SHOULD update because lastUsedAt is null (first use)
|
||||
expect(prisma.apiKey.update).toHaveBeenCalledWith({
|
||||
where: { id: "apikey123" },
|
||||
data: { lastUsedAt: expect.any(Date) },
|
||||
});
|
||||
});
|
||||
|
||||
test("returns null if api key not found", async () => {
|
||||
test("returns api key with permissions for v2 format and DOES update lastUsedAt when older than 30s", async () => {
|
||||
const { verifySecret } = await import("@/lib/crypto");
|
||||
const oldDate = new Date(Date.now() - 1000 * 60); // 60 seconds ago (old enough)
|
||||
const mockUpdatePromise = {
|
||||
catch: vi.fn().mockReturnThis(),
|
||||
};
|
||||
vi.mocked(prisma.apiKey.findUnique).mockResolvedValueOnce({
|
||||
...mockApiKey,
|
||||
lastUsedAt: oldDate,
|
||||
} as any);
|
||||
vi.mocked(prisma.apiKey.update).mockReturnValueOnce(mockUpdatePromise as any);
|
||||
|
||||
const result = await getApiKeyWithPermissions("fbk_testSecret123");
|
||||
|
||||
expect(result).toMatchObject({
|
||||
...mockApiKey,
|
||||
lastUsedAt: oldDate,
|
||||
});
|
||||
expect(prisma.apiKey.findUnique).toHaveBeenCalledWith({
|
||||
where: { lookupHash: "sha256LookupHashValue" },
|
||||
include: expect.any(Object),
|
||||
});
|
||||
// Verify hybrid approach: bcrypt verification is called
|
||||
expect(verifySecret).toHaveBeenCalledWith("testSecret123", mockApiKey.hashedKey);
|
||||
// SHOULD update because lastUsedAt is old enough (> 30s)
|
||||
expect(prisma.apiKey.update).toHaveBeenCalledWith({
|
||||
where: { id: "apikey123" },
|
||||
data: { lastUsedAt: expect.any(Date) },
|
||||
});
|
||||
});
|
||||
|
||||
test("returns api key with permissions for v1 legacy format but does NOT update lastUsedAt when within 30s", async () => {
|
||||
const recentDate = new Date(Date.now() - 1000 * 20); // 20 seconds ago (too recent)
|
||||
vi.mocked(prisma.apiKey.findFirst).mockResolvedValueOnce({
|
||||
...mockApiKey,
|
||||
lastUsedAt: recentDate,
|
||||
} as any);
|
||||
|
||||
const result = await getApiKeyWithPermissions("legacy-api-key");
|
||||
|
||||
expect(result).toMatchObject({
|
||||
...mockApiKey,
|
||||
lastUsedAt: recentDate,
|
||||
});
|
||||
expect(prisma.apiKey.findFirst).toHaveBeenCalledWith({
|
||||
where: { hashedKey: "sha256HashValue" },
|
||||
include: expect.any(Object),
|
||||
});
|
||||
// Should NOT update because lastUsedAt is too recent (< 30s)
|
||||
expect(prisma.apiKey.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns api key and DOES update lastUsedAt for legacy format when older than 30s", async () => {
|
||||
const oldDate = new Date(Date.now() - 1000 * 45); // 45 seconds ago (old enough)
|
||||
const mockUpdatePromise = {
|
||||
catch: vi.fn().mockReturnThis(),
|
||||
};
|
||||
vi.mocked(prisma.apiKey.findFirst).mockResolvedValueOnce({
|
||||
...mockApiKey,
|
||||
lastUsedAt: oldDate,
|
||||
} as any);
|
||||
vi.mocked(prisma.apiKey.update).mockReturnValueOnce(mockUpdatePromise as any);
|
||||
|
||||
const result = await getApiKeyWithPermissions("legacy-api-key");
|
||||
|
||||
expect(result).toMatchObject({
|
||||
...mockApiKey,
|
||||
lastUsedAt: oldDate,
|
||||
});
|
||||
expect(prisma.apiKey.findFirst).toHaveBeenCalledWith({
|
||||
where: { hashedKey: "sha256HashValue" },
|
||||
include: expect.any(Object),
|
||||
});
|
||||
// SHOULD update because lastUsedAt is old enough (> 30s)
|
||||
expect(prisma.apiKey.update).toHaveBeenCalledWith({
|
||||
where: { id: "apikey123" },
|
||||
data: { lastUsedAt: expect.any(Date) },
|
||||
});
|
||||
});
|
||||
|
||||
test("returns null if v2 api key not found", async () => {
|
||||
const { verifySecret } = await import("@/lib/crypto");
|
||||
vi.mocked(prisma.apiKey.findUnique).mockResolvedValue(null);
|
||||
const result = await getApiKeyWithPermissions("invalid-key");
|
||||
|
||||
const result = await getApiKeyWithPermissions("fbk_invalid_secret");
|
||||
|
||||
expect(result).toBeNull();
|
||||
// Verify timing attack prevention: verifySecret should be called even when key not found
|
||||
expect(verifySecret).toHaveBeenCalledWith(
|
||||
"invalid_secret",
|
||||
"$2b$12$fzHf9le13Ss9UJ04xzmsjODXpFJxz6vsnupoepF5FiqDECkX2BH5q" // control hash
|
||||
);
|
||||
});
|
||||
|
||||
test("returns null if v2 api key bcrypt verification fails", async () => {
|
||||
const { verifySecret } = await import("@/lib/crypto");
|
||||
// Mock verifySecret to return false for this test
|
||||
vi.mocked(verifySecret).mockResolvedValueOnce(false);
|
||||
|
||||
vi.mocked(prisma.apiKey.findUnique).mockResolvedValueOnce({
|
||||
...mockApiKey,
|
||||
} as any);
|
||||
|
||||
const result = await getApiKeyWithPermissions("fbk_wrongSecret");
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(verifySecret).toHaveBeenCalledWith("wrongSecret", mockApiKey.hashedKey);
|
||||
});
|
||||
|
||||
test("returns null if v1 api key not found", async () => {
|
||||
vi.mocked(prisma.apiKey.findFirst).mockResolvedValue(null);
|
||||
const result = await getApiKeyWithPermissions("invalid-legacy-key");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("throws DatabaseError on prisma error", async () => {
|
||||
test("throws DatabaseError on prisma error for v2 key", async () => {
|
||||
const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error message", {
|
||||
code: "P2002",
|
||||
clientVersion: "0.0.1",
|
||||
});
|
||||
vi.mocked(prisma.apiKey.findUnique).mockRejectedValueOnce(errToThrow);
|
||||
await expect(getApiKeyWithPermissions("apikey123")).rejects.toThrow(DatabaseError);
|
||||
await expect(getApiKeyWithPermissions("fbk_testSecret123")).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
test("throws error if prisma throws an error", async () => {
|
||||
test("throws error if prisma throws an error for v2 key", async () => {
|
||||
const errToThrow = new Error("Mock error message");
|
||||
vi.mocked(prisma.apiKey.findUnique).mockRejectedValueOnce(errToThrow);
|
||||
await expect(getApiKeyWithPermissions("apikey123")).rejects.toThrow(errToThrow);
|
||||
await expect(getApiKeyWithPermissions("fbk_testSecret123")).rejects.toThrow(errToThrow);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -221,13 +388,23 @@ describe("API Key Management", () => {
|
||||
],
|
||||
};
|
||||
|
||||
test("creates an API key successfully", async () => {
|
||||
test("creates an API key successfully with v2 format", async () => {
|
||||
vi.mocked(prisma.apiKey.create).mockResolvedValueOnce(mockApiKey);
|
||||
|
||||
const result = await createApiKey("org123", "user123", mockApiKeyData);
|
||||
|
||||
expect(result).toEqual({ ...mockApiKey, actualKey: "generated_key" });
|
||||
expect(prisma.apiKey.create).toHaveBeenCalled();
|
||||
expect(result).toEqual({ ...mockApiKey, actualKey: "fbk_testSecret123" });
|
||||
expect(prisma.apiKey.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
label: "Test API Key",
|
||||
hashedKey: "$2a$12$mockBcryptHashFortestSecret123", // bcrypt hash
|
||||
lookupHash: "sha256LookupHashValue", // SHA-256 lookup hash
|
||||
createdBy: "user123",
|
||||
}),
|
||||
include: {
|
||||
apiKeyEnvironments: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("creates an API key with environment permissions successfully", async () => {
|
||||
@@ -238,7 +415,7 @@ describe("API Key Management", () => {
|
||||
environmentPermissions: [{ environmentId: "env123", permission: ApiKeyPermission.manage }],
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ...mockApiKeyWithEnvironments, actualKey: "generated_key" });
|
||||
expect(result).toEqual({ ...mockApiKeyWithEnvironments, actualKey: "fbk_testSecret123" });
|
||||
expect(prisma.apiKey.create).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { ApiKey, ApiKeyPermission } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import { ZApiKey } from "@formbricks/database/zod/api-keys";
|
||||
import { ZApiKey, ZApiKeyEnvironment } from "@formbricks/database/zod/api-keys";
|
||||
import { ZOrganizationAccess } from "@formbricks/types/api-key";
|
||||
import { ZEnvironment } from "@formbricks/types/environment";
|
||||
import { ZProject } from "@formbricks/types/project";
|
||||
|
||||
export const ZApiKeyEnvironmentPermission = z.object({
|
||||
environmentId: z.string(),
|
||||
@@ -53,3 +54,15 @@ export interface TApiKeyWithEnvironmentPermission
|
||||
extends Pick<ApiKey, "id" | "label" | "createdAt" | "organizationAccess"> {
|
||||
apiKeyEnvironments: TApiKeyEnvironmentPermission[];
|
||||
}
|
||||
|
||||
export const ZApiKeyWithEnvironmentAndProject = ZApiKey.extend({
|
||||
apiKeyEnvironments: z.array(
|
||||
ZApiKeyEnvironment.extend({
|
||||
environment: ZEnvironment.extend({
|
||||
project: ZProject.pick({ id: true, name: true }),
|
||||
}),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
export type TApiKeyWithEnvironmentAndProject = z.infer<typeof ZApiKeyWithEnvironmentAndProject>;
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
import { ZActionClassInput } from "@formbricks/types/action-classes";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { deleteActionClass, getActionClass, updateActionClass } from "@/lib/actionClass/service";
|
||||
import { getSurveysByActionClassId } from "@/lib/survey/service";
|
||||
import { actionClient, authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
@@ -7,10 +11,6 @@ import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-clie
|
||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||
import { getOrganizationIdFromActionClassId, getProjectIdFromActionClassId } from "@/lib/utils/helper";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { z } from "zod";
|
||||
import { ZActionClassInput } from "@formbricks/types/action-classes";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
|
||||
const ZDeleteActionClassAction = z.object({
|
||||
actionClassId: ZId,
|
||||
@@ -124,15 +124,11 @@ export const getActiveInactiveSurveysAction = authenticatedActionClient
|
||||
|
||||
const getLatestStableFbRelease = async (): Promise<string | null> => {
|
||||
try {
|
||||
const res = await fetch("https://api.github.com/repos/formbricks/formbricks/releases");
|
||||
const releases = await res.json();
|
||||
const res = await fetch("https://api.github.com/repos/formbricks/formbricks/releases/latest");
|
||||
const release = await res.json();
|
||||
|
||||
if (Array.isArray(releases)) {
|
||||
const latestStableReleaseTag = releases.filter((release) => !release.prerelease)?.[0]
|
||||
?.tag_name as string;
|
||||
if (latestStableReleaseTag) {
|
||||
return latestStableReleaseTag;
|
||||
}
|
||||
if (release && release.tag_name) {
|
||||
return release.tag_name;
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { Project } from "@prisma/client";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import Image from "next/image";
|
||||
import { ChangeEvent, useRef, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { updateProjectAction } from "@/modules/projects/settings/actions";
|
||||
import { handleFileUpload } from "@/modules/storage/file-upload";
|
||||
@@ -11,11 +16,6 @@ import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import { FileInput } from "@/modules/ui/components/file-input";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { showStorageNotConfiguredToast } from "@/modules/ui/components/storage-not-configured-toast/lib/utils";
|
||||
import { Project } from "@prisma/client";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import Image from "next/image";
|
||||
import { ChangeEvent, useRef, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
interface EditLogoProps {
|
||||
project: Project;
|
||||
@@ -151,6 +151,7 @@ export const EditLogo = ({ project, environmentId, isReadOnly, isStorageConfigur
|
||||
setIsEditing(true);
|
||||
}}
|
||||
disabled={isReadOnly}
|
||||
maxSizeInMB={5}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export enum FileUploadError {
|
||||
NO_FILE = "No file provided or invalid file type. Expected a File or Blob.",
|
||||
INVALID_FILE_TYPE = "Please upload an image file.",
|
||||
FILE_SIZE_EXCEEDED = "File size must be less than 10 MB.",
|
||||
FILE_SIZE_EXCEEDED = "File size must be less than 5 MB.",
|
||||
UPLOAD_FAILED = "Upload failed. Please try again.",
|
||||
INVALID_FILE_NAME = "Invalid file name. Please rename your file and try again.",
|
||||
}
|
||||
@@ -36,7 +36,9 @@ export const handleFileUpload = async (
|
||||
const bufferBytes = fileBuffer.byteLength;
|
||||
const bufferKB = bufferBytes / 1024;
|
||||
|
||||
if (bufferKB > 10240) {
|
||||
const MAX_FILE_SIZE_MB = 5;
|
||||
const maxSizeInKB = MAX_FILE_SIZE_MB * 1024;
|
||||
if (bufferKB > maxSizeInKB) {
|
||||
return {
|
||||
error: FileUploadError.FILE_SIZE_EXCEEDED,
|
||||
url: "",
|
||||
|
||||
+16
-15
@@ -57,15 +57,15 @@ describe("FallbackInput", () => {
|
||||
render(<FallbackInput {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText("Add a placeholder to show if the question gets skipped:")).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText("Fallback for Item 1")).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText("Fallback for Item 2")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Add" })).toBeDisabled();
|
||||
expect(screen.getByLabelText("Item 1")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Item 2")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "common.save" })).toBeDisabled();
|
||||
});
|
||||
|
||||
test("enables Add button when fallbacks are provided for all items", () => {
|
||||
test("enables Save button when fallbacks are provided for all items", () => {
|
||||
render(<FallbackInput {...defaultProps} fallbacks={{ item1: "fallback1", item2: "fallback2" }} />);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Add" })).toBeEnabled();
|
||||
expect(screen.getByRole("button", { name: "common.save" })).toBeEnabled();
|
||||
});
|
||||
|
||||
test("updates fallbacks when input changes", async () => {
|
||||
@@ -73,10 +73,11 @@ describe("FallbackInput", () => {
|
||||
|
||||
render(<FallbackInput {...defaultProps} />);
|
||||
|
||||
const input1 = screen.getByPlaceholderText("Fallback for Item 1");
|
||||
await user.type(input1, "new fallback");
|
||||
const input1 = screen.getByLabelText("Item 1");
|
||||
await user.type(input1, "test");
|
||||
|
||||
expect(mockSetFallbacks).toHaveBeenCalledWith({ item1: "new fallback" });
|
||||
// Check that setFallbacks was called (at least once)
|
||||
expect(mockSetFallbacks).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("handles Enter key press correctly when input is valid", async () => {
|
||||
@@ -84,7 +85,7 @@ describe("FallbackInput", () => {
|
||||
|
||||
render(<FallbackInput {...defaultProps} fallbacks={{ item1: "fallback1", item2: "fallback2" }} />);
|
||||
|
||||
const input = screen.getByPlaceholderText("Fallback for Item 1");
|
||||
const input = screen.getByLabelText("Item 1");
|
||||
await user.type(input, "{Enter}");
|
||||
|
||||
expect(mockAddFallback).toHaveBeenCalled();
|
||||
@@ -96,7 +97,7 @@ describe("FallbackInput", () => {
|
||||
|
||||
render(<FallbackInput {...defaultProps} fallbacks={{ item1: "" }} />);
|
||||
|
||||
const input = screen.getByPlaceholderText("Fallback for Item 1");
|
||||
const input = screen.getByLabelText("Item 1");
|
||||
await user.type(input, "{Enter}");
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith("Fallback missing");
|
||||
@@ -104,13 +105,13 @@ describe("FallbackInput", () => {
|
||||
expect(mockSetOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("calls addFallback when Add button is clicked", async () => {
|
||||
test("calls addFallback when Save button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<FallbackInput {...defaultProps} fallbacks={{ item1: "fallback1", item2: "fallback2" }} />);
|
||||
|
||||
const addButton = screen.getByRole("button", { name: "Add" });
|
||||
await user.click(addButton);
|
||||
const saveButton = screen.getByRole("button", { name: "common.save" });
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(mockAddFallback).toHaveBeenCalled();
|
||||
expect(mockSetOpen).toHaveBeenCalledWith(false);
|
||||
@@ -124,14 +125,14 @@ describe("FallbackInput", () => {
|
||||
|
||||
render(<FallbackInput {...defaultProps} filteredRecallItems={mixedRecallItems} />);
|
||||
|
||||
expect(screen.getByPlaceholderText("Fallback for Item 1")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Item 1")).toBeInTheDocument();
|
||||
expect(screen.queryByText("undefined")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("replaces 'nbsp' with space in fallback value", () => {
|
||||
render(<FallbackInput {...defaultProps} fallbacks={{ item1: "fallbacknbsptext" }} />);
|
||||
|
||||
const input = screen.getByPlaceholderText("Fallback for Item 1");
|
||||
const input = screen.getByLabelText("Item 1");
|
||||
expect(input).toHaveValue("fallback text");
|
||||
});
|
||||
|
||||
|
||||
+22
-17
@@ -1,29 +1,31 @@
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { RefObject } from "react";
|
||||
import { ReactNode } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { TSurveyRecallItem } from "@formbricks/types/surveys/types";
|
||||
import { getTextContentWithRecallTruncated } from "@/lib/utils/recall";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
|
||||
|
||||
interface FallbackInputProps {
|
||||
filteredRecallItems: (TSurveyRecallItem | undefined)[];
|
||||
fallbacks: { [type: string]: string };
|
||||
setFallbacks: (fallbacks: { [type: string]: string }) => void;
|
||||
fallbackInputRef: RefObject<HTMLInputElement>;
|
||||
addFallback: () => void;
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
triggerButton?: ReactNode;
|
||||
}
|
||||
|
||||
export const FallbackInput = ({
|
||||
filteredRecallItems,
|
||||
fallbacks,
|
||||
setFallbacks,
|
||||
fallbackInputRef,
|
||||
addFallback,
|
||||
open,
|
||||
setOpen,
|
||||
triggerButton,
|
||||
}: FallbackInputProps) => {
|
||||
const { t } = useTranslate();
|
||||
const containsEmptyFallback = () => {
|
||||
@@ -32,9 +34,9 @@ export const FallbackInput = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open}>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="z-10 h-0 w-full cursor-pointer" />
|
||||
{open ? <div className="z-10 h-0 w-full cursor-pointer" /> : triggerButton}
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent
|
||||
@@ -44,18 +46,21 @@ export const FallbackInput = ({
|
||||
sideOffset={4}>
|
||||
<p className="font-medium">{t("environments.surveys.edit.add_fallback_placeholder")}</p>
|
||||
|
||||
<div className="mt-2 space-y-2">
|
||||
<div className="mt-2 space-y-3">
|
||||
{filteredRecallItems.map((recallItem, idx) => {
|
||||
if (!recallItem) return null;
|
||||
const inputId = `fallback-${recallItem.id}`;
|
||||
return (
|
||||
<div key={recallItem.id} className="flex flex-col">
|
||||
<div key={recallItem.id} className="flex flex-col gap-1">
|
||||
<Label htmlFor={inputId} className="text-xs font-medium text-slate-700">
|
||||
{getTextContentWithRecallTruncated(recallItem.label)}
|
||||
</Label>
|
||||
<Input
|
||||
className="placeholder:text-md h-full bg-white"
|
||||
ref={idx === 0 ? fallbackInputRef : undefined}
|
||||
id="fallback"
|
||||
className="h-9 bg-white"
|
||||
id={inputId}
|
||||
autoFocus={idx === filteredRecallItems.length - 1}
|
||||
value={fallbacks[recallItem.id]?.replaceAll("nbsp", " ")}
|
||||
placeholder={`${t("environments.surveys.edit.fallback_for")} ${recallItem.label}`}
|
||||
value={fallbacks[recallItem.id]?.replaceAll("nbsp", " ") || ""}
|
||||
placeholder={t("environments.surveys.edit.enter_fallback_value")}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
@@ -80,14 +85,14 @@ export const FallbackInput = ({
|
||||
|
||||
<div className="flex w-full justify-end">
|
||||
<Button
|
||||
className="mt-2 h-full py-2"
|
||||
className="mt-2 h-9"
|
||||
disabled={containsEmptyFallback()}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
addFallback();
|
||||
setOpen(false);
|
||||
}}>
|
||||
{t("environments.surveys.edit.add_fallback")}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
|
||||
+5
-4
@@ -1,12 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { getEnabledLanguages } from "@/lib/i18n/utils";
|
||||
import { headlineToRecall, recallToHeadline } from "@/lib/utils/recall";
|
||||
import { LanguageIndicator } from "@/modules/ee/multi-language-surveys/components/language-indicator";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { ReactNode, useMemo } from "react";
|
||||
import { TI18nString, TSurvey, TSurveyRecallItem } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { getEnabledLanguages } from "@/lib/i18n/utils";
|
||||
import { headlineToRecall, recallToHeadline } from "@/lib/utils/recall";
|
||||
import { LanguageIndicator } from "@/modules/ee/multi-language-surveys/components/language-indicator";
|
||||
|
||||
interface MultiLangWrapperRenderProps {
|
||||
value: TI18nString;
|
||||
@@ -82,7 +83,7 @@ export const MultiLangWrapper = ({
|
||||
{usedLanguageCode !== "default" && value && typeof value["default"] !== "undefined" && (
|
||||
<div className="mt-1 text-xs text-slate-500">
|
||||
<strong>{t("environments.project.languages.translate")}:</strong>{" "}
|
||||
{recallToHeadline(value, localSurvey, false, "default")["default"]}
|
||||
{getTextContent(recallToHeadline(value, localSurvey, false, "default")["default"] ?? "")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
+43
-24
@@ -11,7 +11,26 @@ import {
|
||||
import { RecallItemSelect } from "./recall-item-select";
|
||||
|
||||
vi.mock("@/lib/utils/recall", () => ({
|
||||
replaceRecallInfoWithUnderline: vi.fn((text) => `_${text}_`),
|
||||
getTextContentWithRecallTruncated: vi.fn((text: string, maxLength: number = 25) => {
|
||||
// Remove all HTML tags by repeatedly applying the regex
|
||||
let cleaned = text;
|
||||
let prev;
|
||||
do {
|
||||
prev = cleaned;
|
||||
cleaned = cleaned.replace(/<[^>]*>/g, "");
|
||||
} while (cleaned !== prev);
|
||||
cleaned = cleaned.replace(/\s+/g, " ").trim();
|
||||
|
||||
const withRecallReplaced = cleaned.replace(/#recall:[^#]+#/g, "___");
|
||||
|
||||
if (withRecallReplaced.length <= maxLength) {
|
||||
return withRecallReplaced;
|
||||
}
|
||||
|
||||
const start = withRecallReplaced.slice(0, 10);
|
||||
const end = withRecallReplaced.slice(-10);
|
||||
return `${start}...${end}`;
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("RecallItemSelect", () => {
|
||||
@@ -78,15 +97,15 @@ describe("RecallItemSelect", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("_Question 1_")).toBeInTheDocument();
|
||||
expect(screen.getByText("_Question 2_")).toBeInTheDocument();
|
||||
expect(screen.getByText("_hidden1_")).toBeInTheDocument();
|
||||
expect(screen.getByText("_hidden2_")).toBeInTheDocument();
|
||||
expect(screen.getByText("_Variable 1_")).toBeInTheDocument();
|
||||
expect(screen.getByText("_Variable 2_")).toBeInTheDocument();
|
||||
expect(screen.getByText("Question 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Question 2")).toBeInTheDocument();
|
||||
expect(screen.getByText("hidden1")).toBeInTheDocument();
|
||||
expect(screen.getByText("hidden2")).toBeInTheDocument();
|
||||
expect(screen.getByText("Variable 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Variable 2")).toBeInTheDocument();
|
||||
|
||||
expect(screen.queryByText("_Current Question_")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("_File Upload Question_")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Current Question")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("File Upload Question")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("do not render questions if questionId is 'start' (welcome card)", async () => {
|
||||
@@ -102,16 +121,16 @@ describe("RecallItemSelect", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByText("_Question 1_")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("_Question 2_")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Question 1")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Question 2")).not.toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText("_hidden1_")).toBeInTheDocument();
|
||||
expect(screen.getByText("_hidden2_")).toBeInTheDocument();
|
||||
expect(screen.getByText("_Variable 1_")).toBeInTheDocument();
|
||||
expect(screen.getByText("_Variable 2_")).toBeInTheDocument();
|
||||
expect(screen.getByText("hidden1")).toBeInTheDocument();
|
||||
expect(screen.getByText("hidden2")).toBeInTheDocument();
|
||||
expect(screen.getByText("Variable 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Variable 2")).toBeInTheDocument();
|
||||
|
||||
expect(screen.queryByText("_Current Question_")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("_File Upload Question_")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Current Question")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("File Upload Question")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("filters recall items based on search input", async () => {
|
||||
@@ -131,9 +150,9 @@ describe("RecallItemSelect", () => {
|
||||
const searchInput = screen.getByPlaceholderText("Search options");
|
||||
await user.type(searchInput, "Variable");
|
||||
|
||||
expect(screen.getByText("_Variable 1_")).toBeInTheDocument();
|
||||
expect(screen.getByText("_Variable 2_")).toBeInTheDocument();
|
||||
expect(screen.queryByText("_Question 1_")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Variable 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Variable 2")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Question 1")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("calls addRecallItem and setShowRecallItemSelect when item is selected", async () => {
|
||||
@@ -150,7 +169,7 @@ describe("RecallItemSelect", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
const firstItem = screen.getByText("_Question 1_");
|
||||
const firstItem = screen.getByText("Question 1");
|
||||
await user.click(firstItem);
|
||||
|
||||
expect(mockAddRecallItem).toHaveBeenCalledWith({
|
||||
@@ -176,8 +195,8 @@ describe("RecallItemSelect", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByText("_Question 1_")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("_Question 2_")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Question 1")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Question 2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows 'No recall items found' when search has no results", async () => {
|
||||
@@ -197,6 +216,6 @@ describe("RecallItemSelect", () => {
|
||||
const searchInput = screen.getByPlaceholderText("Search options");
|
||||
await user.type(searchInput, "nonexistent");
|
||||
|
||||
expect(screen.getByText("No recall items found 🤷")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.surveys.edit.no_recall_items_found")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
+72
-69
@@ -1,11 +1,4 @@
|
||||
import { replaceRecallInfoWithUnderline } from "@/lib/utils/recall";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { DropdownMenuItem } from "@radix-ui/react-dropdown-menu";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import {
|
||||
CalendarDaysIcon,
|
||||
ContactIcon,
|
||||
@@ -29,6 +22,14 @@ import {
|
||||
TSurveyQuestionId,
|
||||
TSurveyRecallItem,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { getTextContentWithRecallTruncated } from "@/lib/utils/recall";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
|
||||
const questionIconMapping = {
|
||||
openText: MessageSquareTextIcon,
|
||||
@@ -62,6 +63,7 @@ export const RecallItemSelect = ({
|
||||
selectedLanguageCode,
|
||||
}: RecallItemSelectProps) => {
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
const { t } = useTranslate();
|
||||
const isNotAllowedQuestionType = (question: TSurveyQuestion): boolean => {
|
||||
return (
|
||||
question.type === "fileUpload" ||
|
||||
@@ -128,7 +130,7 @@ export const RecallItemSelect = ({
|
||||
});
|
||||
|
||||
return filteredQuestions;
|
||||
}, [localSurvey.questions, questionId, recallItemIds]);
|
||||
}, [localSurvey.questions, questionId, recallItemIds, selectedLanguageCode]);
|
||||
|
||||
const filteredRecallItems: TSurveyRecallItem[] = useMemo(() => {
|
||||
return [...surveyQuestionRecallItems, ...hiddenFieldRecallItems, ...variableRecallItems].filter(
|
||||
@@ -141,11 +143,6 @@ export const RecallItemSelect = ({
|
||||
);
|
||||
}, [surveyQuestionRecallItems, hiddenFieldRecallItems, variableRecallItems, searchValue]);
|
||||
|
||||
// function to modify headline (recallInfo to corresponding headline)
|
||||
const getRecallLabel = (label: string): string => {
|
||||
return replaceRecallInfoWithUnderline(label);
|
||||
};
|
||||
|
||||
const getRecallItemIcon = (recallItem: TSurveyRecallItem) => {
|
||||
switch (recallItem.type) {
|
||||
case "question":
|
||||
@@ -162,60 +159,66 @@ export const RecallItemSelect = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu defaultOpen={true} modal={false}>
|
||||
<DropdownMenuTrigger className="z-10 cursor-pointer" asChild>
|
||||
<div className="flex h-0 w-full items-center justify-between overflow-hidden" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-96 bg-slate-50 text-slate-700" align="start" side="bottom">
|
||||
<p className="m-2 text-sm font-medium">Recall Information from...</p>
|
||||
<Input
|
||||
id="recallItemSearchInput"
|
||||
placeholder="Search options"
|
||||
className="mb-1 w-full bg-white"
|
||||
onChange={(e) => setSearchValue(e.target.value)}
|
||||
autoFocus={true}
|
||||
value={searchValue}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "ArrowDown") {
|
||||
document.getElementById("recallItem-0")?.focus();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="max-h-72 overflow-y-auto overflow-x-hidden">
|
||||
{filteredRecallItems.map((recallItem, index) => {
|
||||
const IconComponent = getRecallItemIcon(recallItem);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
id={"recallItem-" + index}
|
||||
key={recallItem.id}
|
||||
title={recallItem.label}
|
||||
onSelect={() => {
|
||||
addRecallItem({ id: recallItem.id, label: recallItem.label, type: recallItem.type });
|
||||
setShowRecallItemSelect(false);
|
||||
}}
|
||||
autoFocus={false}
|
||||
className="flex w-full cursor-pointer items-center rounded-md p-2 focus:bg-slate-200 focus:outline-none"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "ArrowUp" && index === 0) {
|
||||
document.getElementById("recallItemSearchInput")?.focus();
|
||||
} else if (e.key === "ArrowDown" && index === filteredRecallItems.length - 1) {
|
||||
document.getElementById("recallItemSearchInput")?.focus();
|
||||
}
|
||||
}}>
|
||||
<div>{IconComponent && <IconComponent className="mr-2 w-4" />}</div>
|
||||
<p className="max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm">
|
||||
{getRecallLabel(recallItem.label)}
|
||||
</p>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
{filteredRecallItems.length === 0 && (
|
||||
<p className="p-2 text-sm font-medium text-slate-700">No recall items found 🤷</p>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
<DropdownMenu defaultOpen={true} modal={true}>
|
||||
<DropdownMenuTrigger className="z-10 cursor-pointer" asChild>
|
||||
<div className="flex w-full items-center justify-between overflow-hidden" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="flex w-96 flex-col gap-2 bg-slate-50 p-3 text-xs text-slate-700"
|
||||
align="start"
|
||||
side="bottom"
|
||||
data-recall-dropdown>
|
||||
<p className="font-medium">{t("environments.surveys.edit.recall_information_from")}</p>
|
||||
<Input
|
||||
id="recallItemSearchInput"
|
||||
placeholder="Search options"
|
||||
className="w-full bg-white"
|
||||
onChange={(e) => setSearchValue(e.target.value)}
|
||||
autoFocus={true}
|
||||
value={searchValue}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "ArrowDown") {
|
||||
document.getElementById("recallItem-0")?.focus();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="max-h-72 overflow-y-auto overflow-x-hidden">
|
||||
{filteredRecallItems.map((recallItem, index) => {
|
||||
const IconComponent = getRecallItemIcon(recallItem);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
id={"recallItem-" + index}
|
||||
key={recallItem.id}
|
||||
title={recallItem.type}
|
||||
onSelect={() => {
|
||||
addRecallItem({ id: recallItem.id, label: recallItem.label, type: recallItem.type });
|
||||
setShowRecallItemSelect(false);
|
||||
}}
|
||||
autoFocus={false}
|
||||
className="flex w-full cursor-pointer items-center rounded-md p-2 focus:bg-slate-200 focus:outline-none"
|
||||
onKeyDown={(e) => {
|
||||
if (
|
||||
(e.key === "ArrowUp" && index === 0) ||
|
||||
(e.key === "ArrowDown" && index === filteredRecallItems.length - 1)
|
||||
) {
|
||||
e.preventDefault();
|
||||
document.getElementById("recallItemSearchInput")?.focus();
|
||||
}
|
||||
}}>
|
||||
<div>{IconComponent && <IconComponent className="mr-2 w-4" />}</div>
|
||||
<p className="max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm">
|
||||
{getTextContentWithRecallTruncated(recallItem.label)}
|
||||
</p>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
{filteredRecallItems.length === 0 && (
|
||||
<p className="p-2 text-sm font-medium text-slate-700">
|
||||
{t("environments.surveys.edit.no_recall_items_found")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
+18
-94
@@ -1,11 +1,11 @@
|
||||
import * as recallUtils from "@/lib/utils/recall";
|
||||
import { RecallItemSelect } from "@/modules/survey/components/question-form-input/components/recall-item-select";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TSurvey, TSurveyRecallItem } from "@formbricks/types/surveys/types";
|
||||
import * as recallUtils from "@/lib/utils/recall";
|
||||
import { RecallItemSelect } from "@/modules/survey/components/question-form-input/components/recall-item-select";
|
||||
import { RecallWrapper } from "./recall-wrapper";
|
||||
|
||||
vi.mock("react-hot-toast", () => ({
|
||||
@@ -144,82 +144,16 @@ describe("RecallWrapper", () => {
|
||||
expect(RecallItemSelect).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("handles fallback addition through user interaction and verifies state changes", async () => {
|
||||
// Start with a value that already contains a recall item
|
||||
test("detects recall items when value contains recall syntax", () => {
|
||||
const valueWithRecall = "Test with #recall:testId/fallback:# inside";
|
||||
const recallItems = [{ id: "testId", label: "testLabel", type: "question" }] as TSurveyRecallItem[];
|
||||
|
||||
// Set up mocks to simulate the component's recall detection and fallback functionality
|
||||
vi.mocked(recallUtils.getRecallItems).mockReturnValue(recallItems);
|
||||
vi.mocked(recallUtils.findRecallInfoById).mockReturnValue("#recall:testId/fallback:#");
|
||||
vi.mocked(recallUtils.getFallbackValues).mockReturnValue({ testId: "" });
|
||||
|
||||
// Track onChange and onAddFallback calls to verify component state changes
|
||||
const onChangeMock = vi.fn();
|
||||
const onAddFallbackMock = vi.fn();
|
||||
render(<RecallWrapper {...defaultProps} value={valueWithRecall} />);
|
||||
|
||||
render(
|
||||
<RecallWrapper
|
||||
{...defaultProps}
|
||||
value={valueWithRecall}
|
||||
onChange={onChangeMock}
|
||||
onAddFallback={onAddFallbackMock}
|
||||
/>
|
||||
);
|
||||
|
||||
// Verify that the edit recall button appears (indicating recall item is detected)
|
||||
expect(screen.getByText("Edit Recall")).toBeInTheDocument();
|
||||
|
||||
// Click the "Edit Recall" button to trigger the fallback addition flow
|
||||
await userEvent.click(screen.getByText("Edit Recall"));
|
||||
|
||||
// Since the mocked FallbackInput renders a simplified version,
|
||||
// check if the fallback input interface is shown
|
||||
const { FallbackInput } = await import(
|
||||
"@/modules/survey/components/question-form-input/components/fallback-input"
|
||||
);
|
||||
const FallbackInputMock = vi.mocked(FallbackInput);
|
||||
|
||||
// If the FallbackInput is rendered, verify its state and simulate the fallback addition
|
||||
if (FallbackInputMock.mock.calls.length > 0) {
|
||||
// Get the functions from the mock call
|
||||
const lastCall = FallbackInputMock.mock.calls[FallbackInputMock.mock.calls.length - 1][0];
|
||||
const { addFallback, setFallbacks } = lastCall;
|
||||
|
||||
// Simulate user adding a fallback value
|
||||
setFallbacks({ testId: "test fallback value" });
|
||||
|
||||
// Simulate clicking the "Add Fallback" button
|
||||
addFallback();
|
||||
|
||||
// Verify that the component's state was updated through the callbacks
|
||||
expect(onChangeMock).toHaveBeenCalled();
|
||||
expect(onAddFallbackMock).toHaveBeenCalled();
|
||||
|
||||
// Verify that the final value reflects the fallback addition
|
||||
const finalValue = onAddFallbackMock.mock.calls[0][0];
|
||||
expect(finalValue).toContain("#recall:testId/fallback:");
|
||||
expect(finalValue).toContain("test fallback value");
|
||||
expect(finalValue).toContain("# inside");
|
||||
} else {
|
||||
// Verify that the component is in a state that would allow fallback addition
|
||||
expect(screen.getByText("Edit Recall")).toBeInTheDocument();
|
||||
|
||||
// Verify that the callbacks are configured and would handle fallback addition
|
||||
expect(onChangeMock).toBeDefined();
|
||||
expect(onAddFallbackMock).toBeDefined();
|
||||
|
||||
// Simulate the expected behavior of fallback addition
|
||||
// This tests that the component would handle fallback addition correctly
|
||||
const simulatedFallbackValue = "Test with #recall:testId/fallback:test fallback value# inside";
|
||||
onAddFallbackMock(simulatedFallbackValue);
|
||||
|
||||
// Verify that the simulated fallback value has the correct structure
|
||||
expect(onAddFallbackMock).toHaveBeenCalledWith(simulatedFallbackValue);
|
||||
expect(simulatedFallbackValue).toContain("#recall:testId/fallback:");
|
||||
expect(simulatedFallbackValue).toContain("test fallback value");
|
||||
expect(simulatedFallbackValue).toContain("# inside");
|
||||
}
|
||||
// Verify that recall items are detected
|
||||
expect(recallUtils.getRecallItems).toHaveBeenCalledWith(valueWithRecall, expect.any(Object), "en");
|
||||
});
|
||||
|
||||
test("displays error when trying to add empty recall item", async () => {
|
||||
@@ -263,37 +197,27 @@ describe("RecallWrapper", () => {
|
||||
expect(screen.getByTestId("recall-select-visible").textContent).toBe("false");
|
||||
});
|
||||
|
||||
test("shows edit recall button when value contains recall syntax", () => {
|
||||
test("renders recall items when value contains recall syntax", () => {
|
||||
const valueWithRecall = "Test with #recall:testId/fallback:# inside";
|
||||
const recallItems = [{ id: "testId", label: "testLabel", type: "question" }] as TSurveyRecallItem[];
|
||||
|
||||
vi.mocked(recallUtils.getRecallItems).mockReturnValue(recallItems);
|
||||
|
||||
render(<RecallWrapper {...defaultProps} value={valueWithRecall} />);
|
||||
|
||||
expect(screen.getByText("Edit Recall")).toBeInTheDocument();
|
||||
// Verify that recall items are detected and rendered
|
||||
expect(recallUtils.getRecallItems).toHaveBeenCalledWith(valueWithRecall, expect.any(Object), "en");
|
||||
});
|
||||
|
||||
test("edit recall button toggles visibility state", async () => {
|
||||
test("handles recall item state changes", () => {
|
||||
const valueWithRecall = "Test with #recall:testId/fallback:# inside";
|
||||
const recallItems = [{ id: "testId", label: "testLabel", type: "question" }] as TSurveyRecallItem[];
|
||||
|
||||
vi.mocked(recallUtils.getRecallItems).mockReturnValue(recallItems);
|
||||
|
||||
render(<RecallWrapper {...defaultProps} value={valueWithRecall} />);
|
||||
|
||||
const editButton = screen.getByText("Edit Recall");
|
||||
|
||||
// Verify the edit button is functional and clickable
|
||||
expect(editButton).toBeInTheDocument();
|
||||
expect(editButton).toBeEnabled();
|
||||
|
||||
// Click the "Edit Recall" button - this should work without errors
|
||||
await userEvent.click(editButton);
|
||||
|
||||
// The button should still be present and functional after clicking
|
||||
expect(editButton).toBeInTheDocument();
|
||||
expect(editButton).toBeEnabled();
|
||||
|
||||
// Click again to verify the button can be clicked multiple times
|
||||
await userEvent.click(editButton);
|
||||
|
||||
// Button should still be functional
|
||||
expect(editButton).toBeInTheDocument();
|
||||
expect(editButton).toBeEnabled();
|
||||
// Verify that recall items are detected
|
||||
expect(recallUtils.getRecallItems).toHaveBeenCalledWith(valueWithRecall, expect.any(Object), "en");
|
||||
});
|
||||
});
|
||||
|
||||
+23
-32
@@ -1,5 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { PencilIcon } from "lucide-react";
|
||||
import React, { JSX, ReactNode, useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { TSurvey, TSurveyRecallItem } from "@formbricks/types/surveys/types";
|
||||
import { structuredClone } from "@/lib/pollyfills/structuredClone";
|
||||
import {
|
||||
extractId,
|
||||
@@ -14,11 +19,6 @@ import {
|
||||
import { FallbackInput } from "@/modules/survey/components/question-form-input/components/fallback-input";
|
||||
import { RecallItemSelect } from "@/modules/survey/components/question-form-input/components/recall-item-select";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { PencilIcon } from "lucide-react";
|
||||
import React, { JSX, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { TSurvey, TSurveyRecallItem } from "@formbricks/types/surveys/types";
|
||||
|
||||
interface RecallWrapperRenderProps {
|
||||
value: string;
|
||||
@@ -61,16 +61,19 @@ export const RecallWrapper = ({
|
||||
|
||||
const [internalValue, setInternalValue] = useState<string>(headlineToRecall(value, recallItems, fallbacks));
|
||||
const [renderedText, setRenderedText] = useState<JSX.Element[]>([]);
|
||||
const fallbackInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const hasRecallItems = useMemo(() => {
|
||||
return recallItems.length > 0 || value?.includes("recall:");
|
||||
}, [recallItems.length, value]);
|
||||
|
||||
useEffect(() => {
|
||||
setInternalValue(headlineToRecall(value, recallItems, fallbacks));
|
||||
}, [value, recallItems, fallbacks]);
|
||||
|
||||
// Update recall items when value changes
|
||||
useEffect(() => {
|
||||
if (value?.includes("#recall:")) {
|
||||
const newRecallItems = getRecallItems(value, localSurvey, usedLanguageCode);
|
||||
setRecallItems(newRecallItems);
|
||||
}
|
||||
}, [value, localSurvey, usedLanguageCode]);
|
||||
|
||||
const checkForRecallSymbol = useCallback((str: string) => {
|
||||
// Get cursor position by finding last character
|
||||
// Only trigger when @ is the last character typed
|
||||
@@ -178,12 +181,6 @@ export const RecallWrapper = ({
|
||||
[fallbacks, internalValue, onChange, recallItems, setInternalValue]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (showFallbackInput && fallbackInputRef.current) {
|
||||
fallbackInputRef.current.focus();
|
||||
}
|
||||
}, [showFallbackInput]);
|
||||
|
||||
useEffect(() => {
|
||||
const recallItemLabels = recallItems.flatMap((recallItem) => {
|
||||
if (!recallItem.label.includes("#recall:")) {
|
||||
@@ -255,20 +252,6 @@ export const RecallWrapper = ({
|
||||
isRecallSelectVisible: showRecallItemSelect,
|
||||
children: (
|
||||
<div>
|
||||
{hasRecallItems && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
type="button"
|
||||
className="absolute right-2 top-full z-[1] flex h-6 cursor-pointer items-center rounded-b-lg rounded-t-none bg-slate-100 px-2.5 py-0 text-xs hover:bg-slate-200"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setShowFallbackInput(!showFallbackInput);
|
||||
}}>
|
||||
{t("environments.surveys.edit.edit_recall")}
|
||||
<PencilIcon className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{showRecallItemSelect && (
|
||||
<RecallItemSelect
|
||||
localSurvey={localSurvey}
|
||||
@@ -281,15 +264,23 @@ export const RecallWrapper = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{showFallbackInput && recallItems.length > 0 && (
|
||||
{recallItems.length > 0 && (
|
||||
<FallbackInput
|
||||
filteredRecallItems={recallItems}
|
||||
fallbacks={fallbacks}
|
||||
setFallbacks={setFallbacks}
|
||||
fallbackInputRef={fallbackInputRef as React.RefObject<HTMLInputElement>}
|
||||
addFallback={addFallback}
|
||||
open={showFallbackInput}
|
||||
setOpen={setShowFallbackInput}
|
||||
triggerButton={
|
||||
<Button
|
||||
variant="ghost"
|
||||
type="button"
|
||||
className="absolute right-2 top-full z-[1] flex h-6 cursor-pointer items-center rounded-b-lg rounded-t-none bg-slate-100 px-2.5 py-0 text-xs hover:bg-slate-200">
|
||||
{t("environments.surveys.edit.edit_recall")}
|
||||
<PencilIcon className="h-3 w-3" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { createI18nString } from "@/lib/i18n/utils";
|
||||
// Import FileInput to get the mocked version
|
||||
import { FileInput } from "@/modules/ui/components/file-input";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { createI18nString } from "@/lib/i18n/utils";
|
||||
// Import FileInput to get the mocked version
|
||||
import { FileInput } from "@/modules/ui/components/file-input";
|
||||
import { QuestionFormInput } from "./index";
|
||||
|
||||
// Mock all the modules that might cause server-side environment variable access issues
|
||||
@@ -162,6 +162,26 @@ vi.mock("@/modules/ui/components/tooltip", () => ({
|
||||
TooltipRenderer: ({ children, tooltipContent }: any) => (
|
||||
<span data-tooltip={tooltipContent}>{children}</span>
|
||||
),
|
||||
TooltipProvider: ({ children }: any) => <div>{children}</div>,
|
||||
Tooltip: ({ children }: any) => <div>{children}</div>,
|
||||
TooltipTrigger: ({ children, asChild }: any) => (asChild ? children : <div>{children}</div>),
|
||||
TooltipContent: ({ children }: any) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
// Mock LocalizedEditor to render as a simple input for testing
|
||||
vi.mock("@/modules/ee/multi-language-surveys/components/localized-editor", () => ({
|
||||
LocalizedEditor: ({ id, value, updateQuestion, questionIdx }: any) => (
|
||||
<input
|
||||
data-testid={id}
|
||||
id={id}
|
||||
defaultValue={value?.default || ""}
|
||||
onChange={(e) => {
|
||||
if (updateQuestion) {
|
||||
updateQuestion(questionIdx, { [id]: { default: e.target.value } });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock component imports to avoid rendering real components that might access server-side resources
|
||||
@@ -280,7 +300,7 @@ const mockSurvey = {
|
||||
welcomeCard: {
|
||||
enabled: true,
|
||||
headline: createI18nString("Welcome", ["en", "fr"]),
|
||||
html: createI18nString("<p>Welcome to our survey</p>", ["en", "fr"]),
|
||||
subheader: createI18nString("<p>Welcome to our survey</p>", ["en", "fr"]),
|
||||
buttonLabel: createI18nString("Start", ["en", "fr"]),
|
||||
fileUrl: "",
|
||||
videoUrl: "",
|
||||
|
||||
@@ -11,12 +11,14 @@ import {
|
||||
TSurveyEndScreenCard,
|
||||
TSurveyQuestion,
|
||||
TSurveyQuestionChoice,
|
||||
TSurveyQuestionTypeEnum,
|
||||
TSurveyRedirectUrlCard,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
|
||||
import { useSyncScroll } from "@/lib/utils/hooks/useSyncScroll";
|
||||
import { recallToHeadline } from "@/lib/utils/recall";
|
||||
import { LocalizedEditor } from "@/modules/ee/multi-language-surveys/components/localized-editor";
|
||||
import { MultiLangWrapper } from "@/modules/survey/components/question-form-input/components/multi-lang-wrapper";
|
||||
import { RecallWrapper } from "@/modules/survey/components/question-form-input/components/recall-wrapper";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
@@ -55,6 +57,10 @@ interface QuestionFormInputProps {
|
||||
locale: TUserLocale;
|
||||
onKeyDown?: React.KeyboardEventHandler<HTMLInputElement>;
|
||||
isStorageConfigured: boolean;
|
||||
autoFocus?: boolean;
|
||||
firstRender?: boolean;
|
||||
setFirstRender?: (value: boolean) => void;
|
||||
isExternalUrlsAllowed?: boolean;
|
||||
}
|
||||
|
||||
export const QuestionFormInput = ({
|
||||
@@ -77,6 +83,10 @@ export const QuestionFormInput = ({
|
||||
locale,
|
||||
onKeyDown,
|
||||
isStorageConfigured = true,
|
||||
autoFocus,
|
||||
firstRender: externalFirstRender,
|
||||
setFirstRender: externalSetFirstRender,
|
||||
isExternalUrlsAllowed,
|
||||
}: QuestionFormInputProps) => {
|
||||
const { t } = useTranslate();
|
||||
const defaultLanguageCode =
|
||||
@@ -274,13 +284,133 @@ export const QuestionFormInput = ({
|
||||
const debouncedHandleUpdate = useMemo(() => debounce((value) => handleUpdate(value), 100), [handleUpdate]);
|
||||
|
||||
const [animationParent] = useAutoAnimate();
|
||||
const [internalFirstRender, setInternalFirstRender] = useState(true);
|
||||
|
||||
const renderRemoveDescriptionButton = useMemo(() => {
|
||||
if (id !== "subheader") return false;
|
||||
return !!question?.subheader || (endingCard?.type === "endScreen" && !!endingCard?.subheader);
|
||||
// Use external firstRender state if provided, otherwise use internal state
|
||||
const firstRender = externalFirstRender ?? internalFirstRender;
|
||||
const setFirstRender = externalSetFirstRender ?? setInternalFirstRender;
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [endingCard?.type, id, question?.subheader]);
|
||||
const renderRemoveDescriptionButton = () => {
|
||||
if (
|
||||
question &&
|
||||
(question.type === TSurveyQuestionTypeEnum.CTA || question.type === TSurveyQuestionTypeEnum.Consent)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (id === "subheader") {
|
||||
return !!question?.subheader || (endingCard?.type === "endScreen" && !!endingCard?.subheader);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const useRichTextEditor = id === "headline" || id === "subheader" || id === "html";
|
||||
|
||||
// For rich text editor fields, we need either updateQuestion or updateSurvey
|
||||
if (useRichTextEditor && !updateQuestion && !updateSurvey) {
|
||||
throw new Error("Either updateQuestion or updateSurvey must be provided");
|
||||
}
|
||||
|
||||
if (useRichTextEditor) {
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<div className="mb-2 mt-3">
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-4" ref={animationParent}>
|
||||
{showImageUploader && id === "headline" && (
|
||||
<FileInput
|
||||
id="question-image"
|
||||
allowedFileExtensions={["png", "jpeg", "jpg", "webp", "heic"]}
|
||||
environmentId={localSurvey.environmentId}
|
||||
onFileUpload={(url: string[] | undefined, fileType: "image" | "video") => {
|
||||
if (url) {
|
||||
const update =
|
||||
fileType === "video"
|
||||
? { videoUrl: url[0], imageUrl: "" }
|
||||
: { imageUrl: url[0], videoUrl: "" };
|
||||
if ((isWelcomeCard || isEndingCard) && updateSurvey) {
|
||||
updateSurvey(update);
|
||||
} else if (updateQuestion) {
|
||||
updateQuestion(questionIdx, update);
|
||||
}
|
||||
}
|
||||
}}
|
||||
fileUrl={getFileUrl()}
|
||||
videoUrl={getVideoUrl()}
|
||||
isVideoAllowed={true}
|
||||
maxSizeInMB={5}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex w-full items-start gap-2">
|
||||
<div className="flex-1">
|
||||
<LocalizedEditor
|
||||
key={`${questionId}-${id}-${selectedLanguageCode}`}
|
||||
id={id}
|
||||
value={value}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
isInvalid={isInvalid}
|
||||
updateQuestion={(isWelcomeCard || isEndingCard ? updateSurvey : updateQuestion)!}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
firstRender={firstRender}
|
||||
setFirstRender={setFirstRender}
|
||||
locale={locale}
|
||||
questionId={questionId}
|
||||
isCard={isWelcomeCard || isEndingCard}
|
||||
autoFocus={autoFocus}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{id === "headline" && !isWelcomeCard && (
|
||||
<TooltipRenderer tooltipContent={t("environments.surveys.edit.add_photo_or_video")}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
aria-label="Toggle image uploader"
|
||||
data-testid="toggle-image-uploader-button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setShowImageUploader((prev) => !prev);
|
||||
}}>
|
||||
<ImagePlusIcon />
|
||||
</Button>
|
||||
</TooltipRenderer>
|
||||
)}
|
||||
|
||||
{id === "subheader" && renderRemoveDescriptionButton() && (
|
||||
<TooltipRenderer tooltipContent={t("environments.surveys.edit.remove_description")}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
aria-label="Remove description"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (updateSurvey) {
|
||||
updateSurvey({ subheader: undefined });
|
||||
}
|
||||
|
||||
if (updateQuestion) {
|
||||
updateQuestion(questionIdx, { subheader: undefined });
|
||||
}
|
||||
}}>
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
</TooltipRenderer>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
@@ -346,6 +476,7 @@ export const QuestionFormInput = ({
|
||||
fileUrl={getFileUrl()}
|
||||
videoUrl={getVideoUrl()}
|
||||
isVideoAllowed={true}
|
||||
maxSizeInMB={5}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
/>
|
||||
)}
|
||||
@@ -419,7 +550,7 @@ export const QuestionFormInput = ({
|
||||
</Button>
|
||||
</TooltipRenderer>
|
||||
)}
|
||||
{renderRemoveDescriptionButton ? (
|
||||
{renderRemoveDescriptionButton() ? (
|
||||
<TooltipRenderer tooltipContent={t("environments.surveys.edit.remove_description")}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
@@ -428,12 +559,14 @@ export const QuestionFormInput = ({
|
||||
className="ml-2"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (updateQuestion) {
|
||||
updateQuestion(questionIdx, { subheader: undefined });
|
||||
}
|
||||
|
||||
if (updateSurvey) {
|
||||
updateSurvey({ subheader: undefined });
|
||||
}
|
||||
|
||||
if (updateQuestion) {
|
||||
updateQuestion(questionIdx, { subheader: undefined });
|
||||
}
|
||||
}}>
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
|
||||
@@ -5,24 +5,25 @@ import { actionClient, authenticatedActionClient } from "@/lib/utils/action-clie
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||
import {
|
||||
getOrganizationIdFromEnvironmentId,
|
||||
getOrganizationIdFromProjectId,
|
||||
getOrganizationIdFromSurveyId,
|
||||
getProjectIdFromEnvironmentId,
|
||||
getProjectIdFromSurveyId,
|
||||
getOrganizationIdFromEnvironmentId,
|
||||
getOrganizationIdFromProjectId,
|
||||
getOrganizationIdFromSurveyId,
|
||||
getProjectIdFromEnvironmentId,
|
||||
getProjectIdFromSurveyId,
|
||||
} from "@/lib/utils/helper";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { checkMultiLanguagePermission } from "@/modules/ee/multi-language-surveys/lib/actions";
|
||||
import { createActionClass } from "@/modules/survey/editor/lib/action-class";
|
||||
import { checkExternalUrlsPermission } from "@/modules/survey/editor/lib/check-external-urls-permission";
|
||||
import { updateSurvey } from "@/modules/survey/editor/lib/survey";
|
||||
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
|
||||
import { checkSpamProtectionPermission } from "@/modules/survey/lib/permission";
|
||||
import { getOrganizationBilling, getSurvey } from "@/modules/survey/lib/survey";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { ZActionClassInput } from "@formbricks/types/action-classes";
|
||||
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TSurvey, ZSurvey } from "@formbricks/types/surveys/types";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { getProject } from "./lib/project";
|
||||
|
||||
/**
|
||||
@@ -82,6 +83,9 @@ export const updateSurveyAction = authenticatedActionClient.schema(ZSurvey).acti
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.surveyId = parsedInput.id;
|
||||
const oldObject = await getSurvey(parsedInput.id);
|
||||
|
||||
// Check external URLs permission (with grandfathering)
|
||||
await checkExternalUrlsPermission(organizationId, parsedInput, oldObject);
|
||||
const result = await updateSurvey(parsedInput);
|
||||
ctx.auditLoggingCtx.oldObject = oldObject;
|
||||
ctx.auditLoggingCtx.newObject = result;
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
|
||||
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { QuestionToggleTable } from "@/modules/ui/components/question-toggle-table";
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { type JSX, useEffect } from "react";
|
||||
import { TSurvey, TSurveyAddressQuestion } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
|
||||
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { QuestionToggleTable } from "@/modules/ui/components/question-toggle-table";
|
||||
|
||||
interface AddressQuestionFormProps {
|
||||
localSurvey: TSurvey;
|
||||
@@ -21,6 +21,7 @@ interface AddressQuestionFormProps {
|
||||
setSelectedLanguageCode: (language: string) => void;
|
||||
locale: TUserLocale;
|
||||
isStorageConfigured: boolean;
|
||||
isExternalUrlsAllowed?: boolean;
|
||||
}
|
||||
|
||||
export const AddressQuestionForm = ({
|
||||
@@ -33,6 +34,7 @@ export const AddressQuestionForm = ({
|
||||
setSelectedLanguageCode,
|
||||
locale,
|
||||
isStorageConfigured = true,
|
||||
isExternalUrlsAllowed,
|
||||
}: AddressQuestionFormProps): JSX.Element => {
|
||||
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages ?? []);
|
||||
const { t } = useTranslate();
|
||||
@@ -93,6 +95,7 @@ export const AddressQuestionForm = ({
|
||||
]);
|
||||
|
||||
const [parent] = useAutoAnimate();
|
||||
|
||||
return (
|
||||
<form>
|
||||
<QuestionFormInput
|
||||
@@ -107,6 +110,8 @@ export const AddressQuestionForm = ({
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
autoFocus={!question.headline?.default || question.headline.default.trim() === ""}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
|
||||
<div ref={parent}>
|
||||
@@ -125,6 +130,8 @@ export const AddressQuestionForm = ({
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
autoFocus={!question.subheader?.default || question.subheader.default.trim() === ""}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { type JSX, useEffect, useState } from "react";
|
||||
import { TSurvey, TSurveyCalQuestion } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
|
||||
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
|
||||
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { type JSX, useEffect, useState } from "react";
|
||||
import { TSurvey, TSurveyCalQuestion } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
|
||||
interface CalQuestionFormProps {
|
||||
localSurvey: TSurvey;
|
||||
@@ -23,6 +23,7 @@ interface CalQuestionFormProps {
|
||||
isInvalid: boolean;
|
||||
locale: TUserLocale;
|
||||
isStorageConfigured: boolean;
|
||||
isExternalUrlsAllowed?: boolean;
|
||||
}
|
||||
|
||||
export const CalQuestionForm = ({
|
||||
@@ -35,6 +36,7 @@ export const CalQuestionForm = ({
|
||||
isInvalid,
|
||||
locale,
|
||||
isStorageConfigured = true,
|
||||
isExternalUrlsAllowed,
|
||||
}: CalQuestionFormProps): JSX.Element => {
|
||||
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages);
|
||||
const [isCalHostEnabled, setIsCalHostEnabled] = useState(!!question.calHost);
|
||||
@@ -63,6 +65,8 @@ export const CalQuestionForm = ({
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
autoFocus={!question.headline?.default || question.headline.default.trim() === ""}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
<div>
|
||||
{question.subheader !== undefined && (
|
||||
@@ -80,6 +84,8 @@ export const CalQuestionForm = ({
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
autoFocus={!question.subheader?.default || question.subheader.default.trim() === ""}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -95,7 +101,6 @@ export const CalQuestionForm = ({
|
||||
subheader: createI18nString("", surveyLanguageCodes),
|
||||
});
|
||||
}}>
|
||||
{" "}
|
||||
<PlusIcon className="mr-1 h-4 w-4" />
|
||||
{t("environments.surveys.edit.add_description")}
|
||||
</Button>
|
||||
|
||||
@@ -5,15 +5,11 @@ import { TUserLocale } from "@formbricks/types/user";
|
||||
import { ConsentQuestionForm } from "./consent-question-form";
|
||||
|
||||
vi.mock("@/modules/survey/components/question-form-input", () => ({
|
||||
QuestionFormInput: ({ label }: { label: string }) => <div data-testid="question-form-input">{label}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/multi-language-surveys/components/localized-editor", () => ({
|
||||
LocalizedEditor: ({ id }: { id: string }) => <div data-testid="localized-editor">{id}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/label", () => ({
|
||||
Label: ({ children }: { children: string }) => <div data-testid="label">{children}</div>,
|
||||
QuestionFormInput: ({ label, id }: { label: string; id: string }) => (
|
||||
<div data-testid="question-form-input" data-field-id={id}>
|
||||
{label}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("ConsentQuestionForm", () => {
|
||||
@@ -61,9 +57,18 @@ describe("ConsentQuestionForm", () => {
|
||||
);
|
||||
|
||||
const questionFormInputs = screen.getAllByTestId("question-form-input");
|
||||
expect(questionFormInputs).toHaveLength(3);
|
||||
|
||||
// Check headline field
|
||||
expect(questionFormInputs[0]).toHaveTextContent("environments.surveys.edit.question*");
|
||||
expect(screen.getByTestId("label")).toHaveTextContent("common.description");
|
||||
expect(screen.getByTestId("localized-editor")).toHaveTextContent("subheader");
|
||||
expect(questionFormInputs[1]).toHaveTextContent("environments.surveys.edit.checkbox_label*");
|
||||
expect(questionFormInputs[0]).toHaveAttribute("data-field-id", "headline");
|
||||
|
||||
// Check html (description) field
|
||||
expect(questionFormInputs[1]).toHaveTextContent("common.description");
|
||||
expect(questionFormInputs[1]).toHaveAttribute("data-field-id", "subheader");
|
||||
|
||||
// Check label (checkbox label) field
|
||||
expect(questionFormInputs[2]).toHaveTextContent("environments.surveys.edit.checkbox_label*");
|
||||
expect(questionFormInputs[2]).toHaveAttribute("data-field-id", "label");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { LocalizedEditor } from "@/modules/ee/multi-language-surveys/components/localized-editor";
|
||||
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { type JSX, useState } from "react";
|
||||
import { type JSX } from "react";
|
||||
import { TSurvey, TSurveyConsentQuestion } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
|
||||
|
||||
interface ConsentQuestionFormProps {
|
||||
localSurvey: TSurvey;
|
||||
@@ -18,6 +16,7 @@ interface ConsentQuestionFormProps {
|
||||
isInvalid: boolean;
|
||||
locale: TUserLocale;
|
||||
isStorageConfigured: boolean;
|
||||
isExternalUrlsAllowed?: boolean;
|
||||
}
|
||||
|
||||
export const ConsentQuestionForm = ({
|
||||
@@ -30,57 +29,48 @@ export const ConsentQuestionForm = ({
|
||||
setSelectedLanguageCode,
|
||||
locale,
|
||||
isStorageConfigured = true,
|
||||
isExternalUrlsAllowed,
|
||||
}: ConsentQuestionFormProps): JSX.Element => {
|
||||
const [firstRender, setFirstRender] = useState(true);
|
||||
const { t } = useTranslate();
|
||||
|
||||
// Common props shared across all QuestionFormInput components
|
||||
const commonInputProps = {
|
||||
localSurvey,
|
||||
questionIdx,
|
||||
isInvalid,
|
||||
updateQuestion,
|
||||
selectedLanguageCode,
|
||||
setSelectedLanguageCode,
|
||||
locale,
|
||||
isStorageConfigured,
|
||||
isExternalUrlsAllowed,
|
||||
};
|
||||
|
||||
return (
|
||||
<form>
|
||||
<QuestionFormInput
|
||||
{...commonInputProps}
|
||||
id="headline"
|
||||
label={t("environments.surveys.edit.question") + "*"}
|
||||
value={question.headline}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
isInvalid={isInvalid}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
label={t("environments.surveys.edit.question") + "*"}
|
||||
autoFocus={!question.headline?.default || question.headline.default.trim() === ""}
|
||||
/>
|
||||
|
||||
<div className="mt-3">
|
||||
<Label htmlFor="subheader">{t("common.description")}</Label>
|
||||
<div className="mt-2">
|
||||
<LocalizedEditor
|
||||
id="subheader"
|
||||
value={question.html}
|
||||
localSurvey={localSurvey}
|
||||
isInvalid={isInvalid}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
firstRender={firstRender}
|
||||
setFirstRender={setFirstRender}
|
||||
questionIdx={questionIdx}
|
||||
locale={locale}
|
||||
/>
|
||||
</div>
|
||||
<QuestionFormInput
|
||||
{...commonInputProps}
|
||||
id="subheader"
|
||||
value={question.subheader}
|
||||
label={t("common.description")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<QuestionFormInput
|
||||
{...commonInputProps}
|
||||
id="label"
|
||||
label={t("environments.surveys.edit.checkbox_label") + "*"}
|
||||
placeholder="I agree to the terms and conditions"
|
||||
value={question.label}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
isInvalid={isInvalid}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
|
||||
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { QuestionToggleTable } from "@/modules/ui/components/question-toggle-table";
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { type JSX, useEffect } from "react";
|
||||
import { TSurvey, TSurveyContactInfoQuestion } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
|
||||
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { QuestionToggleTable } from "@/modules/ui/components/question-toggle-table";
|
||||
|
||||
interface ContactInfoQuestionFormProps {
|
||||
localSurvey: TSurvey;
|
||||
@@ -22,6 +22,7 @@ interface ContactInfoQuestionFormProps {
|
||||
setSelectedLanguageCode: (language: string) => void;
|
||||
locale: TUserLocale;
|
||||
isStorageConfigured: boolean;
|
||||
isExternalUrlsAllowed?: boolean;
|
||||
}
|
||||
|
||||
export const ContactInfoQuestionForm = ({
|
||||
@@ -34,6 +35,7 @@ export const ContactInfoQuestionForm = ({
|
||||
setSelectedLanguageCode,
|
||||
locale,
|
||||
isStorageConfigured = true,
|
||||
isExternalUrlsAllowed,
|
||||
}: ContactInfoQuestionFormProps): JSX.Element => {
|
||||
const { t } = useTranslate();
|
||||
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages ?? []);
|
||||
@@ -82,7 +84,6 @@ export const ContactInfoQuestionForm = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [question.firstName, question.lastName, question.email, question.phone, question.company]);
|
||||
|
||||
// Auto animate
|
||||
const [parent] = useAutoAnimate();
|
||||
|
||||
return (
|
||||
@@ -99,6 +100,8 @@ export const ContactInfoQuestionForm = ({
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
autoFocus={!question.headline?.default || question.headline.default.trim() === ""}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
|
||||
<div ref={parent}>
|
||||
@@ -117,6 +120,8 @@ export const ContactInfoQuestionForm = ({
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
autoFocus={!question.subheader?.default || question.subheader.default.trim() === ""}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createI18nString } from "@/lib/i18n/utils";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TSurvey, TSurveyCTAQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { createI18nString } from "@/lib/i18n/utils";
|
||||
import { CTAQuestionForm } from "./cta-question-form";
|
||||
|
||||
vi.mock("@formkit/auto-animate/react", () => ({
|
||||
@@ -9,11 +9,11 @@ vi.mock("@formkit/auto-animate/react", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/survey/components/question-form-input", () => ({
|
||||
QuestionFormInput: () => <div data-testid="question-form-input">QuestionFormInput</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/multi-language-surveys/components/localized-editor", () => ({
|
||||
LocalizedEditor: () => <div data-testid="localized-editor">LocalizedEditor</div>,
|
||||
QuestionFormInput: ({ id }: { id: string }) => (
|
||||
<div data-testid="question-form-input" data-field-id={id}>
|
||||
QuestionFormInput-{id}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/options-switch", () => ({
|
||||
@@ -65,12 +65,18 @@ describe("CTAQuestionForm", () => {
|
||||
setSelectedLanguageCode={mockSetSelectedLanguageCode}
|
||||
locale={mockLocale}
|
||||
isStorageConfigured={true}
|
||||
isExternalUrlsAllowed={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const questionFormInputs = screen.getAllByTestId("question-form-input");
|
||||
expect(questionFormInputs.length).toBe(2);
|
||||
expect(screen.getByTestId("localized-editor")).toBeInTheDocument();
|
||||
expect(questionFormInputs.length).toBe(3);
|
||||
|
||||
// Check that we have headline, html (description), and buttonLabel fields
|
||||
expect(questionFormInputs[0]).toHaveAttribute("data-field-id", "headline");
|
||||
expect(questionFormInputs[1]).toHaveAttribute("data-field-id", "subheader");
|
||||
expect(questionFormInputs[2]).toHaveAttribute("data-field-id", "buttonLabel");
|
||||
|
||||
expect(screen.getByTestId("options-switch")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user