Compare commits

...

10 Commits

Author SHA1 Message Date
Johannes 75f05f85e9 remove race condition 2025-10-19 16:18:26 +02:00
Dhruwang Jariwala 74405cc05f fix: update OpenAPI schema for action class creation endpoint (#6617)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-10-18 15:16:48 +00:00
Johannes 785359955a chore: prevent phishing for CTA question & on thank you page (#6694)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-10-18 09:58:12 +00:00
Anshuman Pandey f6157d5109 fix: Duplicate PR for fixing invalid email validation (#6709)
Co-authored-by: Aashish-png <aashishsarwa512@gmail.com>
Co-authored-by: Aashish <59650752+Aashish-png@users.noreply.github.com>
Co-authored-by: Johannes <johannes@formbricks.com>
2025-10-17 19:10:45 +00:00
Matti Nannt 070dd9f268 chore: remove cloud infrastructure from main repository (#6686) 2025-10-17 12:58:03 +00:00
Johannes 7a40d647d8 fix: prevent navigation collapse/expand flash on page load (quick fix) (#6678)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-10-17 12:56:13 +00:00
Johannes 2186a1c60d revert: revert accidental merges (#6701) (#6703) 2025-10-17 05:47:17 +00:00
Victor Hugo dos Santos 2054de4a9d chore: add PR size guidelines and pre-push hook for size checks (#6679) 2025-10-17 04:57:18 +00:00
Johannes e068955fbf fix: removes unused migration and language flag from the codebase (#6704) 2025-10-16 15:34:04 +00:00
Johannes 4f5180ea8f fix: revert accidental merges (#6701) 2025-10-16 05:42:00 -07:00
88 changed files with 1509 additions and 2379 deletions
-152
View File
@@ -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
+165
View File
@@ -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
View File
@@ -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
@@ -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 });
-6
View File
@@ -168,12 +168,6 @@ export const createEnvironment = async (
description: "Your contact's last name",
type: "default",
},
{
key: "language",
name: "Language",
description: "The language preference of a contact",
type: "default",
},
],
},
},
+1 -1
View File
@@ -1315,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",
@@ -1344,6 +1343,7 @@
"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",
"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",
+1 -1
View File
@@ -1315,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",
@@ -1344,6 +1343,7 @@
"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",
"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",
+1 -1
View File
@@ -1315,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",
@@ -1344,6 +1343,7 @@
"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",
"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}\"",
+1 -1
View File
@@ -1315,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": "フォームの回答数を表示",
@@ -1344,6 +1343,7 @@
"error_saving_changes": "変更の保存中にエラーが発生しました",
"even_after_they_submitted_a_response_e_g_feedback_box": "回答を送信した後でも(例:フィードバックボックス)",
"everyone": "全員",
"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}\" クォータ で使用されています",
+1 -1
View File
@@ -1315,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",
@@ -1344,6 +1343,7 @@
"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",
"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}\"",
+1 -1
View File
@@ -1315,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",
@@ -1344,6 +1343,7 @@
"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",
"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}\"",
+1 -1
View File
@@ -1315,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",
@@ -1344,6 +1343,7 @@
"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",
"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}\"",
+1 -1
View File
@@ -1315,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": "显示 调查 响应 数量",
@@ -1344,6 +1343,7 @@
"error_saving_changes": "保存 更改 时 出错",
"even_after_they_submitted_a_response_e_g_feedback_box": "即使 他们 提交 了 回复(例如 反馈框)",
"everyone": "所有 人",
"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}\" 配额 使用",
+1 -1
View File
@@ -1315,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": "顯示問卷的回應數",
@@ -1344,6 +1343,7 @@
"error_saving_changes": "儲存變更時發生錯誤",
"even_after_they_submitted_a_response_e_g_feedback_box": "即使他們提交回應之後(例如,意見反應方塊)",
"everyone": "所有人",
"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}\" 配額中",
@@ -319,51 +319,6 @@ describe("createContactsFromCSV", () => {
createContactsFromCSV(csvData, environmentId, "skip", { email: "email", name: "name" })
).rejects.toThrow(genericError);
});
test("handles language attribute key like other default attributes", async () => {
vi.mocked(prisma.contact.findMany).mockResolvedValue([]);
vi.mocked(prisma.contactAttribute.findMany).mockResolvedValue([]);
vi.mocked(prisma.contactAttributeKey.findMany).mockResolvedValue([
{ key: "email", id: "id-email" },
{ key: "userId", id: "id-userId" },
{ key: "firstName", id: "id-firstName" },
{ key: "lastName", id: "id-lastName" },
{ key: "language", id: "id-language" },
] as any);
vi.mocked(prisma.contact.create).mockResolvedValue({
id: "c1",
environmentId,
createdAt: new Date(),
updatedAt: new Date(),
attributes: [
{ attributeKey: { key: "email" }, value: "john@example.com" },
{ attributeKey: { key: "userId" }, value: "user123" },
{ attributeKey: { key: "firstName" }, value: "John" },
{ attributeKey: { key: "lastName" }, value: "Doe" },
{ attributeKey: { key: "language" }, value: "en" },
],
} as any);
const csvData = [
{
email: "john@example.com",
userId: "user123",
firstName: "John",
lastName: "Doe",
language: "en",
},
];
const result = await createContactsFromCSV(csvData, environmentId, "skip", {
email: "email",
userId: "userId",
firstName: "firstName",
lastName: "lastName",
language: "language",
});
expect(Array.isArray(result)).toBe(true);
expect(result[0].id).toBe("c1");
// language attribute key should already exist, no need to create it
expect(prisma.contactAttributeKey.createMany).not.toHaveBeenCalled();
});
});
describe("buildContactWhereClause", () => {
@@ -27,6 +27,7 @@ interface LocalizedEditorProps {
questionId: string;
isCard?: boolean; // Flag to indicate if this is a welcome/ending card
autoFocus?: boolean;
isExternalUrlsAllowed?: boolean;
}
const checkIfValueIsIncomplete = (
@@ -58,6 +59,7 @@ export function LocalizedEditor({
questionId,
isCard,
autoFocus,
isExternalUrlsAllowed,
}: Readonly<LocalizedEditorProps>) {
const { t } = useTranslate();
@@ -90,6 +92,11 @@ export function LocalizedEditor({
key={`${questionId}-${id}-${selectedLanguageCode}`}
setFirstRender={setFirstRender}
setText={(v: string) => {
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];
@@ -113,8 +120,8 @@ export function LocalizedEditor({
}
const translatedContent = {
...(value ?? {}),
[selectedLanguageCode]: v,
...value,
[selectedLanguageCode]: sanitizedContent,
};
updateQuestion({ [id]: translatedContent });
return;
@@ -122,8 +129,8 @@ export function LocalizedEditor({
if (currentQuestion && currentQuestion[id] !== undefined) {
const translatedContent = {
...(value ?? {}),
[selectedLanguageCode]: v,
...value,
[selectedLanguageCode]: sanitizedContent,
};
updateQuestion(questionIdx, { [id]: translatedContent });
}
@@ -131,6 +138,7 @@ export function LocalizedEditor({
localSurvey={localSurvey}
questionId={questionId}
selectedLanguageCode={selectedLanguageCode}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
{localSurvey.languages.length > 1 && (
<div>
@@ -60,6 +60,7 @@ interface QuestionFormInputProps {
autoFocus?: boolean;
firstRender?: boolean;
setFirstRender?: (value: boolean) => void;
isExternalUrlsAllowed?: boolean;
}
export const QuestionFormInput = ({
@@ -85,6 +86,7 @@ export const QuestionFormInput = ({
autoFocus,
firstRender: externalFirstRender,
setFirstRender: externalSetFirstRender,
isExternalUrlsAllowed,
}: QuestionFormInputProps) => {
const { t } = useTranslate();
const defaultLanguageCode =
@@ -363,6 +365,7 @@ export const QuestionFormInput = ({
questionId={questionId}
isCard={isWelcomeCard || isEndingCard}
autoFocus={autoFocus}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
</div>
+11 -7
View File
@@ -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;
@@ -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();
@@ -109,6 +111,7 @@ export const AddressQuestionForm = ({
locale={locale}
isStorageConfigured={isStorageConfigured}
autoFocus={!question.headline?.default || question.headline.default.trim() === ""}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
<div ref={parent}>
@@ -128,6 +131,7 @@ export const AddressQuestionForm = ({
locale={locale}
isStorageConfigured={isStorageConfigured}
autoFocus={!question.subheader?.default || question.subheader.default.trim() === ""}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
</div>
</div>
@@ -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);
@@ -64,6 +66,7 @@ export const CalQuestionForm = ({
locale={locale}
isStorageConfigured={isStorageConfigured}
autoFocus={!question.headline?.default || question.headline.default.trim() === ""}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
<div>
{question.subheader !== undefined && (
@@ -82,6 +85,7 @@ export const CalQuestionForm = ({
locale={locale}
isStorageConfigured={isStorageConfigured}
autoFocus={!question.subheader?.default || question.subheader.default.trim() === ""}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
</div>
</div>
@@ -16,6 +16,7 @@ interface ConsentQuestionFormProps {
isInvalid: boolean;
locale: TUserLocale;
isStorageConfigured: boolean;
isExternalUrlsAllowed?: boolean;
}
export const ConsentQuestionForm = ({
@@ -28,6 +29,7 @@ export const ConsentQuestionForm = ({
setSelectedLanguageCode,
locale,
isStorageConfigured = true,
isExternalUrlsAllowed,
}: ConsentQuestionFormProps): JSX.Element => {
const { t } = useTranslate();
@@ -41,6 +43,7 @@ export const ConsentQuestionForm = ({
setSelectedLanguageCode,
locale,
isStorageConfigured,
isExternalUrlsAllowed,
};
return (
@@ -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 ?? []);
@@ -99,6 +101,7 @@ export const ContactInfoQuestionForm = ({
locale={locale}
isStorageConfigured={isStorageConfigured}
autoFocus={!question.headline?.default || question.headline.default.trim() === ""}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
<div ref={parent}>
@@ -118,6 +121,7 @@ export const ContactInfoQuestionForm = ({
locale={locale}
isStorageConfigured={isStorageConfigured}
autoFocus={!question.subheader?.default || question.subheader.default.trim() === ""}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
</div>
</div>
@@ -65,6 +65,7 @@ describe("CTAQuestionForm", () => {
setSelectedLanguageCode={mockSetSelectedLanguageCode}
locale={mockLocale}
isStorageConfigured={true}
isExternalUrlsAllowed={true}
/>
);
@@ -8,6 +8,7 @@ import { QuestionFormInput } from "@/modules/survey/components/question-form-inp
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
import { OptionsSwitch } from "@/modules/ui/components/options-switch";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
interface CTAQuestionFormProps {
localSurvey: TSurvey;
@@ -20,6 +21,7 @@ interface CTAQuestionFormProps {
isInvalid: boolean;
locale: TUserLocale;
isStorageConfigured: boolean;
isExternalUrlsAllowed?: boolean;
}
export const CTAQuestionForm = ({
@@ -33,6 +35,7 @@ export const CTAQuestionForm = ({
setSelectedLanguageCode,
locale,
isStorageConfigured = true,
isExternalUrlsAllowed,
}: CTAQuestionFormProps): JSX.Element => {
const { t } = useTranslate();
const options = [
@@ -58,6 +61,7 @@ export const CTAQuestionForm = ({
locale={locale}
isStorageConfigured={isStorageConfigured}
autoFocus={!question.headline?.default || question.headline.default.trim() === ""}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
<div className="mt-3">
@@ -73,14 +77,28 @@ export const CTAQuestionForm = ({
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
</div>
<div className="mt-3">
<OptionsSwitch
options={options}
currentOption={question.buttonExternal ? "external" : "internal"}
handleOptionChange={(e) => updateQuestion(questionIdx, { buttonExternal: e === "external" })}
/>
<TooltipRenderer
shouldRender={!isExternalUrlsAllowed && !question.buttonExternal}
tooltipContent={t("environments.surveys.edit.external_urls_paywall_tooltip")}>
<OptionsSwitch
options={options.map((opt) => ({
...opt,
disabled: opt.value === "external" && !isExternalUrlsAllowed && !question.buttonExternal,
}))}
currentOption={question.buttonExternal ? "external" : "internal"}
handleOptionChange={(e) => {
const canSwitchToExternal =
e !== "external" || isExternalUrlsAllowed || question.buttonExternal;
if (canSwitchToExternal) {
updateQuestion(questionIdx, { buttonExternal: e === "external" });
}
}}
/>
</TooltipRenderer>
</div>
<div className="mt-2 flex justify-between gap-8">
@@ -22,6 +22,7 @@ interface IDateQuestionFormProps {
isInvalid: boolean;
locale: TUserLocale;
isStorageConfigured: boolean;
isExternalUrlsAllowed?: boolean;
}
const dateOptions = [
@@ -49,6 +50,7 @@ export const DateQuestionForm = ({
setSelectedLanguageCode,
locale,
isStorageConfigured = true,
isExternalUrlsAllowed,
}: IDateQuestionFormProps): JSX.Element => {
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages);
const { t } = useTranslate();
@@ -69,6 +71,7 @@ export const DateQuestionForm = ({
locale={locale}
isStorageConfigured={isStorageConfigured}
autoFocus={!question.headline?.default || question.headline.default.trim() === ""}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
<div ref={parent}>
{question.subheader !== undefined && (
@@ -87,6 +90,7 @@ export const DateQuestionForm = ({
locale={locale}
isStorageConfigured={isStorageConfigured}
autoFocus={!question.subheader?.default || question.subheader.default.trim() === ""}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
</div>
</div>
@@ -8,7 +8,6 @@ import { useTranslate } from "@tolgee/react";
import { GripIcon, Handshake, Undo2 } from "lucide-react";
import { useMemo, useState } from "react";
import toast from "react-hot-toast";
import { TOrganizationBillingPlan } from "@formbricks/types/organizations";
import { TSurveyQuota } from "@formbricks/types/quota";
import {
TSurvey,
@@ -41,12 +40,12 @@ interface EditEndingCardProps {
isInvalid: boolean;
selectedLanguageCode: string;
setSelectedLanguageCode: (languageCode: string) => void;
plan: TOrganizationBillingPlan;
addEndingCard: (index: number) => void;
isFormbricksCloud: boolean;
locale: TUserLocale;
isStorageConfigured: boolean;
quotas: TSurveyQuota[];
isExternalUrlsAllowed: boolean;
}
export const EditEndingCard = ({
@@ -58,12 +57,12 @@ export const EditEndingCard = ({
isInvalid,
selectedLanguageCode,
setSelectedLanguageCode,
plan,
addEndingCard,
isFormbricksCloud,
locale,
isStorageConfigured,
quotas,
isExternalUrlsAllowed,
}: EditEndingCardProps) => {
const { t } = useTranslate();
@@ -73,7 +72,7 @@ export const EditEndingCard = ({
);
const isRedirectToUrlDisabled = isFormbricksCloud
? plan === "free" && endingCard.type !== "redirectToUrl"
? !isExternalUrlsAllowed && endingCard.type !== "redirectToUrl"
: false;
const [openDeleteConfirmationModal, setOpenDeleteConfirmationModal] = useState(false);
@@ -281,7 +280,7 @@ export const EditEndingCard = ({
<Collapsible.CollapsibleContent className={`flex flex-col px-4 ${open && "mt-3 pb-6"}`}>
<TooltipRenderer
shouldRender={endingCard.type === "endScreen" && isRedirectToUrlDisabled}
tooltipContent={t("environments.surveys.edit.redirect_to_url_not_available_on_free_plan")}
tooltipContent={t("environments.surveys.edit.external_urls_paywall_tooltip")}
triggerClass="w-full">
<OptionsSwitch
options={endingCardTypes}
@@ -309,6 +308,7 @@ export const EditEndingCard = ({
endingCard={endingCard}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
)}
{endingCard.type === "redirectToUrl" && (
@@ -111,6 +111,7 @@ const defaultProps = {
endingCard: defaultEndScreenCard,
locale: "en-US" as TUserLocale,
isStorageConfigured: true,
isExternalUrlsAllowed: true,
};
describe("EndScreenForm", () => {
@@ -286,4 +287,38 @@ describe("EndScreenForm", () => {
const buttonLabelInput = container.querySelector("#buttonLabel");
expect(buttonLabelInput).toBeInTheDocument();
});
test("disables buttonLink input when isExternalUrlsAllowed is false", () => {
const propsWithDisabledUrl = {
...defaultProps,
endingCard: {
...defaultEndScreenCard,
buttonLabel: createI18nString("Click Me", ["en"]),
buttonLink: "https://example.com",
},
isExternalUrlsAllowed: false,
};
const { container } = render(<EndScreenForm {...propsWithDisabledUrl} />);
const buttonLinkInput = container.querySelector("#buttonLink") as HTMLInputElement;
expect(buttonLinkInput).toBeInTheDocument();
expect(buttonLinkInput).toBeDisabled();
});
test("shows upgrade message when isExternalUrlsAllowed is false", () => {
const propsWithDisabledUrl = {
...defaultProps,
endingCard: {
...defaultEndScreenCard,
buttonLabel: createI18nString("Click Me", ["en"]),
buttonLink: undefined,
},
isExternalUrlsAllowed: false,
};
const { getByText } = render(<EndScreenForm {...propsWithDisabledUrl} />);
expect(getByText("environments.surveys.edit.external_urls_paywall_tooltip")).toBeInTheDocument();
});
});
@@ -24,6 +24,7 @@ interface EndScreenFormProps {
endingCard: TSurveyEndScreenCard;
locale: TUserLocale;
isStorageConfigured: boolean;
isExternalUrlsAllowed: boolean;
}
export const EndScreenForm = ({
@@ -36,6 +37,7 @@ export const EndScreenForm = ({
endingCard,
locale,
isStorageConfigured,
isExternalUrlsAllowed,
}: EndScreenFormProps) => {
const { t } = useTranslate();
const inputRef = useRef<HTMLInputElement>(null);
@@ -184,7 +186,7 @@ export const EndScreenForm = ({
ref={inputRef}
id="buttonLink"
name="buttonLink"
className="relative text-black caret-black"
className={`relative text-black caret-black ${!isExternalUrlsAllowed ? "cursor-not-allowed opacity-50" : ""}`}
placeholder="https://formbricks.com"
value={
recallToHeadline(
@@ -196,7 +198,8 @@ export const EndScreenForm = ({
"default"
)[selectedLanguageCode]
}
onChange={(e) => onChange(e.target.value)}
onChange={(e) => isExternalUrlsAllowed && onChange(e.target.value)}
disabled={!isExternalUrlsAllowed}
/>
{children}
</div>
@@ -204,6 +207,11 @@ export const EndScreenForm = ({
}}
/>
</div>
{!isExternalUrlsAllowed && (
<p className="text-xs text-slate-500">
{t("environments.surveys.edit.external_urls_paywall_tooltip")}
</p>
)}
</div>
</div>
)}
@@ -29,6 +29,7 @@ interface FileUploadFormProps {
isFormbricksCloud: boolean;
locale: TUserLocale;
isStorageConfigured: boolean;
isExternalUrlsAllowed?: boolean;
}
export const FileUploadQuestionForm = ({
@@ -43,6 +44,7 @@ export const FileUploadQuestionForm = ({
isFormbricksCloud,
locale,
isStorageConfigured = true,
isExternalUrlsAllowed,
}: FileUploadFormProps): JSX.Element => {
const [extension, setExtension] = useState("");
const { t } = useTranslate();
@@ -146,6 +148,7 @@ export const FileUploadQuestionForm = ({
locale={locale}
isStorageConfigured={isStorageConfigured}
autoFocus={!question.headline?.default || question.headline.default.trim() === ""}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
<div ref={parent}>
{question.subheader !== undefined && (
@@ -164,6 +167,7 @@ export const FileUploadQuestionForm = ({
locale={locale}
isStorageConfigured={isStorageConfigured}
autoFocus={!question.subheader?.default || question.subheader.default.trim() === ""}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
</div>
</div>
@@ -29,6 +29,7 @@ interface MatrixQuestionFormProps {
isInvalid: boolean;
locale: TUserLocale;
isStorageConfigured: boolean;
isExternalUrlsAllowed?: boolean;
}
export const MatrixQuestionForm = ({
@@ -41,6 +42,7 @@ export const MatrixQuestionForm = ({
setSelectedLanguageCode,
locale,
isStorageConfigured = true,
isExternalUrlsAllowed,
}: MatrixQuestionFormProps): JSX.Element => {
const languageCodes = extractLanguageCodes(localSurvey.languages);
const { t } = useTranslate();
@@ -201,6 +203,7 @@ export const MatrixQuestionForm = ({
locale={locale}
isStorageConfigured={isStorageConfigured}
autoFocus={!question.headline?.default || question.headline.default.trim() === ""}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
<div ref={parent}>
{question.subheader !== undefined && (
@@ -219,6 +222,7 @@ export const MatrixQuestionForm = ({
locale={locale}
isStorageConfigured={isStorageConfigured}
autoFocus={!question.subheader?.default || question.subheader.default.trim() === ""}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
</div>
</div>
@@ -34,6 +34,7 @@ interface MultipleChoiceQuestionFormProps {
isInvalid: boolean;
locale: TUserLocale;
isStorageConfigured: boolean;
isExternalUrlsAllowed?: boolean;
}
export const MultipleChoiceQuestionForm = ({
@@ -46,6 +47,7 @@ export const MultipleChoiceQuestionForm = ({
setSelectedLanguageCode,
locale,
isStorageConfigured = true,
isExternalUrlsAllowed,
}: MultipleChoiceQuestionFormProps): JSX.Element => {
const { t } = useTranslate();
const lastChoiceRef = useRef<HTMLInputElement>(null);
@@ -211,6 +213,7 @@ export const MultipleChoiceQuestionForm = ({
locale={locale}
isStorageConfigured={isStorageConfigured}
autoFocus={!question.headline?.default || question.headline.default.trim() === ""}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
<div ref={parent}>
@@ -230,6 +233,7 @@ export const MultipleChoiceQuestionForm = ({
locale={locale}
isStorageConfigured={isStorageConfigured}
autoFocus={!question.subheader?.default || question.subheader.default.trim() === ""}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
</div>
</div>
@@ -22,6 +22,7 @@ interface NPSQuestionFormProps {
isInvalid: boolean;
locale: TUserLocale;
isStorageConfigured: boolean;
isExternalUrlsAllowed?: boolean;
}
export const NPSQuestionForm = ({
@@ -35,6 +36,7 @@ export const NPSQuestionForm = ({
setSelectedLanguageCode,
locale,
isStorageConfigured = true,
isExternalUrlsAllowed,
}: NPSQuestionFormProps): JSX.Element => {
const { t } = useTranslate();
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages);
@@ -55,6 +57,7 @@ export const NPSQuestionForm = ({
locale={locale}
isStorageConfigured={isStorageConfigured}
autoFocus={!question.headline?.default || question.headline.default.trim() === ""}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
<div ref={parent}>
@@ -74,6 +77,7 @@ export const NPSQuestionForm = ({
locale={locale}
isStorageConfigured={isStorageConfigured}
autoFocus={!question.subheader?.default || question.subheader.default.trim() === ""}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
</div>
</div>
@@ -29,6 +29,7 @@ interface OpenQuestionFormProps {
isInvalid: boolean;
locale: TUserLocale;
isStorageConfigured: boolean;
isExternalUrlsAllowed?: boolean;
}
export const OpenQuestionForm = ({
@@ -41,6 +42,7 @@ export const OpenQuestionForm = ({
setSelectedLanguageCode,
locale,
isStorageConfigured = true,
isExternalUrlsAllowed,
}: OpenQuestionFormProps): JSX.Element => {
const { t } = useTranslate();
const questionTypes = [
@@ -79,7 +81,7 @@ export const OpenQuestionForm = ({
} else {
setIsCharLimitEnabled(false);
}
}, []);
}, [question?.charLimit?.max, question?.charLimit?.min]);
return (
<form>
@@ -96,6 +98,7 @@ export const OpenQuestionForm = ({
locale={locale}
isStorageConfigured={isStorageConfigured}
autoFocus={!question.headline?.default || question.headline.default.trim() === ""}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
<div ref={parent}>
@@ -115,6 +118,7 @@ export const OpenQuestionForm = ({
locale={locale}
isStorageConfigured={isStorageConfigured}
autoFocus={!question.subheader?.default || question.subheader.default.trim() === ""}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
</div>
</div>
@@ -64,6 +64,7 @@ interface QuestionCardProps {
responseCount: number;
onAlertTrigger: () => void;
isStorageConfigured: boolean;
isExternalUrlsAllowed: boolean;
}
export const QuestionCard = ({
@@ -88,6 +89,7 @@ export const QuestionCard = ({
responseCount,
onAlertTrigger,
isStorageConfigured = true,
isExternalUrlsAllowed,
}: QuestionCardProps) => {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: question.id,
@@ -294,6 +296,7 @@ export const QuestionCard = ({
isInvalid={isInvalid}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
) : question.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle ? (
<MultipleChoiceQuestionForm
@@ -306,6 +309,7 @@ export const QuestionCard = ({
isInvalid={isInvalid}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
) : question.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti ? (
<MultipleChoiceQuestionForm
@@ -318,6 +322,7 @@ export const QuestionCard = ({
isInvalid={isInvalid}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
) : question.type === TSurveyQuestionTypeEnum.NPS ? (
<NPSQuestionForm
@@ -331,6 +336,7 @@ export const QuestionCard = ({
isInvalid={isInvalid}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
) : question.type === TSurveyQuestionTypeEnum.CTA ? (
<CTAQuestionForm
@@ -344,6 +350,7 @@ export const QuestionCard = ({
isInvalid={isInvalid}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
) : question.type === TSurveyQuestionTypeEnum.Rating ? (
<RatingQuestionForm
@@ -357,6 +364,7 @@ export const QuestionCard = ({
isInvalid={isInvalid}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
) : question.type === TSurveyQuestionTypeEnum.Consent ? (
<ConsentQuestionForm
@@ -369,6 +377,7 @@ export const QuestionCard = ({
isInvalid={isInvalid}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
) : question.type === TSurveyQuestionTypeEnum.Date ? (
<DateQuestionForm
@@ -381,6 +390,7 @@ export const QuestionCard = ({
isInvalid={isInvalid}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
) : question.type === TSurveyQuestionTypeEnum.PictureSelection ? (
<PictureSelectionForm
@@ -407,6 +417,7 @@ export const QuestionCard = ({
isFormbricksCloud={isFormbricksCloud}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
) : question.type === TSurveyQuestionTypeEnum.Cal ? (
<CalQuestionForm
@@ -420,6 +431,7 @@ export const QuestionCard = ({
isInvalid={isInvalid}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
) : question.type === TSurveyQuestionTypeEnum.Matrix ? (
<MatrixQuestionForm
@@ -432,6 +444,7 @@ export const QuestionCard = ({
isInvalid={isInvalid}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
) : question.type === TSurveyQuestionTypeEnum.Address ? (
<AddressQuestionForm
@@ -444,6 +457,7 @@ export const QuestionCard = ({
isInvalid={isInvalid}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
) : question.type === TSurveyQuestionTypeEnum.Ranking ? (
<RankingQuestionForm
@@ -456,6 +470,7 @@ export const QuestionCard = ({
isInvalid={isInvalid}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
) : question.type === TSurveyQuestionTypeEnum.ContactInfo ? (
<ContactInfoQuestionForm
@@ -469,6 +484,7 @@ export const QuestionCard = ({
isInvalid={isInvalid}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
) : null}
<div className="mt-4">
@@ -1,9 +1,9 @@
import { QuestionCard } from "@/modules/survey/editor/components/question-card";
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { Project } from "@prisma/client";
import { TSurvey, TSurveyQuestionId } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { QuestionCard } from "@/modules/survey/editor/components/question-card";
interface QuestionsDraggableProps {
localSurvey: TSurvey;
@@ -24,6 +24,7 @@ interface QuestionsDraggableProps {
responseCount: number;
onAlertTrigger: () => void;
isStorageConfigured: boolean;
isExternalUrlsAllowed: boolean;
}
export const QuestionsDroppable = ({
@@ -45,6 +46,7 @@ export const QuestionsDroppable = ({
responseCount,
onAlertTrigger,
isStorageConfigured = true,
isExternalUrlsAllowed,
}: QuestionsDraggableProps) => {
const [parent] = useAutoAnimate();
@@ -75,6 +77,7 @@ export const QuestionsDroppable = ({
responseCount={responseCount}
onAlertTrigger={onAlertTrigger}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
))}
</SortableContext>
@@ -15,16 +15,16 @@ import { Language, Project } from "@prisma/client";
import { useTranslate } from "@tolgee/react";
import React, { SetStateAction, useEffect, useMemo } from "react";
import toast from "react-hot-toast";
import { TOrganizationBillingPlan } from "@formbricks/types/organizations";
import { TSurveyQuota } from "@formbricks/types/quota";
import {
TConditionGroup,
TSingleCondition,
TSurvey,
TSurveyLogic,
TSurveyLogicAction,
TSurveyQuestion,
TSurveyQuestionId,
} from "@formbricks/types/surveys/types";
import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types";
import { findQuestionsWithCyclicLogic } from "@formbricks/types/surveys/validation";
import { TUserLocale } from "@formbricks/types/user";
import { getDefaultEndingCard } from "@/app/lib/survey-builder";
@@ -61,13 +61,13 @@ interface QuestionsViewProps {
setSelectedLanguageCode: (languageCode: string) => void;
isMultiLanguageAllowed?: boolean;
isFormbricksCloud: boolean;
plan: TOrganizationBillingPlan;
isCxMode: boolean;
locale: TUserLocale;
responseCount: number;
setIsCautionDialogOpen: (open: boolean) => void;
isStorageConfigured: boolean;
quotas: TSurveyQuota[];
isExternalUrlsAllowed: boolean;
}
export const QuestionsView = ({
@@ -83,13 +83,13 @@ export const QuestionsView = ({
selectedLanguageCode,
isMultiLanguageAllowed,
isFormbricksCloud,
plan,
isCxMode,
locale,
responseCount,
setIsCautionDialogOpen,
isStorageConfigured = true,
quotas,
isExternalUrlsAllowed,
}: QuestionsViewProps) => {
const { t } = useTranslate();
const internalQuestionIdMap = useMemo(() => {
@@ -495,6 +495,7 @@ export const QuestionsView = ({
responseCount={responseCount}
onAlertTrigger={() => setIsCautionDialogOpen(true)}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
</DndContext>
@@ -519,12 +520,12 @@ export const QuestionsView = ({
isInvalid={invalidQuestions ? invalidQuestions.includes(ending.id) : false}
setSelectedLanguageCode={setSelectedLanguageCode}
selectedLanguageCode={selectedLanguageCode}
plan={plan}
addEndingCard={addEndingCard}
isFormbricksCloud={isFormbricksCloud}
locale={locale}
isStorageConfigured={isStorageConfigured}
quotas={quotas}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
);
})}
@@ -26,6 +26,7 @@ interface RankingQuestionFormProps {
isInvalid: boolean;
locale: TUserLocale;
isStorageConfigured: boolean;
isExternalUrlsAllowed?: boolean;
}
export const RankingQuestionForm = ({
@@ -38,6 +39,7 @@ export const RankingQuestionForm = ({
setSelectedLanguageCode,
locale,
isStorageConfigured = true,
isExternalUrlsAllowed,
}: RankingQuestionFormProps): JSX.Element => {
const { t } = useTranslate();
const lastChoiceRef = useRef<HTMLInputElement>(null);
@@ -132,6 +134,7 @@ export const RankingQuestionForm = ({
locale={locale}
isStorageConfigured={isStorageConfigured}
autoFocus={!question.headline?.default || question.headline.default.trim() === ""}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
<div ref={parent}>
@@ -151,6 +154,7 @@ export const RankingQuestionForm = ({
locale={locale}
isStorageConfigured={isStorageConfigured}
autoFocus={!question.subheader?.default || question.subheader.default.trim() === ""}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
</div>
</div>
@@ -23,6 +23,7 @@ interface RatingQuestionFormProps {
isInvalid: boolean;
locale: TUserLocale;
isStorageConfigured: boolean;
isExternalUrlsAllowed?: boolean;
}
export const RatingQuestionForm = ({
@@ -35,6 +36,7 @@ export const RatingQuestionForm = ({
setSelectedLanguageCode,
locale,
isStorageConfigured = true,
isExternalUrlsAllowed,
}: RatingQuestionFormProps) => {
const { t } = useTranslate();
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages);
@@ -55,6 +57,7 @@ export const RatingQuestionForm = ({
locale={locale}
isStorageConfigured={isStorageConfigured}
autoFocus={!question.headline?.default || question.headline.default.trim() === ""}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
<div ref={parent}>
@@ -74,6 +77,7 @@ export const RatingQuestionForm = ({
locale={locale}
isStorageConfigured={isStorageConfigured}
autoFocus={!question.subheader?.default || question.subheader.default.trim() === ""}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
</div>
</div>
@@ -1,5 +1,12 @@
"use client";
import { ActionClass, Environment, Language, OrganizationRole, Project } from "@prisma/client";
import { useCallback, useEffect, useRef, useState } from "react";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { TSurveyQuota } from "@formbricks/types/quota";
import { TSegment } from "@formbricks/types/segment";
import { TSurvey, TSurveyEditorTabs, TSurveyStyling } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { extractLanguageCodes, getEnabledLanguages } from "@/lib/i18n/utils";
import { structuredClone } from "@/lib/pollyfills/structuredClone";
import { useDocumentVisibility } from "@/lib/useDocumentVisibility";
@@ -14,14 +21,6 @@ import { SurveyMenuBar } from "@/modules/survey/editor/components/survey-menu-ba
import { TFollowUpEmailToUser } from "@/modules/survey/editor/types/survey-follow-up";
import { FollowUpsView } from "@/modules/survey/follow-ups/components/follow-ups-view";
import { PreviewSurvey } from "@/modules/ui/components/preview-survey";
import { ActionClass, Environment, Language, OrganizationRole, Project } from "@prisma/client";
import { useCallback, useEffect, useRef, useState } from "react";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { TOrganizationBillingPlan } from "@formbricks/types/organizations";
import { TSurveyQuota } from "@formbricks/types/quota";
import { TSegment } from "@formbricks/types/segment";
import { TSurvey, TSurveyEditorTabs, TSurveyStyling } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { refetchProjectAction } from "../actions";
interface SurveyEditorProps {
@@ -49,8 +48,8 @@ interface SurveyEditorProps {
userEmail: string;
teamMemberDetails: TFollowUpEmailToUser[];
isStorageConfigured: boolean;
plan: TOrganizationBillingPlan;
quotas: TSurveyQuota[];
isExternalUrlsAllowed: boolean;
}
export const SurveyEditor = ({
@@ -72,7 +71,6 @@ export const SurveyEditor = ({
isQuotasAllowed,
isCxMode = false,
locale,
plan,
projectPermission,
mailFrom,
isSurveyFollowUpsAllowed = false,
@@ -80,10 +78,12 @@ export const SurveyEditor = ({
teamMemberDetails,
isStorageConfigured,
quotas,
isExternalUrlsAllowed,
}: SurveyEditorProps) => {
const [activeView, setActiveView] = useState<TSurveyEditorTabs>("questions");
const [activeQuestionId, setActiveQuestionId] = useState<string | null>(null);
const [localSurvey, setLocalSurvey] = useState<TSurvey | null>(() => structuredClone(survey));
const [savedSurvey, setSavedSurvey] = useState<TSurvey>(survey);
const [invalidQuestions, setInvalidQuestions] = useState<string[] | null>(null);
const [selectedLanguageCode, setSelectedLanguageCode] = useState<string>("default");
const surveyEditorRef = useRef(null);
@@ -109,6 +109,7 @@ export const SurveyEditor = ({
const surveyClone = structuredClone(survey);
setLocalSurvey(surveyClone);
setSavedSurvey(surveyClone);
if (survey.questions.length > 0) {
setActiveQuestionId(survey.questions[0].id);
@@ -161,8 +162,9 @@ export const SurveyEditor = ({
<SurveyMenuBar
setLocalSurvey={setLocalSurvey}
localSurvey={localSurvey}
survey={survey}
survey={savedSurvey}
environmentId={environment.id}
setSavedSurvey={setSavedSurvey}
activeId={activeView}
setActiveId={setActiveView}
setInvalidQuestions={setInvalidQuestions}
@@ -197,17 +199,17 @@ export const SurveyEditor = ({
projectLanguages={projectLanguages}
invalidQuestions={invalidQuestions}
setInvalidQuestions={setInvalidQuestions}
selectedLanguageCode={selectedLanguageCode ? selectedLanguageCode : "default"}
selectedLanguageCode={selectedLanguageCode || "default"}
setSelectedLanguageCode={setSelectedLanguageCode}
isMultiLanguageAllowed={isMultiLanguageAllowed}
isFormbricksCloud={isFormbricksCloud}
plan={plan}
isCxMode={isCxMode}
locale={locale}
responseCount={responseCount}
setIsCautionDialogOpen={setIsCautionDialogOpen}
isStorageConfigured={isStorageConfigured}
quotas={quotas}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
)}
@@ -1,17 +1,12 @@
"use client";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { createSegmentAction } from "@/modules/ee/contacts/segments/actions";
import { Alert, AlertButton, AlertTitle } from "@/modules/ui/components/alert";
import { AlertDialog } from "@/modules/ui/components/alert-dialog";
import { Button } from "@/modules/ui/components/button";
import { Input } from "@/modules/ui/components/input";
import { Project } from "@prisma/client";
import { useTranslate } from "@tolgee/react";
import { isEqual } from "lodash";
import { ArrowLeftIcon, SettingsIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { flushSync } from "react-dom";
import toast from "react-hot-toast";
import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
import { TSegment } from "@formbricks/types/segment";
@@ -23,11 +18,18 @@ import {
ZSurveyEndScreenCard,
ZSurveyRedirectUrlCard,
} from "@formbricks/types/surveys/types";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { createSegmentAction } from "@/modules/ee/contacts/segments/actions";
import { Alert, AlertButton, AlertTitle } from "@/modules/ui/components/alert";
import { AlertDialog } from "@/modules/ui/components/alert-dialog";
import { Button } from "@/modules/ui/components/button";
import { Input } from "@/modules/ui/components/input";
import { updateSurveyAction } from "../actions";
import { isSurveyValid } from "../lib/validation";
interface SurveyMenuBarProps {
localSurvey: TSurvey;
setSavedSurvey: (survey: TSurvey) => void;
survey: TSurvey;
setLocalSurvey: (survey: TSurvey) => void;
environmentId: string;
@@ -47,6 +49,7 @@ interface SurveyMenuBarProps {
export const SurveyMenuBar = ({
localSurvey,
survey,
setSavedSurvey,
environmentId,
setLocalSurvey,
activeId,
@@ -247,9 +250,12 @@ export const SurveyMenuBar = ({
setIsSurveySaving(false);
if (updatedSurveyResponse?.data) {
setLocalSurvey(updatedSurveyResponse.data);
const updatedSurvey = updatedSurveyResponse.data;
flushSync(() => {
setLocalSurvey(updatedSurvey);
setSavedSurvey(updatedSurvey);
});
toast.success(t("environments.surveys.edit.changes_saved"));
router.refresh();
} else {
const errorMessage = getFormattedErrorMessage(updatedSurveyResponse);
toast.error(errorMessage);
@@ -292,12 +298,18 @@ export const SurveyMenuBar = ({
const segment = await handleSegmentUpdate();
clearSurveyLocalStorage();
await updateSurveyAction({
const publishedSurveyResponse = await updateSurveyAction({
...localSurvey,
status,
segment,
});
setIsSurveyPublishing(false);
if (publishedSurveyResponse?.data) {
const publishedSurvey = publishedSurveyResponse.data;
flushSync(() => {
setSavedSurvey(publishedSurvey);
});
}
router.push(`/environments/${environmentId}/surveys/${localSurvey.id}/summary?success=true`);
} catch (error) {
console.error(error);
@@ -0,0 +1,412 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { createI18nString } from "@/lib/i18n/utils";
import { checkExternalUrlsPermission } from "./check-external-urls-permission";
vi.mock("@/modules/survey/lib/survey", () => ({
getOrganizationBilling: vi.fn(),
}));
vi.mock("@/modules/survey/lib/permission", () => ({
getExternalUrlsPermission: vi.fn(),
}));
const { getOrganizationBilling } = await import("@/modules/survey/lib/survey");
const { getExternalUrlsPermission } = await import("@/modules/survey/lib/permission");
describe("checkExternalUrlsPermission", () => {
const mockOrganizationId = "org123";
const baseSurvey: TSurvey = {
id: "survey123",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Survey",
type: "link",
environmentId: "env123",
createdBy: "user123",
status: "draft",
displayOption: "displayOnce",
questions: [],
endings: [],
hiddenFields: { enabled: false },
delay: 0,
autoComplete: null,
projectOverwrites: null,
styling: null,
showLanguageSwitch: false,
segment: null,
surveyClosedMessage: null,
singleUse: null,
isVerifyEmailEnabled: false,
recaptcha: null,
isSingleResponsePerEmailEnabled: false,
isBackButtonHidden: false,
pin: null,
displayPercentage: null,
languages: [],
variables: [],
followUps: [],
welcomeCard: {
enabled: false,
timeToFinish: true,
showResponseCount: false,
},
triggers: [],
metadata: {},
} as unknown as TSurvey;
const mockOrganizationBilling = {
id: mockOrganizationId,
plan: "free",
};
beforeEach(() => {
vi.clearAllMocks();
});
test("should throw ResourceNotFoundError when organization billing is not found", async () => {
vi.mocked(getOrganizationBilling).mockResolvedValue(null);
await expect(checkExternalUrlsPermission(mockOrganizationId, baseSurvey, null)).rejects.toThrow(
ResourceNotFoundError
);
expect(getOrganizationBilling).toHaveBeenCalledWith(mockOrganizationId);
});
test("should allow external URLs when permission is granted", async () => {
vi.mocked(getOrganizationBilling).mockResolvedValue(mockOrganizationBilling as any);
vi.mocked(getExternalUrlsPermission).mockResolvedValue(true);
const surveyWithExternalUrl: TSurvey = {
...baseSurvey,
endings: [
{
id: "end1",
type: "endScreen",
headline: createI18nString("Thank you", ["en"]),
buttonLink: "https://example.com",
},
],
};
await expect(
checkExternalUrlsPermission(mockOrganizationId, surveyWithExternalUrl, null)
).resolves.not.toThrow();
});
test("should throw OperationNotAllowedError for new ending card button link without permission", async () => {
vi.mocked(getOrganizationBilling).mockResolvedValue(mockOrganizationBilling as any);
vi.mocked(getExternalUrlsPermission).mockResolvedValue(false);
const surveyWithNewButtonLink: TSurvey = {
...baseSurvey,
endings: [
{
id: "end1",
type: "endScreen",
headline: createI18nString("Thank you", ["en"]),
buttonLink: "https://example.com",
},
],
};
await expect(
checkExternalUrlsPermission(mockOrganizationId, surveyWithNewButtonLink, null)
).rejects.toThrow(OperationNotAllowedError);
await expect(
checkExternalUrlsPermission(mockOrganizationId, surveyWithNewButtonLink, null)
).rejects.toThrow("External URLs are not enabled for this organization");
});
test("should allow unchanged ending card button link (grandfathering)", async () => {
vi.mocked(getOrganizationBilling).mockResolvedValue(mockOrganizationBilling as any);
vi.mocked(getExternalUrlsPermission).mockResolvedValue(false);
const oldSurvey: TSurvey = {
...baseSurvey,
endings: [
{
id: "end1",
type: "endScreen",
headline: createI18nString("Thank you", ["en"]),
buttonLink: "https://example.com",
},
],
};
const newSurvey: TSurvey = {
...baseSurvey,
endings: [
{
id: "end1",
type: "endScreen",
headline: createI18nString("Thank you very much", ["en"]),
buttonLink: "https://example.com",
},
],
};
await expect(
checkExternalUrlsPermission(mockOrganizationId, newSurvey, oldSurvey)
).resolves.not.toThrow();
});
test("should throw OperationNotAllowedError for changed ending card button link without permission", async () => {
vi.mocked(getOrganizationBilling).mockResolvedValue(mockOrganizationBilling as any);
vi.mocked(getExternalUrlsPermission).mockResolvedValue(false);
const oldSurvey: TSurvey = {
...baseSurvey,
endings: [
{
id: "end1",
type: "endScreen",
headline: createI18nString("Thank you", ["en"]),
buttonLink: "https://example.com",
},
],
};
const newSurvey: TSurvey = {
...baseSurvey,
endings: [
{
id: "end1",
type: "endScreen",
headline: createI18nString("Thank you", ["en"]),
buttonLink: "https://different-url.com",
},
],
};
await expect(checkExternalUrlsPermission(mockOrganizationId, newSurvey, oldSurvey)).rejects.toThrow(
OperationNotAllowedError
);
});
test("should allow ending card without button link", async () => {
vi.mocked(getOrganizationBilling).mockResolvedValue(mockOrganizationBilling as any);
vi.mocked(getExternalUrlsPermission).mockResolvedValue(false);
const surveyWithoutButtonLink: TSurvey = {
...baseSurvey,
endings: [
{
id: "end1",
type: "endScreen",
headline: createI18nString("Thank you", ["en"]),
},
],
};
await expect(
checkExternalUrlsPermission(mockOrganizationId, surveyWithoutButtonLink, null)
).resolves.not.toThrow();
});
test("should throw OperationNotAllowedError for new external CTA button without permission", async () => {
vi.mocked(getOrganizationBilling).mockResolvedValue(mockOrganizationBilling as any);
vi.mocked(getExternalUrlsPermission).mockResolvedValue(false);
const surveyWithExternalCTA: TSurvey = {
...baseSurvey,
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.CTA,
headline: createI18nString("Click here", ["en"]),
buttonLabel: createI18nString("Visit", ["en"]),
buttonExternal: true,
buttonUrl: "https://example.com",
required: false,
},
],
};
await expect(
checkExternalUrlsPermission(mockOrganizationId, surveyWithExternalCTA, null)
).rejects.toThrow(OperationNotAllowedError);
await expect(
checkExternalUrlsPermission(mockOrganizationId, surveyWithExternalCTA, null)
).rejects.toThrow("External URLs are not enabled for this organization");
});
test("should allow unchanged external CTA button (grandfathering)", async () => {
vi.mocked(getOrganizationBilling).mockResolvedValue(mockOrganizationBilling as any);
vi.mocked(getExternalUrlsPermission).mockResolvedValue(false);
const oldSurvey: TSurvey = {
...baseSurvey,
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.CTA,
headline: createI18nString("Click here", ["en"]),
buttonLabel: createI18nString("Visit", ["en"]),
buttonExternal: true,
buttonUrl: "https://example.com",
required: false,
},
],
};
const newSurvey: TSurvey = {
...baseSurvey,
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.CTA,
headline: createI18nString("Click here now", ["en"]),
buttonLabel: createI18nString("Visit", ["en"]),
buttonExternal: true,
buttonUrl: "https://example.com",
required: false,
},
],
};
await expect(
checkExternalUrlsPermission(mockOrganizationId, newSurvey, oldSurvey)
).resolves.not.toThrow();
});
test("should throw OperationNotAllowedError when switching CTA button to external without permission", async () => {
vi.mocked(getOrganizationBilling).mockResolvedValue(mockOrganizationBilling as any);
vi.mocked(getExternalUrlsPermission).mockResolvedValue(false);
const oldSurvey: TSurvey = {
...baseSurvey,
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.CTA,
headline: createI18nString("Click here", ["en"]),
buttonLabel: createI18nString("Visit", ["en"]),
buttonExternal: false,
buttonUrl: "",
required: false,
},
],
};
const newSurvey: TSurvey = {
...baseSurvey,
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.CTA,
headline: createI18nString("Click here", ["en"]),
buttonLabel: createI18nString("Visit", ["en"]),
buttonExternal: true,
buttonUrl: "https://example.com",
required: false,
},
],
};
await expect(checkExternalUrlsPermission(mockOrganizationId, newSurvey, oldSurvey)).rejects.toThrow(
OperationNotAllowedError
);
});
test("should throw OperationNotAllowedError when changing external CTA button URL without permission", async () => {
vi.mocked(getOrganizationBilling).mockResolvedValue(mockOrganizationBilling as any);
vi.mocked(getExternalUrlsPermission).mockResolvedValue(false);
const oldSurvey: TSurvey = {
...baseSurvey,
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.CTA,
headline: createI18nString("Click here", ["en"]),
buttonLabel: createI18nString("Visit", ["en"]),
buttonExternal: true,
buttonUrl: "https://example.com",
required: false,
},
],
};
const newSurvey: TSurvey = {
...baseSurvey,
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.CTA,
headline: createI18nString("Click here", ["en"]),
buttonLabel: createI18nString("Visit", ["en"]),
buttonExternal: true,
buttonUrl: "https://different-url.com",
required: false,
},
],
};
await expect(checkExternalUrlsPermission(mockOrganizationId, newSurvey, oldSurvey)).rejects.toThrow(
OperationNotAllowedError
);
});
test("should allow internal CTA button without permission", async () => {
vi.mocked(getOrganizationBilling).mockResolvedValue(mockOrganizationBilling as any);
vi.mocked(getExternalUrlsPermission).mockResolvedValue(false);
const surveyWithInternalCTA: TSurvey = {
...baseSurvey,
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.CTA,
headline: createI18nString("Click here", ["en"]),
buttonLabel: createI18nString("Visit", ["en"]),
buttonExternal: false,
buttonUrl: "",
required: false,
},
],
};
await expect(
checkExternalUrlsPermission(mockOrganizationId, surveyWithInternalCTA, null)
).resolves.not.toThrow();
});
test("should handle surveys with multiple questions and endings", async () => {
vi.mocked(getOrganizationBilling).mockResolvedValue(mockOrganizationBilling as any);
vi.mocked(getExternalUrlsPermission).mockResolvedValue(false);
const complexSurvey: TSurvey = {
...baseSurvey,
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: createI18nString("Question 1", ["en"]),
required: false,
inputType: "text",
charLimit: { enabled: false },
},
{
id: "q2",
type: TSurveyQuestionTypeEnum.CTA,
headline: createI18nString("Click here", ["en"]),
buttonLabel: createI18nString("Visit", ["en"]),
buttonExternal: false,
buttonUrl: "",
required: false,
},
],
endings: [
{
id: "end1",
type: "endScreen",
headline: createI18nString("Thank you", ["en"]),
},
],
};
await expect(checkExternalUrlsPermission(mockOrganizationId, complexSurvey, null)).resolves.not.toThrow();
});
});
@@ -0,0 +1,62 @@
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getExternalUrlsPermission } from "@/modules/survey/lib/permission";
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
/**
* Checks if external URLs can be added or modified for the given organization.
* Grandfathers existing external URLs (allows keeping them even on free plan).
*
* @param { string } organizationId The ID of the organization to check.
* @param { TSurvey } newSurvey The new survey state.
* @param { TSurvey | null } oldSurvey The old survey state (or null for new surveys).
* @returns { Promise<void> } A promise that resolves if the permission is granted.
* @throws { ResourceNotFoundError } If the organization is not found.
* @throws { OperationNotAllowedError } If external URLs are not allowed and new/changed URLs are detected.
*/
export const checkExternalUrlsPermission = async (
organizationId: string,
newSurvey: TSurvey,
oldSurvey: TSurvey | null
): Promise<void> => {
const organizationBilling = await getOrganizationBilling(organizationId);
if (!organizationBilling) {
throw new ResourceNotFoundError("Organization", organizationId);
}
const isExternalUrlsAllowed = await getExternalUrlsPermission(organizationBilling.plan);
if (isExternalUrlsAllowed) {
return;
}
// Check ending cards for new/changed button links
for (const newEnding of newSurvey.endings) {
const oldEnding = oldSurvey?.endings.find((e) => e.id === newEnding.id);
if (newEnding.type === "endScreen" && newEnding.buttonLink) {
if (!oldEnding || oldEnding.type !== "endScreen" || oldEnding.buttonLink !== newEnding.buttonLink) {
throw new OperationNotAllowedError(
"External URLs are not enabled for this organization. Upgrade to use external button links."
);
}
}
}
// Check CTA questions for new/changed external button URLs
for (const newQuestion of newSurvey.questions) {
const oldQuestion = oldSurvey?.questions.find((q) => q.id === newQuestion.id);
if (newQuestion.type === "cta" && newQuestion.buttonExternal) {
if (
!oldQuestion ||
oldQuestion.type !== "cta" ||
!oldQuestion.buttonExternal ||
oldQuestion.buttonUrl !== newQuestion.buttonUrl
) {
throw new OperationNotAllowedError(
"External URLs are not enabled for this organization. Upgrade to use external CTA buttons."
);
}
}
}
};
+15 -8
View File
@@ -21,6 +21,7 @@ import { getTeamMemberDetails } from "@/modules/survey/editor/lib/team";
import { getUserEmail } from "@/modules/survey/editor/lib/user";
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
import { getActionClasses } from "@/modules/survey/lib/action-class";
import { getExternalUrlsPermission } from "@/modules/survey/lib/permission";
import { getProjectWithTeamIdsByEnvironmentId } from "@/modules/survey/lib/project";
import { getResponseCountBySurveyId } from "@/modules/survey/lib/response";
import { getOrganizationBilling, getSurvey } from "@/modules/survey/lib/survey";
@@ -71,13 +72,19 @@ export const SurveyEditorPage = async (props) => {
]);
const isUserTargetingAllowed = await getIsContactsEnabled();
const [isMultiLanguageAllowed, isSurveyFollowUpsAllowed, isSpamProtectionAllowed, isQuotasAllowed] =
await Promise.all([
getMultiLanguagePermission(organizationBilling.plan),
getSurveyFollowUpsPermission(organizationBilling.plan),
getIsSpamProtectionEnabled(organizationBilling.plan),
getIsQuotasEnabled(organizationBilling.plan),
]);
const [
isMultiLanguageAllowed,
isSurveyFollowUpsAllowed,
isSpamProtectionAllowed,
isQuotasAllowed,
isExternalUrlsAllowed,
] = await Promise.all([
getMultiLanguagePermission(organizationBilling.plan),
getSurveyFollowUpsPermission(organizationBilling.plan),
getIsSpamProtectionEnabled(organizationBilling.plan),
getIsQuotasEnabled(organizationBilling.plan),
getExternalUrlsPermission(organizationBilling.plan),
]);
const quotas = isQuotasAllowed && survey ? await getQuotas(survey.id) : [];
const [projectLanguages, teamMemberDetails] = await Promise.all([
@@ -115,7 +122,6 @@ export const SurveyEditorPage = async (props) => {
isMultiLanguageAllowed={isMultiLanguageAllowed}
isSpamProtectionAllowed={isSpamProtectionAllowed}
projectLanguages={projectLanguages}
plan={organizationBilling.plan}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
isUnsplashConfigured={!!UNSPLASH_ACCESS_KEY}
isCxMode={isCxMode}
@@ -127,6 +133,7 @@ export const SurveyEditorPage = async (props) => {
isStorageConfigured={IS_STORAGE_CONFIGURED}
isQuotasAllowed={isQuotasAllowed}
quotas={quotas}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
);
};
+127 -3
View File
@@ -1,10 +1,10 @@
import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
import { Organization } from "@prisma/client";
import { cleanup } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { checkSpamProtectionPermission } from "./permission";
import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
import { checkSpamProtectionPermission, getExternalUrlsPermission } from "./permission";
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getIsSpamProtectionEnabled: vi.fn(),
@@ -14,6 +14,16 @@ vi.mock("@/modules/survey/lib/survey", () => ({
getOrganizationBilling: vi.fn(),
}));
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: true,
PROJECT_FEATURE_KEYS: {
FREE: "free",
PRO: "pro",
ENTERPRISE: "enterprise",
SCALE: "scale",
},
}));
describe("checkSpamProtectionPermission", () => {
const mockOrganizationId = "mock-organization-id";
const mockBillingData: Organization["billing"] = {
@@ -51,3 +61,117 @@ describe("checkSpamProtectionPermission", () => {
);
});
});
describe("getExternalUrlsPermission - Formbricks Cloud", () => {
test("should return false for free plan in Formbricks Cloud", async () => {
const result = await getExternalUrlsPermission("free");
expect(result).toBe(false);
});
test("should return true for pro plan in Formbricks Cloud", async () => {
const result = await getExternalUrlsPermission("pro");
expect(result).toBe(true);
});
test("should return true for enterprise plan in Formbricks Cloud", async () => {
const result = await getExternalUrlsPermission("enterprise");
expect(result).toBe(true);
});
test("should return true for scale plan in Formbricks Cloud", async () => {
const result = await getExternalUrlsPermission("scale");
expect(result).toBe(true);
});
test("should return true for any non-free plan string in Formbricks Cloud", async () => {
const result = await getExternalUrlsPermission("custom-plan");
expect(result).toBe(true);
});
});
describe("getExternalUrlsPermission - Self-hosted", () => {
afterEach(() => {
cleanup();
vi.resetModules();
});
test("should return true for free plan in self-hosted", async () => {
vi.doMock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
PROJECT_FEATURE_KEYS: {
FREE: "free",
PRO: "pro",
ENTERPRISE: "enterprise",
SCALE: "scale",
},
}));
const { getExternalUrlsPermission: getExternalUrlsPermissionSelfHosted } = await import("./permission");
const result = await getExternalUrlsPermissionSelfHosted("free");
expect(result).toBe(true);
});
test("should return true for pro plan in self-hosted", async () => {
vi.doMock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
PROJECT_FEATURE_KEYS: {
FREE: "free",
PRO: "pro",
ENTERPRISE: "enterprise",
SCALE: "scale",
},
}));
const { getExternalUrlsPermission: getExternalUrlsPermissionSelfHosted } = await import("./permission");
const result = await getExternalUrlsPermissionSelfHosted("pro");
expect(result).toBe(true);
});
test("should return true for enterprise plan in self-hosted", async () => {
vi.doMock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
PROJECT_FEATURE_KEYS: {
FREE: "free",
PRO: "pro",
ENTERPRISE: "enterprise",
SCALE: "scale",
},
}));
const { getExternalUrlsPermission: getExternalUrlsPermissionSelfHosted } = await import("./permission");
const result = await getExternalUrlsPermissionSelfHosted("enterprise");
expect(result).toBe(true);
});
test("should return true for scale plan in self-hosted", async () => {
vi.doMock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
PROJECT_FEATURE_KEYS: {
FREE: "free",
PRO: "pro",
ENTERPRISE: "enterprise",
SCALE: "scale",
},
}));
const { getExternalUrlsPermission: getExternalUrlsPermissionSelfHosted } = await import("./permission");
const result = await getExternalUrlsPermissionSelfHosted("scale");
expect(result).toBe(true);
});
test("should return true for any plan in self-hosted", async () => {
vi.doMock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
PROJECT_FEATURE_KEYS: {
FREE: "free",
PRO: "pro",
ENTERPRISE: "enterprise",
SCALE: "scale",
},
}));
const { getExternalUrlsPermission: getExternalUrlsPermissionSelfHosted } = await import("./permission");
const result = await getExternalUrlsPermissionSelfHosted("custom-plan");
expect(result).toBe(true);
});
});
+10 -1
View File
@@ -1,6 +1,8 @@
import { Organization } from "@prisma/client";
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { IS_FORMBRICKS_CLOUD, PROJECT_FEATURE_KEYS } from "@/lib/constants";
import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
/**
* Checks if the organization has spam protection enabled.
@@ -20,3 +22,10 @@ export const checkSpamProtectionPermission = async (organizationId: string): Pro
throw new OperationNotAllowedError("Spam protection is not enabled for this organization");
}
};
export const getExternalUrlsPermission = async (
billingPlan: Organization["billing"]["plan"]
): Promise<boolean> => {
if (IS_FORMBRICKS_CLOUD) return billingPlan !== PROJECT_FEATURE_KEYS.FREE;
return true;
};
@@ -113,14 +113,32 @@ describe("Editor", () => {
expect(screen.getByTestId("toolbar-plugin")).toBeInTheDocument();
expect(screen.getByTestId("rich-text-plugin")).toBeInTheDocument();
expect(screen.getByTestId("list-plugin")).toBeInTheDocument();
expect(screen.getByTestId("link-plugin")).toBeInTheDocument();
expect(screen.getByTestId("auto-link-plugin")).toBeInTheDocument();
expect(screen.getByTestId("markdown-plugin")).toBeInTheDocument();
// Link plugins should not be rendered by default (isExternalUrlsAllowed is undefined/false)
expect(screen.queryByTestId("link-plugin")).not.toBeInTheDocument();
expect(screen.queryByTestId("auto-link-plugin")).not.toBeInTheDocument();
// Editor should be editable by default
expect(screen.getByTestId("lexical-composer")).toHaveAttribute("data-editable", "true");
});
test("renders link plugins when isExternalUrlsAllowed is true", () => {
render(<Editor getText={() => "Sample text"} setText={() => {}} isExternalUrlsAllowed={true} />);
// Link plugins should be rendered when external URLs are allowed
expect(screen.getByTestId("link-plugin")).toBeInTheDocument();
expect(screen.getByTestId("auto-link-plugin")).toBeInTheDocument();
});
test("does not render link plugins when isExternalUrlsAllowed is false", () => {
render(<Editor getText={() => "Sample text"} setText={() => {}} isExternalUrlsAllowed={false} />);
// Link plugins should not be rendered when external URLs are not allowed
expect(screen.queryByTestId("link-plugin")).not.toBeInTheDocument();
expect(screen.queryByTestId("auto-link-plugin")).not.toBeInTheDocument();
});
test("renders the editor with custom height", () => {
render(<Editor getText={() => "Sample text"} setText={() => {}} height="200px" />);
@@ -1,7 +1,7 @@
import { CodeHighlightNode, CodeNode } from "@lexical/code";
import { AutoLinkNode, LinkNode } from "@lexical/link";
import { ListItemNode, ListNode } from "@lexical/list";
import { TRANSFORMERS } from "@lexical/markdown";
import { LINK, TRANSFORMERS } from "@lexical/markdown";
import { AutoFocusPlugin } from "@lexical/react/LexicalAutoFocusPlugin";
import { LexicalComposer } from "@lexical/react/LexicalComposer";
import { ContentEditable } from "@lexical/react/LexicalContentEditable";
@@ -57,6 +57,7 @@ export type TextEditorProps = {
addFallback?: () => void;
autoFocus?: boolean;
id?: string;
isExternalUrlsAllowed?: boolean;
};
const editorConfig = {
@@ -114,6 +115,7 @@ export const Editor = (props: TextEditorProps) => {
recallItemsCount={recallItems.length}
setShowFallbackInput={setShowFallbackInput}
setShowLinkEditor={setShowLinkEditor}
isExternalUrlsAllowed={props.isExternalUrlsAllowed}
/>
{props.onEmptyChange ? <EditorContentChecker onEmptyChange={props.onEmptyChange} /> : null}
<div
@@ -136,8 +138,8 @@ export const Editor = (props: TextEditorProps) => {
ErrorBoundary={LexicalErrorBoundary}
/>
<ListPlugin />
<LinkPlugin />
<AutoLinkPlugin />
{props.isExternalUrlsAllowed && <LinkPlugin />}
{props.isExternalUrlsAllowed && <AutoLinkPlugin />}
{props.autoFocus && <AutoFocusPlugin />}
{props.localSurvey && props.questionId && props.selectedLanguageCode && (
<RecallPlugin
@@ -160,8 +162,8 @@ export const Editor = (props: TextEditorProps) => {
props.disableLists
? TRANSFORMERS.filter((value, index) => {
if (index !== 3 && index !== 4) return value;
})
: TRANSFORMERS
}).filter((t) => (props.isExternalUrlsAllowed ? true : t !== LINK))
: TRANSFORMERS.filter((t) => (props.isExternalUrlsAllowed ? true : t !== LINK))
}
/>
</div>
@@ -274,6 +274,8 @@ export const ToolbarPlugin = (
});
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
@@ -315,6 +317,14 @@ export const ToolbarPlugin = (
if (!props.editable) return <></>;
const getLinkItemTooltipText = () => {
if (!props.isExternalUrlsAllowed) {
return t("environments.surveys.edit.external_urls_paywall_tooltip");
}
return isLink ? t("environments.surveys.edit.edit_link") : t("environments.surveys.edit.insert_link");
};
const items = [
{
key: "bold",
@@ -345,10 +355,8 @@ export const ToolbarPlugin = (
icon: Link,
onClick: insertLink,
active: isLink,
tooltipText: isLink
? t("environments.surveys.edit.edit_link")
: t("environments.surveys.edit.insert_link"),
disabled: !isLink && !hasTextSelection,
tooltipText: getLinkItemTooltipText(),
disabled: !props.isExternalUrlsAllowed || (!isLink && !hasTextSelection),
},
{
key: "recall",
+49 -2
View File
@@ -2340,6 +2340,50 @@
"name": "My Action from Postman",
"type": "code"
},
"properties": {
"description": {
"description": "Optional description of the action class",
"type": "string"
},
"environmentId": {
"description": "The environment ID where the action class will be created",
"type": "string"
},
"key": {
"description": "Required when type is 'code'. A unique identifier for the action. Not needed for 'noCode' type.",
"minLength": 1,
"type": "string"
},
"name": {
"description": "Name of the action class",
"minLength": 1,
"type": "string"
},
"noCodeConfig": {
"description": "Configuration object required when type is 'noCode'. Defines the conditions for triggering the action. Not needed for 'code' type.",
"example": {
"elementSelector": {
"cssSelector": ".button-class",
"innerHtml": "Click me"
},
"type": "click",
"urlFilters": [
{
"rule": "contains",
"value": "https://www.google.com"
}
]
},
"nullable": true,
"type": "object"
},
"type": {
"description": "Type of action class",
"enum": ["code", "noCode"],
"type": "string"
}
},
"required": ["environmentId", "name", "type"],
"type": "object"
}
}
@@ -2417,8 +2461,11 @@
"application/json": {
"example": {
"code": "bad_request",
"details": {},
"message": "Database error when creating an action for environment clurwouax000azffxt7n5unn3"
"details": {
"environmentId": "Required",
"key": "Required"
},
"message": "Fields are missing or incorrectly formatted"
},
"schema": {
"type": "object"
-8
View File
@@ -1,8 +0,0 @@
#!/bin/bash
# This is a better (faster) alternative to the built-in Nix support
if ! has nix_direnv_version || ! nix_direnv_version 3.0.4; then
source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/3.0.4/direnvrc" "sha256-DzlYZ33mWF/Gs8DDeyjr8mnVmQGx7ASYqA5WlxwvBG4="
fi
use flake
-3
View File
@@ -1,3 +0,0 @@
.terraform/
builds
/.direnv/
-61
View File
@@ -1,61 +0,0 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1754767907,
"narHash": "sha256-8OnUzRQZkqtUol9vuUuQC30hzpMreKptNyET2T9lB6g=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "c5f08b62ed75415439d48152c2a784e36909b1bc",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-25.05",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}
-30
View File
@@ -1,30 +0,0 @@
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05";
flake-utils.url = "github:numtide/flake-utils";
};
outputs =
{
self,
nixpkgs,
flake-utils,
}:
flake-utils.lib.eachDefaultSystem (
system:
let
pkgs = import nixpkgs {
inherit system;
config.allowUnfree = true;
};
in
with pkgs;
{
devShells.default = mkShell {
buildInputs = [
awscli
terraform
];
};
}
);
}
@@ -1,33 +0,0 @@
repositories:
- name: helm-charts
url: ghcr.io/formbricks/helm-charts
oci: true
releases:
- name: formbricks
namespace: formbricks
chart: helm-charts/formbricks
version: ^3.0.0
values:
- values.yaml.gotmpl
set:
- name: deployment.image.tag
value: {{ requiredEnv "VERSION" }}
- name: deployment.image.repository
value: {{ requiredEnv "REPOSITORY" }}
labels:
environment: prod
- name: formbricks-stage
namespace: formbricks-stage
chart: helm-charts/formbricks
version: ^3.0.0
values:
- values-staging.yaml.gotmpl
createNamespace: true
set:
- name: deployment.image.tag
value: {{ requiredEnv "VERSION" }}
- name: deployment.image.repository
value: {{ requiredEnv "REPOSITORY" }}
labels:
environment: stage
@@ -1,91 +0,0 @@
nameOverride: "formbricks-stage"
## Deployment & Autoscaling
deployment:
image:
pullPolicy: Always
resources:
limits:
cpu: 2
memory: 2Gi
requests:
cpu: 1
memory: 1Gi
env:
RATE_LIMITING_DISABLED:
value: "1"
envFrom:
app-env:
nameSuffix: app-env
type: secret
nodeSelector:
karpenter.sh/capacity-type: spot
reloadOnChange: true
autoscaling:
enabled: true
maxReplicas: 95
minReplicas: 3
metrics:
- resource:
name: cpu
target:
averageUtilization: 60
type: Utilization
type: Resource
- resource:
name: memory
target:
averageUtilization: 60
type: Utilization
type: Resource
### Secrets
secret:
enabled: false
externalSecret:
enabled: true
files:
app-env:
dataFrom:
key: stage/formbricks/environment
app-secrets:
dataFrom:
key: stage/formbricks/secrets
refreshInterval: 1m
secretStore:
kind: ClusterSecretStore
name: aws-secrets-manager
## Ingress
ingress:
annotations:
alb.ingress.kubernetes.io/certificate-arn: {{ requiredEnv "FORMBRICKS_INGRESS_CERT_ARN" }}
alb.ingress.kubernetes.io/group.name: internal
alb.ingress.kubernetes.io/healthcheck-path: /health
alb.ingress.kubernetes.io/listen-ports: '[{"HTTP": 80}, {"HTTPS": 443}]'
alb.ingress.kubernetes.io/ssl-policy: ELBSecurityPolicy-TLS13-1-2-Res-2021-06
alb.ingress.kubernetes.io/ssl-redirect: "443"
alb.ingress.kubernetes.io/target-type: ip
enabled: true
hosts:
- host: stage.app.formbricks.com
paths:
- path: /
pathType: Prefix
serviceName: formbricks-stage
ingressClassName: alb
## RBAC
rbac:
enabled: true
serviceAccount:
annotations:
eks.amazonaws.com/role-arn: {{ requiredEnv "FORMBRICKS_ROLE_ARN" }}
additionalLabels: {}
enabled: true
name: formbricks-stage
## Dependencies
postgresql:
enabled: false
redis:
enabled: false
@@ -1,92 +0,0 @@
## Deployment & Autoscaling
deployment:
resources:
limits:
memory: 2Gi
requests:
cpu: 1
memory: 1Gi
env: {}
envFrom:
app-env:
nameSuffix: app-env
type: secret
nodeSelector:
karpenter.sh/capacity-type: on-demand
reloadOnChange: true
autoscaling:
enabled: true
maxReplicas: 95
minReplicas: 3
metrics:
- resource:
name: cpu
target:
averageUtilization: 60
type: Utilization
type: Resource
- resource:
name: memory
target:
averageUtilization: 60
type: Utilization
type: Resource
### Secrets
secret:
enabled: false
externalSecret:
enabled: true
files:
app-env:
dataFrom:
key: prod/formbricks/environment
app-secrets:
dataFrom:
key: prod/formbricks/secrets
refreshInterval: 1m
secretStore:
kind: ClusterSecretStore
name: aws-secrets-manager
## Ingress
ingress:
annotations:
alb.ingress.kubernetes.io/certificate-arn: {{ requiredEnv "FORMBRICKS_INGRESS_CERT_ARN" }}
alb.ingress.kubernetes.io/group.name: formbricks
alb.ingress.kubernetes.io/healthcheck-path: /health
alb.ingress.kubernetes.io/listen-ports: '[{"HTTP": 80}, {"HTTPS": 443}]'
alb.ingress.kubernetes.io/scheme: internet-facing
alb.ingress.kubernetes.io/ssl-policy: ELBSecurityPolicy-TLS13-1-2-2021-06
alb.ingress.kubernetes.io/load-balancer-attributes: idle_timeout.timeout_seconds=600,client_keep_alive.seconds=590
alb.ingress.kubernetes.io/ssl-redirect: "443"
alb.ingress.kubernetes.io/target-type: ip
enabled: true
hosts:
- host: app.k8s.formbricks.com
paths:
- path: /
pathType: Prefix
serviceName: formbricks
- host: app.formbricks.com
paths:
- path: /
pathType: Prefix
serviceName: formbricks
ingressClassName: alb
## RBAC
rbac:
enabled: true
serviceAccount:
annotations:
eks.amazonaws.com/role-arn: {{ requiredEnv "FORMBRICKS_ROLE_ARN" }}
additionalLabels: {}
enabled: true
name: formbricks
## Dependencies
postgresql:
enabled: false
redis:
enabled: false
-205
View File
@@ -1,205 +0,0 @@
# This file is maintained automatically by "terraform init".
# Manual edits may be lost in future updates.
provider "registry.terraform.io/hashicorp/aws" {
version = "5.100.0"
constraints = ">= 3.29.0, >= 4.0.0, >= 4.8.0, >= 4.33.0, >= 4.36.0, >= 4.47.0, >= 4.63.0, >= 5.0.0, >= 5.46.0, >= 5.73.0, >= 5.79.0, >= 5.81.0, >= 5.83.0, >= 5.86.0, >= 5.95.0, < 6.0.0"
hashes = [
"h1:Ijt7pOlB7Tr7maGQIqtsLFbl7pSMIj06TVdkoSBcYOw=",
"zh:054b8dd49f0549c9a7cc27d159e45327b7b65cf404da5e5a20da154b90b8a644",
"zh:0b97bf8d5e03d15d83cc40b0530a1f84b459354939ba6f135a0086c20ebbe6b2",
"zh:1589a2266af699cbd5d80737a0fe02e54ec9cf2ca54e7e00ac51c7359056f274",
"zh:6330766f1d85f01ae6ea90d1b214b8b74cc8c1badc4696b165b36ddd4cc15f7b",
"zh:7c8c2e30d8e55291b86fcb64bdf6c25489d538688545eb48fd74ad622e5d3862",
"zh:99b1003bd9bd32ee323544da897148f46a527f622dc3971af63ea3e251596342",
"zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425",
"zh:9f8b909d3ec50ade83c8062290378b1ec553edef6a447c56dadc01a99f4eaa93",
"zh:aaef921ff9aabaf8b1869a86d692ebd24fbd4e12c21205034bb679b9caf883a2",
"zh:ac882313207aba00dd5a76dbd572a0ddc818bb9cbf5c9d61b28fe30efaec951e",
"zh:bb64e8aff37becab373a1a0cc1080990785304141af42ed6aa3dd4913b000421",
"zh:dfe495f6621df5540d9c92ad40b8067376350b005c637ea6efac5dc15028add4",
"zh:f0ddf0eaf052766cfe09dea8200a946519f653c384ab4336e2a4a64fdd6310e9",
"zh:f1b7e684f4c7ae1eed272b6de7d2049bb87a0275cb04dbb7cda6636f600699c9",
"zh:ff461571e3f233699bf690db319dfe46aec75e58726636a0d97dd9ac6e32fb70",
]
}
provider "registry.terraform.io/hashicorp/cloudinit" {
version = "2.3.7"
constraints = ">= 2.0.0"
hashes = [
"h1:M9TpQxKAE/hyOwytdX9MUNZw30HoD/OXqYIug5fkqH8=",
"zh:06f1c54e919425c3139f8aeb8fcf9bceca7e560d48c9f0c1e3bb0a8ad9d9da1e",
"zh:0e1e4cf6fd98b019e764c28586a386dc136129fef50af8c7165a067e7e4a31d5",
"zh:1871f4337c7c57287d4d67396f633d224b8938708b772abfc664d1f80bd67edd",
"zh:2b9269d91b742a71b2248439d5e9824f0447e6d261bfb86a8a88528609b136d1",
"zh:3d8ae039af21426072c66d6a59a467d51f2d9189b8198616888c1b7fc42addc7",
"zh:3ef4e2db5bcf3e2d915921adced43929214e0946a6fb11793085d9a48995ae01",
"zh:42ae54381147437c83cbb8790cc68935d71b6357728a154109d3220b1beb4dc9",
"zh:4496b362605ae4cbc9ef7995d102351e2fe311897586ffc7a4a262ccca0c782a",
"zh:652a2401257a12706d32842f66dac05a735693abcb3e6517d6b5e2573729ba13",
"zh:7406c30806f5979eaed5f50c548eced2ea18ea121e01801d2f0d4d87a04f6a14",
"zh:7848429fd5a5bcf35f6fee8487df0fb64b09ec071330f3ff240c0343fe2a5224",
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
]
}
provider "registry.terraform.io/hashicorp/external" {
version = "2.3.5"
constraints = ">= 1.0.0"
hashes = [
"h1:FnUk98MI5nOh3VJ16cHf8mchQLewLfN1qZG/MqNgPrI=",
"zh:6e89509d056091266532fa64de8c06950010498adf9070bf6ff85bc485a82562",
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
"zh:86868aec05b58dc0aa1904646a2c26b9367d69b890c9ad70c33c0d3aa7b1485a",
"zh:a2ce38fda83a62fa5fb5a70e6ca8453b168575feb3459fa39803f6f40bd42154",
"zh:a6c72798f4a9a36d1d1433c0372006cc9b904e8cfd60a2ae03ac5b7d2abd2398",
"zh:a8a3141d2fc71c86bf7f3c13b0b3be8a1b0f0144a47572a15af4dfafc051e28a",
"zh:aa20a1242eb97445ad26ebcfb9babf2cd675bdb81cac5f989268ebefa4ef278c",
"zh:b58a22445fb8804e933dcf835ab06c29a0f33148dce61316814783ee7f4e4332",
"zh:cb5626a661ee761e0576defb2a2d75230a3244799d380864f3089c66e99d0dcc",
"zh:d1acb00d20445f682c4e705c965e5220530209c95609194c2dc39324f3d4fcce",
"zh:d91a254ba77b69a29d8eae8ed0e9367cbf0ea6ac1a85b58e190f8cb096a40871",
"zh:f6592327673c9f85cdb6f20336faef240abae7621b834f189c4a62276ea5db41",
]
}
provider "registry.terraform.io/hashicorp/helm" {
version = "2.17.0"
constraints = ">= 2.9.0, ~> 2.17, < 3.0.0"
hashes = [
"h1:kQMkcPVvHOguOqnxoEU2sm1ND9vCHiT8TvZ2x6v/Rsw=",
"zh:06fb4e9932f0afc1904d2279e6e99353c2ddac0d765305ce90519af410706bd4",
"zh:104eccfc781fc868da3c7fec4385ad14ed183eb985c96331a1a937ac79c2d1a7",
"zh:129345c82359837bb3f0070ce4891ec232697052f7d5ccf61d43d818912cf5f3",
"zh:3956187ec239f4045975b35e8c30741f701aa494c386aaa04ebabffe7749f81c",
"zh:66a9686d92a6b3ec43de3ca3fde60ef3d89fb76259ed3313ca4eb9bb8c13b7dd",
"zh:88644260090aa621e7e8083585c468c8dd5e09a3c01a432fb05da5c4623af940",
"zh:a248f650d174a883b32c5b94f9e725f4057e623b00f171936dcdcc840fad0b3e",
"zh:aa498c1f1ab93be5c8fbf6d48af51dc6ef0f10b2ea88d67bcb9f02d1d80d3930",
"zh:bf01e0f2ec2468c53596e027d376532a2d30feb72b0b5b810334d043109ae32f",
"zh:c46fa84cc8388e5ca87eb575a534ebcf68819c5a5724142998b487cb11246654",
"zh:d0c0f15ffc115c0965cbfe5c81f18c2e114113e7a1e6829f6bfd879ce5744fbb",
"zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c",
]
}
provider "registry.terraform.io/hashicorp/kubernetes" {
version = "2.38.0"
constraints = ">= 2.20.0, ~> 2.36"
hashes = [
"h1:soK8Lt0SZ6dB+HsypFRDzuX/npqlMU6M0fvyaR1yW0k=",
"zh:0af928d776eb269b192dc0ea0f8a3f0f5ec117224cd644bdacdc682300f84ba0",
"zh:1be998e67206f7cfc4ffe77c01a09ac91ce725de0abaec9030b22c0a832af44f",
"zh:326803fe5946023687d603f6f1bab24de7af3d426b01d20e51d4e6fbe4e7ec1b",
"zh:4a99ec8d91193af961de1abb1f824be73df07489301d62e6141a656b3ebfff12",
"zh:5136e51765d6a0b9e4dbcc3b38821e9736bd2136cf15e9aac11668f22db117d2",
"zh:63fab47349852d7802fb032e4f2b6a101ee1ce34b62557a9ad0f0f0f5b6ecfdc",
"zh:924fb0257e2d03e03e2bfe9c7b99aa73c195b1f19412ca09960001bee3c50d15",
"zh:b63a0be5e233f8f6727c56bed3b61eb9456ca7a8bb29539fba0837f1badf1396",
"zh:d39861aa21077f1bc899bc53e7233262e530ba8a3a2d737449b100daeb303e4d",
"zh:de0805e10ebe4c83ce3b728a67f6b0f9d18be32b25146aa89116634df5145ad4",
"zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c",
"zh:faf23e45f0090eef8ba28a8aac7ec5d4fdf11a36c40a8d286304567d71c1e7db",
]
}
provider "registry.terraform.io/hashicorp/local" {
version = "2.5.3"
constraints = ">= 1.0.0"
hashes = [
"h1:MCzg+hs1/ZQ32u56VzJMWP9ONRQPAAqAjuHuzbyshvI=",
"zh:284d4b5b572eacd456e605e94372f740f6de27b71b4e1fd49b63745d8ecd4927",
"zh:40d9dfc9c549e406b5aab73c023aa485633c1b6b730c933d7bcc2fa67fd1ae6e",
"zh:6243509bb208656eb9dc17d3c525c89acdd27f08def427a0dce22d5db90a4c8b",
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
"zh:885d85869f927853b6fe330e235cd03c337ac3b933b0d9ae827ec32fa1fdcdbf",
"zh:bab66af51039bdfcccf85b25fe562cbba2f54f6b3812202f4873ade834ec201d",
"zh:c505ff1bf9442a889ac7dca3ac05a8ee6f852e0118dd9a61796a2f6ff4837f09",
"zh:d36c0b5770841ddb6eaf0499ba3de48e5d4fc99f4829b6ab66b0fab59b1aaf4f",
"zh:ddb6a407c7f3ec63efb4dad5f948b54f7f4434ee1a2607a49680d494b1776fe1",
"zh:e0dafdd4500bec23d3ff221e3a9b60621c5273e5df867bc59ef6b7e41f5c91f6",
"zh:ece8742fd2882a8fc9d6efd20e2590010d43db386b920b2a9c220cfecc18de47",
"zh:f4c6b3eb8f39105004cf720e202f04f57e3578441cfb76ca27611139bc116a82",
]
}
provider "registry.terraform.io/hashicorp/null" {
version = "3.2.4"
constraints = ">= 2.0.0, >= 3.0.0"
hashes = [
"h1:L5V05xwp/Gto1leRryuesxjMfgZwjb7oool4WS1UEFQ=",
"zh:59f6b52ab4ff35739647f9509ee6d93d7c032985d9f8c6237d1f8a59471bbbe2",
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
"zh:795c897119ff082133150121d39ff26cb5f89a730a2c8c26f3a9c1abf81a9c43",
"zh:7b9c7b16f118fbc2b05a983817b8ce2f86df125857966ad356353baf4bff5c0a",
"zh:85e33ab43e0e1726e5f97a874b8e24820b6565ff8076523cc2922ba671492991",
"zh:9d32ac3619cfc93eb3c4f423492a8e0f79db05fec58e449dee9b2d5873d5f69f",
"zh:9e15c3c9dd8e0d1e3731841d44c34571b6c97f5b95e8296a45318b94e5287a6e",
"zh:b4c2ab35d1b7696c30b64bf2c0f3a62329107bd1a9121ce70683dec58af19615",
"zh:c43723e8cc65bcdf5e0c92581dcbbdcbdcf18b8d2037406a5f2033b1e22de442",
"zh:ceb5495d9c31bfb299d246ab333f08c7fb0d67a4f82681fbf47f2a21c3e11ab5",
"zh:e171026b3659305c558d9804062762d168f50ba02b88b231d20ec99578a6233f",
"zh:ed0fe2acdb61330b01841fa790be00ec6beaac91d41f311fb8254f74eb6a711f",
]
}
provider "registry.terraform.io/hashicorp/random" {
version = "3.7.2"
constraints = ">= 2.0.0, >= 3.6.0"
hashes = [
"h1:KG4NuIBl1mRWU0KD/BGfCi1YN/j3F7H4YgeeM7iSdNs=",
"zh:14829603a32e4bc4d05062f059e545a91e27ff033756b48afbae6b3c835f508f",
"zh:1527fb07d9fea400d70e9e6eb4a2b918d5060d604749b6f1c361518e7da546dc",
"zh:1e86bcd7ebec85ba336b423ba1db046aeaa3c0e5f921039b3f1a6fc2f978feab",
"zh:24536dec8bde66753f4b4030b8f3ef43c196d69cccbea1c382d01b222478c7a3",
"zh:29f1786486759fad9b0ce4fdfbbfece9343ad47cd50119045075e05afe49d212",
"zh:4d701e978c2dd8604ba1ce962b047607701e65c078cb22e97171513e9e57491f",
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
"zh:7b8434212eef0f8c83f5a90c6d76feaf850f6502b61b53c329e85b3b281cba34",
"zh:ac8a23c212258b7976e1621275e3af7099e7e4a3d4478cf8d5d2a27f3bc3e967",
"zh:b516ca74431f3df4c6cf90ddcdb4042c626e026317a33c53f0b445a3d93b720d",
"zh:dc76e4326aec2490c1600d6871a95e78f9050f9ce427c71707ea412a2f2f1a62",
"zh:eac7b63e86c749c7d48f527671c7aee5b4e26c10be6ad7232d6860167f99dbb0",
]
}
provider "registry.terraform.io/hashicorp/time" {
version = "0.13.1"
constraints = ">= 0.9.0"
hashes = [
"h1:ZT5ppCNIModqk3iOkVt5my8b8yBHmDpl663JtXAIRqM=",
"zh:02cb9aab1002f0f2a94a4f85acec8893297dc75915f7404c165983f720a54b74",
"zh:04429b2b31a492d19e5ecf999b116d396dac0b24bba0d0fb19ecaefe193fdb8f",
"zh:26f8e51bb7c275c404ba6028c1b530312066009194db721a8427a7bc5cdbc83a",
"zh:772ff8dbdbef968651ab3ae76d04afd355c32f8a868d03244db3f8496e462690",
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
"zh:898db5d2b6bd6ca5457dccb52eedbc7c5b1a71e4a4658381bcbb38cedbbda328",
"zh:8de913bf09a3fa7bedc29fec18c47c571d0c7a3d0644322c46f3aa648cf30cd8",
"zh:9402102c86a87bdfe7e501ffbb9c685c32bbcefcfcf897fd7d53df414c36877b",
"zh:b18b9bb1726bb8cfbefc0a29cf3657c82578001f514bcf4c079839b6776c47f0",
"zh:b9d31fdc4faecb909d7c5ce41d2479dd0536862a963df434be4b16e8e4edc94d",
"zh:c951e9f39cca3446c060bd63933ebb89cedde9523904813973fbc3d11863ba75",
"zh:e5b773c0d07e962291be0e9b413c7a22c044b8c7b58c76e8aa91d1659990dfb5",
]
}
provider "registry.terraform.io/hashicorp/tls" {
version = "4.1.0"
constraints = ">= 3.0.0"
hashes = [
"h1:zEv9tY1KR5vaLSyp2lkrucNJ+Vq3c+sTFK9GyQGLtFs=",
"zh:14c35d89307988c835a7f8e26f1b83ce771e5f9b41e407f86a644c0152089ac2",
"zh:2fb9fe7a8b5afdbd3e903acb6776ef1be3f2e587fb236a8c60f11a9fa165faa8",
"zh:35808142ef850c0c60dd93dc06b95c747720ed2c40c89031781165f0c2baa2fc",
"zh:35b5dc95bc75f0b3b9c5ce54d4d7600c1ebc96fbb8dfca174536e8bf103c8cdc",
"zh:38aa27c6a6c98f1712aa5cc30011884dc4b128b4073a4a27883374bfa3ec9fac",
"zh:51fb247e3a2e88f0047cb97bb9df7c228254a3b3021c5534e4563b4007e6f882",
"zh:62b981ce491e38d892ba6364d1d0cdaadcee37cc218590e07b310b1dfa34be2d",
"zh:bc8e47efc611924a79f947ce072a9ad698f311d4a60d0b4dfff6758c912b7298",
"zh:c149508bd131765d1bc085c75a870abb314ff5a6d7f5ac1035a8892d686b6297",
"zh:d38d40783503d278b63858978d40e07ac48123a2925e1a6b47e62179c046f87a",
"zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c",
"zh:fb07f708e3316615f6d218cec198504984c0ce7000b9f1eebff7516e384f4b54",
]
}
-177
View File
@@ -1,177 +0,0 @@
# ################################################################################
# # GitOps Bridge: Bootstrap
# ################################################################################
# locals {
# addons = {
# enable_cert_manager = true
# enable_external_dns = true
# enable_istio = false
# enable_istio_ingress = false
# enable_external_secrets = true
# enable_metrics_server = false
# enable_keda = false
# enable_aws_load_balancer_controller = true
# enable_aws_ebs_csi_resources = false
# enable_velero = false
# enable_observability = false
# enable_karpenter = true
# }
#
# addons_default_versions = {
# cert_manager = "v1.17.1"
# external_dns = "1.15.2"
# karpenter = "1.3.0"
# external_secrets = "0.14.3"
# aws_load_balancer_controller = "1.10.0"
# # keda = "2.16.0"
# # istio = "1.23.3"
# }
#
# addons_metadata = merge(
# # module.addons.gitops_metadata
# {
# aws_cluster_name = module.eks.cluster_name
# aws_region = data.aws_region.selected.name
# aws_account_id = data.aws_caller_identity.current.account_id
# aws_vpc_id = module.vpc.vpc_id
# }
# )
#
# argocd_apps = {
# eks-addons = {
# project = "default"
# repo_url = var.addons_repo_url
# target_revision = var.addons_target_revision
# addons_repo_revision = var.addons_target_revision
# path = var.addons_repo_path
# values = merge({
# addons_repo_revision = var.addons_target_revision
# certManager = {
# enabled = local.addons.enable_cert_manager
# iamRoleArn = try(module.addons.gitops_metadata.cert_manager_iam_role_arn, "")
# values = try(yamldecode(join("\n", var.cert_manager_helm_config.values)), {})
# chartVersion = try(var.cert_manager_helm_config.chart_version, local.addons_default_versions.cert_manager)
# }
# externalDNS = {
# enabled = local.addons.enable_external_dns
# iamRoleArn = try(module.addons.gitops_metadata.external_dns_iam_role_arn, "")
# values = try(yamldecode(join("\n", var.external_dns_helm_config.values)), {})
# chartVersion = try(var.external_dns_helm_config.chart_version, local.addons_default_versions.external_dns)
# }
# externalSecrets = {
# enabled = local.addons.enable_external_secrets
# iamRoleArn = try(module.addons.gitops_metadata.external_secrets_iam_role_arn, "")
# values = try(yamldecode(join("\n", var.external_secrets_helm_config.values)), {})
# chartVersion = try(var.external_secrets_helm_config.chart_version, local.addons_default_versions.external_secrets)
# }
# karpenter = {
# enabled = true
# iamRoleArn = try(module.addons.gitops_metadata.karpenter_iam_role_arn, "")
# values = try(yamldecode(join("\n", var.karpenter_helm_config.values)), {})
# chartVersion = try(var.karpenter_helm_config.chart_version, local.addons_default_versions.karpenter)
# enableCrdWebhookConfig = true
# clusterName = module.eks.cluster_name
# clusterEndpoint = module.eks.cluster_endpoint
# interruptionQueue = try(module.addons.gitops_metadata.karpenter_interruption_queue, null)
# nodeIamRoleName = try(module.addons.gitops_metadata.karpenter_node_iam_role_arn, null)
# }
# loadBalancerController = {
# enabled = local.addons.enable_aws_load_balancer_controller
# iamRoleArn = try(module.addons.gitops_metadata.aws_load_balancer_controller_iam_role_arn, "")
# values = try(yamldecode(join("\n", var.aws_load_balancer_controller_helm_config.values)), {})
# clusterName = module.eks.cluster_name
# chartVersion = try(var.aws_load_balancer_controller_helm_config.chart_version, local.addons_default_versions.aws_load_balancer_controller)
# vpcId = module.vpc.vpc_id
# }
# })
# }
# workloads = {
# project = "default"
# repo_url = var.workloads_repo_url
# target_revision = var.workloads_target_revision
# addons_repo_revision = var.workloads_target_revision
# path = var.workloads_repo_path
# values = merge({
# addons_repo_revision = var.workloads_target_revision
# formbricks = {
# certificateArn = try(module.acm.acm_certificate_arn, "")
# ingressHost = "app.k8s.formbricks.com"
# env = {
# TEST = {
# value = "test "
# }
# }
# }
# })
# }
# }
# }
#
# variable "enable_gitops_bridge_bootstrap" {
# default = true
# }
#
# module "gitops_bridge_bootstrap" {
# count = var.enable_gitops_bridge_bootstrap ? 1 : 0
# source = "../modules/argocd-gitops-bridge"
#
# cluster = {
# metadata = local.addons_metadata
# }
# argocd = {
# chart_version = "7.8.7"
# values = [
# <<-EOT
# global:
# nodeSelector:
# CriticalAddonsOnly: "true"
# tolerations:
# - key: "CriticalAddonsOnly"
# operator: "Exists"
# effect: "NoSchedule"
# configs:
# params:
# server.insecure: true
# EOT
# ]
# }
# apps = local.argocd_apps
# }
#
# ###############################################################################
# # EKS Blueprints Addons
# ###############################################################################
# module "addons" {
# source = "../modules/addons"
# oidc_provider_arn = module.eks.oidc_provider_arn
# aws_region = data.aws_region.selected.name
# aws_account_id = data.aws_caller_identity.current.account_id
# aws_partition = data.aws_partition.current.partition
# cluster_name = module.eks.cluster_name
# cluster_endpoint = module.eks.cluster_endpoint
# cluster_certificate_authority_data = module.eks.cluster_certificate_authority_data
# cluster_token = data.aws_eks_cluster_auth.eks.token
# cluster_version = module.eks.cluster_version
# vpc_id = module.vpc.vpc_id
# node_security_group_id = module.eks.node_security_group_id
# cluster_security_group_id = module.eks.cluster_security_group_id
#
# # Using GitOps Bridge
# create_kubernetes_resources = var.enable_gitops_bridge_bootstrap ? false : true
#
# # Cert Manager
# enable_cert_manager = local.addons.enable_cert_manager
#
# # External DNS
# enable_external_dns = local.addons.enable_external_dns
#
# # Karpenter
# enable_karpenter = local.addons.enable_karpenter
#
# # External Secrets
# enable_external_secrets = local.addons.enable_external_secrets
#
# # Load Balancer Controller
# enable_aws_load_balancer_controller = local.addons.enable_aws_load_balancer_controller
#
# }
-252
View File
@@ -1,252 +0,0 @@
data "aws_ssm_parameter" "slack_notification_channel" {
name = "/prod/formbricks/slack-webhook-url"
with_decryption = true
}
resource "aws_cloudwatch_log_group" "cloudwatch_cis_benchmark" {
name = "/aws/cis-benchmark-group"
retention_in_days = 365
}
module "notify-slack" {
source = "terraform-aws-modules/notify-slack/aws"
version = "6.6.0"
slack_channel = "kubernetes"
slack_username = "formbricks-cloudwatch"
slack_webhook_url = data.aws_ssm_parameter.slack_notification_channel.value
sns_topic_name = "cloudwatch-alarms"
create_sns_topic = true
}
module "cloudwatch_cis-alarms" {
source = "terraform-aws-modules/cloudwatch/aws//modules/cis-alarms"
version = "5.7.1"
log_group_name = aws_cloudwatch_log_group.cloudwatch_cis_benchmark.name
alarm_actions = [module.notify-slack.slack_topic_arn]
}
locals {
alb_id = "app/k8s-formbricks-21ab9ecd60/342ed65d128ce4cb"
alarms = {
ALB_HTTPCode_Target_5XX_Count = {
alarm_description = "Average API 5XX target group error code count is too high"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 5
threshold = 5
period = 600
unit = "Count"
namespace = "AWS/ApplicationELB"
metric_name = "HTTPCode_Target_5XX_Count"
statistic = "Sum"
dimensions = {
LoadBalancer = local.alb_id
}
}
ALB_HTTPCode_ELB_5XX_Count = {
alarm_description = "Average API 5XX load balancer error code count is too high"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 5
threshold = 10
period = 600
unit = "Count"
namespace = "AWS/ApplicationELB"
metric_name = "HTTPCode_ELB_5XX_Count"
statistic = "Sum"
dimensions = {
LoadBalancer = local.alb_id
}
}
ALB_TargetResponseTime = {
alarm_description = format("Average API response time is greater than %s", 5)
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 5
threshold = 5
period = 60
unit = "Seconds"
namespace = "AWS/ApplicationELB"
metric_name = "TargetResponseTime"
statistic = "Average"
dimensions = {
LoadBalancer = local.alb_id
}
}
ALB_UnHealthyHostCount = {
alarm_description = format("Unhealthy host count is greater than %s", 2)
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 5
threshold = 2
period = 60
unit = "Count"
namespace = "AWS/ApplicationELB"
metric_name = "UnHealthyHostCount"
statistic = "Minimum"
dimensions = {
LoadBalancer = local.alb_id
}
}
RDS_CPUUtilization = {
alarm_description = format("Average RDS CPU utilization is greater than %s", 80)
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 5
threshold = 80
period = 60
unit = "Percent"
namespace = "AWS/RDS"
metric_name = "CPUUtilization"
statistic = "Average"
dimensions = {
DBInstanceIdentifier = module.rds-aurora["prod"].cluster_instances["one"].id
}
}
RDS_FreeStorageSpace = {
alarm_description = format("Average RDS free storage space is less than %s", 5)
comparison_operator = "LessThanThreshold"
evaluation_periods = 5
threshold = 5
period = 60
unit = "Gigabytes"
namespace = "AWS/RDS"
metric_name = "FreeStorageSpace"
statistic = "Average"
dimensions = {
DBInstanceIdentifier = module.rds-aurora["prod"].cluster_instances["one"].id
}
}
RDS_FreeableMemory = {
alarm_description = format("Average RDS freeable memory is less than %s", 100)
comparison_operator = "LessThanThreshold"
evaluation_periods = 5
threshold = 100
period = 60
unit = "Megabytes"
namespace = "AWS/RDS"
metric_name = "FreeableMemory"
statistic = "Average"
dimensions = {
DBInstanceIdentifier = module.rds-aurora["prod"].cluster_instances["one"].id
}
}
RDS_DiskQueueDepth = {
alarm_description = format("Average RDS disk queue depth is greater than %s", 1)
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 5
threshold = 1
period = 60
unit = "Count"
namespace = "AWS/RDS"
metric_name = "DiskQueueDepth"
statistic = "Average"
dimensions = {
DBInstanceIdentifier = module.rds-aurora["prod"].cluster_instances["one"].id
}
}
RDS_ReadIOPS = {
alarm_description = format("Average RDS read IOPS is greater than %s", 1000)
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 5
threshold = 1000
period = 60
unit = "Count/Second"
namespace = "AWS/RDS"
metric_name = "ReadIOPS"
statistic = "Average"
dimensions = {
DBInstanceIdentifier = module.rds-aurora["prod"].cluster_instances["one"].id
}
}
RDS_WriteIOPS = {
alarm_description = format("Average RDS write IOPS is greater than %s", 1000)
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 5
threshold = 1000
period = 60
unit = "Count/Second"
namespace = "AWS/RDS"
metric_name = "WriteIOPS"
statistic = "Average"
dimensions = {
DBInstanceIdentifier = module.rds-aurora["prod"].cluster_instances["one"].id
}
}
SQS_ApproximateAgeOfOldestMessage = {
alarm_description = format("Average SQS approximate age of oldest message is greater than %s", 300)
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 5
threshold = 300
period = 60
unit = "Seconds"
namespace = "AWS/SQS"
metric_name = "ApproximateAgeOfOldestMessage"
statistic = "Maximum"
dimensions = {
QueueName = module.karpenter.queue_name
}
}
DynamoDB_ConsumedReadCapacityUnits = {
alarm_description = format("Average DynamoDB consumed read capacity units is greater than %s", 90)
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 5
threshold = 90
period = 60
unit = "Count"
namespace = "AWS/DynamoDB"
metric_name = "ConsumedReadCapacityUnits"
statistic = "Average"
dimensions = {
TableName = "terraform-lock"
}
}
DynamoDB_ConsumedWriteCapacityUnits = {
alarm_description = format("Average DynamoDB consumed write capacity units is greater than %s", 90)
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 5
threshold = 90
period = 60
unit = "Count"
namespace = "AWS/DynamoDB"
metric_name = "ConsumedWriteCapacityUnits"
statistic = "Average"
dimensions = {
TableName = "terraform-lock"
}
}
Lambda_Errors = {
alarm_description = format("Average Lambda errors is greater than %s", 1)
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 5
threshold = 1
period = 60
unit = "Count"
namespace = "AWS/Lambda"
metric_name = "Errors"
statistic = "Sum"
dimensions = {
FunctionName = module.notify-slack.notify_slack_lambda_function_name
}
}
}
}
module "metric_alarm" {
source = "terraform-aws-modules/cloudwatch/aws//modules/metric-alarm"
version = "5.7.1"
for_each = local.alarms
alarm_name = each.key
alarm_description = each.value.alarm_description
comparison_operator = each.value.comparison_operator
evaluation_periods = each.value.evaluation_periods
threshold = each.value.threshold
period = each.value.period
unit = each.value.unit
insufficient_data_actions = []
namespace = each.value.namespace
metric_name = each.value.metric_name
statistic = each.value.statistic
dimensions = each.value.dimensions
alarm_actions = [module.notify-slack.slack_topic_arn]
}
-24
View File
@@ -1,24 +0,0 @@
data "aws_region" "selected" {}
data "aws_caller_identity" "current" {}
data "aws_availability_zones" "available" {}
data "aws_partition" "current" {}
data "aws_eks_cluster_auth" "eks" {
name = module.eks.cluster_name
}
data "aws_ecrpublic_authorization_token" "token" {
provider = aws.virginia
}
data "aws_iam_roles" "administrator" {
name_regex = "AWSReservedSSO_AdministratorAccess"
}
data "aws_iam_roles" "github" {
name_regex = "formbricks-prod-github"
}
data "aws_acm_certificate" "formbricks" {
domain = local.domain
}
-78
View File
@@ -1,78 +0,0 @@
################################################################################
# ElastiCache Module
################################################################################
locals {
valkey_major_version = 8
}
moved {
from = random_password.valkey
to = random_password.valkey["prod"]
}
resource "random_password" "valkey" {
for_each = local.envs
length = 20
special = false
}
module "valkey_sg" {
source = "terraform-aws-modules/security-group/aws"
version = "~> 5.0"
name = "valkey-sg"
description = "Security group for VPC traffic"
vpc_id = module.vpc.vpc_id
ingress_cidr_blocks = [module.vpc.vpc_cidr_block]
ingress_rules = ["redis-tcp"]
tags = local.tags
}
module "elasticache_user_group" {
for_each = local.envs
source = "terraform-aws-modules/elasticache/aws//modules/user-group"
version = "1.4.1"
user_group_id = "${each.value}-valkey"
create_default_user = false
default_user = {
user_id = each.value
passwords = [random_password.valkey[each.key].result]
}
users = {
"${each.value}" = {
access_string = "on ~* +@all"
passwords = [random_password.valkey[each.key].result]
}
}
engine = "redis"
tags = merge(local.tags, {
terraform-aws-modules = "elasticache"
})
}
module "valkey_serverless" {
for_each = local.envs
source = "terraform-aws-modules/elasticache/aws//modules/serverless-cache"
version = "1.4.1"
engine = "valkey"
cache_name = "${each.value}-valkey-serverless"
major_engine_version = local.valkey_major_version
# cache_usage_limits = {
# data_storage = {
# maximum = 2
# }
# ecpu_per_second = {
# maximum = 1000
# }
# }
subnet_ids = module.vpc.database_subnets
security_group_ids = [
module.valkey_sg.security_group_id
]
user_group_id = module.elasticache_user_group[each.key].group_id
}
-30
View File
@@ -1,30 +0,0 @@
################################################################################
# GitHub OIDC Provider
# Note: This is one per AWS account
################################################################################
module "iam_github_oidc_provider" {
source = "terraform-aws-modules/iam/aws//modules/iam-github-oidc-provider"
version = "5.54.0"
tags = local.tags
}
################################################################################
# GitHub OIDC Role
################################################################################
module "iam_github_oidc_role" {
source = "terraform-aws-modules/iam/aws//modules/iam-github-oidc-role"
version = "5.54.0"
name = "${local.name}-github"
subjects = [
"repo:formbricks/*:*",
]
policies = {
Administrator = "arn:aws:iam::aws:policy/AdministratorAccess"
}
tags = local.tags
}
-533
View File
@@ -1,533 +0,0 @@
locals {
project = "formbricks"
environment = "prod"
name = "${local.project}-${local.environment}"
envs = {
prod = "${local.project}-prod"
stage = "${local.project}-stage"
}
vpc_cidr = "10.0.0.0/16"
azs = slice(data.aws_availability_zones.available.names, 0, 3)
tags = {
Project = local.project
Environment = local.environment
ManagedBy = "Terraform"
Blueprint = local.name
}
tags_map = {
prod = {
Project = local.project
Environment = "prod"
ManagedBy = "Terraform"
Blueprint = "${local.project}-prod"
}
stage = {
Project = local.project
Environment = "stage"
ManagedBy = "Terraform"
Blueprint = "${local.project}-stage"
}
}
domain = "k8s.formbricks.com"
karpetner_helm_version = "1.3.1"
karpenter_namespace = "karpenter"
}
################################################################################
# Route53 Hosted Zone
################################################################################
module "route53_zones" {
source = "terraform-aws-modules/route53/aws//modules/zones"
version = "4.1.0"
zones = {
"k8s.formbricks.com" = {
comment = "${local.domain} (testing)"
tags = {
Name = local.domain
}
}
}
}
################################################################################
# VPC
################################################################################
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.19.0"
name = "${local.name}-vpc"
cidr = local.vpc_cidr
azs = local.azs
private_subnets = [for k, v in local.azs : cidrsubnet(local.vpc_cidr, 4, k)] # /20
public_subnets = [for k, v in local.azs : cidrsubnet(local.vpc_cidr, 8, k + 48)] # Public LB /24
intra_subnets = [for k, v in local.azs : cidrsubnet(local.vpc_cidr, 8, k + 52)] # eks interface /24
database_subnets = [for k, v in local.azs : cidrsubnet(local.vpc_cidr, 8, k + 56)] # RDS / Elastic cache /24
database_subnet_group_name = "${local.name}-subnet-group"
enable_nat_gateway = true
single_nat_gateway = true
public_subnet_tags = {
"kubernetes.io/role/elb" = 1
}
private_subnet_tags = {
"kubernetes.io/role/internal-elb" = 1
# Tags subnets for Karpenter auto-discovery
"karpenter.sh/discovery" = "${local.name}-eks"
}
tags = local.tags
}
################################################################################
# VPC Endpoints Module
################################################################################
module "vpc_vpc-endpoints" {
source = "terraform-aws-modules/vpc/aws//modules/vpc-endpoints"
version = "5.19.0"
vpc_id = module.vpc.vpc_id
endpoints = {
"s3" = {
service = "s3"
service_type = "Gateway"
route_table_ids = flatten([
module.vpc.intra_route_table_ids,
module.vpc.private_route_table_ids,
module.vpc.public_route_table_ids
])
tags = { Name = "s3-vpc-endpoint" }
}
}
tags = local.tags
}
################################################################################
# EKS Module
################################################################################
module "ebs_csi_driver_irsa" {
source = "terraform-aws-modules/iam/aws//modules/iam-role-for-service-accounts-eks"
version = "~> 5.52"
role_name_prefix = "${local.name}-ebs-csi-driver-"
attach_ebs_csi_policy = true
oidc_providers = {
main = {
provider_arn = module.eks.oidc_provider_arn
namespace_service_accounts = ["kube-system:ebs-csi-controller-sa"]
}
}
tags = local.tags
}
module "eks" {
source = "terraform-aws-modules/eks/aws"
version = "20.37.2"
cluster_name = "${local.name}-eks"
cluster_version = "1.32"
enable_cluster_creator_admin_permissions = false
cluster_endpoint_public_access = false
cloudwatch_log_group_retention_in_days = 365
cluster_addons = {
coredns = {
most_recent = true
}
eks-pod-identity-agent = {
most_recent = true
}
aws-ebs-csi-driver = {
addon_version = "v1.46.0-eksbuild.1"
service_account_role_arn = module.ebs_csi_driver_irsa.iam_role_arn
}
kube-proxy = {
most_recent = true
}
vpc-cni = {
most_recent = true
}
}
cluster_security_group_additional_rules = {
ingress_from_vpc_cidr = {
description = "Allow all traffic from the VPC CIDR"
from_port = 0
to_port = 0
protocol = "-1"
type = "ingress"
cidr_blocks = [local.vpc_cidr]
}
}
kms_key_administrators = [
tolist(data.aws_iam_roles.github.arns)[0],
tolist(data.aws_iam_roles.administrator.arns)[0]
]
kms_key_users = [
tolist(data.aws_iam_roles.github.arns)[0],
tolist(data.aws_iam_roles.administrator.arns)[0]
]
access_entries = {
administrator = {
principal_arn = tolist(data.aws_iam_roles.administrator.arns)[0]
policy_associations = {
Admin = {
policy_arn = "arn:aws:eks::aws:cluster-access-policy/AmazonEKSClusterAdminPolicy"
access_scope = {
type = "cluster"
}
}
}
}
github = {
principal_arn = tolist(data.aws_iam_roles.github.arns)[0]
policy_associations = {
Admin = {
policy_arn = "arn:aws:eks::aws:cluster-access-policy/AmazonEKSClusterAdminPolicy"
access_scope = {
type = "cluster"
}
}
}
}
}
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnets
control_plane_subnet_ids = module.vpc.intra_subnets
eks_managed_node_group_defaults = {
iam_role_additional_policies = {
AmazonSSMManagedInstanceCore = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}
}
eks_managed_node_groups = {
system = {
ami_type = "BOTTLEROCKET_ARM_64"
instance_types = ["t4g.small"]
min_size = 2
max_size = 3
desired_size = 2
labels = {
CriticalAddonsOnly = "true"
"karpenter.sh/controller" = "true"
}
taints = {
addons = {
key = "CriticalAddonsOnly"
value = "true"
effect = "NO_SCHEDULE"
},
}
}
}
node_security_group_tags = merge(local.tags, {
# NOTE - if creating multiple security groups with this module, only tag the
# security group that Karpenter should utilize with the following tag
# (i.e. - at most, only one security group should have this tag in your account)
"karpenter.sh/discovery" = "${local.name}-eks"
})
tags = local.tags
}
module "karpenter" {
source = "terraform-aws-modules/eks/aws//modules/karpenter"
version = "20.34.0"
cluster_name = module.eks.cluster_name
enable_v1_permissions = true
# Name needs to match role name passed to the EC2NodeClass
node_iam_role_use_name_prefix = false
node_iam_role_name = local.name
create_pod_identity_association = true
namespace = local.karpenter_namespace
# Used to attach additional IAM policies to the Karpenter node IAM role
node_iam_role_additional_policies = {
AmazonSSMManagedInstanceCore = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}
tags = local.tags
}
output "karpenter_node_role" {
value = module.karpenter.node_iam_role_name
}
# resource "helm_release" "karpenter_crds" {
# name = "karpenter-crds"
# repository = "oci://public.ecr.aws/karpenter"
# repository_username = data.aws_ecrpublic_authorization_token.token.user_name
# repository_password = data.aws_ecrpublic_authorization_token.token.password
# chart = "karpenter-crd"
# version = "1.3.1"
# namespace = local.karpenter_namespace
# values = [
# <<-EOT
# webhook:
# enabled: true
# serviceNamespace: ${local.karpenter_namespace}
# EOT
# ]
# }
# resource "helm_release" "karpenter" {
# name = "karpenter"
# repository = "oci://public.ecr.aws/karpenter"
# repository_username = data.aws_ecrpublic_authorization_token.token.user_name
# repository_password = data.aws_ecrpublic_authorization_token.token.password
# chart = "karpenter"
# version = "1.3.1"
# namespace = local.karpenter_namespace
# skip_crds = true
#
# values = [
# <<-EOT
# nodeSelector:
# karpenter.sh/controller: 'true'
# dnsPolicy: Default
# settings:
# clusterName: ${module.eks.cluster_name}
# clusterEndpoint: ${module.eks.cluster_endpoint}
# interruptionQueue: ${module.karpenter.queue_name}
# EOT
# ]
# }
#
# resource "kubernetes_manifest" "ec2_node_class" {
# manifest = {
# apiVersion = "karpenter.k8s.aws/v1"
# kind = "EC2NodeClass"
# metadata = {
# name = "default"
# }
# spec = {
# amiSelectorTerms = [
# {
# alias = "bottlerocket@latest"
# }
# ]
# role = module.karpenter.node_iam_role_name
# subnetSelectorTerms = [
# {
# tags = {
# "karpenter.sh/discovery" = "${local.name}-eks"
# }
# }
# ]
# securityGroupSelectorTerms = [
# {
# tags = {
# "karpenter.sh/discovery" = "${local.name}-eks"
# }
# }
# ]
# tags = {
# "karpenter.sh/discovery" = "${local.name}-eks"
# }
# }
# }
# }
# resource "kubernetes_manifest" "node_pool" {
# manifest = {
# apiVersion = "karpenter.sh/v1"
# kind = "NodePool"
# metadata = {
# name = "default"
# }
# spec = {
# template = {
# spec = {
# nodeClassRef = {
# group = "karpenter.k8s.aws"
# kind = "EC2NodeClass"
# name = "default"
# }
# requirements = [
# {
# key = "karpenter.k8s.aws/instance-family"
# operator = "In"
# values = ["c8g", "c7g", "m8g", "m7g", "r8g", "r7g"]
# },
# {
# key = "karpenter.k8s.aws/instance-cpu"
# operator = "In"
# values = ["2", "4", "8"]
# },
# {
# key = "karpenter.k8s.aws/instance-hypervisor"
# operator = "In"
# values = ["nitro"]
# }
# ]
# }
# }
# limits = {
# cpu = 1000
# }
# disruption = {
# consolidationPolicy = "WhenEmptyOrUnderutilized"
# consolidateAfter = "30s"
# }
# }
# }
# }
module "eks_blueprints_addons" {
source = "aws-ia/eks-blueprints-addons/aws"
version = "~> 1"
cluster_name = module.eks.cluster_name
cluster_endpoint = module.eks.cluster_endpoint
cluster_version = module.eks.cluster_version
oidc_provider_arn = module.eks.oidc_provider_arn
enable_metrics_server = true
metrics_server = {
chart_version = "3.12.2"
}
enable_aws_load_balancer_controller = true
aws_load_balancer_controller = {
chart_version = "1.10.0"
values = [
<<-EOT
vpcId: ${module.vpc.vpc_id}
EOT
]
}
enable_external_dns = true
external_dns_route53_zone_arns = [module.route53_zones.route53_zone_zone_arn[local.domain]]
external_dns = {
chart_version = "1.15.2"
}
enable_cert_manager = false
cert_manager = {
chart_version = "v1.17.1"
values = [
<<-EOT
installCRDs: false
crds:
enabled: true
keep: true
EOT
]
}
enable_external_secrets = true
external_secrets = {
chart_version = "0.14.3"
}
tags = local.tags
}
### Formbricks App
moved {
from = module.formbricks_s3_bucket
to = module.formbricks_s3_bucket["prod"]
}
module "formbricks_s3_bucket" {
for_each = local.envs
source = "terraform-aws-modules/s3-bucket/aws"
version = "4.6.0"
bucket = each.key == "prod" ? "formbricks-cloud-eks" : "formbricks-cloud-eks-${each.key}"
force_destroy = true
control_object_ownership = true
object_ownership = "BucketOwnerPreferred"
versioning = {
enabled = true
}
cors_rule = [
{
allowed_methods = ["POST"]
allowed_origins = ["https://*"]
allowed_headers = ["*"]
expose_headers = []
}
]
}
moved {
from = module.formbricks_app_iam_policy
to = module.formbricks_app_iam_policy["prod"]
}
module "formbricks_app_iam_policy" {
for_each = local.envs
source = "terraform-aws-modules/iam/aws//modules/iam-policy"
version = "5.53.0"
name_prefix = each.key == "prod" ? "formbricks-" : "formbricks-${each.key}-"
path = "/"
description = "Policy for fombricks app"
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"s3:*",
]
Resource = [
module.formbricks_s3_bucket[each.key].s3_bucket_arn,
"${module.formbricks_s3_bucket[each.key].s3_bucket_arn}/*"
]
}
]
})
}
moved {
from = module.formbricks_app_iam_role
to = module.formbricks_app_iam_role["prod"]
}
module "formbricks_app_iam_role" {
for_each = local.envs
source = "terraform-aws-modules/iam/aws//modules/iam-role-for-service-accounts-eks"
version = "5.53.0"
role_name_prefix = each.key == "prod" ? "formbricks-" : "formbricks-${each.key}-"
role_policy_arns = {
"formbricks" = module.formbricks_app_iam_policy[each.key].arn
}
assume_role_condition_test = "StringLike"
oidc_providers = {
eks = {
provider_arn = module.eks.oidc_provider_arn
namespace_service_accounts = each.key == "prod" ? ["formbricks:*"] : ["formbricks-${each.key}:*"]
}
}
}
-136
View File
@@ -1,136 +0,0 @@
module "loki_s3_bucket" {
source = "terraform-aws-modules/s3-bucket/aws"
version = "4.6.0"
bucket_prefix = "loki-"
force_destroy = true
control_object_ownership = true
object_ownership = "BucketOwnerPreferred"
}
module "observability_loki_iam_policy" {
source = "terraform-aws-modules/iam/aws//modules/iam-policy"
version = "5.53.0"
name_prefix = "loki-"
path = "/"
description = "Policy for fombricks observability apps"
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"s3:*",
]
Resource = [
module.loki_s3_bucket.s3_bucket_arn,
"${module.loki_s3_bucket.s3_bucket_arn}/*"
]
}
]
})
}
module "observability_loki_iam_role" {
source = "terraform-aws-modules/iam/aws//modules/iam-role-for-service-accounts-eks"
version = "5.53.0"
role_name_prefix = "loki-"
role_policy_arns = {
"formbricks" = module.observability_loki_iam_policy.arn
}
assume_role_condition_test = "StringLike"
oidc_providers = {
eks = {
provider_arn = module.eks.oidc_provider_arn
namespace_service_accounts = ["monitoring:loki"]
}
}
}
module "observability_grafana_iam_policy" {
source = "terraform-aws-modules/iam/aws//modules/iam-policy"
version = "5.53.0"
name_prefix = "grafana-"
path = "/"
description = "Policy for Formbricks observability apps - Grafana"
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "AllowReadingMetricsFromCloudWatch"
Effect = "Allow"
Action = [
"cloudwatch:DescribeAlarmsForMetric",
"cloudwatch:DescribeAlarmHistory",
"cloudwatch:DescribeAlarms",
"cloudwatch:ListMetrics",
"cloudwatch:GetMetricData",
"cloudwatch:GetInsightRuleReport"
]
Resource = "*"
},
{
Sid = "AllowReadingResourceMetricsFromPerformanceInsights"
Effect = "Allow"
Action = "pi:GetResourceMetrics"
Resource = "*"
},
{
Sid = "AllowReadingLogsFromCloudWatch"
Effect = "Allow"
Action = [
"logs:DescribeLogGroups",
"logs:GetLogGroupFields",
"logs:StartQuery",
"logs:StopQuery",
"logs:GetQueryResults",
"logs:GetLogEvents"
]
Resource = "*"
},
{
Sid = "AllowReadingTagsInstancesRegionsFromEC2"
Effect = "Allow"
Action = [
"ec2:DescribeTags",
"ec2:DescribeInstances",
"ec2:DescribeRegions"
]
Resource = "*"
},
{
Sid = "AllowReadingResourcesForTags"
Effect = "Allow"
Action = "tag:GetResources"
Resource = "*"
}
]
})
}
module "observability_grafana_iam_role" {
source = "terraform-aws-modules/iam/aws//modules/iam-role-for-service-accounts-eks"
version = "5.53.0"
role_name_prefix = "grafana-"
role_policy_arns = {
"formbricks" = module.observability_grafana_iam_policy.arn
}
assume_role_condition_test = "StringLike"
oidc_providers = {
eks = {
provider_arn = module.eks.oidc_provider_arn
namespace_service_accounts = ["monitoring:grafana"]
}
}
}
-31
View File
@@ -1,31 +0,0 @@
provider "aws" {
region = "eu-central-1"
}
provider "aws" {
region = "us-east-1"
alias = "virginia"
}
terraform {
backend "s3" {
bucket = "715841356175-terraform"
key = "terraform.tfstate"
region = "eu-central-1"
dynamodb_table = "terraform-lock"
}
}
provider "kubernetes" {
host = module.eks.cluster_endpoint
cluster_ca_certificate = base64decode(module.eks.cluster_certificate_authority_data)
token = data.aws_eks_cluster_auth.eks.token
}
provider "helm" {
kubernetes {
host = module.eks.cluster_endpoint
cluster_ca_certificate = base64decode(module.eks.cluster_certificate_authority_data)
token = data.aws_eks_cluster_auth.eks.token
}
}
-79
View File
@@ -1,79 +0,0 @@
################################################################################
# PostgreSQL Serverless v2
################################################################################
data "aws_rds_engine_version" "postgresql" {
engine = "aurora-postgresql"
version = "16.6"
}
moved {
from = random_password.postgres
to = random_password.postgres["prod"]
}
resource "random_password" "postgres" {
for_each = local.envs
length = 20
special = false
}
moved {
from = module.rds-aurora
to = module.rds-aurora["prod"]
}
module "rds-aurora" {
for_each = local.envs
source = "terraform-aws-modules/rds-aurora/aws"
version = "9.12.0"
name = "${each.value}-postgres"
engine = data.aws_rds_engine_version.postgresql.engine
engine_mode = "provisioned"
engine_version = data.aws_rds_engine_version.postgresql.version
storage_encrypted = true
master_username = "formbricks"
master_password = random_password.postgres[each.key].result
manage_master_user_password = false
create_db_cluster_parameter_group = true
db_cluster_parameter_group_family = data.aws_rds_engine_version.postgresql.parameter_group_family
db_cluster_parameter_group_parameters = [
{
name = "shared_preload_libraries"
value = "pglogical"
apply_method = "pending-reboot"
}
]
vpc_id = module.vpc.vpc_id
db_subnet_group_name = module.vpc.database_subnet_group_name
security_group_rules = {
vpc_ingress = {
cidr_blocks = [module.vpc.vpc_cidr_block]
}
}
performance_insights_enabled = true
cluster_performance_insights_enabled = true
backup_retention_period = 7
apply_immediately = true
skip_final_snapshot = false
deletion_protection = true
enable_http_endpoint = true
serverlessv2_scaling_configuration = {
min_capacity = 0.5
max_capacity = 50
}
instance_class = "db.serverless"
instances = {
one = {}
}
tags = local.tags_map[each.key]
}
-24
View File
@@ -1,24 +0,0 @@
# Create the first AWS Secrets Manager secret for environment variables
moved {
from = aws_secretsmanager_secret.formbricks_app_secrets
to = aws_secretsmanager_secret.formbricks_app_secrets["prod"]
}
resource "aws_secretsmanager_secret" "formbricks_app_secrets" {
for_each = local.envs
name = "${each.key}/formbricks/secrets"
}
moved {
from = aws_secretsmanager_secret_version.formbricks_app_secrets
to = aws_secretsmanager_secret_version.formbricks_app_secrets["prod"]
}
resource "aws_secretsmanager_secret_version" "formbricks_app_secrets" {
for_each = local.envs
secret_id = aws_secretsmanager_secret.formbricks_app_secrets[each.key].id
secret_string = jsonencode({
REDIS_URL = "rediss://${each.value}:${random_password.valkey[each.key].result}@${module.valkey_serverless[each.key].serverless_cache_endpoint[0].address}:6379"
})
}
-1
View File
@@ -1 +0,0 @@
#
-18
View File
@@ -1,18 +0,0 @@
terraform {
required_version = ">= 1.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.46"
}
kubernetes = {
source = "hashicorp/kubernetes"
version = "~> 2.36"
}
helm = {
source = "hashicorp/helm"
version = "~> 2.17"
}
}
}
@@ -1,60 +0,0 @@
/* eslint-disable no-constant-condition -- Required for the while loop */
/* eslint-disable @typescript-eslint/no-unnecessary-condition -- Required for a while loop here */
import { createId } from "@paralleldrive/cuid2";
import { logger } from "@formbricks/logger";
import type { MigrationScript } from "../../src/scripts/migration-runner";
export const addLanguageAttributeKey: MigrationScript = {
type: "data",
id: "add_language_attribute_key_v1",
name: "20251016000000_add_language_attribute_key",
run: async ({ tx }) => {
const BATCH_SIZE = 1000;
let skip = 0;
let totalProcessed = 0;
logger.info("Starting migration to add language attribute key to environments");
while (true) {
// Fetch environments in batches
const environments = await tx.$queryRaw<{ id: string }[]>`
SELECT id FROM "Environment"
LIMIT ${BATCH_SIZE} OFFSET ${skip}
`;
if (environments.length === 0) {
break;
}
logger.info(`Processing ${environments.length.toString()} environments`);
// Process each environment
for (const env of environments) {
// Insert language attribute key if it doesn't exist
await tx.$executeRaw`
INSERT INTO "ContactAttributeKey" (
"id", "created_at", "updated_at", "key", "name", "description", "type", "isUnique", "environmentId"
) VALUES (
${createId()},
NOW(),
NOW(),
'language',
'Language',
'The language preference of a contact',
'default',
false,
${env.id}
)
ON CONFLICT ("key", "environmentId") DO NOTHING
`;
}
totalProcessed += environments.length;
skip += BATCH_SIZE;
logger.info(`Processed ${totalProcessed.toString()} environments so far`);
}
logger.info(`Migration completed. Total environments processed: ${totalProcessed.toString()}`);
},
};
+2
View File
@@ -54,7 +54,9 @@ checksums:
errors/invalid_device_error/message: 8813dcd0e3e41934af18d7a15f8c83f4
errors/invalid_device_error/title: 20d261b478aaba161b0853a588926e23
errors/please_book_an_appointment: 9e8acea3721f660b6a988f79c4105ab8
errors/please_enter_a_valid_email_address: 8de4bc8832b11b380bc4cbcedc16e48b
errors/please_enter_a_valid_phone_number: 1530eb9ab7d6d190bddb37667c711631
errors/please_enter_a_valid_url: e3bcfb605be4ee32aa19d9ac32bb11a4
errors/please_fill_out_this_field: 88d4fd502ae8d423277aef723afcd1a7
errors/please_rank_all_items_before_submitting: 24fb14a2550bd7ec3e253dda0997cea8
errors/please_select_a_date: 1abdc8ffb887dbbdcc0d05486cd84de7
+2
View File
@@ -59,7 +59,9 @@
"title": "هذا الجهاز لا يدعم حماية البريد المزعج."
},
"please_book_an_appointment": "يرجى حجز موعد",
"please_enter_a_valid_email_address": "الرجاء إدخال عنوان بريد إلكتروني صالح",
"please_enter_a_valid_phone_number": "يرجى إدخال رقم هاتف صحيح",
"please_enter_a_valid_url": "الرجاء إدخال عنوان URL صالح",
"please_fill_out_this_field": "يرجى ملء هذا الحقل",
"please_rank_all_items_before_submitting": "يرجى ترتيب جميع العناصر قبل الإرسال",
"please_select_a_date": "يرجى اختيار تاريخ",
+2
View File
@@ -59,7 +59,9 @@
"title": "Dieses Gerät unterstützt keinen Spam-Schutz."
},
"please_book_an_appointment": "Bitte vereinbaren Sie einen Termin",
"please_enter_a_valid_email_address": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
"please_enter_a_valid_phone_number": "Bitte geben Sie eine gültige Telefonnummer ein",
"please_enter_a_valid_url": "Bitte geben Sie eine gültige URL ein",
"please_fill_out_this_field": "Bitte füllen Sie dieses Feld aus",
"please_rank_all_items_before_submitting": "Bitte ordnen Sie alle Elemente vor dem Absenden",
"please_select_a_date": "Bitte wählen Sie ein Datum aus",
+2
View File
@@ -59,7 +59,9 @@
"title": "This device doesnt support spam protection."
},
"please_book_an_appointment": "Please book an appointment",
"please_enter_a_valid_email_address": "Please enter a valid email address",
"please_enter_a_valid_phone_number": "Please enter a valid phone number",
"please_enter_a_valid_url": "Please enter a valid URL",
"please_fill_out_this_field": "Please fill out this field",
"please_rank_all_items_before_submitting": "Please rank all items before submitting",
"please_select_a_date": "Please select a date",
+2
View File
@@ -59,7 +59,9 @@
"title": "Este dispositivo no es compatible con la protección contra spam."
},
"please_book_an_appointment": "Por favor, reserve una cita",
"please_enter_a_valid_email_address": "Por favor, introduce una dirección de correo electrónico válida",
"please_enter_a_valid_phone_number": "Por favor, introduzca un número de teléfono válido",
"please_enter_a_valid_url": "Por favor, introduce una URL válida",
"please_fill_out_this_field": "Por favor, complete este campo",
"please_rank_all_items_before_submitting": "Por favor, clasifique todos los elementos antes de enviar",
"please_select_a_date": "Por favor, seleccione una fecha",
+2
View File
@@ -59,7 +59,9 @@
"title": "Cet appareil ne prend pas en charge la protection contre le spam."
},
"please_book_an_appointment": "Veuillez prendre rendez-vous",
"please_enter_a_valid_email_address": "Veuillez saisir une adresse e-mail valide",
"please_enter_a_valid_phone_number": "Veuillez saisir un numéro de téléphone valide",
"please_enter_a_valid_url": "Veuillez saisir une URL valide",
"please_fill_out_this_field": "Veuillez remplir ce champ",
"please_rank_all_items_before_submitting": "Veuillez classer tous les éléments avant de soumettre",
"please_select_a_date": "Veuillez sélectionner une date",
+2
View File
@@ -59,7 +59,9 @@
"title": "यह डिवाइस स्पैम सुरक्षा का समर्थन नहीं करता है।"
},
"please_book_an_appointment": "कृपया एक अपॉइंटमेंट बुक करें",
"please_enter_a_valid_email_address": "कृपया एक वैध ईमेल पता दर्ज करें",
"please_enter_a_valid_phone_number": "कृपया एक वैध फोन नंबर दर्ज करें",
"please_enter_a_valid_url": "कृपया एक वैध URL दर्ज करें",
"please_fill_out_this_field": "कृपया इस फील्ड को भरें",
"please_rank_all_items_before_submitting": "जमा करने से पहले कृपया सभी आइटम्स को रैंक करें",
"please_select_a_date": "कृपया एक तारीख चुनें",
+2
View File
@@ -59,7 +59,9 @@
"title": "Questo dispositivo non supporta la protezione anti-spam."
},
"please_book_an_appointment": "Prenota un appuntamento",
"please_enter_a_valid_email_address": "Inserisci un indirizzo email valido",
"please_enter_a_valid_phone_number": "Inserisci un numero di telefono valido",
"please_enter_a_valid_url": "Inserisci un URL valido",
"please_fill_out_this_field": "Compila questo campo",
"please_rank_all_items_before_submitting": "Classifica tutti gli elementi prima di inviare",
"please_select_a_date": "Seleziona una data",
+2
View File
@@ -59,7 +59,9 @@
"title": "このデバイスはスパム保護に対応していません。"
},
"please_book_an_appointment": "予約をお取りください",
"please_enter_a_valid_email_address": "有効なメールアドレスを入力してください",
"please_enter_a_valid_phone_number": "有効な電話番号を入力してください",
"please_enter_a_valid_url": "有効なURLを入力してください",
"please_fill_out_this_field": "このフィールドに入力してください",
"please_rank_all_items_before_submitting": "送信する前にすべての項目をランク付けしてください",
"please_select_a_date": "日付を選択してください",
+2
View File
@@ -59,7 +59,9 @@
"title": "Este dispositivo não suporta proteção contra spam."
},
"please_book_an_appointment": "Por favor, marque uma consulta",
"please_enter_a_valid_email_address": "Por favor, insira um endereço de email válido",
"please_enter_a_valid_phone_number": "Por favor, insira um número de telefone válido",
"please_enter_a_valid_url": "Por favor, insira uma URL válida",
"please_fill_out_this_field": "Por favor, preencha este campo",
"please_rank_all_items_before_submitting": "Por favor, classifique todos os itens antes de enviar",
"please_select_a_date": "Por favor, selecione uma data",
+2
View File
@@ -59,7 +59,9 @@
"title": "Acest dispozitiv nu acceptă protecția împotriva spamului."
},
"please_book_an_appointment": "Vă rugăm să faceți o programare",
"please_enter_a_valid_email_address": "Vă rugăm să introduceți o adresă de email validă",
"please_enter_a_valid_phone_number": "Vă rugăm să introduceți un număr de telefon valid",
"please_enter_a_valid_url": "Vă rugăm să introduceți un URL valid",
"please_fill_out_this_field": "Vă rugăm să completați acest câmp",
"please_rank_all_items_before_submitting": "Vă rugăm să clasificați toate elementele înainte de a trimite",
"please_select_a_date": "Vă rugăm să selectați o dată",
+2
View File
@@ -59,7 +59,9 @@
"title": "Это устройство не поддерживает защиту от спама."
},
"please_book_an_appointment": "Пожалуйста, запишитесь на приём",
"please_enter_a_valid_email_address": "Пожалуйста, введите действительный адрес электронной почты",
"please_enter_a_valid_phone_number": "Пожалуйста, введите действительный номер телефона",
"please_enter_a_valid_url": "Пожалуйста, введите действительный URL-адрес",
"please_fill_out_this_field": "Пожалуйста, заполните это поле",
"please_rank_all_items_before_submitting": "Пожалуйста, оцените все элементы перед отправкой",
"please_select_a_date": "Пожалуйста, выберите дату",
+2
View File
@@ -59,7 +59,9 @@
"title": "Ushbu qurilma spam himoyasini qo'llab-quvvatlamaydi."
},
"please_book_an_appointment": "Iltimos, uchrashuvni bron qiling",
"please_enter_a_valid_email_address": "Iltimos, toʻgʻri elektron pochta manzilini kiriting",
"please_enter_a_valid_phone_number": "Iltimos, to'g'ri telefon raqamini kiriting",
"please_enter_a_valid_url": "Iltimos, toʻgʻri URL manzilini kiriting",
"please_fill_out_this_field": "Iltimos, ushbu maydonni to'ldiring",
"please_rank_all_items_before_submitting": "Iltimos, yuborishdan oldin barcha elementlarni baholang",
"please_select_a_date": "Iltimos, sanani tanlang",
+2
View File
@@ -59,7 +59,9 @@
"title": "此设备不支持垃圾邮件保护。"
},
"please_book_an_appointment": "请预约",
"please_enter_a_valid_email_address": "请输入有效的电子邮件地址",
"please_enter_a_valid_phone_number": "请输入有效的电话号码",
"please_enter_a_valid_url": "请输入有效的URL",
"please_fill_out_this_field": "请填写此字段",
"please_rank_all_items_before_submitting": "请在提交之前对所有项目进行排名",
"please_select_a_date": "请选择一个日期",
@@ -454,4 +454,328 @@ describe("OpenTextQuestion", () => {
const textarea = screen.getByRole("textbox");
expect(textarea).not.toHaveAttribute("autoFocus");
});
test("prevents submission when required field is empty", async () => {
const onSubmit = vi.fn();
const setCustomValidityMock = vi.fn();
const reportValidityMock = vi.fn(() => true);
const setCustomValiditySpy = vi
.spyOn(HTMLInputElement.prototype, "setCustomValidity")
.mockImplementation(setCustomValidityMock);
const reportValiditySpy = vi
.spyOn(HTMLInputElement.prototype, "reportValidity")
.mockImplementation(reportValidityMock);
const { container } = render(
<OpenTextQuestion
{...defaultProps}
value=""
onSubmit={onSubmit}
question={{ ...defaultQuestion, required: true }}
/>
);
const form = container.querySelector("form");
fireEvent.submit(form!);
expect(setCustomValidityMock).toHaveBeenCalledWith("errors.please_fill_out_this_field");
expect(reportValidityMock).toHaveBeenCalled();
expect(onSubmit).not.toHaveBeenCalled();
setCustomValiditySpy.mockRestore();
reportValiditySpy.mockRestore();
});
test("prevents submission when required field contains only whitespace", async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
const setCustomValidityMock = vi.fn();
const reportValidityMock = vi.fn(() => true);
const setCustomValiditySpy = vi
.spyOn(HTMLInputElement.prototype, "setCustomValidity")
.mockImplementation(setCustomValidityMock);
const reportValiditySpy = vi
.spyOn(HTMLInputElement.prototype, "reportValidity")
.mockImplementation(reportValidityMock);
render(
<OpenTextQuestion
{...defaultProps}
value=" "
onSubmit={onSubmit}
question={{ ...defaultQuestion, required: true }}
/>
);
const submitButton = screen.getByRole("button", { name: "Submit" });
await user.click(submitButton);
expect(setCustomValidityMock).toHaveBeenCalledWith("errors.please_fill_out_this_field");
expect(reportValidityMock).toHaveBeenCalled();
expect(onSubmit).not.toHaveBeenCalled();
setCustomValiditySpy.mockRestore();
reportValiditySpy.mockRestore();
});
test("prevents submission with invalid email", async () => {
const onSubmit = vi.fn();
const setCustomValidityMock = vi.fn();
const reportValidityMock = vi.fn(() => true);
const setCustomValiditySpy = vi
.spyOn(HTMLInputElement.prototype, "setCustomValidity")
.mockImplementation(setCustomValidityMock);
const reportValiditySpy = vi
.spyOn(HTMLInputElement.prototype, "reportValidity")
.mockImplementation(reportValidityMock);
const { container } = render(
<OpenTextQuestion
{...defaultProps}
value="invalid-email"
onSubmit={onSubmit}
question={{ ...defaultQuestion, inputType: "email" }}
/>
);
const form = container.querySelector("form");
fireEvent.submit(form!);
expect(setCustomValidityMock).toHaveBeenCalledWith("errors.please_enter_a_valid_email_address");
expect(reportValidityMock).toHaveBeenCalled();
expect(onSubmit).not.toHaveBeenCalled();
setCustomValiditySpy.mockRestore();
reportValiditySpy.mockRestore();
});
test("allows submission with valid email", async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
const setTtc = vi.fn();
render(
<OpenTextQuestion
{...defaultProps}
value="test@example.com"
onSubmit={onSubmit}
setTtc={setTtc}
question={{ ...defaultQuestion, inputType: "email" }}
/>
);
const submitButton = screen.getByRole("button", { name: "Submit" });
await user.click(submitButton);
expect(onSubmit).toHaveBeenCalledWith({ q1: "test@example.com" }, {});
expect(setTtc).toHaveBeenCalled();
});
test("prevents submission with invalid URL", async () => {
const onSubmit = vi.fn();
const setCustomValidityMock = vi.fn();
const reportValidityMock = vi.fn(() => true);
const setCustomValiditySpy = vi
.spyOn(HTMLInputElement.prototype, "setCustomValidity")
.mockImplementation(setCustomValidityMock);
const reportValiditySpy = vi
.spyOn(HTMLInputElement.prototype, "reportValidity")
.mockImplementation(reportValidityMock);
const { container } = render(
<OpenTextQuestion
{...defaultProps}
value="not-a-url"
onSubmit={onSubmit}
question={{ ...defaultQuestion, inputType: "url" }}
/>
);
const form = container.querySelector("form");
fireEvent.submit(form!);
expect(setCustomValidityMock).toHaveBeenCalledWith("errors.please_enter_a_valid_url");
expect(reportValidityMock).toHaveBeenCalled();
expect(onSubmit).not.toHaveBeenCalled();
setCustomValiditySpy.mockRestore();
reportValiditySpy.mockRestore();
});
test("allows submission with valid URL", async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
const setTtc = vi.fn();
render(
<OpenTextQuestion
{...defaultProps}
value="https://example.com"
onSubmit={onSubmit}
setTtc={setTtc}
question={{ ...defaultQuestion, inputType: "url" }}
/>
);
const submitButton = screen.getByRole("button", { name: "Submit" });
await user.click(submitButton);
expect(onSubmit).toHaveBeenCalledWith({ q1: "https://example.com" }, {});
expect(setTtc).toHaveBeenCalled();
});
test("prevents submission with invalid phone number", async () => {
const onSubmit = vi.fn();
const setCustomValidityMock = vi.fn();
const reportValidityMock = vi.fn(() => true);
const setCustomValiditySpy = vi
.spyOn(HTMLInputElement.prototype, "setCustomValidity")
.mockImplementation(setCustomValidityMock);
const reportValiditySpy = vi
.spyOn(HTMLInputElement.prototype, "reportValidity")
.mockImplementation(reportValidityMock);
const { container } = render(
<OpenTextQuestion
{...defaultProps}
value="abc"
onSubmit={onSubmit}
question={{ ...defaultQuestion, inputType: "phone" }}
/>
);
const form = container.querySelector("form");
fireEvent.submit(form!);
expect(setCustomValidityMock).toHaveBeenCalledWith("errors.please_enter_a_valid_phone_number");
expect(reportValidityMock).toHaveBeenCalled();
expect(onSubmit).not.toHaveBeenCalled();
setCustomValiditySpy.mockRestore();
reportValiditySpy.mockRestore();
});
test("allows submission with valid phone number", async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
const setTtc = vi.fn();
render(
<OpenTextQuestion
{...defaultProps}
value="+1234567890"
onSubmit={onSubmit}
setTtc={setTtc}
question={{ ...defaultQuestion, inputType: "phone" }}
/>
);
const submitButton = screen.getByRole("button", { name: "Submit" });
await user.click(submitButton);
expect(onSubmit).toHaveBeenCalledWith({ q1: "+1234567890" }, {});
expect(setTtc).toHaveBeenCalled();
});
test("allows submission with valid phone number with spaces and dashes", async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
const setTtc = vi.fn();
render(
<OpenTextQuestion
{...defaultProps}
value="+1 234 567 890"
onSubmit={onSubmit}
setTtc={setTtc}
question={{ ...defaultQuestion, inputType: "phone" }}
/>
);
const submitButton = screen.getByRole("button", { name: "Submit" });
await user.click(submitButton);
expect(onSubmit).toHaveBeenCalledWith({ q1: "+1 234 567 890" }, {});
expect(setTtc).toHaveBeenCalled();
});
test("prevents submission with phone number that is too short", async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
const setCustomValidityMock = vi.fn();
const reportValidityMock = vi.fn(() => true);
const setCustomValiditySpy = vi
.spyOn(HTMLInputElement.prototype, "setCustomValidity")
.mockImplementation(setCustomValidityMock);
const reportValiditySpy = vi
.spyOn(HTMLInputElement.prototype, "reportValidity")
.mockImplementation(reportValidityMock);
render(
<OpenTextQuestion
{...defaultProps}
value="123"
onSubmit={onSubmit}
question={{ ...defaultQuestion, inputType: "phone" }}
/>
);
const submitButton = screen.getByRole("button", { name: "Submit" });
await user.click(submitButton);
expect(setCustomValidityMock).toHaveBeenCalledWith("errors.please_enter_a_valid_phone_number");
expect(reportValidityMock).toHaveBeenCalled();
expect(onSubmit).not.toHaveBeenCalled();
setCustomValiditySpy.mockRestore();
reportValiditySpy.mockRestore();
});
test("clears custom validity on input change", async () => {
const onChange = vi.fn();
const setCustomValidityMock = vi.fn();
const setCustomValiditySpy = vi
.spyOn(HTMLInputElement.prototype, "setCustomValidity")
.mockImplementation(setCustomValidityMock);
render(<OpenTextQuestion {...defaultProps} onChange={onChange} />);
const input = screen.getByPlaceholderText("Type here...");
fireEvent.input(input, { target: { value: "Test" } });
expect(setCustomValidityMock).toHaveBeenCalledWith("");
expect(onChange).toHaveBeenCalledWith({ q1: "Test" });
setCustomValiditySpy.mockRestore();
});
test("allows submission of optional empty field", async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
const setTtc = vi.fn();
render(
<OpenTextQuestion
{...defaultProps}
value=""
onSubmit={onSubmit}
setTtc={setTtc}
question={{ ...defaultQuestion, required: false }}
/>
);
const submitButton = screen.getByRole("button", { name: "Submit" });
await user.click(submitButton);
expect(onSubmit).toHaveBeenCalledWith({ q1: "" }, {});
expect(setTtc).toHaveBeenCalled();
});
});
@@ -1,3 +1,9 @@
import { type RefObject } from "preact";
import { useEffect, useRef, useState } from "preact/hooks";
import { useTranslation } from "react-i18next";
import { ZEmail, ZUrl } from "@formbricks/types/common";
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
import type { TSurveyOpenTextQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types";
import { BackButton } from "@/components/buttons/back-button";
import { SubmitButton } from "@/components/buttons/submit-button";
import { Headline } from "@/components/general/headline";
@@ -6,11 +12,6 @@ import { Subheader } from "@/components/general/subheader";
import { ScrollableContainer } from "@/components/wrappers/scrollable-container";
import { getLocalizedValue } from "@/lib/i18n";
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
import { type RefObject } from "preact";
import { useEffect, useRef, useState } from "preact/hooks";
import { useTranslation } from "react-i18next";
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
import type { TSurveyOpenTextQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types";
interface OpenTextQuestionProps {
question: TSurveyOpenTextQuestion;
@@ -52,7 +53,6 @@ export function OpenTextQuestion({
const isCurrent = question.id === currentQuestionId;
useTtc(question.id, ttc, setTtc, startTime, setStartTime, isCurrent);
const { t } = useTranslation();
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>(null);
useEffect(() => {
@@ -78,7 +78,29 @@ export function OpenTextQuestion({
return;
}
// at this point, validity is clean
if (value && value.trim() !== "") {
if (question.inputType === "email") {
if (!ZEmail.safeParse(value).success) {
input?.setCustomValidity(t("errors.please_enter_a_valid_email_address"));
input?.reportValidity();
return;
}
} else if (question.inputType === "url") {
if (!ZUrl.safeParse(value).success) {
input?.setCustomValidity(t("errors.please_enter_a_valid_url"));
input?.reportValidity();
return;
}
} else if (question.inputType === "phone") {
const phoneRegex = /^[+]?[\d\s\-()]{7,}$/;
if (!phoneRegex.test(value)) {
input?.setCustomValidity(t("errors.please_enter_a_valid_phone_number"));
input?.reportValidity();
return;
}
}
}
const updatedTtc = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedTtc);
onSubmit({ [question.id]: value }, updatedTtc);
@@ -114,12 +136,20 @@ export function OpenTextQuestion({
value={value ? value : ""}
type={question.inputType}
onInput={(e) => {
handleInputChange(e.currentTarget.value);
const input = e.currentTarget;
handleInputChange(input.value);
input.setCustomValidity("");
}}
className="fb-border-border placeholder:fb-text-placeholder fb-text-subheading focus:fb-border-brand fb-bg-input-bg fb-rounded-custom fb-block fb-w-full fb-border fb-p-2 fb-shadow-sm focus:fb-outline-none focus:fb-ring-0 sm:fb-text-sm"
pattern={question.inputType === "phone" ? "^[0-9+][0-9+\\- ]*[0-9]$" : ".*"}
title={
question.inputType === "phone" ? t("errors.please_enter_a_valid_phone_number") : undefined
question.inputType === "phone"
? t("errors.please_enter_a_valid_phone_number")
: question.inputType === "email"
? t("errors.please_enter_a_valid_email_address")
: question.inputType === "url"
? t("errors.please_enter_a_valid_url")
: undefined
}
minLength={question.inputType === "text" ? question.charLimit?.min : undefined}
maxLength={
+4
View File
@@ -4,6 +4,8 @@ export const ZBoolean = z.boolean();
export const ZString = z.string();
export const ZUrl = z.string().url();
export const ZNumber = z.number();
export const ZOptionalNumber = z.number().optional();
@@ -178,3 +180,5 @@ export const safeUrlRefinement = (url: string, ctx: z.RefinementCtx): void => {
});
}
};
export const ZEmail = z.string().email();