Compare commits

...

45 Commits

Author SHA1 Message Date
Victor Santos
d5b054af23 feat: enhance JWT functionality with comprehensive security tests
- Refactored JWT functions to improve security and error handling.
- Added tests for various scenarios including missing secrets, token verification, and legacy compatibility.
- Implemented fallback mechanisms for decryption and token verification to handle legacy formats.
- Enhanced error logging for better traceability during token operations.
- Updated tests to ensure robust coverage of new security features and edge cases.
2025-09-29 12:10:46 -03:00
Anshuman Pandey
391b3a3fb0 fix: backports formbricks.sh fix (#6550) 2025-09-15 17:58:45 +02:00
Victor Santos
0bcd85d658 refactor: enhance removeCondition logic and add comprehensive tests
- Updated the removeCondition function to return a boolean indicating success or failure.
- Implemented cleanup logic to remove empty condition groups after a condition is removed.
- Added tests to ensure correct behavior of removeCondition, including scenarios with nested groups and empty groups.
- Enhanced ConditionsEditor to disable the 'Create Group' button when only one condition exists, and ensure it is enabled when multiple conditions are present.
2025-09-01 08:58:11 -03:00
Matthias Nannt
f59df49588 chore: backport ecr build and push action 2025-08-20 14:55:58 +02:00
Matti Nannt
f08fabfb13 fix(backport): github release action fix (#6425) 2025-08-15 13:26:09 +02:00
Dhruwang Jariwala
ee8af9dd74 chore: metadata tweaks backport (#6421) 2025-08-15 13:02:28 +02:00
Dhruwang Jariwala
1091b40bd1 fix(backport): cross button hover (#6416) 2025-08-14 14:30:05 +02:00
Anshuman Pandey
87a2d727ed fix: disables tabs when single use is enabled [Backport] (#6412) 2025-08-14 04:07:35 -07:00
Dhruwang Jariwala
4786ab61e7 feat: customizable link previews (#6361)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-08-12 06:37:30 +00:00
Dhruwang Jariwala
819380d21c chore: sonarqube label fixes (#6381) 2025-08-11 18:22:21 +00:00
Anshuman Pandey
fd3fedb6ed fix: fixes follow up UI when email is hidden in the contact info question (#6388) 2025-08-11 14:48:32 +00:00
Dhruwang Jariwala
88b1e63771 chore: updated nextjs version (#6389) 2025-08-11 13:24:35 +00:00
Piyush Gupta
3132fe74f1 chore: remove response note feature (#6390) 2025-08-11 12:01:31 +00:00
Harsh Bhat
a27a2a67c8 chore: Change pricing form link (#6394) 2025-08-11 05:13:43 -07:00
Piyush Gupta
4a7ace5a0a feat: adds metadata columns in response table (#6368) 2025-08-11 11:25:06 +00:00
Victor Hugo dos Santos
43628caa3b feat: Add rate limiting to API V1 (#6355)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-08-11 09:10:45 +00:00
Matti Nannt
9d84bc0c8d fix: Uncontrolled data used in path expression in storage service (#6375)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-08-11 08:37:33 +00:00
Dhruwang Jariwala
babc020085 chore: short url legacy removal (#6391) 2025-08-11 07:55:52 +00:00
Matti Nannt
95ee83ef31 chore: remove semantic PR thank you comment to reduce PR spam (#6380) 2025-08-11 07:24:59 +00:00
Matti Nannt
d994af2dfd chore: Add Docker Image Vulnerability Scanning for SOC-2 Compliance (#6371) 2025-08-08 16:42:55 +00:00
Matti Nannt
4b5b5bf59f chore: add cursor rule for github workflows & actions (#6382) 2025-08-08 13:30:27 +00:00
Anshuman Pandey
62166dc4b1 fix: tidying up the survey card header (#6341)
Co-authored-by: Jakob Schott <jakob@formbricks.com>
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Victor Santos <victor@formbricks.com>
Co-authored-by: Jakob Schott <154420406+jakobsitory@users.noreply.github.com>
2025-08-08 10:18:56 +00:00
Matti Nannt
ec6d88bf11 fix: OneLeet Code Scanning Sentry action issues (#6378) 2025-08-08 08:06:57 +00:00
Dhruwang Jariwala
c0240d60a1 feat: romanian translations (#6369) 2025-08-08 04:03:55 +00:00
Dhruwang Jariwala
cd2884d83e chore: app connection info alert (#6370)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-08-07 15:57:49 +00:00
StepSecurity Bot
f7aea2e706 chore: Harden GitHub Actions (#6373)
Signed-off-by: StepSecurity Bot <bot@stepsecurity.io>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-08-07 16:33:42 +02:00
Matti Nannt
e80fc2ee61 chore: remove unused github workflows/actions (#6372) 2025-08-07 15:26:11 +02:00
Jakob Schott
9b489b0682 chore: Optimize styling for MultiLanguageCard (#6353) 2025-08-07 10:23:25 +00:00
Jakob Schott
2ee0efa1c2 fix: dynamic width for InputCombobox (#6365) 2025-08-06 23:52:09 -07:00
Anshuman Pandey
9ffd67262c fix: updates tolgee key (#6367) 2025-08-07 06:30:00 +00:00
Dhruwang Jariwala
68dc63ce0b chore: search bar and preview on survey list page (#6349)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-08-07 04:57:28 +00:00
Piyush Gupta
f239ee9697 feat: adds multiLanguageSurveys and accessControl license features (#6331) 2025-08-06 14:35:28 +00:00
Piyush Gupta
282b3e070c fix: sonarqube medium vulnerability issues (#6362) 2025-08-06 11:23:27 +00:00
Johannes
b5f0bd8f9a fix: update wording to match actual behaviour (#6364) 2025-08-06 03:38:47 -07:00
Piyush Gupta
3784bd6b5e fix: Missing space in Access Control Modal (#6356)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-08-06 08:17:47 +00:00
Piyush Gupta
41d27c2093 fix: use full width on sidebar elements (#6357) 2025-08-06 07:58:28 +00:00
Piyush Gupta
7400ce2e67 fix: secure cookies fix for callback URL (#6358) 2025-08-05 17:44:13 +00:00
Piyush Gupta
355782f404 chore: sonarqube low reliability issues (#6359) 2025-08-05 10:06:53 +00:00
Anshuman Pandey
de70e97940 fix: adds loading state to the responses download button (#6352) 2025-08-05 04:22:22 +00:00
Dhruwang Jariwala
287c45f996 feat: surface option ids (#6339) 2025-08-05 04:03:12 +00:00
Harsh Bhat
3b07a6d013 docs: update multi-language surveys (#6354) 2025-08-04 10:02:31 -07:00
Jonas Höbenreich
0cc2606ec6 fix: Remove rounded-lg Class from Company Logo (#6347) 2025-08-04 01:42:05 -07:00
Dhruwang Jariwala
0fada94b80 chore: Replace entity ids (#6317) 2025-08-04 04:10:41 +00:00
Piyush Gupta
a59ede20c7 fix: one leet security issues (#6303) 2025-08-01 14:35:11 +00:00
Piyush Gupta
84294f9df2 feat: adds debug logs (#6237)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-08-01 11:10:21 +00:00
332 changed files with 21344 additions and 5830 deletions

View File

@@ -0,0 +1,232 @@
---
description: Security best practices and guidelines for writing GitHub Actions and workflows
globs: .github/workflows/*.yml,.github/workflows/*.yaml,.github/actions/*/action.yml,.github/actions/*/action.yaml
---
# GitHub Actions Security Best Practices
## Required Security Measures
### 1. Set Minimum GITHUB_TOKEN Permissions
Always explicitly set the minimum required permissions for GITHUB_TOKEN:
```yaml
permissions:
contents: read
# Only add additional permissions if absolutely necessary:
# pull-requests: write # for commenting on PRs
# issues: write # for creating/updating issues
# checks: write # for publishing check results
```
### 2. Add Harden-Runner as First Step
For **every job** on `ubuntu-latest`, add Harden-Runner as the first step:
```yaml
- name: Harden the runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit # or 'block' for stricter security
```
### 3. Pin Actions to Full Commit SHA
**Always** pin third-party actions to their full commit SHA, not tags:
```yaml
# ❌ BAD - uses mutable tag
- uses: actions/checkout@v4
# ✅ GOOD - pinned to immutable commit SHA
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
```
### 4. Secure Variable Handling
Prevent command injection by properly quoting variables:
```yaml
# ❌ BAD - potential command injection
run: echo "Processing ${{ inputs.user_input }}"
# ✅ GOOD - properly quoted
env:
USER_INPUT: ${{ inputs.user_input }}
run: echo "Processing ${USER_INPUT}"
```
Use `${VARIABLE}` syntax in shell scripts instead of `$VARIABLE`.
### 5. Environment Variables for Secrets
Store sensitive data in environment variables, not inline:
```yaml
# ❌ BAD
run: curl -H "Authorization: Bearer ${{ secrets.TOKEN }}" api.example.com
# ✅ GOOD
env:
API_TOKEN: ${{ secrets.TOKEN }}
run: curl -H "Authorization: Bearer ${API_TOKEN}" api.example.com
```
## Workflow Structure Best Practices
### Required Workflow Elements
```yaml
name: "Descriptive Workflow Name"
on:
# Define specific triggers
push:
branches: [main]
pull_request:
branches: [main]
# Always set explicit permissions
permissions:
contents: read
jobs:
job-name:
name: "Descriptive Job Name"
runs-on: ubuntu-latest
timeout-minutes: 30 # tune per job; standardize repo-wide
# Set job-level permissions if different from workflow level
permissions:
contents: read
steps:
# Always start with Harden-Runner on ubuntu-latest
- name: Harden the runner
uses: step-security/harden-runner@v2
with:
egress-policy: audit
# Pin all actions to commit SHA
- name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
```
### Input Validation for Actions
For composite actions, always validate inputs:
```yaml
inputs:
user_input:
description: "User provided input"
required: true
runs:
using: "composite"
steps:
- name: Validate input
shell: bash
run: |
# Harden shell and validate input format/content before use
set -euo pipefail
USER_INPUT="${{ inputs.user_input }}"
if [[ ! "${USER_INPUT}" =~ ^[A-Za-z0-9._-]+$ ]]; then
echo "❌ Invalid input format"
exit 1
fi
```
## Docker Security in Actions
### Pin Docker Images to Digests
```yaml
# ❌ BAD - mutable tag
container: node:18
# ✅ GOOD - pinned to digest
container: node:18@sha256:a1ba21bf0c92931d02a8416f0a54daad66cb36a85d6a37b82dfe1604c4c09cad
```
## Common Patterns
### Secure File Operations
```yaml
- name: Process files securely
shell: bash
env:
FILE_PATH: ${{ inputs.file_path }}
run: |
set -euo pipefail # Fail on errors, undefined vars, pipe failures
# Use absolute paths and validate
SAFE_PATH=$(realpath "${FILE_PATH}")
if [[ "$SAFE_PATH" != "${GITHUB_WORKSPACE}"/* ]]; then
echo "❌ Path outside workspace"
exit 1
fi
```
### Artifact Handling
```yaml
- name: Upload artifacts securely
uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.0
with:
name: build-artifacts
path: |
dist/
!dist/**/*.log # Exclude sensitive files
retention-days: 30
```
### GHCR authentication for pulls/scans
```yaml
# Minimal permissions required for GHCR pulls/scans
permissions:
contents: read
packages: read
steps:
- name: Log in to GitHub Container Registry
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
```
## Security Checklist
- [ ] Minimum GITHUB_TOKEN permissions set
- [ ] Harden-Runner added to all ubuntu-latest jobs
- [ ] All third-party actions pinned to commit SHA
- [ ] Input validation implemented for custom actions
- [ ] Variables properly quoted in shell scripts
- [ ] Secrets stored in environment variables
- [ ] Docker images pinned to digests (if used)
- [ ] Error handling with `set -euo pipefail`
- [ ] File paths validated and sanitized
- [ ] No sensitive data in logs or outputs
- [ ] GHCR login performed before pulls/scans (packages: read)
- [ ] Job timeouts configured (`timeout-minutes`)
## Recommended Additional Workflows
Consider adding these security-focused workflows to your repository:
1. **CodeQL Analysis** - Static Application Security Testing (SAST)
2. **Dependency Review** - Scan for vulnerable dependencies in PRs
3. **Dependabot Configuration** - Automated dependency updates
## Resources
- [GitHub Security Hardening Guide](https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions)
- [Step Security Harden-Runner](https://github.com/step-security/harden-runner)
- [Secure-Repo Best Practices](https://github.com/step-security/secure-repo)

View File

@@ -62,10 +62,12 @@ runs:
shell: bash
- name: Fill ENCRYPTION_KEY, ENTERPRISE_LICENSE_KEY and E2E_TESTING in .env
env:
E2E_TESTING_MODE: ${{ inputs.e2e_testing_mode }}
run: |
RANDOM_KEY=$(openssl rand -hex 32)
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
echo "E2E_TESTING=${{ inputs.e2e_testing_mode }}" >> .env
echo "E2E_TESTING=$E2E_TESTING_MODE" >> .env
shell: bash
- run: |

View File

@@ -1,70 +1,49 @@
name: 'Upload Sentry Sourcemaps'
description: 'Extract sourcemaps from Docker image and upload to Sentry'
name: "Upload Sentry Sourcemaps"
description: "Extract sourcemaps from Docker image and upload to Sentry"
inputs:
docker_image:
description: 'Docker image to extract sourcemaps from'
description: "Docker image to extract sourcemaps from"
required: true
release_version:
description: 'Sentry release version (e.g., v1.2.3)'
description: "Sentry release version (e.g., v1.2.3)"
required: true
sentry_auth_token:
description: 'Sentry authentication token'
description: "Sentry authentication token"
required: true
environment:
description: 'Sentry environment (e.g., production, staging)'
description: "Sentry environment (e.g., production, staging)"
required: false
default: 'staging'
default: "staging"
runs:
using: 'composite'
using: "composite"
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Validate Sentry auth token
- name: Extract sourcemaps from Docker image
shell: bash
env:
DOCKER_IMAGE: ${{ inputs.docker_image }}
run: |
set -euo pipefail
echo "🔐 Validating Sentry authentication token..."
# Assign token to local variable for secure handling
SENTRY_TOKEN="${{ inputs.sentry_auth_token }}"
# Test the token by making a simple API call to Sentry
response=$(curl -s -w "%{http_code}" -o /tmp/sentry_response.json \
-H "Authorization: Bearer $SENTRY_TOKEN" \
"https://sentry.io/api/0/organizations/formbricks/")
http_code=$(echo "$response" | tail -n1)
if [ "$http_code" != "200" ]; then
echo "❌ Error: Invalid Sentry auth token (HTTP $http_code)"
echo "Please check your SENTRY_AUTH_TOKEN is correct and has the necessary permissions."
if [ -f /tmp/sentry_response.json ]; then
echo "Response body:"
cat /tmp/sentry_response.json
fi
# Validate docker image format (basic validation)
if [[ ! "$DOCKER_IMAGE" =~ ^[a-zA-Z0-9._/-]+:[a-zA-Z0-9._-]+$ ]] && [[ ! "$DOCKER_IMAGE" =~ ^[a-zA-Z0-9._/-]+@sha256:[A-Fa-f0-9]{64}$ ]]; then
echo "❌ Error: Invalid docker image format. Must be in format 'image:tag' or 'image@sha256:hash'"
echo "Provided: ${DOCKER_IMAGE}"
exit 1
fi
echo "✅ Sentry auth token validated successfully"
# Clean up temp file
rm -f /tmp/sentry_response.json
- name: Extract sourcemaps from Docker image
shell: bash
run: |
set -euo pipefail
echo "📦 Extracting sourcemaps from Docker image: ${{ inputs.docker_image }}"
echo "📦 Extracting sourcemaps from Docker image: ${DOCKER_IMAGE}"
# Create temporary container from the image and capture its ID
echo "Creating temporary container..."
CONTAINER_ID=$(docker create "${{ inputs.docker_image }}")
echo "Container created with ID: $CONTAINER_ID"
CONTAINER_ID=$(docker create "$DOCKER_IMAGE")
echo "Container created with ID: ${CONTAINER_ID}"
# Set up cleanup function to ensure container is removed on script exit
cleanup_container() {
@@ -76,13 +55,13 @@ runs:
# Remove the container if it exists (ignore errors if already removed)
if [ -n "$CONTAINER_ID" ]; then
docker rm -f "$CONTAINER_ID" 2>/dev/null || true
echo "Container $CONTAINER_ID removed"
echo "Container ${CONTAINER_ID} removed"
fi
# Exit with the original exit code to preserve script success/failure status
exit $original_exit_code
}
# Register cleanup function to run on script exit (success or failure)
trap cleanup_container EXIT
@@ -97,7 +76,7 @@ runs:
fi
sourcemap_count=$(find ./extracted-next/static/chunks -name "*.map" | wc -l)
echo "✅ Found $sourcemap_count sourcemap files"
echo "✅ Found ${sourcemap_count} sourcemap files"
if [ "$sourcemap_count" -eq 0 ]; then
echo "❌ Error: No sourcemap files found. Check that productionBrowserSourceMaps is enabled."
@@ -113,7 +92,7 @@ runs:
with:
environment: ${{ inputs.environment }}
version: ${{ inputs.release_version }}
sourcemaps: './extracted-next/'
sourcemaps: "./extracted-next/"
- name: Clean up extracted files
shell: bash

View File

@@ -1,82 +0,0 @@
name: "Apply issue labels to PR"
on:
pull_request_target:
types:
- opened
permissions:
contents: read
jobs:
label_on_pr:
runs-on: ubuntu-latest
permissions:
contents: none
issues: read
pull-requests: write
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: Apply labels from linked issue to PR
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
async function getLinkedIssues(owner, repo, prNumber) {
const query = `query GetLinkedIssues($owner: String!, $repo: String!, $prNumber: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $prNumber) {
closingIssuesReferences(first: 10) {
nodes {
number
labels(first: 10) {
nodes {
name
}
}
}
}
}
}
}`;
const variables = {
owner: owner,
repo: repo,
prNumber: prNumber,
};
const result = await github.graphql(query, variables);
return result.repository.pullRequest.closingIssuesReferences.nodes;
}
const pr = context.payload.pull_request;
const linkedIssues = await getLinkedIssues(
context.repo.owner,
context.repo.repo,
pr.number
);
const labelsToAdd = new Set();
for (const issue of linkedIssues) {
if (issue.labels && issue.labels.nodes) {
for (const label of issue.labels.nodes) {
labelsToAdd.add(label.name);
}
}
}
if (labelsToAdd.size) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
labels: Array.from(labelsToAdd),
});
}

View File

@@ -0,0 +1,99 @@
name: Build & Push Docker to ECR
on:
workflow_dispatch:
inputs:
image_tag:
description: "Image tag to push (e.g., v3.16.1)"
required: true
default: "v3.16.1"
permissions:
contents: read
id-token: write
env:
ECR_REGION: ${{ vars.ECR_REGION }}
# ECR settings are sourced from repository/environment variables for portability across envs/forks
ECR_REGISTRY: ${{ vars.ECR_REGISTRY }}
ECR_REPOSITORY: ${{ vars.ECR_REPOSITORY }}
DOCKERFILE: apps/web/Dockerfile
CONTEXT: .
jobs:
build-and-push:
name: Build and Push
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Validate image tag input
shell: bash
env:
IMAGE_TAG: ${{ inputs.image_tag }}
run: |
set -euo pipefail
if [[ -z "${IMAGE_TAG}" ]]; then
echo "❌ Image tag is required (non-empty)."
exit 1
fi
if (( ${#IMAGE_TAG} > 128 )); then
echo "❌ Image tag must be at most 128 characters."
exit 1
fi
if [[ ! "${IMAGE_TAG}" =~ ^[a-z0-9._-]+$ ]]; then
echo "❌ Image tag may only contain lowercase letters, digits, '.', '_' and '-'."
exit 1
fi
if [[ "${IMAGE_TAG}" =~ ^[.-] || "${IMAGE_TAG}" =~ [.-]$ ]]; then
echo "❌ Image tag must not start or end with '.' or '-'."
exit 1
fi
- name: Validate required variables
shell: bash
env:
ECR_REGISTRY: ${{ env.ECR_REGISTRY }}
ECR_REPOSITORY: ${{ env.ECR_REPOSITORY }}
ECR_REGION: ${{ env.ECR_REGION }}
run: |
set -euo pipefail
if [[ -z "${ECR_REGISTRY}" || -z "${ECR_REPOSITORY}" || -z "${ECR_REGION}" ]]; then
echo "ECR_REGION, ECR_REGISTRY and ECR_REPOSITORY must be set via repository or environment variables (Settings → Variables)."
exit 1
fi
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a
with:
role-to-assume: ${{ secrets.AWS_ECR_PUSH_ROLE_ARN }}
aws-region: ${{ env.ECR_REGION }}
- name: Log in to Amazon ECR
uses: aws-actions/amazon-ecr-login@062b18b96a7aff071d4dc91bc00c4c1a7945b076
- name: Set up Depot CLI
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
- name: Build and push image (Depot remote builder)
uses: depot/build-push-action@636daae76684e38c301daa0c5eca1c095b24e780 # v1.14.0
with:
project: tw0fqmsx3c
token: ${{ secrets.DEPOT_PROJECT_TOKEN }}
context: ${{ env.CONTEXT }}
file: ${{ env.DOCKERFILE }}
platforms: linux/amd64,linux/arm64
push: true
tags: |
${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:${{ inputs.image_tag }}
${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:latest
secrets: |
database_url=${{ secrets.DUMMY_DATABASE_URL }}
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}

View File

@@ -6,12 +6,14 @@ on:
- main
workflow_dispatch:
permissions:
contents: read
jobs:
chromatic:
name: Run Chromatic
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
actions: read

View File

@@ -1,27 +0,0 @@
# Dependency Review Action
#
# This Action will scan dependency manifest files that change as part of a Pull Request,
# surfacing known-vulnerable versions of the packages declared or updated in the PR.
# Once installed, if the workflow run is marked as required,
# PRs introducing known-vulnerable packages will be blocked from merging.
#
# Source repository: https://github.com/actions/dependency-review-action
name: 'Dependency Review'
on: [pull_request]
permissions:
contents: read
jobs:
dependency-review:
runs-on: ubuntu-latest
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: 'Checkout Repository'
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: 'Dependency Review'
uses: actions/dependency-review-action@38ecb5b593bf0eb19e335c03f97670f792489a8b # v4.7.0

View File

@@ -37,17 +37,22 @@ on:
permissions:
id-token: write
contents: write
contents: read
jobs:
helmfile-deploy:
runs-on: ubuntu-latest
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@v4.2.2
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Tailscale
uses: tailscale/github-action@v3
uses: tailscale/github-action@84a3f23bb4d843bcf4da6cf824ec1be473daf4de # v3.2.3
with:
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
@@ -66,7 +71,7 @@ jobs:
env:
AWS_REGION: eu-central-1
- uses: helmfile/helmfile-action@v2
- uses: helmfile/helmfile-action@712000e3d4e28c72778ecc53857746082f555ef3 # v2.0.4
name: Deploy Formbricks Cloud Production
if: inputs.ENVIRONMENT == 'production'
env:
@@ -84,7 +89,7 @@ jobs:
helmfile-auto-init: "false"
helmfile-workdirectory: infra/formbricks-cloud-helm
- uses: helmfile/helmfile-action@v2
- uses: helmfile/helmfile-action@712000e3d4e28c72778ecc53857746082f555ef3 # v2.0.4
name: Deploy Formbricks Cloud Staging
if: inputs.ENVIRONMENT == 'staging'
env:
@@ -106,15 +111,16 @@ jobs:
env:
CF_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }}
CF_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
ENVIRONMENT: ${{ inputs.ENVIRONMENT }}
run: |
# Set hostname based on environment
if [[ "${{ inputs.ENVIRONMENT }}" == "production" ]]; then
if [[ "$ENVIRONMENT" == "production" ]]; then
PURGE_HOST="app.formbricks.com"
else
PURGE_HOST="stage.app.formbricks.com"
fi
echo "Purging Cloudflare cache for host: $PURGE_HOST (environment: ${{ inputs.ENVIRONMENT }}, zone: $CF_ZONE_ID)"
echo "Purging Cloudflare cache for host: $PURGE_HOST (environment: $ENVIRONMENT, zone: $CF_ZONE_ID)"
# Prepare JSON payload for selective cache purge
json_payload=$(cat << EOF

View File

@@ -39,20 +39,29 @@ jobs:
--health-retries 5
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout Repository
uses: actions/checkout@v4.2.2
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Build Docker Image
uses: docker/build-push-action@v6
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
env:
GITHUB_SHA: ${{ github.sha }}
with:
context: .
file: ./apps/web/Dockerfile
push: false
load: true
tags: formbricks-test:${{ github.sha }}
tags: formbricks-test:${{ env.GITHUB_SHA }}
cache-from: type=gha
cache-to: type=gha,mode=max
secrets: |
@@ -89,6 +98,9 @@ jobs:
- name: Test Docker Image with Health Check
shell: bash
env:
GITHUB_SHA: ${{ github.sha }}
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
run: |
echo "🧪 Testing if the Docker image starts correctly..."
@@ -100,8 +112,8 @@ jobs:
$DOCKER_RUN_ARGS \
-p 3000:3000 \
-e DATABASE_URL="postgresql://test:test@host.docker.internal:5432/formbricks" \
-e ENCRYPTION_KEY="${{ secrets.DUMMY_ENCRYPTION_KEY }}" \
-d formbricks-test:${{ github.sha }}
-e ENCRYPTION_KEY="$DUMMY_ENCRYPTION_KEY" \
-d "formbricks-test:$GITHUB_SHA"
# Start health check polling immediately (every 5 seconds for up to 5 minutes)
echo "🏥 Polling /health endpoint every 5 seconds for up to 5 minutes..."

View File

@@ -0,0 +1,40 @@
name: Docker Security Scan
on:
schedule:
- cron: "0 2 * * *" # Daily at 2 AM UTC
workflow_dispatch:
workflow_run:
workflows: ["Docker Release to Github"]
types: [completed]
permissions:
contents: read
packages: read
security-events: write
jobs:
scan:
name: Vulnerability Scan
runs-on: ubuntu-latest
steps:
- name: Log in to GitHub Container Registry
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@dc5a429b52fcf669ce959baa2c2dd26090d2a6c4 # v0.32.0
with:
image-ref: "ghcr.io/${{ github.repository }}:latest"
format: "sarif"
output: "trivy-results.sarif"
severity: "CRITICAL,HIGH,MEDIUM,LOW"
- name: Upload Trivy scan results to GitHub Security tab
uses: github/codeql-action/upload-sarif@a4e1a019f5e24960714ff6296aee04b736cbc3cf # v3.29.6
if: ${{ always() && hashFiles('trivy-results.sarif') != '' }}
with:
sarif_file: "trivy-results.sarif"

View File

@@ -7,12 +7,13 @@ on:
permissions:
contents: read
env:
ENVIRONMENT: ${{ github.event.release.prerelease && 'staging' || 'production' }}
jobs:
docker-build:
name: Build & release docker image
permissions:
contents: read
packages: write
id-token: write
uses: ./.github/workflows/release-docker-github.yml
secrets: inherit
with:
@@ -20,6 +21,9 @@ jobs:
helm-chart-release:
name: Release Helm Chart
permissions:
contents: read
packages: write
uses: ./.github/workflows/release-helm-chart.yml
secrets: inherit
needs:
@@ -29,6 +33,9 @@ jobs:
deploy-formbricks-cloud:
name: Deploy Helm Chart to Formbricks Cloud
permissions:
contents: read
id-token: write
secrets: inherit
uses: ./.github/workflows/deploy-formbricks-cloud.yml
needs:
@@ -36,7 +43,7 @@ jobs:
- helm-chart-release
with:
VERSION: v${{ needs.docker-build.outputs.VERSION }}
ENVIRONMENT: ${{ env.ENVIRONMENT }}
ENVIRONMENT: ${{ github.event.release.prerelease && 'staging' || 'production' }}
upload-sentry-sourcemaps:
name: Upload Sentry Sourcemaps
@@ -47,8 +54,13 @@ jobs:
- docker-build
- deploy-formbricks-cloud
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@v4.2.2
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
@@ -59,4 +71,4 @@ jobs:
docker_image: ghcr.io/formbricks/formbricks:v${{ needs.docker-build.outputs.VERSION }}
release_version: v${{ needs.docker-build.outputs.VERSION }}
sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }}
environment: ${{ env.ENVIRONMENT }}
environment: ${{ github.event.release.prerelease && 'staging' || 'production' }}

View File

@@ -41,14 +41,16 @@ jobs:
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- name: Generate SemVer version from branch or tag
id: generate_version
env:
REF_NAME: ${{ github.ref_name }}
REF_TYPE: ${{ github.ref_type }}
run: |
# Get reference name and type
REF_NAME="${{ github.ref_name }}"
REF_TYPE="${{ github.ref_type }}"
# Get reference name and type from environment variables
echo "Reference type: $REF_TYPE"
echo "Reference name: $REF_NAME"
@@ -172,8 +174,13 @@ jobs:
needs:
- build
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@v4.2.2
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0

View File

@@ -9,7 +9,7 @@ on:
workflow_call:
inputs:
IS_PRERELEASE:
description: 'Whether this is a prerelease (affects latest tag)'
description: "Whether this is a prerelease (affects latest tag)"
required: false
type: boolean
default: false
@@ -26,6 +26,9 @@ env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
@@ -52,9 +55,20 @@ jobs:
id: extract_release_tag
run: |
# Extract version from tag (e.g., refs/tags/v1.2.3 -> 1.2.3)
TAG=${{ github.ref }}
TAG="$GITHUB_REF"
TAG=${TAG#refs/tags/v}
# Validate the extracted tag format
if [[ ! "$TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$ ]]; then
echo "❌ Error: Invalid release tag format after extraction. Must be semver (e.g., 1.2.3, 1.2.3-alpha)"
echo "Original ref: $GITHUB_REF"
echo "Extracted tag: $TAG"
exit 1
fi
# Safely add to environment variables
echo "RELEASE_TAG=$TAG" >> $GITHUB_ENV
echo "VERSION=$TAG" >> $GITHUB_OUTPUT
echo "Using tag-based version: $TAG"

View File

@@ -26,8 +26,23 @@ jobs:
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Extract release version
run: echo "VERSION=${{ github.event.release.tag_name }}" >> $GITHUB_ENV
- name: Validate input version
env:
INPUT_VERSION: ${{ inputs.VERSION }}
run: |
set -euo pipefail
# Validate input version format (expects clean semver without 'v' prefix)
if [[ ! "$INPUT_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$ ]]; then
echo "❌ Error: Invalid version format. Must be clean semver (e.g., 1.2.3, 1.2.3-alpha)"
echo "Expected: clean version without 'v' prefix"
echo "Provided: $INPUT_VERSION"
exit 1
fi
# Store validated version in environment variable
echo "VERSION<<EOF" >> $GITHUB_ENV
echo "$INPUT_VERSION" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
- name: Set up Helm
uses: azure/setup-helm@5119fcb9089d432beecbf79bb2c7915207344b78 # v3.5
@@ -35,15 +50,18 @@ jobs:
version: latest
- name: Log in to GitHub Container Registry
run: echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ghcr.io --username ${{ github.actor }} --password-stdin
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_ACTOR: ${{ github.actor }}
run: printf '%s' "$GITHUB_TOKEN" | helm registry login ghcr.io --username "$GITHUB_ACTOR" --password-stdin
- name: Install YQ
uses: dcarbone/install-yq-action@4075b4dca348d74bd83f2bf82d30f25d7c54539b # v1.3.1
- name: Update Chart.yaml with new version
run: |
yq -i ".version = \"${{ inputs.VERSION }}\"" helm-chart/Chart.yaml
yq -i ".appVersion = \"v${{ inputs.VERSION }}\"" helm-chart/Chart.yaml
yq -i ".version = \"$VERSION\"" helm-chart/Chart.yaml
yq -i ".appVersion = \"v$VERSION\"" helm-chart/Chart.yaml
- name: Package Helm chart
run: |
@@ -51,4 +69,4 @@ jobs:
- name: Push Helm chart to GitHub Container Registry
run: |
helm push formbricks-${{ inputs.VERSION }}.tgz oci://ghcr.io/formbricks/helm-charts
helm push "formbricks-$VERSION.tgz" oci://ghcr.io/formbricks/helm-charts

View File

@@ -1,81 +0,0 @@
# This workflow uses actions that are not certified by GitHub. They are provided
# by a third-party and are governed by separate terms of service, privacy
# policy, and support documentation.
name: Scorecard supply-chain security
on:
# For Branch-Protection check. Only the default branch is supported. See
# https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection
branch_protection_rule:
# To guarantee Maintained check is occasionally updated. See
# https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained
schedule:
- cron: "17 17 * * 6"
push:
branches: ["main"]
workflow_dispatch:
# Declare default permissions as read only.
permissions: read-all
jobs:
analysis:
name: Scorecard analysis
runs-on: ubuntu-latest
permissions:
# Needed to upload the results to code-scanning dashboard.
security-events: write
# Needed to publish results and get a badge (see publish_results below).
id-token: write
# Add this permission
actions: write # Required for artifact upload
# Uncomment the permissions below if installing in a private repository.
# contents: read
# actions: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: "Checkout code"
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: "Run analysis"
uses: ossf/scorecard-action@0864cf19026789058feabb7e87baa5f140aac736 # v2.3.1
with:
results_file: results.sarif
results_format: sarif
# (Optional) "write" PAT token. Uncomment the `repo_token` line below if:
# - you want to enable the Branch-Protection check on a *public* repository, or
# - you are installing Scorecard on a *private* repository
# To create the PAT, follow the steps in https://github.com/ossf/scorecard-action?tab=readme-ov-file#authentication-with-fine-grained-pat-optional.
# repo_token: ${{ secrets.SCORECARD_TOKEN }}
# Public repositories:
# - Publish results to OpenSSF REST API for easy access by consumers
# - Allows the repository to include the Scorecard badge.
# - See https://github.com/ossf/scorecard-action#publishing-results.
# For private repositories:
# - `publish_results` will always be set to `false`, regardless
# of the value entered here.
publish_results: true
# Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
# format to the repository Actions tab.
- name: "Upload artifact"
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: sarif
path: results.sarif
retention-days: 5
# Upload the results to GitHub's code scanning dashboard (optional).
# Commenting out will disable upload of results to your repo's Code Scanning dashboard
- name: "Upload to code-scanning"
uses: github/codeql-action/upload-sarif@b56ba49b26e50535fa1e7f7db0f4f7b4bf65d80d # v3.28.10
with:
sarif_file: results.sarif

View File

@@ -56,11 +56,3 @@ jobs:
```
${{ steps.lint_pr_title.outputs.error_message }}
```
# Delete a previous comment when the issue has been resolved
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
uses: marocchino/sticky-pull-request-comment@67d0dec7b07ed060a405f9b2a64b8ab319fdd7db # v2.9.2
with:
header: pr-title-lint-error
message: |
Thank you for following the naming conventions for pull request titles! 🙏

View File

@@ -14,12 +14,14 @@ on:
paths:
- "infra/terraform/**"
permissions:
contents: read
jobs:
terraform:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
pull-requests: write
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -33,7 +35,7 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Tailscale
uses: tailscale/github-action@v3
uses: tailscale/github-action@84a3f23bb4d843bcf4da6cf824ec1be473daf4de # v3.2.3
with:
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}

View File

@@ -27,10 +27,18 @@ jobs:
- name: Get source branch name
id: branch-name
env:
RAW_BRANCH: ${{ github.head_ref }}
run: |
RAW_BRANCH="${{ github.head_ref }}"
# Validate and sanitize branch name - only allow alphanumeric, dots, underscores, hyphens, and forward slashes
SOURCE_BRANCH=$(echo "$RAW_BRANCH" | sed 's/[^a-zA-Z0-9._\/-]//g')
# Additional validation - ensure branch name is not empty after sanitization
if [[ -z "$SOURCE_BRANCH" ]]; then
echo "❌ Error: Branch name is empty after sanitization"
echo "Original branch: $RAW_BRANCH"
exit 1
fi
# Safely add to environment variables using GitHub's recommended method
# This prevents environment variable injection attacks

View File

@@ -23,24 +23,26 @@ jobs:
upload-sourcemaps:
name: Upload Sourcemaps to Sentry
runs-on: ubuntu-latest
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@v4.2.2
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- name: Set Docker Image
run: |
if [ -n "${{ inputs.tag_version }}" ]; then
echo "DOCKER_IMAGE=${{ inputs.docker_image }}:${{ inputs.tag_version }}" >> $GITHUB_ENV
else
echo "DOCKER_IMAGE=${{ inputs.docker_image }}:${{ inputs.release_version }}" >> $GITHUB_ENV
fi
run: echo "DOCKER_IMAGE=${DOCKER_IMAGE}" >> $GITHUB_ENV
env:
DOCKER_IMAGE: ${{ inputs.docker_image }}:${{ inputs.tag_version != '' && inputs.tag_version || inputs.release_version }}
- name: Upload Sourcemaps to Sentry
uses: ./.github/actions/upload-sentry-sourcemaps
with:
docker_image: ${{ env.DOCKER_IMAGE }}
release_version: ${{ inputs.release_version }}
sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }}
sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }}

View File

@@ -1,32 +0,0 @@
name: "Welcome new contributors"
on:
issues:
types: opened
pull_request_target:
types: opened
permissions:
pull-requests: write
issues: write
jobs:
welcome-message:
name: Welcoming New Users
runs-on: ubuntu-latest
timeout-minutes: 10
if: github.event.action == 'opened'
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- uses: actions/first-interaction@3c71ce730280171fd1cfb57c00c774f8998586f7 # v1
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
pr-message: |-
Thank you so much for making your first Pull Request and taking the time to improve Formbricks! 🚀🙏❤️
Feel free to join the conversation on [Github Discussions](https://github.com/formbricks/formbricks/discussions) if you need any help or have any questions. 😊
issue-message: |
Thank you for opening your first issue! 🙏❤️ One of our team members will review it and get back to you as soon as it possible. 😊

View File

@@ -31,6 +31,10 @@
{
"language": "pt-PT",
"path": "./apps/web/locales/pt-PT.json"
},
{
"language": "ro-RO",
"path": "./apps/web/locales/ro-RO.json"
}
],
"forceMode": "OVERRIDE"

View File

@@ -80,25 +80,25 @@ export const LandingSidebar = ({
<DropdownMenuTrigger
asChild
id="userDropdownTrigger"
className="w-full rounded-br-xl border-t py-4 pl-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-none">
<div tabIndex={0} className={cn("flex cursor-pointer flex-row items-center space-x-3")}>
className="w-full rounded-br-xl border-t p-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-none">
<div tabIndex={0} className={cn("flex cursor-pointer flex-row items-center gap-3")}>
<ProfileAvatar userId={user.id} imageUrl={user.imageUrl} />
<>
<div>
<div className="grow overflow-hidden">
<p
title={user?.email}
className={cn(
"ph-no-capture ph-no-capture -mb-0.5 max-w-28 truncate text-sm font-bold text-slate-700"
"ph-no-capture ph-no-capture -mb-0.5 truncate text-sm font-bold text-slate-700"
)}>
{user?.name ? <span>{user?.name}</span> : <span>{user?.email}</span>}
</p>
<p
title={capitalizeFirstLetter(organization?.name)}
className="max-w-28 truncate text-sm text-slate-500">
className="truncate text-sm text-slate-500">
{capitalizeFirstLetter(organization?.name)}
</p>
</div>
<ChevronRightIcon className={cn("h-5 w-5 text-slate-700 hover:text-slate-500")} />
<ChevronRightIcon className={cn("h-5 w-5 shrink-0 text-slate-700 hover:text-slate-500")} />
</>
</div>
</DropdownMenuTrigger>

View File

@@ -62,7 +62,7 @@ describe("ProjectSettings component", () => {
industry: "ind",
defaultBrandColor: "#fff",
organizationTeams: [],
canDoRoleManagement: false,
isAccessControlAllowed: false,
userProjectsCount: 0,
} as any;

View File

@@ -42,7 +42,7 @@ interface ProjectSettingsProps {
industry: TProjectConfigIndustry;
defaultBrandColor: string;
organizationTeams: TOrganizationTeam[];
canDoRoleManagement: boolean;
isAccessControlAllowed: boolean;
userProjectsCount: number;
}
@@ -53,7 +53,7 @@ export const ProjectSettings = ({
industry,
defaultBrandColor,
organizationTeams,
canDoRoleManagement = false,
isAccessControlAllowed = false,
userProjectsCount,
}: ProjectSettingsProps) => {
const [createTeamModalOpen, setCreateTeamModalOpen] = useState(false);
@@ -174,7 +174,7 @@ export const ProjectSettings = ({
)}
/>
{canDoRoleManagement && userProjectsCount > 0 && (
{isAccessControlAllowed && userProjectsCount > 0 && (
<FormField
control={form.control}
name="teamIds"

View File

@@ -1,6 +1,6 @@
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
import { getUserProjects } from "@/lib/project/service";
import { getRoleManagementPermission } from "@/modules/ee/license-check/lib/utils";
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
@@ -12,7 +12,7 @@ vi.mock("@/lib/constants", () => ({ DEFAULT_BRAND_COLOR: "#fff" }));
// Mocks before component import
vi.mock("@/app/(app)/(onboarding)/lib/onboarding", () => ({ getTeamsByOrganizationId: vi.fn() }));
vi.mock("@/lib/project/service", () => ({ getUserProjects: vi.fn() }));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({ getRoleManagementPermission: vi.fn() }));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({ getAccessControlPermission: vi.fn() }));
vi.mock("@/modules/organization/lib/utils", () => ({ getOrganizationAuth: vi.fn() }));
vi.mock("@/tolgee/server", () => ({ getTranslate: () => Promise.resolve((key: string) => key) }));
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
@@ -61,7 +61,7 @@ describe("ProjectSettingsPage", () => {
} as any);
vi.mocked(getUserProjects).mockResolvedValueOnce([] as any);
vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce(null as any);
vi.mocked(getRoleManagementPermission).mockResolvedValueOnce(false as any);
vi.mocked(getAccessControlPermission).mockResolvedValueOnce(false as any);
await expect(Page({ params, searchParams })).rejects.toThrow("common.organization_teams_not_found");
});
@@ -73,7 +73,7 @@ describe("ProjectSettingsPage", () => {
} as any);
vi.mocked(getUserProjects).mockResolvedValueOnce([{ id: "p1" }] as any);
vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce([{ id: "t1", name: "Team1" }] as any);
vi.mocked(getRoleManagementPermission).mockResolvedValueOnce(true as any);
vi.mocked(getAccessControlPermission).mockResolvedValueOnce(true as any);
const element = await Page({ params, searchParams });
render(element as React.ReactElement);
@@ -96,7 +96,7 @@ describe("ProjectSettingsPage", () => {
} as any);
vi.mocked(getUserProjects).mockResolvedValueOnce([] as any);
vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce([{ id: "t1", name: "Team1" }] as any);
vi.mocked(getRoleManagementPermission).mockResolvedValueOnce(true as any);
vi.mocked(getAccessControlPermission).mockResolvedValueOnce(true as any);
const element = await Page({ params, searchParams });
render(element as React.ReactElement);

View File

@@ -2,7 +2,7 @@ import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboardin
import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings";
import { DEFAULT_BRAND_COLOR } from "@/lib/constants";
import { getUserProjects } from "@/lib/project/service";
import { getRoleManagementPermission } from "@/modules/ee/license-check/lib/utils";
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { Button } from "@/modules/ui/components/button";
import { Header } from "@/modules/ui/components/header";
@@ -41,7 +41,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
const organizationTeams = await getTeamsByOrganizationId(params.organizationId);
const canDoRoleManagement = await getRoleManagementPermission(organization.billing.plan);
const isAccessControlAllowed = await getAccessControlPermission(organization.billing.plan);
if (!organizationTeams) {
throw new Error(t("common.organization_teams_not_found"));
@@ -60,7 +60,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
industry={industry}
defaultBrandColor={DEFAULT_BRAND_COLOR}
organizationTeams={organizationTeams}
canDoRoleManagement={canDoRoleManagement}
isAccessControlAllowed={isAccessControlAllowed}
userProjectsCount={projects.length}
/>
{projects.length >= 1 && (

View File

@@ -8,8 +8,8 @@ import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-clie
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import {
getAccessControlPermission,
getOrganizationProjectsLimit,
getRoleManagementPermission,
} from "@/modules/ee/license-check/lib/utils";
import { createProject } from "@/modules/projects/settings/lib/project";
import { z } from "zod";
@@ -58,9 +58,9 @@ export const createProjectAction = authenticatedActionClient.schema(ZCreateProje
}
if (parsedInput.data.teamIds && parsedInput.data.teamIds.length > 0) {
const canDoRoleManagement = await getRoleManagementPermission(organization.billing.plan);
const isAccessControlAllowed = await getAccessControlPermission(organization.billing.plan);
if (!canDoRoleManagement) {
if (!isAccessControlAllowed) {
throw new OperationNotAllowedError("You do not have permission to manage roles");
}
}

View File

@@ -10,8 +10,8 @@ import {
import { getUserProjects } from "@/lib/project/service";
import { getUser } from "@/lib/user/service";
import {
getAccessControlPermission,
getOrganizationProjectsLimit,
getRoleManagementPermission,
} from "@/modules/ee/license-check/lib/utils";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { getTeamsByOrganizationId } from "@/modules/ee/teams/team-list/lib/team";
@@ -53,7 +53,7 @@ vi.mock("@/lib/membership/utils", () => ({
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getOrganizationProjectsLimit: vi.fn(),
getRoleManagementPermission: vi.fn(),
getAccessControlPermission: vi.fn(),
}));
vi.mock("@/modules/ee/teams/lib/roles", () => ({
getProjectPermissionByUserId: vi.fn(),
@@ -79,11 +79,11 @@ vi.mock("@/lib/constants", () => ({
// Mock components
vi.mock("@/app/(app)/environments/[environmentId]/components/MainNavigation", () => ({
MainNavigation: ({ organizationTeams, canDoRoleManagement }: any) => (
MainNavigation: ({ organizationTeams, isAccessControlAllowed }: any) => (
<div data-testid="main-navigation">
MainNavigation
<div data-testid="organization-teams">{JSON.stringify(organizationTeams || [])}</div>
<div data-testid="can-do-role-management">{canDoRoleManagement?.toString() || "false"}</div>
<div data-testid="is-access-control-allowed">{isAccessControlAllowed?.toString() || "false"}</div>
</div>
),
}));
@@ -202,7 +202,7 @@ describe("EnvironmentLayout", () => {
vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(null as any);
vi.mocked(getProjectPermissionByUserId).mockResolvedValue(mockProjectPermission);
vi.mocked(getTeamsByOrganizationId).mockResolvedValue(mockOrganizationTeams);
vi.mocked(getRoleManagementPermission).mockResolvedValue(true);
vi.mocked(getAccessControlPermission).mockResolvedValue(true);
mockIsDevelopment = false;
mockIsFormbricksCloud = false;
});
@@ -315,7 +315,7 @@ describe("EnvironmentLayout", () => {
expect(screen.getByTestId("downgrade-banner")).toBeInTheDocument();
});
test("passes canDoRoleManagement props to MainNavigation", async () => {
test("passes isAccessControlAllowed props to MainNavigation", async () => {
vi.resetModules();
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({
@@ -337,8 +337,8 @@ describe("EnvironmentLayout", () => {
})
);
expect(screen.getByTestId("can-do-role-management")).toHaveTextContent("true");
expect(vi.mocked(getRoleManagementPermission)).toHaveBeenCalledWith(mockOrganization.billing.plan);
expect(screen.getByTestId("is-access-control-allowed")).toHaveTextContent("true");
expect(vi.mocked(getAccessControlPermission)).toHaveBeenCalledWith(mockOrganization.billing.plan);
});
test("handles empty organizationTeams array", async () => {
@@ -393,8 +393,8 @@ describe("EnvironmentLayout", () => {
expect(screen.getByTestId("organization-teams")).toHaveTextContent("[]");
});
test("handles canDoRoleManagement false", async () => {
vi.mocked(getRoleManagementPermission).mockResolvedValue(false);
test("handles isAccessControlAllowed false", async () => {
vi.mocked(getAccessControlPermission).mockResolvedValue(false);
vi.resetModules();
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({
@@ -416,7 +416,7 @@ describe("EnvironmentLayout", () => {
})
);
expect(screen.getByTestId("can-do-role-management")).toHaveTextContent("false");
expect(screen.getByTestId("is-access-control-allowed")).toHaveTextContent("false");
});
test("throws error if user not found", async () => {

View File

@@ -14,8 +14,8 @@ import { getUserProjects } from "@/lib/project/service";
import { getUser } from "@/lib/user/service";
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
import {
getAccessControlPermission,
getOrganizationProjectsLimit,
getRoleManagementPermission,
} from "@/modules/ee/license-check/lib/utils";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { DevEnvironmentBanner } from "@/modules/ui/components/dev-environment-banner";
@@ -51,10 +51,10 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
throw new Error(t("common.environment_not_found"));
}
const [projects, environments, canDoRoleManagement] = await Promise.all([
const [projects, environments, isAccessControlAllowed] = await Promise.all([
getUserProjects(user.id, organization.id),
getEnvironments(environment.projectId),
getRoleManagementPermission(organization.billing.plan),
getAccessControlPermission(organization.billing.plan),
]);
if (!projects || !environments || !organizations) {
@@ -121,16 +121,16 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
membershipRole={membershipRole}
isMultiOrgEnabled={isMultiOrgEnabled}
isLicenseActive={active}
canDoRoleManagement={canDoRoleManagement}
isAccessControlAllowed={isAccessControlAllowed}
/>
<div id="mainContent" className="flex-1 overflow-y-auto bg-slate-50">
<div id="mainContent" className="flex flex-1 flex-col overflow-hidden bg-slate-50">
<TopControlBar
environment={environment}
environments={environments}
membershipRole={membershipRole}
projectPermission={projectPermission}
/>
<div className="mt-14">{children}</div>
<div className="flex-1 overflow-y-auto">{children}</div>
</div>
</div>
</div>

View File

@@ -56,16 +56,16 @@ vi.mock("@/modules/projects/components/project-switcher", () => ({
ProjectSwitcher: ({
isCollapsed,
organizationTeams,
canDoRoleManagement,
isAccessControlAllowed,
}: {
isCollapsed: boolean;
organizationTeams: TOrganizationTeam[];
canDoRoleManagement: boolean;
isAccessControlAllowed: boolean;
}) => (
<div data-testid="project-switcher" data-collapsed={isCollapsed}>
Project Switcher
<div data-testid="organization-teams-count">{organizationTeams?.length || 0}</div>
<div data-testid="can-do-role-management">{canDoRoleManagement.toString()}</div>
<div data-testid="is-access-control-allowed">{isAccessControlAllowed.toString()}</div>
</div>
),
}));
@@ -157,7 +157,7 @@ const defaultProps = {
membershipRole: "owner" as const,
organizationProjectsLimit: 5,
isLicenseActive: true,
canDoRoleManagement: true,
isAccessControlAllowed: true,
};
describe("MainNavigation", () => {
@@ -347,11 +347,11 @@ describe("MainNavigation", () => {
expect(screen.queryByText("common.license")).not.toBeInTheDocument();
});
test("passes canDoRoleManagement props to ProjectSwitcher", () => {
test("passes isAccessControlAllowed props to ProjectSwitcher", () => {
render(<MainNavigation {...defaultProps} />);
expect(screen.getByTestId("organization-teams-count")).toHaveTextContent("0");
expect(screen.getByTestId("can-do-role-management")).toHaveTextContent("true");
expect(screen.getByTestId("is-access-control-allowed")).toHaveTextContent("true");
});
test("handles no organizationTeams", () => {
@@ -360,9 +360,9 @@ describe("MainNavigation", () => {
expect(screen.getByTestId("organization-teams-count")).toHaveTextContent("0");
});
test("handles canDoRoleManagement false", () => {
render(<MainNavigation {...defaultProps} canDoRoleManagement={false} />);
test("handles isAccessControlAllowed false", () => {
render(<MainNavigation {...defaultProps} isAccessControlAllowed={false} />);
expect(screen.getByTestId("can-do-role-management")).toHaveTextContent("false");
expect(screen.getByTestId("is-access-control-allowed")).toHaveTextContent("false");
});
});

View File

@@ -66,7 +66,7 @@ interface NavigationProps {
membershipRole?: TOrganizationRole;
organizationProjectsLimit: number;
isLicenseActive: boolean;
canDoRoleManagement: boolean;
isAccessControlAllowed: boolean;
}
export const MainNavigation = ({
@@ -81,7 +81,7 @@ export const MainNavigation = ({
organizationProjectsLimit,
isLicenseActive,
isDevelopment,
canDoRoleManagement,
isAccessControlAllowed,
}: NavigationProps) => {
const router = useRouter();
const pathname = usePathname();
@@ -325,7 +325,7 @@ export const MainNavigation = ({
isTextVisible={isTextVisible}
organization={organization}
organizationProjectsLimit={organizationProjectsLimit}
canDoRoleManagement={canDoRoleManagement}
isAccessControlAllowed={isAccessControlAllowed}
/>
)}
@@ -339,27 +339,30 @@ export const MainNavigation = ({
<div
tabIndex={0}
className={cn(
"flex cursor-pointer flex-row items-center space-x-3",
isCollapsed ? "pl-2" : "pl-4"
"flex cursor-pointer flex-row items-center gap-3",
isCollapsed ? "justify-center px-2" : "px-4"
)}>
<ProfileAvatar userId={user.id} imageUrl={user.imageUrl} />
{!isCollapsed && !isTextVisible && (
<>
<div className={cn(isTextVisible ? "opacity-0" : "opacity-100")}>
<div
className={cn(isTextVisible ? "opacity-0" : "opacity-100", "grow overflow-hidden")}>
<p
title={user?.email}
className={cn(
"ph-no-capture ph-no-capture -mb-0.5 max-w-28 truncate text-sm font-bold text-slate-700"
"ph-no-capture ph-no-capture -mb-0.5 truncate text-sm font-bold text-slate-700"
)}>
{user?.name ? <span>{user?.name}</span> : <span>{user?.email}</span>}
</p>
<p
title={capitalizeFirstLetter(organization?.name)}
className="max-w-28 truncate text-sm text-slate-500">
className="truncate text-sm text-slate-500">
{capitalizeFirstLetter(organization?.name)}
</p>
</div>
<ChevronRightIcon className={cn("h-5 w-5 text-slate-700 hover:text-slate-500")} />
<ChevronRightIcon
className={cn("h-5 w-5 shrink-0 text-slate-700 hover:text-slate-500")}
/>
</>
)}
</div>

View File

@@ -44,10 +44,8 @@ describe("TopControlBar", () => {
);
// Check if the main div is rendered
const mainDiv = screen.getByTestId("top-control-buttons").parentElement?.parentElement?.parentElement;
expect(mainDiv).toHaveClass(
"fixed inset-0 top-0 z-30 flex h-14 w-full items-center justify-end bg-slate-50 px-6"
);
const mainDiv = screen.getByTestId("fb__global-top-control-bar");
expect(mainDiv).toHaveClass("flex h-14 w-full items-center justify-end bg-slate-50 px-6");
// Check if the mocked child component is rendered
expect(screen.getByTestId("top-control-buttons")).toBeInTheDocument();

View File

@@ -17,7 +17,9 @@ export const TopControlBar = ({
projectPermission,
}: SideBarProps) => {
return (
<div className="fixed inset-0 top-0 z-30 flex h-14 w-full items-center justify-end bg-slate-50 px-6">
<div
className="flex h-14 w-full items-center justify-end bg-slate-50 px-6"
data-testid="fb__global-top-control-bar">
<div className="shadow-xs z-10">
<div className="flex w-fit items-center space-x-2 py-2">
<TopControlButtons

View File

@@ -121,8 +121,9 @@ describe("ProfilePage", () => {
expect(screen.getByTestId("account-security")).toBeInTheDocument(); // Shown because 2FA license is enabled
expect(screen.queryByTestId("upgrade-prompt")).not.toBeInTheDocument();
expect(screen.getByTestId("delete-account")).toBeInTheDocument();
// Use a regex to match the text content, allowing for variable whitespace
expect(screen.getByText(new RegExp(`common\\.profile\\s*:\\s*${mockUser.id}`))).toBeInTheDocument(); // SettingsId
// Check for IdBadge content
expect(screen.getByText("common.profile_id")).toBeInTheDocument();
expect(screen.getByText(mockUser.id)).toBeInTheDocument();
});
});

View File

@@ -5,9 +5,9 @@ import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/servi
import { getUser } from "@/lib/user/service";
import { getIsMultiOrgEnabled, getIsTwoFactorAuthEnabled } from "@/modules/ee/license-check/lib/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { SettingsId } from "@/modules/ui/components/settings-id";
import { UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
import { getTranslate } from "@/tolgee/server";
import { SettingsCard } from "../../components/SettingsCard";
@@ -103,7 +103,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
isMultiOrgEnabled={isMultiOrgEnabled}
/>
</SettingsCard>
<SettingsId title={t("common.profile")} id={user.id}></SettingsId>
<IdBadge id={user.id} label={t("common.profile_id")} variant="column" />
</div>
)}
</PageContentWrapper>

View File

@@ -5,7 +5,7 @@ import { getIsMultiOrgEnabled, getWhiteLabelPermission } from "@/modules/ee/lice
import { EmailCustomizationSettings } from "@/modules/ee/whitelabel/email-customization/components/email-customization-settings";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { TEnvironmentAuth } from "@/modules/environments/types/environment-auth";
import { SettingsId } from "@/modules/ui/components/settings-id";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { getTranslate } from "@/tolgee/server";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
@@ -78,8 +78,8 @@ vi.mock("./components/DeleteOrganization", () => ({
DeleteOrganization: vi.fn(() => <div>DeleteOrganization</div>),
}));
vi.mock("@/modules/ui/components/settings-id", () => ({
SettingsId: vi.fn(() => <div>SettingsId</div>),
vi.mock("@/modules/ui/components/id-badge", () => ({
IdBadge: vi.fn(() => <div>IdBadge</div>),
}));
describe("Page", () => {
@@ -156,10 +156,11 @@ describe("Page", () => {
},
undefined
);
expect(SettingsId).toHaveBeenCalledWith(
expect(IdBadge).toHaveBeenCalledWith(
{
title: "common.organization_id",
id: mockEnvironmentAuth.organization.id,
label: "common.organization_id",
variant: "column",
},
undefined
);

View File

@@ -4,9 +4,9 @@ import { getUser } from "@/lib/user/service";
import { getIsMultiOrgEnabled, getWhiteLabelPermission } from "@/modules/ee/license-check/lib/utils";
import { EmailCustomizationSettings } from "@/modules/ee/whitelabel/email-customization/components/email-customization-settings";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { SettingsId } from "@/modules/ui/components/settings-id";
import { getTranslate } from "@/tolgee/server";
import { SettingsCard } from "../../components/SettingsCard";
import { DeleteOrganization } from "./components/DeleteOrganization";
@@ -70,7 +70,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
</SettingsCard>
)}
<SettingsId title={t("common.organization_id")} id={organization.id}></SettingsId>
<IdBadge id={organization.id} label={t("common.organization_id")} variant="column" />
</PageContentWrapper>
);
};

View File

@@ -60,7 +60,6 @@ const mockResponses = [
userAgent: { browser: "Chrome", os: "Mac OS", device: "Desktop" },
url: "http://localhost:3000",
},
notes: [],
tags: [],
} as unknown as TResponse,
{
@@ -74,7 +73,6 @@ const mockResponses = [
userAgent: { browser: "Firefox", os: "Windows", device: "Desktop" },
url: "http://localhost:3000/page2",
},
notes: [],
tags: [],
} as unknown as TResponse,
{
@@ -88,7 +86,6 @@ const mockResponses = [
userAgent: { browser: "Safari", os: "iOS", device: "Mobile" },
url: "http://localhost:3000/page3",
},
notes: [],
tags: [],
} as unknown as TResponse,
] as unknown as TResponse[];

View File

@@ -82,7 +82,6 @@ export const ResponseCardModal = ({
survey={survey}
response={responses[currentIndex]}
user={user}
pageType="response"
environment={environment}
environmentTags={environmentTags}
isReadOnly={isReadOnly}

View File

@@ -117,17 +117,6 @@ const mockResponses: TResponse[] = [
singleUseId: null,
ttc: {},
tags: [{ id: "tag1", name: "Tag1", environmentId: "env1", createdAt: new Date(), updatedAt: new Date() }],
notes: [
{
id: "note1",
text: "Note 1",
createdAt: new Date(),
updatedAt: new Date(),
isResolved: false,
isEdited: false,
user: { id: "user1", name: "User 1" },
},
],
variables: { var1: "Response Var Value" },
language: "en",
contact: null,
@@ -144,7 +133,6 @@ const mockResponses: TResponse[] = [
singleUseId: null,
ttc: {},
tags: [],
notes: [],
variables: {},
language: "de",
contact: null,
@@ -234,12 +222,17 @@ describe("ResponseDataView", () => {
status: "Completed",
responseId: "response1",
tags: mockResponses[0].tags,
notes: mockResponses[0].notes,
variables: { var1: "Response Var Value" },
verifiedEmail: "test@example.com",
language: "en",
person: null,
contactAttributes: null,
meta: {
url: "http://localhost",
userAgent: {
browser: "test-agent",
},
},
},
{
responseData: {
@@ -249,12 +242,17 @@ describe("ResponseDataView", () => {
status: "Not Completed",
responseId: "response2",
tags: [],
notes: [],
variables: {},
verifiedEmail: "",
language: "de",
person: null,
contactAttributes: null,
meta: {
url: "http://localhost",
userAgent: {
browser: "test-agent-2",
},
},
},
];

View File

@@ -90,7 +90,6 @@ export const mapResponsesToTableData = (
: t("environments.surveys.responses.not_completed"),
responseId: response.id,
tags: response.tags,
notes: response.notes,
variables: survey.variables.reduce(
(acc, curr) => {
return Object.assign(acc, { [curr.id]: response.variables[curr.id] });
@@ -101,6 +100,7 @@ export const mapResponsesToTableData = (
language: response.language,
person: response.contact,
contactAttributes: response.contactAttributes,
meta: response.meta,
}));
};

View File

@@ -107,7 +107,6 @@ const mockResponses: TResponse[] = [
finished: true,
data: {},
meta: { userAgent: {} },
notes: [],
tags: [],
} as unknown as TResponse,
{
@@ -118,7 +117,6 @@ const mockResponses: TResponse[] = [
finished: true,
data: {},
meta: { userAgent: {} },
notes: [],
tags: [],
} as unknown as TResponse,
];

View File

@@ -1,12 +1,11 @@
import { extractChoiceIdsFromResponse } from "@/lib/response/utils";
import { processResponseData } from "@/lib/responses";
import { getContactIdentifier } from "@/lib/utils/contact";
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
import { getSelectionColumn } from "@/modules/ui/components/data-table";
import { ResponseBadges } from "@/modules/ui/components/response-badges";
import { cleanup } from "@testing-library/react";
import { AnyActionArg } from "react";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TResponseNote, TResponseNoteUser, TResponseTableData } from "@formbricks/types/responses";
import { TResponseTableData } from "@formbricks/types/responses";
import {
TSurvey,
TSurveyQuestion,
@@ -60,6 +59,7 @@ vi.mock("@/modules/survey/lib/questions", () => ({
getQuestionIconMap: vi.fn(() => ({
[TSurveyQuestionTypeEnum.OpenText]: <span>OT</span>,
[TSurveyQuestionTypeEnum.MultipleChoiceSingle]: <span>MCS</span>,
[TSurveyQuestionTypeEnum.MultipleChoiceMulti]: <span>MCM</span>,
[TSurveyQuestionTypeEnum.Matrix]: <span>MX</span>,
[TSurveyQuestionTypeEnum.Address]: <span>AD</span>,
[TSurveyQuestionTypeEnum.ContactInfo]: <span>CI</span>,
@@ -102,6 +102,33 @@ vi.mock("lucide-react", () => ({
EyeOffIcon: () => <span>EyeOff</span>,
MailIcon: () => <span>Mail</span>,
TagIcon: () => <span>Tag</span>,
MousePointerClickIcon: () => <span>MousePointerClick</span>,
AirplayIcon: () => <span>Airplay</span>,
ArrowUpFromDotIcon: () => <span>ArrowUpFromDot</span>,
FlagIcon: () => <span>Flag</span>,
GlobeIcon: () => <span>Globe</span>,
SmartphoneIcon: () => <span>Smartphone</span>,
}));
// Mock new dependencies
vi.mock("@/lib/response/utils", () => ({
extractChoiceIdsFromResponse: vi.fn((responseValue) => {
// Mock implementation that returns choice IDs based on response value
if (Array.isArray(responseValue)) {
return responseValue.map((_, index) => `choice-${index + 1}`);
} else if (typeof responseValue === "string") {
return [`choice-single`];
}
return [];
}),
}));
vi.mock("@/modules/ui/components/id-badge", () => ({
IdBadge: vi.fn(({ id }) => <div data-testid="id-badge">{id}</div>),
}));
vi.mock("@/modules/ui/lib/utils", () => ({
cn: vi.fn((...classes) => classes.filter(Boolean).join(" ")),
}));
const mockSurvey = {
@@ -136,6 +163,28 @@ const mockSurvey = {
headline: { default: "Contact Info Question" },
required: false,
} as unknown as TSurveyQuestion,
{
id: "q5single",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
headline: { default: "Single Choice Question" },
required: false,
choices: [
{ id: "choice-1", label: { default: "Option 1" } },
{ id: "choice-2", label: { default: "Option 2" } },
{ id: "choice-3", label: { default: "Option 3" } },
],
} as unknown as TSurveyQuestion,
{
id: "q6multi",
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
headline: { default: "Multi Choice Question" },
required: false,
choices: [
{ id: "choice-a", label: { default: "Choice A" } },
{ id: "choice-b", label: { default: "Choice B" } },
{ id: "choice-c", label: { default: "Choice C" } },
],
} as unknown as TSurveyQuestion,
],
variables: [
{ id: "var1", name: "User Segment", type: "text" } as TSurveyVariable,
@@ -173,19 +222,13 @@ const mockResponseData = {
firstName: "John",
email: "john.doe@example.com",
hf1: "Hidden Field 1 Value",
q5single: "Option 1", // Single choice response
q6multi: ["Choice A", "Choice C"], // Multi choice response
},
variables: {
var1: "Segment A",
var2: 100,
},
notes: [
{
id: "note1",
text: "This is a note",
updatedAt: new Date(),
user: { name: "User" } as unknown as TResponseNoteUser,
} as TResponseNote,
],
status: "completed",
tags: [{ id: "tag1", name: "Important" } as unknown as TTag],
language: "default",
@@ -251,14 +294,6 @@ describe("generateResponseTableColumns", () => {
const hf1Col = columns.find((col) => (col as any).accessorKey === "hf1");
expect(hf1Col).toBeUndefined();
});
test("should generate Notes column", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const notesCol = columns.find((col) => (col as any).accessorKey === "notes");
expect(notesCol).toBeDefined();
(notesCol?.cell as any)?.({ row: { original: mockResponseData } } as any);
expect(vi.mocked(processResponseData)).toHaveBeenCalledWith(["This is a note"]);
});
});
describe("ResponseTableColumns", () => {
@@ -398,36 +433,6 @@ describe("ResponseTableColumns - Column Implementations", () => {
expect(cellResult).toBeUndefined();
});
test("notesColumn renders when notes is an array", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const notesColumn: any = columns.find((col) => (col as any).accessorKey === "notes");
expect(notesColumn).toBeDefined();
// Mock a response with notes
const mockRow = {
original: { notes: [{ text: "Note 1" }, { text: "Note 2" }] },
} as any;
// Call the cell function
notesColumn?.cell?.({ row: mockRow } as any);
expect(vi.mocked(processResponseData)).toHaveBeenCalledWith(["Note 1", "Note 2"]);
});
test("notesColumn returns undefined when notes is not an array", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const notesColumn: any = columns.find((col) => (col as any).accessorKey === "notes");
expect(notesColumn).toBeDefined();
// Mock a response with no notes
const mockRow = {
original: { notes: null },
} as any;
// Call the cell function
const cellResult = notesColumn?.cell?.({ row: mockRow } as any);
expect(cellResult).toBeUndefined();
});
test("variableColumns render variable values correctly", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
@@ -495,3 +500,281 @@ describe("ResponseTableColumns - Column Implementations", () => {
expect(hfColumn).toBeUndefined();
});
});
describe("ResponseTableColumns - Multiple Choice Questions", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
test("generates two columns for multipleChoiceSingle questions", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
// Should have main response column
const mainColumn = columns.find((col) => (col as any).accessorKey === "q5single");
expect(mainColumn).toBeDefined();
// Should have option IDs column
const optionIdsColumn = columns.find((col) => (col as any).accessorKey === "q5singleoptionIds");
expect(optionIdsColumn).toBeDefined();
});
test("generates two columns for multipleChoiceMulti questions", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
// Should have main response column
const mainColumn = columns.find((col) => (col as any).accessorKey === "q6multi");
expect(mainColumn).toBeDefined();
// Should have option IDs column
const optionIdsColumn = columns.find((col) => (col as any).accessorKey === "q6multioptionIds");
expect(optionIdsColumn).toBeDefined();
});
test("multipleChoiceSingle main column renders RenderResponse component", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const mainColumn: any = columns.find((col) => (col as any).accessorKey === "q5single");
const mockRow = {
original: {
responseData: { q5single: "Option 1" },
language: "default",
},
};
const cellResult = mainColumn?.cell?.({ row: mockRow } as any);
// Check that RenderResponse component is returned
expect(cellResult).toBeDefined();
});
test("multipleChoiceMulti main column renders RenderResponse component", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const mainColumn: any = columns.find((col) => (col as any).accessorKey === "q6multi");
const mockRow = {
original: {
responseData: { q6multi: ["Choice A", "Choice C"] },
language: "default",
},
};
const cellResult = mainColumn?.cell?.({ row: mockRow } as any);
// Check that RenderResponse component is returned
expect(cellResult).toBeDefined();
});
});
describe("ResponseTableColumns - Choice ID Columns", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
test("option IDs column calls extractChoiceIdsFromResponse for string response", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const optionIdsColumn: any = columns.find((col) => (col as any).accessorKey === "q5singleoptionIds");
const mockRow = {
original: {
responseData: { q5single: "Option 1" },
language: "default",
},
};
optionIdsColumn?.cell?.({ row: mockRow } as any);
expect(vi.mocked(extractChoiceIdsFromResponse)).toHaveBeenCalledWith(
"Option 1",
expect.objectContaining({ id: "q5single", type: "multipleChoiceSingle" }),
"default"
);
});
test("option IDs column calls extractChoiceIdsFromResponse for array response", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const optionIdsColumn: any = columns.find((col) => (col as any).accessorKey === "q6multioptionIds");
const mockRow = {
original: {
responseData: { q6multi: ["Choice A", "Choice C"] },
language: "default",
},
};
optionIdsColumn?.cell?.({ row: mockRow } as any);
expect(vi.mocked(extractChoiceIdsFromResponse)).toHaveBeenCalledWith(
["Choice A", "Choice C"],
expect.objectContaining({ id: "q6multi", type: "multipleChoiceMulti" }),
"default"
);
});
test("option IDs column renders IdBadge components for choice IDs", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const optionIdsColumn: any = columns.find((col) => (col as any).accessorKey === "q6multioptionIds");
const mockRow = {
original: {
responseData: { q6multi: ["Choice A", "Choice C"] },
language: "default",
},
};
// Mock extractChoiceIdsFromResponse to return specific choice IDs
vi.mocked(extractChoiceIdsFromResponse).mockReturnValueOnce(["choice-1", "choice-3"]);
const cellResult = optionIdsColumn?.cell?.({ row: mockRow } as any);
// Should render something for choice IDs
expect(cellResult).toBeDefined();
// Verify that extractChoiceIdsFromResponse was called
expect(vi.mocked(extractChoiceIdsFromResponse)).toHaveBeenCalled();
});
test("option IDs column returns null for non-string/array response values", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const optionIdsColumn: any = columns.find((col) => (col as any).accessorKey === "q5singleoptionIds");
const mockRow = {
original: {
responseData: { q5single: 123 }, // Invalid type
language: "default",
},
};
const cellResult = optionIdsColumn?.cell?.({ row: mockRow } as any);
expect(cellResult).toBeNull();
expect(vi.mocked(extractChoiceIdsFromResponse)).not.toHaveBeenCalled();
});
test("option IDs column returns null when no choice IDs found", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const optionIdsColumn: any = columns.find((col) => (col as any).accessorKey === "q5singleoptionIds");
const mockRow = {
original: {
responseData: { q5single: "Non-existent option" },
language: "default",
},
};
// Mock extractChoiceIdsFromResponse to return empty array
vi.mocked(extractChoiceIdsFromResponse).mockReturnValueOnce([]);
const cellResult = optionIdsColumn?.cell?.({ row: mockRow } as any);
expect(cellResult).toBeNull();
});
test("option IDs column handles missing language gracefully", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const optionIdsColumn: any = columns.find((col) => (col as any).accessorKey === "q5singleoptionIds");
const mockRow = {
original: {
responseData: { q5single: "Option 1" },
language: null, // No language
},
};
optionIdsColumn?.cell?.({ row: mockRow } as any);
expect(vi.mocked(extractChoiceIdsFromResponse)).toHaveBeenCalledWith(
"Option 1",
expect.objectContaining({ id: "q5single" }),
undefined
);
});
});
describe("ResponseTableColumns - Helper Functions", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
test("question headers are properly created for multiple choice questions", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const mainColumn: any = columns.find((col) => (col as any).accessorKey === "q5single");
const optionIdsColumn: any = columns.find((col) => (col as any).accessorKey === "q5singleoptionIds");
// Test main column header
const mainHeader = mainColumn?.header?.();
expect(mainHeader).toBeDefined();
expect(mainHeader?.props?.className).toContain("flex items-center justify-between");
// Test option IDs column header
const optionHeader = optionIdsColumn?.header?.();
expect(optionHeader).toBeDefined();
expect(optionHeader?.props?.className).toContain("flex items-center justify-between");
});
test("question headers include proper icons for multiple choice questions", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const singleChoiceColumn: any = columns.find((col) => (col as any).accessorKey === "q5single");
const multiChoiceColumn: any = columns.find((col) => (col as any).accessorKey === "q6multi");
// Headers should be functions that return JSX
expect(typeof singleChoiceColumn?.header).toBe("function");
expect(typeof multiChoiceColumn?.header).toBe("function");
// Call headers to ensure they don't throw
expect(() => singleChoiceColumn?.header?.()).not.toThrow();
expect(() => multiChoiceColumn?.header?.()).not.toThrow();
});
});
describe("ResponseTableColumns - Integration Tests", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
test("multiple choice questions work end-to-end with real data", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
// Find all multiple choice related columns
const singleMainCol = columns.find((col) => (col as any).accessorKey === "q5single");
const singleIdsCol = columns.find((col) => (col as any).accessorKey === "q5singleoptionIds");
const multiMainCol = columns.find((col) => (col as any).accessorKey === "q6multi");
const multiIdsCol = columns.find((col) => (col as any).accessorKey === "q6multioptionIds");
expect(singleMainCol).toBeDefined();
expect(singleIdsCol).toBeDefined();
expect(multiMainCol).toBeDefined();
expect(multiIdsCol).toBeDefined();
// Test with actual mock response data
const mockRow = { original: mockResponseData };
// Test single choice main column
const singleMainResult = (singleMainCol?.cell as any)?.({ row: mockRow });
expect(singleMainResult).toBeDefined();
// Test multi choice main column
const multiMainResult = (multiMainCol?.cell as any)?.({ row: mockRow });
expect(multiMainResult).toBeDefined();
// Test that choice ID columns exist and can be called
const singleIdsResult = (singleIdsCol?.cell as any)?.({ row: mockRow });
const multiIdsResult = (multiIdsCol?.cell as any)?.({ row: mockRow });
// Should not error when calling the cell functions
expect(() => singleIdsResult).not.toThrow();
expect(() => multiIdsResult).not.toThrow();
});
});

View File

@@ -1,58 +1,31 @@
"use client";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { processResponseData } from "@/lib/responses";
import { extractChoiceIdsFromResponse } from "@/lib/response/utils";
import { getContactIdentifier } from "@/lib/utils/contact";
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
import { recallToHeadline } from "@/lib/utils/recall";
import { RenderResponse } from "@/modules/analysis/components/SingleResponseCard/components/RenderResponse";
import { VARIABLES_ICON_MAP, getQuestionIconMap } from "@/modules/survey/lib/questions";
import { getSelectionColumn } from "@/modules/ui/components/data-table";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { ResponseBadges } from "@/modules/ui/components/response-badges";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
import { cn } from "@/modules/ui/lib/utils";
import { ColumnDef } from "@tanstack/react-table";
import { TFnType } from "@tolgee/react";
import { CircleHelpIcon, EyeOffIcon, MailIcon, TagIcon } from "lucide-react";
import Link from "next/link";
import { TResponseTableData } from "@formbricks/types/responses";
import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types";
const getAddressFieldLabel = (field: string, t: TFnType) => {
switch (field) {
case "addressLine1":
return t("environments.surveys.responses.address_line_1");
case "addressLine2":
return t("environments.surveys.responses.address_line_2");
case "city":
return t("environments.surveys.responses.city");
case "state":
return t("environments.surveys.responses.state_region");
case "zip":
return t("environments.surveys.responses.zip_post_code");
case "country":
return t("environments.surveys.responses.country");
default:
break;
}
};
const getContactInfoFieldLabel = (field: string, t: TFnType) => {
switch (field) {
case "firstName":
return t("environments.surveys.responses.first_name");
case "lastName":
return t("environments.surveys.responses.last_name");
case "email":
return t("environments.surveys.responses.email");
case "phone":
return t("environments.surveys.responses.phone");
case "company":
return t("environments.surveys.responses.company");
default:
break;
}
};
import {
COLUMNS_ICON_MAP,
METADATA_FIELDS,
getAddressFieldLabel,
getContactInfoFieldLabel,
getMetadataFieldLabel,
getMetadataValue,
} from "../lib/utils";
const getQuestionColumnsData = (
question: TSurveyQuestion,
@@ -61,6 +34,42 @@ const getQuestionColumnsData = (
t: TFnType
): ColumnDef<TResponseTableData>[] => {
const QUESTIONS_ICON_MAP = getQuestionIconMap(t);
// Helper function to create consistent column headers
const createQuestionHeader = (questionType: string, headline: string, suffix?: string) => {
const title = suffix ? `${headline} - ${suffix}` : headline;
const QuestionHeader = () => (
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2 overflow-hidden">
<span className="h-4 w-4">{QUESTIONS_ICON_MAP[questionType]}</span>
<span className="truncate">{title}</span>
</div>
</div>
);
QuestionHeader.displayName = "QuestionHeader";
return QuestionHeader;
};
// Helper function to get localized question headline
const getQuestionHeadline = (question: TSurveyQuestion, survey: TSurvey) => {
return getLocalizedValue(recallToHeadline(question.headline, survey, false, "default"), "default");
};
// Helper function to render choice ID badges
const renderChoiceIdBadges = (choiceIds: string[], isExpanded: boolean) => {
if (choiceIds.length === 0) return null;
const containerClasses = cn("flex gap-x-1 w-full", isExpanded && "flex-wrap gap-y-1");
return (
<div className={containerClasses}>
{choiceIds.map((choiceId, index) => (
<IdBadge key={`${choiceId}-${index}`} id={choiceId} />
))}
</div>
);
};
switch (question.type) {
case "matrix":
return question.rows.map((matrixRow) => {
@@ -137,6 +146,50 @@ const getQuestionColumnsData = (
};
});
case "multipleChoiceMulti":
case "multipleChoiceSingle":
case "ranking":
case "pictureSelection": {
const questionHeadline = getQuestionHeadline(question, survey);
return [
{
accessorKey: question.id,
header: createQuestionHeader(question.type, questionHeadline),
cell: ({ row }) => {
const responseValue = row.original.responseData[question.id];
const language = row.original.language;
return (
<RenderResponse
question={question}
survey={survey}
responseData={responseValue}
language={language}
isExpanded={isExpanded}
showId={false}
/>
);
},
},
{
accessorKey: question.id + "optionIds",
header: createQuestionHeader(question.type, questionHeadline, t("common.option_id")),
cell: ({ row }) => {
const responseValue = row.original.responseData[question.id];
// Type guard to ensure responseValue is the correct type
if (typeof responseValue === "string" || Array.isArray(responseValue)) {
const choiceIds = extractChoiceIdsFromResponse(
responseValue,
question,
row.original.language || undefined
);
return renderChoiceIdBadges(choiceIds, isExpanded);
}
return null;
},
},
];
}
default:
return [
{
@@ -164,6 +217,7 @@ const getQuestionColumnsData = (
responseData={responseValue}
language={language}
isExpanded={isExpanded}
showId={false}
/>
);
},
@@ -172,6 +226,33 @@ const getQuestionColumnsData = (
}
};
const getMetadataColumnsData = (t: TFnType): ColumnDef<TResponseTableData>[] => {
const metadataColumns: ColumnDef<TResponseTableData>[] = [];
METADATA_FIELDS.forEach((label) => {
const IconComponent = COLUMNS_ICON_MAP[label];
metadataColumns.push({
accessorKey: label,
header: () => (
<div className="flex items-center space-x-2 overflow-hidden">
<span className="h-4 w-4">{IconComponent && <IconComponent className="h-4 w-4" />}</span>
<span className="truncate">{getMetadataFieldLabel(label, t)}</span>
</div>
),
cell: ({ row }) => {
const value = getMetadataValue(row.original.meta, label);
if (value) {
return <div className="truncate text-slate-900">{value}</div>;
}
return null;
},
});
});
return metadataColumns;
};
export const generateResponseTableColumns = (
survey: TSurvey,
isExpanded: boolean,
@@ -230,7 +311,7 @@ export const generateResponseTableColumns = (
header: t("common.status"),
cell: ({ row }) => {
const status = row.original.status;
return <ResponseBadges items={[status]} />;
return <ResponseBadges items={[{ value: status }]} showId={false} />;
},
};
@@ -243,27 +324,16 @@ export const generateResponseTableColumns = (
const tagsArray = tags.map((tag) => tag.name);
return (
<ResponseBadges
items={tagsArray}
items={tagsArray.map((tag) => ({ value: tag }))}
isExpanded={isExpanded}
icon={<TagIcon className="h-4 w-4 text-slate-500" />}
showId={false}
/>
);
}
},
};
const notesColumn: ColumnDef<TResponseTableData> = {
accessorKey: "notes",
header: t("common.notes"),
cell: ({ row }) => {
const notes = row.original.notes;
if (Array.isArray(notes)) {
const notesArray = notes.map((note) => note.text);
return processResponseData(notesArray);
}
},
};
const variableColumns: ColumnDef<TResponseTableData>[] = survey.variables.map((variable) => {
return {
accessorKey: variable.id,
@@ -304,6 +374,8 @@ export const generateResponseTableColumns = (
})
: [];
const metadataColumns = getMetadataColumnsData(t);
const verifiedEmailColumn: ColumnDef<TResponseTableData> = {
accessorKey: "verifiedEmail",
header: () => (
@@ -317,7 +389,6 @@ export const generateResponseTableColumns = (
};
// Combine the selection column with the dynamic question columns
const baseColumns = [
personColumn,
dateColumn,
@@ -326,8 +397,8 @@ export const generateResponseTableColumns = (
...questionColumns,
...variableColumns,
...hiddenFieldColumns,
...metadataColumns,
tagsColumn,
notesColumn,
];
return isReadOnly ? baseColumns : [getSelectionColumn(), ...baseColumns];

View File

@@ -0,0 +1,204 @@
import "@testing-library/jest-dom/vitest";
import {
AirplayIcon,
ArrowUpFromDotIcon,
FlagIcon,
GlobeIcon,
MousePointerClickIcon,
SmartphoneIcon,
} from "lucide-react";
import { describe, expect, test, vi } from "vitest";
import {
COLUMNS_ICON_MAP,
getAddressFieldLabel,
getContactInfoFieldLabel,
getMetadataFieldLabel,
getMetadataValue,
} from "./utils";
describe("utils", () => {
const mockT = vi.fn((key: string) => {
const translations: Record<string, string> = {
"environments.surveys.responses.address_line_1": "Address Line 1",
"environments.surveys.responses.address_line_2": "Address Line 2",
"environments.surveys.responses.city": "City",
"environments.surveys.responses.state_region": "State/Region",
"environments.surveys.responses.zip_post_code": "ZIP/Post Code",
"environments.surveys.responses.country": "Country",
"environments.surveys.responses.first_name": "First Name",
"environments.surveys.responses.last_name": "Last Name",
"environments.surveys.responses.email": "Email",
"environments.surveys.responses.phone": "Phone",
"environments.surveys.responses.company": "Company",
"common.action": "Action",
"environments.surveys.responses.os": "OS",
"environments.surveys.responses.device": "Device",
"environments.surveys.responses.browser": "Browser",
"common.url": "URL",
"environments.surveys.responses.source": "Source",
};
return translations[key] || key;
});
describe("getAddressFieldLabel", () => {
test("returns correct label for addressLine1", () => {
const result = getAddressFieldLabel("addressLine1", mockT);
expect(result).toBe("Address Line 1");
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.address_line_1");
});
test("returns correct label for addressLine2", () => {
const result = getAddressFieldLabel("addressLine2", mockT);
expect(result).toBe("Address Line 2");
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.address_line_2");
});
test("returns correct label for city", () => {
const result = getAddressFieldLabel("city", mockT);
expect(result).toBe("City");
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.city");
});
test("returns correct label for state", () => {
const result = getAddressFieldLabel("state", mockT);
expect(result).toBe("State/Region");
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.state_region");
});
test("returns correct label for zip", () => {
const result = getAddressFieldLabel("zip", mockT);
expect(result).toBe("ZIP/Post Code");
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.zip_post_code");
});
test("returns correct label for country", () => {
const result = getAddressFieldLabel("country", mockT);
expect(result).toBe("Country");
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.country");
});
test("returns undefined for unknown field", () => {
const result = getAddressFieldLabel("unknown", mockT);
expect(result).toBeUndefined();
expect(mockT).not.toHaveBeenCalled();
});
});
describe("getContactInfoFieldLabel", () => {
test("returns correct label for firstName", () => {
const result = getContactInfoFieldLabel("firstName", mockT);
expect(result).toBe("First Name");
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.first_name");
});
test("returns correct label for lastName", () => {
const result = getContactInfoFieldLabel("lastName", mockT);
expect(result).toBe("Last Name");
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.last_name");
});
test("returns correct label for email", () => {
const result = getContactInfoFieldLabel("email", mockT);
expect(result).toBe("Email");
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.email");
});
test("returns correct label for phone", () => {
const result = getContactInfoFieldLabel("phone", mockT);
expect(result).toBe("Phone");
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.phone");
});
test("returns correct label for company", () => {
const result = getContactInfoFieldLabel("company", mockT);
expect(result).toBe("Company");
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.company");
});
test("returns undefined for unknown field", () => {
const result = getContactInfoFieldLabel("unknown", mockT);
expect(result).toBeUndefined();
expect(mockT).not.toHaveBeenCalled();
});
});
describe("getMetadataFieldLabel", () => {
test("returns correct label for action", () => {
const result = getMetadataFieldLabel("action", mockT);
expect(result).toBe("Action");
expect(mockT).toHaveBeenCalledWith("common.action");
});
test("returns correct label for country", () => {
const result = getMetadataFieldLabel("country", mockT);
expect(result).toBe("Country");
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.country");
});
test("returns correct label for os", () => {
const result = getMetadataFieldLabel("os", mockT);
expect(result).toBe("OS");
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.os");
});
test("returns correct label for device", () => {
const result = getMetadataFieldLabel("device", mockT);
expect(result).toBe("Device");
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.device");
});
test("returns correct label for browser", () => {
const result = getMetadataFieldLabel("browser", mockT);
expect(result).toBe("Browser");
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.browser");
});
test("returns correct label for url", () => {
const result = getMetadataFieldLabel("url", mockT);
expect(result).toBe("URL");
expect(mockT).toHaveBeenCalledWith("common.url");
});
test("returns correct label for source", () => {
const result = getMetadataFieldLabel("source", mockT);
expect(result).toBe("Source");
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.source");
});
test("returns capitalized label for unknown field", () => {
const result = getMetadataFieldLabel("customField", mockT);
expect(result).toBe("Customfield");
expect(mockT).not.toHaveBeenCalled();
});
test("returns capitalized label for field with underscores", () => {
const result = getMetadataFieldLabel("custom_field", mockT);
expect(result).toBe("Custom_field");
expect(mockT).not.toHaveBeenCalled();
});
});
describe("COLUMNS_ICON_MAP", () => {
test("contains correct icon mappings", () => {
expect(COLUMNS_ICON_MAP.action).toBe(MousePointerClickIcon);
expect(COLUMNS_ICON_MAP.country).toBe(FlagIcon);
expect(COLUMNS_ICON_MAP.browser).toBe(GlobeIcon);
expect(COLUMNS_ICON_MAP.os).toBe(AirplayIcon);
expect(COLUMNS_ICON_MAP.device).toBe(SmartphoneIcon);
expect(COLUMNS_ICON_MAP.source).toBe(ArrowUpFromDotIcon);
expect(COLUMNS_ICON_MAP.url).toBe(GlobeIcon);
});
});
describe("getMetadataValue", () => {
test("returns correct value for action", () => {
const result = getMetadataValue({ action: "action_column" }, "action");
expect(result).toBe("action_column");
});
test("returns correct value for userAgent", () => {
const result = getMetadataValue({ userAgent: { browser: "browser_column" } }, "browser");
expect(result).toBe("browser_column");
});
});
});

View File

@@ -0,0 +1,88 @@
import { TFnType } from "@tolgee/react";
import { capitalize } from "lodash";
import {
AirplayIcon,
ArrowUpFromDotIcon,
FlagIcon,
GlobeIcon,
MousePointerClickIcon,
SmartphoneIcon,
} from "lucide-react";
import { TResponseMeta } from "@formbricks/types/responses";
export const getAddressFieldLabel = (field: string, t: TFnType) => {
switch (field) {
case "addressLine1":
return t("environments.surveys.responses.address_line_1");
case "addressLine2":
return t("environments.surveys.responses.address_line_2");
case "city":
return t("environments.surveys.responses.city");
case "state":
return t("environments.surveys.responses.state_region");
case "zip":
return t("environments.surveys.responses.zip_post_code");
case "country":
return t("environments.surveys.responses.country");
default:
break;
}
};
export const getContactInfoFieldLabel = (field: string, t: TFnType) => {
switch (field) {
case "firstName":
return t("environments.surveys.responses.first_name");
case "lastName":
return t("environments.surveys.responses.last_name");
case "email":
return t("environments.surveys.responses.email");
case "phone":
return t("environments.surveys.responses.phone");
case "company":
return t("environments.surveys.responses.company");
default:
break;
}
};
export const getMetadataFieldLabel = (label: string, t: TFnType) => {
switch (label) {
case "action":
return t("common.action");
case "country":
return t("environments.surveys.responses.country");
case "os":
return t("environments.surveys.responses.os");
case "device":
return t("environments.surveys.responses.device");
case "browser":
return t("environments.surveys.responses.browser");
case "url":
return t("common.url");
case "source":
return t("environments.surveys.responses.source");
default:
return capitalize(label);
}
};
export const COLUMNS_ICON_MAP = {
action: MousePointerClickIcon,
country: FlagIcon,
browser: GlobeIcon,
os: AirplayIcon,
device: SmartphoneIcon,
source: ArrowUpFromDotIcon,
url: GlobeIcon,
};
const userAgentFields = ["browser", "os", "device"];
export const METADATA_FIELDS = ["action", "country", ...userAgentFields, "source", "url"];
export const getMetadataValue = (meta: TResponseMeta, label: string) => {
if (userAgentFields.includes(label)) {
return meta.userAgent?.[label];
}
return meta[label];
};

View File

@@ -7,6 +7,13 @@ vi.mock("@/modules/ui/components/avatars", () => ({
PersonAvatar: ({ personId }: any) => <div data-testid="avatar">{personId}</div>,
}));
vi.mock("./QuestionSummaryHeader", () => ({ QuestionSummaryHeader: () => <div data-testid="header" /> }));
vi.mock("@/modules/ui/components/id-badge", () => ({
IdBadge: ({ id }: { id: string }) => (
<div data-testid="id-badge" data-id={id}>
ID: {id}
</div>
),
}));
describe("MultipleChoiceSummary", () => {
afterEach(() => {
@@ -160,8 +167,8 @@ describe("MultipleChoiceSummary", () => {
/>
);
const btns = screen.getAllByRole("button");
expect(btns[0]).toHaveTextContent("2 - Y50%2 common.selections");
expect(btns[1]).toHaveTextContent("1 - X50%1 common.selection");
expect(btns[0]).toHaveTextContent("2 - YID: other2 common.selections50%");
expect(btns[1]).toHaveTextContent("1 - XID: other1 common.selection50%");
});
test("places choice with others after one without when reversed inputs", () => {
@@ -272,4 +279,127 @@ describe("MultipleChoiceSummary", () => {
["O5"]
);
});
// New tests for IdBadge functionality
test("renders IdBadge when choice ID is found", () => {
const setFilter = vi.fn();
const q = {
question: {
id: "q6",
headline: "H6",
type: "multipleChoiceSingle",
choices: [
{ id: "choice1", label: { default: "Option A" } },
{ id: "choice2", label: { default: "Option B" } },
],
},
choices: {
"Option A": { value: "Option A", count: 5, percentage: 50, others: [] },
"Option B": { value: "Option B", count: 5, percentage: 50, others: [] },
},
type: "multipleChoiceSingle",
selectionCount: 0,
} as any;
render(
<MultipleChoiceSummary
questionSummary={q}
environmentId="env"
surveyType="link"
survey={baseSurvey}
setFilter={setFilter}
/>
);
const idBadges = screen.getAllByTestId("id-badge");
expect(idBadges).toHaveLength(2);
expect(idBadges[0]).toHaveAttribute("data-id", "choice1");
expect(idBadges[1]).toHaveAttribute("data-id", "choice2");
expect(idBadges[0]).toHaveTextContent("ID: choice1");
expect(idBadges[1]).toHaveTextContent("ID: choice2");
});
test("getChoiceIdByValue function correctly maps values to IDs", () => {
const setFilter = vi.fn();
const q = {
question: {
id: "q8",
headline: "H8",
type: "multipleChoiceMulti",
choices: [
{ id: "id-apple", label: { default: "Apple" } },
{ id: "id-banana", label: { default: "Banana" } },
{ id: "id-cherry", label: { default: "Cherry" } },
],
},
choices: {
Apple: { value: "Apple", count: 3, percentage: 30, others: [] },
Banana: { value: "Banana", count: 4, percentage: 40, others: [] },
Cherry: { value: "Cherry", count: 3, percentage: 30, others: [] },
},
type: "multipleChoiceMulti",
selectionCount: 0,
} as any;
render(
<MultipleChoiceSummary
questionSummary={q}
environmentId="env"
surveyType="link"
survey={baseSurvey}
setFilter={setFilter}
/>
);
const idBadges = screen.getAllByTestId("id-badge");
expect(idBadges).toHaveLength(3);
// Check that each badge has the correct ID
const expectedMappings = [
{ text: "Banana", id: "id-banana" }, // Highest count appears first
{ text: "Apple", id: "id-apple" },
{ text: "Cherry", id: "id-cherry" },
];
expectedMappings.forEach(({ text, id }, index) => {
expect(screen.getByText(`${3 - index} - ${text}`)).toBeInTheDocument();
expect(idBadges[index]).toHaveAttribute("data-id", id);
});
});
test("handles choices with special characters in labels", () => {
const setFilter = vi.fn();
const q = {
question: {
id: "q9",
headline: "H9",
type: "multipleChoiceSingle",
choices: [
{ id: "special-1", label: { default: "Option & Choice" } },
{ id: "special-2", label: { default: "Choice with 'quotes'" } },
],
},
choices: {
"Option & Choice": { value: "Option & Choice", count: 2, percentage: 50, others: [] },
"Choice with 'quotes'": { value: "Choice with 'quotes'", count: 2, percentage: 50, others: [] },
},
type: "multipleChoiceSingle",
selectionCount: 0,
} as any;
render(
<MultipleChoiceSummary
questionSummary={q}
environmentId="env"
surveyType="link"
survey={baseSurvey}
setFilter={setFilter}
/>
);
const idBadges = screen.getAllByTestId("id-badge");
expect(idBadges).toHaveLength(2);
expect(idBadges[0]).toHaveAttribute("data-id", "special-1");
expect(idBadges[1]).toHaveAttribute("data-id", "special-2");
});
});

View File

@@ -1,8 +1,10 @@
"use client";
import { getChoiceIdByValue } from "@/lib/response/utils";
import { getContactIdentifier } from "@/lib/utils/contact";
import { PersonAvatar } from "@/modules/ui/components/avatars";
import { Button } from "@/modules/ui/components/button";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { ProgressBar } from "@/modules/ui/components/progress-bar";
import { useTranslate } from "@tolgee/react";
import { InboxIcon } from "lucide-react";
@@ -84,90 +86,95 @@ export const MultipleChoiceSummary = ({
}
/>
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{results.map((result, resultsIdx) => (
<Fragment key={result.value}>
<button
className="group w-full cursor-pointer"
onClick={() =>
setFilter(
questionSummary.question.id,
questionSummary.question.headline,
questionSummary.question.type,
questionSummary.type === "multipleChoiceSingle" || otherValue === result.value
? t("environments.surveys.summary.includes_either")
: t("environments.surveys.summary.includes_all"),
[result.value]
)
}>
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
<div className="mr-8 flex w-full justify-between space-x-1 sm:justify-normal">
<p className="font-semibold text-slate-700 underline-offset-4 group-hover:underline">
{results.length - resultsIdx} - {result.value}
</p>
<div>
{results.map((result, resultsIdx) => {
const choiceId = getChoiceIdByValue(result.value, questionSummary.question);
return (
<Fragment key={result.value}>
<button
type="button"
className="group w-full cursor-pointer"
onClick={() =>
setFilter(
questionSummary.question.id,
questionSummary.question.headline,
questionSummary.question.type,
questionSummary.type === "multipleChoiceSingle" || otherValue === result.value
? t("environments.surveys.summary.includes_either")
: t("environments.surveys.summary.includes_all"),
[result.value]
)
}>
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
<div className="mr-8 flex w-full justify-between space-x-2 sm:justify-normal">
<p className="font-semibold text-slate-700 underline-offset-4 group-hover:underline">
{results.length - resultsIdx} - {result.value}
</p>
{choiceId && <IdBadge id={choiceId} />}
</div>
<div className="flex w-full space-x-2">
<p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0">
{result.count} {result.count === 1 ? t("common.selection") : t("common.selections")}
</p>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(result.percentage, 2)}%
</p>
</div>
</div>
<p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0">
{result.count} {result.count === 1 ? t("common.selection") : t("common.selections")}
</p>
</div>
<div className="group-hover:opacity-80">
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
</div>
</button>
{result.others && result.others.length > 0 && (
<div className="mt-4 rounded-lg border border-slate-200">
<div className="grid h-12 grid-cols-2 content-center rounded-t-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
<div className="col-span-1 pl-6">
{t("environments.surveys.summary.other_values_found")}
</div>
<div className="col-span-1 pl-6">{surveyType === "app" && t("common.user")}</div>
<div className="group-hover:opacity-80">
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
</div>
{result.others
.filter((otherValue) => otherValue.value !== "")
.slice(0, visibleOtherResponses)
.map((otherValue, idx) => (
<div key={`${idx}-${otherValue}`} dir="auto">
{surveyType === "link" && (
<div className="ph-no-capture col-span-1 m-2 flex h-10 items-center rounded-lg pl-4 text-sm font-medium text-slate-900">
<span>{otherValue.value}</span>
</div>
)}
{surveyType === "app" && otherValue.contact && (
<Link
href={
otherValue.contact.id
? `/environments/${environmentId}/contacts/${otherValue.contact.id}`
: { pathname: null }
}
className="m-2 grid h-16 grid-cols-2 items-center rounded-lg text-sm hover:bg-slate-100">
<div className="ph-no-capture col-span-1 pl-4 font-medium text-slate-900">
</button>
{result.others && result.others.length > 0 && (
<div className="mt-4 rounded-lg border border-slate-200">
<div className="grid h-12 grid-cols-2 content-center rounded-t-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
<div className="col-span-1 pl-6">
{t("environments.surveys.summary.other_values_found")}
</div>
<div className="col-span-1 pl-6">{surveyType === "app" && t("common.user")}</div>
</div>
{result.others
.filter((otherValue) => otherValue.value !== "")
.slice(0, visibleOtherResponses)
.map((otherValue, idx) => (
<div key={`${idx}-${otherValue}`} dir="auto">
{surveyType === "link" && (
<div className="ph-no-capture col-span-1 m-2 flex h-10 items-center rounded-lg pl-4 text-sm font-medium text-slate-900">
<span>{otherValue.value}</span>
</div>
<div className="ph-no-capture col-span-1 flex items-center space-x-4 pl-6 font-medium text-slate-900">
{otherValue.contact.id && <PersonAvatar personId={otherValue.contact.id} />}
<span>
{getContactIdentifier(otherValue.contact, otherValue.contactAttributes)}
</span>
</div>
</Link>
)}
)}
{surveyType === "app" && otherValue.contact && (
<Link
href={
otherValue.contact.id
? `/environments/${environmentId}/contacts/${otherValue.contact.id}`
: { pathname: null }
}
className="m-2 grid h-16 grid-cols-2 items-center rounded-lg text-sm hover:bg-slate-100">
<div className="ph-no-capture col-span-1 pl-4 font-medium text-slate-900">
<span>{otherValue.value}</span>
</div>
<div className="ph-no-capture col-span-1 flex items-center space-x-4 pl-6 font-medium text-slate-900">
{otherValue.contact.id && <PersonAvatar personId={otherValue.contact.id} />}
<span>
{getContactIdentifier(otherValue.contact, otherValue.contactAttributes)}
</span>
</div>
</Link>
)}
</div>
))}
{visibleOtherResponses < result.others.length && (
<div className="flex justify-center py-4">
<Button onClick={handleLoadMore} variant="secondary" size="sm">
{t("common.load_more")}
</Button>
</div>
))}
{visibleOtherResponses < result.others.length && (
<div className="flex justify-center py-4">
<Button onClick={handleLoadMore} variant="secondary" size="sm">
{t("common.load_more")}
</Button>
</div>
)}
</div>
)}
</Fragment>
))}
)}
</div>
)}
</Fragment>
);
})}
</div>
</div>
);

View File

@@ -1,7 +1,11 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import {
TSurvey,
TSurveyPictureSelectionQuestion,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import { PictureChoiceSummary } from "./PictureChoiceSummary";
vi.mock("@/modules/ui/components/progress-bar", () => ({
@@ -12,6 +16,19 @@ vi.mock("@/modules/ui/components/progress-bar", () => ({
vi.mock("./QuestionSummaryHeader", () => ({
QuestionSummaryHeader: ({ additionalInfo }: any) => <div data-testid="header">{additionalInfo}</div>,
}));
vi.mock("@/modules/ui/components/id-badge", () => ({
IdBadge: ({ id }: { id: string }) => (
<div data-testid="id-badge" data-id={id}>
ID: {id}
</div>
),
}));
vi.mock("@/lib/response/utils", () => ({
getChoiceIdByValue: (value: string, question: TSurveyPictureSelectionQuestion) => {
return question.choices?.find((choice) => choice.imageUrl === value)?.id ?? "other";
},
}));
// mock next image
vi.mock("next/image", () => ({
@@ -88,4 +105,73 @@ describe("PictureChoiceSummary", () => {
expect(screen.getByTestId("header")).toBeEmptyDOMElement();
});
// New tests for IdBadge functionality
test("renders IdBadge when choice ID is found via imageUrl", () => {
const choices = [
{ id: "choice1", imageUrl: "https://example.com/img1.png", percentage: 50, count: 5 },
{ id: "choice2", imageUrl: "https://example.com/img2.png", percentage: 50, count: 5 },
];
const questionSummary = {
choices,
question: {
id: "q2",
type: TSurveyQuestionTypeEnum.PictureSelection,
headline: "Picture Question",
allowMulti: true,
choices: [
{ id: "pic-choice-1", imageUrl: "https://example.com/img1.png" },
{ id: "pic-choice-2", imageUrl: "https://example.com/img2.png" },
],
},
selectionCount: 10,
} as any;
render(<PictureChoiceSummary questionSummary={questionSummary} survey={survey} setFilter={() => {}} />);
const idBadges = screen.getAllByTestId("id-badge");
expect(idBadges).toHaveLength(2);
expect(idBadges[0]).toHaveAttribute("data-id", "pic-choice-1");
expect(idBadges[1]).toHaveAttribute("data-id", "pic-choice-2");
expect(idBadges[0]).toHaveTextContent("ID: pic-choice-1");
expect(idBadges[1]).toHaveTextContent("ID: pic-choice-2");
});
test("getChoiceIdByValue function correctly maps imageUrl to choice ID", () => {
const choices = [
{ id: "choice1", imageUrl: "https://cdn.example.com/photo1.jpg", percentage: 33.33, count: 2 },
{ id: "choice2", imageUrl: "https://cdn.example.com/photo2.jpg", percentage: 33.33, count: 2 },
{ id: "choice3", imageUrl: "https://cdn.example.com/photo3.jpg", percentage: 33.33, count: 2 },
];
const questionSummary = {
choices,
question: {
id: "q4",
type: TSurveyQuestionTypeEnum.PictureSelection,
headline: "Photo Selection",
allowMulti: true,
choices: [
{ id: "photo-a", imageUrl: "https://cdn.example.com/photo1.jpg" },
{ id: "photo-b", imageUrl: "https://cdn.example.com/photo2.jpg" },
{ id: "photo-c", imageUrl: "https://cdn.example.com/photo3.jpg" },
],
},
selectionCount: 6,
} as any;
render(<PictureChoiceSummary questionSummary={questionSummary} survey={survey} setFilter={() => {}} />);
const idBadges = screen.getAllByTestId("id-badge");
expect(idBadges).toHaveLength(3);
expect(idBadges[0]).toHaveAttribute("data-id", "photo-a");
expect(idBadges[1]).toHaveAttribute("data-id", "photo-b");
expect(idBadges[2]).toHaveAttribute("data-id", "photo-c");
// Verify the images are also rendered correctly
const images = screen.getAllByRole("img");
expect(images).toHaveLength(3);
expect(images[0]).toHaveAttribute("src", "https://cdn.example.com/photo1.jpg");
expect(images[1]).toHaveAttribute("src", "https://cdn.example.com/photo2.jpg");
expect(images[2]).toHaveAttribute("src", "https://cdn.example.com/photo3.jpg");
});
});

View File

@@ -1,5 +1,7 @@
"use client";
import { getChoiceIdByValue } from "@/lib/response/utils";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { ProgressBar } from "@/modules/ui/components/progress-bar";
import { useTranslate } from "@tolgee/react";
import { InboxIcon } from "lucide-react";
@@ -29,6 +31,7 @@ interface PictureChoiceSummaryProps {
export const PictureChoiceSummary = ({ questionSummary, survey, setFilter }: PictureChoiceSummaryProps) => {
const results = questionSummary.choices;
const { t } = useTranslate();
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<QuestionSummaryHeader
@@ -44,43 +47,48 @@ export const PictureChoiceSummary = ({ questionSummary, survey, setFilter }: Pic
}
/>
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{results.map((result, index) => (
<button
className="w-full cursor-pointer hover:opacity-80"
key={result.id}
onClick={() =>
setFilter(
questionSummary.question.id,
questionSummary.question.headline,
questionSummary.question.type,
t("environments.surveys.summary.includes_all"),
[`${t("environments.surveys.edit.picture_idx", { idx: index + 1 })}`]
)
}>
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
<div className="mr-8 flex w-full justify-between space-x-1 sm:justify-normal">
<div className="relative h-32 w-[220px]">
<Image
src={result.imageUrl}
alt="choice-image"
layout="fill"
objectFit="cover"
className="rounded-md"
/>
{results.map((result, index) => {
const choiceId = getChoiceIdByValue(result.imageUrl, questionSummary.question);
return (
<button
type="button"
className="w-full cursor-pointer hover:opacity-80"
key={result.id}
onClick={() =>
setFilter(
questionSummary.question.id,
questionSummary.question.headline,
questionSummary.question.type,
t("environments.surveys.summary.includes_all"),
[`${t("environments.surveys.edit.picture_idx", { idx: index + 1 })}`]
)
}>
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
<div className="mr-8 flex w-full justify-between space-x-2 sm:justify-normal">
<div className="relative h-32 w-[220px]">
<Image
src={result.imageUrl}
alt="choice-image"
layout="fill"
objectFit="cover"
className="rounded-md"
/>
</div>
<div className="self-end">{choiceId && <IdBadge id={choiceId} />}</div>
</div>
<div className="self-end">
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
<div className="flex w-full space-x-2">
<p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0">
{result.count} {result.count === 1 ? t("common.selection") : t("common.selections")}
</p>
<p className="self-end rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(result.percentage, 2)}%
</p>
</div>
</div>
<p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0">
{result.count} {result.count === 1 ? t("common.selection") : t("common.selections")}
</p>
</div>
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100 || 0} />
</button>
))}
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100 || 0} />
</button>
);
})}
</div>
</div>
);

View File

@@ -27,10 +27,10 @@ vi.mock("@/modules/survey/lib/questions", () => ({
],
}));
vi.mock("@/modules/ui/components/settings-id", () => ({
SettingsId: ({ title, id }: { title: string; id: string }) => (
<div data-testid="settings-id">
{title}: {id}
vi.mock("@/modules/ui/components/id-badge", () => ({
IdBadge: ({ label, id }: { label: string; id: string }) => (
<div data-testid="id-badge">
{label}: {id}
</div>
),
}));
@@ -76,7 +76,7 @@ describe("QuestionSummaryHeader", () => {
).toBeInTheDocument();
expect(screen.getByTestId("question-icon")).toBeInTheDocument();
expect(screen.getByTestId("settings-id")).toHaveTextContent("common.question_id: q1");
expect(screen.getByTestId("id-badge")).toHaveTextContent("common.question_id: q1");
expect(screen.queryByText("environments.surveys.edit.optional")).not.toBeInTheDocument();
});

View File

@@ -3,7 +3,7 @@
import { recallToHeadline } from "@/lib/utils/recall";
import { formatTextWithSlashes } from "@/modules/survey/editor/lib/utils";
import { getQuestionTypes } from "@/modules/survey/lib/questions";
import { SettingsId } from "@/modules/ui/components/settings-id";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { useTranslate } from "@tolgee/react";
import { InboxIcon } from "lucide-react";
import type { JSX } from "react";
@@ -55,7 +55,7 @@ export const QuestionSummaryHeader = ({
</div>
)}
</div>
<SettingsId title={t("common.question_id")} id={questionSummary.question.id}></SettingsId>
<IdBadge id={questionSummary.question.id} label={t("common.question_id")} />
</div>
);
};

View File

@@ -1,6 +1,6 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TSurvey, TSurveyQuestionSummaryRanking, TSurveyType } from "@formbricks/types/surveys/types";
import { TSurvey, TSurveyQuestionSummaryRanking } from "@formbricks/types/surveys/types";
import { RankingSummary } from "./RankingSummary";
// Mock dependencies
@@ -12,17 +12,32 @@ vi.mock("../lib/utils", () => ({
convertFloatToNDecimal: (value: number) => value.toFixed(2),
}));
vi.mock("@/modules/ui/components/id-badge", () => ({
IdBadge: ({ id }: { id: string }) => (
<div data-testid="id-badge" data-id={id}>
ID: {id}
</div>
),
}));
describe("RankingSummary", () => {
afterEach(() => {
cleanup();
});
const survey = {} as TSurvey;
const surveyType: TSurveyType = "app";
test("renders ranking results in correct order", () => {
const questionSummary = {
question: { id: "q1", headline: "Rank the following" },
question: {
id: "q1",
headline: "Rank the following",
choices: [
{ id: "choice1", label: { default: "Option A" } },
{ id: "choice2", label: { default: "Option B" } },
{ id: "choice3", label: { default: "Option C" } },
],
},
choices: {
option1: { value: "Option A", avgRanking: 1.5, others: [] },
option2: { value: "Option B", avgRanking: 2.3, others: [] },
@@ -30,7 +45,7 @@ describe("RankingSummary", () => {
},
} as unknown as TSurveyQuestionSummaryRanking;
render(<RankingSummary questionSummary={questionSummary} survey={survey} surveyType={surveyType} />);
render(<RankingSummary questionSummary={questionSummary} survey={survey} />);
expect(screen.getByTestId("question-summary-header")).toBeInTheDocument();
@@ -51,43 +66,13 @@ describe("RankingSummary", () => {
expect(screen.getByText("#2.30")).toBeInTheDocument();
});
test("renders 'other values found' section when others exist", () => {
const questionSummary = {
question: { id: "q1", headline: "Rank the following" },
choices: {
option1: {
value: "Option A",
avgRanking: 1.0,
others: [{ value: "Other value", count: 2 }],
},
},
} as unknown as TSurveyQuestionSummaryRanking;
render(<RankingSummary questionSummary={questionSummary} survey={survey} surveyType={surveyType} />);
expect(screen.getByText("environments.surveys.summary.other_values_found")).toBeInTheDocument();
});
test("shows 'User' column in other values section for app survey type", () => {
const questionSummary = {
question: { id: "q1", headline: "Rank the following" },
choices: {
option1: {
value: "Option A",
avgRanking: 1.0,
others: [{ value: "Other value", count: 1 }],
},
},
} as unknown as TSurveyQuestionSummaryRanking;
render(<RankingSummary questionSummary={questionSummary} survey={survey} surveyType="app" />);
expect(screen.getByText("common.user")).toBeInTheDocument();
});
test("doesn't show 'User' column for link survey type", () => {
const questionSummary = {
question: { id: "q1", headline: "Rank the following" },
question: {
id: "q1",
headline: "Rank the following",
choices: [{ id: "choice1", label: { default: "Option A" } }],
},
choices: {
option1: {
value: "Option A",
@@ -97,8 +82,132 @@ describe("RankingSummary", () => {
},
} as unknown as TSurveyQuestionSummaryRanking;
render(<RankingSummary questionSummary={questionSummary} survey={survey} surveyType="link" />);
render(<RankingSummary questionSummary={questionSummary} survey={survey} />);
expect(screen.queryByText("common.user")).not.toBeInTheDocument();
});
// New tests for IdBadge functionality
test("renders IdBadge when choice ID is found via label", () => {
const questionSummary = {
question: {
id: "q2",
headline: "Rank these options",
choices: [
{ id: "rank-choice-1", label: { default: "First Option" } },
{ id: "rank-choice-2", label: { default: "Second Option" } },
{ id: "rank-choice-3", label: { default: "Third Option" } },
],
},
choices: {
option1: { value: "First Option", avgRanking: 1.5, others: [] },
option2: { value: "Second Option", avgRanking: 2.1, others: [] },
option3: { value: "Third Option", avgRanking: 2.8, others: [] },
},
} as unknown as TSurveyQuestionSummaryRanking;
render(<RankingSummary questionSummary={questionSummary} survey={survey} />);
const idBadges = screen.getAllByTestId("id-badge");
expect(idBadges).toHaveLength(3);
expect(idBadges[0]).toHaveAttribute("data-id", "rank-choice-1");
expect(idBadges[1]).toHaveAttribute("data-id", "rank-choice-2");
expect(idBadges[2]).toHaveAttribute("data-id", "rank-choice-3");
expect(idBadges[0]).toHaveTextContent("ID: rank-choice-1");
expect(idBadges[1]).toHaveTextContent("ID: rank-choice-2");
expect(idBadges[2]).toHaveTextContent("ID: rank-choice-3");
});
test("getChoiceIdByValue function correctly maps ranking values to choice IDs", () => {
const questionSummary = {
question: {
id: "q4",
headline: "Rate importance",
choices: [
{ id: "importance-high", label: { default: "Very Important" } },
{ id: "importance-medium", label: { default: "Somewhat Important" } },
{ id: "importance-low", label: { default: "Not Important" } },
],
},
choices: {
option1: { value: "Very Important", avgRanking: 1.2, others: [] },
option2: { value: "Somewhat Important", avgRanking: 2.0, others: [] },
option3: { value: "Not Important", avgRanking: 2.8, others: [] },
},
} as unknown as TSurveyQuestionSummaryRanking;
render(<RankingSummary questionSummary={questionSummary} survey={survey} />);
const idBadges = screen.getAllByTestId("id-badge");
expect(idBadges).toHaveLength(3);
// Should be ordered by avgRanking (ascending)
expect(screen.getByText("Very Important")).toBeInTheDocument(); // avgRanking: 1.2
expect(screen.getByText("Somewhat Important")).toBeInTheDocument(); // avgRanking: 2.0
expect(screen.getByText("Not Important")).toBeInTheDocument(); // avgRanking: 2.8
expect(idBadges[0]).toHaveAttribute("data-id", "importance-high");
expect(idBadges[1]).toHaveAttribute("data-id", "importance-medium");
expect(idBadges[2]).toHaveAttribute("data-id", "importance-low");
});
test("handles mixed choices with and without matching IDs", () => {
const questionSummary = {
question: {
id: "q5",
headline: "Mixed options",
choices: [
{ id: "valid-choice-1", label: { default: "Valid Option" } },
{ id: "valid-choice-2", label: { default: "Another Valid Option" } },
],
},
choices: {
option1: { value: "Valid Option", avgRanking: 1.5, others: [] },
option2: { value: "Unknown Option", avgRanking: 2.0, others: [] },
option3: { value: "Another Valid Option", avgRanking: 2.5, others: [] },
},
} as unknown as TSurveyQuestionSummaryRanking;
render(<RankingSummary questionSummary={questionSummary} survey={survey} />);
const idBadges = screen.getAllByTestId("id-badge");
expect(idBadges).toHaveLength(3); // Only 2 out of 3 should have badges
// Check that all options are still displayed
expect(screen.getByText("Valid Option")).toBeInTheDocument();
expect(screen.getByText("Unknown Option")).toBeInTheDocument();
expect(screen.getByText("Another Valid Option")).toBeInTheDocument();
// Check that only the valid choices have badges
expect(idBadges[0]).toHaveAttribute("data-id", "valid-choice-1");
expect(idBadges[1]).toHaveAttribute("data-id", "other");
expect(idBadges[2]).toHaveAttribute("data-id", "valid-choice-2");
});
test("handles special characters in choice labels", () => {
const questionSummary = {
question: {
id: "q6",
headline: "Special characters test",
choices: [
{ id: "special-1", label: { default: "Option with 'quotes'" } },
{ id: "special-2", label: { default: "Option & Ampersand" } },
],
},
choices: {
option1: { value: "Option with 'quotes'", avgRanking: 1.0, others: [] },
option2: { value: "Option & Ampersand", avgRanking: 2.0, others: [] },
},
} as unknown as TSurveyQuestionSummaryRanking;
render(<RankingSummary questionSummary={questionSummary} survey={survey} />);
const idBadges = screen.getAllByTestId("id-badge");
expect(idBadges).toHaveLength(2);
expect(idBadges[0]).toHaveAttribute("data-id", "special-1");
expect(idBadges[1]).toHaveAttribute("data-id", "special-2");
expect(screen.getByText("Option with 'quotes'")).toBeInTheDocument();
expect(screen.getByText("Option & Ampersand")).toBeInTheDocument();
});
});

View File

@@ -1,15 +1,16 @@
import { getChoiceIdByValue } from "@/lib/response/utils";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { useTranslate } from "@tolgee/react";
import { TSurvey, TSurveyQuestionSummaryRanking, TSurveyType } from "@formbricks/types/surveys/types";
import { TSurvey, TSurveyQuestionSummaryRanking } from "@formbricks/types/surveys/types";
import { convertFloatToNDecimal } from "../lib/utils";
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
interface RankingSummaryProps {
questionSummary: TSurveyQuestionSummaryRanking;
surveyType: TSurveyType;
survey: TSurvey;
}
export const RankingSummary = ({ questionSummary, surveyType, survey }: RankingSummaryProps) => {
export const RankingSummary = ({ questionSummary, survey }: RankingSummaryProps) => {
// sort by count and transform to array
const { t } = useTranslate();
const results = Object.values(questionSummary.choices).sort((a, b) => {
@@ -20,35 +21,30 @@ export const RankingSummary = ({ questionSummary, surveyType, survey }: RankingS
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{results.map((result, resultsIdx) => (
<div key={result.value} className="group cursor-pointer">
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
<div className="mr-8 flex w-full justify-between space-x-1 sm:justify-normal">
<div className="flex w-full items-center">
<span className="mr-2 text-slate-400">#{resultsIdx + 1}</span>
<div className="rounded bg-slate-100 px-2 py-1">{result.value}</div>
<span className="ml-auto flex items-center space-x-1">
<span className="font-bold text-slate-600">
#{convertFloatToNDecimal(result.avgRanking, 2)}
{results.map((result, resultsIdx) => {
const choiceId = getChoiceIdByValue(result.value, questionSummary.question);
return (
<div key={result.value} className="group cursor-pointer">
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
<div className="mr-8 flex w-full justify-between space-x-2 sm:justify-normal">
<div className="flex w-full items-center">
<div className="flex items-center space-x-2">
<span className="mr-2 text-slate-400">#{resultsIdx + 1}</span>
<div className="rounded bg-slate-100 px-2 py-1">{result.value}</div>
{choiceId && <IdBadge id={choiceId} />}
</div>
<span className="ml-auto flex items-center space-x-1">
<span className="font-bold text-slate-600">
#{convertFloatToNDecimal(result.avgRanking, 2)}
</span>
<span>{t("environments.surveys.summary.average")}</span>
</span>
<span>{t("environments.surveys.summary.average")}</span>
</span>
</div>
</div>
</div>
</div>
{result.others && result.others.length > 0 && (
<div className="mt-4 rounded-lg border border-slate-200">
<div className="grid h-12 grid-cols-2 content-center rounded-t-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
<div className="col-span-1 pl-6">
{t("environments.surveys.summary.other_values_found")}
</div>
<div className="col-span-1 pl-6">{surveyType === "app" && t("common.user")}</div>
</div>
</div>
)}
</div>
))}
);
})}
</div>
</div>
);

View File

@@ -244,7 +244,6 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
<RankingSummary
key={questionSummary.question.id}
questionSummary={questionSummary}
surveyType={survey.type}
survey={survey}
/>
);

View File

@@ -172,6 +172,15 @@ vi.mock("./shareEmbedModal/success-view", () => ({
),
}));
// Mock LinkSettingsTab
vi.mock("./shareEmbedModal/link-settings-tab", () => ({
LinkSettingsTab: ({ survey, isReadOnly, locale }: any) => (
<div data-testid="link-settings-tab">
LinkSettings for {survey.id} - ReadOnly: {isReadOnly.toString()} - Locale: {locale}
</div>
),
}));
// Mock lucide-react icons
vi.mock("lucide-react", async (importOriginal) => {
const actual = (await importOriginal()) as any;
@@ -184,6 +193,7 @@ vi.mock("lucide-react", async (importOriginal) => {
SmartphoneIcon: () => <svg data-testid="smartphone-icon" />,
SquareStack: () => <svg data-testid="square-stack-icon" />,
UserIcon: () => <svg data-testid="user-icon" />,
Settings: () => <svg data-testid="settings-icon" />,
};
});
@@ -227,6 +237,7 @@ const mockSurvey: TSurvey = {
recaptcha: null,
isSingleResponsePerEmailEnabled: false,
isBackButtonHidden: false,
metadata: {},
};
const mockAppSurvey: TSurvey = {
@@ -270,6 +281,7 @@ const defaultProps = {
segments: mockSegments,
isContactsEnabled: true,
isFormbricksCloud: false,
isReadOnly: false,
};
describe("ShareSurveyModal", () => {
@@ -392,7 +404,6 @@ describe("ShareSurveyModal", () => {
});
test("resets to start view when modal is closed and reopened", async () => {
const user = userEvent.setup();
const { rerender } = render(<ShareSurveyModal {...defaultProps} modalView="share" />);
expect(screen.getByTestId("share-view")).toBeInTheDocument();
@@ -468,4 +479,40 @@ describe("ShareSurveyModal", () => {
expect(screen.getByTestId("app-tab")).toBeInTheDocument();
expect(screen.queryByTestId("share-view")).not.toBeInTheDocument();
});
test("includes link settings tab in share tabs for link surveys", () => {
render(<ShareSurveyModal {...defaultProps} modalView="share" />);
const shareView = screen.getByTestId("share-view");
expect(shareView).toBeInTheDocument();
// Check that tabs are rendered and include link settings
const tabsContainer = screen.getByTestId("tabs");
expect(tabsContainer).toBeInTheDocument();
// Look for the link-settings tab button
expect(screen.getByTestId("tab-link-settings")).toBeInTheDocument();
});
test("does not include link settings for app surveys", () => {
render(<ShareSurveyModal {...defaultProps} survey={mockAppSurvey} modalView="share" />);
// App surveys should render TabContainer, not ShareView with link settings
expect(screen.getByTestId("tab-container")).toBeInTheDocument();
expect(screen.getByTestId("app-tab")).toBeInTheDocument();
expect(screen.queryByTestId("share-view")).not.toBeInTheDocument();
expect(screen.queryByTestId("tab-link-settings")).not.toBeInTheDocument();
});
test("handles locale prop correctly for link settings", () => {
const customProps = {
...defaultProps,
modalView: "share" as const,
};
render(<ShareSurveyModal {...customProps} />);
// Verify that ShareView is rendered (which would contain LinkSettingsTab)
expect(screen.getByTestId("share-view")).toBeInTheDocument();
});
});

View File

@@ -4,18 +4,32 @@ import { AnonymousLinksTab } from "@/app/(app)/environments/[environmentId]/surv
import { AppTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/app-tab";
import { DynamicPopupTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/dynamic-popup-tab";
import { EmailTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/email-tab";
import { LinkSettingsTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/link-settings-tab";
import { PersonalLinksTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/personal-links-tab";
import { QRCodeTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/qr-code-tab";
import { SocialMediaTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/social-media-tab";
import { TabContainer } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/tab-container";
import { WebsiteEmbedTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/website-embed-tab";
import { ShareViewType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/types/share";
import {
LinkTabsType,
ShareSettingsType,
ShareViaType,
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/types/share";
import { getSurveyUrl } from "@/modules/analysis/utils";
import { Dialog, DialogContent, DialogTitle } from "@/modules/ui/components/dialog";
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
import { useTranslate } from "@tolgee/react";
import { Code2Icon, LinkIcon, MailIcon, QrCodeIcon, Share2Icon, SquareStack, UserIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import {
Code2Icon,
LinkIcon,
MailIcon,
QrCodeIcon,
Settings,
Share2Icon,
SquareStack,
UserIcon,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { TSegment } from "@formbricks/types/segment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUser } from "@formbricks/types/user";
@@ -55,17 +69,20 @@ export const ShareSurveyModal = ({
const { email } = user;
const { t } = useTranslate();
const linkTabs: {
id: ShareViewType;
id: ShareViaType | ShareSettingsType;
type: LinkTabsType;
label: string;
icon: React.ElementType;
title: string;
description: string;
componentType: React.ComponentType<any>;
componentProps: any;
componentType: React.ComponentType<unknown>;
componentProps: unknown;
disabled?: boolean;
}[] = useMemo(
() => [
{
id: ShareViewType.ANON_LINKS,
id: ShareViaType.ANON_LINKS,
type: LinkTabsType.SHARE_VIA,
label: t("environments.surveys.share.anonymous_links.nav_title"),
icon: LinkIcon,
title: t("environments.surveys.share.anonymous_links.nav_title"),
@@ -81,7 +98,8 @@ export const ShareSurveyModal = ({
},
},
{
id: ShareViewType.PERSONAL_LINKS,
id: ShareViaType.PERSONAL_LINKS,
type: LinkTabsType.SHARE_VIA,
label: t("environments.surveys.share.personal_links.nav_title"),
icon: UserIcon,
title: t("environments.surveys.share.personal_links.nav_title"),
@@ -94,45 +112,55 @@ export const ShareSurveyModal = ({
isContactsEnabled,
isFormbricksCloud,
},
disabled: survey.singleUse?.enabled,
},
{
id: ShareViewType.WEBSITE_EMBED,
id: ShareViaType.WEBSITE_EMBED,
type: LinkTabsType.SHARE_VIA,
label: t("environments.surveys.share.embed_on_website.nav_title"),
icon: Code2Icon,
title: t("environments.surveys.share.embed_on_website.nav_title"),
description: t("environments.surveys.share.embed_on_website.description"),
componentType: WebsiteEmbedTab,
componentProps: { surveyUrl },
disabled: survey.singleUse?.enabled,
},
{
id: ShareViewType.EMAIL,
id: ShareViaType.EMAIL,
type: LinkTabsType.SHARE_VIA,
label: t("environments.surveys.share.send_email.nav_title"),
icon: MailIcon,
title: t("environments.surveys.share.send_email.nav_title"),
description: t("environments.surveys.share.send_email.description"),
componentType: EmailTab,
componentProps: { surveyId: survey.id, email },
disabled: survey.singleUse?.enabled,
},
{
id: ShareViewType.SOCIAL_MEDIA,
id: ShareViaType.SOCIAL_MEDIA,
type: LinkTabsType.SHARE_VIA,
label: t("environments.surveys.share.social_media.title"),
icon: Share2Icon,
title: t("environments.surveys.share.social_media.title"),
description: t("environments.surveys.share.social_media.description"),
componentType: SocialMediaTab,
componentProps: { surveyUrl, surveyTitle: survey.name },
disabled: survey.singleUse?.enabled,
},
{
id: ShareViewType.QR_CODE,
id: ShareViaType.QR_CODE,
type: LinkTabsType.SHARE_VIA,
label: t("environments.surveys.summary.qr_code"),
icon: QrCodeIcon,
title: t("environments.surveys.summary.qr_code"),
description: t("environments.surveys.summary.qr_code_description"),
componentType: QRCodeTab,
componentProps: { surveyUrl },
disabled: survey.singleUse?.enabled,
},
{
id: ShareViewType.DYNAMIC_POPUP,
id: ShareViaType.DYNAMIC_POPUP,
type: LinkTabsType.SHARE_VIA,
label: t("environments.surveys.share.dynamic_popup.nav_title"),
icon: SquareStack,
title: t("environments.surveys.share.dynamic_popup.nav_title"),
@@ -140,14 +168,24 @@ export const ShareSurveyModal = ({
componentType: DynamicPopupTab,
componentProps: { environmentId, surveyId: survey.id },
},
{
id: ShareSettingsType.LINK_SETTINGS,
type: LinkTabsType.SHARE_SETTING,
label: t("environments.surveys.share.link_settings.title"),
icon: Settings,
title: t("environments.surveys.share.link_settings.title"),
description: t("environments.surveys.share.link_settings.description"),
componentType: LinkSettingsTab,
componentProps: { isReadOnly, locale: user.locale },
},
],
[
t,
survey,
publicDomain,
setSurveyUrl,
user.locale,
surveyUrl,
isReadOnly,
environmentId,
segments,
isContactsEnabled,
@@ -156,9 +194,14 @@ export const ShareSurveyModal = ({
]
);
const [activeId, setActiveId] = useState(
survey.type === "link" ? ShareViewType.ANON_LINKS : ShareViewType.APP
);
const getDefaultActiveId = useCallback(() => {
if (survey.type !== "link") {
return ShareViaType.APP;
}
return ShareViaType.ANON_LINKS;
}, [survey.type]);
const [activeId, setActiveId] = useState<ShareViaType | ShareSettingsType>(getDefaultActiveId());
useEffect(() => {
if (open) {
@@ -166,11 +209,19 @@ export const ShareSurveyModal = ({
}
}, [open, modalView]);
// Ensure active tab is not disabled - if it is, switch to default
useEffect(() => {
const activeTab = linkTabs.find((tab) => tab.id === activeId);
if (activeTab?.disabled) {
setActiveId(getDefaultActiveId());
}
}, [activeId, linkTabs, getDefaultActiveId]);
const handleOpenChange = (open: boolean) => {
setOpen(open);
if (!open) {
setShowView("start");
setActiveId(ShareViewType.ANON_LINKS);
setActiveId(getDefaultActiveId());
}
};
@@ -178,7 +229,7 @@ export const ShareSurveyModal = ({
setShowView(view);
};
const handleEmbedViewWithTab = (tabId: ShareViewType) => {
const handleEmbedViewWithTab = (tabId: ShareViaType | ShareSettingsType) => {
setShowView("share");
setActiveId(tabId);
};

View File

@@ -0,0 +1,428 @@
import { useSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context";
import { createI18nString, extractLanguageCodes, getEnabledLanguages } from "@/lib/i18n/utils";
import { updateSurveyAction } from "@/modules/survey/editor/actions";
import "@testing-library/jest-dom/vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
import { TSurvey, TSurveyLanguage } from "@formbricks/types/surveys/types";
import { LinkSettingsTab } from "./link-settings-tab";
// Mock dependencies
vi.mock("@/lib/i18n/utils", () => ({
createI18nString: vi.fn(),
extractLanguageCodes: vi.fn(),
getEnabledLanguages: vi.fn(),
}));
vi.mock("@/modules/survey/editor/actions", () => ({
updateSurveyAction: vi.fn(),
}));
vi.mock("@formbricks/i18n-utils/src/utils", () => ({
getLanguageLabel: vi.fn(),
}));
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context", () => ({
useSurvey: vi.fn(),
}));
vi.mock("react-hot-toast", () => ({
default: {
success: vi.fn(),
error: vi.fn(),
},
}));
vi.mock("@/modules/ui/components/file-input", () => ({
FileInput: vi.fn(({ onFileUpload, fileUrl, disabled, id }) => (
<div data-testid={id}>
<input
data-testid="file-input"
type="text"
value={fileUrl || ""}
onChange={(e) => onFileUpload(e.target.value ? [e.target.value] : undefined)}
disabled={disabled}
/>
</div>
)),
}));
vi.mock("@/modules/ui/components/button", () => ({
Button: vi.fn(({ children, disabled, type, ...props }) => (
<button data-testid="save-button" disabled={disabled} type={type} {...props}>
{children}
</button>
)),
}));
const mockSurvey: TSurvey = {
id: "test-survey-id",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Survey",
type: "link",
environmentId: "test-env-id",
status: "inProgress",
displayOption: "displayOnce",
autoClose: null,
triggers: [],
recontactDays: null,
displayLimit: null,
welcomeCard: { enabled: false, timeToFinish: false, showResponseCount: false },
questions: [],
endings: [],
hiddenFields: { enabled: false },
displayPercentage: null,
autoComplete: null,
segment: null,
languages: [
{ language: { id: "lang1", code: "default" }, default: true, enabled: true } as TSurveyLanguage,
{ language: { id: "lang2", code: "en" }, default: false, enabled: true } as TSurveyLanguage,
],
showLanguageSwitch: false,
singleUse: { enabled: false, isEncrypted: false },
projectOverwrites: null,
surveyClosedMessage: null,
delay: 0,
isVerifyEmailEnabled: false,
createdBy: null,
variables: [],
followUps: [],
runOnDate: null,
closeOnDate: null,
styling: null,
pin: null,
recaptcha: null,
isSingleResponsePerEmailEnabled: false,
isBackButtonHidden: false,
metadata: {
title: { default: "Test Title", en: "Test Title EN" },
description: { default: "Test Description", en: "Test Description EN" },
ogImage: "https://example.com/image.png",
},
};
const mockSingleLanguageSurvey: TSurvey = {
...mockSurvey,
languages: [
{ language: { id: "lang1", code: "default" }, default: true, enabled: true },
] as TSurveyLanguage[],
};
describe("LinkSettingsTab", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(useSurvey).mockReturnValue({
survey: mockSurvey,
});
vi.mocked(getEnabledLanguages).mockReturnValue([
{ language: { id: "lang1", code: "default" }, default: true, enabled: true } as TSurveyLanguage,
{ language: { id: "lang2", code: "en" }, default: false, enabled: true } as TSurveyLanguage,
]);
vi.mocked(extractLanguageCodes).mockReturnValue(["default", "en"]);
vi.mocked(createI18nString).mockImplementation((text, languages) => {
const result = {};
languages.forEach((lang) => {
result[lang] = typeof text === "string" ? text : "";
});
return result;
});
vi.mocked(getLanguageLabel).mockImplementation((code) => {
const labels = {
default: "Default",
en: "English",
};
return labels[code] || code;
});
vi.mocked(updateSurveyAction).mockResolvedValue({ data: mockSurvey });
});
afterEach(() => {
cleanup();
});
test("renders form fields correctly", () => {
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
expect(screen.getByText("common.language")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.share.link_settings.link_title")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.share.link_settings.link_description")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.share.link_settings.preview_image")).toBeInTheDocument();
expect(screen.getByTestId("save-button")).toBeInTheDocument();
});
test("initializes form with existing metadata", () => {
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
const titleInput = screen.getByDisplayValue("Test Title");
const descriptionInput = screen.getByDisplayValue("Test Description");
expect(titleInput).toBeInTheDocument();
expect(descriptionInput).toBeInTheDocument();
});
test("initializes form with empty values when metadata is undefined", () => {
const mockSurveyWithoutMetadata: TSurvey = {
...mockSurvey,
metadata: {},
};
vi.mocked(useSurvey).mockReturnValue({
survey: mockSurveyWithoutMetadata,
});
vi.mocked(createI18nString).mockReturnValue({ default: "", en: "" });
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
expect(vi.mocked(createI18nString)).toHaveBeenCalledWith("", ["default", "en"]);
});
test("does not show language selector for single language surveys", () => {
vi.mocked(useSurvey).mockReturnValue({
survey: mockSingleLanguageSurvey,
});
vi.mocked(getEnabledLanguages).mockReturnValue([
{ language: { id: "lang1", code: "default" }, default: true, enabled: true } as TSurveyLanguage,
]);
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
expect(screen.queryByText("common.language")).not.toBeInTheDocument();
});
test("shows language selector for multi-language surveys", () => {
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
expect(screen.getByText("common.language")).toBeInTheDocument();
});
test("handles language change correctly", async () => {
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
// Since the Select component is complex to test in JSDOM, let's test that
// the language selector is rendered and has the expected options
const languageSelect = screen.getByRole("combobox");
expect(languageSelect).toBeInTheDocument();
// Check that the language options are available in the hidden select
const hiddenSelect = screen.getByDisplayValue("Default");
expect(hiddenSelect).toBeInTheDocument();
// Check for English option in the select
const englishOption = screen
.getByDisplayValue("Default")
.closest("select")
?.querySelector('option[value="en"]');
expect(englishOption).toBeInTheDocument();
});
test("handles title input change", async () => {
const user = userEvent.setup();
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
const titleInput = screen.getByDisplayValue("Test Title");
await user.clear(titleInput);
await user.type(titleInput, "New Title");
expect(titleInput).toHaveValue("New Title");
});
test("handles description input change", async () => {
const user = userEvent.setup();
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
const descriptionInput = screen.getByDisplayValue("Test Description");
await user.clear(descriptionInput);
await user.type(descriptionInput, "New Description");
expect(descriptionInput).toHaveValue("New Description");
});
test("handles file upload", async () => {
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
const fileInput = screen.getByTestId("file-input");
fireEvent.change(fileInput, { target: { value: "https://example.com/new-image.png" } });
expect(fileInput).toHaveValue("https://example.com/new-image.png");
});
test("handles file removal", async () => {
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
const fileInput = screen.getByTestId("file-input");
fireEvent.change(fileInput, { target: { value: "" } });
expect(fileInput).toHaveValue("");
});
test("disables form when isReadOnly is true", () => {
render(<LinkSettingsTab isReadOnly={true} locale="en-US" />);
const titleInput = screen.getByDisplayValue("Test Title");
const descriptionInput = screen.getByDisplayValue("Test Description");
const saveButton = screen.getByTestId("save-button");
expect(titleInput).toBeDisabled();
expect(descriptionInput).toBeDisabled();
expect(saveButton).toBeDisabled();
});
test("submits form successfully", async () => {
const user = userEvent.setup();
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
const titleInput = screen.getByDisplayValue("Test Title");
await user.clear(titleInput);
await user.type(titleInput, "Updated Title");
const saveButton = screen.getByTestId("save-button");
await user.click(saveButton);
await waitFor(() => {
expect(vi.mocked(updateSurveyAction)).toHaveBeenCalled();
});
const callArgs = vi.mocked(updateSurveyAction).mock.calls[0][0];
expect(callArgs.metadata.title?.default).toBe("Updated Title");
});
test("handles submission error", async () => {
const user = userEvent.setup();
vi.mocked(updateSurveyAction).mockResolvedValue({ data: mockSurvey });
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
const titleInput = screen.getByDisplayValue("Test Title");
await user.clear(titleInput);
await user.type(titleInput, "Updated Title");
const saveButton = screen.getByTestId("save-button");
await user.click(saveButton);
await waitFor(() => {
expect(vi.mocked(updateSurveyAction)).toHaveBeenCalled();
});
});
test("does not submit when form is saving", async () => {
const user = userEvent.setup();
let resolvePromise: (value: any) => void;
const pendingPromise = new Promise((resolve) => {
resolvePromise = resolve;
});
vi.mocked(updateSurveyAction).mockReturnValue(pendingPromise as any);
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
// Make form dirty first
const titleInput = screen.getByDisplayValue("Test Title");
await user.clear(titleInput);
await user.type(titleInput, "Modified Title");
const saveButton = screen.getByTestId("save-button");
// First click should trigger submission
await user.click(saveButton);
// Wait a bit to ensure the first submission started
await new Promise((resolve) => setTimeout(resolve, 10));
// Second click should be prevented because form is saving
await user.click(saveButton);
// Should only be called once
expect(vi.mocked(updateSurveyAction)).toHaveBeenCalledTimes(1);
// Clean up by resolving the promise
resolvePromise!({ data: mockSurvey });
});
test("does not submit when isReadOnly is true", async () => {
const user = userEvent.setup();
render(<LinkSettingsTab isReadOnly={true} locale="en-US" />);
const saveButton = screen.getByTestId("save-button");
await user.click(saveButton);
expect(vi.mocked(updateSurveyAction)).not.toHaveBeenCalled();
});
test("handles ogImage correctly in form submission", async () => {
const user = userEvent.setup();
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
const fileInput = screen.getByTestId("file-input");
fireEvent.change(fileInput, { target: { value: "https://example.com/new-image.png" } });
const saveButton = screen.getByTestId("save-button");
await user.click(saveButton);
await waitFor(() => {
expect(vi.mocked(updateSurveyAction)).toHaveBeenCalled();
});
const callArgs = vi.mocked(updateSurveyAction).mock.calls[0][0];
expect(callArgs.metadata.ogImage).toBe("https://example.com/new-image.png");
});
test("handles empty ogImage correctly in form submission", async () => {
const user = userEvent.setup();
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
const fileInput = screen.getByTestId("file-input");
fireEvent.change(fileInput, { target: { value: "" } });
const saveButton = screen.getByTestId("save-button");
await user.click(saveButton);
await waitFor(() => {
expect(vi.mocked(updateSurveyAction)).toHaveBeenCalled();
});
const callArgs = vi.mocked(updateSurveyAction).mock.calls[0][0];
expect(callArgs.metadata.ogImage).toBeUndefined();
});
test("merges form data with existing metadata correctly", async () => {
const user = userEvent.setup();
const surveyWithPartialMetadata: TSurvey = {
...mockSurvey,
metadata: {
title: { default: "Existing Title" },
description: { default: "Existing Description" },
},
};
vi.mocked(useSurvey).mockReturnValue({
survey: surveyWithPartialMetadata,
});
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
const titleInput = screen.getByDisplayValue("Existing Title");
await user.clear(titleInput);
await user.type(titleInput, "New Title");
const saveButton = screen.getByTestId("save-button");
await user.click(saveButton);
await waitFor(() => {
expect(vi.mocked(updateSurveyAction)).toHaveBeenCalled();
});
const callArgs = vi.mocked(updateSurveyAction).mock.calls[0][0];
expect(callArgs.metadata.title?.default).toBe("New Title");
expect(callArgs.metadata.description?.default).toBe("Existing Description");
});
});

View File

@@ -0,0 +1,257 @@
"use client";
import { useSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context";
import { createI18nString, extractLanguageCodes, getEnabledLanguages } from "@/lib/i18n/utils";
import { updateSurveyAction } from "@/modules/survey/editor/actions";
import { Button } from "@/modules/ui/components/button";
import { FileInput } from "@/modules/ui/components/file-input";
import {
FormControl,
FormDescription,
FormError,
FormField,
FormItem,
FormLabel,
FormProvider,
} from "@/modules/ui/components/form";
import { Input } from "@/modules/ui/components/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import { useTranslate } from "@tolgee/react";
import { useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
import { TI18nString, TSurvey, TSurveyMetadata } from "@formbricks/types/surveys/types";
interface LinkSettingsTabProps {
isReadOnly: boolean;
locale: string;
}
interface LinkSettingsFormData {
title: TI18nString;
description: TI18nString;
ogImage?: string;
}
export const LinkSettingsTab = ({ isReadOnly, locale }: LinkSettingsTabProps) => {
const { t } = useTranslate();
const { survey } = useSurvey();
const enabledLanguages = getEnabledLanguages(survey.languages);
const hasMultipleLanguages = enabledLanguages.length > 1;
// Set default language - use 'default' if no multi-language is set up
const defaultLanguageCode = hasMultipleLanguages
? survey.languages.find((lang) => lang.default)?.language.code || "default"
: "default";
const languageCodes = useMemo(() => extractLanguageCodes(survey.languages), [survey.languages]);
const [selectedLanguageCode, setSelectedLanguageCode] = useState(defaultLanguageCode);
const [isSaving, setIsSaving] = useState(false);
// Current language code for metadata storage
const currentLangCode = selectedLanguageCode === defaultLanguageCode ? "default" : selectedLanguageCode;
// Initialize form with current values - memoize to prevent re-initialization
const initialFormData = useMemo(() => {
const metadata = survey.metadata || {
title: createI18nString("", languageCodes),
description: createI18nString("", languageCodes),
ogImage: undefined,
};
return {
title: metadata.title || createI18nString("", languageCodes),
description: metadata.description || createI18nString("", languageCodes),
ogImage: metadata.ogImage || "",
};
}, [survey.metadata, languageCodes]);
const form = useForm<LinkSettingsFormData>({
defaultValues: initialFormData,
});
const {
handleSubmit,
setValue,
reset,
formState: { isDirty },
} = form;
const handleFileUpload = (urls: string[] | undefined) => {
if (urls && urls.length > 0) {
setValue("ogImage", urls[0], { shouldDirty: true });
} else {
// Handle removal of the image
setValue("ogImage", "", { shouldDirty: true });
}
};
const onSubmit = async (data: LinkSettingsFormData) => {
if (isSaving || isReadOnly) return;
setIsSaving(true);
// Get current linkMetadata
const currentSurveyMetadata = survey.metadata || {
title: createI18nString("", languageCodes),
description: createI18nString("", languageCodes),
ogImage: undefined,
};
// Merge form data with existing linkMetadata
const updatedTitle: TI18nString = {
...currentSurveyMetadata.title,
...data.title,
};
const updatedDescription: TI18nString = {
...currentSurveyMetadata.description,
...data.description,
};
const updatedSurveyMetadata: TSurveyMetadata = {
title: updatedTitle,
description: updatedDescription,
ogImage: data.ogImage || undefined,
};
const updatedSurvey: TSurvey = {
...survey,
metadata: updatedSurveyMetadata,
};
const result = await updateSurveyAction(updatedSurvey);
if (result?.data) {
toast.success(t("environments.surveys.edit.settings_saved_successfully"));
reset(data);
} else {
toast.error(t("common.something_went_wrong_please_try_again"));
}
setIsSaving(false);
};
const inputFields: {
name: "title" | "description";
label: string;
description: string;
placeholder: string;
}[] = [
{
name: "title",
label: t("environments.surveys.share.link_settings.link_title"),
description: t("environments.surveys.share.link_settings.link_title_description"),
placeholder: survey.name,
},
{
name: "description",
label: t("environments.surveys.share.link_settings.link_description"),
description: t("environments.surveys.share.link_settings.link_description_description"),
placeholder: "Please complete this survey.",
},
];
return (
<div className="px-1">
<FormProvider {...form}>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{/* Language Selection - only show if survey has multiple languages */}
{hasMultipleLanguages && (
<FormItem>
<FormLabel>{t("common.language")}</FormLabel>
<FormControl>
<Select value={selectedLanguageCode} onValueChange={setSelectedLanguageCode}>
<SelectTrigger className="bg-white">
<SelectValue />
</SelectTrigger>
<SelectContent>
{enabledLanguages.map((lang) => (
<SelectItem key={lang.language.code} value={lang.language.code} className="bg-white">
{getLanguageLabel(lang.language.code, locale)}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormDescription>
{t("environments.surveys.share.link_settings.language_help_text")}
</FormDescription>
</FormItem>
)}
{inputFields.map((inputField) => {
return (
<FormField
key={inputField.name}
control={form.control}
name={inputField.name}
render={({ field }) => (
<FormItem>
<FormLabel>{inputField.label}</FormLabel>
<FormControl>
<Input
value={field.value[currentLangCode] || ""}
className="bg-white"
onChange={(e) => {
const updatedValue = {
...field.value,
[currentLangCode]: e.target.value,
};
field.onChange(updatedValue);
}}
placeholder={inputField.placeholder}
disabled={isReadOnly}
/>
</FormControl>
<FormDescription>{inputField.description}</FormDescription>
<FormError />
</FormItem>
)}
/>
);
})}
{/* Preview Image */}
<FormField
control={form.control}
name="ogImage"
render={({ field }) => (
<FormItem>
<FormLabel>{t("environments.surveys.share.link_settings.preview_image")}</FormLabel>
<FormControl>
<FileInput
id={`og-image-upload-${survey.id}`}
allowedFileExtensions={["png", "jpeg", "jpg", "webp"]}
environmentId={survey.environmentId}
onFileUpload={handleFileUpload}
fileUrl={field.value}
maxSizeInMB={5}
disabled={isReadOnly}
/>
</FormControl>
<FormDescription>
{t("environments.surveys.share.link_settings.preview_image_description")}
</FormDescription>
<FormError />
</FormItem>
)}
/>
{/* Save Button */}
<Button type="submit" disabled={isSaving || isReadOnly || !isDirty}>
{isSaving ? t("common.saving") : t("common.save")}
</Button>
</form>
</FormProvider>
</div>
);
};

View File

@@ -3,7 +3,7 @@ import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { ShareViewType } from "../../types/share";
import { LinkTabsType, ShareSettingsType, ShareViaType } from "../../types/share";
import { ShareView } from "./share-view";
// Mock sidebar components
@@ -161,6 +161,7 @@ vi.mock("lucide-react", () => ({
Share2Icon: () => <div data-testid="share2-icon">Share2Icon</div>,
SquareStack: () => <div data-testid="square-stack-icon">SquareStack</div>,
UserIcon: () => <div data-testid="user-icon">UserIcon</div>,
Settings: () => <div data-testid="settings-icon">Settings</div>,
}));
// Mock tooltip and typography components
@@ -264,9 +265,25 @@ const mockSurvey = {
styling: null,
} as any;
// Mock LinkSettingsTab
const MockLinkSettingsTab = ({
survey,
isReadOnly,
locale,
}: {
survey: any;
isReadOnly: boolean;
locale: string;
}) => (
<div data-testid="link-settings-tab">
LinkSettingsTab Content for {survey.id} - ReadOnly: {isReadOnly.toString()} - Locale: {locale}
</div>
);
const mockTabs = [
{
id: ShareViewType.EMAIL,
id: ShareViaType.EMAIL,
type: LinkTabsType.SHARE_VIA,
label: "Email",
icon: () => <div data-testid="email-tab-icon" />,
componentType: MockEmailTab,
@@ -275,7 +292,8 @@ const mockTabs = [
description: "Send survey via email",
},
{
id: ShareViewType.WEBSITE_EMBED,
id: ShareViaType.WEBSITE_EMBED,
type: LinkTabsType.SHARE_VIA,
label: "Website Embed",
icon: () => <div data-testid="website-embed-tab-icon" />,
componentType: MockWebsiteEmbedTab,
@@ -284,7 +302,8 @@ const mockTabs = [
description: "Embed survey on your website",
},
{
id: ShareViewType.DYNAMIC_POPUP,
id: ShareViaType.DYNAMIC_POPUP,
type: LinkTabsType.SHARE_VIA,
label: "Dynamic Popup",
icon: () => <div data-testid="dynamic-popup-tab-icon" />,
componentType: MockDynamicPopupTab,
@@ -293,7 +312,8 @@ const mockTabs = [
description: "Show survey as popup",
},
{
id: ShareViewType.ANON_LINKS,
id: ShareViaType.ANON_LINKS,
type: LinkTabsType.SHARE_VIA,
label: "Anonymous Links",
icon: () => <div data-testid="anonymous-links-tab-icon" />,
componentType: MockAnonymousLinksTab,
@@ -308,7 +328,8 @@ const mockTabs = [
description: "Share anonymous links",
},
{
id: ShareViewType.QR_CODE,
id: ShareViaType.QR_CODE,
type: LinkTabsType.SHARE_VIA,
label: "QR Code",
icon: () => <div data-testid="qr-code-tab-icon" />,
componentType: MockQRCodeTab,
@@ -317,7 +338,8 @@ const mockTabs = [
description: "Generate QR code",
},
{
id: ShareViewType.PERSONAL_LINKS,
id: ShareViaType.PERSONAL_LINKS,
type: LinkTabsType.SHARE_VIA,
label: "Personal Links",
icon: () => <div data-testid="personal-links-tab-icon" />,
componentType: MockPersonalLinksTab,
@@ -326,7 +348,8 @@ const mockTabs = [
description: "Create personal links",
},
{
id: ShareViewType.SOCIAL_MEDIA,
id: ShareViaType.SOCIAL_MEDIA,
type: LinkTabsType.SHARE_VIA,
label: "Social Media",
icon: () => <div data-testid="social-media-tab-icon" />,
componentType: MockSocialMediaTab,
@@ -334,11 +357,21 @@ const mockTabs = [
title: "Social Media",
description: "Share on social media",
},
{
id: ShareSettingsType.LINK_SETTINGS,
type: LinkTabsType.SHARE_SETTING,
label: "Link Settings",
icon: () => <div data-testid="link-settings-tab-icon" />,
componentType: MockLinkSettingsTab,
componentProps: { survey: mockSurvey, isReadOnly: false, locale: "en-US" },
title: "Link Settings",
description: "Configure link settings",
},
];
const defaultProps = {
tabs: mockTabs,
activeId: ShareViewType.EMAIL,
activeId: ShareViaType.EMAIL,
setActiveId: vi.fn(),
};
@@ -370,43 +403,51 @@ describe("ShareView", () => {
});
test("renders desktop tabs", () => {
render(<ShareView {...defaultProps} activeId={ShareViewType.EMAIL} />);
render(<ShareView {...defaultProps} activeId={ShareViaType.EMAIL} />);
// Desktop sidebar should be rendered
const sidebarLabel = screen.getByText("environments.surveys.share.share_view_title");
expect(sidebarLabel).toBeInTheDocument();
});
test("renders share settings section", () => {
render(<ShareView {...defaultProps} />);
// Share settings section should be rendered
const shareSettingsLabel = screen.getByText("environments.surveys.share.share_settings_title");
expect(shareSettingsLabel).toBeInTheDocument();
});
test("calls setActiveId when a tab is clicked (desktop)", async () => {
render(<ShareView {...defaultProps} activeId={ShareViewType.EMAIL} />);
render(<ShareView {...defaultProps} activeId={ShareViaType.EMAIL} />);
const websiteEmbedTabButton = screen.getByLabelText("Website Embed");
await userEvent.click(websiteEmbedTabButton);
expect(defaultProps.setActiveId).toHaveBeenCalledWith(ShareViewType.WEBSITE_EMBED);
expect(defaultProps.setActiveId).toHaveBeenCalledWith(ShareViaType.WEBSITE_EMBED);
});
test("renders EmailTab when activeId is EMAIL", () => {
render(<ShareView {...defaultProps} activeId={ShareViewType.EMAIL} />);
render(<ShareView {...defaultProps} activeId={ShareViaType.EMAIL} />);
expect(screen.getByTestId("email-tab")).toBeInTheDocument();
expect(screen.getByText("EmailTab Content for survey1 with test@example.com")).toBeInTheDocument();
});
test("renders WebsiteEmbedTab when activeId is WEBSITE_EMBED", () => {
render(<ShareView {...defaultProps} activeId={ShareViewType.WEBSITE_EMBED} />);
render(<ShareView {...defaultProps} activeId={ShareViaType.WEBSITE_EMBED} />);
expect(screen.getByTestId("tab-container")).toBeInTheDocument();
expect(screen.getByTestId("website-embed-tab")).toBeInTheDocument();
expect(screen.getByText("WebsiteEmbedTab Content for http://example.com/survey1")).toBeInTheDocument();
});
test("renders DynamicPopupTab when activeId is DYNAMIC_POPUP", () => {
render(<ShareView {...defaultProps} activeId={ShareViewType.DYNAMIC_POPUP} />);
render(<ShareView {...defaultProps} activeId={ShareViaType.DYNAMIC_POPUP} />);
expect(screen.getByTestId("tab-container")).toBeInTheDocument();
expect(screen.getByTestId("dynamic-popup-tab")).toBeInTheDocument();
expect(screen.getByText("DynamicPopupTab Content for survey1 in env1")).toBeInTheDocument();
});
test("renders AnonymousLinksTab when activeId is ANON_LINKS", () => {
render(<ShareView {...defaultProps} activeId={ShareViewType.ANON_LINKS} />);
render(<ShareView {...defaultProps} activeId={ShareViaType.ANON_LINKS} />);
expect(screen.getByTestId("anonymous-links-tab")).toBeInTheDocument();
expect(
screen.getByText("AnonymousLinksTab Content for survey1 at http://example.com/survey1")
@@ -414,16 +455,24 @@ describe("ShareView", () => {
});
test("renders QRCodeTab when activeId is QR_CODE", () => {
render(<ShareView {...defaultProps} activeId={ShareViewType.QR_CODE} />);
render(<ShareView {...defaultProps} activeId={ShareViaType.QR_CODE} />);
expect(screen.getByTestId("qr-code-tab")).toBeInTheDocument();
});
test("renders LinkSettingsTab when activeId is LINK_SETTINGS", () => {
render(<ShareView {...defaultProps} activeId={ShareSettingsType.LINK_SETTINGS} />);
expect(screen.getByTestId("link-settings-tab")).toBeInTheDocument();
expect(
screen.getByText("LinkSettingsTab Content for survey1 - ReadOnly: false - Locale: en-US")
).toBeInTheDocument();
});
test("renders nothing when activeId doesn't match any tab", () => {
// Create a special case with no matching tab
const propsWithNoMatchingTab = {
...defaultProps,
tabs: mockTabs.slice(0, 3), // Only include first 3 tabs
activeId: ShareViewType.SOCIAL_MEDIA, // Use a tab not in the subset
activeId: ShareViaType.SOCIAL_MEDIA, // Use a tab not in the subset
};
render(<ShareView {...propsWithNoMatchingTab} />);
@@ -436,16 +485,17 @@ describe("ShareView", () => {
expect(screen.queryByTestId("qr-code-tab")).not.toBeInTheDocument();
expect(screen.queryByTestId("personal-links-tab")).not.toBeInTheDocument();
expect(screen.queryByTestId("social-media-tab")).not.toBeInTheDocument();
expect(screen.queryByTestId("link-settings-tab")).not.toBeInTheDocument();
});
test("renders PersonalLinksTab when activeId is PERSONAL_LINKS", () => {
render(<ShareView {...defaultProps} activeId={ShareViewType.PERSONAL_LINKS} />);
render(<ShareView {...defaultProps} activeId={ShareViaType.PERSONAL_LINKS} />);
expect(screen.getByTestId("personal-links-tab")).toBeInTheDocument();
expect(screen.getByText("PersonalLinksTab Content for survey1 in env1")).toBeInTheDocument();
});
test("renders SocialMediaTab when activeId is SOCIAL_MEDIA", () => {
render(<ShareView {...defaultProps} activeId={ShareViewType.SOCIAL_MEDIA} />);
render(<ShareView {...defaultProps} activeId={ShareViaType.SOCIAL_MEDIA} />);
expect(screen.getByTestId("social-media-tab")).toBeInTheDocument();
expect(
screen.getByText("SocialMediaTab Content for Test Survey at http://example.com/survey1")
@@ -453,7 +503,7 @@ describe("ShareView", () => {
});
test("calls setActiveId when a responsive tab is clicked", async () => {
render(<ShareView {...defaultProps} activeId={ShareViewType.EMAIL} />);
render(<ShareView {...defaultProps} activeId={ShareViaType.EMAIL} />);
// Get responsive buttons - these are Button components containing icons
const responsiveButtons = screen.getAllByTestId("website-embed-tab-icon");
@@ -467,12 +517,12 @@ describe("ShareView", () => {
if (responsiveButton) {
await userEvent.click(responsiveButton);
expect(defaultProps.setActiveId).toHaveBeenCalledWith(ShareViewType.WEBSITE_EMBED);
expect(defaultProps.setActiveId).toHaveBeenCalledWith(ShareViaType.WEBSITE_EMBED);
}
});
test("applies active styles to the active tab (desktop)", () => {
render(<ShareView {...defaultProps} activeId={ShareViewType.EMAIL} />);
render(<ShareView {...defaultProps} activeId={ShareViaType.EMAIL} />);
const emailTabButton = screen.getByLabelText("Email");
expect(emailTabButton).toHaveClass("bg-slate-100");
@@ -485,7 +535,7 @@ describe("ShareView", () => {
});
test("applies active styles to the active tab (responsive)", () => {
render(<ShareView {...defaultProps} activeId={ShareViewType.EMAIL} />);
render(<ShareView {...defaultProps} activeId={ShareViaType.EMAIL} />);
// Get responsive buttons - these are Button components with ghost variant
const responsiveButtons = screen.getAllByTestId("email-tab-icon");
@@ -537,6 +587,7 @@ describe("ShareView", () => {
"qr-code-tab-icon",
"personal-links-tab-icon",
"social-media-tab-icon",
"link-settings-tab-icon",
];
expectedTestIds.forEach((testId) => {
@@ -548,4 +599,28 @@ describe("ShareView", () => {
expect(responsiveButton).toBeTruthy();
});
});
test("separates share via and share settings tabs correctly", () => {
render(<ShareView {...defaultProps} />);
// Check that share via tabs are rendered
expect(screen.getByLabelText("Email")).toBeInTheDocument();
expect(screen.getByLabelText("Website Embed")).toBeInTheDocument();
expect(screen.getByLabelText("Anonymous Links")).toBeInTheDocument();
// Check that share settings tabs are rendered
expect(screen.getByLabelText("Link Settings")).toBeInTheDocument();
// Check that both sections have their titles
expect(screen.getByText("environments.surveys.share.share_view_title")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.share.share_settings_title")).toBeInTheDocument();
});
test("calls setActiveId when a share settings tab is clicked", async () => {
render(<ShareView {...defaultProps} activeId={ShareViaType.EMAIL} />);
const linkSettingsTabButton = screen.getByLabelText("Link Settings");
await userEvent.click(linkSettingsTabButton);
expect(defaultProps.setActiveId).toHaveBeenCalledWith(ShareSettingsType.LINK_SETTINGS);
});
});

View File

@@ -1,7 +1,11 @@
"use client";
import { TabContainer } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/tab-container";
import { ShareViewType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/types/share";
import {
LinkTabsType,
ShareSettingsType,
ShareViaType,
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/types/share";
import { cn } from "@/lib/cn";
import { Button } from "@/modules/ui/components/button";
import {
@@ -22,16 +26,18 @@ import { useEffect, useState } from "react";
interface ShareViewProps {
tabs: Array<{
id: ShareViewType;
id: ShareViaType | ShareSettingsType;
type: LinkTabsType;
label: string;
icon: React.ElementType;
componentType: React.ComponentType<any>;
componentProps: any;
title: string;
description?: string;
disabled?: boolean;
}>;
activeId: ShareViewType;
setActiveId: React.Dispatch<React.SetStateAction<ShareViewType>>;
activeId: ShareViaType | ShareSettingsType;
setActiveId: React.Dispatch<React.SetStateAction<ShareViaType | ShareSettingsType>>;
}
export const ShareView = ({ tabs, activeId, setActiveId }: ShareViewProps) => {
@@ -63,6 +69,21 @@ export const ShareView = ({ tabs, activeId, setActiveId }: ShareViewProps) => {
);
};
const shareSettingsTabs = tabs.filter((tab) => tab.type === LinkTabsType.SHARE_SETTING);
const shareViaTabs = tabs.filter((tab) => tab.type === LinkTabsType.SHARE_VIA);
const sideBarGroups = [
{
id: LinkTabsType.SHARE_VIA,
label: t("environments.surveys.share.share_view_title"),
tabs: shareViaTabs,
},
{
id: LinkTabsType.SHARE_SETTING,
label: t("environments.surveys.share.share_settings_title"),
tabs: shareSettingsTabs,
},
];
return (
<div className="h-full">
<div className={`flex h-full lg:grid lg:grid-cols-4`}>
@@ -76,32 +97,35 @@ export const ShareView = ({ tabs, activeId, setActiveId }: ShareViewProps) => {
}>
<Sidebar className="relative h-full p-0" variant="inset" collapsible="icon">
<SidebarContent className="h-full rounded-l-lg border-r border-slate-200 bg-white p-4">
<SidebarGroup className="p-0">
<SidebarGroupLabel>
<Small className="text-xs text-slate-500">
{t("environments.surveys.share.share_view_title")}
</Small>
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu className="flex flex-col gap-1">
{tabs.map((tab) => (
<SidebarMenuItem key={tab.id}>
<SidebarMenuButton
onClick={() => setActiveId(tab.id)}
className={cn(
"flex w-full justify-start rounded-md p-2 text-slate-600 hover:bg-slate-100 hover:text-slate-900",
tab.id === activeId ? "bg-slate-100 font-medium text-slate-900" : "text-slate-700"
)}
tooltip={tab.label}
isActive={tab.id === activeId}>
<tab.icon className="h-4 w-4 text-slate-700" />
<span>{tab.label}</span>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
{sideBarGroups.map((group) => (
<SidebarGroup className="p-0" key={group.id}>
<SidebarGroupLabel>
<Small className="text-xs text-slate-500">{group.label}</Small>
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu className="flex flex-col gap-1">
{group.tabs.map((tab) => (
<SidebarMenuItem key={tab.id}>
<SidebarMenuButton
onClick={() => setActiveId(tab.id)}
className={cn(
"flex w-full justify-start rounded-md p-2 text-slate-600 hover:bg-slate-100 hover:text-slate-900",
tab.id === activeId && !tab.disabled
? "bg-slate-100 font-medium text-slate-900"
: "text-slate-700"
)}
tooltip={tab.label}
isActive={tab.id === activeId}
disabled={tab.disabled}>
<tab.icon className="h-4 w-4 text-slate-700" />
<span>{tab.label}</span>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
))}
</SidebarContent>
</Sidebar>
</SidebarProvider>
@@ -114,9 +138,10 @@ export const ShareView = ({ tabs, activeId, setActiveId }: ShareViewProps) => {
<Button
variant="ghost"
onClick={() => setActiveId(tab.id)}
disabled={tab.disabled}
className={cn(
"rounded-md px-4 py-2",
tab.id === activeId
tab.id === activeId && !tab.disabled
? "bg-white text-slate-900 shadow-sm hover:bg-white"
: "border-transparent text-slate-700 hover:text-slate-900"
)}>

View File

@@ -98,8 +98,8 @@ vi.mock("@/modules/ui/components/page-header", () => ({
PageHeader: vi.fn(({ children }) => <div data-testid="page-header">{children}</div>),
}));
vi.mock("@/modules/ui/components/settings-id", () => ({
SettingsId: vi.fn(() => <div data-testid="settings-id"></div>),
vi.mock("@/modules/ui/components/id-badge", () => ({
IdBadge: vi.fn(() => <div data-testid="id-badge"></div>),
}));
vi.mock("@/tolgee/server", () => ({
@@ -227,7 +227,7 @@ describe("SurveyPage", () => {
expect(screen.getByTestId("page-header")).toBeInTheDocument();
expect(screen.getByTestId("survey-analysis-navigation")).toBeInTheDocument();
expect(screen.getByTestId("summary-page")).toBeInTheDocument();
expect(screen.getByTestId("settings-id")).toBeInTheDocument();
expect(screen.getByTestId("id-badge")).toBeInTheDocument();
expect(vi.mocked(getEnvironmentAuth)).toHaveBeenCalledWith(mockEnvironmentId);
expect(vi.mocked(getSurvey)).toHaveBeenCalledWith(mockSurveyId);

View File

@@ -9,9 +9,9 @@ import { getUser } from "@/lib/user/service";
import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { SettingsId } from "@/modules/ui/components/settings-id";
import { getTranslate } from "@/tolgee/server";
import { notFound } from "next/navigation";
@@ -74,7 +74,7 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
initialSurveySummary={initialSurveySummary}
/>
<SettingsId title={t("common.survey_id")} id={surveyId} />
<IdBadge id={surveyId} label={t("common.survey_id")} variant="column" />
</PageContentWrapper>
);
};

View File

@@ -1,4 +1,4 @@
export enum ShareViewType {
export enum ShareViaType {
ANON_LINKS = "anon-links",
PERSONAL_LINKS = "personal-links",
EMAIL = "email",
@@ -9,3 +9,12 @@ export enum ShareViewType {
SOCIAL_MEDIA = "social-media",
QR_CODE = "qr-code",
}
export enum ShareSettingsType {
LINK_SETTINGS = "link-settings",
}
export enum LinkTabsType {
SHARE_VIA = "share_via",
SHARE_SETTING = "share_setting",
}

View File

@@ -188,4 +188,70 @@ describe("CustomFilter", () => {
expect(screen.queryByTestId("calendar-mock")).not.toBeInTheDocument();
});
});
test("downloading all and filtered responses in csv and xlsx formats", async () => {
const user = userEvent.setup();
render(<CustomFilter survey={mockSurvey} />);
// Mock the action to return undefined data to avoid DOM manipulation
vi.mocked(getResponsesDownloadUrlAction).mockResolvedValue({
data: undefined,
});
// Test CSV download
const downloadButton = screen.getByTestId("fb__custom-filter-download-responses-button");
await user.click(downloadButton);
const downloadAllCsv = screen.getByTestId("fb__custom-filter-download-all-csv");
await user.click(downloadAllCsv);
await waitFor(() => {
expect(getResponsesDownloadUrlAction).toHaveBeenCalledWith({
surveyId: "survey-1",
format: "csv",
filterCriteria: {},
});
});
// Test XLSX download
await user.click(downloadButton);
const downloadAllXlsx = screen.getByTestId("fb__custom-filter-download-all-xlsx");
await user.click(downloadAllXlsx);
await waitFor(() => {
expect(getResponsesDownloadUrlAction).toHaveBeenCalledWith({
surveyId: "survey-1",
format: "xlsx",
filterCriteria: {},
});
});
// Test filtered CSV download
await user.click(downloadButton);
const downloadFilteredCsv = screen.getByTestId("fb__custom-filter-download-filtered-csv");
await user.click(downloadFilteredCsv);
await waitFor(() => {
expect(getResponsesDownloadUrlAction).toHaveBeenCalledWith({
surveyId: "survey-1",
format: "csv",
filterCriteria: {},
});
});
// Test filtered XLSX download
await user.click(downloadButton);
const downloadFilteredXlsx = screen.getByTestId("fb__custom-filter-download-filtered-xlsx");
await user.click(downloadFilteredXlsx);
await waitFor(() => {
expect(getResponsesDownloadUrlAction).toHaveBeenCalledWith({
surveyId: "survey-1",
format: "xlsx",
filterCriteria: {},
});
});
vi.restoreAllMocks();
});
});

View File

@@ -15,6 +15,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { cn } from "@/modules/ui/lib/utils";
import { TFnType, useTranslate } from "@tolgee/react";
import {
differenceInDays,
@@ -31,7 +32,7 @@ import {
subQuarters,
subYears,
} from "date-fns";
import { ArrowDownToLineIcon, ChevronDown, ChevronUp, DownloadIcon } from "lucide-react";
import { ArrowDownToLineIcon, ChevronDown, ChevronUp, DownloadIcon, Loader2Icon } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import toast from "react-hot-toast";
import { TSurvey } from "@formbricks/types/surveys/types";
@@ -135,6 +136,7 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
const [isDatePickerOpen, setIsDatePickerOpen] = useState<boolean>(false);
const [isFilterDropDownOpen, setIsFilterDropDownOpen] = useState<boolean>(false);
const [hoveredRange, setHoveredRange] = useState<DateRange | null>(null);
const [isDownloading, setIsDownloading] = useState<boolean>(false);
const firstMountRef = useRef(true);
@@ -236,28 +238,29 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
setSelectingDate(DateSelected.FROM);
};
const handleDowndloadResponses = async (filter: FilterDownload, filetype: "csv" | "xlsx") => {
try {
const responseFilters = filter === FilterDownload.ALL ? {} : filters;
const responsesDownloadUrlResponse = await getResponsesDownloadUrlAction({
surveyId: survey.id,
format: filetype,
filterCriteria: responseFilters,
});
if (responsesDownloadUrlResponse?.data) {
const link = document.createElement("a");
link.href = responsesDownloadUrlResponse.data;
link.download = "";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} else {
const errorMessage = getFormattedErrorMessage(responsesDownloadUrlResponse);
toast.error(errorMessage);
}
} catch (error) {
toast.error("Error downloading responses");
const handleDownloadResponses = async (filter: FilterDownload, filetype: "csv" | "xlsx") => {
const responseFilters = filter === FilterDownload.ALL ? {} : filters;
setIsDownloading(true);
const responsesDownloadUrlResponse = await getResponsesDownloadUrlAction({
surveyId: survey.id,
format: filetype,
filterCriteria: responseFilters,
});
if (responsesDownloadUrlResponse?.data) {
const link = document.createElement("a");
link.href = responsesDownloadUrlResponse.data;
link.download = "";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} else {
const errorMessage = getFormattedErrorMessage(responsesDownloadUrlResponse);
toast.error(errorMessage);
}
setIsDownloading(false);
};
useClickOutside(datePickerRef, () => handleDatePickerClose());
@@ -386,11 +389,22 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
onOpenChange={(value) => {
value && handleDatePickerClose();
}}>
<DropdownMenuTrigger asChild className="focus:bg-muted cursor-pointer outline-none">
<DropdownMenuTrigger
asChild
className={cn(
"focus:bg-muted cursor-pointer outline-none",
isDownloading && "cursor-not-allowed opacity-50"
)}
disabled={isDownloading}
data-testid="fb__custom-filter-download-responses-button">
<div className="min-w-auto h-auto rounded-md border border-slate-200 bg-white p-3 hover:border-slate-300 sm:flex sm:px-6 sm:py-3">
<div className="hidden w-full items-center justify-between sm:flex">
<span className="text-sm text-slate-700">{t("common.download")}</span>
<ArrowDownToLineIcon className="ml-2 h-4 w-4" />
{isDownloading ? (
<Loader2Icon className="ml-2 h-4 w-4 animate-spin" />
) : (
<ArrowDownToLineIcon className="ml-2 h-4 w-4" />
)}
</div>
<DownloadIcon className="block h-4 sm:hidden" />
</div>
@@ -398,26 +412,30 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
<DropdownMenuContent align="start">
<DropdownMenuItem
onClick={() => {
handleDowndloadResponses(FilterDownload.ALL, "csv");
data-testid="fb__custom-filter-download-all-csv"
onClick={async () => {
await handleDownloadResponses(FilterDownload.ALL, "csv");
}}>
<p className="text-slate-700">{t("environments.surveys.summary.all_responses_csv")}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
handleDowndloadResponses(FilterDownload.ALL, "xlsx");
data-testid="fb__custom-filter-download-all-xlsx"
onClick={async () => {
await handleDownloadResponses(FilterDownload.ALL, "xlsx");
}}>
<p className="text-slate-700">{t("environments.surveys.summary.all_responses_excel")}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
handleDowndloadResponses(FilterDownload.FILTER, "csv");
data-testid="fb__custom-filter-download-filtered-csv"
onClick={async () => {
await handleDownloadResponses(FilterDownload.FILTER, "csv");
}}>
<p className="text-slate-700">{t("environments.surveys.summary.filtered_responses_csv")}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
handleDowndloadResponses(FilterDownload.FILTER, "xlsx");
data-testid="fb__custom-filter-download-filtered-xlsx"
onClick={async () => {
await handleDownloadResponses(FilterDownload.FILTER, "xlsx");
}}>
<p className="text-slate-700">{t("environments.surveys.summary.filtered_responses_excel")}</p>
</DropdownMenuItem>

View File

@@ -1,12 +0,0 @@
const Loading = () => {
return (
<div className="flex h-full w-full items-center justify-center">
<div className="flex h-1/2 w-1/4 flex-col">
<div className="ph-no-capture h-16 w-1/3 animate-pulse rounded-lg bg-slate-200 font-medium text-slate-900"></div>
<div className="ph-no-capture mt-4 h-full animate-pulse rounded-lg bg-slate-200 text-slate-900"></div>
</div>
</div>
);
};
export default Loading;

View File

@@ -1,59 +0,0 @@
import { getShortUrl } from "@/lib/shortUrl/service";
import { getMetadataForLinkSurvey } from "@/modules/survey/link/metadata";
import type { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import { logger } from "@formbricks/logger";
import { TShortUrl, ZShortUrlId } from "@formbricks/types/short-url";
export const generateMetadata = async (props): Promise<Metadata> => {
const params = await props.params;
if (!params.shortUrlId) {
notFound();
}
if (ZShortUrlId.safeParse(params.shortUrlId).success !== true) {
notFound();
}
try {
const shortUrl = await getShortUrl(params.shortUrlId);
if (!shortUrl) {
notFound();
}
const surveyId = shortUrl.url.substring(shortUrl.url.lastIndexOf("/") + 1);
return getMetadataForLinkSurvey(surveyId);
} catch (error) {
notFound();
}
};
const Page = async (props) => {
const params = await props.params;
if (!params.shortUrlId) {
notFound();
}
if (ZShortUrlId.safeParse(params.shortUrlId).success !== true) {
// return not found if unable to parse short url id
notFound();
}
let shortUrl: TShortUrl | null = null;
try {
shortUrl = await getShortUrl(params.shortUrlId);
} catch (error) {
logger.error(error, "Could not fetch short url");
notFound();
}
if (shortUrl) {
redirect(shortUrl.url);
}
notFound();
};
export default Page;

View File

@@ -253,7 +253,6 @@ export const mockResponse: TResponse = {
contactAttributes: {},
meta: {},
finished: true,
notes: [],
singleUseId: null,
tags: [],
displayId: null,

View File

@@ -90,7 +90,6 @@ const mockPipelineInput = {
personAttributes: {},
singleUseId: null,
personId: "person1",
notes: [],
tags: [],
variables: {
[variableId]: "Variable Value",

View File

@@ -1,10 +1,11 @@
import { responses } from "@/app/lib/api/response";
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key";
import { NextRequest } from "next/server";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
export const authenticateRequest = async (request: Request): Promise<TAuthenticationApiKey | null> => {
export const authenticateRequest = async (request: NextRequest): Promise<TAuthenticationApiKey | null> => {
const apiKey = request.headers.get("x-api-key");
if (!apiKey) return null;

View File

@@ -5,6 +5,7 @@ import { getSyncSurveys } from "@/app/api/v1/client/[environmentId]/app/sync/lib
import { replaceAttributeRecall } from "@/app/api/v1/client/[environmentId]/app/sync/lib/utils";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { getActionClasses } from "@/lib/actionClass/service";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getEnvironment, updateEnvironment } from "@/lib/environment/service";
@@ -21,168 +22,180 @@ import { COLOR_DEFAULTS } from "@/lib/styling/constants";
import { NextRequest, userAgent } from "next/server";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { ZJsPeopleUserIdInput } from "@formbricks/types/js";
import { TJsPeopleUserIdInput, ZJsPeopleUserIdInput } from "@formbricks/types/js";
import { TSurvey } from "@formbricks/types/surveys/types";
const validateInput = (
environmentId: string,
userId: string
): { isValid: true; data: TJsPeopleUserIdInput } | { isValid: false; error: Response } => {
const inputValidation = ZJsPeopleUserIdInput.safeParse({ environmentId, userId });
if (!inputValidation.success) {
return {
isValid: false,
error: responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
),
};
}
return { isValid: true, data: inputValidation.data };
};
const checkResponseLimit = async (environmentId: string): Promise<boolean> => {
if (!IS_FORMBRICKS_CLOUD) return false;
const organization = await getOrganizationByEnvironmentId(environmentId);
if (!organization) {
logger.error({ environmentId }, "Organization does not exist");
// fail closed if the organization does not exist
return true;
}
const currentResponseCount = await getMonthlyOrganizationResponseCount(organization.id);
const monthlyResponseLimit = organization.billing.limits.monthly.responses;
const isLimitReached = monthlyResponseLimit !== null && currentResponseCount >= monthlyResponseLimit;
if (isLimitReached) {
try {
await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, {
plan: organization.billing.plan,
limits: {
projects: null,
monthly: { responses: monthlyResponseLimit, miu: null },
},
});
} catch (error) {
logger.error({ error }, `Error sending plan limits reached event to Posthog`);
}
}
return isLimitReached;
};
export const OPTIONS = async (): Promise<Response> => {
return responses.successResponse({}, true);
};
export const GET = async (
request: NextRequest,
props: {
params: Promise<{
environmentId: string;
userId: string;
}>;
}
): Promise<Response> => {
const params = await props.params;
try {
const { device } = userAgent(request);
export const GET = withV1ApiWrapper({
handler: async ({
req,
props,
}: {
req: NextRequest;
props: { params: Promise<{ environmentId: string; userId: string }> };
}) => {
const params = await props.params;
try {
const { device } = userAgent(req);
// validate using zod
const inputValidation = ZJsPeopleUserIdInput.safeParse({
environmentId: params.environmentId,
userId: params.userId,
});
if (!inputValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
);
}
const { environmentId, userId } = inputValidation.data;
const environment = await getEnvironment(environmentId);
if (!environment) {
throw new Error("Environment does not exist");
}
const project = await getProjectByEnvironmentId(environmentId);
if (!project) {
throw new Error("Project not found");
}
if (!environment.appSetupCompleted) {
await Promise.all([
updateEnvironment(environment.id, { appSetupCompleted: true }),
capturePosthogEnvironmentEvent(environmentId, "app setup completed"),
]);
}
// check organization subscriptions
const organization = await getOrganizationByEnvironmentId(environmentId);
if (!organization) {
throw new Error("Organization does not exist");
}
// check if response limit is reached
let isAppSurveyResponseLimitReached = false;
if (IS_FORMBRICKS_CLOUD) {
const currentResponseCount = await getMonthlyOrganizationResponseCount(organization.id);
const monthlyResponseLimit = organization.billing.limits.monthly.responses;
isAppSurveyResponseLimitReached =
monthlyResponseLimit !== null && currentResponseCount >= monthlyResponseLimit;
if (isAppSurveyResponseLimitReached) {
try {
await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, {
plan: organization.billing.plan,
limits: {
projects: null,
monthly: {
responses: monthlyResponseLimit,
miu: null,
},
},
});
} catch (error) {
logger.error({ error, url: request.url }, `Error sending plan limits reached event to Posthog`);
}
// validate using zod
const validation = validateInput(params.environmentId, params.userId);
if (!validation.isValid) {
return { response: validation.error };
}
}
let contact = await getContactByUserId(environmentId, userId);
if (!contact) {
contact = await prisma.contact.create({
data: {
attributes: {
create: {
attributeKey: {
connect: {
key_environmentId: {
key: "userId",
environmentId,
const { environmentId, userId } = validation.data;
const environment = await getEnvironment(environmentId);
if (!environment) {
throw new Error("Environment does not exist");
}
const project = await getProjectByEnvironmentId(environmentId);
if (!project) {
throw new Error("Project not found");
}
if (!environment.appSetupCompleted) {
await Promise.all([
updateEnvironment(environment.id, { appSetupCompleted: true }),
capturePosthogEnvironmentEvent(environmentId, "app setup completed"),
]);
}
// check organization subscriptions and response limits
const isAppSurveyResponseLimitReached = await checkResponseLimit(environmentId);
let contact = await getContactByUserId(environmentId, userId);
if (!contact) {
contact = await prisma.contact.create({
data: {
attributes: {
create: {
attributeKey: {
connect: {
key_environmentId: {
key: "userId",
environmentId,
},
},
},
value: userId,
},
value: userId,
},
environment: { connect: { id: environmentId } },
},
environment: { connect: { id: environmentId } },
},
select: {
id: true,
attributes: { select: { attributeKey: { select: { key: true } }, value: true } },
},
});
select: {
id: true,
attributes: { select: { attributeKey: { select: { key: true } }, value: true } },
},
});
}
const contactAttributes = contact.attributes.reduce((acc, attribute) => {
acc[attribute.attributeKey.key] = attribute.value;
return acc;
}, {}) as Record<string, string>;
const [surveys, actionClasses] = await Promise.all([
getSyncSurveys(
environmentId,
contact.id,
contactAttributes,
device.type === "mobile" ? "phone" : "desktop"
),
getActionClasses(environmentId),
]);
const updatedProject: any = {
...project,
brandColor: project.styling.brandColor?.light ?? COLOR_DEFAULTS.brandColor,
...(project.styling.highlightBorderColor?.light && {
highlightBorderColor: project.styling.highlightBorderColor.light,
}),
};
const language = contactAttributes["language"];
// Scenario 1: Multi language and updated trigger action classes supported.
// Use the surveys as they are.
let transformedSurveys: TSurvey[] = surveys;
// creating state object
let state = {
surveys: !isAppSurveyResponseLimitReached
? transformedSurveys.map((survey) => replaceAttributeRecall(survey, contactAttributes))
: [],
actionClasses,
language,
project: updatedProject,
};
return {
response: responses.successResponse({ ...state }, true),
};
} catch (error) {
logger.error({ error, url: req.url }, "Error in GET /api/v1/client/[environmentId]/app/sync/[userId]");
return {
response: responses.internalServerErrorResponse(
"Unable to handle the request: " + error.message,
true
),
};
}
const contactAttributes = contact.attributes.reduce((acc, attribute) => {
acc[attribute.attributeKey.key] = attribute.value;
return acc;
}, {}) as Record<string, string>;
const [surveys, actionClasses] = await Promise.all([
getSyncSurveys(
environmentId,
contact.id,
contactAttributes,
device.type === "mobile" ? "phone" : "desktop"
),
getActionClasses(environmentId),
]);
if (!project) {
throw new Error("Project not found");
}
const updatedProject: any = {
...project,
brandColor: project.styling.brandColor?.light ?? COLOR_DEFAULTS.brandColor,
...(project.styling.highlightBorderColor?.light && {
highlightBorderColor: project.styling.highlightBorderColor.light,
}),
};
const language = contactAttributes["language"];
// Scenario 1: Multi language and updated trigger action classes supported.
// Use the surveys as they are.
let transformedSurveys: TSurvey[] = surveys;
// creating state object
let state = {
surveys: !isAppSurveyResponseLimitReached
? transformedSurveys.map((survey) => replaceAttributeRecall(survey, contactAttributes))
: [],
actionClasses,
language,
project: updatedProject,
};
return responses.successResponse({ ...state }, true);
} catch (error) {
logger.error(
{ error, url: request.url },
"Error in GET /api/v1/client/[environmentId]/app/sync/[userId]"
);
return responses.internalServerErrorResponse("Unable to handle the request: " + error.message, true);
}
};
},
});

View File

@@ -1,7 +1,9 @@
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { ZDisplayCreateInput } from "@formbricks/types/displays";
import { ResourceNotFoundError } from "@formbricks/types/errors";
@@ -23,40 +25,55 @@ export const OPTIONS = async (): Promise<Response> => {
);
};
export const POST = async (request: Request, context: Context): Promise<Response> => {
const params = await context.params;
const jsonInput = await request.json();
const inputValidation = ZDisplayCreateInput.safeParse({
...jsonInput,
environmentId: params.environmentId,
});
export const POST = withV1ApiWrapper({
handler: async ({ req, props }: { req: NextRequest; props: Context }) => {
const params = await props.params;
const jsonInput = await req.json();
const inputValidation = ZDisplayCreateInput.safeParse({
...jsonInput,
environmentId: params.environmentId,
});
if (!inputValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
);
}
if (inputValidation.data.userId) {
const isContactsEnabled = await getIsContactsEnabled();
if (!isContactsEnabled) {
return responses.forbiddenResponse("User identification is only available for enterprise users.", true);
if (!inputValidation.success) {
return {
response: responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
),
};
}
}
try {
const response = await createDisplay(inputValidation.data);
await capturePosthogEnvironmentEvent(inputValidation.data.environmentId, "display created");
return responses.successResponse(response, true);
} catch (error) {
if (error instanceof ResourceNotFoundError) {
return responses.notFoundResponse("Survey", inputValidation.data.surveyId);
} else {
logger.error({ error, url: request.url }, "Error in POST /api/v1/client/[environmentId]/displays");
return responses.internalServerErrorResponse("Something went wrong. Please try again.");
if (inputValidation.data.userId) {
const isContactsEnabled = await getIsContactsEnabled();
if (!isContactsEnabled) {
return {
response: responses.forbiddenResponse(
"User identification is only available for enterprise users.",
true
),
};
}
}
}
};
try {
const response = await createDisplay(inputValidation.data);
await capturePosthogEnvironmentEvent(inputValidation.data.environmentId, "display created");
return {
response: responses.successResponse(response, true),
};
} catch (error) {
if (error instanceof ResourceNotFoundError) {
return {
response: responses.notFoundResponse("Survey", inputValidation.data.surveyId),
};
} else {
logger.error({ error, url: req.url }, "Error in POST /api/v1/client/[environmentId]/displays");
return {
response: responses.internalServerErrorResponse("Something went wrong. Please try again."),
};
}
}
},
});

View File

@@ -1,5 +1,6 @@
import { getEnvironmentState } from "@/app/api/v1/client/[environmentId]/environment/lib/environmentState";
import { responses } from "@/app/lib/api/response";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { ResourceNotFoundError } from "@formbricks/types/errors";
@@ -16,60 +17,69 @@ export const OPTIONS = async (): Promise<Response> => {
);
};
export const GET = async (
request: NextRequest,
props: {
params: Promise<{
environmentId: string;
}>;
}
): Promise<Response> => {
const params = await props.params;
export const GET = withV1ApiWrapper({
handler: async ({
req,
props,
}: {
req: NextRequest;
props: { params: Promise<{ environmentId: string }> };
}) => {
const params = await props.params;
try {
// Simple validation for environmentId (faster than Zod for high-frequency endpoint)
if (!params.environmentId || typeof params.environmentId !== "string") {
return responses.badRequestResponse("Environment ID is required", undefined, true);
}
try {
// Simple validation for environmentId (faster than Zod for high-frequency endpoint)
if (typeof params.environmentId !== "string") {
return {
response: responses.badRequestResponse("Environment ID is required", undefined, true),
};
}
// Use optimized environment state fetcher with new caching approach
const environmentState = await getEnvironmentState(params.environmentId);
const { data } = environmentState;
// Use optimized environment state fetcher with new caching approach
const environmentState = await getEnvironmentState(params.environmentId);
const { data } = environmentState;
return responses.successResponse(
{
data,
expiresAt: new Date(Date.now() + 1000 * 60 * 60), // 1 hour for SDK to recheck
},
true,
// Optimized cache headers for Cloudflare CDN and browser caching
// max-age=3600: 1hr browser cache (per guidelines)
// s-maxage=1800: 30min Cloudflare cache (per guidelines)
// stale-while-revalidate=1800: 30min stale serving during revalidation
// stale-if-error=3600: 1hr stale serving on origin errors
"public, s-maxage=1800, max-age=3600, stale-while-revalidate=1800, stale-if-error=3600"
);
} catch (err) {
if (err instanceof ResourceNotFoundError) {
logger.warn(
return {
response: responses.successResponse(
{
data,
expiresAt: new Date(Date.now() + 1000 * 60 * 60), // 1 hour for SDK to recheck
},
true,
// Optimized cache headers for Cloudflare CDN and browser caching
// max-age=3600: 1hr browser cache (per guidelines)
// s-maxage=1800: 30min Cloudflare cache (per guidelines)
// stale-while-revalidate=1800: 30min stale serving during revalidation
// stale-if-error=3600: 1hr stale serving on origin errors
"public, s-maxage=1800, max-age=3600, stale-while-revalidate=1800, stale-if-error=3600"
),
};
} catch (err) {
if (err instanceof ResourceNotFoundError) {
logger.warn(
{
environmentId: params.environmentId,
resourceType: err.resourceType,
resourceId: err.resourceId,
},
"Resource not found in environment endpoint"
);
return {
response: responses.notFoundResponse(err.resourceType, err.resourceId),
};
}
logger.error(
{
error: err,
url: req.url,
environmentId: params.environmentId,
resourceType: err.resourceType,
resourceId: err.resourceId,
},
"Resource not found in environment endpoint"
"Error in GET /api/v1/client/[environmentId]/environment"
);
return responses.notFoundResponse(err.resourceType, err.resourceId);
return {
response: responses.internalServerErrorResponse(err.message, true),
};
}
logger.error(
{
error: err,
url: request.url,
environmentId: params.environmentId,
},
"Error in GET /api/v1/client/[environmentId]/environment"
);
return responses.internalServerErrorResponse(err.message, true);
}
};
},
});

View File

@@ -1,10 +1,12 @@
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines";
import { validateFileUploads } from "@/lib/fileValidation";
import { getResponse, updateResponse } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/question";
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { ZResponseUpdateInput } from "@formbricks/types/responses";
@@ -27,108 +29,135 @@ const handleDatabaseError = (error: Error, url: string, endpoint: string, respon
return responses.internalServerErrorResponse("Unknown error occurred", true);
};
export const PUT = async (
request: Request,
props: { params: Promise<{ responseId: string }> }
): Promise<Response> => {
const params = await props.params;
const { responseId } = params;
export const PUT = withV1ApiWrapper({
handler: async ({
req,
props,
}: {
req: NextRequest;
props: { params: Promise<{ responseId: string }> };
}) => {
const params = await props.params;
const { responseId } = params;
if (!responseId) {
return responses.badRequestResponse("Response ID is missing", undefined, true);
}
const responseUpdate = await request.json();
const inputValidation = ZResponseUpdateInput.safeParse(responseUpdate);
if (!inputValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
);
}
let response;
try {
response = await getResponse(responseId);
} catch (error) {
const endpoint = "PUT /api/v1/client/[environmentId]/responses/[responseId]";
return handleDatabaseError(error, request.url, endpoint, responseId);
}
if (response.finished) {
return responses.badRequestResponse("Response is already finished", undefined, true);
}
// get survey to get environmentId
let survey;
try {
survey = await getSurvey(response.surveyId);
} catch (error) {
const endpoint = "PUT /api/v1/client/[environmentId]/responses/[responseId]";
return handleDatabaseError(error, request.url, endpoint, responseId);
}
if (!validateFileUploads(inputValidation.data.data, survey.questions)) {
return responses.badRequestResponse("Invalid file upload response", undefined, true);
}
// Validate response data for "other" options exceeding character limit
const otherResponseInvalidQuestionId = validateOtherOptionLengthForMultipleChoice({
responseData: inputValidation.data.data,
surveyQuestions: survey.questions,
responseLanguage: inputValidation.data.language,
});
if (otherResponseInvalidQuestionId) {
return responses.badRequestResponse(
`Response exceeds character limit`,
{
questionId: otherResponseInvalidQuestionId,
},
true
);
}
// update response
let updatedResponse;
try {
updatedResponse = await updateResponse(responseId, inputValidation.data);
} catch (error) {
if (error instanceof ResourceNotFoundError) {
return responses.notFoundResponse("Response", responseId, true);
if (!responseId) {
return {
response: responses.badRequestResponse("Response ID is missing", undefined, true),
};
}
if (error instanceof InvalidInputError) {
return responses.badRequestResponse(error.message);
}
if (error instanceof DatabaseError) {
logger.error(
{ error, url: request.url },
"Error in PUT /api/v1/client/[environmentId]/responses/[responseId]"
);
return responses.internalServerErrorResponse(error.message);
}
}
// send response update to pipeline
// don't await to not block the response
sendToPipeline({
event: "responseUpdated",
environmentId: survey.environmentId,
surveyId: survey.id,
response: updatedResponse,
});
const responseUpdate = await req.json();
const inputValidation = ZResponseUpdateInput.safeParse(responseUpdate);
if (updatedResponse.finished) {
// send response to pipeline
if (!inputValidation.success) {
return {
response: responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
),
};
}
let response;
try {
response = await getResponse(responseId);
} catch (error) {
const endpoint = "PUT /api/v1/client/[environmentId]/responses/[responseId]";
return {
response: handleDatabaseError(error, req.url, endpoint, responseId),
};
}
if (response.finished) {
return {
response: responses.badRequestResponse("Response is already finished", undefined, true),
};
}
// get survey to get environmentId
let survey;
try {
survey = await getSurvey(response.surveyId);
} catch (error) {
const endpoint = "PUT /api/v1/client/[environmentId]/responses/[responseId]";
return {
response: handleDatabaseError(error, req.url, endpoint, responseId),
};
}
if (!validateFileUploads(inputValidation.data.data, survey.questions)) {
return {
response: responses.badRequestResponse("Invalid file upload response", undefined, true),
};
}
// Validate response data for "other" options exceeding character limit
const otherResponseInvalidQuestionId = validateOtherOptionLengthForMultipleChoice({
responseData: inputValidation.data.data,
surveyQuestions: survey.questions,
responseLanguage: inputValidation.data.language,
});
if (otherResponseInvalidQuestionId) {
return {
response: responses.badRequestResponse(
`Response exceeds character limit`,
{
questionId: otherResponseInvalidQuestionId,
},
true
),
};
}
// update response
let updatedResponse;
try {
updatedResponse = await updateResponse(responseId, inputValidation.data);
} catch (error) {
if (error instanceof ResourceNotFoundError) {
return {
response: responses.notFoundResponse("Response", responseId, true),
};
}
if (error instanceof InvalidInputError) {
return {
response: responses.badRequestResponse(error.message),
};
}
if (error instanceof DatabaseError) {
logger.error(
{ error, url: req.url },
"Error in PUT /api/v1/client/[environmentId]/responses/[responseId]"
);
return {
response: responses.internalServerErrorResponse(error.message),
};
}
}
// send response update to pipeline
// don't await to not block the response
sendToPipeline({
event: "responseFinished",
event: "responseUpdated",
environmentId: survey.environmentId,
surveyId: survey.id,
response: updatedResponse,
});
}
return responses.successResponse({}, true);
};
if (updatedResponse.finished) {
// send response to pipeline
// don't await to not block the response
sendToPipeline({
event: "responseFinished",
environmentId: survey.environmentId,
surveyId: survey.id,
response: updatedResponse,
});
}
return {
response: responses.successResponse({}, true),
};
},
});

View File

@@ -98,7 +98,6 @@ const mockResponsePrisma = {
language: null,
displayId: null,
tags: [],
notes: [],
};
describe("createResponse", () => {

View File

@@ -53,22 +53,6 @@ export const responseSelection = {
},
},
},
notes: {
select: {
id: true,
createdAt: true,
updatedAt: true,
text: true,
user: {
select: {
id: true,
name: true,
},
},
isResolved: true,
isEdited: true,
},
},
} satisfies Prisma.ResponseSelect;
export const createResponse = async (responseInput: TResponseInput): Promise<TResponse> => {

View File

@@ -1,11 +1,13 @@
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines";
import { validateFileUploads } from "@/lib/fileValidation";
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
import { getSurvey } from "@/lib/survey/service";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { headers } from "next/headers";
import { NextRequest } from "next/server";
import { UAParser } from "ua-parser-js";
import { logger } from "@formbricks/logger";
import { ZId } from "@formbricks/types/common";
@@ -29,121 +31,150 @@ export const OPTIONS = async (): Promise<Response> => {
);
};
export const POST = async (request: Request, context: Context): Promise<Response> => {
const params = await context.params;
const requestHeaders = await headers();
let responseInput;
try {
responseInput = await request.json();
} catch (error) {
return responses.badRequestResponse("Invalid JSON in request body", { error: error.message }, true);
}
const { environmentId } = params;
const environmentIdValidation = ZId.safeParse(environmentId);
const responseInputValidation = ZResponseInput.safeParse({ ...responseInput, environmentId });
if (!environmentIdValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(environmentIdValidation.error),
true
);
}
if (!responseInputValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(responseInputValidation.error),
true
);
}
const userAgent = request.headers.get("user-agent") || undefined;
const agent = new UAParser(userAgent);
const country =
requestHeaders.get("CF-IPCountry") ||
requestHeaders.get("X-Vercel-IP-Country") ||
requestHeaders.get("CloudFront-Viewer-Country") ||
undefined;
const responseInputData = responseInputValidation.data;
if (responseInputData.userId) {
const isContactsEnabled = await getIsContactsEnabled();
if (!isContactsEnabled) {
return responses.forbiddenResponse("User identification is only available for enterprise users.", true);
export const POST = withV1ApiWrapper({
handler: async ({ req, props }: { req: NextRequest; props: Context }) => {
const params = await props.params;
const requestHeaders = await headers();
let responseInput;
try {
responseInput = await req.json();
} catch (error) {
return {
response: responses.badRequestResponse(
"Invalid JSON in request body",
{ error: error.message },
true
),
};
}
}
// get and check survey
const survey = await getSurvey(responseInputData.surveyId);
if (!survey) {
return responses.notFoundResponse("Survey", responseInputData.surveyId, true);
}
if (survey.environmentId !== environmentId) {
return responses.badRequestResponse(
"Survey is part of another environment",
{
"survey.environmentId": survey.environmentId,
environmentId,
},
true
);
}
const { environmentId } = params;
const environmentIdValidation = ZId.safeParse(environmentId);
const responseInputValidation = ZResponseInput.safeParse({ ...responseInput, environmentId });
if (!validateFileUploads(responseInputData.data, survey.questions)) {
return responses.badRequestResponse("Invalid file upload response");
}
let response: TResponse;
try {
const meta: TResponseInput["meta"] = {
source: responseInputData?.meta?.source,
url: responseInputData?.meta?.url,
userAgent: {
browser: agent.getBrowser().name,
device: agent.getDevice().type || "desktop",
os: agent.getOS().name,
},
country: country,
action: responseInputData?.meta?.action,
};
response = await createResponse({
...responseInputData,
meta,
});
} catch (error) {
if (error instanceof InvalidInputError) {
return responses.badRequestResponse(error.message);
} else {
logger.error({ error, url: request.url }, "Error creating response");
return responses.internalServerErrorResponse(error.message);
if (!environmentIdValidation.success) {
return {
response: responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(environmentIdValidation.error),
true
),
};
}
}
sendToPipeline({
event: "responseCreated",
environmentId: survey.environmentId,
surveyId: response.surveyId,
response: response,
});
if (!responseInputValidation.success) {
return {
response: responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(responseInputValidation.error),
true
),
};
}
const userAgent = req.headers.get("user-agent") || undefined;
const agent = new UAParser(userAgent);
const country =
requestHeaders.get("CF-IPCountry") ||
requestHeaders.get("X-Vercel-IP-Country") ||
requestHeaders.get("CloudFront-Viewer-Country") ||
undefined;
const responseInputData = responseInputValidation.data;
if (responseInputData.userId) {
const isContactsEnabled = await getIsContactsEnabled();
if (!isContactsEnabled) {
return {
response: responses.forbiddenResponse(
"User identification is only available for enterprise users.",
true
),
};
}
}
// get and check survey
const survey = await getSurvey(responseInputData.surveyId);
if (!survey) {
return {
response: responses.notFoundResponse("Survey", responseInputData.surveyId, true),
};
}
if (survey.environmentId !== environmentId) {
return {
response: responses.badRequestResponse(
"Survey is part of another environment",
{
"survey.environmentId": survey.environmentId,
environmentId,
},
true
),
};
}
if (!validateFileUploads(responseInputData.data, survey.questions)) {
return {
response: responses.badRequestResponse("Invalid file upload response"),
};
}
let response: TResponse;
try {
const meta: TResponseInput["meta"] = {
source: responseInputData?.meta?.source,
url: responseInputData?.meta?.url,
userAgent: {
browser: agent.getBrowser().name,
device: agent.getDevice().type || "desktop",
os: agent.getOS().name,
},
country: country,
action: responseInputData?.meta?.action,
};
response = await createResponse({
...responseInputData,
meta,
});
} catch (error) {
if (error instanceof InvalidInputError) {
return {
response: responses.badRequestResponse(error.message),
};
} else {
logger.error({ error, url: req.url }, "Error creating response");
return {
response: responses.internalServerErrorResponse(error.message),
};
}
}
if (responseInput.finished) {
sendToPipeline({
event: "responseFinished",
event: "responseCreated",
environmentId: survey.environmentId,
surveyId: response.surveyId,
response: response,
});
}
await capturePosthogEnvironmentEvent(survey.environmentId, "response created", {
surveyId: response.surveyId,
surveyType: survey.type,
});
if (responseInput.finished) {
sendToPipeline({
event: "responseFinished",
environmentId: survey.environmentId,
surveyId: response.surveyId,
response: response,
});
}
return responses.successResponse({ id: response.id }, true);
};
await capturePosthogEnvironmentEvent(survey.environmentId, "response created", {
surveyId: response.surveyId,
surveyType: survey.type,
});
return {
response: responses.successResponse({ id: response.id }, true),
};
},
});

View File

@@ -2,6 +2,7 @@
// body -> should be a valid file object (buffer)
// method -> PUT (to be the same as the signedUrl method)
import { responses } from "@/app/lib/api/response";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { ENCRYPTION_KEY, UPLOADS_DIR } from "@/lib/constants";
import { validateLocalSignedUrl } from "@/lib/crypto";
import { validateFile } from "@/lib/fileValidation";
@@ -28,115 +29,150 @@ export const OPTIONS = async (): Promise<Response> => {
);
};
export const POST = async (req: NextRequest, context: Context): Promise<Response> => {
if (!ENCRYPTION_KEY) {
return responses.internalServerErrorResponse("Encryption key is not set");
}
const params = await context.params;
const environmentId = params.environmentId;
export const POST = withV1ApiWrapper({
handler: async ({ req, props }: { req: NextRequest; props: Context }) => {
if (!ENCRYPTION_KEY) {
return {
response: responses.internalServerErrorResponse("Encryption key is not set"),
};
}
const params = await props.params;
const environmentId = params.environmentId;
const accessType = "private"; // private files are accessible only by authorized users
const accessType = "private"; // private files are accessible only by authorized users
const jsonInput = await req.json();
const fileType = jsonInput.fileType as string;
const encodedFileName = jsonInput.fileName as string;
const surveyId = jsonInput.surveyId as string;
const signedSignature = jsonInput.signature as string;
const signedUuid = jsonInput.uuid as string;
const signedTimestamp = jsonInput.timestamp as string;
const jsonInput = await req.json();
const fileType = jsonInput.fileType as string;
const encodedFileName = jsonInput.fileName as string;
const surveyId = jsonInput.surveyId as string;
const signedSignature = jsonInput.signature as string;
const signedUuid = jsonInput.uuid as string;
const signedTimestamp = jsonInput.timestamp as string;
if (!fileType) {
return responses.badRequestResponse("contentType is required");
}
if (!fileType) {
return {
response: responses.badRequestResponse("contentType is required"),
};
}
if (!encodedFileName) {
return responses.badRequestResponse("fileName is required");
}
if (!encodedFileName) {
return {
response: responses.badRequestResponse("fileName is required"),
};
}
if (!surveyId) {
return responses.badRequestResponse("surveyId is required");
}
if (!surveyId) {
return {
response: responses.badRequestResponse("surveyId is required"),
};
}
if (!signedSignature) {
return responses.unauthorizedResponse();
}
if (!signedSignature) {
return {
response: responses.unauthorizedResponse(),
};
}
if (!signedUuid) {
return responses.unauthorizedResponse();
}
if (!signedUuid) {
return {
response: responses.unauthorizedResponse(),
};
}
if (!signedTimestamp) {
return responses.unauthorizedResponse();
}
if (!signedTimestamp) {
return {
response: responses.unauthorizedResponse(),
};
}
const [survey, organization] = await Promise.all([
getSurvey(surveyId),
getOrganizationByEnvironmentId(environmentId),
]);
const [survey, organization] = await Promise.all([
getSurvey(surveyId),
getOrganizationByEnvironmentId(environmentId),
]);
if (!survey) {
return responses.notFoundResponse("Survey", surveyId);
}
if (!survey) {
return {
response: responses.notFoundResponse("Survey", surveyId),
};
}
if (!organization) {
return responses.notFoundResponse("OrganizationByEnvironmentId", environmentId);
}
if (!organization) {
return {
response: responses.notFoundResponse("OrganizationByEnvironmentId", environmentId),
};
}
const fileName = decodeURIComponent(encodedFileName);
const fileName = decodeURIComponent(encodedFileName);
// Perform server-side file validation again
// This is crucial as attackers could bypass the initial validation and directly call this endpoint
const fileValidation = validateFile(fileName, fileType);
if (!fileValidation.valid) {
return responses.badRequestResponse(fileValidation.error ?? "Invalid file", { fileName, fileType });
}
// Perform server-side file validation again
// This is crucial as attackers could bypass the initial validation and directly call this endpoint
const fileValidation = validateFile(fileName, fileType);
if (!fileValidation.valid) {
return {
response: responses.badRequestResponse(fileValidation.error ?? "Invalid file", {
fileName,
fileType,
}),
};
}
// validate signature
const validated = validateLocalSignedUrl(
signedUuid,
fileName,
environmentId,
fileType,
Number(signedTimestamp),
signedSignature,
ENCRYPTION_KEY
);
if (!validated) {
return responses.unauthorizedResponse();
}
const base64String = jsonInput.fileBase64String as string;
const buffer = Buffer.from(base64String.split(",")[1], "base64");
const file = new Blob([buffer], { type: fileType });
if (!file) {
return responses.badRequestResponse("fileBuffer is required");
}
try {
const isBiggerFileUploadAllowed = await getBiggerUploadFileSizePermission(organization.billing.plan);
const bytes = await file.arrayBuffer();
const fileBuffer = Buffer.from(bytes);
await putFileToLocalStorage(
// validate signature
const validated = validateLocalSignedUrl(
signedUuid,
fileName,
fileBuffer,
accessType,
environmentId,
UPLOADS_DIR,
isBiggerFileUploadAllowed
fileType,
Number(signedTimestamp),
signedSignature,
ENCRYPTION_KEY
);
return responses.successResponse({
message: "File uploaded successfully",
});
} catch (err) {
logger.error({ error: err, url: req.url }, "Error in POST /api/v1/client/[environmentId]/upload");
if (err.name === "FileTooLargeError") {
return responses.badRequestResponse(err.message);
if (!validated) {
return {
response: responses.unauthorizedResponse(),
};
}
return responses.internalServerErrorResponse("File upload failed");
}
};
const base64String = jsonInput.fileBase64String as string;
const buffer = Buffer.from(base64String.split(",")[1], "base64");
const file = new Blob([buffer], { type: fileType });
if (!file) {
return {
response: responses.badRequestResponse("fileBuffer is required"),
};
}
try {
const isBiggerFileUploadAllowed = await getBiggerUploadFileSizePermission(organization.billing.plan);
const bytes = await file.arrayBuffer();
const fileBuffer = Buffer.from(bytes);
await putFileToLocalStorage(
fileName,
fileBuffer,
accessType,
environmentId,
UPLOADS_DIR,
isBiggerFileUploadAllowed
);
return {
response: responses.successResponse({
message: "File uploaded successfully",
}),
};
} catch (err) {
logger.error({ error: err, url: req.url }, "Error in POST /api/v1/client/[environmentId]/upload");
if (err.name === "FileTooLargeError") {
return {
response: responses.badRequestResponse(err.message),
};
}
return {
response: responses.internalServerErrorResponse("File upload failed"),
};
}
},
});

View File

@@ -1,5 +1,6 @@
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { validateFile } from "@/lib/fileValidation";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getSurvey } from "@/lib/survey/service";
@@ -30,46 +31,62 @@ export const OPTIONS = async (): Promise<Response> => {
// use this to let users upload files to a survey for example
// this api endpoint will return a signed url for uploading the file to s3 and another url for uploading file to the local storage
export const POST = async (req: NextRequest, context: Context): Promise<Response> => {
const params = await context.params;
const environmentId = params.environmentId;
export const POST = withV1ApiWrapper({
handler: async ({ req, props }: { req: NextRequest; props: Context }) => {
const params = await props.params;
const environmentId = params.environmentId;
const jsonInput = await req.json();
const inputValidation = ZUploadFileRequest.safeParse({
...jsonInput,
environmentId,
});
const jsonInput = await req.json();
const inputValidation = ZUploadFileRequest.safeParse({
...jsonInput,
environmentId,
});
if (!inputValidation.success) {
return responses.badRequestResponse(
"Invalid request",
transformErrorToDetails(inputValidation.error),
true
);
}
if (!inputValidation.success) {
return {
response: responses.badRequestResponse(
"Invalid request",
transformErrorToDetails(inputValidation.error),
true
),
};
}
const { fileName, fileType, surveyId } = inputValidation.data;
const { fileName, fileType, surveyId } = inputValidation.data;
// Perform server-side file validation
const fileValidation = validateFile(fileName, fileType);
if (!fileValidation.valid) {
return responses.badRequestResponse(fileValidation.error ?? "Invalid file", { fileName, fileType }, true);
}
// Perform server-side file validation
const fileValidation = validateFile(fileName, fileType);
if (!fileValidation.valid) {
return {
response: responses.badRequestResponse(
fileValidation.error ?? "Invalid file",
{ fileName, fileType },
true
),
};
}
const [survey, organization] = await Promise.all([
getSurvey(surveyId),
getOrganizationByEnvironmentId(environmentId),
]);
const [survey, organization] = await Promise.all([
getSurvey(surveyId),
getOrganizationByEnvironmentId(environmentId),
]);
if (!survey) {
return responses.notFoundResponse("Survey", surveyId);
}
if (!survey) {
return {
response: responses.notFoundResponse("Survey", surveyId),
};
}
if (!organization) {
return responses.notFoundResponse("OrganizationByEnvironmentId", environmentId);
}
if (!organization) {
return {
response: responses.notFoundResponse("OrganizationByEnvironmentId", environmentId),
};
}
const isBiggerFileUploadAllowed = await getBiggerUploadFileSizePermission(organization.billing.plan);
const isBiggerFileUploadAllowed = await getBiggerUploadFileSizePermission(organization.billing.plan);
return await uploadPrivateFile(fileName, environmentId, fileType, isBiggerFileUploadAllowed);
};
return {
response: await uploadPrivateFile(fileName, environmentId, fileType, isBiggerFileUploadAllowed),
};
},
});

View File

@@ -1,10 +1,9 @@
import { responses } from "@/app/lib/api/response";
import { TSessionAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { fetchAirtableAuthToken } from "@/lib/airtable/service";
import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@/lib/constants";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { createOrUpdateIntegration } from "@/lib/integration/service";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getServerSession } from "next-auth";
import { NextRequest } from "next/server";
import * as z from "zod";
import { logger } from "@formbricks/logger";
@@ -21,65 +20,83 @@ const getEmail = async (token: string) => {
return z.string().parse(res_?.email);
};
export const GET = async (req: NextRequest) => {
const url = req.url;
const queryParams = new URLSearchParams(url.split("?")[1]); // Split the URL and get the query parameters
const environmentId = queryParams.get("state"); // Get the value of the 'state' parameter
const code = queryParams.get("code");
const session = await getServerSession(authOptions);
export const GET = withV1ApiWrapper({
handler: async ({
req,
authentication,
}: {
req: NextRequest;
authentication: NonNullable<TSessionAuthentication>;
}) => {
const url = req.url;
const queryParams = new URLSearchParams(url.split("?")[1]); // Split the URL and get the query parameters
const environmentId = queryParams.get("state"); // Get the value of the 'state' parameter
const code = queryParams.get("code");
if (!environmentId) {
return responses.badRequestResponse("Invalid environmentId");
}
if (!session) {
return responses.notAuthenticatedResponse();
}
if (code && typeof code !== "string") {
return responses.badRequestResponse("`code` must be a string");
}
const canUserAccessEnvironment = await hasUserEnvironmentAccess(session?.user.id, environmentId);
if (!canUserAccessEnvironment) {
return responses.unauthorizedResponse();
}
const client_id = AIRTABLE_CLIENT_ID;
const redirect_uri = WEBAPP_URL + "/api/v1/integrations/airtable/callback";
const code_verifier = Buffer.from(environmentId + session.user.id + environmentId).toString("base64");
if (!client_id) return responses.internalServerErrorResponse("Airtable client id is missing");
if (!redirect_uri) return responses.internalServerErrorResponse("Airtable redirect url is missing");
const formData = {
grant_type: "authorization_code",
code,
redirect_uri,
client_id,
code_verifier,
};
try {
const key = await fetchAirtableAuthToken(formData);
if (!key) {
return responses.notFoundResponse("airtable auth token", key);
if (!environmentId) {
return {
response: responses.badRequestResponse("Invalid environmentId"),
};
}
const email = await getEmail(key.access_token);
const airtableIntegrationInput = {
type: "airtable" as "airtable",
environment: environmentId,
config: {
key,
data: [],
email,
},
if (!code) {
return {
response: responses.badRequestResponse("`code` is missing"),
};
}
const canUserAccessEnvironment = await hasUserEnvironmentAccess(authentication.user.id, environmentId);
if (!canUserAccessEnvironment) {
return {
response: responses.unauthorizedResponse(),
};
}
const client_id = AIRTABLE_CLIENT_ID;
const redirect_uri = WEBAPP_URL + "/api/v1/integrations/airtable/callback";
const code_verifier = Buffer.from(environmentId + authentication.user.id + environmentId).toString(
"base64"
);
if (!client_id)
return {
response: responses.internalServerErrorResponse("Airtable client id is missing"),
};
const formData = {
grant_type: "authorization_code",
code,
redirect_uri,
client_id,
code_verifier,
};
await createOrUpdateIntegration(environmentId, airtableIntegrationInput);
return Response.redirect(`${WEBAPP_URL}/environments/${environmentId}/integrations/airtable`);
} catch (error) {
logger.error({ error, url: req.url }, "Error in GET /api/v1/integrations/airtable/callback");
responses.internalServerErrorResponse(error);
}
responses.badRequestResponse("unknown error occurred");
};
try {
const key = await fetchAirtableAuthToken(formData);
if (!key) {
return {
response: responses.notFoundResponse("airtable auth token", key),
};
}
const email = await getEmail(key.access_token);
const airtableIntegrationInput = {
type: "airtable" as "airtable",
environment: environmentId,
config: {
key,
data: [],
email,
},
};
await createOrUpdateIntegration(environmentId, airtableIntegrationInput);
return {
response: Response.redirect(`${WEBAPP_URL}/environments/${environmentId}/integrations/airtable`),
};
} catch (error) {
logger.error({ error, url: req.url }, "Error in GET /api/v1/integrations/airtable/callback");
return {
response: responses.internalServerErrorResponse(error),
};
}
},
});

View File

@@ -1,54 +1,66 @@
import { responses } from "@/app/lib/api/response";
import { TSessionAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@/lib/constants";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { authOptions } from "@/modules/auth/lib/authOptions";
import crypto from "crypto";
import { getServerSession } from "next-auth";
import { NextRequest } from "next/server";
const scope = `data.records:read data.records:write schema.bases:read schema.bases:write user.email:read`;
export const GET = async (req: NextRequest) => {
const environmentId = req.headers.get("environmentId");
const session = await getServerSession(authOptions);
export const GET = withV1ApiWrapper({
handler: async ({
req,
authentication,
}: {
req: NextRequest;
authentication: NonNullable<TSessionAuthentication>;
}) => {
const environmentId = req.headers.get("environmentId");
if (!environmentId) {
return responses.badRequestResponse("environmentId is missing");
}
if (!environmentId) {
return {
response: responses.badRequestResponse("environmentId is missing"),
};
}
if (!session) {
return responses.notAuthenticatedResponse();
}
const canUserAccessEnvironment = await hasUserEnvironmentAccess(authentication.user.id, environmentId);
if (!canUserAccessEnvironment) {
return {
response: responses.unauthorizedResponse(),
};
}
const canUserAccessEnvironment = await hasUserEnvironmentAccess(session?.user.id, environmentId);
if (!canUserAccessEnvironment) {
return responses.unauthorizedResponse();
}
const client_id = AIRTABLE_CLIENT_ID;
const redirect_uri = WEBAPP_URL + "/api/v1/integrations/airtable/callback";
if (!client_id)
return {
response: responses.internalServerErrorResponse("Airtable client id is missing"),
};
const codeVerifier = Buffer.from(environmentId + authentication.user.id + environmentId).toString(
"base64"
);
const client_id = AIRTABLE_CLIENT_ID;
const redirect_uri = WEBAPP_URL + "/api/v1/integrations/airtable/callback";
if (!client_id) return responses.internalServerErrorResponse("Airtable client id is missing");
if (!redirect_uri) return responses.internalServerErrorResponse("Airtable redirect url is missing");
const codeVerifier = Buffer.from(environmentId + session.user.id + environmentId).toString("base64");
const codeChallengeMethod = "S256";
const codeChallenge = crypto
.createHash("sha256")
.update(codeVerifier) // hash the code verifier with the sha256 algorithm
.digest("base64") // base64 encode, needs to be transformed to base64url
.replace(/=/g, "") // remove =
.replace(/\+/g, "-") // replace + with -
.replace(/\//g, "_"); // replace / with _ now base64url encoded
const codeChallengeMethod = "S256";
const codeChallenge = crypto
.createHash("sha256")
.update(codeVerifier) // hash the code verifier with the sha256 algorithm
.digest("base64") // base64 encode, needs to be transformed to base64url
.replace(/=/g, "") // remove =
.replace(/\+/g, "-") // replace + with -
.replace(/\//g, "_"); // replace / with _ now base64url encoded
const authUrl = new URL("https://airtable.com/oauth2/v1/authorize");
const authUrl = new URL("https://airtable.com/oauth2/v1/authorize");
authUrl.searchParams.append("client_id", client_id);
authUrl.searchParams.append("redirect_uri", redirect_uri);
authUrl.searchParams.append("state", environmentId);
authUrl.searchParams.append("scope", scope);
authUrl.searchParams.append("response_type", "code");
authUrl.searchParams.append("code_challenge_method", codeChallengeMethod);
authUrl.searchParams.append("code_challenge", codeChallenge);
authUrl.searchParams.append("client_id", client_id);
authUrl.searchParams.append("redirect_uri", redirect_uri);
authUrl.searchParams.append("state", environmentId);
authUrl.searchParams.append("scope", scope);
authUrl.searchParams.append("response_type", "code");
authUrl.searchParams.append("code_challenge_method", codeChallengeMethod);
authUrl.searchParams.append("code_challenge", codeChallenge);
return responses.successResponse({ authUrl: authUrl.toString() });
};
return {
response: responses.successResponse({ authUrl: authUrl.toString() }),
};
},
});

View File

@@ -1,43 +1,55 @@
import { responses } from "@/app/lib/api/response";
import { TSessionAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { getTables } from "@/lib/airtable/service";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { getIntegrationByType } from "@/lib/integration/service";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getServerSession } from "next-auth";
import { NextRequest } from "next/server";
import * as z from "zod";
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
export const GET = async (req: NextRequest) => {
const url = req.url;
const environmentId = req.headers.get("environmentId");
const queryParams = new URLSearchParams(url.split("?")[1]);
const session = await getServerSession(authOptions);
const baseId = z.string().safeParse(queryParams.get("baseId"));
export const GET = withV1ApiWrapper({
handler: async ({
req,
authentication,
}: {
req: NextRequest;
authentication: NonNullable<TSessionAuthentication>;
}) => {
const url = req.url;
const environmentId = req.headers.get("environmentId");
const queryParams = new URLSearchParams(url.split("?")[1]);
const baseId = z.string().safeParse(queryParams.get("baseId"));
if (!baseId.success) {
return responses.badRequestResponse("Base Id is Required");
}
if (!baseId.success) {
return {
response: responses.badRequestResponse("Base Id is Required"),
};
}
if (!session) {
return responses.notAuthenticatedResponse();
}
if (!environmentId) {
return {
response: responses.badRequestResponse("environmentId is missing"),
};
}
if (!environmentId) {
return responses.badRequestResponse("environmentId is missing");
}
const canUserAccessEnvironment = await hasUserEnvironmentAccess(authentication.user.id, environmentId);
if (!canUserAccessEnvironment) {
return {
response: responses.unauthorizedResponse(),
};
}
const canUserAccessEnvironment = await hasUserEnvironmentAccess(session?.user.id, environmentId);
if (!canUserAccessEnvironment || !environmentId) {
return responses.unauthorizedResponse();
}
const integration = (await getIntegrationByType(environmentId, "airtable")) as TIntegrationAirtable;
const integration = (await getIntegrationByType(environmentId, "airtable")) as TIntegrationAirtable;
if (!integration) {
return {
response: responses.notFoundResponse("Integration not found", environmentId),
};
}
if (!integration) {
return responses.notFoundResponse("Integration not found", environmentId);
}
const tables = await getTables(integration.config.key, baseId.data);
return responses.successResponse(tables);
};
const tables = await getTables(integration.config.key, baseId.data);
return {
response: responses.successResponse(tables),
};
},
});

View File

@@ -1,4 +1,5 @@
import { responses } from "@/app/lib/api/response";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import {
ENCRYPTION_KEY,
NOTION_OAUTH_CLIENT_ID,
@@ -11,70 +12,93 @@ import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integrati
import { NextRequest } from "next/server";
import { TIntegrationNotionConfigData, TIntegrationNotionInput } from "@formbricks/types/integration/notion";
export const GET = async (req: NextRequest) => {
const url = req.url;
const queryParams = new URLSearchParams(url.split("?")[1]); // Split the URL and get the query parameters
const environmentId = queryParams.get("state"); // Get the value of the 'state' parameter
const code = queryParams.get("code");
const error = queryParams.get("error");
export const GET = withV1ApiWrapper({
handler: async ({ req }: { req: NextRequest }) => {
const url = req.url;
const queryParams = new URLSearchParams(url.split("?")[1]); // Split the URL and get the query parameters
const environmentId = queryParams.get("state"); // Get the value of the 'state' parameter
const code = queryParams.get("code");
const error = queryParams.get("error");
if (!environmentId) {
return responses.badRequestResponse("Invalid environmentId");
}
if (!environmentId) {
return {
response: responses.badRequestResponse("Invalid environmentId"),
};
}
if (code && typeof code !== "string") {
return responses.badRequestResponse("`code` must be a string");
}
if (code && typeof code !== "string") {
return {
response: responses.badRequestResponse("`code` must be a string"),
};
}
const client_id = NOTION_OAUTH_CLIENT_ID;
const client_secret = NOTION_OAUTH_CLIENT_SECRET;
const redirect_uri = NOTION_REDIRECT_URI;
if (!client_id) return responses.internalServerErrorResponse("Notion client id is missing");
if (!redirect_uri) return responses.internalServerErrorResponse("Notion redirect url is missing");
if (!client_secret) return responses.internalServerErrorResponse("Notion client secret is missing");
if (code) {
// encode in base 64
const encoded = Buffer.from(`${client_id}:${client_secret}`).toString("base64");
const client_id = NOTION_OAUTH_CLIENT_ID;
const client_secret = NOTION_OAUTH_CLIENT_SECRET;
const redirect_uri = NOTION_REDIRECT_URI;
if (!client_id)
return {
response: responses.internalServerErrorResponse("Notion client id is missing"),
};
if (!redirect_uri)
return {
response: responses.internalServerErrorResponse("Notion redirect url is missing"),
};
if (!client_secret)
return {
response: responses.internalServerErrorResponse("Notion client secret is missing"),
};
if (code) {
// encode in base 64
const encoded = Buffer.from(`${client_id}:${client_secret}`).toString("base64");
const response = await fetch("https://api.notion.com/v1/oauth/token", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Basic ${encoded}`,
},
body: JSON.stringify({
grant_type: "authorization_code",
code: code,
redirect_uri: redirect_uri,
}),
});
const response = await fetch("https://api.notion.com/v1/oauth/token", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Basic ${encoded}`,
},
body: JSON.stringify({
grant_type: "authorization_code",
code: code,
redirect_uri: redirect_uri,
}),
});
const tokenData = await response.json();
const encryptedAccessToken = symmetricEncrypt(tokenData.access_token, ENCRYPTION_KEY!);
tokenData.access_token = encryptedAccessToken;
const tokenData = await response.json();
const encryptedAccessToken = symmetricEncrypt(tokenData.access_token, ENCRYPTION_KEY);
tokenData.access_token = encryptedAccessToken;
const notionIntegration: TIntegrationNotionInput = {
type: "notion" as "notion",
config: {
key: tokenData,
data: [],
},
const notionIntegration: TIntegrationNotionInput = {
type: "notion" as "notion",
config: {
key: tokenData,
data: [],
},
};
const existingIntegration = await getIntegrationByType(environmentId, "notion");
if (existingIntegration) {
notionIntegration.config.data = existingIntegration.config.data as TIntegrationNotionConfigData[];
}
const result = await createOrUpdateIntegration(environmentId, notionIntegration);
if (result) {
return {
response: Response.redirect(`${WEBAPP_URL}/environments/${environmentId}/integrations/notion`),
};
}
} else if (error) {
return {
response: Response.redirect(
`${WEBAPP_URL}/environments/${environmentId}/integrations/notion?error=${error}`
),
};
}
return {
response: responses.badRequestResponse("Missing code or error parameter"),
};
const existingIntegration = await getIntegrationByType(environmentId, "notion");
if (existingIntegration) {
notionIntegration.config.data = existingIntegration.config.data as TIntegrationNotionConfigData[];
}
const result = await createOrUpdateIntegration(environmentId, notionIntegration);
if (result) {
return Response.redirect(`${WEBAPP_URL}/environments/${environmentId}/integrations/notion`);
}
} else if (error) {
return Response.redirect(
`${WEBAPP_URL}/environments/${environmentId}/integrations/notion?error=${error}`
);
}
};
},
});

View File

@@ -1,4 +1,5 @@
import { responses } from "@/app/lib/api/response";
import { TSessionAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import {
NOTION_AUTH_URL,
NOTION_OAUTH_CLIENT_ID,
@@ -6,35 +7,54 @@ import {
NOTION_REDIRECT_URI,
} from "@/lib/constants";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getServerSession } from "next-auth";
import { NextRequest } from "next/server";
export const GET = async (req: NextRequest) => {
const environmentId = req.headers.get("environmentId");
const session = await getServerSession(authOptions);
export const GET = withV1ApiWrapper({
handler: async ({
req,
authentication,
}: {
req: NextRequest;
authentication: NonNullable<TSessionAuthentication>;
}) => {
const environmentId = req.headers.get("environmentId");
if (!environmentId) {
return responses.badRequestResponse("environmentId is missing");
}
if (!environmentId) {
return {
response: responses.badRequestResponse("environmentId is missing"),
};
}
if (!session) {
return responses.notAuthenticatedResponse();
}
const canUserAccessEnvironment = await hasUserEnvironmentAccess(authentication.user.id, environmentId);
if (!canUserAccessEnvironment) {
return {
response: responses.unauthorizedResponse(),
};
}
const canUserAccessEnvironment = await hasUserEnvironmentAccess(session?.user.id, environmentId);
if (!canUserAccessEnvironment) {
return responses.unauthorizedResponse();
}
const client_id = NOTION_OAUTH_CLIENT_ID;
const client_secret = NOTION_OAUTH_CLIENT_SECRET;
const auth_url = NOTION_AUTH_URL;
const redirect_uri = NOTION_REDIRECT_URI;
if (!client_id)
return {
response: responses.internalServerErrorResponse("Notion client id is missing"),
};
if (!redirect_uri)
return {
response: responses.internalServerErrorResponse("Notion redirect url is missing"),
};
if (!client_secret)
return {
response: responses.internalServerErrorResponse("Notion client secret is missing"),
};
if (!auth_url)
return {
response: responses.internalServerErrorResponse("Notion auth url is missing"),
};
const client_id = NOTION_OAUTH_CLIENT_ID;
const client_secret = NOTION_OAUTH_CLIENT_SECRET;
const auth_url = NOTION_AUTH_URL;
const redirect_uri = NOTION_REDIRECT_URI;
if (!client_id) return responses.internalServerErrorResponse("Notion client id is missing");
if (!redirect_uri) return responses.internalServerErrorResponse("Notion redirect url is missing");
if (!client_secret) return responses.internalServerErrorResponse("Notion client secret is missing");
if (!auth_url) return responses.internalServerErrorResponse("Notion auth url is missing");
return responses.successResponse({ authUrl: `${auth_url}&state=${environmentId}` });
};
return {
response: responses.successResponse({ authUrl: `${auth_url}&state=${environmentId}` }),
};
},
});

View File

@@ -1,4 +1,5 @@
import { responses } from "@/app/lib/api/response";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@/lib/constants";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
import { NextRequest } from "next/server";
@@ -8,79 +9,103 @@ import {
TIntegrationSlackCredential,
} from "@formbricks/types/integration/slack";
export const GET = async (req: NextRequest) => {
const url = req.url;
const queryParams = new URLSearchParams(url.split("?")[1]); // Split the URL and get the query parameters
const environmentId = queryParams.get("state"); // Get the value of the 'state' parameter
const code = queryParams.get("code");
const error = queryParams.get("error");
export const GET = withV1ApiWrapper({
handler: async ({ req }: { req: NextRequest }) => {
const url = req.url;
const queryParams = new URLSearchParams(url.split("?")[1]); // Split the URL and get the query parameters
const environmentId = queryParams.get("state"); // Get the value of the 'state' parameter
const code = queryParams.get("code");
const error = queryParams.get("error");
if (!environmentId) {
return responses.badRequestResponse("Invalid environmentId");
}
if (code && typeof code !== "string") {
return responses.badRequestResponse("`code` must be a string");
}
if (!SLACK_CLIENT_ID) return responses.internalServerErrorResponse("Slack client id is missing");
if (!SLACK_CLIENT_SECRET) return responses.internalServerErrorResponse("Slack client secret is missing");
const formData = {
code,
client_id: SLACK_CLIENT_ID,
client_secret: SLACK_CLIENT_SECRET,
};
const formBody: string[] = [];
for (const property in formData) {
const encodedKey = encodeURIComponent(property);
const encodedValue = encodeURIComponent(formData[property]);
formBody.push(encodedKey + "=" + encodedValue);
}
const bodyString = formBody.join("&");
if (code) {
const response = await fetch("https://slack.com/api/oauth.v2.access", {
method: "POST",
body: bodyString,
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
const data = await response.json();
if (!data.ok) {
return responses.badRequestResponse(data.error);
if (!environmentId) {
return {
response: responses.badRequestResponse("Invalid environmentId"),
};
}
const slackCredentials: TIntegrationSlackCredential = {
app_id: data.app_id,
authed_user: data.authed_user,
token_type: data.token_type,
access_token: data.access_token,
bot_user_id: data.bot_user_id,
team: data.team,
};
const slackIntegration = await getIntegrationByType(environmentId, "slack");
const slackConfiguration: TIntegrationSlackConfig = {
data: (slackIntegration?.config.data as TIntegrationSlackConfigData[]) ?? [],
key: slackCredentials,
};
const integration = {
type: "slack" as "slack",
environment: environmentId,
config: slackConfiguration,
};
const result = await createOrUpdateIntegration(environmentId, integration);
if (result) {
return Response.redirect(`${WEBAPP_URL}/environments/${environmentId}/integrations/slack`);
if (code && typeof code !== "string") {
return {
response: responses.badRequestResponse("`code` must be a string"),
};
}
} else if (error) {
return Response.redirect(`${WEBAPP_URL}/environments/${environmentId}/integrations/slack?error=${error}`);
}
};
if (!SLACK_CLIENT_ID)
return {
response: responses.internalServerErrorResponse("Slack client id is missing"),
};
if (!SLACK_CLIENT_SECRET)
return {
response: responses.internalServerErrorResponse("Slack client secret is missing"),
};
const formData = {
code,
client_id: SLACK_CLIENT_ID,
client_secret: SLACK_CLIENT_SECRET,
};
const formBody: string[] = [];
for (const property in formData) {
const encodedKey = encodeURIComponent(property);
const encodedValue = encodeURIComponent(formData[property]);
formBody.push(encodedKey + "=" + encodedValue);
}
const bodyString = formBody.join("&");
if (code) {
const response = await fetch("https://slack.com/api/oauth.v2.access", {
method: "POST",
body: bodyString,
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
const data = await response.json();
if (!data.ok) {
return {
response: responses.badRequestResponse(data.error),
};
}
const slackCredentials: TIntegrationSlackCredential = {
app_id: data.app_id,
authed_user: data.authed_user,
token_type: data.token_type,
access_token: data.access_token,
bot_user_id: data.bot_user_id,
team: data.team,
};
const slackIntegration = await getIntegrationByType(environmentId, "slack");
const slackConfiguration: TIntegrationSlackConfig = {
data: (slackIntegration?.config.data as TIntegrationSlackConfigData[]) ?? [],
key: slackCredentials,
};
const integration = {
type: "slack" as "slack",
environment: environmentId,
config: slackConfiguration,
};
const result = await createOrUpdateIntegration(environmentId, integration);
if (result) {
return {
response: Response.redirect(`${WEBAPP_URL}/environments/${environmentId}/integrations/slack`),
};
}
} else if (error) {
return {
response: Response.redirect(
`${WEBAPP_URL}/environments/${environmentId}/integrations/slack?error=${error}`
),
};
}
return {
response: responses.badRequestResponse("Missing code or error parameter"),
};
},
});

View File

@@ -1,30 +1,47 @@
import { responses } from "@/app/lib/api/response";
import { TSessionAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { SLACK_AUTH_URL, SLACK_CLIENT_ID, SLACK_CLIENT_SECRET } from "@/lib/constants";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getServerSession } from "next-auth";
import { NextRequest } from "next/server";
export const GET = async (req: NextRequest) => {
const environmentId = req.headers.get("environmentId");
const session = await getServerSession(authOptions);
export const GET = withV1ApiWrapper({
handler: async ({
req,
authentication,
}: {
req: NextRequest;
authentication: NonNullable<TSessionAuthentication>;
}) => {
const environmentId = req.headers.get("environmentId");
if (!environmentId) {
return responses.badRequestResponse("environmentId is missing");
}
if (!environmentId) {
return {
response: responses.badRequestResponse("environmentId is missing"),
};
}
if (!session) {
return responses.notAuthenticatedResponse();
}
const canUserAccessEnvironment = await hasUserEnvironmentAccess(authentication.user.id, environmentId);
if (!canUserAccessEnvironment) {
return {
response: responses.unauthorizedResponse(),
};
}
const canUserAccessEnvironment = await hasUserEnvironmentAccess(session?.user.id, environmentId);
if (!canUserAccessEnvironment) {
return responses.unauthorizedResponse();
}
if (!SLACK_CLIENT_ID)
return {
response: responses.internalServerErrorResponse("Slack client id is missing"),
};
if (!SLACK_CLIENT_SECRET)
return {
response: responses.internalServerErrorResponse("Slack client secret is missing"),
};
if (!SLACK_AUTH_URL)
return {
response: responses.internalServerErrorResponse("Slack auth url is missing"),
};
if (!SLACK_CLIENT_ID) return responses.internalServerErrorResponse("Slack client id is missing");
if (!SLACK_CLIENT_SECRET) return responses.internalServerErrorResponse("Slack client secret is missing");
if (!SLACK_AUTH_URL) return responses.internalServerErrorResponse("Slack auth url is missing");
return responses.successResponse({ authUrl: `${SLACK_AUTH_URL}&state=${environmentId}` });
};
return {
response: responses.successResponse({ authUrl: `${SLACK_AUTH_URL}&state=${environmentId}` }),
};
},
});

View File

@@ -1,9 +1,10 @@
import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth";
import { handleErrorResponse } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { ApiAuditLog, withApiLogging } from "@/app/lib/api/with-api-logging";
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { deleteActionClass, getActionClass, updateActionClass } from "@/lib/actionClass/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { TActionClass, ZActionClassInput } from "@formbricks/types/action-classes";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
@@ -27,36 +28,49 @@ const fetchAndAuthorizeActionClass = async (
return actionClass;
};
export const GET = async (
request: Request,
props: { params: Promise<{ actionClassId: string }> }
): Promise<Response> => {
const params = await props.params;
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId, "GET");
if (actionClass) {
return responses.successResponse(actionClass);
}
return responses.notFoundResponse("Action Class", params.actionClassId);
} catch (error) {
return handleErrorResponse(error);
}
};
export const PUT = withApiLogging(
async (request: Request, props: { params: Promise<{ actionClassId: string }> }, auditLog: ApiAuditLog) => {
export const GET = withV1ApiWrapper({
handler: async ({
props,
authentication,
}: {
props: { params: Promise<{ actionClassId: string }> };
authentication: NonNullable<TApiKeyAuthentication>;
}) => {
const params = await props.params;
try {
const authentication = await authenticateRequest(request);
if (!authentication) {
const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId, "GET");
if (actionClass) {
return {
response: responses.notAuthenticatedResponse(),
response: responses.successResponse(actionClass),
};
}
auditLog.userId = authentication.apiKeyId;
return {
response: responses.notFoundResponse("Action Class", params.actionClassId),
};
} catch (error) {
return {
response: handleErrorResponse(error),
};
}
},
});
export const PUT = withV1ApiWrapper({
handler: async ({
req,
props,
auditLog,
authentication,
}: {
req: NextRequest;
props: { params: Promise<{ actionClassId: string }> };
auditLog: TApiAuditLog;
authentication: NonNullable<TApiKeyAuthentication>;
}) => {
const params = await props.params;
try {
const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId, "PUT");
if (!actionClass) {
return {
@@ -64,13 +78,12 @@ export const PUT = withApiLogging(
};
}
auditLog.oldObject = actionClass;
auditLog.organizationId = authentication.organizationId;
let actionClassUpdate;
try {
actionClassUpdate = await request.json();
actionClassUpdate = await req.json();
} catch (error) {
logger.error({ error, url: request.url }, "Error parsing JSON");
logger.error({ error, url: req.url }, "Error parsing JSON");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
};
@@ -105,24 +118,25 @@ export const PUT = withApiLogging(
};
}
},
"updated",
"actionClass"
);
action: "updated",
targetType: "actionClass",
});
export const DELETE = withApiLogging(
async (request: Request, props: { params: Promise<{ actionClassId: string }> }, auditLog: ApiAuditLog) => {
export const DELETE = withV1ApiWrapper({
handler: async ({
props,
auditLog,
authentication,
}: {
props: { params: Promise<{ actionClassId: string }> };
auditLog: TApiAuditLog;
authentication: NonNullable<TApiKeyAuthentication>;
}) => {
const params = await props.params;
auditLog.targetId = params.actionClassId;
try {
const authentication = await authenticateRequest(request);
if (!authentication) {
return {
response: responses.notAuthenticatedResponse(),
};
}
auditLog.userId = authentication.apiKeyId;
const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId, "DELETE");
if (!actionClass) {
return {
@@ -131,7 +145,6 @@ export const DELETE = withApiLogging(
}
auditLog.oldObject = actionClass;
auditLog.organizationId = authentication.organizationId;
const deletedActionClass = await deleteActionClass(params.actionClassId);
return {
@@ -143,6 +156,6 @@ export const DELETE = withApiLogging(
};
}
},
"deleted",
"actionClass"
);
action: "deleted",
targetType: "actionClass",
});

View File

@@ -1,51 +1,53 @@
import { authenticateRequest } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { ApiAuditLog, withApiLogging } from "@/app/lib/api/with-api-logging";
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { createActionClass } from "@/lib/actionClass/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { TActionClass, ZActionClassInput } from "@formbricks/types/action-classes";
import { DatabaseError } from "@formbricks/types/errors";
import { getActionClasses } from "./lib/action-classes";
export const GET = async (request: Request) => {
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
const environmentIds = authentication.environmentPermissions.map(
(permission) => permission.environmentId
);
const actionClasses = await getActionClasses(environmentIds);
return responses.successResponse(actionClasses);
} catch (error) {
if (error instanceof DatabaseError) {
return responses.badRequestResponse(error.message);
}
throw error;
}
};
export const POST = withApiLogging(
async (request: Request, _, auditLog: ApiAuditLog) => {
export const GET = withV1ApiWrapper({
handler: async ({ authentication }: { authentication: NonNullable<TApiKeyAuthentication> }) => {
try {
const authentication = await authenticateRequest(request);
if (!authentication) {
const environmentIds = authentication.environmentPermissions.map(
(permission) => permission.environmentId
);
const actionClasses = await getActionClasses(environmentIds);
return {
response: responses.successResponse(actionClasses),
};
} catch (error) {
if (error instanceof DatabaseError) {
return {
response: responses.notAuthenticatedResponse(),
response: responses.badRequestResponse(error.message),
};
}
auditLog.userId = authentication.apiKeyId;
auditLog.organizationId = authentication.organizationId;
throw error;
}
},
});
export const POST = withV1ApiWrapper({
handler: async ({
req,
auditLog,
authentication,
}: {
req: NextRequest;
auditLog: TApiAuditLog;
authentication: NonNullable<TApiKeyAuthentication>;
}) => {
try {
let actionClassInput;
try {
actionClassInput = await request.json();
actionClassInput = await req.json();
} catch (error) {
logger.error({ error, url: request.url }, "Error parsing JSON input");
logger.error({ error, url: req.url }, "Error parsing JSON input");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
};
@@ -85,6 +87,6 @@ export const POST = withApiLogging(
throw error;
}
},
"created",
"actionClass"
);
action: "created",
targetType: "actionClass",
});

View File

@@ -1,5 +1,8 @@
import { getSessionUser } from "@/app/api/v1/management/me/lib/utils";
import { responses } from "@/app/lib/api/response";
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { headers } from "next/headers";
import { prisma } from "@formbricks/database";
@@ -9,9 +12,11 @@ export const GET = async () => {
const headersList = await headers();
const apiKey = headersList.get("x-api-key");
if (apiKey) {
const hashedApiKey = hashApiKey(apiKey);
const apiKeyData = await prisma.apiKey.findUnique({
where: {
hashedKey: hashApiKey(apiKey),
hashedKey: hashedApiKey,
},
select: {
apiKeyEnvironments: {
@@ -39,9 +44,13 @@ export const GET = async () => {
});
if (!apiKeyData) {
return new Response("Not authenticated", {
status: 401,
});
return responses.notAuthenticatedResponse();
}
try {
await applyRateLimit(rateLimitConfigs.api.v1, hashedApiKey);
} catch (error) {
return responses.tooManyRequestsResponse(error.message);
}
if (
@@ -60,16 +69,18 @@ export const GET = async () => {
},
});
} else {
return new Response("You can't use this method with this API key", {
status: 400,
});
return responses.badRequestResponse("You can't use this method with this API key");
}
} else {
const sessionUser = await getSessionUser();
if (!sessionUser) {
return new Response("Not authenticated", {
status: 401,
});
return responses.notAuthenticatedResponse();
}
try {
await applyRateLimit(rateLimitConfigs.api.v1, sessionUser.id);
} catch (error) {
return responses.tooManyRequestsResponse(error.message);
}
const user = await prisma.user.findUnique({

View File

@@ -1,19 +1,24 @@
import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth";
import { handleErrorResponse } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { ApiAuditLog, withApiLogging } from "@/app/lib/api/with-api-logging";
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { validateFileUploads } from "@/lib/fileValidation";
import { deleteResponse, getResponse, updateResponse } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { ZResponseUpdateInput } from "@formbricks/types/responses";
async function fetchAndAuthorizeResponse(
responseId: string,
authentication: any,
authentication: TApiKeyAuthentication,
requiredPermission: "GET" | "PUT" | "DELETE"
) {
if (!authentication) {
return { error: responses.notAuthenticatedResponse() };
}
const response = await getResponse(responseId);
if (!response) {
return { error: responses.notFoundResponse("Response", responseId) };
@@ -31,38 +36,47 @@ async function fetchAndAuthorizeResponse(
return { response, survey };
}
export const GET = async (
request: Request,
props: { params: Promise<{ responseId: string }> }
): Promise<Response> => {
const params = await props.params;
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
export const GET = withV1ApiWrapper({
handler: async ({
props,
authentication,
}: {
props: { params: Promise<{ responseId: string }> };
authentication: TApiKeyAuthentication;
}) => {
const params = await props.params;
try {
const result = await fetchAndAuthorizeResponse(params.responseId, authentication, "GET");
if (result.error) {
return {
response: result.error,
};
}
const result = await fetchAndAuthorizeResponse(params.responseId, authentication, "GET");
if (result.error) return result.error;
return {
response: responses.successResponse(result.response),
};
} catch (error) {
return {
response: handleErrorResponse(error),
};
}
},
});
return responses.successResponse(result.response);
} catch (error) {
return handleErrorResponse(error);
}
};
export const DELETE = withApiLogging(
async (request: Request, props: { params: Promise<{ responseId: string }> }, auditLog: ApiAuditLog) => {
export const DELETE = withV1ApiWrapper({
handler: async ({
props,
auditLog,
authentication,
}: {
props: { params: Promise<{ responseId: string }> };
auditLog: TApiAuditLog;
authentication: TApiKeyAuthentication;
}) => {
const params = await props.params;
auditLog.targetId = params.responseId;
try {
const authentication = await authenticateRequest(request);
if (!authentication) {
return {
response: responses.notAuthenticatedResponse(),
};
}
auditLog.userId = authentication.apiKeyId;
auditLog.organizationId = authentication.organizationId;
const result = await fetchAndAuthorizeResponse(params.responseId, authentication, "DELETE");
if (result.error) {
return {
@@ -81,24 +95,25 @@ export const DELETE = withApiLogging(
};
}
},
"deleted",
"response"
);
action: "deleted",
targetType: "response",
});
export const PUT = withApiLogging(
async (request: Request, props: { params: Promise<{ responseId: string }> }, auditLog: ApiAuditLog) => {
export const PUT = withV1ApiWrapper({
handler: async ({
req,
props,
auditLog,
authentication,
}: {
req: NextRequest;
props: { params: Promise<{ responseId: string }> };
auditLog: TApiAuditLog;
authentication: TApiKeyAuthentication;
}) => {
const params = await props.params;
auditLog.targetId = params.responseId;
try {
const authentication = await authenticateRequest(request);
if (!authentication) {
return {
response: responses.notAuthenticatedResponse(),
};
}
auditLog.userId = authentication.apiKeyId;
auditLog.organizationId = authentication.organizationId;
const result = await fetchAndAuthorizeResponse(params.responseId, authentication, "PUT");
if (result.error) {
return {
@@ -109,9 +124,9 @@ export const PUT = withApiLogging(
let responseUpdate;
try {
responseUpdate = await request.json();
responseUpdate = await req.json();
} catch (error) {
logger.error({ error, url: request.url }, "Error parsing JSON");
logger.error({ error, url: req.url }, "Error parsing JSON");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
};
@@ -144,6 +159,6 @@ export const PUT = withApiLogging(
};
}
},
"updated",
"response"
);
action: "updated",
targetType: "response",
});

View File

@@ -65,8 +65,7 @@ const mockResponsePrisma = {
displayId,
contact: null, // Prisma relation
tags: [], // Prisma relation
notes: [], // Prisma relation
} as unknown as ResponsePrisma & { contact: any; tags: any[]; notes: any[] }; // Adjust type as needed
} as unknown as ResponsePrisma & { contact: any; tags: any[] }; // Adjust type as needed
const mockResponse: TResponse = {
id: responseId,
@@ -85,7 +84,6 @@ const mockResponse: TResponse = {
displayId,
contact: null, // Transformed structure
tags: [], // Transformed structure
notes: [], // Transformed structure
};
const mockEnvironmentIds = [environmentId, "env-2"];

View File

@@ -57,22 +57,6 @@ export const responseSelection = {
},
},
},
notes: {
select: {
id: true,
createdAt: true,
updatedAt: true,
text: true,
user: {
select: {
id: true,
name: true,
},
},
isResolved: true,
isEdited: true,
},
},
} satisfies Prisma.ResponseSelect;
export const createResponse = async (responseInput: TResponseInput): Promise<TResponse> => {

View File

@@ -1,7 +1,6 @@
import { authenticateRequest } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { ApiAuditLog, withApiLogging } from "@/app/lib/api/with-api-logging";
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { validateFileUploads } from "@/lib/fileValidation";
import { getResponses } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
@@ -12,42 +11,56 @@ import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
import { createResponse, getResponsesByEnvironmentIds } from "./lib/response";
export const GET = async (request: NextRequest) => {
const searchParams = request.nextUrl.searchParams;
const surveyId = searchParams.get("surveyId");
const limit = searchParams.get("limit") ? Number(searchParams.get("limit")) : undefined;
const offset = searchParams.get("skip") ? Number(searchParams.get("skip")) : undefined;
export const GET = withV1ApiWrapper({
handler: async ({
req,
authentication,
}: {
req: NextRequest;
authentication: NonNullable<TApiKeyAuthentication>;
}) => {
const searchParams = req.nextUrl.searchParams;
const surveyId = searchParams.get("surveyId");
const limit = searchParams.get("limit") ? Number(searchParams.get("limit")) : undefined;
const offset = searchParams.get("skip") ? Number(searchParams.get("skip")) : undefined;
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
let allResponses: TResponse[] = [];
try {
let allResponses: TResponse[] = [];
if (surveyId) {
const survey = await getSurvey(surveyId);
if (!survey) {
return responses.notFoundResponse("Survey", surveyId, true);
if (surveyId) {
const survey = await getSurvey(surveyId);
if (!survey) {
return {
response: responses.notFoundResponse("Survey", surveyId, true),
};
}
if (!hasPermission(authentication.environmentPermissions, survey.environmentId, "GET")) {
return {
response: responses.unauthorizedResponse(),
};
}
const surveyResponses = await getResponses(surveyId, limit, offset);
allResponses.push(...surveyResponses);
} else {
const environmentIds = authentication.environmentPermissions.map(
(permission) => permission.environmentId
);
const environmentResponses = await getResponsesByEnvironmentIds(environmentIds, limit, offset);
allResponses.push(...environmentResponses);
}
if (!hasPermission(authentication.environmentPermissions, survey.environmentId, "GET")) {
return responses.unauthorizedResponse();
return {
response: responses.successResponse(allResponses),
};
} catch (error) {
if (error instanceof DatabaseError) {
return {
response: responses.badRequestResponse(error.message),
};
}
const surveyResponses = await getResponses(surveyId, limit, offset);
allResponses.push(...surveyResponses);
} else {
const environmentIds = authentication.environmentPermissions.map(
(permission) => permission.environmentId
);
const environmentResponses = await getResponsesByEnvironmentIds(environmentIds, limit, offset);
allResponses.push(...environmentResponses);
throw error;
}
return responses.successResponse(allResponses);
} catch (error) {
if (error instanceof DatabaseError) {
return responses.badRequestResponse(error.message);
}
throw error;
}
};
},
});
const validateInput = async (request: Request) => {
let jsonInput;
@@ -92,19 +105,18 @@ const validateSurvey = async (responseInput: TResponseInput, environmentId: stri
return { survey };
};
export const POST = withApiLogging(
async (request: Request, _, auditLog: ApiAuditLog) => {
export const POST = withV1ApiWrapper({
handler: async ({
req,
auditLog,
authentication,
}: {
req: NextRequest;
auditLog: TApiAuditLog;
authentication: NonNullable<TApiKeyAuthentication>;
}) => {
try {
const authentication = await authenticateRequest(request);
if (!authentication) {
return {
response: responses.notAuthenticatedResponse(),
};
}
auditLog.userId = authentication.apiKeyId;
auditLog.organizationId = authentication.organizationId;
const inputResult = await validateInput(request);
const inputResult = await validateInput(req);
if (inputResult.error) {
return {
response: inputResult.error,
@@ -145,16 +157,14 @@ export const POST = withApiLogging(
response: responses.successResponse(response, true),
};
} catch (error) {
logger.error({ error, url: req.url }, "Error in POST /api/v1/management/responses");
if (error instanceof InvalidInputError) {
return {
response: responses.badRequestResponse(error.message),
};
} else if (error instanceof DatabaseError) {
return {
response: responses.badRequestResponse(error.message),
};
}
logger.error({ error, url: request.url }, "Error in POST /api/v1/management/responses");
return {
response: responses.internalServerErrorResponse(error.message),
};
@@ -168,6 +178,6 @@ export const POST = withApiLogging(
throw error;
}
},
"created",
"response"
);
action: "created",
targetType: "response",
});

View File

@@ -1,24 +1,16 @@
import { authenticateRequest } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { Session } from "next-auth";
import { NextRequest } from "next/server";
import { describe, expect, test } from "vitest";
import { vi } from "vitest";
import { describe, expect, test, vi } from "vitest";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { checkForRequiredFields } from "./utils";
import { checkAuth } from "./utils";
import { checkAuth, checkForRequiredFields } from "./utils";
// Create mock response objects
const mockBadRequestResponse = new Response("Bad Request", { status: 400 });
const mockNotAuthenticatedResponse = new Response("Not authenticated", { status: 401 });
const mockUnauthorizedResponse = new Response("Unauthorized", { status: 401 });
vi.mock("@/app/api/v1/auth", () => ({
authenticateRequest: vi.fn(),
}));
vi.mock("@/lib/environment/auth", () => ({
hasUserEnvironmentAccess: vi.fn(),
}));
@@ -80,19 +72,22 @@ describe("checkForRequiredFields", () => {
describe("checkAuth", () => {
const environmentId = "env-123";
const mockRequest = new NextRequest("http://localhost:3000/api/test");
test("returns notAuthenticatedResponse when no session and no authentication", async () => {
vi.mocked(authenticateRequest).mockResolvedValue(null);
test("returns notAuthenticatedResponse when authentication is null", async () => {
const result = await checkAuth(null, environmentId);
const result = await checkAuth(null, environmentId, mockRequest);
expect(authenticateRequest).toHaveBeenCalledWith(mockRequest);
expect(responses.notAuthenticatedResponse).toHaveBeenCalled();
expect(result).toBe(mockNotAuthenticatedResponse);
});
test("returns unauthorizedResponse when no session and authentication lacks POST permission", async () => {
test("returns notAuthenticatedResponse when authentication is undefined", async () => {
const result = await checkAuth(undefined as any, environmentId);
expect(responses.notAuthenticatedResponse).toHaveBeenCalled();
expect(result).toBe(mockNotAuthenticatedResponse);
});
test("returns unauthorizedResponse when API key authentication lacks POST permission", async () => {
const mockAuthentication: TAuthenticationApiKey = {
type: "apiKey",
environmentPermissions: [
@@ -112,12 +107,10 @@ describe("checkAuth", () => {
},
};
vi.mocked(authenticateRequest).mockResolvedValue(mockAuthentication);
vi.mocked(hasPermission).mockReturnValue(false);
const result = await checkAuth(null, environmentId, mockRequest);
const result = await checkAuth(mockAuthentication, environmentId);
expect(authenticateRequest).toHaveBeenCalledWith(mockRequest);
expect(hasPermission).toHaveBeenCalledWith(
mockAuthentication.environmentPermissions,
environmentId,
@@ -127,7 +120,7 @@ describe("checkAuth", () => {
expect(result).toBe(mockUnauthorizedResponse);
});
test("returns undefined when no session and authentication has POST permission", async () => {
test("returns undefined when API key authentication has POST permission", async () => {
const mockAuthentication: TAuthenticationApiKey = {
type: "apiKey",
environmentPermissions: [
@@ -147,12 +140,10 @@ describe("checkAuth", () => {
},
};
vi.mocked(authenticateRequest).mockResolvedValue(mockAuthentication);
vi.mocked(hasPermission).mockReturnValue(true);
const result = await checkAuth(null, environmentId, mockRequest);
const result = await checkAuth(mockAuthentication, environmentId);
expect(authenticateRequest).toHaveBeenCalledWith(mockRequest);
expect(hasPermission).toHaveBeenCalledWith(
mockAuthentication.environmentPermissions,
environmentId,
@@ -171,7 +162,7 @@ describe("checkAuth", () => {
vi.mocked(hasUserEnvironmentAccess).mockResolvedValue(false);
const result = await checkAuth(mockSession, environmentId, mockRequest);
const result = await checkAuth(mockSession, environmentId);
expect(hasUserEnvironmentAccess).toHaveBeenCalledWith("user-123", environmentId);
expect(responses.unauthorizedResponse).toHaveBeenCalled();
@@ -188,25 +179,18 @@ describe("checkAuth", () => {
vi.mocked(hasUserEnvironmentAccess).mockResolvedValue(true);
const result = await checkAuth(mockSession, environmentId, mockRequest);
const result = await checkAuth(mockSession, environmentId);
expect(hasUserEnvironmentAccess).toHaveBeenCalledWith("user-123", environmentId);
expect(result).toBeUndefined();
});
test("does not call authenticateRequest when session exists", async () => {
const mockSession: Session = {
user: {
id: "user-123",
},
expires: "2024-12-31T23:59:59.999Z",
};
test("returns notAuthenticatedResponse when authentication object is neither session nor API key", async () => {
const invalidAuth = { someProperty: "value" } as any;
vi.mocked(hasUserEnvironmentAccess).mockResolvedValue(true);
const result = await checkAuth(invalidAuth, environmentId);
await checkAuth(mockSession, environmentId, mockRequest);
expect(authenticateRequest).not.toHaveBeenCalled();
expect(hasUserEnvironmentAccess).toHaveBeenCalledWith("user-123", environmentId);
expect(responses.notAuthenticatedResponse).toHaveBeenCalled();
expect(result).toBe(mockNotAuthenticatedResponse);
});
});

View File

@@ -1,9 +1,7 @@
import { authenticateRequest } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { TApiV1Authentication } from "@/app/lib/api/with-api-logging";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { Session } from "next-auth";
import { NextRequest } from "next/server";
export const checkForRequiredFields = (
environmentId: string,
@@ -23,19 +21,21 @@ export const checkForRequiredFields = (
}
};
export const checkAuth = async (session: Session | null, environmentId: string, request: NextRequest) => {
if (!session) {
//check whether its using API key
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
export const checkAuth = async (authentication: TApiV1Authentication, environmentId: string) => {
if (!authentication) {
return responses.notAuthenticatedResponse();
}
if ("user" in authentication) {
const isUserAuthorized = await hasUserEnvironmentAccess(authentication.user.id, environmentId);
if (!isUserAuthorized) {
return responses.unauthorizedResponse();
}
} else if ("hashedApiKey" in authentication) {
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
return responses.unauthorizedResponse();
}
} else {
const isUserAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId);
if (!isUserAuthorized) {
return responses.unauthorizedResponse();
}
return responses.notAuthenticatedResponse();
}
};

View File

@@ -3,88 +3,107 @@
// method -> PUT (to be the same as the signedUrl method)
import { checkAuth, checkForRequiredFields } from "@/app/api/v1/management/storage/lib/utils";
import { responses } from "@/app/lib/api/response";
import { TApiV1Authentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { ENCRYPTION_KEY, UPLOADS_DIR } from "@/lib/constants";
import { validateLocalSignedUrl } from "@/lib/crypto";
import { validateFile } from "@/lib/fileValidation";
import { putFileToLocalStorage } from "@/lib/storage/service";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getServerSession } from "next-auth";
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
export const POST = async (req: NextRequest): Promise<Response> => {
if (!ENCRYPTION_KEY) {
return responses.internalServerErrorResponse("Encryption key is not set");
}
const accessType = "public"; // public files are accessible by anyone
const jsonInput = await req.json();
const fileType = jsonInput.fileType as string;
const encodedFileName = jsonInput.fileName as string;
const signedSignature = jsonInput.signature as string;
const signedUuid = jsonInput.uuid as string;
const signedTimestamp = jsonInput.timestamp as string;
const environmentId = jsonInput.environmentId as string;
const requiredFieldResponse = checkForRequiredFields(environmentId, fileType, encodedFileName);
if (requiredFieldResponse) return requiredFieldResponse;
if (!signedSignature || !signedUuid || !signedTimestamp) {
return responses.unauthorizedResponse();
}
const session = await getServerSession(authOptions);
const authResponse = await checkAuth(session, environmentId, req);
if (authResponse) return authResponse;
const fileName = decodeURIComponent(encodedFileName);
// Perform server-side file validation
const fileValidation = validateFile(fileName, fileType);
if (!fileValidation.valid) {
return responses.badRequestResponse(fileValidation.error ?? "Invalid file");
}
// validate signature
const validated = validateLocalSignedUrl(
signedUuid,
fileName,
environmentId,
fileType,
Number(signedTimestamp),
signedSignature,
ENCRYPTION_KEY
);
if (!validated) {
return responses.unauthorizedResponse();
}
const base64String = jsonInput.fileBase64String as string;
const buffer = Buffer.from(base64String.split(",")[1], "base64");
const file = new Blob([buffer], { type: fileType });
if (!file) {
return responses.badRequestResponse("fileBuffer is required");
}
try {
const bytes = await file.arrayBuffer();
const fileBuffer = Buffer.from(bytes);
await putFileToLocalStorage(fileName, fileBuffer, accessType, environmentId, UPLOADS_DIR);
return responses.successResponse({
message: "File uploaded successfully",
});
} catch (err) {
logger.error(err, "Error uploading file");
if (err.name === "FileTooLargeError") {
return responses.badRequestResponse(err.message);
export const POST = withV1ApiWrapper({
handler: async ({ req, authentication }: { req: NextRequest; authentication: TApiV1Authentication }) => {
if (!ENCRYPTION_KEY) {
return {
response: responses.internalServerErrorResponse("Encryption key is not set"),
};
}
return responses.internalServerErrorResponse("File upload failed");
}
};
const accessType = "public"; // public files are accessible by anyone
const jsonInput = await req.json();
const fileType = jsonInput.fileType as string;
const encodedFileName = jsonInput.fileName as string;
const signedSignature = jsonInput.signature as string;
const signedUuid = jsonInput.uuid as string;
const signedTimestamp = jsonInput.timestamp as string;
const environmentId = jsonInput.environmentId as string;
const requiredFieldResponse = checkForRequiredFields(environmentId, fileType, encodedFileName);
if (requiredFieldResponse) {
return {
response: requiredFieldResponse,
};
}
if (!signedSignature || !signedUuid || !signedTimestamp) {
return {
response: responses.unauthorizedResponse(),
};
}
const authResponse = await checkAuth(authentication, environmentId);
if (authResponse) return { response: authResponse };
const fileName = decodeURIComponent(encodedFileName);
// Perform server-side file validation
const fileValidation = validateFile(fileName, fileType);
if (!fileValidation.valid) {
return {
response: responses.badRequestResponse(fileValidation.error ?? "Invalid file"),
};
}
// validate signature
const validated = validateLocalSignedUrl(
signedUuid,
fileName,
environmentId,
fileType,
Number(signedTimestamp),
signedSignature,
ENCRYPTION_KEY
);
if (!validated) {
return {
response: responses.unauthorizedResponse(),
};
}
const base64String = jsonInput.fileBase64String as string;
const buffer = Buffer.from(base64String.split(",")[1], "base64");
const file = new Blob([buffer], { type: fileType });
if (!file) {
return {
response: responses.badRequestResponse("fileBuffer is required"),
};
}
try {
const bytes = await file.arrayBuffer();
const fileBuffer = Buffer.from(bytes);
await putFileToLocalStorage(fileName, fileBuffer, accessType, environmentId, UPLOADS_DIR);
return {
response: responses.successResponse({
message: "File uploaded successfully",
}),
};
} catch (err) {
logger.error(err, "Error uploading file");
if (err.name === "FileTooLargeError") {
return {
response: responses.badRequestResponse(err.message),
};
}
return {
response: responses.internalServerErrorResponse("File upload failed"),
};
}
},
});

View File

@@ -1,8 +1,7 @@
import { checkAuth, checkForRequiredFields } from "@/app/api/v1/management/storage/lib/utils";
import { responses } from "@/app/lib/api/response";
import { TApiV1Authentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { validateFile } from "@/lib/fileValidation";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getServerSession } from "next-auth";
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { getSignedUrlForPublicFile } from "./lib/getSignedUrl";
@@ -13,40 +12,57 @@ import { getSignedUrlForPublicFile } from "./lib/getSignedUrl";
// use this to upload files for a specific resource, e.g. a user profile picture or a survey
// this api endpoint will return a signed url for uploading the file to s3 and another url for uploading file to the local storage
export const POST = async (request: NextRequest): Promise<Response> => {
let storageInput;
export const POST = withV1ApiWrapper({
handler: async ({ req, authentication }: { req: NextRequest; authentication: TApiV1Authentication }) => {
let storageInput;
try {
storageInput = await request.json();
} catch (error) {
logger.error({ error, url: request.url }, "Error parsing JSON input");
return responses.badRequestResponse("Malformed JSON input, please check your request body");
}
const { fileName, fileType, environmentId, allowedFileExtensions } = storageInput;
const requiredFieldResponse = checkForRequiredFields(environmentId, fileType, fileName);
if (requiredFieldResponse) return requiredFieldResponse;
const session = await getServerSession(authOptions);
const authResponse = await checkAuth(session, environmentId, request);
if (authResponse) return authResponse;
// Perform server-side file validation first to block dangerous file types
const fileValidation = validateFile(fileName, fileType);
if (!fileValidation.valid) {
return responses.badRequestResponse(fileValidation.error ?? "Invalid file type");
}
// Also perform client-specified allowed file extensions validation if provided
if (allowedFileExtensions?.length) {
const fileExtension = fileName.split(".").pop()?.toLowerCase();
if (!fileExtension || !allowedFileExtensions.includes(fileExtension)) {
return responses.badRequestResponse(
`File extension is not allowed, allowed extensions are: ${allowedFileExtensions.join(", ")}`
);
try {
storageInput = await req.json();
} catch (error) {
logger.error({ error, url: req.url }, "Error parsing JSON input");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
};
}
}
return await getSignedUrlForPublicFile(fileName, environmentId, fileType);
};
const { fileName, fileType, environmentId, allowedFileExtensions } = storageInput;
const requiredFieldResponse = checkForRequiredFields(environmentId, fileType, fileName);
if (requiredFieldResponse) {
return {
response: requiredFieldResponse,
};
}
const authResponse = await checkAuth(authentication, environmentId);
if (authResponse) {
return {
response: authResponse,
};
}
// Perform server-side file validation first to block dangerous file types
const fileValidation = validateFile(fileName, fileType);
if (!fileValidation.valid) {
return {
response: responses.badRequestResponse(fileValidation.error ?? "Invalid file type"),
};
}
// Also perform client-specified allowed file extensions validation if provided
if (allowedFileExtensions?.length) {
const fileExtension = fileName.split(".").pop()?.toLowerCase();
if (!fileExtension || !allowedFileExtensions.includes(fileExtension)) {
return {
response: responses.badRequestResponse(
`File extension is not allowed, allowed extensions are: ${allowedFileExtensions.join(", ")}`
),
};
}
}
return {
response: await getSignedUrlForPublicFile(fileName, environmentId, fileType),
};
},
});

View File

@@ -1,12 +1,13 @@
import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth";
import { handleErrorResponse } from "@/app/api/v1/auth";
import { deleteSurvey } from "@/app/api/v1/management/surveys/[surveyId]/lib/surveys";
import { checkFeaturePermissions } from "@/app/api/v1/management/surveys/lib/utils";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { ApiAuditLog, withApiLogging } from "@/app/lib/api/with-api-logging";
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { ZSurveyUpdateInput } from "@formbricks/types/surveys/types";
@@ -27,36 +28,47 @@ const fetchAndAuthorizeSurvey = async (
return { survey };
};
export const GET = async (
request: Request,
props: { params: Promise<{ surveyId: string }> }
): Promise<Response> => {
const params = await props.params;
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
const result = await fetchAndAuthorizeSurvey(params.surveyId, authentication, "GET");
if (result.error) return result.error;
return responses.successResponse(result.survey);
} catch (error) {
return handleErrorResponse(error);
}
};
export const GET = withV1ApiWrapper({
handler: async ({
props,
authentication,
}: {
props: { params: Promise<{ surveyId: string }> };
authentication: NonNullable<TApiKeyAuthentication>;
}) => {
const params = await props.params;
export const DELETE = withApiLogging(
async (request: Request, props: { params: Promise<{ surveyId: string }> }, auditLog: ApiAuditLog) => {
try {
const result = await fetchAndAuthorizeSurvey(params.surveyId, authentication, "GET");
if (result.error) {
return {
response: result.error,
};
}
return {
response: responses.successResponse(result.survey),
};
} catch (error) {
return {
response: handleErrorResponse(error),
};
}
},
});
export const DELETE = withV1ApiWrapper({
handler: async ({
props,
auditLog,
authentication,
}: {
props: { params: Promise<{ surveyId: string }> };
auditLog: TApiAuditLog;
authentication: NonNullable<TApiKeyAuthentication>;
}) => {
const params = await props.params;
auditLog.targetId = params.surveyId;
try {
const authentication = await authenticateRequest(request);
if (!authentication) {
return {
response: responses.notAuthenticatedResponse(),
};
}
auditLog.userId = authentication.apiKeyId;
auditLog.organizationId = authentication.organizationId;
const result = await fetchAndAuthorizeSurvey(params.surveyId, authentication, "DELETE");
if (result.error) {
return {
@@ -75,23 +87,25 @@ export const DELETE = withApiLogging(
};
}
},
"deleted",
"survey"
);
action: "deleted",
targetType: "survey",
});
export const PUT = withApiLogging(
async (request: Request, props: { params: Promise<{ surveyId: string }> }, auditLog: ApiAuditLog) => {
export const PUT = withV1ApiWrapper({
handler: async ({
req,
props,
auditLog,
authentication,
}: {
req: NextRequest;
props: { params: Promise<{ surveyId: string }> };
auditLog: TApiAuditLog;
authentication: NonNullable<TApiKeyAuthentication>;
}) => {
const params = await props.params;
auditLog.targetId = params.surveyId;
try {
const authentication = await authenticateRequest(request);
if (!authentication) {
return {
response: responses.notAuthenticatedResponse(),
};
}
auditLog.userId = authentication.apiKeyId;
const result = await fetchAndAuthorizeSurvey(params.surveyId, authentication, "PUT");
if (result.error) {
return {
@@ -106,13 +120,12 @@ export const PUT = withApiLogging(
response: responses.notFoundResponse("Organization", null),
};
}
auditLog.organizationId = organization.id;
let surveyUpdate;
try {
surveyUpdate = await request.json();
surveyUpdate = await req.json();
} catch (error) {
logger.error({ error, url: request.url }, "Error parsing JSON input");
logger.error({ error, url: req.url }, "Error parsing JSON input");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
};
@@ -146,18 +159,16 @@ export const PUT = withApiLogging(
response: responses.successResponse(updatedSurvey),
};
} catch (error) {
auditLog.status = "failure";
return {
response: handleErrorResponse(error),
};
}
} catch (error) {
auditLog.status = "failure";
return {
response: handleErrorResponse(error),
};
}
},
"updated",
"survey"
);
action: "updated",
targetType: "survey",
});

View File

@@ -1,55 +1,77 @@
import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth";
import { handleErrorResponse } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getSurvey } from "@/lib/survey/service";
import { generateSurveySingleUseIds } from "@/lib/utils/single-use-surveys";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { NextRequest } from "next/server";
export const GET = async (
request: NextRequest,
props: { params: Promise<{ surveyId: string }> }
): Promise<Response> => {
const params = await props.params;
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
const survey = await getSurvey(params.surveyId);
if (!survey) {
return responses.notFoundResponse("Survey", params.surveyId);
export const GET = withV1ApiWrapper({
handler: async ({
req,
props,
authentication,
}: {
req: NextRequest;
props: { params: Promise<{ surveyId: string }> };
authentication: NonNullable<TApiKeyAuthentication>;
}) => {
try {
const params = await props.params;
const survey = await getSurvey(params.surveyId);
if (!survey) {
return {
response: responses.notFoundResponse("Survey", params.surveyId),
};
}
if (!hasPermission(authentication.environmentPermissions, survey.environmentId, "GET")) {
return {
response: responses.unauthorizedResponse(),
};
}
if (survey.type !== "link") {
return {
response: responses.badRequestResponse("Single use links are only available for link surveys"),
};
}
if (!survey.singleUse || !survey.singleUse.enabled) {
return {
response: responses.badRequestResponse("Single use links are not enabled for this survey"),
};
}
const searchParams = req.nextUrl.searchParams;
const limit = searchParams.get("limit") ? Number(searchParams.get("limit")) : 10;
if (limit < 1) {
return {
response: responses.badRequestResponse("Limit cannot be less than 1"),
};
}
if (limit > 5000) {
return {
response: responses.badRequestResponse("Limit cannot be more than 5000"),
};
}
const singleUseIds = generateSurveySingleUseIds(limit, survey.singleUse.isEncrypted);
const publicDomain = getPublicDomain();
// map single use ids to survey links
const surveyLinks = singleUseIds.map(
(singleUseId) => `${publicDomain}/s/${survey.id}?suId=${singleUseId}`
);
return {
response: responses.successResponse(surveyLinks),
};
} catch (error) {
return {
response: handleErrorResponse(error),
};
}
if (!hasPermission(authentication.environmentPermissions, survey.environmentId, "GET")) {
return responses.unauthorizedResponse();
}
if (survey.type !== "link") {
return responses.badRequestResponse("Single use links are only available for link surveys");
}
if (!survey.singleUse || !survey.singleUse.enabled) {
return responses.badRequestResponse("Single use links are not enabled for this survey");
}
const searchParams = request.nextUrl.searchParams;
const limit = searchParams.get("limit") ? Number(searchParams.get("limit")) : 10;
if (limit < 1) {
return responses.badRequestResponse("Limit cannot be less than 1");
}
if (limit > 5000) {
return responses.badRequestResponse("Limit cannot be more than 5000");
}
const singleUseIds = generateSurveySingleUseIds(limit, survey.singleUse.isEncrypted);
const publicDomain = getPublicDomain();
// map single use ids to survey links
const surveyLinks = singleUseIds.map(
(singleUseId) => `${publicDomain}/s/${survey.id}?suId=${singleUseId}`
);
return responses.successResponse(surveyLinks);
} catch (error) {
return handleErrorResponse(error);
}
};
},
});

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