Compare commits

..

52 Commits

Author SHA1 Message Date
harshsbhat
d2343cb60c requested changes 2025-07-22 14:00:36 +05:30
harshsbhat
e10a42f61e Merge remote-tracking branch 'origin/main' into harsh/plain-integration 2025-07-22 10:25:43 +05:30
Piyush Gupta
f043314537 fix: required action revert logic (#6269) 2025-07-22 04:10:09 +00:00
Victor Hugo dos Santos
2ce842dd8d chore: updated SAML SSO docs (#6280) 2025-07-22 04:09:11 +00:00
Johannes
43b43839c5 chore: auto-add bug to eng project (#6277) 2025-07-21 08:33:27 -07:00
Piyush Gupta
8b6e3fec37 fix: response filters icons and text (#6266) 2025-07-21 08:48:10 +00:00
Anshuman Pandey
31bcf98779 fix: fixes PIN 4 digit length error (#6265) 2025-07-21 07:30:03 +00:00
Matti Nannt
b35cabcbcc chore(infra): enable cluster public access to mitigate tailscale issues (#6264) 2025-07-19 08:53:31 +02:00
Matti Nannt
4f435f1a1f fix: enable Tailscale subnet routes for EKS access (#6263) 2025-07-18 21:32:01 +02:00
Victor Hugo dos Santos
99c1e434df feat: Deploy to staging on pre-release builds (#6261) 2025-07-18 15:35:00 +00:00
Piyush Gupta
b13699801b fix: survey preview for suid enabled surveys (#6253)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-07-18 08:54:48 +00:00
Jakob Schott
ceb2e85d96 chore: 742 storybook setup and cursor rule (#6220)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-07-18 08:03:39 +00:00
Anshuman Pandey
c5f8b5ec32 fix: removes suid UI from the survey-editor (#6249)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-07-18 07:41:05 +00:00
Anshuman Pandey
bdbd57c2fc fix: adds read only survey url (#6252) 2025-07-18 05:14:32 +00:00
harshsbhat
1319ca648c delete integration button 2025-07-18 10:11:12 +05:30
Victor Hugo dos Santos
d44aa17814 feat: add sentry sourcemaps to pre-releases (#6242) 2025-07-17 16:11:28 +00:00
Jakob Schott
23d38b4c5b chore: move tab component to storybook (#6214) 2025-07-17 09:26:31 +00:00
Piyush Gupta
58213969e8 feat: remove brevo contact on account deletion (#6231)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-07-16 16:00:34 +00:00
pandeymangg
f6a544d01e fixes dep resolution with tar-fs 2025-07-16 18:59:47 +05:30
harshsbhat
697c132581 fix: resolve merge conflict in pnpm-lock.yaml 2025-07-16 18:38:23 +05:30
harshsbhat
a53e9a1bee sonar qube issues 2025-07-16 18:35:07 +05:30
Victor Hugo dos Santos
ef973c8995 chore: merge rate limiter epic branch into main (#6236)
Co-authored-by: Harsh Bhat <90265455+harshsbhat@users.noreply.github.com>
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Piyush Gupta <56182734+gupta-piyush19@users.noreply.github.com>
Co-authored-by: Aditya <162564995+Naidu-4444@users.noreply.github.com>
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
Co-authored-by: Dhruwang Jariwala <67850763+Dhruwang@users.noreply.github.com>
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
Co-authored-by: Jakob Schott <154420406+jakobsitory@users.noreply.github.com>
Co-authored-by: Suraj <surajsuthar0067@gmail.com>
Co-authored-by: Kshitij Sharma <63995641+kshitij-codes@users.noreply.github.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: Matti Nannt <mail@matthiasnannt.com>
2025-07-16 12:28:59 +00:00
dependabot[bot]
bea02ba3b5 chore(deps): bump the npm_and_yarn group across 2 directories with 1 update (#6161)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-07-16 10:42:54 +00:00
harshsbhat
64fd5e40d7 remove console.log 2025-07-16 15:26:21 +05:30
harshsbhat
b3886014cb console.log 2025-07-16 14:49:44 +05:30
Piyush Jain
1c1e2ee09c chore: add timeout settings for production LB (#5884) 2025-07-16 09:08:11 +00:00
Piyush Gupta
2bf7fe6c54 docs: adds email address validation note (#6239) 2025-07-16 01:55:21 -07:00
harshsbhat
44c5bec535 remove previous changes and reduce duplicate 2025-07-16 13:55:16 +05:30
Saurav Jain
9639402c39 fix: allow read and write API key permissions for /v1/management/me (#6178)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-07-16 07:52:10 +00:00
harshsbhat
416f142385 chore: reduce duplicate code 2025-07-16 12:52:33 +05:30
harshsbhat
d3d9e3223d reduce duplicate code 2025-07-16 12:21:27 +05:30
harshsbhat
130ed59677 Merge remote-tracking branch 'origin/main' into harsh/plain-integration 2025-07-16 09:40:06 +05:30
harshsbhat
7d8d7fc744 remove console.log 2025-07-16 00:41:50 +05:30
harshsbhat
721ae66811 chore: duplicate code 2025-07-15 22:00:15 +05:30
harshsbhat
af6d9542e4 chore: add more test coverage 2025-07-15 21:25:34 +05:30
harshsbhat
71b408e066 chore: slack build error 2025-07-15 20:37:46 +05:30
harshsbhat
c7277bb709 chore: update the plain connection 2025-07-15 20:14:28 +05:30
harshsbhat
04a709c6c2 Merge remote-tracking branch 'origin/main' into harsh/plain-integration 2025-07-15 20:10:07 +05:30
harshsbhat
4ee0b9ec03 chore: add tests 2025-07-15 18:32:16 +05:30
harshsbhat
eb8eac8aa4 lint errors 2025-07-15 17:21:17 +05:30
harshsbhat
c1444f8427 add tests and translations 2025-07-15 16:58:56 +05:30
harshsbhat
15adaf6976 Merge remote-tracking branch 'origin/main' into harsh/plain-integration 2025-07-15 15:06:16 +05:30
harshsbhat
85fababd57 add everything in pipeline 2025-07-14 22:49:11 +05:30
harshsbhat
3694f93429 Mege conflicts 2025-07-14 09:21:35 +05:30
harshsbhat
36e0e62f01 plain integration 2025-07-14 09:02:25 +05:30
harshsbhat
6d2bd9210c Merge remote-tracking branch 'origin/main' into harsh/plain-integration 2025-06-28 11:29:48 +05:30
harshsbhat
636374ae04 add mapping 2025-06-16 23:43:21 +05:30
harshsbhat
b0627fffa5 tweaks 2025-06-16 13:44:00 +05:30
harshsbhat
84a94ad027 Merge remote-tracking branch 'origin/main' into harsh/plain-integration 2025-06-16 10:47:28 +05:30
harshsbhat
55a1b95988 temp 2025-05-22 16:12:15 +05:30
harshsbhat
bdf1698c05 Merge remote-tracking branch 'origin/main' into harsh/plain-integration 2025-05-22 14:50:49 +05:30
harshsbhat
c761f51b0e first commit 2025-05-20 18:44:50 +05:30
158 changed files with 12516 additions and 1907 deletions

View File

@@ -1,5 +1,5 @@
---
description:
description: Migrate deprecated UI components to a unified component
globs:
alwaysApply: false
---

View File

@@ -0,0 +1,177 @@
---
description: Create a story in Storybook for a given component
globs:
alwaysApply: false
---
# Formbricks Storybook Stories
## When generating Storybook stories for Formbricks components:
### 1. **File Structure**
- Create `stories.tsx` (not `.stories.tsx`) in component directory
- Use exact import: `import { Meta, StoryObj } from "@storybook/react-vite";`
- Import component from `"./index"`
### 2. **Story Structure Template**
```tsx
import { Meta, StoryObj } from "@storybook/react-vite";
import { ComponentName } from "./index";
// For complex components with configurable options
// consider this as an example the options need to reflect the props types
interface StoryOptions {
showIcon: boolean;
numberOfElements: number;
customLabels: string[];
}
type StoryProps = React.ComponentProps<typeof ComponentName> & StoryOptions;
const meta: Meta<StoryProps> = {
title: "UI/ComponentName",
component: ComponentName,
tags: ["autodocs"],
parameters: {
layout: "centered",
controls: { sort: "alpha", exclude: [] },
docs: {
description: {
component: "The **ComponentName** component provides [description].",
},
},
},
argTypes: {
// Organize in exactly these categories: Behavior, Appearance, Content
},
};
export default meta;
type Story = StoryObj<typeof ComponentName> & { args: StoryOptions };
```
### 3. **ArgTypes Organization**
Organize ALL argTypes into exactly three categories:
- **Behavior**: disabled, variant, onChange, etc.
- **Appearance**: size, color, layout, styling, etc.
- **Content**: text, icons, numberOfElements, etc.
Format:
```tsx
argTypes: {
propName: {
control: "select" | "boolean" | "text" | "number",
options: ["option1", "option2"], // for select
description: "Clear description",
table: {
category: "Behavior" | "Appearance" | "Content",
type: { summary: "string" },
defaultValue: { summary: "default" },
},
order: 1,
},
}
```
### 4. **Required Stories**
Every component must include:
- `Default`: Most common use case
- `Disabled`: If component supports disabled state
- `WithIcon`: If component supports icons
- Variant stories for each variant (Primary, Secondary, Error, etc.)
- Edge case stories (ManyElements, LongText, CustomStyling)
### 5. **Story Format**
```tsx
export const Default: Story = {
args: {
// Props with realistic values
},
};
export const EdgeCase: Story = {
args: { /* ... */ },
parameters: {
docs: {
description: {
story: "Use this when [specific scenario].",
},
},
},
};
```
### 6. **Dynamic Content Pattern**
For components with dynamic content, create render function:
```tsx
const renderComponent = (args: StoryProps) => {
const { numberOfElements, showIcon, customLabels } = args;
// Generate dynamic content
const elements = Array.from({ length: numberOfElements }, (_, i) => ({
id: `element-${i}`,
label: customLabels[i] || `Element ${i + 1}`,
icon: showIcon ? <IconComponent /> : undefined,
}));
return <ComponentName {...args} elements={elements} />;
};
export const Dynamic: Story = {
render: renderComponent,
args: {
numberOfElements: 3,
showIcon: true,
customLabels: ["First", "Second", "Third"],
},
};
```
### 7. **State Management**
For interactive components:
```tsx
import { useState } from "react";
const ComponentWithState = (args: any) => {
const [value, setValue] = useState(args.defaultValue);
return (
<ComponentName
{...args}
value={value}
onChange={(newValue) => {
setValue(newValue);
args.onChange?.(newValue);
}}
/>
);
};
export const Interactive: Story = {
render: ComponentWithState,
args: { defaultValue: "initial" },
};
```
### 8. **Quality Requirements**
- Include component description in parameters.docs
- Add story documentation for non-obvious use cases
- Test edge cases (overflow, empty states, many elements)
- Ensure no TypeScript errors
- Use realistic prop values
- Include at least 3-5 story variants
- Example values need to be in the context of survey application
### 9. **Naming Conventions**
- **Story titles**: "UI/ComponentName"
- **Story exports**: PascalCase (Default, WithIcon, ManyElements)
- **Categories**: "Behavior", "Appearance", "Content" (exact spelling)
- **Props**: camelCase matching component props
### 10. **Special Cases**
- **Generic components**: Remove `component` from meta if type conflicts
- **Form components**: Include Invalid, WithValue stories
- **Navigation**: Include ManyItems stories
- **Modals, Dropdowns and Popups **: Include trigger and content structure
## Generate stories that are comprehensive, well-documented, and reflect all component states and edge cases.

View File

@@ -189,7 +189,6 @@ ENTERPRISE_LICENSE_KEY=
UNSPLASH_ACCESS_KEY=
# The below is used for Next Caching (uses In-Memory from Next Cache if not provided)
# You can also add more configuration to Redis using the redis.conf file in the root directory
REDIS_URL=redis://localhost:6379
# The below is used for Rate Limiting (uses In-Memory LRU Cache if not provided) (You can use a service like Webdis for this)

View File

@@ -1,6 +1,7 @@
name: Bug report
description: "Found a bug? Please fill out the sections below. \U0001F44D"
type: bug
projects: "formbricks/8"
labels: ["bug"]
body:
- type: textarea

View File

@@ -11,6 +11,10 @@ inputs:
sentry_auth_token:
description: 'Sentry authentication token'
required: true
environment:
description: 'Sentry environment (e.g., production, staging)'
required: false
default: 'staging'
runs:
using: 'composite'
@@ -107,7 +111,7 @@ runs:
SENTRY_ORG: formbricks
SENTRY_PROJECT: formbricks-cloud
with:
environment: production
environment: ${{ inputs.environment }}
version: ${{ inputs.release_version }}
sourcemaps: './extracted-next/'

View File

@@ -17,8 +17,8 @@ on:
required: true
type: choice
options:
- stage
- prod
- staging
- production
workflow_call:
inputs:
VERSION:
@@ -52,6 +52,7 @@ jobs:
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
tags: tag:github
args: --accept-routes
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@f24d7193d98baebaeacc7e2227925dd47cc267f5 # v4.2.0
@@ -66,8 +67,8 @@ jobs:
AWS_REGION: eu-central-1
- uses: helmfile/helmfile-action@v2
name: Deploy Formbricks Cloud Prod
if: inputs.ENVIRONMENT == 'prod'
name: Deploy Formbricks Cloud Production
if: inputs.ENVIRONMENT == 'production'
env:
VERSION: ${{ inputs.VERSION }}
REPOSITORY: ${{ inputs.REPOSITORY }}
@@ -84,8 +85,8 @@ jobs:
helmfile-workdirectory: infra/formbricks-cloud-helm
- uses: helmfile/helmfile-action@v2
name: Deploy Formbricks Cloud Stage
if: inputs.ENVIRONMENT == 'stage'
name: Deploy Formbricks Cloud Staging
if: inputs.ENVIRONMENT == 'staging'
env:
VERSION: ${{ inputs.VERSION }}
REPOSITORY: ${{ inputs.REPOSITORY }}
@@ -101,13 +102,13 @@ jobs:
helmfile-workdirectory: infra/formbricks-cloud-helm
- name: Purge Cloudflare Cache
if: ${{ inputs.ENVIRONMENT == 'prod' || inputs.ENVIRONMENT == 'stage' }}
if: ${{ inputs.ENVIRONMENT == 'production' || inputs.ENVIRONMENT == 'staging' }}
env:
CF_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }}
CF_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
# Set hostname based on environment
if [[ "${{ inputs.ENVIRONMENT }}" == "prod" ]]; then
if [[ "${{ inputs.ENVIRONMENT }}" == "production" ]]; then
PURGE_HOST="app.formbricks.com"
else
PURGE_HOST="stage.app.formbricks.com"

View File

@@ -89,6 +89,7 @@ jobs:
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
sed -i "s/ENTERPRISE_LICENSE_KEY=.*/ENTERPRISE_LICENSE_KEY=${{ secrets.ENTERPRISE_LICENSE_KEY }}/" .env
sed -i "s|REDIS_URL=.*|REDIS_URL=redis://localhost:6379|" .env
echo "" >> .env
echo "E2E_TESTING=1" >> .env
shell: bash
@@ -102,6 +103,12 @@ jobs:
# pnpm prisma migrate deploy
pnpm db:migrate:dev
- name: Run Rate Limiter Load Tests
run: |
echo "Running rate limiter load tests with Redis/Valkey..."
cd apps/web && pnpm vitest run modules/core/rate-limit/rate-limit-load.test.ts
shell: bash
- name: Check for Enterprise License
run: |
LICENSE_KEY=$(grep '^ENTERPRISE_LICENSE_KEY=' .env | cut -d'=' -f2-)

View File

@@ -1,17 +1,22 @@
name: Build, release & deploy Formbricks images
on:
workflow_dispatch:
push:
tags:
- "v*"
release:
types: [published]
permissions:
contents: read
env:
ENVIRONMENT: ${{ github.event.release.prerelease && 'staging' || 'production' }}
jobs:
docker-build:
name: Build & release stable docker image
if: startsWith(github.ref, 'refs/tags/v')
name: Build & release docker image
uses: ./.github/workflows/release-docker-github.yml
secrets: inherit
with:
IS_PRERELEASE: ${{ github.event.release.prerelease }}
helm-chart-release:
name: Release Helm Chart
@@ -31,7 +36,7 @@ jobs:
- helm-chart-release
with:
VERSION: v${{ needs.docker-build.outputs.VERSION }}
ENVIRONMENT: "prod"
ENVIRONMENT: ${{ env.ENVIRONMENT }}
upload-sentry-sourcemaps:
name: Upload Sentry Sourcemaps
@@ -54,3 +59,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 }}

View File

@@ -29,6 +29,10 @@ jobs:
# with sigstore/fulcio when running outside of PRs.
id-token: write
outputs:
DOCKER_IMAGE: ${{ steps.extract_image_info.outputs.DOCKER_IMAGE }}
RELEASE_VERSION: ${{ steps.extract_image_info.outputs.RELEASE_VERSION }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
@@ -38,6 +42,56 @@ jobs:
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Generate SemVer version from branch or tag
id: generate_version
run: |
# Get reference name and type
REF_NAME="${{ github.ref_name }}"
REF_TYPE="${{ github.ref_type }}"
echo "Reference type: $REF_TYPE"
echo "Reference name: $REF_NAME"
if [[ "$REF_TYPE" == "tag" ]]; then
# If running from a tag, use the tag name
if [[ "$REF_NAME" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+.*$ ]]; then
# Tag looks like a SemVer, use it directly (remove 'v' prefix if present)
VERSION=$(echo "$REF_NAME" | sed 's/^v//')
echo "Using SemVer tag: $VERSION"
else
# Tag is not SemVer, treat as prerelease
SANITIZED_TAG=$(echo "$REF_NAME" | sed 's/[^a-zA-Z0-9.-]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g')
VERSION="0.0.0-$SANITIZED_TAG"
echo "Using tag as prerelease: $VERSION"
fi
else
# Running from branch, use branch name as prerelease
SANITIZED_BRANCH=$(echo "$REF_NAME" | sed 's/[^a-zA-Z0-9.-]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g')
VERSION="0.0.0-$SANITIZED_BRANCH"
echo "Using branch as prerelease: $VERSION"
fi
echo "VERSION=$VERSION" >> $GITHUB_ENV
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
echo "Generated SemVer version: $VERSION"
- name: Update package.json version
env:
VERSION: ${{ env.VERSION }}
run: |
cd ./apps/web
npm version $VERSION --no-git-tag-version
echo "Updated version to: $(npm pkg get version)"
- name: Set Sentry environment in .env
run: |
if ! grep -q "^SENTRY_ENVIRONMENT=staging$" .env 2>/dev/null; then
echo "SENTRY_ENVIRONMENT=staging" >> .env
echo "Added SENTRY_ENVIRONMENT=staging to .env file"
else
echo "SENTRY_ENVIRONMENT=staging already exists in .env file"
fi
- name: Set up Depot CLI
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
@@ -83,6 +137,21 @@ jobs:
database_url=${{ secrets.DUMMY_DATABASE_URL }}
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
- name: Extract image info for sourcemap upload
id: extract_image_info
run: |
# Use the first readable tag from metadata action output
DOCKER_IMAGE=$(echo "${{ steps.meta.outputs.tags }}" | head -n1 | xargs)
echo "DOCKER_IMAGE=$DOCKER_IMAGE" >> $GITHUB_OUTPUT
# Use the generated version for Sentry release
RELEASE_VERSION="$VERSION"
echo "RELEASE_VERSION=$RELEASE_VERSION" >> $GITHUB_OUTPUT
echo "Docker image: $DOCKER_IMAGE"
echo "Release version: $RELEASE_VERSION"
echo "Available tags: ${{ steps.meta.outputs.tags }}"
# Sign the resulting Docker image digest except on PRs.
# This will only write to the public Rekor transparency log when the Docker
# repository is public to avoid leaking data. If you would like to publish
@@ -97,3 +166,25 @@ jobs:
# This step uses the identity token to provision an ephemeral certificate
# against the sigstore community Fulcio instance.
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
upload-sentry-sourcemaps:
name: Upload Sentry Sourcemaps
runs-on: ubuntu-latest
permissions:
contents: read
needs:
- build
steps:
- name: Checkout
uses: actions/checkout@v4.2.2
with:
fetch-depth: 0
- name: Upload Sentry Sourcemaps
uses: ./.github/actions/upload-sentry-sourcemaps
continue-on-error: true
with:
docker_image: ${{ needs.build.outputs.DOCKER_IMAGE }}
release_version: ${{ needs.build.outputs.RELEASE_VERSION }}
sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }}
environment: staging

View File

@@ -7,6 +7,12 @@ name: Docker Release to Github
on:
workflow_call:
inputs:
IS_PRERELEASE:
description: 'Whether this is a prerelease (affects latest tag)'
required: false
type: boolean
default: false
outputs:
VERSION:
description: release version
@@ -45,10 +51,12 @@ jobs:
- name: Get Release Tag
id: extract_release_tag
run: |
# Extract version from tag (e.g., refs/tags/v1.2.3 -> 1.2.3)
TAG=${{ github.ref }}
TAG=${TAG#refs/tags/v}
echo "RELEASE_TAG=$TAG" >> $GITHUB_ENV
echo "VERSION=$TAG" >> $GITHUB_OUTPUT
echo "Using tag-based version: $TAG"
- name: Update package.json version
run: |
@@ -81,6 +89,13 @@ jobs:
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
# Default semver tags (version, major.minor, major)
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
# Only tag as 'latest' for stable releases (not prereleases)
type=raw,value=latest,enable=${{ inputs.IS_PRERELEASE != 'true' }}
# Build and push Docker image with Buildx (don't push on PR)
# https://github.com/docker/build-push-action

View File

@@ -43,6 +43,7 @@ jobs:
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
sed -i "s|REDIS_URL=.*|REDIS_URL=|" .env
- name: Run tests with coverage
run: |

View File

@@ -41,6 +41,7 @@ jobs:
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
sed -i "s|REDIS_URL=.*|REDIS_URL=|" .env
- name: Test
run: pnpm test

View File

@@ -86,7 +86,7 @@ vi.mock("@/lib/constants", () => ({
OIDC_ISSUER: "https://mock-oidc-issuer.com",
OIDC_SIGNING_ALGORITHM: "RS256",
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
REDIS_URL: undefined,
AUDIT_LOG_ENABLED: true,
}));

View File

@@ -89,7 +89,7 @@ vi.mock("@/lib/constants", () => ({
OIDC_ISSUER: "https://mock-oidc-issuer.com",
OIDC_SIGNING_ALGORITHM: "RS256",
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
REDIS_URL: undefined,
AUDIT_LOG_ENABLED: true,
}));

View File

@@ -97,7 +97,7 @@ vi.mock("@/lib/constants", () => ({
OIDC_ISSUER: "https://mock-oidc-issuer.com",
OIDC_SIGNING_ALGORITHM: "RS256",
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
REDIS_URL: undefined,
AUDIT_LOG_ENABLED: true,
}));

View File

@@ -35,7 +35,7 @@ vi.mock("@/lib/constants", () => ({
WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false,
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
REDIS_URL: undefined,
AUDIT_LOG_ENABLED: true,
}));

View File

@@ -34,7 +34,7 @@ vi.mock("@/lib/constants", () => ({
WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false,
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
REDIS_URL: undefined,
AUDIT_LOG_ENABLED: true,
}));

View File

@@ -27,7 +27,7 @@ vi.mock("@/lib/constants", () => ({
IS_POSTHOG_CONFIGURED: true,
SESSION_MAX_AGE: 1000,
AUDIT_LOG_ENABLED: 1,
REDIS_URL: "redis://localhost:6379",
REDIS_URL: undefined,
}));
vi.mock("@/lib/env", () => ({

View File

@@ -49,7 +49,7 @@ vi.mock("@/lib/constants", () => ({
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
REDIS_URL: undefined,
AUDIT_LOG_ENABLED: true,
}));

View File

@@ -0,0 +1,116 @@
"use client";
import { Button } from "@/modules/ui/components/button";
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
import React from "react";
import { TEnvironment } from "@formbricks/types/environment";
interface Column<T> {
/** Header text rendered in the table head */
header: React.ReactNode;
/** Cell renderer for an item */
render: (item: T) => React.ReactNode;
}
interface ActionButtonProps {
label: string;
onClick: () => void;
/** Optional Lucide Icon */
icon?: React.ReactNode;
/** Tooltip content */
tooltip?: string;
/** Variant override */
variant?: "default" | "outline" | "secondary" | "destructive" | "ghost";
}
interface IntegrationListPanelProps<T> {
readonly environment: TEnvironment;
readonly statusNode: React.ReactNode;
readonly reconnectAction: ActionButtonProps;
readonly addNewAction: ActionButtonProps;
readonly emptyMessage: string;
readonly items: T[];
readonly columns: Column<T>[];
readonly onRowClick: (index: number) => void;
readonly getRowKey?: (item: T, index: number) => string | number;
}
export function IntegrationListPanel<T>({
environment,
statusNode,
reconnectAction,
addNewAction,
emptyMessage,
items,
columns,
onRowClick,
getRowKey,
}: IntegrationListPanelProps<T>) {
return (
<div className="mt-6 flex w-full flex-col items-center justify-center p-6">
{/* Toolbar */}
<div className="flex w-full justify-end space-x-2">
<div className="mr-6 flex items-center">{statusNode}</div>
{/* Re-connect */}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant={reconnectAction.variant ?? "outline"} onClick={reconnectAction.onClick}>
{reconnectAction.icon}
{reconnectAction.label}
</Button>
</TooltipTrigger>
{reconnectAction.tooltip && <TooltipContent>{reconnectAction.tooltip}</TooltipContent>}
</Tooltip>
</TooltipProvider>
{/* Add new */}
<Button variant={addNewAction.variant ?? "default"} onClick={addNewAction.onClick}>
{addNewAction.icon}
{addNewAction.label}
</Button>
</div>
{/* Empty table view */}
{!items || items.length === 0 ? (
<div className="mt-4 w-full">
<EmptySpaceFiller
type="table"
environment={environment}
noWidgetRequired={true}
emptyMessage={emptyMessage}
/>
</div>
) : (
<div className="mt-4 flex w-full flex-col items-center justify-center">
<div className="mt-6 w-full rounded-lg border border-slate-200">
<div className="grid h-12 grid-cols-6 content-center rounded-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
{columns.map((col) => (
<div key={`hdr-${String(col.header)}`} className="col-span-2 hidden text-center sm:block">
{col.header}
</div>
))}
</div>
{items.map((item, index) => {
const key = getRowKey ? getRowKey(item, index) : index;
return (
<button
key={key}
className="grid h-16 w-full cursor-pointer grid-cols-6 content-center rounded-lg p-2 hover:bg-slate-100"
onClick={() => onRowClick(index)}>
{columns.map((col) => (
<div key={`cell-${String(col.header)}`} className="col-span-2 text-center">
{col.render(item)}
</div>
))}
</button>
);
})}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,60 @@
import { getLocalizedValue } from "@/lib/i18n/utils";
import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { TFnType } from "@tolgee/react";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
export interface QuestionItem {
id: string;
name: string;
type: TSurveyQuestionTypeEnum;
}
/**
* Build a flat list of selectable question / metadata items for integrations.
* Extracted to avoid duplication between integration modals.
*/
export const buildQuestionItems = (
selectedSurvey: TSurvey | null | undefined,
t: TFnType
): QuestionItem[] => {
const questions: QuestionItem[] = selectedSurvey
? replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((q) => ({
id: q.id,
name: getLocalizedValue(q.headline, "default"),
type: q.type,
})) || []
: [];
const variables: QuestionItem[] =
selectedSurvey?.variables.map((variable) => ({
id: variable.id,
name: variable.name,
type: TSurveyQuestionTypeEnum.OpenText,
})) || [];
const hiddenFields: QuestionItem[] = selectedSurvey?.hiddenFields.enabled
? selectedSurvey?.hiddenFields.fieldIds?.map((fId) => ({
id: fId,
name: `${t("common.hidden_field")} : ${fId}`,
type: TSurveyQuestionTypeEnum.OpenText,
})) || []
: [];
const metadata: QuestionItem[] = [
{
id: "metadata",
name: t("common.metadata"),
type: TSurveyQuestionTypeEnum.OpenText,
},
];
const createdAt: QuestionItem[] = [
{
id: "createdAt",
name: t("common.created_at"),
type: TSurveyQuestionTypeEnum.Date,
},
];
return [...questions, ...variables, ...hiddenFields, ...metadata, ...createdAt];
};

View File

@@ -1,15 +1,14 @@
"use client";
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
import { buildQuestionItems } from "@/app/(app)/environments/[environmentId]/integrations/lib/questionItems";
import {
ERRORS,
TYPE_MAPPING,
UNSUPPORTED_TYPES_BY_NOTION,
} from "@/app/(app)/environments/[environmentId]/integrations/notion/constants";
import NotionLogo from "@/images/notion.png";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { structuredClone } from "@/lib/pollyfills/structuredClone";
import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { getQuestionTypes } from "@/modules/survey/lib/questions";
import { Button } from "@/modules/ui/components/button";
import {
@@ -35,7 +34,7 @@ import {
TIntegrationNotionConfigData,
TIntegrationNotionDatabase,
} from "@formbricks/types/integration/notion";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TSurvey } from "@formbricks/types/surveys/types";
interface AddIntegrationModalProps {
environmentId: string;
@@ -118,47 +117,7 @@ export const AddIntegrationModal = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedDatabase?.id]);
const questionItems = useMemo(() => {
const questions = selectedSurvey
? replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((q) => ({
id: q.id,
name: getLocalizedValue(q.headline, "default"),
type: q.type,
}))
: [];
const variables =
selectedSurvey?.variables.map((variable) => ({
id: variable.id,
name: variable.name,
type: TSurveyQuestionTypeEnum.OpenText,
})) || [];
const hiddenFields = selectedSurvey?.hiddenFields.enabled
? selectedSurvey?.hiddenFields.fieldIds?.map((fId) => ({
id: fId,
name: `${t("common.hidden_field")} : ${fId}`,
type: TSurveyQuestionTypeEnum.OpenText,
})) || []
: [];
const Metadata = [
{
id: "metadata",
name: t("common.metadata"),
type: TSurveyQuestionTypeEnum.OpenText,
},
];
const createdAt = [
{
id: "createdAt",
name: t("common.created_at"),
type: TSurveyQuestionTypeEnum.Date,
},
];
return [...questions, ...variables, ...hiddenFields, ...Metadata, ...createdAt];
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedSurvey?.id]);
const questionItems = useMemo(() => buildQuestionItems(selectedSurvey, t), [selectedSurvey?.id, t]);
useEffect(() => {
if (selectedIntegration) {

View File

@@ -5,8 +5,6 @@ import { timeSince } from "@/lib/time";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
import { useTranslate } from "@tolgee/react";
import { RefreshCcwIcon, Trash2Icon } from "lucide-react";
import React, { useState } from "react";
@@ -14,6 +12,7 @@ import toast from "react-hot-toast";
import { TEnvironment } from "@formbricks/types/environment";
import { TIntegrationNotion, TIntegrationNotionConfigData } from "@formbricks/types/integration/notion";
import { TUserLocale } from "@formbricks/types/user";
import { IntegrationListPanel } from "../../components/IntegrationListPanel";
interface ManageIntegrationProps {
environment: TEnvironment;
@@ -70,78 +69,58 @@ export const ManageIntegration = ({
};
return (
<div className="mt-6 flex w-full flex-col items-center justify-center p-6">
<div className="flex w-full justify-end space-x-2">
<div className="mr-6 flex items-center">
<span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span>
<span className="text-slate-500">
{t("environments.integrations.notion.connected_with_workspace", {
workspace: notionIntegration.config.key.workspace_name,
})}
</span>
</div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" onClick={handleNotionAuthorization}>
<RefreshCcwIcon className="mr-2 h-4 w-4" />
{t("environments.integrations.notion.update_connection")}
</Button>
</TooltipTrigger>
<TooltipContent>{t("environments.integrations.notion.update_connection_tooltip")}</TooltipContent>
</Tooltip>
</TooltipProvider>
<Button
onClick={() => {
<>
<IntegrationListPanel
environment={environment}
statusNode={
<>
<span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span>
<span className="text-slate-500">
{t("environments.integrations.notion.connected_with_workspace", {
workspace: notionIntegration.config.key.workspace_name,
})}
</span>
</>
}
reconnectAction={{
label: t("environments.integrations.notion.update_connection"),
onClick: handleNotionAuthorization,
icon: <RefreshCcwIcon className="mr-2 h-4 w-4" />,
tooltip: t("environments.integrations.notion.update_connection_tooltip"),
variant: "outline",
}}
addNewAction={{
label: t("environments.integrations.notion.link_new_database"),
onClick: () => {
setSelectedIntegration(null);
setOpenAddIntegrationModal(true);
}}>
{t("environments.integrations.notion.link_new_database")}
},
}}
emptyMessage={t("environments.integrations.notion.no_databases_found")}
items={integrationArray}
columns={[
{
header: t("common.survey"),
render: (item: TIntegrationNotionConfigData) => item.surveyName,
},
{
header: t("environments.integrations.notion.database_name"),
render: (item: TIntegrationNotionConfigData) => item.databaseName,
},
{
header: t("common.updated_at"),
render: (item: TIntegrationNotionConfigData) => timeSince(item.createdAt.toString(), locale),
},
]}
onRowClick={editIntegration}
getRowKey={(item: TIntegrationNotionConfigData, idx) => `${idx}-${item.databaseId}`}
/>
<div className="mt-4 flex justify-center">
<Button variant="ghost" onClick={() => setIsDeleteIntegrationModalOpen(true)}>
<Trash2Icon />
{t("environments.integrations.delete_integration")}
</Button>
</div>
{!integrationArray || integrationArray.length === 0 ? (
<div className="mt-4 w-full">
<EmptySpaceFiller
type="table"
environment={environment}
noWidgetRequired={true}
emptyMessage={t("environments.integrations.notion.no_databases_found")}
/>
</div>
) : (
<div className="mt-4 flex w-full flex-col items-center justify-center">
<div className="mt-6 w-full rounded-lg border border-slate-200">
<div className="grid h-12 grid-cols-6 content-center rounded-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
<div className="col-span-2 hidden text-center sm:block">{t("common.survey")}</div>
<div className="col-span-2 hidden text-center sm:block">
{t("environments.integrations.notion.database_name")}
</div>
<div className="col-span-2 hidden text-center sm:block">{t("common.updated_at")}</div>
</div>
{integrationArray &&
integrationArray.map((data, index) => {
return (
<button
key={`${index}-${data.databaseId}`}
className="grid h-16 w-full cursor-pointer grid-cols-6 content-center rounded-lg p-2 hover:bg-slate-100"
onClick={() => {
editIntegration(index);
}}>
<div className="col-span-2 text-center">{data.surveyName}</div>
<div className="col-span-2 text-center">{data.databaseName}</div>
<div className="col-span-2 text-center">
{timeSince(data.createdAt.toString(), locale)}
</div>
</button>
);
})}
</div>
</div>
)}
<Button variant="ghost" onClick={() => setIsDeleteIntegrationModalOpen(true)} className="mt-4">
<Trash2Icon />
{t("environments.integrations.delete_integration")}
</Button>
<DeleteDialog
open={isDeleteIntegrationModalOpen}
@@ -151,6 +130,6 @@ export const ManageIntegration = ({
text={t("environments.integrations.delete_integration_confirmation")}
isDeleting={isDeleting}
/>
</div>
</>
);
};

View File

@@ -32,7 +32,7 @@ vi.mock("@/lib/constants", () => ({
GOOGLE_SHEETS_CLIENT_SECRET: "test-client-secret",
GOOGLE_SHEETS_REDIRECT_URL: "test-redirect-url",
SESSION_MAX_AGE: 1000,
REDIS_URL: "mock-redis-url",
REDIS_URL: undefined,
AUDIT_LOG_ENABLED: true,
}));

View File

@@ -6,6 +6,7 @@ import JsLogo from "@/images/jslogo.png";
import MakeLogo from "@/images/make-small.png";
import n8nLogo from "@/images/n8n.png";
import notionLogo from "@/images/notion.png";
import PlainCom from "@/images/plain.webp";
import SlackLogo from "@/images/slacklogo.png";
import WebhookLogo from "@/images/webhook.png";
import ZapierLogo from "@/images/zapier-small.png";
@@ -50,6 +51,7 @@ const Page = async (props) => {
const isGoogleSheetsIntegrationConnected = isIntegrationConnected("googleSheets");
const isNotionIntegrationConnected = isIntegrationConnected("notion");
const isPlainIntegrationConnected = isIntegrationConnected("plain");
const isAirtableIntegrationConnected = isIntegrationConnected("airtable");
const isN8nIntegrationConnected = isIntegrationConnected("n8n");
const isSlackIntegrationConnected = isIntegrationConnected("slack");
@@ -207,6 +209,20 @@ const Page = async (props) => {
: `${activePiecesWebhookCount} ${t("common.integrations")}`,
disabled: isReadOnly,
},
{
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/activepieces",
docsText: t("common.docs"),
docsNewTab: true,
connectHref: `/environments/${params.environmentId}/integrations/plain`,
connectText: `${isPlainIntegrationConnected ? t("common.manage") : t("common.connect")}`,
connectNewTab: false,
label: "Plain",
description: t("environments.integrations.plain.plain_integration_description"),
icon: <Image src={PlainCom} alt="Plain.com Logo" />,
connected: isPlainIntegrationConnected,
statusText: isPlainIntegrationConnected ? t("common.connected") : t("common.not_connected"),
disabled: isReadOnly,
},
];
integrationCards.unshift({

View File

@@ -0,0 +1,62 @@
"use server";
import { ENCRYPTION_KEY } from "@/lib/constants";
import { symmetricEncrypt } from "@/lib/crypto";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import type { TIntegrationPlainConfigData } from "@formbricks/types/integration/plain";
const ZConnectPlainIntegration = z.object({
environmentId: ZId,
key: z.string().min(1),
});
export const connectPlainIntegrationAction = authenticatedActionClient
.schema(ZConnectPlainIntegration)
.action(async ({ ctx, parsedInput }) => {
const { environmentId, key } = parsedInput;
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
const projectId = await getProjectIdFromEnvironmentId(environmentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId,
},
],
});
const encryptedAccessToken = symmetricEncrypt(key, ENCRYPTION_KEY);
const existingIntegration = await getIntegrationByType(environmentId, "plain");
const plainData: TIntegrationPlainConfigData[] =
existingIntegration?.type === "plain"
? (existingIntegration.config.data as TIntegrationPlainConfigData[])
: [];
const integration = await createOrUpdateIntegration(environmentId, {
type: "plain",
config: {
key: encryptedAccessToken,
data: plainData,
},
});
return {
success: true,
integration,
};
});

View File

@@ -0,0 +1,567 @@
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/integrations/plain/components/AddIntegrationModal";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import {
TIntegrationPlain,
TIntegrationPlainConfigData,
TPlainFieldType,
} from "@formbricks/types/integration/plain";
import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
// Mock actions and utilities
vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({
createOrUpdateIntegrationAction: vi.fn(),
}));
vi.mock("@/lib/i18n/utils", () => ({
getLocalizedValue: (value: any, _locale: string) => value?.default || "",
}));
vi.mock("@/lib/pollyfills/structuredClone", () => ({
structuredClone: (obj: any) => JSON.parse(JSON.stringify(obj)),
}));
vi.mock("@/lib/utils/recall", () => ({
replaceHeadlineRecall: (survey: any) => survey,
}));
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, onClick, loading, variant, type = "button" }: any) => (
<button onClick={onClick} disabled={loading} data-variant={variant} type={type}>
{loading ? "Loading..." : children}
</button>
),
}));
vi.mock("@/modules/ui/components/dropdown-selector", () => ({
DropdownSelector: ({ label, items, selectedItem, setSelectedItem, placeholder, disabled }: any) => {
// Ensure the selected item is always available as an option
const allOptions = [...items];
if (selectedItem && !items.some((item: any) => item.id === selectedItem.id)) {
// Use a simple object structure consistent with how options are likely used
allOptions.push({ id: selectedItem.id, name: selectedItem.name });
}
// Remove duplicates just in case
const uniqueOptions = Array.from(new Map(allOptions.map((item) => [item.id, item])).values());
return (
<div>
{label && <label>{label}</label>}
<select
data-testid={`dropdown-${label?.toLowerCase().replace(/\s+/g, "-") || placeholder?.toLowerCase().replace(/\s+/g, "-")}`}
value={selectedItem?.id || ""} // Still set value based on selectedItem prop
onChange={(e) => {
const selected = uniqueOptions.find((item: any) => item.id === e.target.value);
setSelectedItem(selected);
}}
disabled={disabled}>
<option value="">{placeholder || "Select..."}</option>
{/* Render options from the potentially augmented list */}
{uniqueOptions.map((item: any) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
</div>
);
},
}));
vi.mock("@/modules/ui/components/label", () => ({
Label: ({ children }: { children: React.ReactNode }) => <label>{children}</label>,
}));
vi.mock("@/modules/ui/components/dialog", () => ({
Dialog: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
open ? <div data-testid="dialog">{children}</div> : null,
DialogContent: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div data-testid="dialog-content" className={className}>
{children}
</div>
),
DialogHeader: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div data-testid="dialog-header" className={className}>
{children}
</div>
),
DialogDescription: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<p data-testid="dialog-description" className={className}>
{children}
</p>
),
DialogTitle: ({ children }: { children: React.ReactNode }) => (
<h2 data-testid="dialog-title">{children}</h2>
),
DialogBody: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div data-testid="dialog-body" className={className}>
{children}
</div>
),
DialogFooter: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div data-testid="dialog-footer" className={className}>
{children}
</div>
),
}));
vi.mock("lucide-react", () => ({
PlusIcon: () => <span data-testid="plus-icon">+</span>,
TrashIcon: () => <span data-testid="trash-icon">🗑</span>,
}));
vi.mock("next/image", () => ({
// eslint-disable-next-line @next/next/no-img-element
default: ({ src, alt }: { src: string; alt: string }) => <img src={src} alt={alt} />,
}));
vi.mock("react-hook-form", () => ({
useForm: () => ({
handleSubmit: (callback: any) => (event: any) => {
event.preventDefault();
return callback();
},
register: vi.fn(),
setValue: vi.fn(),
watch: vi.fn(),
formState: { errors: {} },
}),
}));
vi.mock("react-hot-toast", () => ({
default: {
success: vi.fn(),
error: vi.fn(),
},
}));
vi.mock("@tolgee/react", async () => {
const MockTolgeeProvider = ({ children }: { children: React.ReactNode }) => <>{children}</>;
const useTranslate = () => ({
t: (key: string) => {
// Simple mock translation function
if (key === "common.warning") return "Warning";
if (key === "common.metadata") return "Metadata";
if (key === "common.created_at") return "Created at";
if (key === "common.hidden_field") return "Hidden Field";
if (key === "common.first_name") return "First Name";
if (key === "common.last_name") return "Last Name";
if (key === "common.email") return "Email";
if (key === "common.select_survey") return "Select survey";
if (key === "common.delete") return "Delete";
if (key === "common.cancel") return "Cancel";
if (key === "common.update") return "Update";
if (key === "environments.integrations.plain.configure_plain_integration")
return "Configure Plain Integration";
if (key === "environments.integrations.plain.plain_integration_description")
return "Connect your Plain account to send survey responses as threads.";
if (key === "environments.integrations.plain.plain_logo") return "Plain logo";
if (key === "environments.integrations.plain.map_formbricks_fields_to_plain")
return "Map Formbricks fields to Plain";
if (key === "environments.integrations.plain.select_a_survey_question")
return "Select a survey question";
if (key === "environments.integrations.plain.select_a_field_to_map") return "Select a field to map";
if (key === "environments.integrations.plain.enter_label_id") return "Enter Label ID";
if (key === "environments.integrations.plain.connect") return "Connect";
if (key === "environments.integrations.plain.no_contact_info_question")
return "No contact info question found in survey";
if (key === "environments.integrations.plain.contact_info_missing_fields")
return "Contact info question is missing required fields:";
if (key === "environments.integrations.plain.contact_info_warning") return "Contact Info Warning";
if (key === "environments.integrations.plain.contact_info_missing_fields_description")
return "The following fields are missing";
if (key === "environments.integrations.plain.please_select_at_least_one_mapping")
return "Please select at least one mapping.";
if (key === "environments.integrations.plain.please_resolve_mapping_errors")
return "Please resolve mapping errors.";
if (key === "environments.integrations.plain.please_complete_mapping_fields")
return "Please complete mapping fields.";
if (key === "environments.integrations.please_select_a_survey_error") return "Please select a survey.";
if (key === "environments.integrations.create_survey_warning")
return "You need to create a survey first.";
if (key === "environments.integrations.integration_updated_successfully")
return "Integration updated successfully.";
if (key === "environments.integrations.integration_added_successfully")
return "Integration added successfully.";
if (key === "environments.integrations.integration_removed_successfully")
return "Integration removed successfully.";
return key; // Return key if no translation is found
},
});
return { TolgeeProvider: MockTolgeeProvider, useTranslate };
});
// Mock dependencies
const createOrUpdateIntegrationAction = vi.mocked(
(await import("@/app/(app)/environments/[environmentId]/integrations/actions"))
.createOrUpdateIntegrationAction
);
const toast = vi.mocked((await import("react-hot-toast")).default);
const environmentId = "test-env-id";
const mockSetOpen = vi.fn();
// Create a mock survey with a ContactInfo question
const surveys: TSurvey[] = [
{
id: "survey1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Survey 1",
type: "app",
environmentId: environmentId,
status: "inProgress",
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Question 1?" },
required: true,
} as unknown as TSurveyQuestion,
{
id: "q2",
type: TSurveyQuestionTypeEnum.ContactInfo,
headline: { default: "Contact Info" },
required: true,
firstName: { show: true },
lastName: { show: true },
email: { show: true },
} as unknown as TSurveyQuestion,
],
variables: [{ id: "var1", name: "Variable 1" }],
hiddenFields: { enabled: true, fieldIds: ["hf1"] },
triggers: [],
recontactDays: null,
autoClose: null,
closeOnDate: null,
delay: 0,
displayOption: "displayOnce",
displayPercentage: null,
autoComplete: null,
singleUse: null,
styling: null,
surveyClosedMessage: null,
segment: null,
languages: [],
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
pin: null,
resultShareKey: null,
displayLimit: null,
} as unknown as TSurvey,
{
id: "survey2",
createdAt: new Date(),
updatedAt: new Date(),
name: "Survey 2",
type: "link",
environmentId: environmentId,
status: "draft",
questions: [
{
id: "q3",
type: TSurveyQuestionTypeEnum.ContactInfo,
headline: { default: "Partial Contact Info" },
required: true,
firstName: { show: true },
lastName: { show: false }, // Missing lastName
email: { show: true },
} as unknown as TSurveyQuestion,
],
variables: [],
hiddenFields: { enabled: false },
triggers: [],
recontactDays: null,
autoClose: null,
closeOnDate: null,
delay: 0,
displayOption: "displayOnce",
displayPercentage: null,
autoComplete: null,
singleUse: null,
styling: null,
surveyClosedMessage: null,
segment: null,
languages: [],
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
pin: null,
resultShareKey: null,
displayLimit: null,
} as unknown as TSurvey,
];
const mockPlainIntegration: TIntegrationPlain = {
id: "integration1",
type: "plain",
environmentId: environmentId,
config: {
key: "test-api-key",
data: [], // Initially empty
},
};
const mockSelectedIntegration: TIntegrationPlainConfigData & { index: number } = {
surveyId: surveys[0].id,
surveyName: surveys[0].name,
mapping: [
{
plainField: { id: "threadTitle", name: "Thread Title", type: "title" as TPlainFieldType },
question: { id: "q1", name: "Question 1?", type: TSurveyQuestionTypeEnum.OpenText },
},
{
plainField: { id: "componentText", name: "Component Text", type: "componentText" as TPlainFieldType },
question: { id: "var1", name: "Variable 1", type: TSurveyQuestionTypeEnum.OpenText },
},
],
includeCreatedAt: true,
includeComponents: true,
labelId: "custom-label",
createdAt: new Date(),
index: 0,
};
describe("AddIntegrationModal (Plain)", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
beforeEach(() => {
// Reset integration data before each test if needed
mockPlainIntegration.config.data = [
{ ...mockSelectedIntegration }, // Simulate existing data for update/delete tests
];
});
test("renders correctly when open (create mode)", () => {
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
plainIntegration={{
...mockPlainIntegration,
config: { ...mockPlainIntegration.config, data: [] },
}}
selectedIntegration={null}
/>
);
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByText("Configure Plain Integration")).toBeInTheDocument();
expect(screen.getByTestId("dropdown-select-survey")).toBeInTheDocument();
expect(screen.getByText("Cancel")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Connect" })).toBeInTheDocument();
expect(screen.queryByText("Delete")).not.toBeInTheDocument();
});
test("renders correctly when open (update mode)", async () => {
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
plainIntegration={mockPlainIntegration}
selectedIntegration={mockSelectedIntegration}
/>
);
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("dropdown-select-survey")).toHaveValue(surveys[0].id);
expect(screen.getByText("Map Formbricks fields to Plain")).toBeInTheDocument();
// Check if mapping rows are rendered
await waitFor(() => {
const questionDropdowns = screen.getAllByTestId("dropdown-select-a-survey-question");
expect(questionDropdowns).toHaveLength(2); // Expecting two rows based on mockSelectedIntegration
});
expect(screen.getByText("Delete")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Update" })).toBeInTheDocument();
});
test("shows survey selection and enables mapping when survey is selected", async () => {
const user = userEvent.setup();
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
plainIntegration={{
...mockPlainIntegration,
config: { ...mockPlainIntegration.config, data: [] },
}}
selectedIntegration={null}
/>
);
// Select a survey
const surveyDropdown = screen.getByTestId("dropdown-select-survey");
await user.selectOptions(surveyDropdown, surveys[0].id);
// Check if mapping section appears
expect(screen.getByText("Map Formbricks fields to Plain")).toBeInTheDocument();
// Check if default mapping rows are present
const questionDropdowns = screen.getAllByTestId("dropdown-select-a-survey-question");
expect(questionDropdowns).toHaveLength(2); // Two default mapping rows
});
test("adds and removes mapping rows", async () => {
const user = userEvent.setup();
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
plainIntegration={mockPlainIntegration}
selectedIntegration={null}
/>
);
// Select a survey first
const surveyDropdown = screen.getByTestId("dropdown-select-survey");
await user.selectOptions(surveyDropdown, surveys[0].id);
// Initial mapping rows
let plusButtons = screen.getAllByTestId("plus-icon");
expect(plusButtons).toHaveLength(2); // Two default rows
// Add a new row
await user.click(plusButtons[0]);
// Check if a new row was added
plusButtons = screen.getAllByTestId("plus-icon");
expect(plusButtons).toHaveLength(3); // Now three rows
// Try to remove a row (not the mandatory ones)
const trashButtons = screen.getAllByTestId("trash-icon");
expect(trashButtons).toHaveLength(1); // Only the new row should be removable
await user.click(trashButtons[0]);
// Check if row was removed
plusButtons = screen.getAllByTestId("plus-icon");
expect(plusButtons).toHaveLength(2); // Back to two rows
});
test("shows warning for survey with incomplete contact info", async () => {
const user = userEvent.setup();
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
plainIntegration={mockPlainIntegration}
selectedIntegration={null}
/>
);
// Select survey with incomplete contact info
const surveyDropdown = screen.getByTestId("dropdown-select-survey");
await user.selectOptions(surveyDropdown, surveys[1].id);
// Check if warning appears
expect(screen.getByText("Contact Info Warning")).toBeInTheDocument();
expect(screen.getByText(/Last Name/)).toBeInTheDocument(); // Missing field
});
test("handles form submission with validation errors", async () => {
const user = userEvent.setup();
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
plainIntegration={mockPlainIntegration}
selectedIntegration={null}
/>
);
// Try to submit without selecting a survey
const connectButton = screen.getByRole("button", { name: "Connect" });
await user.click(connectButton);
// Check if error toast was shown
expect(toast.error).toHaveBeenCalledWith("Please select a survey.");
});
test("handles successful integration update", async () => {
const user = userEvent.setup();
createOrUpdateIntegrationAction.mockResolvedValue({});
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
plainIntegration={mockPlainIntegration}
selectedIntegration={mockSelectedIntegration}
/>
);
// Change a mapping
const questionDropdowns = screen.getAllByTestId("dropdown-select-a-survey-question");
await user.selectOptions(questionDropdowns[0], "q2"); // Change to Contact Info question
// Submit the form
const updateButton = screen.getByRole("button", { name: "Update" });
await user.click(updateButton);
// Check if integration was updated
await waitFor(() => {
expect(createOrUpdateIntegrationAction).toHaveBeenCalled();
expect(toast.success).toHaveBeenCalledWith("Integration updated successfully.");
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
});
test("handles integration deletion", async () => {
const user = userEvent.setup();
createOrUpdateIntegrationAction.mockResolvedValue({});
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
plainIntegration={mockPlainIntegration}
selectedIntegration={mockSelectedIntegration}
/>
);
// Click delete button
const deleteButton = screen.getByRole("button", { name: "Delete" });
await user.click(deleteButton);
// Check if integration was deleted
await waitFor(() => {
expect(createOrUpdateIntegrationAction).toHaveBeenCalled();
expect(toast.success).toHaveBeenCalledWith("Integration removed successfully.");
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
});
test("calls setOpen(false) and resets form on cancel", async () => {
const user = userEvent.setup();
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
plainIntegration={mockPlainIntegration}
selectedIntegration={null}
/>
);
// Click cancel button
const cancelButton = screen.getByRole("button", { name: "Cancel" });
await user.click(cancelButton);
// Check if modal was closed
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
});

View File

@@ -0,0 +1,627 @@
"use client";
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
import { buildQuestionItems } from "@/app/(app)/environments/[environmentId]/integrations/lib/questionItems";
import PlainLogo from "@/images/plain.webp";
import { structuredClone } from "@/lib/pollyfills/structuredClone";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { DropdownSelector } from "@/modules/ui/components/dropdown-selector";
import { Label } from "@/modules/ui/components/label";
import { useTranslate } from "@tolgee/react";
import { PlusIcon, TrashIcon } from "lucide-react";
import Image from "next/image";
import React, { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { TIntegrationInput } from "@formbricks/types/integration";
import {
TIntegrationPlain,
TIntegrationPlainConfigData,
TPlainFieldType,
TPlainMapping,
} from "@formbricks/types/integration/plain";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { INITIAL_MAPPING, PLAIN_FIELD_TYPES } from "../constants";
interface AddIntegrationModalProps {
environmentId: string;
surveys: TSurvey[];
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
plainIntegration: TIntegrationPlain;
selectedIntegration: (TIntegrationPlainConfigData & { index: number }) | null;
}
export const AddIntegrationModal = ({
environmentId,
surveys,
open,
setOpen,
plainIntegration,
selectedIntegration,
}: AddIntegrationModalProps) => {
const { t } = useTranslate();
const { handleSubmit } = useForm();
const [selectedSurvey, setSelectedSurvey] = useState<TSurvey | null>(null);
const [mapping, setMapping] = useState<
{
plainField: { id: string; name: string; type: TPlainFieldType; config?: Record<string, any> };
question: { id: string; name: string; type: string };
error?: {
type: string;
msg: React.ReactNode | string;
} | null;
isMandatory?: boolean;
}[]
>(INITIAL_MAPPING.map((m) => ({ ...m })));
const [isDeleting, setIsDeleting] = useState<boolean>(false);
const [isLinkingIntegration, setIsLinkingIntegration] = useState(false);
const plainFieldTypes = PLAIN_FIELD_TYPES;
// State to track custom label ID values
const [labelIdValues, setLabelIdValues] = useState<Record<string, string>>({});
const plainIntegrationData: TIntegrationInput = {
type: "plain",
config: {
key: plainIntegration?.config?.key,
data: plainIntegration.config?.data || [],
},
};
const questionItems = useMemo(() => buildQuestionItems(selectedSurvey, t), [selectedSurvey?.id, t]);
const checkContactInfoQuestion = (survey: TSurvey | null) => {
if (!survey) return { hasContactInfo: false, missingFields: [] };
// Find ContactInfo questions in the survey
const contactInfoQuestions = survey.questions.filter(
(q) => q.type === TSurveyQuestionTypeEnum.ContactInfo
);
if (contactInfoQuestions.length === 0) {
return { hasContactInfo: false, missingFields: [] };
}
// Check if any ContactInfo question has all required fields enabled
for (const question of contactInfoQuestions) {
const contactQuestion = question as any; // Type assertion to access fields
const missingFields: string[] = [];
if (!contactQuestion.firstName?.show) {
missingFields.push("firstName");
}
if (!contactQuestion.lastName?.show) {
missingFields.push("lastName");
}
if (!contactQuestion.email?.show) {
missingFields.push("email");
}
// If this question has all required fields, return success
if (missingFields.length === 0) {
return {
hasContactInfo: true,
missingFields: [],
questionId: question.id,
question: contactQuestion,
};
}
// Otherwise continue checking other questions
}
// If we get here, we found ContactInfo questions but none with all required fields
return {
hasContactInfo: true,
missingFields: ["firstName", "lastName", "email"],
partialMatch: true,
};
};
useEffect(() => {
if (selectedIntegration) {
setSelectedSurvey(
surveys.find((survey) => {
return survey.id === selectedIntegration.surveyId;
})!
);
// Ensure mandatory fields remain protected from deletion when editing
setMapping(
selectedIntegration.mapping.map((m) => ({
...m,
// Re-apply mandatory flag based on field id
isMandatory: m.plainField.id === "threadTitle" || m.plainField.id === "componentText",
}))
);
// Initialize labelIdValues from existing mapping
const newLabelIdValues: Record<string, string> = {};
selectedIntegration.mapping.forEach((m, idx) => {
if (m.plainField.id === "labelTypeId") {
newLabelIdValues[idx] = m.question.id;
}
});
setLabelIdValues(newLabelIdValues);
return;
}
resetForm();
}, [selectedIntegration, surveys]);
// State to track contact info validation results
const [contactInfoValidation, setContactInfoValidation] = useState<{
hasContactInfo: boolean;
missingFields: string[];
partialMatch?: boolean;
questionId?: string;
question?: any;
}>({ hasContactInfo: false, missingFields: [] });
// Check for ContactInfo question when survey is selected
useEffect(() => {
if (selectedSurvey) {
const contactCheck = checkContactInfoQuestion(selectedSurvey);
setContactInfoValidation(contactCheck);
} else {
setContactInfoValidation({ hasContactInfo: false, missingFields: [] });
}
}, [selectedSurvey]);
const linkIntegration = async () => {
try {
if (!selectedSurvey) {
throw new Error(t("environments.integrations.please_select_a_survey_error"));
}
const contactCheck = checkContactInfoQuestion(selectedSurvey);
if (!contactCheck.hasContactInfo) {
toast.error(t("environments.integrations.plain.no_contact_info_question"));
return;
} else if (contactCheck.partialMatch || contactCheck.missingFields.length > 0) {
const missingFieldsFormatted = contactCheck.missingFields
.map((field) => {
switch (field) {
case "firstName":
return t("common.first_name");
case "lastName":
return t("common.last_name");
case "email":
return t("common.email");
default:
return field;
}
})
.join(", ");
toast.error(
`${t("environments.integrations.plain.contact_info_missing_fields")} ${missingFieldsFormatted}.`
);
return;
}
if (mapping.length === 0 || (mapping.length === 1 && !mapping[0].question.id)) {
throw new Error(t("environments.integrations.plain.please_select_at_least_one_mapping"));
}
if (mapping.filter((m) => m.error).length > 0) {
throw new Error(t("environments.integrations.plain.please_resolve_mapping_errors"));
}
if (mapping.filter((m) => !m.question.id).length >= 1) {
throw new Error(t("environments.integrations.plain.please_complete_mapping_fields"));
}
setIsLinkingIntegration(true);
// Find Label ID mapping if it exists
const labelIdMapping = mapping.find((m) => m.plainField.id === "labelTypeId");
const labelId = labelIdMapping?.question.id || "";
const integrationData: TIntegrationPlainConfigData = {
surveyId: selectedSurvey.id,
surveyName: selectedSurvey.name,
mapping: mapping.map((m) => {
const { error, ...rest } = m;
return rest as TPlainMapping;
}),
includeCreatedAt: true,
includeComponents: true,
labelId: labelId, // Add the Label ID from the mapping
createdAt: new Date(),
};
if (selectedIntegration) {
// update action
plainIntegrationData.config.data[selectedIntegration.index] = integrationData;
} else {
// create action
plainIntegrationData.config.data.push(integrationData);
}
await createOrUpdateIntegrationAction({ environmentId, integrationData: plainIntegrationData });
if (selectedIntegration) {
toast.success(t("environments.integrations.integration_updated_successfully"));
} else {
toast.success(t("environments.integrations.integration_added_successfully"));
}
resetForm();
setOpen(false);
} catch (e) {
toast.error(e.message);
} finally {
setIsLinkingIntegration(false);
}
};
const deleteLink = async () => {
plainIntegrationData.config.data.splice(selectedIntegration!.index, 1);
try {
setIsDeleting(true);
await createOrUpdateIntegrationAction({ environmentId, integrationData: plainIntegrationData });
toast.success(t("environments.integrations.integration_removed_successfully"));
setOpen(false);
} catch (error) {
toast.error(error.message);
} finally {
setIsDeleting(false);
}
};
const resetForm = () => {
setIsLinkingIntegration(false);
setSelectedSurvey(null);
setLabelIdValues({});
setMapping(INITIAL_MAPPING.map((m) => ({ ...m })));
};
const getFilteredQuestionItems = (selectedIdx) => {
const selectedQuestionIds = mapping.filter((_, idx) => idx !== selectedIdx).map((m) => m.question.id);
return questionItems.filter((q) => !selectedQuestionIds.includes(q.id));
};
const createCopy = (item) => structuredClone(item);
const getFilteredPlainFieldTypes = (selectedIdx: number) => {
const selectedPlainFieldIds = mapping.filter((_, idx) => idx !== selectedIdx).map((m) => m.plainField.id);
return plainFieldTypes.filter((field) => !selectedPlainFieldIds.includes(field.id));
};
const MappingRow = ({ idx }: { idx: number }) => {
const filteredQuestionItems = getFilteredQuestionItems(idx);
const filteredPlainFields = getFilteredPlainFieldTypes(idx);
const addRow = () => {
const usedFieldIds = mapping.map((m) => m.plainField.id);
const availableField = plainFieldTypes.find((field) => !usedFieldIds.includes(field.id)) || {
id: "threadField",
name: "Thread Field",
type: "threadField" as TPlainFieldType,
};
setMapping((prev) => [
...prev,
{
plainField: availableField,
question: { id: "", name: "", type: "" },
isMandatory: false,
},
]);
};
const deleteRow = () => {
if (mapping[idx].isMandatory) return;
setMapping((prev) => {
return prev.filter((_, i) => i !== idx);
});
};
interface ErrorMsgProps {
error:
| {
type: string;
msg: React.ReactNode | string;
}
| null
| undefined;
field?: { id: string; name: string; type: TPlainFieldType; config?: Record<string, any> };
ques?: { id: string; name: string; type: string };
}
const ErrorMsg = ({ error }: ErrorMsgProps) => {
if (!error) return null;
return (
<div className="my-4 w-full rounded-lg bg-red-100 p-4 text-sm text-red-800">
<span className="mb-2 block">{error.type}</span>
{error.msg}
</div>
);
};
return (
<div className="w-full">
<ErrorMsg
key={idx}
error={mapping[idx]?.error}
field={mapping[idx].plainField}
ques={mapping[idx].question}
/>
<div className="flex w-full items-center space-x-2">
<div className="flex w-full items-center">
{mapping[idx].plainField.id === "labelTypeId" ? (
<div className="max-w-full flex-1">
<input
type="text"
className="w-full rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-slate-500 focus:outline-none focus:ring-2 focus:ring-slate-200"
placeholder={t("environments.integrations.plain.enter_label_id")}
value={labelIdValues[idx] || ""}
onChange={(e) => {
setLabelIdValues((prev) => ({
...prev,
[idx]: e.target.value,
}));
setMapping((prev) => {
const copy = createCopy(prev);
copy[idx] = {
...copy[idx],
question: {
id: e.target.value,
name: "Label ID",
type: "labelTypeId",
},
error: null,
};
return copy;
});
}}
/>
</div>
) : (
// Regular question dropdown for non-Label ID fields
<div className="max-w-full flex-1">
<DropdownSelector
placeholder={t("environments.integrations.plain.select_a_survey_question")}
items={filteredQuestionItems}
selectedItem={mapping?.[idx]?.question}
setSelectedItem={(item) => {
setMapping((prev) => {
const copy = createCopy(prev);
copy[idx] = {
...copy[idx],
question: item,
error: null,
};
return copy;
});
}}
disabled={questionItems.length === 0}
/>
</div>
)}
<div className="h-px w-4 border-t border-t-slate-300" />
<div className="max-w-full flex-1">
<DropdownSelector
placeholder={t("environments.integrations.plain.select_a_field_to_map")}
items={filteredPlainFields}
selectedItem={mapping?.[idx]?.plainField}
disabled={filteredPlainFields.length === 0}
setSelectedItem={(item) => {
setMapping((prev) => {
const copy = createCopy(prev);
copy[idx] = {
...copy[idx],
plainField: item,
error: null,
};
return copy;
});
}}
/>
</div>
</div>
<div className="flex space-x-2">
{!mapping[idx].isMandatory && (
<Button variant="secondary" size="icon" className="size-10" onClick={deleteRow}>
<TrashIcon />
</Button>
)}
<Button variant="secondary" size="icon" className="size-10" onClick={addRow}>
<PlusIcon />
</Button>
</div>
</div>
</div>
);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<div className="mb-4 flex items-start space-x-2">
<div className="relative size-8">
<Image
fill
className="object-contain object-center"
src={PlainLogo}
alt={t("environments.integrations.plain.plain_logo")}
/>
</div>
<div className="space-y-0.5">
<DialogTitle>{t("environments.integrations.plain.configure_plain_integration")}</DialogTitle>
<DialogDescription>
{t("environments.integrations.plain.plain_integration_description")}
</DialogDescription>
</div>
</div>
</DialogHeader>
<form onSubmit={handleSubmit(linkIntegration)} className="contents space-y-4">
<DialogBody>
<div className="w-full space-y-4">
<div>
<div className="mb-4">
<DropdownSelector
label={t("common.select_survey")}
items={surveys}
selectedItem={selectedSurvey}
setSelectedItem={setSelectedSurvey}
disabled={surveys.length === 0}
/>
<p className="m-1 text-xs text-slate-500">
{surveys.length === 0 && t("environments.integrations.create_survey_warning")}
</p>
{/* Contact Info Validation Alerts */}
{selectedSurvey && (
<>
{/* Success all required fields present */}
{contactInfoValidation.hasContactInfo &&
contactInfoValidation.missingFields.length === 0 && (
<div className="my-4 rounded-md bg-green-50 p-3 text-sm text-green-800">
<p className="font-medium">
{t("environments.integrations.plain.contact_info_success_title", {
defaultValue: "Contact-Info question found",
})}
</p>
<p className="mt-1">
{t("environments.integrations.plain.contact_info_all_present", {
defaultValue:
"This survey contains a complete Contact-Info question (first name, last name & email).",
})}
</p>
</div>
)}
{/* Error no contact info question */}
{!contactInfoValidation.hasContactInfo && (
<div className="mt-2 rounded-md bg-red-50 p-3 text-sm text-red-800">
<p className="font-medium">
{t("environments.integrations.plain.contact_info_missing_title", {
defaultValue: "No Contact-Info question",
})}
</p>
<p className="mt-1">
{t("environments.integrations.plain.no_contact_info_question", {
defaultValue:
"This survey does not include a Contact-Info question. Please add one with first name, last name and email enabled to use Plain.",
})}
</p>
<a
href="https://formbricks.com/docs/integrations/plain#contact-info"
target="_blank"
rel="noopener noreferrer"
className="mt-2 inline-block text-xs font-medium underline">
{t("common.learn_more", { defaultValue: "Learn more" })}
</a>
</div>
)}
{/* Warning partial match (retain existing implementation) */}
{contactInfoValidation.hasContactInfo && contactInfoValidation.partialMatch && (
<div className="mt-2 rounded-md bg-red-50 p-3 text-sm text-red-800">
<p className="font-medium">
{t("environments.integrations.plain.contact_info_warning")}
</p>
<p className="mt-1">
{t("environments.integrations.plain.contact_info_missing_fields_description")}:{" "}
{contactInfoValidation.missingFields
.map((field) => {
switch (field) {
case "firstName":
return t("common.first_name");
case "lastName":
return t("common.last_name");
case "email":
return t("common.email");
default:
return field;
}
})
.join(", ")}
</p>
<a
href="https://docs.formbricks.com/integrations/plain#contact-info"
target="_blank"
rel="noopener noreferrer"
className="mt-2 inline-block text-xs font-medium underline">
{t("common.learn_more", { defaultValue: "Learn more" })}
</a>
</div>
)}
</>
)}
</div>
{selectedSurvey && (
<div className="space-y-4">
<div>
<Label>{t("environments.integrations.plain.map_formbricks_fields_to_plain")}</Label>
<p className="mt-1 text-xs text-slate-500">
{t("environments.integrations.plain.mandatory_mapping_note", {
defaultValue:
"Thread Title and Component Text are mandatory mappings and cannot be removed.",
})}
</p>
<div className="mt-1 space-y-2 overflow-y-auto">
{mapping.map((_, idx) => (
<MappingRow idx={idx} key={idx} />
))}
</div>
</div>
</div>
)}
</div>
</div>
</DialogBody>
<DialogFooter>
{selectedIntegration ? (
<Button
type="button"
variant="destructive"
loading={isDeleting}
onClick={() => {
deleteLink();
}}>
{t("common.delete")}
</Button>
) : (
<Button
type="button"
variant="secondary"
onClick={() => {
setOpen(false);
resetForm();
}}>
{t("common.cancel")}
</Button>
)}
<Button
type="submit"
loading={isLinkingIntegration}
disabled={mapping.filter((m) => m.error).length > 0}>
{selectedIntegration ? t("common.update") : t("environments.integrations.plain.connect")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,71 @@
import { AddKeyModal } from "@/app/(app)/environments/[environmentId]/integrations/plain/components/AddKeyModal";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import toast from "react-hot-toast";
import { type Mock, beforeEach, describe, expect, test, vi } from "vitest";
import { connectPlainIntegrationAction } from "../actions";
vi.mock("../actions", () => ({
connectPlainIntegrationAction: vi.fn(),
}));
vi.mock("react-hot-toast");
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => key,
}),
}));
describe("AddKeyModal", () => {
const environmentId = "test-environment-id";
const setOpen = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
test("should disable the connect button when the API key is empty", () => {
render(<AddKeyModal environmentId={environmentId} open={true} setOpen={setOpen} />);
const connectButton = screen.getByRole("button", { name: "common.connect" });
expect(connectButton).toBeDisabled();
});
test("should enable the connect button when the API key is not empty", async () => {
render(<AddKeyModal environmentId={environmentId} open={true} setOpen={setOpen} />);
const apiKeyInput = screen.getByLabelText("environments.integrations.plain.api_key_label");
await userEvent.type(apiKeyInput, "test-api-key", { pointerEventsCheck: 0 });
const connectButton = screen.getByRole("button", { name: "common.connect" });
expect(connectButton).not.toBeDisabled();
});
test("should call the connect action and show a success toast on successful connection", async () => {
render(<AddKeyModal environmentId={environmentId} open={true} setOpen={setOpen} />);
const apiKeyInput = screen.getByLabelText("environments.integrations.plain.api_key_label");
await userEvent.type(apiKeyInput, "test-api-key", { pointerEventsCheck: 0 });
const connectButton = screen.getByRole("button", { name: "common.connect" });
await userEvent.click(connectButton);
await waitFor(() => {
expect(connectPlainIntegrationAction).toHaveBeenCalledWith({
environmentId,
key: "test-api-key",
});
expect(toast.success).toHaveBeenCalledWith("environments.integrations.plain.connection_success");
expect(setOpen).toHaveBeenCalledWith(false);
});
});
test("should show an error toast on a failed connection", async () => {
(connectPlainIntegrationAction as Mock).mockRejectedValue(new Error("Connection error"));
render(<AddKeyModal environmentId={environmentId} open={true} setOpen={setOpen} />);
const apiKeyInput = screen.getByLabelText("environments.integrations.plain.api_key_label");
await userEvent.type(apiKeyInput, "test-api-key", { pointerEventsCheck: 0 });
const connectButton = screen.getByRole("button", { name: "common.connect" });
await userEvent.click(connectButton);
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith("environments.integrations.plain.connection_error");
});
});
});

View File

@@ -0,0 +1,91 @@
"use client";
import { Button } from "@/modules/ui/components/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/modules/ui/components/dialog";
import { Input } from "@/modules/ui/components/input";
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
import { useTranslate } from "@tolgee/react";
import { KeyIcon } from "lucide-react";
import { useState } from "react";
import toast from "react-hot-toast";
import { connectPlainIntegrationAction } from "../actions";
interface AddKeyModalProps {
environmentId: string;
open?: boolean;
setOpen?: (open: boolean) => void;
}
export const AddKeyModal = ({
environmentId,
open: externalOpen,
setOpen: externalSetOpen,
}: AddKeyModalProps) => {
const { t } = useTranslate();
const [internalOpen, setInternalOpen] = useState(false);
const [keyLabel, setKeyLabel] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const open = externalOpen ?? internalOpen;
const setOpen = externalSetOpen || setInternalOpen;
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center">
<div className="mr-1.5 h-6 w-6 text-slate-500">
<KeyIcon className="h-5 w-5" />
</div>
<div>
<span className="font-medium">{t("environments.integrations.plain.add_key")}</span>
<p className="text-sm font-normal text-slate-500">
{t("environments.integrations.plain.add_key_description")}
</p>
</div>
</DialogTitle>
</DialogHeader>
<div>
<div className="mb-4">
<label htmlFor="keyLabel" className="mb-2 block text-sm font-medium text-slate-700">
{t("environments.integrations.plain.api_key_label")}
</label>
<Input
id="keyLabel"
name="keyLabel"
placeholder="plainApiKey_123"
value={keyLabel}
onChange={(e) => setKeyLabel(e.target.value)}
className="w-full"
/>
</div>
<div className="flex justify-end space-x-2">
<Button variant="outline" onClick={() => setOpen(false)} disabled={isSubmitting}>
{t("common.cancel")}
</Button>
<Button
variant="default"
disabled={!keyLabel.trim() || isSubmitting}
onClick={async () => {
try {
setIsSubmitting(true);
await connectPlainIntegrationAction({
environmentId,
key: keyLabel.trim(),
});
toast.success(t("environments.integrations.plain.connection_success"));
setOpen(false);
} catch {
toast.error(t("environments.integrations.plain.connection_error"));
} finally {
setIsSubmitting(false);
}
}}>
{isSubmitting ? <LoadingSpinner className="h-4 w-4" /> : t("common.connect")}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,186 @@
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { TIntegrationPlain, TIntegrationPlainConfigData } from "@formbricks/types/integration/plain";
import { ManageIntegration } from "./ManageIntegration";
vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({
deleteIntegrationAction: vi.fn(),
}));
vi.mock("@/lib/time", () => ({
timeSince: vi.fn((time) => `mock-time-since-${time}`),
}));
vi.mock("@/lib/utils/helper", () => ({
getFormattedErrorMessage: vi.fn((err) => err?.message || "An error occurred"),
}));
vi.mock("@/modules/ui/components/delete-dialog", () => ({
DeleteDialog: ({ open, setOpen, onDelete, text, isDeleting }) =>
open ? (
<div>
<span>{text}</span>
<button onClick={() => onDelete()}>{isDeleting ? "Deleting..." : "Delete"}</button>
<button onClick={() => setOpen(false)}>Cancel</button>
</div>
) : null,
}));
vi.mock("@/modules/ui/components/empty-space-filler", () => ({
EmptySpaceFiller: ({ emptyMessage }) => <div>{emptyMessage}</div>,
}));
vi.mock("@/lib/constants", () => {
const base = {
IS_FORMBRICKS_CLOUD: false,
IS_PRODUCTION: false,
IS_DEVELOPMENT: true,
E2E_TESTING: false,
ENCRYPTION_KEY: "12345678901234567890123456789012",
REDIS_URL: undefined,
ENTERPRISE_LICENSE_KEY: undefined,
POSTHOG_API_KEY: undefined,
POSTHOG_HOST: undefined,
IS_POSTHOG_CONFIGURED: false,
GITHUB_ID: undefined,
GITHUB_SECRET: undefined,
GOOGLE_CLIENT_ID: undefined,
GOOGLE_CLIENT_SECRET: undefined,
AZUREAD_CLIENT_ID: undefined,
AZUREAD_CLIENT_SECRET: undefined,
AZUREAD_TENANT_ID: undefined,
OIDC_DISPLAY_NAME: undefined,
OIDC_CLIENT_ID: undefined,
OIDC_ISSUER: undefined,
OIDC_CLIENT_SECRET: undefined,
OIDC_SIGNING_ALGORITHM: undefined,
SESSION_MAX_AGE: 1000,
AUDIT_LOG_ENABLED: 1,
WEBAPP_URL: undefined,
SENTRY_DSN: undefined,
SENTRY_RELEASE: undefined,
SENTRY_ENVIRONMENT: undefined,
};
return new Proxy(base, {
get(target, prop) {
return prop in target ? target[prop as keyof typeof target] : undefined;
},
});
});
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key) => key,
}),
}));
vi.mock("react-hot-toast", () => ({
default: {
success: vi.fn(),
error: vi.fn(),
},
}));
const mockEnvironment = { id: "test-env-id" } as TEnvironment;
const mockIntegrationData: TIntegrationPlainConfigData[] = [
{
surveyId: "survey-1",
surveyName: "Survey One",
createdAt: new Date(),
mapping: [],
includeMetadata: true,
includeHiddenFields: true,
includeComponents: false,
},
{
surveyId: "survey-2",
surveyName: "Survey Two",
createdAt: new Date(),
mapping: [],
includeMetadata: true,
includeHiddenFields: true,
includeComponents: false,
},
];
const mockPlainIntegration: TIntegrationPlain = {
id: "integration-id",
type: "plain",
environmentId: "test-env-id",
config: {
key: "test-key",
data: mockIntegrationData,
},
};
describe("ManageIntegration", () => {
let setOpenAddIntegrationModal: (isOpen: boolean) => void;
let setIsConnected: (isConnected: boolean) => void;
let setSelectedIntegration: (integration: (TIntegrationPlainConfigData & { index: number }) | null) => void;
beforeEach(() => {
setOpenAddIntegrationModal = vi.fn();
setIsConnected = vi.fn();
setSelectedIntegration = vi.fn();
});
afterEach(() => {
vi.clearAllMocks();
});
test("renders empty state when no integrations are configured", () => {
render(
<ManageIntegration
environment={mockEnvironment}
plainIntegration={{ ...mockPlainIntegration, config: { ...mockPlainIntegration.config, data: [] } }}
setOpenAddIntegrationModal={setOpenAddIntegrationModal}
setIsConnected={setIsConnected}
setSelectedIntegration={setSelectedIntegration}
locale={"en-US"}
/>
);
expect(screen.getByText("environments.integrations.plain.no_databases_found")).toBeInTheDocument();
});
test("renders a list of integrations when configured", () => {
render(
<ManageIntegration
environment={mockEnvironment}
plainIntegration={mockPlainIntegration}
setOpenAddIntegrationModal={setOpenAddIntegrationModal}
setIsConnected={setIsConnected}
setSelectedIntegration={setSelectedIntegration}
locale={"en-US"}
/>
);
expect(screen.getAllByText("Survey One")[0]).toBeInTheDocument();
expect(screen.getAllByText("Survey Two")[0]).toBeInTheDocument();
});
test("handles successful deletion of an integration", async () => {
vi.mocked(deleteIntegrationAction).mockResolvedValue({ data: mockPlainIntegration });
render(
<ManageIntegration
environment={mockEnvironment}
plainIntegration={mockPlainIntegration}
setOpenAddIntegrationModal={setOpenAddIntegrationModal}
setIsConnected={setIsConnected}
setSelectedIntegration={setSelectedIntegration}
locale={"en-US"}
/>
);
await userEvent.click(screen.getAllByText("environments.integrations.delete_integration")[0]);
expect(screen.getByText("environments.integrations.delete_integration_confirmation")).toBeInTheDocument();
await userEvent.click(screen.getByText("Delete"));
await waitFor(() => {
expect(deleteIntegrationAction).toHaveBeenCalledWith({ integrationId: mockPlainIntegration.id });
});
});
});

View File

@@ -0,0 +1,133 @@
"use client";
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
import { timeSince } from "@/lib/time";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { useTranslate } from "@tolgee/react";
import { RefreshCcwIcon, Trash2Icon } from "lucide-react";
import React, { useState } from "react";
import toast from "react-hot-toast";
import { TEnvironment } from "@formbricks/types/environment";
import { TIntegrationPlain, TIntegrationPlainConfigData } from "@formbricks/types/integration/plain";
import { TUserLocale } from "@formbricks/types/user";
import { IntegrationListPanel } from "../../components/IntegrationListPanel";
import { AddKeyModal } from "./AddKeyModal";
interface ManageIntegrationProps {
environment: TEnvironment;
plainIntegration: TIntegrationPlain;
setOpenAddIntegrationModal: React.Dispatch<React.SetStateAction<boolean>>;
setIsConnected: React.Dispatch<React.SetStateAction<boolean>>;
setSelectedIntegration: React.Dispatch<
React.SetStateAction<(TIntegrationPlainConfigData & { index: number }) | null>
>;
locale: TUserLocale;
}
export const ManageIntegration = ({
environment,
plainIntegration,
setOpenAddIntegrationModal,
setIsConnected,
setSelectedIntegration,
locale,
}: ManageIntegrationProps) => {
const { t } = useTranslate();
const [isDeleteIntegrationModalOpen, setIsDeleteIntegrationModalOpen] = useState(false);
const [isKeyModalOpen, setIsKeyModalOpen] = useState(false);
const [isDeleting, setisDeleting] = useState(false);
let integrationArray: TIntegrationPlainConfigData[] = [];
if (plainIntegration?.config.data) {
integrationArray = plainIntegration.config.data;
}
const handleDeleteIntegration = async () => {
setisDeleting(true);
const deleteIntegrationActionResult = await deleteIntegrationAction({
integrationId: plainIntegration.id,
});
if (deleteIntegrationActionResult?.data) {
toast.success(t("environments.integrations.integration_removed_successfully"));
setIsConnected(false);
} else {
const errorMessage = getFormattedErrorMessage(deleteIntegrationActionResult);
toast.error(errorMessage);
}
setisDeleting(false);
setIsDeleteIntegrationModalOpen(false);
};
const editIntegration = (index: number) => {
setSelectedIntegration({ ...plainIntegration.config.data[index], index });
setOpenAddIntegrationModal(true);
};
return (
<>
<IntegrationListPanel
environment={environment}
statusNode={
<>
<span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span>
<span className="text-slate-500">{t("common.connected")}</span>
</>
}
reconnectAction={{
label: t("environments.integrations.plain.update_connection"),
onClick: () => setIsKeyModalOpen(true),
icon: <RefreshCcwIcon className="mr-2 h-4 w-4" />,
tooltip: t("environments.integrations.plain.update_connection_tooltip"),
variant: "outline",
}}
addNewAction={{
label: t("environments.integrations.plain.link_new_database"),
onClick: () => {
setSelectedIntegration(null);
setOpenAddIntegrationModal(true);
},
}}
emptyMessage={t("environments.integrations.plain.no_databases_found")}
items={integrationArray}
columns={[
{
header: t("common.survey"),
render: (item: TIntegrationPlainConfigData) => item.surveyName,
},
{
header: t("common.survey_id"),
render: (item: TIntegrationPlainConfigData) => item.surveyId,
},
{
header: t("common.updated_at"),
render: (item: TIntegrationPlainConfigData) => timeSince(item.createdAt.toString(), locale),
},
]}
onRowClick={editIntegration}
getRowKey={(item: TIntegrationPlainConfigData, idx) => `${idx}-${item.surveyId}`}
/>
<div className="mt-4 flex justify-center">
<Button variant="ghost" onClick={() => setIsDeleteIntegrationModalOpen(true)}>
<Trash2Icon />
{t("environments.integrations.delete_integration")}
</Button>
</div>
<AddKeyModal environmentId={environment.id} open={isKeyModalOpen} setOpen={setIsKeyModalOpen} />
<DeleteDialog
open={isDeleteIntegrationModalOpen}
setOpen={setIsDeleteIntegrationModalOpen}
deleteWhat={t("environments.integrations.plain.plain_integration")}
onDelete={handleDeleteIntegration}
text={t("environments.integrations.delete_integration_confirmation")}
isDeleting={isDeleting}
/>
</>
);
};

View File

@@ -0,0 +1,81 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { TIntegrationPlain } from "@formbricks/types/integration/plain";
import { TSurvey } from "@formbricks/types/surveys/types";
import { PlainWrapper } from "./PlainWrapper";
// Mock child components
vi.mock("@/modules/ui/components/connect-integration", () => ({
ConnectIntegration: vi.fn(() => <div>Mocked ConnectIntegration</div>),
}));
vi.mock("./AddIntegrationModal", () => ({
AddIntegrationModal: vi.fn(() => <div>Mocked AddIntegrationModal</div>),
}));
vi.mock("./AddKeyModal", () => ({
AddKeyModal: vi.fn(() => <div>Mocked AddKeyModal</div>),
}));
vi.mock("./ManageIntegration", () => ({
ManageIntegration: vi.fn(() => <div>Mocked ManageIntegration</div>),
}));
const mockEnvironment = {
id: "test-env-id",
name: "Test Environment",
} as unknown as TEnvironment;
const mockSurveys: TSurvey[] = [];
const mockPlainIntegration: TIntegrationPlain = {
id: "integration-id",
type: "plain",
environmentId: "test-env-id",
config: {
key: "test-key",
data: [],
},
};
describe("PlainWrapper", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders ConnectIntegration when not connected", () => {
render(
<PlainWrapper
plainIntegration={undefined}
enabled={true}
environment={mockEnvironment}
webAppUrl="http://localhost:3000"
surveys={mockSurveys}
databasesArray={[]}
locale="en-US"
/>
);
expect(screen.getByText("Mocked ConnectIntegration")).toBeInTheDocument();
expect(screen.queryByText("Mocked ManageIntegration")).not.toBeInTheDocument();
});
test("renders ManageIntegration when connected", () => {
render(
<PlainWrapper
plainIntegration={mockPlainIntegration}
enabled={true}
environment={mockEnvironment}
webAppUrl="http://localhost:3000"
surveys={mockSurveys}
databasesArray={[]}
locale="en-US"
/>
);
expect(screen.getByText("Mocked ManageIntegration")).toBeInTheDocument();
expect(screen.queryByText("Mocked ConnectIntegration")).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,77 @@
"use client";
import PlainLogo from "@/images/plain.webp";
import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
import { useState } from "react";
import { TEnvironment } from "@formbricks/types/environment";
import { TIntegrationPlain, TIntegrationPlainConfigData } from "@formbricks/types/integration/plain";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { AddIntegrationModal } from "./AddIntegrationModal";
import { AddKeyModal } from "./AddKeyModal";
import { ManageIntegration } from "./ManageIntegration";
interface PlainWrapperProps {
plainIntegration: TIntegrationPlain | undefined;
enabled: boolean;
environment: TEnvironment;
webAppUrl: string;
surveys: TSurvey[];
databasesArray: any[];
locale: TUserLocale;
}
export const PlainWrapper = ({
plainIntegration,
enabled,
environment,
surveys,
locale,
}: PlainWrapperProps) => {
const [isModalOpen, setModalOpen] = useState(false);
const [open, setOpen] = useState(false);
const [isConnected, setIsConnected] = useState(plainIntegration ? plainIntegration.config.key : false);
const [selectedIntegration, setSelectedIntegration] = useState<
(TIntegrationPlainConfigData & { index: number }) | null
>(null);
const handlePlainAuthorization = async () => {
setOpen(true);
};
return (
<>
{isConnected && plainIntegration ? (
<>
<AddIntegrationModal
environmentId={environment.id}
surveys={surveys}
open={isModalOpen}
setOpen={setModalOpen}
plainIntegration={plainIntegration}
selectedIntegration={selectedIntegration}
/>
<ManageIntegration
environment={environment}
plainIntegration={plainIntegration}
setOpenAddIntegrationModal={setModalOpen}
setIsConnected={setIsConnected}
setSelectedIntegration={setSelectedIntegration}
locale={locale}
/>
</>
) : (
<>
<AddKeyModal environmentId={environment.id} open={open} setOpen={setOpen} />
<ConnectIntegration
isEnabled={enabled}
integrationType={"plain"}
handleAuthorization={handlePlainAuthorization}
integrationLogoSrc={PlainLogo}
/>
</>
)}
</>
);
};

View File

@@ -0,0 +1,24 @@
import { TPlainFieldType } from "@formbricks/types/integration/plain";
export const PLAIN_FIELD_TYPES: {
id: string;
name: string;
type: TPlainFieldType;
}[] = [
{ id: "threadTitle", name: "Thread Title", type: "threadField" as TPlainFieldType },
{ id: "componentText", name: "Component Text", type: "componentText" as TPlainFieldType },
{ id: "labelTypeId", name: "Label ID", type: "labelTypeId" as TPlainFieldType },
];
export const INITIAL_MAPPING = [
{
plainField: { id: "threadTitle", name: "Thread Title", type: "title" as TPlainFieldType },
question: { id: "", name: "", type: "" },
isMandatory: true,
},
{
plainField: { id: "componentText", name: "Component Text", type: "componentText" as TPlainFieldType },
question: { id: "", name: "", type: "" },
isMandatory: true,
},
] as const;

View File

@@ -0,0 +1,210 @@
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
import { getIntegrationByType } from "@/lib/integration/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { cleanup, render, screen } from "@testing-library/react";
import { redirect } from "next/navigation";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { TIntegrationPlain } from "@formbricks/types/integration/plain";
import { TSurvey } from "@formbricks/types/surveys/types";
import Page from "./page";
// Mock dependencies
vi.mock("@/app/(app)/environments/[environmentId]/integrations/plain/components/PlainWrapper", () => ({
PlainWrapper: vi.fn(
({ enabled, surveys, environment, plainIntegration, webAppUrl, databasesArray, locale }) => (
<div>
<span>Mocked PlainWrapper</span>
<span data-testid="enabled">{enabled.toString()}</span>
<span data-testid="environmentId">{environment.id}</span>
<span data-testid="surveyCount">{surveys?.length ?? 0}</span>
<span data-testid="integrationId">{plainIntegration?.id}</span>
<span data-testid="webAppUrl">{webAppUrl}</span>
<span data-testid="databasesArray">{databasesArray?.length ?? 0}</span>
<span data-testid="locale">{locale}</span>
</div>
)
),
}));
vi.mock("@/app/(app)/environments/[environmentId]/integrations/lib/surveys", () => ({
getSurveys: vi.fn(),
}));
vi.mock("@/lib/integration/service", () => ({
getIntegrationByType: vi.fn(),
}));
vi.mock("@/lib/utils/locale", () => ({
findMatchingLocale: vi.fn(),
}));
vi.mock("@/modules/environments/lib/utils", () => ({
getEnvironmentAuth: vi.fn(),
}));
vi.mock("@/modules/ui/components/go-back-button", () => ({
GoBackButton: vi.fn(({ url }) => <div data-testid="go-back">{url}</div>),
}));
vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
PageContentWrapper: vi.fn(({ children }) => <div>{children}</div>),
}));
vi.mock("@/modules/ui/components/page-header", () => ({
PageHeader: vi.fn(({ pageTitle }) => <h1>{pageTitle}</h1>),
}));
vi.mock("@/tolgee/server", () => ({
getTranslate: async () => (key) => key,
}));
vi.mock("next/navigation", () => ({
redirect: vi.fn(),
}));
vi.mock("@/lib/constants", () => ({
WEBAPP_URL: "https://app.formbricks.com",
}));
const mockEnvironment = {
id: "test-env-id",
createdAt: new Date(),
updatedAt: new Date(),
appSetupCompleted: true,
type: "development",
projectId: "project-id",
project: {
id: "project-id",
name: "Test Project",
environments: [],
people: [],
surveys: [],
tags: [],
webhooks: [],
apiKey: {
id: "api-key",
createdAt: new Date(),
updatedAt: new Date(),
hashedKey: "hashed",
label: "api",
},
logo: null,
createdAt: new Date(),
updatedAt: new Date(),
organizationId: "org-id",
recontactDays: 30,
inAppSurveyBranding: false,
linkSurveyBranding: false,
placement: "bottomRight",
clickOutsideClose: true,
darkOverlay: false,
},
} as unknown as TEnvironment;
const mockSurveys: TSurvey[] = [
{
id: "survey1",
name: "Survey 1",
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "test-env-id",
status: "inProgress",
type: "app",
questions: [],
triggers: [],
recontactDays: null,
autoClose: null,
closeOnDate: null,
delay: 0,
displayOption: "displayOnce",
displayPercentage: null,
languages: [],
pin: null,
resultShareKey: null,
segment: null,
singleUse: null,
styling: null,
surveyClosedMessage: null,
welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"],
autoComplete: null,
runOnDate: null,
} as unknown as TSurvey,
];
const mockPlainIntegration = {
id: "integration1",
type: "plain",
environmentId: "test-env-id",
config: {
key: "plain-key",
data: [],
},
} as unknown as TIntegrationPlain;
const mockProps = {
params: { environmentId: "test-env-id" },
};
describe("PlainIntegrationPage", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
beforeEach(() => {
vi.mocked(getEnvironmentAuth).mockResolvedValue({
environment: mockEnvironment,
project: {} as any,
organization: {} as any,
session: {} as any,
currentUserMembership: {} as any,
projectPermission: {} as any,
isMember: true,
isOwner: false,
isManager: false,
isBilling: false,
hasReadAccess: true,
hasReadWriteAccess: true,
hasManageAccess: false,
isReadOnly: false,
});
vi.mocked(getSurveys).mockResolvedValue(mockSurveys);
vi.mocked(getIntegrationByType).mockResolvedValue(mockPlainIntegration);
vi.mocked(findMatchingLocale).mockResolvedValue("en-US");
});
test("renders the page with PlainWrapper when enabled and not read-only", async () => {
const PageComponent = await Page(mockProps);
render(PageComponent);
expect(screen.getByText("environments.integrations.plain.plain_integration")).toBeInTheDocument();
expect(screen.getByText("Mocked PlainWrapper")).toBeInTheDocument();
expect(screen.getByTestId("enabled")).toHaveTextContent("true");
expect(screen.getByTestId("environmentId")).toHaveTextContent(mockEnvironment.id);
expect(screen.getByTestId("surveyCount")).toHaveTextContent(mockSurveys.length.toString());
expect(screen.getByTestId("integrationId")).toHaveTextContent(mockPlainIntegration.id);
expect(screen.getByTestId("webAppUrl")).toHaveTextContent("https://app.formbricks.com");
expect(screen.getByTestId("databasesArray")).toHaveTextContent("0");
expect(screen.getByTestId("locale")).toHaveTextContent("en-US");
expect(screen.getByTestId("go-back")).toHaveTextContent(
`https://app.formbricks.com/environments/${mockProps.params.environmentId}/integrations`
);
expect(vi.mocked(redirect)).not.toHaveBeenCalled();
});
test("calls redirect when user is read-only", async () => {
vi.mocked(getEnvironmentAuth).mockResolvedValue({
environment: mockEnvironment,
project: {} as any,
organization: {} as any,
session: {} as any,
currentUserMembership: {} as any,
projectPermission: {} as any,
isMember: true,
isOwner: false,
isManager: false,
isBilling: false,
hasReadAccess: true,
hasReadWriteAccess: false,
hasManageAccess: false,
isReadOnly: true,
});
const PageComponent = await Page(mockProps);
render(PageComponent);
expect(vi.mocked(redirect)).toHaveBeenCalledWith("./");
});
});

View File

@@ -0,0 +1,49 @@
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
import { PlainWrapper } from "@/app/(app)/environments/[environmentId]/integrations/plain/components/PlainWrapper";
import { WEBAPP_URL } from "@/lib/constants";
import { getIntegrationByType } from "@/lib/integration/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { GoBackButton } from "@/modules/ui/components/go-back-button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { getTranslate } from "@/tolgee/server";
import { redirect } from "next/navigation";
import { TIntegrationPlain } from "@formbricks/types/integration/plain";
const Page = async (props) => {
const params = await props.params;
const t = await getTranslate();
const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId);
const [surveys, plainIntegration] = await Promise.all([
getSurveys(params.environmentId),
getIntegrationByType(params.environmentId, "plain"),
]);
const databasesArray = [];
const locale = await findMatchingLocale();
if (isReadOnly) {
redirect("./");
}
return (
<PageContentWrapper>
<GoBackButton url={`${WEBAPP_URL}/environments/${params.environmentId}/integrations`} />
<PageHeader pageTitle={t("environments.integrations.plain.plain_integration") || "Plain Integration"} />
<PlainWrapper
enabled={true}
surveys={surveys}
environment={environment}
plainIntegration={plainIntegration as TIntegrationPlain}
webAppUrl={WEBAPP_URL}
databasesArray={databasesArray}
locale={locale}
/>
</PageContentWrapper>
);
};
export default Page;

View File

@@ -25,7 +25,7 @@ vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
REDIS_URL: undefined,
AUDIT_LOG_ENABLED: true,
}));

View File

@@ -25,7 +25,7 @@ vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
REDIS_URL: "redis://localhost:6379",
REDIS_URL: undefined,
AUDIT_LOG_ENABLED: 1,
}));

View File

@@ -25,7 +25,7 @@ vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
REDIS_URL: "redis://localhost:6379",
REDIS_URL: undefined,
AUDIT_LOG_ENABLED: 1,
}));

View File

@@ -25,7 +25,7 @@ vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
REDIS_URL: "redis://localhost:6379",
REDIS_URL: undefined,
AUDIT_LOG_ENABLED: 1,
}));

View File

@@ -25,7 +25,7 @@ vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
REDIS_URL: "redis://localhost:6379",
REDIS_URL: undefined,
AUDIT_LOG_ENABLED: 1,
}));

View File

@@ -25,7 +25,7 @@ vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
REDIS_URL: undefined,
AUDIT_LOG_ENABLED: true,
}));

View File

@@ -41,7 +41,7 @@ vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
REDIS_URL: undefined,
AUDIT_LOG_ENABLED: true,
}));

View File

@@ -30,7 +30,7 @@ vi.mock("@/lib/constants", () => ({
SMTP_USER: "mock-smtp-user",
SMTP_PASSWORD: "mock-smtp-password",
SESSION_MAX_AGE: 1000,
REDIS_URL: "redis://localhost:6379",
REDIS_URL: undefined,
AUDIT_LOG_ENABLED: 1,
}));

View File

@@ -45,7 +45,7 @@ vi.mock("@/lib/constants", () => ({
SMTP_USER: "mock-smtp-user",
SMTP_PASSWORD: "mock-smtp-password",
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
REDIS_URL: undefined,
AUDIT_LOG_ENABLED: true,
}));

View File

@@ -40,7 +40,7 @@ vi.mock("@tolgee/react", () => ({
// Mock Next.js hooks
const mockPush = vi.fn();
const mockPathname = "/environments/env-id/surveys/survey-id/summary";
const mockPathname = "/environments/test-env-id/surveys/test-survey-id/summary";
const mockSearchParams = new URLSearchParams();
vi.mock("next/navigation", () => ({
@@ -69,6 +69,14 @@ vi.mock("@/modules/survey/list/actions", () => ({
copySurveyToOtherEnvironmentAction: vi.fn(),
}));
// Mock the useSingleUseId hook
vi.mock("@/modules/survey/hooks/useSingleUseId", () => ({
useSingleUseId: vi.fn(() => ({
singleUseId: "test-single-use-id",
refreshSingleUseId: vi.fn().mockResolvedValue("test-single-use-id"),
})),
}));
// Mock child components
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SuccessMessage",
@@ -434,4 +442,328 @@ describe("SurveyAnalysisCTA", () => {
expect(screen.getByTestId("success-message")).toBeInTheDocument();
expect(screen.getByTestId("survey-status-dropdown")).toBeInTheDocument();
});
test("duplicates survey when primary button is clicked in edit dialog", async () => {
const mockCopySurveyAction = vi.mocked(
await import("@/modules/survey/list/actions")
).copySurveyToOtherEnvironmentAction;
mockCopySurveyAction.mockResolvedValue({
data: {
...mockSurvey,
id: "new-survey-id",
environmentId: "test-env-id",
triggers: [],
segment: null,
resultShareKey: null,
languages: [],
},
});
const toast = await import("react-hot-toast");
const user = userEvent.setup();
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
// Click edit button to open dialog
await user.click(screen.getByTestId("icon-bar-action-1"));
// Click primary button (duplicate & edit)
await user.click(screen.getByTestId("primary-button"));
expect(mockCopySurveyAction).toHaveBeenCalledWith({
environmentId: "test-env-id",
surveyId: "test-survey-id",
targetEnvironmentId: "test-env-id",
});
expect(toast.default.success).toHaveBeenCalledWith("Survey duplicated successfully");
expect(mockPush).toHaveBeenCalledWith("/environments/test-env-id/surveys/new-survey-id/edit");
});
test("handles error when duplicating survey fails", async () => {
const mockCopySurveyAction = vi.mocked(
await import("@/modules/survey/list/actions")
).copySurveyToOtherEnvironmentAction;
mockCopySurveyAction.mockResolvedValue({
data: undefined,
serverError: "Duplication failed",
validationErrors: undefined,
bindArgsValidationErrors: [],
});
const toast = await import("react-hot-toast");
const user = userEvent.setup();
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
// Click edit button to open dialog
await user.click(screen.getByTestId("icon-bar-action-1"));
// Click primary button (duplicate & edit)
await user.click(screen.getByTestId("primary-button"));
expect(toast.default.error).toHaveBeenCalledWith("Error message");
});
test("navigates to edit when secondary button is clicked in edit dialog", async () => {
const user = userEvent.setup();
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
// Click edit button to open dialog
await user.click(screen.getByTestId("icon-bar-action-1"));
// Click secondary button (edit)
await user.click(screen.getByTestId("secondary-button"));
expect(mockPush).toHaveBeenCalledWith("/environments/test-env-id/surveys/test-survey-id/edit");
});
test("shows loading state during duplication", async () => {
const mockCopySurveyAction = vi.mocked(
await import("@/modules/survey/list/actions")
).copySurveyToOtherEnvironmentAction;
// Mock a delayed response
mockCopySurveyAction.mockImplementation(
() =>
new Promise((resolve) =>
setTimeout(
() =>
resolve({
data: {
...mockSurvey,
id: "new-survey-id",
environmentId: "test-env-id",
triggers: [],
segment: null,
resultShareKey: null,
languages: [],
},
}),
100
)
)
);
const user = userEvent.setup();
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
// Click edit button to open dialog
await user.click(screen.getByTestId("icon-bar-action-1"));
// Click primary button (duplicate & edit)
await user.click(screen.getByTestId("primary-button"));
// Check loading state
expect(screen.getByTestId("edit-public-survey-alert-dialog")).toHaveAttribute("data-loading", "true");
});
test("closes dialog after successful duplication", async () => {
const mockCopySurveyAction = vi.mocked(
await import("@/modules/survey/list/actions")
).copySurveyToOtherEnvironmentAction;
mockCopySurveyAction.mockResolvedValue({
data: {
...mockSurvey,
id: "new-survey-id",
environmentId: "test-env-id",
triggers: [],
segment: null,
resultShareKey: null,
languages: [],
},
});
const user = userEvent.setup();
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
// Click edit button to open dialog
await user.click(screen.getByTestId("icon-bar-action-1"));
expect(screen.getByTestId("edit-public-survey-alert-dialog")).toHaveAttribute("data-open", "true");
// Click primary button (duplicate & edit)
await user.click(screen.getByTestId("primary-button"));
// Dialog should be closed
expect(screen.getByTestId("edit-public-survey-alert-dialog")).toHaveAttribute("data-open", "false");
});
test("opens preview with single use ID when enabled", async () => {
const mockUseSingleUseId = vi.mocked(
await import("@/modules/survey/hooks/useSingleUseId")
).useSingleUseId;
mockUseSingleUseId.mockReturnValue({
singleUseId: "test-single-use-id",
refreshSingleUseId: vi.fn().mockResolvedValue("new-single-use-id"),
});
const surveyWithSingleUse = {
...mockSurvey,
type: "link" as const,
singleUse: { enabled: true, isEncrypted: false },
};
const windowOpenSpy = vi.spyOn(window, "open").mockImplementation(() => null);
const user = userEvent.setup();
render(<SurveyAnalysisCTA {...defaultProps} survey={surveyWithSingleUse} />);
await user.click(screen.getByTestId("icon-bar-action-1"));
expect(windowOpenSpy).toHaveBeenCalledWith(
"https://example.com/s/test-survey-id?suId=new-single-use-id&preview=true",
"_blank"
);
windowOpenSpy.mockRestore();
});
test("handles single use ID generation failure", async () => {
const mockUseSingleUseId = vi.mocked(
await import("@/modules/survey/hooks/useSingleUseId")
).useSingleUseId;
mockUseSingleUseId.mockReturnValue({
singleUseId: "test-single-use-id",
refreshSingleUseId: vi.fn().mockResolvedValue(undefined),
});
const surveyWithSingleUse = {
...mockSurvey,
type: "link" as const,
singleUse: { enabled: true, isEncrypted: false },
};
const windowOpenSpy = vi.spyOn(window, "open").mockImplementation(() => null);
const user = userEvent.setup();
render(<SurveyAnalysisCTA {...defaultProps} survey={surveyWithSingleUse} />);
await user.click(screen.getByTestId("icon-bar-action-1"));
expect(windowOpenSpy).toHaveBeenCalledWith("https://example.com/s/test-survey-id?preview=true", "_blank");
windowOpenSpy.mockRestore();
});
test("opens share modal with correct modal view when share button clicked", async () => {
const user = userEvent.setup();
render(<SurveyAnalysisCTA {...defaultProps} />);
await user.click(screen.getByText("Share survey"));
expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-modal-view", "share");
});
test("handles different survey statuses correctly", () => {
const completedSurvey = { ...mockSurvey, status: "completed" as const };
render(<SurveyAnalysisCTA {...defaultProps} survey={completedSurvey} />);
expect(screen.getByTestId("survey-status-dropdown")).toBeInTheDocument();
});
test("handles paused survey status", () => {
const pausedSurvey = { ...mockSurvey, status: "paused" as const };
render(<SurveyAnalysisCTA {...defaultProps} survey={pausedSurvey} />);
expect(screen.getByTestId("survey-status-dropdown")).toBeInTheDocument();
});
test("does not render share modal when user is null", () => {
render(<SurveyAnalysisCTA {...defaultProps} user={null as any} />);
expect(screen.queryByTestId("share-survey-modal")).not.toBeInTheDocument();
});
test("renders with different isFormbricksCloud values", () => {
const { rerender } = render(<SurveyAnalysisCTA {...defaultProps} isFormbricksCloud={true} />);
expect(screen.getByTestId("share-survey-modal")).toBeInTheDocument();
rerender(<SurveyAnalysisCTA {...defaultProps} isFormbricksCloud={false} />);
expect(screen.getByTestId("share-survey-modal")).toBeInTheDocument();
});
test("renders with different isContactsEnabled values", () => {
const { rerender } = render(<SurveyAnalysisCTA {...defaultProps} isContactsEnabled={true} />);
expect(screen.getByTestId("share-survey-modal")).toBeInTheDocument();
rerender(<SurveyAnalysisCTA {...defaultProps} isContactsEnabled={false} />);
expect(screen.getByTestId("share-survey-modal")).toBeInTheDocument();
});
test("handles app survey type", () => {
const appSurvey = { ...mockSurvey, type: "app" as const };
render(<SurveyAnalysisCTA {...defaultProps} survey={appSurvey} />);
// Should not show preview icon for app surveys
expect(screen.queryByTestId("icon-bar-action-1")).toBeInTheDocument(); // This should be edit button
expect(screen.getByTestId("icon-bar-action-1")).toHaveAttribute("title", "Edit");
});
test("handles modal state changes correctly", async () => {
const user = userEvent.setup();
render(<SurveyAnalysisCTA {...defaultProps} />);
// Open modal via share button
await user.click(screen.getByText("Share survey"));
expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-open", "true");
// Close modal
await user.click(screen.getByText("Close Modal"));
expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-open", "false");
});
test("opens share modal via share button", async () => {
const user = userEvent.setup();
render(<SurveyAnalysisCTA {...defaultProps} />);
await user.click(screen.getByText("Share survey"));
// Should open the modal with share view
expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-open", "true");
expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-modal-view", "share");
});
test("closes share modal and updates modal state", async () => {
mockSearchParams.set("share", "true");
const user = userEvent.setup();
render(<SurveyAnalysisCTA {...defaultProps} />);
// Modal should be open initially due to share param
expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-open", "true");
await user.click(screen.getByText("Close Modal"));
// Should close the modal
expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-open", "false");
});
test("handles empty segments array", () => {
render(<SurveyAnalysisCTA {...defaultProps} segments={[]} />);
expect(screen.getByTestId("share-survey-modal")).toBeInTheDocument();
});
test("handles zero response count", () => {
render(<SurveyAnalysisCTA {...defaultProps} responseCount={0} />);
expect(screen.queryByTestId("edit-public-survey-alert-dialog")).not.toBeInTheDocument();
});
test("shows all icon actions for non-readonly app survey", () => {
render(<SurveyAnalysisCTA {...defaultProps} />);
// Should show bell (notifications) and edit actions
expect(screen.getByTestId("icon-bar-action-0")).toHaveAttribute("title", "Configure alerts");
expect(screen.getByTestId("icon-bar-action-1")).toHaveAttribute("title", "Edit");
});
test("shows all icon actions for non-readonly link survey", () => {
const linkSurvey = { ...mockSurvey, type: "link" as const };
render(<SurveyAnalysisCTA {...defaultProps} survey={linkSurvey} />);
// Should show bell (notifications), preview, and edit actions
expect(screen.getByTestId("icon-bar-action-0")).toHaveAttribute("title", "Configure alerts");
expect(screen.getByTestId("icon-bar-action-1")).toHaveAttribute("title", "Preview");
expect(screen.getByTestId("icon-bar-action-2")).toHaveAttribute("title", "Edit");
});
});

View File

@@ -5,6 +5,7 @@ import { ShareSurveyModal } from "@/app/(app)/environments/[environmentId]/surve
import { SurveyStatusDropdown } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SurveyStatusDropdown";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { EditPublicSurveyAlertDialog } from "@/modules/survey/components/edit-public-survey-alert-dialog";
import { useSingleUseId } from "@/modules/survey/hooks/useSingleUseId";
import { copySurveyToOtherEnvironmentAction } from "@/modules/survey/list/actions";
import { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button";
@@ -12,7 +13,7 @@ import { IconBar } from "@/modules/ui/components/iconbar";
import { useTranslate } from "@tolgee/react";
import { BellRing, Eye, SquarePenIcon } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { TEnvironment } from "@formbricks/types/environment";
import { TSegment } from "@formbricks/types/segment";
@@ -48,17 +49,16 @@ export const SurveyAnalysisCTA = ({
isFormbricksCloud,
}: SurveyAnalysisCTAProps) => {
const { t } = useTranslate();
const searchParams = useSearchParams();
const pathname = usePathname();
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [loading, setLoading] = useState(false);
const [modalState, setModalState] = useState<ModalState>({
start: searchParams.get("share") === "true",
share: false,
});
const surveyUrl = useMemo(() => `${publicDomain}/s/${survey.id}`, [survey.id, publicDomain]);
const { refreshSingleUseId } = useSingleUseId(survey);
const widgetSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
@@ -102,9 +102,18 @@ export const SurveyAnalysisCTA = ({
setLoading(false);
};
const getPreviewUrl = () => {
const separator = surveyUrl.includes("?") ? "&" : "?";
return `${surveyUrl}${separator}preview=true`;
const getPreviewUrl = async () => {
const surveyUrl = new URL(`${publicDomain}/s/${survey.id}`);
if (survey.singleUse?.enabled) {
const newId = await refreshSingleUseId();
if (newId) {
surveyUrl.searchParams.set("suId", newId);
}
}
surveyUrl.searchParams.set("preview", "true");
return surveyUrl.toString();
};
const [isCautionDialogOpen, setIsCautionDialogOpen] = useState(false);
@@ -119,7 +128,10 @@ export const SurveyAnalysisCTA = ({
{
icon: Eye,
tooltip: t("common.preview"),
onClick: () => window.open(getPreviewUrl(), "_blank"),
onClick: async () => {
const previewUrl = await getPreviewUrl();
window.open(previewUrl, "_blank");
},
isVisible: survey.type === "link",
},
{

View File

@@ -1,6 +1,5 @@
"use client";
import { TabContainer } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/tab-container";
import { AnonymousLinksTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/anonymous-links-tab";
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";
@@ -8,21 +7,14 @@ import { EmailTab } from "@/app/(app)/environments/[environmentId]/surveys/[surv
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 { 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 { Code2Icon, LinkIcon, MailIcon, QrCodeIcon, Share2Icon, SquareStack, UserIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { TSegment } from "@formbricks/types/segment";
import { TSurvey } from "@formbricks/types/surveys/types";
@@ -205,17 +197,11 @@ export const ShareSurveyModal = ({
}
if (survey.type === "link") {
return (
<ShareView
tabs={linkTabs}
activeId={activeId}
setActiveId={setActiveId}
/>
);
return <ShareView tabs={linkTabs} activeId={activeId} setActiveId={setActiveId} />;
}
return (
<div className={`h-full w-full bg-slate-50 p-6 rounded-lg`}>
<div className={`h-full w-full rounded-lg bg-slate-50 p-6`}>
<TabContainer
title={t("environments.surveys.summary.in_app.title")}
description={t("environments.surveys.summary.in_app.description")}>
@@ -235,7 +221,7 @@ export const ShareSurveyModal = ({
width={survey.type === "link" ? "wide" : "default"}
aria-describedby={undefined}
unconstrained>
{renderContent()}
{renderContent()}
</DialogContent>
</Dialog>
);

View File

@@ -378,4 +378,56 @@ describe("AnonymousLinksTab", () => {
screen.getByText("environments.surveys.share.anonymous_links.data_prefilling")
).toBeInTheDocument();
});
test("shows read-only input with copy button when encryption is disabled", async () => {
// surveyWithSingleUse has encryption disabled
render(<AnonymousLinksTab {...defaultProps} survey={surveyWithSingleUse} />);
// Check if single-use link is enabled
expect(screen.getByTestId("toggle-single-use-link-switch")).toHaveAttribute("data-checked", "true");
// Check if encryption is disabled
expect(screen.getByTestId("toggle-single-use-encryption-switch")).toHaveAttribute(
"data-checked",
"false"
);
// Check for the custom URL display
const surveyUrlWithCustomSuid = `${defaultProps.surveyUrl}?suId=CUSTOM-ID`;
expect(screen.getByText(surveyUrlWithCustomSuid)).toBeInTheDocument();
// Check for the copy button and try to click it
const copyButton = screen.getByText("common.copy");
expect(copyButton).toBeInTheDocument();
await userEvent.click(copyButton);
// check if toast is called
expect(toast.success).toHaveBeenCalledWith("common.copied_to_clipboard");
// Check for the alert
expect(
screen.getByText("environments.surveys.share.anonymous_links.custom_single_use_id_title")
).toBeInTheDocument();
// Ensure the number of links input is not visible
expect(
screen.queryByText("environments.surveys.share.anonymous_links.number_of_links_label")
).not.toBeInTheDocument();
});
test("hides read-only input with copy button when encryption is enabled", async () => {
// surveyWithEncryption has encryption enabled
render(<AnonymousLinksTab {...defaultProps} survey={surveyWithEncryption} />);
// Check if single-use link is enabled
expect(screen.getByTestId("toggle-single-use-link-switch")).toHaveAttribute("data-checked", "true");
// Check if encryption is enabled
expect(screen.getByTestId("toggle-single-use-encryption-switch")).toHaveAttribute("data-checked", "true");
// Ensure the number of links input is visible
expect(
screen.getByText("environments.surveys.share.anonymous_links.number_of_links_label")
).toBeInTheDocument();
});
});

View File

@@ -4,14 +4,13 @@ import { updateSingleUseLinksAction } from "@/app/(app)/environments/[environmen
import { DisableLinkModal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/disable-link-modal";
import { DocumentationLinks } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/documentation-links";
import { ShareSurveyLink } from "@/modules/analysis/components/ShareSurveyLink";
import { getSurveyUrl } from "@/modules/analysis/utils";
import { generateSingleUseIdsAction } from "@/modules/survey/list/actions";
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { Input } from "@/modules/ui/components/input";
import { useTranslate } from "@tolgee/react";
import { CirclePlayIcon } from "lucide-react";
import { CirclePlayIcon, CopyIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
@@ -33,6 +32,7 @@ export const AnonymousLinksTab = ({
setSurveyUrl,
locale,
}: AnonymousLinksTabProps) => {
const surveyUrlWithCustomSuid = `${surveyUrl}?suId=CUSTOM-ID`;
const router = useRouter();
const { t } = useTranslate();
@@ -173,11 +173,9 @@ export const AnonymousLinksTab = ({
count,
});
const baseSurveyUrl = getSurveyUrl(survey, publicDomain, "default");
if (!!response?.data?.length) {
const singleUseIds = response.data;
const surveyLinks = singleUseIds.map((singleUseId) => `${baseSurveyUrl}?suId=${singleUseId}`);
const surveyLinks = singleUseIds.map((singleUseId) => `${surveyUrl}?suId=${singleUseId}`);
// Create content with just the links
const csvContent = surveyLinks.join("\n");
@@ -258,14 +256,33 @@ export const AnonymousLinksTab = ({
/>
{!singleUseEncryption ? (
<Alert variant="info" size="default">
<AlertTitle>
{t("environments.surveys.share.anonymous_links.custom_single_use_id_title")}
</AlertTitle>
<AlertDescription>
{t("environments.surveys.share.anonymous_links.custom_single_use_id_description")}
</AlertDescription>
</Alert>
<div className="flex w-full flex-col gap-4">
<Alert variant="info" size="default">
<AlertTitle>
{t("environments.surveys.share.anonymous_links.custom_single_use_id_title")}
</AlertTitle>
<AlertDescription>
{t("environments.surveys.share.anonymous_links.custom_single_use_id_description")}
</AlertDescription>
</Alert>
<div className="grid w-full grid-cols-6 items-center gap-2">
<div className="col-span-5 truncate rounded-md border border-slate-200 px-2 py-1">
<span className="truncate text-sm text-slate-900">{surveyUrlWithCustomSuid}</span>
</div>
<Button
variant="secondary"
onClick={() => {
navigator.clipboard.writeText(surveyUrlWithCustomSuid);
toast.success(t("common.copied_to_clipboard"));
}}
className="col-span-1 gap-1 text-sm">
{t("common.copy")}
<CopyIcon />
</Button>
</div>
</div>
) : null}
{singleUseEncryption && (

View File

@@ -15,11 +15,13 @@ import { useTranslate } from "@tolgee/react";
import clsx from "clsx";
import {
AirplayIcon,
ArrowUpFromDotIcon,
CheckIcon,
ChevronDown,
ChevronUp,
ContactIcon,
EyeOff,
FlagIcon,
GlobeIcon,
GridIcon,
HashIcon,
@@ -89,8 +91,9 @@ const questionIcons = {
device: SmartphoneIcon,
os: AirplayIcon,
browser: GlobeIcon,
source: GlobeIcon,
source: ArrowUpFromDotIcon,
action: MousePointerClickIcon,
country: FlagIcon,
// others
Language: LanguagesIcon,
@@ -132,10 +135,16 @@ export const SelectedCommandItem = ({ label, questionType, type }: Partial<Quest
return "bg-amber-500";
}
};
const getLabelStyle = (): string | undefined => {
if (type !== OptionsType.META) return undefined;
return label === "os" ? "uppercase" : "capitalize";
};
return (
<div className="flex h-5 w-[12rem] items-center sm:w-4/5">
<span className={clsx("rounded-md p-1", getColor())}>{getIconType()}</span>
<p className="ml-3 truncate text-sm text-slate-600">
<p className={clsx("ml-3 truncate text-sm text-slate-600", getLabelStyle())}>
{typeof label === "string" ? label : getLocalizedValue(label, "default")}
</p>
</div>

View File

@@ -39,7 +39,7 @@ vi.mock("@/lib/constants", () => ({
FORMBRICKS_ENVIRONMENT_ID: "mock-formbricks-environment-id",
IS_FORMBRICKS_ENABLED: true,
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
REDIS_URL: undefined,
AUDIT_LOG_ENABLED: true,
}));

View File

@@ -4,6 +4,7 @@ import { NOTION_RICH_TEXT_LIMIT } from "@/lib/constants";
import { writeData } from "@/lib/googleSheet/service";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { writeData as writeNotionData } from "@/lib/notion/service";
import { writeData as writeDataToPlain } from "@/lib/plain/service";
import { processResponseData } from "@/lib/responses";
import { writeDataToSlack } from "@/lib/slack/service";
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
@@ -15,6 +16,7 @@ import { TIntegration, TIntegrationType } from "@formbricks/types/integration";
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
import { TIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";
import { TIntegrationNotion, TIntegrationNotionConfigData } from "@formbricks/types/integration/notion";
import { TIntegrationPlain, TIntegrationPlainConfigData } from "@formbricks/types/integration/plain";
import { TIntegrationSlack } from "@formbricks/types/integration/slack";
import { TResponseMeta } from "@formbricks/types/responses";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
@@ -109,6 +111,13 @@ export const handleIntegrations = async (
logger.error(notionResult.error, "Error in notion integration");
}
break;
case "plain": {
const plainResult = await handlePlainIntegration(integration as TIntegrationPlain, data);
if (!plainResult.ok) {
logger.error(plainResult.error, "Error in plain integration");
}
break;
}
}
}
};
@@ -436,3 +445,29 @@ const getValue = (colType: string, value: string | string[] | Date | number | Re
throw new Error("Payload build failed!");
}
};
const handlePlainIntegration = async (
integration: TIntegrationPlain,
data: TPipelineInput
): Promise<Result<void, Error>> => {
try {
if (integration.config.data.length > 0) {
for (const element of integration.config.data) {
if (element.surveyId === data.surveyId) {
const configData: TIntegrationPlainConfigData = element;
await writeDataToPlain(integration.config, data, configData);
}
}
}
return {
ok: true,
data: undefined,
};
} catch (err) {
return {
ok: false,
error: err instanceof Error ? err : new Error(String(err)),
};
}
};

View File

@@ -51,7 +51,7 @@ export const GET = async (req: NextRequest) => {
});
const tokenData = await response.json();
const encryptedAccessToken = symmetricEncrypt(tokenData.access_token, ENCRYPTION_KEY!);
const encryptedAccessToken = symmetricEncrypt(tokenData.access_token, ENCRYPTION_KEY);
tokenData.access_token = encryptedAccessToken;
const notionIntegration: TIntegrationNotionInput = {

View File

@@ -9,16 +9,46 @@ vi.mock("@/modules/ui/components/button", () => ({
}));
vi.mock("@/modules/ui/components/error-component", () => ({
ErrorComponent: () => <div data-testid="ErrorComponent">ErrorComponent</div>,
ErrorComponent: ({ title, description }: { title: string; description: string }) => (
<div data-testid="ErrorComponent">
<div data-testid="error-title">{title}</div>
<div data-testid="error-description">{description}</div>
</div>
),
}));
vi.mock("@sentry/nextjs", () => ({
captureException: vi.fn(),
}));
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
"common.error_rate_limit_title": "Too Many Requests",
"common.error_rate_limit_description": "You're making too many requests. Please slow down.",
"common.error_component_title": "Something went wrong",
"common.error_component_description": "An unexpected error occurred. Please try again.",
"common.try_again": "Try Again",
"common.go_to_dashboard": "Go to Dashboard",
};
return translations[key] || key;
},
}),
}));
vi.mock("@formbricks/types/errors", async (importOriginal) => {
const actual = await importOriginal<typeof import("@formbricks/types/errors")>();
return {
...actual,
getClientErrorData: vi.fn(),
};
});
describe("ErrorBoundary", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
const dummyError = new Error("Test error");
@@ -29,6 +59,12 @@ describe("ErrorBoundary", () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
vi.mocked(Sentry.captureException).mockImplementation((() => {}) as any);
const { getClientErrorData } = await import("@formbricks/types/errors");
vi.mocked(getClientErrorData).mockReturnValue({
type: "general",
showButtons: true,
});
render(<ErrorBoundary error={dummyError} reset={resetMock} />);
await waitFor(() => {
@@ -42,7 +78,13 @@ describe("ErrorBoundary", () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
vi.mocked(Sentry.captureException).mockImplementation((() => {}) as any);
render(<ErrorBoundary error={{ ...dummyError }} reset={resetMock} />);
const { getClientErrorData } = await import("@formbricks/types/errors");
vi.mocked(getClientErrorData).mockReturnValue({
type: "general",
showButtons: true,
});
render(<ErrorBoundary error={dummyError} reset={resetMock} />);
await waitFor(() => {
expect(Sentry.captureException).toHaveBeenCalled();
@@ -50,23 +92,95 @@ describe("ErrorBoundary", () => {
expect(consoleErrorSpy).not.toHaveBeenCalled();
});
test("calls reset when try again button is clicked", async () => {
test("calls reset when try again button is clicked for general errors", async () => {
const { getClientErrorData } = await import("@formbricks/types/errors");
vi.mocked(getClientErrorData).mockReturnValue({
type: "general",
showButtons: true,
});
render(<ErrorBoundary error={{ ...dummyError }} reset={resetMock} />);
const tryAgainBtn = screen.getByRole("button", { name: "common.try_again" });
const tryAgainBtn = screen.getByRole("button", { name: "Try Again" });
userEvent.click(tryAgainBtn);
await waitFor(() => expect(resetMock).toHaveBeenCalled());
});
test("sets window.location.href to '/' when dashboard button is clicked", async () => {
test("sets window.location.href to '/' when dashboard button is clicked for general errors", async () => {
const { getClientErrorData } = await import("@formbricks/types/errors");
vi.mocked(getClientErrorData).mockReturnValue({
type: "general",
showButtons: true,
});
const originalLocation = window.location;
delete (window as any).location;
(window as any).location = undefined;
(window as any).location = { href: "" };
render(<ErrorBoundary error={{ ...dummyError }} reset={resetMock} />);
const dashBtn = screen.getByRole("button", { name: "common.go_to_dashboard" });
const dashBtn = screen.getByRole("button", { name: "Go to Dashboard" });
userEvent.click(dashBtn);
await waitFor(() => {
expect(window.location.href).toBe("/");
});
window.location = originalLocation;
(window as any).location = originalLocation;
});
test("does not show buttons for rate limit errors", async () => {
const { getClientErrorData } = await import("@formbricks/types/errors");
vi.mocked(getClientErrorData).mockReturnValue({
type: "rate_limit",
showButtons: false,
});
render(<ErrorBoundary error={{ ...dummyError }} reset={resetMock} />);
expect(screen.queryByRole("button", { name: "Try Again" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Go to Dashboard" })).not.toBeInTheDocument();
});
test("shows error component with rate limit messages for rate limit errors", async () => {
const { getClientErrorData } = await import("@formbricks/types/errors");
vi.mocked(getClientErrorData).mockReturnValue({
type: "rate_limit",
showButtons: false,
});
render(<ErrorBoundary error={dummyError} reset={resetMock} />);
expect(screen.getByTestId("ErrorComponent")).toBeInTheDocument();
expect(screen.getByTestId("error-title")).toHaveTextContent("Too Many Requests");
expect(screen.getByTestId("error-description")).toHaveTextContent(
"You're making too many requests. Please slow down."
);
expect(getClientErrorData).toHaveBeenCalledWith(dummyError);
});
test("shows error component with general messages for general errors", async () => {
const { getClientErrorData } = await import("@formbricks/types/errors");
vi.mocked(getClientErrorData).mockReturnValue({
type: "general",
showButtons: true,
});
render(<ErrorBoundary error={dummyError} reset={resetMock} />);
expect(screen.getByTestId("ErrorComponent")).toBeInTheDocument();
expect(screen.getByTestId("error-title")).toHaveTextContent("Something went wrong");
expect(screen.getByTestId("error-description")).toHaveTextContent(
"An unexpected error occurred. Please try again."
);
expect(getClientErrorData).toHaveBeenCalledWith(dummyError);
});
test("shows buttons for general errors", async () => {
const { getClientErrorData } = await import("@formbricks/types/errors");
vi.mocked(getClientErrorData).mockReturnValue({
type: "general",
showButtons: true,
});
render(<ErrorBoundary error={dummyError} reset={resetMock} />);
expect(screen.getByRole("button", { name: "Try Again" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Go to Dashboard" })).toBeInTheDocument();
});
});

View File

@@ -5,9 +5,31 @@ import { Button } from "@/modules/ui/components/button";
import { ErrorComponent } from "@/modules/ui/components/error-component";
import * as Sentry from "@sentry/nextjs";
import { useTranslate } from "@tolgee/react";
import { type ClientErrorType, getClientErrorData } from "@formbricks/types/errors";
/**
* Get translated error messages based on error type
* All translation keys are directly visible to Tolgee's static analysis
*/
const getErrorMessages = (type: ClientErrorType, t: (key: string) => string) => {
if (type === "rate_limit") {
return {
title: t("common.error_rate_limit_title"),
description: t("common.error_rate_limit_description"),
};
}
return {
title: t("common.error_component_title"),
description: t("common.error_component_description"),
};
};
const ErrorBoundary = ({ error, reset }: { error: Error; reset: () => void }) => {
const { t } = useTranslate();
const errorData = getClientErrorData(error);
const { title, description } = getErrorMessages(errorData.type, t);
if (process.env.NODE_ENV === "development") {
console.error(error.message);
} else {
@@ -16,13 +38,15 @@ const ErrorBoundary = ({ error, reset }: { error: Error; reset: () => void }) =>
return (
<div className="flex h-full w-full flex-col items-center justify-center">
<ErrorComponent />
<div className="mt-2">
<Button variant="secondary" onClick={() => reset()} className="mr-2">
{t("common.try_again")}
</Button>
<Button onClick={() => (window.location.href = "/")}>{t("common.go_to_dashboard")}</Button>
</div>
<ErrorComponent title={title} description={description} />
{errorData.showButtons && (
<div className="mt-2">
<Button variant="secondary" onClick={() => reset()} className="mr-2">
{t("common.try_again")}
</Button>
<Button onClick={() => (window.location.href = "/")}>{t("common.go_to_dashboard")}</Button>
</div>
)}
</div>
);
};

View File

@@ -13,54 +13,6 @@ describe("bucket middleware rate limiters", () => {
mockedRateLimit.mockImplementation((config) => config);
});
test("loginLimiter uses LOGIN_RATE_LIMIT settings", async () => {
const { loginLimiter } = await import("./bucket");
expect(rateLimit).toHaveBeenCalledWith({
interval: constants.LOGIN_RATE_LIMIT.interval,
allowedPerInterval: constants.LOGIN_RATE_LIMIT.allowedPerInterval,
});
expect(loginLimiter).toEqual({
interval: constants.LOGIN_RATE_LIMIT.interval,
allowedPerInterval: constants.LOGIN_RATE_LIMIT.allowedPerInterval,
});
});
test("signupLimiter uses SIGNUP_RATE_LIMIT settings", async () => {
const { signupLimiter } = await import("./bucket");
expect(rateLimit).toHaveBeenCalledWith({
interval: constants.SIGNUP_RATE_LIMIT.interval,
allowedPerInterval: constants.SIGNUP_RATE_LIMIT.allowedPerInterval,
});
expect(signupLimiter).toEqual({
interval: constants.SIGNUP_RATE_LIMIT.interval,
allowedPerInterval: constants.SIGNUP_RATE_LIMIT.allowedPerInterval,
});
});
test("verifyEmailLimiter uses VERIFY_EMAIL_RATE_LIMIT settings", async () => {
const { verifyEmailLimiter } = await import("./bucket");
expect(rateLimit).toHaveBeenCalledWith({
interval: constants.VERIFY_EMAIL_RATE_LIMIT.interval,
allowedPerInterval: constants.VERIFY_EMAIL_RATE_LIMIT.allowedPerInterval,
});
expect(verifyEmailLimiter).toEqual({
interval: constants.VERIFY_EMAIL_RATE_LIMIT.interval,
allowedPerInterval: constants.VERIFY_EMAIL_RATE_LIMIT.allowedPerInterval,
});
});
test("forgotPasswordLimiter uses FORGET_PASSWORD_RATE_LIMIT settings", async () => {
const { forgotPasswordLimiter } = await import("./bucket");
expect(rateLimit).toHaveBeenCalledWith({
interval: constants.FORGET_PASSWORD_RATE_LIMIT.interval,
allowedPerInterval: constants.FORGET_PASSWORD_RATE_LIMIT.allowedPerInterval,
});
expect(forgotPasswordLimiter).toEqual({
interval: constants.FORGET_PASSWORD_RATE_LIMIT.interval,
allowedPerInterval: constants.FORGET_PASSWORD_RATE_LIMIT.allowedPerInterval,
});
});
test("clientSideApiEndpointsLimiter uses CLIENT_SIDE_API_RATE_LIMIT settings", async () => {
const { clientSideApiEndpointsLimiter } = await import("./bucket");
expect(rateLimit).toHaveBeenCalledWith({
@@ -73,18 +25,6 @@ describe("bucket middleware rate limiters", () => {
});
});
test("shareUrlLimiter uses SHARE_RATE_LIMIT settings", async () => {
const { shareUrlLimiter } = await import("./bucket");
expect(rateLimit).toHaveBeenCalledWith({
interval: constants.SHARE_RATE_LIMIT.interval,
allowedPerInterval: constants.SHARE_RATE_LIMIT.allowedPerInterval,
});
expect(shareUrlLimiter).toEqual({
interval: constants.SHARE_RATE_LIMIT.interval,
allowedPerInterval: constants.SHARE_RATE_LIMIT.allowedPerInterval,
});
});
test("syncUserIdentificationLimiter uses SYNC_USER_IDENTIFICATION_RATE_LIMIT settings", async () => {
const { syncUserIdentificationLimiter } = await import("./bucket");
expect(rateLimit).toHaveBeenCalledWith({

View File

@@ -1,40 +1,11 @@
import {
CLIENT_SIDE_API_RATE_LIMIT,
FORGET_PASSWORD_RATE_LIMIT,
LOGIN_RATE_LIMIT,
SHARE_RATE_LIMIT,
SIGNUP_RATE_LIMIT,
SYNC_USER_IDENTIFICATION_RATE_LIMIT,
VERIFY_EMAIL_RATE_LIMIT,
} from "@/lib/constants";
import { CLIENT_SIDE_API_RATE_LIMIT, SYNC_USER_IDENTIFICATION_RATE_LIMIT } from "@/lib/constants";
import { rateLimit } from "@/lib/utils/rate-limit";
export const loginLimiter = rateLimit({
interval: LOGIN_RATE_LIMIT.interval,
allowedPerInterval: LOGIN_RATE_LIMIT.allowedPerInterval,
});
export const signupLimiter = rateLimit({
interval: SIGNUP_RATE_LIMIT.interval,
allowedPerInterval: SIGNUP_RATE_LIMIT.allowedPerInterval,
});
export const verifyEmailLimiter = rateLimit({
interval: VERIFY_EMAIL_RATE_LIMIT.interval,
allowedPerInterval: VERIFY_EMAIL_RATE_LIMIT.allowedPerInterval,
});
export const forgotPasswordLimiter = rateLimit({
interval: FORGET_PASSWORD_RATE_LIMIT.interval,
allowedPerInterval: FORGET_PASSWORD_RATE_LIMIT.allowedPerInterval,
});
export const clientSideApiEndpointsLimiter = rateLimit({
interval: CLIENT_SIDE_API_RATE_LIMIT.interval,
allowedPerInterval: CLIENT_SIDE_API_RATE_LIMIT.allowedPerInterval,
});
export const shareUrlLimiter = rateLimit({
interval: SHARE_RATE_LIMIT.interval,
allowedPerInterval: SHARE_RATE_LIMIT.allowedPerInterval,
});
export const syncUserIdentificationLimiter = rateLimit({
interval: SYNC_USER_IDENTIFICATION_RATE_LIMIT.interval,
allowedPerInterval: SYNC_USER_IDENTIFICATION_RATE_LIMIT.allowedPerInterval,

View File

@@ -3,63 +3,13 @@ import {
isAdminDomainRoute,
isAuthProtectedRoute,
isClientSideApiRoute,
isForgotPasswordRoute,
isLoginRoute,
isManagementApiRoute,
isPublicDomainRoute,
isRouteAllowedForDomain,
isShareUrlRoute,
isSignupRoute,
isSyncWithUserIdentificationEndpoint,
isVerifyEmailRoute,
} from "./endpoint-validator";
describe("endpoint-validator", () => {
describe("isLoginRoute", () => {
test("should return true for login routes", () => {
expect(isLoginRoute("/api/auth/callback/credentials")).toBe(true);
expect(isLoginRoute("/auth/login")).toBe(true);
});
test("should return false for non-login routes", () => {
expect(isLoginRoute("/auth/signup")).toBe(false);
expect(isLoginRoute("/api/something")).toBe(false);
});
});
describe("isSignupRoute", () => {
test("should return true for signup route", () => {
expect(isSignupRoute("/auth/signup")).toBe(true);
});
test("should return false for non-signup routes", () => {
expect(isSignupRoute("/auth/login")).toBe(false);
expect(isSignupRoute("/api/something")).toBe(false);
});
});
describe("isVerifyEmailRoute", () => {
test("should return true for verify email route", () => {
expect(isVerifyEmailRoute("/auth/verify-email")).toBe(true);
});
test("should return false for non-verify email routes", () => {
expect(isVerifyEmailRoute("/auth/login")).toBe(false);
expect(isVerifyEmailRoute("/api/something")).toBe(false);
});
});
describe("isForgotPasswordRoute", () => {
test("should return true for forgot password route", () => {
expect(isForgotPasswordRoute("/auth/forgot-password")).toBe(true);
});
test("should return false for non-forgot password routes", () => {
expect(isForgotPasswordRoute("/auth/login")).toBe(false);
expect(isForgotPasswordRoute("/api/something")).toBe(false);
});
});
describe("isClientSideApiRoute", () => {
test("should return true for client-side API routes", () => {
expect(isClientSideApiRoute("/api/v1/js/actions")).toBe(true);
@@ -91,20 +41,6 @@ describe("endpoint-validator", () => {
});
});
describe("isShareUrlRoute", () => {
test("should return true for share URL routes", () => {
expect(isShareUrlRoute("/share/abc123/summary")).toBe(true);
expect(isShareUrlRoute("/share/abc123/responses")).toBe(true);
expect(isShareUrlRoute("/share/abc123def456/summary")).toBe(true);
});
test("should return false for non-share URL routes", () => {
expect(isShareUrlRoute("/share/abc123")).toBe(false);
expect(isShareUrlRoute("/share/abc123/other")).toBe(false);
expect(isShareUrlRoute("/api/something")).toBe(false);
});
});
describe("isAuthProtectedRoute", () => {
test("should return true for protected routes", () => {
expect(isAuthProtectedRoute("/environments")).toBe(true);

View File

@@ -4,15 +4,6 @@ import {
matchesAnyPattern,
} from "./route-config";
export const isLoginRoute = (url: string) =>
url === "/api/auth/callback/credentials" || url === "/auth/login";
export const isSignupRoute = (url: string) => url === "/auth/signup";
export const isVerifyEmailRoute = (url: string) => url === "/auth/verify-email";
export const isForgotPasswordRoute = (url: string) => url === "/auth/forgot-password";
export const isClientSideApiRoute = (url: string): boolean => {
// Open Graph image generation route is a client side API route but it should not be rate limited
if (url.includes("/api/v1/client/og")) return false;
@@ -28,11 +19,6 @@ export const isManagementApiRoute = (url: string): boolean => {
return regex.test(url);
};
export const isShareUrlRoute = (url: string): boolean => {
const regex = /\/share\/[A-Za-z0-9]+\/(?:summary|responses)/;
return regex.test(url);
};
export const isAuthProtectedRoute = (url: string): boolean => {
// List of routes that require authentication
const protectedRoutes = ["/environments", "/setup/organization", "/organizations"];

View File

@@ -7,6 +7,8 @@ import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getSurvey, getSurveyIdByResultShareKey } from "@/lib/survey/service";
import { getTagsByEnvironmentId } from "@/lib/tag/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { getTranslate } from "@/tolgee/server";
@@ -21,6 +23,8 @@ interface ResponsesPageProps {
}
const Page = async (props: ResponsesPageProps) => {
await applyIPRateLimit(rateLimitConfigs.share.url);
const t = await getTranslate();
const params = await props.params;
const surveyId = await getSurveyIdByResultShareKey(params.sharingKey);

View File

@@ -0,0 +1,144 @@
import { getSurveyIdByResultShareKey } from "@/lib/survey/service";
// Import mocked functions
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { cleanup } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
// Mock all dependencies to avoid server-side environment issues
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
IS_PRODUCTION: false,
WEBAPP_URL: "http://localhost:3000",
SHORT_URL_BASE: "http://localhost:3000",
ENCRYPTION_KEY: "test-key",
RATE_LIMITING_DISABLED: false,
}));
vi.mock("@/lib/env", () => ({
env: {
IS_FORMBRICKS_CLOUD: "0",
NODE_ENV: "test",
WEBAPP_URL: "http://localhost:3000",
SHORT_URL_BASE: "http://localhost:3000",
ENCRYPTION_KEY: "test-key",
RATE_LIMITING_DISABLED: "false",
},
}));
// Mock rate limiting dependencies
vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyIPRateLimit: vi.fn(),
}));
vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({
rateLimitConfigs: {
share: {
url: { interval: 60, allowedPerInterval: 30, namespace: "share:url" },
},
},
}));
// Mock other dependencies
vi.mock("@/lib/survey/service", () => ({
getSurveyIdByResultShareKey: vi.fn(),
}));
describe("Share Summary Page Rate Limiting", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
describe("Rate Limiting Configuration", () => {
test("should have correct rate limit config for share URLs", () => {
expect(rateLimitConfigs.share.url).toEqual({
interval: 60,
allowedPerInterval: 30,
namespace: "share:url",
});
});
test("should apply rate limiting function correctly", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue();
await applyIPRateLimit(rateLimitConfigs.share.url);
expect(applyIPRateLimit).toHaveBeenCalledWith({
interval: 60,
allowedPerInterval: 30,
namespace: "share:url",
});
});
test("should throw rate limit error when limit exceeded", async () => {
vi.mocked(applyIPRateLimit).mockRejectedValue(
new Error("Maximum number of requests reached. Please try again later.")
);
await expect(applyIPRateLimit(rateLimitConfigs.share.url)).rejects.toThrow(
"Maximum number of requests reached. Please try again later."
);
});
});
describe("Share Key Validation Flow", () => {
test("should validate sharing key after rate limiting", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.mocked(getSurveyIdByResultShareKey).mockResolvedValue("survey123");
// Simulate the flow: rate limit first, then validate sharing key
await applyIPRateLimit(rateLimitConfigs.share.url);
const surveyId = await getSurveyIdByResultShareKey("test-sharing-key-123");
expect(applyIPRateLimit).toHaveBeenCalledWith(rateLimitConfigs.share.url);
expect(getSurveyIdByResultShareKey).toHaveBeenCalledWith("test-sharing-key-123");
expect(surveyId).toBe("survey123");
});
test("should handle invalid sharing keys after rate limiting", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.mocked(getSurveyIdByResultShareKey).mockResolvedValue(null);
await applyIPRateLimit(rateLimitConfigs.share.url);
const surveyId = await getSurveyIdByResultShareKey("invalid-key");
expect(applyIPRateLimit).toHaveBeenCalledWith(rateLimitConfigs.share.url);
expect(getSurveyIdByResultShareKey).toHaveBeenCalledWith("invalid-key");
expect(surveyId).toBeNull();
});
});
describe("Security Considerations", () => {
test("should rate limit all requests regardless of sharing key validity", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue();
// Test with valid sharing key
vi.mocked(getSurveyIdByResultShareKey).mockResolvedValue("survey123");
await applyIPRateLimit(rateLimitConfigs.share.url);
await getSurveyIdByResultShareKey("valid-key");
// Test with invalid sharing key
vi.mocked(getSurveyIdByResultShareKey).mockResolvedValue(null);
await applyIPRateLimit(rateLimitConfigs.share.url);
await getSurveyIdByResultShareKey("invalid-key");
expect(applyIPRateLimit).toHaveBeenCalledTimes(2);
});
test("should not expose internal errors when rate limited", async () => {
const rateLimitError = new Error("Maximum number of requests reached. Please try again later.");
vi.mocked(applyIPRateLimit).mockRejectedValue(rateLimitError);
await expect(applyIPRateLimit(rateLimitConfigs.share.url)).rejects.toThrow(
"Maximum number of requests reached. Please try again later."
);
// Ensure no other operations are performed
expect(getSurveyIdByResultShareKey).not.toHaveBeenCalled();
});
});
});

View File

@@ -6,6 +6,8 @@ import { getEnvironment } from "@/lib/environment/service";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getSurvey, getSurveyIdByResultShareKey } from "@/lib/survey/service";
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { getTranslate } from "@/tolgee/server";
@@ -20,6 +22,8 @@ interface SummaryPageProps {
}
const Page = async (props: SummaryPageProps) => {
await applyIPRateLimit(rateLimitConfigs.share.url);
const t = await getTranslate();
const params = await props.params;
const surveyId = await getSurveyIdByResultShareKey(params.sharingKey);

View File

@@ -0,0 +1,47 @@
import { redirect } from "next/navigation";
import { describe, expect, test, vi } from "vitest";
// Mock the redirect function
vi.mock("next/navigation", () => ({
redirect: vi.fn(),
}));
// Import the page component
const PageComponent = (await import("./page")).default;
describe("Share Redirect Page", () => {
test("should redirect to summary page without rate limiting", async () => {
const mockParams = Promise.resolve({ sharingKey: "test-sharing-key-123" });
await PageComponent({ params: mockParams });
expect(redirect).toHaveBeenCalledWith("/share/test-sharing-key-123/summary");
});
test("should handle different sharing keys", async () => {
const testCases = ["abc123", "survey-key-456", "long-sharing-key-with-dashes-789"];
for (const sharingKey of testCases) {
vi.clearAllMocks();
const mockParams = Promise.resolve({ sharingKey });
await PageComponent({ params: mockParams });
expect(redirect).toHaveBeenCalledWith(`/share/${sharingKey}/summary`);
}
});
test("should be lightweight and not perform any rate limiting", async () => {
// This test ensures the page doesn't import or use rate limiting
const mockParams = Promise.resolve({ sharingKey: "test-key" });
// Measure execution time to ensure it's very fast (< 10ms)
const startTime = performance.now();
await PageComponent({ params: mockParams });
const endTime = performance.now();
const executionTime = endTime - startTime;
expect(executionTime).toBeLessThan(10); // Should be very fast since it's just a redirect
expect(redirect).toHaveBeenCalled();
});
});

BIN
apps/web/images/plain.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -154,15 +154,6 @@ export const SURVEY_BG_COLORS = [
];
// Rate Limiting
export const SIGNUP_RATE_LIMIT = {
interval: 60 * 60, // 60 minutes
allowedPerInterval: 30,
};
export const LOGIN_RATE_LIMIT = {
interval: 15 * 60, // 15 minutes
allowedPerInterval: 30,
};
export const CLIENT_SIDE_API_RATE_LIMIT = {
interval: 60, // 1 minute
allowedPerInterval: 100,
@@ -171,23 +162,6 @@ export const MANAGEMENT_API_RATE_LIMIT = {
interval: 60, // 1 minute
allowedPerInterval: 100,
};
export const SHARE_RATE_LIMIT = {
interval: 60 * 1, // 1 minutes
allowedPerInterval: 30,
};
export const FORGET_PASSWORD_RATE_LIMIT = {
interval: 60 * 60, // 60 minutes
allowedPerInterval: 5, // Limit to 5 requests per hour
};
export const RESET_PASSWORD_RATE_LIMIT = {
interval: 60 * 60, // 60 minutes
allowedPerInterval: 5, // Limit to 5 requests per hour
};
export const VERIFY_EMAIL_RATE_LIMIT = {
interval: 60 * 60, // 60 minutes
allowedPerInterval: 10, // Limit to 10 requests per hour
};
export const SYNC_USER_IDENTIFICATION_RATE_LIMIT = {
interval: 60, // 1 minute
allowedPerInterval: 5,

View File

@@ -1,5 +1,5 @@
import { describe, expect, test } from "vitest";
import { hashString } from "./hashString";
import { hashString } from "./hash-string";
describe("hashString", () => {
test("should return a string", () => {

View File

@@ -61,7 +61,7 @@ export const writeData = async (
};
const getHeaders = (config: TIntegrationNotionConfig) => {
const decryptedToken = symmetricDecrypt(config.key.access_token, ENCRYPTION_KEY!);
const decryptedToken = symmetricDecrypt(config.key.access_token, ENCRYPTION_KEY);
return {
Accept: "application/json",
"Content-Type": "application/json",

View File

@@ -0,0 +1,283 @@
import { TPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
import { symmetricDecrypt } from "@/lib/crypto";
import { PlainClient } from "@team-plain/typescript-sdk";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
import { err, ok } from "@formbricks/types/error-handlers";
import { TIntegrationPlainConfig, TIntegrationPlainConfigData } from "@formbricks/types/integration/plain";
import { writeData } from "./service";
// Mock dependencies before importing the module under test
vi.mock("@team-plain/typescript-sdk", () => {
return {
PlainClient: vi.fn(),
};
});
vi.mock("@/lib/crypto", () => {
return {
symmetricDecrypt: vi.fn(),
};
});
vi.mock("@formbricks/logger", () => {
return {
logger: {
error: vi.fn(),
},
};
});
vi.mock("@/lib/constants", () => {
return {
ENCRYPTION_KEY: "test-encryption-key",
};
});
describe("Plain Service", () => {
// Mock data
const mockConfig: TIntegrationPlainConfig = {
key: "encrypted-api-key",
data: [],
};
const mockIntegrationConfig: TIntegrationPlainConfigData = {
surveyId: "survey-123",
surveyName: "Test Survey",
mapping: [
{
plainField: {
id: "threadTitle",
name: "Thread Title",
type: "title" as const,
},
question: {
id: "q1",
name: "Question 1",
type: "openText",
},
},
{
plainField: {
id: "componentText",
name: "Component Text",
type: "componentText" as const,
},
question: {
id: "q2",
name: "Question 2",
type: "openText",
},
},
{
plainField: {
id: "labelTypeId",
name: "Label Type",
type: "labelTypeId" as const,
},
question: {
id: "q3",
name: "Question 3",
type: "openText",
},
},
],
includeCreatedAt: true,
includeComponents: true,
createdAt: new Date(),
};
const mockPipelineInput: TPipelineInput = {
environmentId: "env-123",
surveyId: "survey-123",
event: "responseFinished",
response: {
id: "response-123",
createdAt: new Date(),
updatedAt: new Date(),
surveyId: "survey-123",
finished: true,
data: {
q1: "Test Thread Title",
q2: "This is the component text content",
q3: "label-456",
contactInfo: ["John", "Doe", "john.doe@example.com"],
},
meta: { url: "https://example.com" },
contact: null,
contactAttributes: null,
variables: {},
notes: [],
tags: [],
singleUseId: null,
language: null,
},
};
// Mock implementations
const mockUpsertCustomer = vi.fn().mockResolvedValue({});
const mockCreateThread = vi.fn().mockResolvedValue({
id: "thread-123",
title: "Test Thread",
});
const mockPlainClientInstance = {
upsertCustomer: mockUpsertCustomer,
createThread: mockCreateThread,
};
// Setup before each test
beforeEach(() => {
vi.clearAllMocks();
// Setup PlainClient mock
vi.mocked(PlainClient).mockImplementation(() => mockPlainClientInstance as unknown as PlainClient);
// Setup symmetricDecrypt mock
vi.mocked(symmetricDecrypt).mockReturnValue("decrypted-api-key");
});
test("successfully sends data to Plain", async () => {
// Act
const result = await writeData(mockConfig, mockPipelineInput, mockIntegrationConfig);
// Assert
expect(symmetricDecrypt).toHaveBeenCalledWith("encrypted-api-key", "test-encryption-key");
expect(PlainClient).toHaveBeenCalledWith({ apiKey: "decrypted-api-key" });
// Verify customer creation
expect(mockUpsertCustomer).toHaveBeenCalledWith({
identifier: {
emailAddress: "john.doe@example.com",
},
onCreate: {
fullName: "John Doe",
email: {
email: "john.doe@example.com",
isVerified: false,
},
},
onUpdate: {
fullName: {
value: "John Doe",
},
},
});
// Verify thread creation
expect(mockCreateThread).toHaveBeenCalledWith({
title: "Test Thread Title",
customerIdentifier: {
emailAddress: "john.doe@example.com",
},
components: [
{
componentText: {
text: "This is the component text content",
},
},
],
labelTypeIds: ["label-456"],
});
expect(result).toEqual(ok(undefined));
});
test("returns error when title is missing", async () => {
// Arrange
const inputWithoutTitle: TPipelineInput = {
...mockPipelineInput,
response: {
...mockPipelineInput.response,
data: {
// No q1 (title) field
q2: "This is the component text content",
q3: "label-456",
contactInfo: ["John", "Doe", "john.doe@example.com"],
},
},
};
// Act
const result = await writeData(mockConfig, inputWithoutTitle, mockIntegrationConfig);
// Assert
expect(result).toEqual(err(new Error("Missing title in response data.")));
expect(mockUpsertCustomer).not.toHaveBeenCalled();
expect(mockCreateThread).not.toHaveBeenCalled();
});
test("returns error when component text is missing", async () => {
// Arrange
const inputWithoutComponentText: TPipelineInput = {
...mockPipelineInput,
response: {
...mockPipelineInput.response,
data: {
q1: "Test Thread Title",
// No q2 (component text) field
q3: "label-456",
contactInfo: ["John", "Doe", "john.doe@example.com"],
},
},
};
// Act
const result = await writeData(mockConfig, inputWithoutComponentText, mockIntegrationConfig);
// Assert
expect(result).toEqual(err(new Error("Missing component text in response data.")));
expect(mockUpsertCustomer).not.toHaveBeenCalled();
expect(mockCreateThread).not.toHaveBeenCalled();
});
test("creates thread without label when labelId is not mapped", async () => {
// Arrange
const configWithoutLabel: TIntegrationPlainConfigData = {
...mockIntegrationConfig,
mapping: mockIntegrationConfig.mapping.filter((m) => m.plainField.id !== "labelTypeId"),
};
// Act
const result = await writeData(mockConfig, mockPipelineInput, configWithoutLabel);
// Assert
expect(mockCreateThread).toHaveBeenCalledWith(
expect.not.objectContaining({
labelTypeIds: expect.anything(),
})
);
expect(result).toEqual(ok(undefined));
});
test("handles API errors gracefully", async () => {
// Arrange
const apiError = new Error("API Error");
mockUpsertCustomer.mockRejectedValueOnce(apiError);
// Act
const result = await writeData(mockConfig, mockPipelineInput, mockIntegrationConfig);
// Assert
expect(logger.error).toHaveBeenCalledWith("Exception in Plain writeData function", {
error: apiError,
});
expect(result).toEqual(err(apiError));
});
test("handles decryption errors", async () => {
// Arrange
const decryptionError = new Error("Decryption failed");
vi.mocked(symmetricDecrypt).mockImplementationOnce(() => {
throw decryptionError;
});
// Act
const result = await writeData(mockConfig, mockPipelineInput, mockIntegrationConfig);
// Assert
expect(logger.error).toHaveBeenCalledWith("Exception in Plain writeData function", {
error: decryptionError,
});
expect(result).toEqual(err(decryptionError));
});
});

View File

@@ -0,0 +1,108 @@
import { TPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
import { ENCRYPTION_KEY } from "@/lib/constants";
import { symmetricDecrypt } from "@/lib/crypto";
import { PlainClient } from "@team-plain/typescript-sdk";
import { logger } from "@formbricks/logger";
import { Result, err, ok } from "@formbricks/types/error-handlers";
import { TIntegrationPlainConfig, TIntegrationPlainConfigData } from "@formbricks/types/integration/plain";
/**
* Function that handles sending survey response data to Plain
*/
export const writeData = async (
config: TIntegrationPlainConfig,
data: TPipelineInput,
integrationConfig: TIntegrationPlainConfigData
): Promise<Result<void, Error>> => {
try {
const decryptedToken = symmetricDecrypt(config.key, ENCRYPTION_KEY);
const client = new PlainClient({
apiKey: decryptedToken,
});
const titleId = integrationConfig.mapping.find((m) => m.plainField.id === "threadTitle")?.question.id;
const componentTextId = integrationConfig.mapping.find((m) => m.plainField.id === "componentText")
?.question.id;
const labelId = integrationConfig.mapping.find((m) => m.plainField.id === "labelTypeId")?.question.id;
const rawTitle = titleId ? data.response.data[titleId] : undefined;
if (typeof rawTitle !== "string" || rawTitle.trim() === "") {
return err(new Error("Missing title in response data."));
}
const title = rawTitle.trim();
const rawComponentText = componentTextId ? data.response.data[componentTextId] : undefined;
if (typeof rawComponentText !== "string" || rawComponentText.trim() === "") {
return err(new Error("Missing component text in response data."));
}
const componentText = rawComponentText.trim();
const labelValue = labelId ? data.response.data[labelId] : undefined;
// Extract contact information from the response data
let firstName = "";
let lastName = "";
let email = "";
// Find contact info questions by detecting arrays with email pattern
Object.entries(data.response.data || {}).forEach(([_, answer]) => {
if (
Array.isArray(answer) &&
answer.length >= 3 &&
typeof answer[2] === "string" &&
answer[2].includes("@")
) {
firstName = String(answer[0] || "");
lastName = String(answer[1] || "");
email = String(answer[2] || "");
}
});
// Create a customer on Plain
await client.upsertCustomer({
identifier: {
emailAddress: email,
},
// If the customer is not found and should be created then
// these details will be used:
onCreate: {
fullName: `${firstName} ${lastName}`,
email: {
email: email,
isVerified: false, // or true, depending on your requirements
},
},
// If the customer already exists and should be updated then
// these details will be used. You can do partial updates by
// just providing some of the fields below.
onUpdate: {
fullName: {
value: `${firstName} ${lastName}`,
},
},
});
// Create a thread on Plain
await client.createThread({
title: title,
customerIdentifier: {
emailAddress: email,
},
components: [
{
componentText: {
text: componentText,
},
},
],
...(typeof labelValue === "string" && labelValue.trim() !== ""
? { labelTypeIds: [labelValue.trim()] }
: {}),
});
return ok(undefined);
} catch (error) {
logger.error("Exception in Plain writeData function", { error });
return err(error instanceof Error ? error : new Error(String(error)));
}
};

View File

@@ -1,12 +1,12 @@
import { Prisma } from "@prisma/client";
import { DatabaseError, UnknownError } from "@formbricks/types/errors";
import { TIntegration, TIntegrationItem } from "@formbricks/types/integration";
import { TIntegrationItem } from "@formbricks/types/integration";
import { TIntegrationSlack, TIntegrationSlackCredential } from "@formbricks/types/integration/slack";
import { SLACK_MESSAGE_LIMIT } from "../constants";
import { deleteIntegration, getIntegrationByType } from "../integration/service";
import { truncateText } from "../utils/strings";
export const fetchChannels = async (slackIntegration: TIntegration): Promise<TIntegrationItem[]> => {
export const fetchChannels = async (slackIntegration: TIntegrationSlack): Promise<TIntegrationItem[]> => {
let channels: TIntegrationItem[] = [];
// `nextCursor` is a pagination token returned by the Slack API. It indicates the presence of additional pages of data.
// When `nextCursor` is not empty, it should be included in subsequent requests to fetch the next page of data.

View File

@@ -1,6 +1,7 @@
import "server-only";
import { isValidImageFile } from "@/lib/fileValidation";
import { deleteOrganization, getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
import { deleteBrevoCustomerByEmail } from "@/modules/auth/lib/brevo";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { z } from "zod";
@@ -133,6 +134,7 @@ export const deleteUser = async (id: string): Promise<TUser> => {
}
const deletedUser = await deleteUserById(id);
await deleteBrevoCustomerByEmail({ email: deletedUser.email });
return deletedUser;
} catch (error) {

View File

@@ -134,6 +134,8 @@
"and": "und",
"and_response_limit_of": "und Antwortlimit von",
"anonymous": "Anonym",
"api_key_label": "Plain-API-Schlüssel",
"api_key_label_placeholder": "plainApiKey_xxxx",
"api_keys": "API-Schlüssel",
"app": "App",
"app_survey": "App-Umfrage",
@@ -199,8 +201,6 @@
"environment_not_found": "Umgebung nicht gefunden",
"environment_notice": "Du befindest dich derzeit in der {environment}-Umgebung.",
"error": "Fehler",
"error_component_description": "Diese Ressource existiert nicht oder Du hast nicht die notwendigen Rechte, um darauf zuzugreifen.",
"error_component_title": "Fehler beim Laden der Ressourcen",
"expand_rows": "Zeilen erweitern",
"finish": "Fertigstellen",
"follow_these": "Folge diesen",
@@ -703,6 +703,29 @@
"update_connection_tooltip": "Verbinde die Integration erneut, um neu hinzugefügte Datenbanken einzuschließen. Deine bestehenden Integrationen bleiben erhalten."
},
"notion_integration_description": "Sende Daten an deine Notion Datenbank",
"plain": {
"add_key": "Plain-API-Schlüssel hinzufügen",
"add_key_description": "Fügen Sie Ihren Plain-API-Schlüssel hinzu, um sich mit Plain zu verbinden",
"api_key_label": "Plain-API-Schlüssel",
"configure_plain_integration": "Umfrage verknüpfen, um Plain-Threads zu erstellen",
"connect": "Link-Umfrage",
"connect_with_plain": "Mit Plain verbinden",
"connection_success": "Einfach verbunden",
"contact_info_all_present": "Um ein Ticket zu erstellen, muss Plain einen neuen Kunden mit Vorname, Nachname und E-Mail erstellen. Wir müssen diese Informationen mit dem Fragetyp \"Kontakt\" erfassen.",
"contact_info_missing_title": "Diese Umfrage fehlt erforderliche Fragen.",
"contact_info_success_title": "Diese Umfrage enthält alle erforderlichen Fragen.",
"enter_label_id": "Geben Sie die Label-ID aus Plain ein",
"link_new_database": "Neue Umfrage verknüpfen",
"mandatory_mapping_note": "Diskussionstitel und Komponententext sind Pflichtfelder für die Einrichtung der Plain-Integration",
"map_formbricks_fields_to_plain": "Formbricks-Antworten auf Plain-Felder abbilden",
"no_contact_info_question": "Um ein Ticket zu erstellen, muss Plain einen neuen Kunden mit Vorname, Nachname und E-Mail erstellen. Wir müssen diese Informationen mit dem Fragetyp \"Kontakt\" erfassen.",
"no_databases_found": "Keine Umfragen verbunden",
"plain_integration": "Plain Integration",
"plain_integration_description": "Threads auf Plain mit Formbricks-Antworten erstellen",
"select_a_survey_question": "Wähle eine Umfragefrage aus",
"update_connection": "Erneut mit Plain verbinden",
"update_connection_tooltip": "Plain-Verbindung aktualisieren"
},
"please_select_a_survey_error": "Bitte wähle eine Umfrage aus",
"select_at_least_one_question_error": "Bitte wähle mindestens eine Frage aus",
"slack": {
@@ -1282,8 +1305,6 @@
"automatically_release_the_survey_at_the_beginning_of_the_day_utc": "Umfrage automatisch zu Beginn des Tages (UTC) freigeben.",
"back_button_label": "Zurück\"- Button ",
"background_styling": "Hintergründe",
"blocks_survey_if_a_submission_with_the_single_use_id_suid_exists_already": "Blockiert die Umfrage, wenn bereits eine Antwort mit der Single Use Id (suId) existiert.",
"blocks_survey_if_the_survey_url_has_no_single_use_id_suid": "Blockiert Umfrage, wenn die Umfrage-URL keine Single-Use-ID (suId) hat.",
"brand_color": "Markenfarbe",
"brightness": "Helligkeit",
"button_label": "Beschriftung",
@@ -1368,7 +1389,6 @@
"does_not_start_with": "Fängt nicht an mit",
"edit_recall": "Erinnerung bearbeiten",
"edit_translations": "{lang} -Übersetzungen bearbeiten",
"enable_encryption_of_single_use_id_suid_in_survey_url": "Single Use Id (suId) in der Umfrage-URL verschlüsseln.",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Teilnehmer können die Umfragesprache jederzeit während der Umfrage ändern.",
"enable_recaptcha_to_protect_your_survey_from_spam": "Spamschutz verwendet reCAPTCHA v3, um Spam-Antworten herauszufiltern.",
"enable_spam_protection": "Spamschutz",
@@ -1444,7 +1464,6 @@
"hide_the_logo_in_this_specific_survey": "Logo in dieser speziellen Umfrage verstecken",
"hostname": "Hostname",
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "Wie funky sollen deine Karten in {surveyTypeDerived} Umfragen sein",
"how_it_works": "Wie es funktioniert",
"if_you_need_more_please": "Wenn Du mehr brauchst, bitte",
"if_you_really_want_that_answer_ask_until_you_get_it": "Wenn Du diese Antwort brauchst, frag so lange, bis Du sie bekommst.",
"ignore_waiting_time_between_surveys": "Wartezeit zwischen Umfragen ignorieren",
@@ -1482,7 +1501,6 @@
"limit_the_maximum_file_size": "Maximale Dateigröße begrenzen",
"limit_upload_file_size_to": "Maximale Dateigröße für Uploads",
"link_survey_description": "Teile einen Link zu einer Umfrageseite oder bette ihn in eine Webseite oder E-Mail ein.",
"link_used_message": "Link verwendet",
"load_segment": "Segment laden",
"logic_error_warning": "Änderungen werden zu Logikfehlern führen",
"logic_error_warning_text": "Das Ändern des Fragetypen entfernt die Logikbedingungen von dieser Frage",
@@ -1574,8 +1592,6 @@
"show_survey_to_users": "Umfrage % der Nutzer anzeigen",
"show_to_x_percentage_of_targeted_users": "Zeige {percentage}% der Zielbenutzer",
"simple": "Einfach",
"single_use_survey_links": "Einmalige Umfragelinks",
"single_use_survey_links_description": "Erlaube nur eine Antwort pro Umfragelink.",
"six_points": "6 Punkte",
"skip_button_label": "Überspringen-Button-Beschriftung",
"smiley": "Smiley",
@@ -1592,8 +1608,6 @@
"subheading": "Zwischenüberschrift",
"subtract": "Subtrahieren -",
"suggest_colors": "Farben vorschlagen",
"survey_already_answered_heading": "Die Umfrage wurde bereits beantwortet.",
"survey_already_answered_subheading": "Du kannst diesen Link nur einmal verwenden.",
"survey_completed_heading": "Umfrage abgeschlossen",
"survey_completed_subheading": "Diese kostenlose und quelloffene Umfrage wurde geschlossen",
"survey_display_settings": "Einstellungen zur Anzeige der Umfrage",
@@ -1624,7 +1638,6 @@
"upload": "Hochladen",
"upload_at_least_2_images": "Lade mindestens 2 Bilder hoch",
"upper_label": "Oberes Label",
"url_encryption": "URL-Verschlüsselung",
"url_filters": "URL-Filter",
"url_not_supported": "URL nicht unterstützt",
"use_with_caution": "Mit Vorsicht verwenden",

View File

@@ -134,6 +134,8 @@
"and": "And",
"and_response_limit_of": "and response limit of",
"anonymous": "Anonymous",
"api_key_label": "Plain API key",
"api_key_label_placeholder": "plainApiKey_xxxx",
"api_keys": "API Keys",
"app": "App",
"app_survey": "App Survey",
@@ -199,8 +201,6 @@
"environment_not_found": "Environment not found",
"environment_notice": "You're currently in the {environment} environment.",
"error": "Error",
"error_component_description": "This resource doesn't exist or you don't have the necessary rights to access it.",
"error_component_title": "Error loading resources",
"expand_rows": "Expand rows",
"finish": "Finish",
"follow_these": "Follow these",
@@ -703,6 +703,29 @@
"update_connection_tooltip": "Reconnect the integration to include newly added databases. Your existing integrations will remain intact."
},
"notion_integration_description": "Send data to your Notion database",
"plain": {
"add_key": "Add Plain API Key",
"add_key_description": "Add your Plain API Key to connect with Plain",
"api_key_label": "Plain API Key",
"configure_plain_integration": "Link survey to create Plain threads",
"connect": "Link survey",
"connect_with_plain": "Connect with Plain",
"connection_success": "Plain connected successfully",
"contact_info_all_present": "To be able to create a ticket, Plain requires to create a new customer with first name, last name and email. We need to capture this information using the question type Contact.",
"contact_info_missing_title": "This survey is missing necessary questions.",
"contact_info_success_title": "This survey has all necessary questions included.",
"enter_label_id": "Enter label ID from Plain",
"link_new_database": "Link new survey",
"mandatory_mapping_note": "Thread title and Component text are mandatory fields to setup the Plain integration",
"map_formbricks_fields_to_plain": "Map Formbricks responses to Plain fields",
"no_contact_info_question": "To be able to create a ticket, Plain requires to create a new customer with first name, last name and email. We need to capture this information using the question type Contact.",
"no_databases_found": "No surveys connected",
"plain_integration": "Plain Integration",
"plain_integration_description": "Create threads on Plain using Formbricks responses",
"select_a_survey_question": "Select a survey question",
"update_connection": "Reconnect Plain",
"update_connection_tooltip": "Update Plain connection"
},
"please_select_a_survey_error": "Please select a survey",
"select_at_least_one_question_error": "Please select at least one question",
"slack": {
@@ -1282,8 +1305,6 @@
"automatically_release_the_survey_at_the_beginning_of_the_day_utc": "Automatically release the survey at the beginning of the day (UTC).",
"back_button_label": "\"Back\" Button Label",
"background_styling": "Background Styling",
"blocks_survey_if_a_submission_with_the_single_use_id_suid_exists_already": "Blocks survey if a submission with the Single Use Id (suId) exists already.",
"blocks_survey_if_the_survey_url_has_no_single_use_id_suid": "Blocks survey if the survey URL has no Single Use Id (suId).",
"brand_color": "Brand color",
"brightness": "Brightness",
"button_label": "Button Label",
@@ -1368,7 +1389,6 @@
"does_not_start_with": "Does not start with",
"edit_recall": "Edit Recall",
"edit_translations": "Edit {lang} translations",
"enable_encryption_of_single_use_id_suid_in_survey_url": "Enable encryption of Single Use Id (suId) in survey URL.",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Enable participants to switch the survey language at any point during the survey.",
"enable_recaptcha_to_protect_your_survey_from_spam": "Spam protection uses reCAPTCHA v3 to filter out the spam responses.",
"enable_spam_protection": "Spam protection",
@@ -1444,7 +1464,6 @@
"hide_the_logo_in_this_specific_survey": "Hide the logo in this specific survey",
"hostname": "Hostname",
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "How funky do you want your cards in {surveyTypeDerived} Surveys",
"how_it_works": "How it works",
"if_you_need_more_please": "If you need more, please",
"if_you_really_want_that_answer_ask_until_you_get_it": "If you really want that answer, ask until you get it.",
"ignore_waiting_time_between_surveys": "Ignore waiting time between surveys",
@@ -1482,7 +1501,6 @@
"limit_the_maximum_file_size": "Limit the maximum file size",
"limit_upload_file_size_to": "Limit upload file size to",
"link_survey_description": "Share a link to a survey page or embed it in a web page or email.",
"link_used_message": "Link Used",
"load_segment": "Load segment",
"logic_error_warning": "Changing will cause logic errors",
"logic_error_warning_text": "Changing the question type will remove the logic conditions from this question",
@@ -1574,8 +1592,6 @@
"show_survey_to_users": "Show survey to % of users",
"show_to_x_percentage_of_targeted_users": "Show to {percentage}% of targeted users",
"simple": "Simple",
"single_use_survey_links": "Single-use survey links",
"single_use_survey_links_description": "Allow only 1 response per survey link.",
"six_points": "6 points",
"skip_button_label": "Skip Button Label",
"smiley": "Smiley",
@@ -1592,8 +1608,6 @@
"subheading": "Subheading",
"subtract": "Subtract -",
"suggest_colors": "Suggest colors",
"survey_already_answered_heading": "The survey has already been answered.",
"survey_already_answered_subheading": "You can only use this link once.",
"survey_completed_heading": "Survey Completed",
"survey_completed_subheading": "This free & open-source survey has been closed",
"survey_display_settings": "Survey Display Settings",
@@ -1624,7 +1638,6 @@
"upload": "Upload",
"upload_at_least_2_images": "Upload at least 2 images",
"upper_label": "Upper Label",
"url_encryption": "URL Encryption",
"url_filters": "URL Filters",
"url_not_supported": "URL not supported",
"use_with_caution": "Use with caution",

View File

@@ -134,6 +134,8 @@
"and": "Et",
"and_response_limit_of": "et limite de réponse de",
"anonymous": "Anonyme",
"api_key_label": "Clé API Plain",
"api_key_label_placeholder": "plainApiKey_xxxx",
"api_keys": "Clés API",
"app": "Application",
"app_survey": "Sondage d'application",
@@ -199,8 +201,6 @@
"environment_not_found": "Environnement non trouvé",
"environment_notice": "Vous êtes actuellement dans l'environnement {environment}.",
"error": "Erreur",
"error_component_description": "Cette ressource n'existe pas ou vous n'avez pas les droits nécessaires pour y accéder.",
"error_component_title": "Erreur de chargement des ressources",
"expand_rows": "Développer les lignes",
"finish": "Terminer",
"follow_these": "Suivez ceci",
@@ -703,6 +703,29 @@
"update_connection_tooltip": "Reconnectez l'intégration pour inclure les nouvelles bases de données ajoutées. Vos intégrations existantes resteront intactes."
},
"notion_integration_description": "Envoyer des données à votre base de données Notion",
"plain": {
"add_key": "Ajouter une clé API Plain",
"add_key_description": "Ajoutez votre clé API Plain pour vous connecter avec Plain",
"api_key_label": "Clé API Plain",
"configure_plain_integration": "Lier l'enquête pour créer des fils Plain",
"connect": "Lier enquête",
"connect_with_plain": "Se connecter avec Plain",
"connection_success": "Connexion simple réussie",
"contact_info_all_present": "Pour pouvoir créer un ticket, Plain nécessite la création d'un nouveau client avec prénom, nom et email. Nous devons capturer ces informations à l'aide du type de question Contact.",
"contact_info_missing_title": "Ce sondage n'inclut pas les questions nécessaires.",
"contact_info_success_title": "Ce sondage comprend toutes les questions nécessaires.",
"enter_label_id": "Saisissez l'ID de l'étiquette de Plain",
"link_new_database": "Lier nouvelle enquête",
"mandatory_mapping_note": "Le titre du fil et le texte du composant sont des champs obligatoires pour configurer l'intégration simple.",
"map_formbricks_fields_to_plain": "Mapper les réponses de Formbricks aux champs Plain",
"no_contact_info_question": "Pour pouvoir créer un ticket, Plain nécessite la création d'un nouveau client avec prénom, nom et email. Nous devons capturer ces informations à l'aide du type de question Contact.",
"no_databases_found": "Aucune enquête connectée",
"plain_integration": "Intégration Plain",
"plain_integration_description": "Créer des fils sur Plain en utilisant les réponses de Formbricks",
"select_a_survey_question": "Sélectionnez une question d'enquête",
"update_connection": "Reconnecter Plain",
"update_connection_tooltip": "Mettre à jour la connexion Plain"
},
"please_select_a_survey_error": "Veuillez sélectionner une enquête.",
"select_at_least_one_question_error": "Veuillez sélectionner au moins une question.",
"slack": {
@@ -1282,8 +1305,6 @@
"automatically_release_the_survey_at_the_beginning_of_the_day_utc": "Libérer automatiquement l'enquête au début de la journée (UTC).",
"back_button_label": "Label du bouton \"Retour''",
"background_styling": "Style de fond",
"blocks_survey_if_a_submission_with_the_single_use_id_suid_exists_already": "Bloque les enquêtes si une soumission avec l'Identifiant à Usage Unique (suId) existe déjà.",
"blocks_survey_if_the_survey_url_has_no_single_use_id_suid": "Bloque les enquêtes si l'URL de l'enquête n'a pas d'Identifiant d'Utilisation Unique (suId).",
"brand_color": "Couleur de marque",
"brightness": "Luminosité",
"button_label": "Label du bouton",
@@ -1368,7 +1389,6 @@
"does_not_start_with": "Ne commence pas par",
"edit_recall": "Modifier le rappel",
"edit_translations": "Modifier les traductions {lang}",
"enable_encryption_of_single_use_id_suid_in_survey_url": "Activer le chiffrement de l'identifiant à usage unique (suId) dans l'URL de l'enquête.",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permettre aux participants de changer la langue de l'enquête à tout moment pendant celle-ci.",
"enable_recaptcha_to_protect_your_survey_from_spam": "La protection contre le spam utilise reCAPTCHA v3 pour filtrer les réponses indésirables.",
"enable_spam_protection": "Protection contre le spam",
@@ -1444,7 +1464,6 @@
"hide_the_logo_in_this_specific_survey": "Cacher le logo dans cette enquête spécifique",
"hostname": "Nom d'hôte",
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "À quel point voulez-vous que vos cartes soient funky dans les enquêtes {surveyTypeDerived}",
"how_it_works": "Comment ça fonctionne",
"if_you_need_more_please": "Si vous en avez besoin de plus, s'il vous plaît",
"if_you_really_want_that_answer_ask_until_you_get_it": "Si tu veux vraiment cette réponse, demande jusqu'à ce que tu l'obtiennes.",
"ignore_waiting_time_between_surveys": "Ignorer le temps d'attente entre les enquêtes",
@@ -1482,7 +1501,6 @@
"limit_the_maximum_file_size": "Limiter la taille maximale du fichier",
"limit_upload_file_size_to": "Limiter la taille des fichiers téléchargés à",
"link_survey_description": "Partagez un lien vers une page d'enquête ou intégrez-le dans une page web ou un e-mail.",
"link_used_message": "Lien utilisé",
"load_segment": "Segment de chargement",
"logic_error_warning": "Changer causera des erreurs logiques",
"logic_error_warning_text": "Changer le type de question supprimera les conditions logiques de cette question.",
@@ -1574,8 +1592,6 @@
"show_survey_to_users": "Afficher l'enquête à % des utilisateurs",
"show_to_x_percentage_of_targeted_users": "Afficher à {percentage}% des utilisateurs ciblés",
"simple": "Simple",
"single_use_survey_links": "Liens d'enquête à usage unique",
"single_use_survey_links_description": "Autoriser uniquement 1 réponse par lien d'enquête.",
"six_points": "6 points",
"skip_button_label": "Étiquette du bouton Ignorer",
"smiley": "Sourire",
@@ -1592,8 +1608,6 @@
"subheading": "Sous-titre",
"subtract": "Soustraire -",
"suggest_colors": "Suggérer des couleurs",
"survey_already_answered_heading": "L'enquête a déjà été répondue.",
"survey_already_answered_subheading": "Vous ne pouvez utiliser ce lien qu'une seule fois.",
"survey_completed_heading": "Enquête terminée",
"survey_completed_subheading": "Cette enquête gratuite et open-source a été fermée",
"survey_display_settings": "Paramètres d'affichage de l'enquête",
@@ -1624,7 +1638,6 @@
"upload": "Télécharger",
"upload_at_least_2_images": "Téléchargez au moins 2 images",
"upper_label": "Étiquette supérieure",
"url_encryption": "Chiffrement d'URL",
"url_filters": "Filtres d'URL",
"url_not_supported": "URL non supportée",
"use_with_caution": "À utiliser avec précaution",

View File

@@ -134,6 +134,8 @@
"and": "E",
"and_response_limit_of": "e limite de resposta de",
"anonymous": "Anônimo",
"api_key_label": "Chave API do Plain",
"api_key_label_placeholder": "plainApiKey_xxxx",
"api_keys": "Chaves de API",
"app": "app",
"app_survey": "Pesquisa de App",
@@ -199,8 +201,6 @@
"environment_not_found": "Ambiente não encontrado",
"environment_notice": "Você está atualmente no ambiente {environment}.",
"error": "Erro",
"error_component_description": "Esse recurso não existe ou você não tem permissão para acessá-lo.",
"error_component_title": "Erro ao carregar recursos",
"expand_rows": "Expandir linhas",
"finish": "Terminar",
"follow_these": "Siga esses",
@@ -703,6 +703,29 @@
"update_connection_tooltip": "Reconecte a integração para incluir os novos bancos de dados adicionados. Suas integrações existentes permanecerão intactas."
},
"notion_integration_description": "Enviar dados para seu banco de dados do Notion",
"plain": {
"add_key": "Adicionar Chave API do Plain",
"add_key_description": "Adicione sua chave API do Plain para conectar com o Plain",
"api_key_label": "Chave API do Plain",
"configure_plain_integration": "Vincule a pesquisa para criar tópicos Plain",
"connect": "Pesquisa de Link",
"connect_with_plain": "Conectar com o Plain",
"connection_success": "Conexão realizada com sucesso",
"contact_info_all_present": "Para conseguir criar um ticket, a Plain requer a criação de um novo cliente com nome, sobrenome e email. Precisamos capturar essas informações usando o tipo de pergunta Contato.",
"contact_info_missing_title": "Esta pesquisa está sem as perguntas necessárias.",
"contact_info_success_title": "Esta pesquisa inclui todas as perguntas necessárias.",
"enter_label_id": "Insira o ID da etiqueta do Plain",
"link_new_database": "Link de nova pesquisa",
"mandatory_mapping_note": "O título do tópico e o texto do componente são campos obrigatórios para configurar a integração Plain",
"map_formbricks_fields_to_plain": "Mapear respostas do Formbricks para campos Plain",
"no_contact_info_question": "Para conseguir criar um ticket, a Plain requer a criação de um novo cliente com nome, sobrenome e email. Precisamos capturar essas informações usando o tipo de pergunta Contato.",
"no_databases_found": "Nenhuma pesquisa conectada",
"plain_integration": "Integração com o Plain",
"plain_integration_description": "Criar threads no Plain usando respostas do Formbricks",
"select_a_survey_question": "Escolha uma pergunta da pesquisa",
"update_connection": "Reconectar Plain",
"update_connection_tooltip": "Atualizar conexão Plain"
},
"please_select_a_survey_error": "Por favor, escolha uma pesquisa",
"select_at_least_one_question_error": "Por favor, selecione pelo menos uma pergunta",
"slack": {
@@ -1282,8 +1305,6 @@
"automatically_release_the_survey_at_the_beginning_of_the_day_utc": "Liberar automaticamente a pesquisa no começo do dia (UTC).",
"back_button_label": "Voltar",
"background_styling": "Estilo de Fundo",
"blocks_survey_if_a_submission_with_the_single_use_id_suid_exists_already": "Bloqueia a pesquisa se já existir uma submissão com o Id de Uso Único (suId).",
"blocks_survey_if_the_survey_url_has_no_single_use_id_suid": "Bloqueia a pesquisa se a URL da pesquisa não tiver um Id de Uso Único (suId).",
"brand_color": "Cor da marca",
"brightness": "brilho",
"button_label": "Rótulo do Botão",
@@ -1368,7 +1389,6 @@
"does_not_start_with": "Não começa com",
"edit_recall": "Editar Lembrete",
"edit_translations": "Editar traduções de {lang}",
"enable_encryption_of_single_use_id_suid_in_survey_url": "Habilitar criptografia do Id de Uso Único (suId) na URL da pesquisa.",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permitir que os participantes mudem o idioma da pesquisa a qualquer momento durante a pesquisa.",
"enable_recaptcha_to_protect_your_survey_from_spam": "A proteção contra spam usa o reCAPTCHA v3 para filtrar as respostas de spam.",
"enable_spam_protection": "Proteção contra spam",
@@ -1444,7 +1464,6 @@
"hide_the_logo_in_this_specific_survey": "Esconder o logo nessa pesquisa específica",
"hostname": "nome do host",
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "Quão descoladas você quer suas cartas em Pesquisas {surveyTypeDerived}",
"how_it_works": "Como funciona",
"if_you_need_more_please": "Se você precisar de mais, por favor",
"if_you_really_want_that_answer_ask_until_you_get_it": "Se você realmente quer essa resposta, pergunte até conseguir.",
"ignore_waiting_time_between_surveys": "Ignorar tempo de espera entre pesquisas",
@@ -1482,7 +1501,6 @@
"limit_the_maximum_file_size": "Limitar o tamanho máximo do arquivo",
"limit_upload_file_size_to": "Limitar tamanho do arquivo de upload para",
"link_survey_description": "Compartilhe um link para a página da pesquisa ou incorpore-a em uma página da web ou e-mail.",
"link_used_message": "Link Usado",
"load_segment": "segmento de carga",
"logic_error_warning": "Mudar vai causar erros de lógica",
"logic_error_warning_text": "Mudar o tipo de pergunta vai remover as condições lógicas dessa pergunta",
@@ -1574,8 +1592,6 @@
"show_survey_to_users": "Mostrar pesquisa para % dos usuários",
"show_to_x_percentage_of_targeted_users": "Mostrar para {percentage}% dos usuários segmentados",
"simple": "Simples",
"single_use_survey_links": "Links de pesquisa de uso único",
"single_use_survey_links_description": "Permitir apenas 1 resposta por link da pesquisa.",
"six_points": "6 pontos",
"skip_button_label": "Botão de Pular",
"smiley": "Sorridente",
@@ -1592,8 +1608,6 @@
"subheading": "Subtítulo",
"subtract": "Subtrair -",
"suggest_colors": "Sugerir cores",
"survey_already_answered_heading": "A pesquisa já foi respondida.",
"survey_already_answered_subheading": "Você só pode usar esse link uma vez.",
"survey_completed_heading": "Pesquisa Concluída",
"survey_completed_subheading": "Essa pesquisa gratuita e de código aberto foi encerrada",
"survey_display_settings": "Configurações de Exibição da Pesquisa",
@@ -1624,7 +1638,6 @@
"upload": "Enviar",
"upload_at_least_2_images": "Faz o upload de pelo menos 2 imagens",
"upper_label": "Etiqueta Superior",
"url_encryption": "Criptografia de URL",
"url_filters": "Filtros de URL",
"url_not_supported": "URL não suportada",
"use_with_caution": "Use com cuidado",

View File

@@ -134,6 +134,8 @@
"and": "E",
"and_response_limit_of": "e limite de resposta de",
"anonymous": "Anónimo",
"api_key_label": "Chave API do Plain",
"api_key_label_placeholder": "plainApiKey_xxxx",
"api_keys": "Chaves API",
"app": "Aplicação",
"app_survey": "Inquérito da Aplicação",
@@ -199,8 +201,6 @@
"environment_not_found": "Ambiente não encontrado",
"environment_notice": "Está atualmente no ambiente {environment}.",
"error": "Erro",
"error_component_description": "Este recurso não existe ou não tem os direitos necessários para aceder a ele.",
"error_component_title": "Erro ao carregar recursos",
"expand_rows": "Expandir linhas",
"finish": "Concluir",
"follow_these": "Siga estes",
@@ -703,6 +703,29 @@
"update_connection_tooltip": "Restabeleça a integração para incluir as bases de dados recentemente adicionadas. As suas integrações existentes permanecerão intactas."
},
"notion_integration_description": "Enviar dados para a sua base de dados do Notion",
"plain": {
"add_key": "Adicionar Chave API do Plain",
"add_key_description": "Adicione a sua chave API do Plain para ligar ao Plain",
"api_key_label": "Chave API do Plain",
"configure_plain_integration": "Associar inquérito para criar tópicos Plain",
"connect": "Ligar inquérito",
"connect_with_plain": "Ligar ao Plain",
"connection_success": "Ligação simples realizada com sucesso",
"contact_info_all_present": "Para poder criar um ticket, a Plain exige criar um novo cliente com nome, sobrenome e email. Precisamos capturar essa informação usando o tipo de pergunta Contato.",
"contact_info_missing_title": "Este inquérito está a faltar perguntas necessárias.",
"contact_info_success_title": "Este inquérito inclui todas as perguntas necessárias.",
"enter_label_id": "Insira o ID do rótulo da Plain",
"link_new_database": "Ligar novo inquérito",
"mandatory_mapping_note": "Título do tópico e texto do componente são campos obrigatórios para configurar a integração Plain",
"map_formbricks_fields_to_plain": "Mapear respostas do Formbricks para campos Plain",
"no_contact_info_question": "Para poder criar um ticket, a Plain exige criar um novo cliente com nome, sobrenome e email. Precisamos capturar essa informação usando o tipo de pergunta Contato.",
"no_databases_found": "Nenhum inquérito conectado",
"plain_integration": "Integração com Plain",
"plain_integration_description": "Criar tópicos no Plain usando respostas do Formbricks",
"select_a_survey_question": "Selecione uma pergunta do inquérito",
"update_connection": "Reconectar Plain",
"update_connection_tooltip": "Atualizar a ligação Plain"
},
"please_select_a_survey_error": "Por favor, selecione um inquérito",
"select_at_least_one_question_error": "Por favor, selecione pelo menos uma pergunta",
"slack": {
@@ -1282,8 +1305,6 @@
"automatically_release_the_survey_at_the_beginning_of_the_day_utc": "Lançar automaticamente o inquérito no início do dia (UTC).",
"back_button_label": "Rótulo do botão \"Voltar\"",
"background_styling": "Estilo de Fundo",
"blocks_survey_if_a_submission_with_the_single_use_id_suid_exists_already": "Bloqueia o inquérito se já existir uma submissão com o Id de Uso Único (suId).",
"blocks_survey_if_the_survey_url_has_no_single_use_id_suid": "Bloqueia o inquérito se o URL do inquérito não tiver um Id de Uso Único (suId).",
"brand_color": "Cor da marca",
"brightness": "Brilho",
"button_label": "Rótulo do botão",
@@ -1368,7 +1389,6 @@
"does_not_start_with": "Não começa com",
"edit_recall": "Editar Lembrete",
"edit_translations": "Editar traduções {lang}",
"enable_encryption_of_single_use_id_suid_in_survey_url": "Ativar encriptação do Id de Uso Único (suId) no URL do inquérito.",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permitir aos participantes mudar a língua do inquérito a qualquer momento durante o inquérito.",
"enable_recaptcha_to_protect_your_survey_from_spam": "A proteção contra spam usa o reCAPTCHA v3 para filtrar as respostas de spam.",
"enable_spam_protection": "Proteção contra spam",
@@ -1444,7 +1464,6 @@
"hide_the_logo_in_this_specific_survey": "Ocultar o logótipo neste inquérito específico",
"hostname": "Nome do host",
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "Quão extravagantes quer os seus cartões em Inquéritos {surveyTypeDerived}",
"how_it_works": "Como funciona",
"if_you_need_more_please": "Se precisar de mais, por favor",
"if_you_really_want_that_answer_ask_until_you_get_it": "Se realmente quiser essa resposta, pergunte até obtê-la.",
"ignore_waiting_time_between_surveys": "Ignorar tempo de espera entre inquéritos",
@@ -1482,7 +1501,6 @@
"limit_the_maximum_file_size": "Limitar o tamanho máximo do ficheiro",
"limit_upload_file_size_to": "Limitar tamanho do ficheiro carregado a",
"link_survey_description": "Partilhe um link para uma página de inquérito ou incorpore-o numa página web ou email.",
"link_used_message": "Link Utilizado",
"load_segment": "Carregar segmento",
"logic_error_warning": "A alteração causará erros de lógica",
"logic_error_warning_text": "Alterar o tipo de pergunta irá remover as condições lógicas desta pergunta",
@@ -1574,8 +1592,6 @@
"show_survey_to_users": "Mostrar inquérito a % dos utilizadores",
"show_to_x_percentage_of_targeted_users": "Mostrar a {percentage}% dos utilizadores alvo",
"simple": "Simples",
"single_use_survey_links": "Links de inquérito de uso único",
"single_use_survey_links_description": "Permitir apenas 1 resposta por link de inquérito.",
"six_points": "6 pontos",
"skip_button_label": "Rótulo do botão Ignorar",
"smiley": "Sorridente",
@@ -1592,8 +1608,6 @@
"subheading": "Subtítulo",
"subtract": "Subtrair -",
"suggest_colors": "Sugerir cores",
"survey_already_answered_heading": "O inquérito já foi respondido.",
"survey_already_answered_subheading": "Só pode usar este link uma vez.",
"survey_completed_heading": "Inquérito Concluído",
"survey_completed_subheading": "Este inquérito gratuito e de código aberto foi encerrado",
"survey_display_settings": "Configurações de Exibição do Inquérito",
@@ -1624,7 +1638,6 @@
"upload": "Carregar",
"upload_at_least_2_images": "Carregue pelo menos 2 imagens",
"upper_label": "Etiqueta Superior",
"url_encryption": "Encriptação de URL",
"url_filters": "Filtros de URL",
"url_not_supported": "URL não suportado",
"use_with_caution": "Usar com cautela",

View File

@@ -134,6 +134,8 @@
"and": "且",
"and_response_limit_of": "且回應上限為",
"anonymous": "匿名",
"api_key_label": "Plain API 金鑰",
"api_key_label_placeholder": "plainApiKey_xxxx",
"api_keys": "API 金鑰",
"app": "應用程式",
"app_survey": "應用程式問卷",
@@ -199,8 +201,6 @@
"environment_not_found": "找不到環境",
"environment_notice": "您目前在 '{'environment'}' 環境中。",
"error": "錯誤",
"error_component_description": "此資源不存在或您沒有存取權限。",
"error_component_title": "載入資源錯誤",
"expand_rows": "展開列",
"finish": "完成",
"follow_these": "按照這些步驟",
@@ -703,6 +703,29 @@
"update_connection_tooltip": "重新連接整合以包含新添加的資料庫。您現有的整合將保持不變。"
},
"notion_integration_description": "將資料傳送至您的 Notion 資料庫",
"plain": {
"add_key": "新增 Plain API 金鑰",
"add_key_description": "添加你的 Plain API Key 以連線 Plain",
"api_key_label": "Plain API 金鑰",
"configure_plain_integration": "連結 調查 來 建立 Plain 線程",
"connect": "連結問卷",
"connect_with_plain": "連線 Plain",
"connection_success": "純連接成功",
"contact_info_all_present": "要 建立 工單 Plain 必須 建立 一個 新 客戶 包括 名、 姓 和 電子 郵件 。 我們 需要 使用 問題 類型 「聯絡資訊」 來 捕捉 這些 資訊 。",
"contact_info_missing_title": "此 調查 缺少 必要 問題。",
"contact_info_success_title": "此 調查 包含 所有 必要 問題。",
"enter_label_id": "輸入 Plain 的標籤 ID",
"link_new_database": "連結新問卷",
"mandatory_mapping_note": "線程標題 和 組件文本 是 設置 Plain 集成 的 必填字段",
"map_formbricks_fields_to_plain": "將 Formbricks 回應 映射到 Plain 欄位",
"no_contact_info_question": "要 建立 工單 Plain 必須 建立 一個 新 客戶 包括 名、 姓 和 電子 郵件 。 我們 需要 使用 問題 類型 「聯絡資訊」 來 捕捉 這些 資訊 。",
"no_databases_found": "未連結任何問卷",
"plain_integration": "Plain 整合",
"plain_integration_description": "使用 Formbricks 回應在 Plain 上建立 threads",
"select_a_survey_question": "選取問卷問題",
"update_connection": "重新連線 Plain",
"update_connection_tooltip": "更新 Plain 連接"
},
"please_select_a_survey_error": "請選取問卷",
"select_at_least_one_question_error": "請選取至少一個問題",
"slack": {
@@ -1282,8 +1305,6 @@
"automatically_release_the_survey_at_the_beginning_of_the_day_utc": "在指定日期UTC時間自動發佈問卷。",
"back_button_label": "「返回」按鈕標籤",
"background_styling": "背景樣式設定",
"blocks_survey_if_a_submission_with_the_single_use_id_suid_exists_already": "如果已存在具有單次使用 ID (suId) 的提交,則封鎖問卷。",
"blocks_survey_if_the_survey_url_has_no_single_use_id_suid": "如果問卷網址沒有單次使用 ID (suId),則封鎖問卷。",
"brand_color": "品牌顏色",
"brightness": "亮度",
"button_label": "按鈕標籤",
@@ -1368,7 +1389,6 @@
"does_not_start_with": "不以...開頭",
"edit_recall": "編輯回憶",
"edit_translations": "編輯 '{'language'}' 翻譯",
"enable_encryption_of_single_use_id_suid_in_survey_url": "啟用問卷網址中單次使用 ID (suId) 的加密。",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "允許參與者在問卷中的任何時間點切換問卷語言。",
"enable_recaptcha_to_protect_your_survey_from_spam": "垃圾郵件保護使用 reCAPTCHA v3 過濾垃圾回應。",
"enable_spam_protection": "垃圾郵件保護",
@@ -1444,7 +1464,6 @@
"hide_the_logo_in_this_specific_survey": "在此特定問卷中隱藏標誌",
"hostname": "主機名稱",
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "您希望 '{'surveyTypeDerived'}' 問卷中的卡片有多酷炫",
"how_it_works": "運作方式",
"if_you_need_more_please": "如果您需要更多,請",
"if_you_really_want_that_answer_ask_until_you_get_it": "如果您真的想要該答案,請詢問直到您獲得它。",
"ignore_waiting_time_between_surveys": "忽略問卷之間的等待時間",
@@ -1482,7 +1501,6 @@
"limit_the_maximum_file_size": "限制最大檔案大小",
"limit_upload_file_size_to": "限制上傳檔案大小為",
"link_survey_description": "分享問卷頁面的連結或將其嵌入網頁或電子郵件中。",
"link_used_message": "已使用連結",
"load_segment": "載入區隔",
"logic_error_warning": "變更將導致邏輯錯誤",
"logic_error_warning_text": "變更問題類型將會從此問題中移除邏輯條件",
@@ -1574,8 +1592,6 @@
"show_survey_to_users": "將問卷顯示給 % 的使用者",
"show_to_x_percentage_of_targeted_users": "顯示給 '{'percentage'}'% 的目標使用者",
"simple": "簡單",
"single_use_survey_links": "單次使用問卷連結",
"single_use_survey_links_description": "每個問卷連結只允許 1 個回應。",
"six_points": "6 分",
"skip_button_label": "「跳過」按鈕標籤",
"smiley": "表情符號",
@@ -1592,8 +1608,6 @@
"subheading": "副標題",
"subtract": "減 -",
"suggest_colors": "建議顏色",
"survey_already_answered_heading": "問卷已回答。",
"survey_already_answered_subheading": "您只能使用此連結一次。",
"survey_completed_heading": "問卷已完成",
"survey_completed_subheading": "此免費且開源的問卷已關閉",
"survey_display_settings": "問卷顯示設定",
@@ -1624,7 +1638,6 @@
"upload": "上傳",
"upload_at_least_2_images": "上傳至少 2 張圖片",
"upper_label": "上標籤",
"url_encryption": "網址加密",
"url_filters": "網址篩選器",
"url_not_supported": "不支援網址",
"use_with_caution": "謹慎使用",

View File

@@ -1,23 +1,10 @@
import {
clientSideApiEndpointsLimiter,
forgotPasswordLimiter,
loginLimiter,
shareUrlLimiter,
signupLimiter,
syncUserIdentificationLimiter,
verifyEmailLimiter,
} from "@/app/middleware/bucket";
import { clientSideApiEndpointsLimiter, syncUserIdentificationLimiter } from "@/app/middleware/bucket";
import { isPublicDomainConfigured, isRequestFromPublicDomain } from "@/app/middleware/domain-utils";
import {
isAuthProtectedRoute,
isClientSideApiRoute,
isForgotPasswordRoute,
isLoginRoute,
isRouteAllowedForDomain,
isShareUrlRoute,
isSignupRoute,
isSyncWithUserIdentificationEndpoint,
isVerifyEmailRoute,
} from "@/app/middleware/endpoint-validator";
import { IS_PRODUCTION, RATE_LIMITING_DISABLED, WEBAPP_URL } from "@/lib/constants";
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
@@ -51,23 +38,13 @@ const handleAuth = async (request: NextRequest): Promise<Response | null> => {
};
const applyRateLimiting = async (request: NextRequest, ip: string) => {
if (isLoginRoute(request.nextUrl.pathname)) {
await loginLimiter(`login-${ip}`);
} else if (isSignupRoute(request.nextUrl.pathname)) {
await signupLimiter(`signup-${ip}`);
} else if (isVerifyEmailRoute(request.nextUrl.pathname)) {
await verifyEmailLimiter(`verify-email-${ip}`);
} else if (isForgotPasswordRoute(request.nextUrl.pathname)) {
await forgotPasswordLimiter(`forgot-password-${ip}`);
} else if (isClientSideApiRoute(request.nextUrl.pathname)) {
if (isClientSideApiRoute(request.nextUrl.pathname)) {
await clientSideApiEndpointsLimiter(`client-side-api-${ip}`);
const envIdAndUserId = isSyncWithUserIdentificationEndpoint(request.nextUrl.pathname);
if (envIdAndUserId) {
const { environmentId, userId } = envIdAndUserId;
await syncUserIdentificationLimiter(`sync-${environmentId}-${userId}`);
}
} else if (isShareUrlRoute(request.nextUrl.pathname)) {
await shareUrlLimiter(`share-${ip}`);
}
};
@@ -134,7 +111,7 @@ export const middleware = async (originalRequest: NextRequest) => {
await applyRateLimiting(request, ip);
return nextResponseWithCustomHeader;
} catch (e) {
// NOSONAR - This is a catch all for rate limiting errors
logger.error(e, "Error applying rate limiting");
const apiError: ApiErrorResponseV2 = {
type: "too_many_requests",
details: [{ field: "", issue: "Too many requests. Please try again later." }],

View File

@@ -4,6 +4,7 @@ import { toast } from "react-hot-toast";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { getSurveyUrl } from "../../utils";
vi.mock("react-hot-toast", () => ({
toast: {
@@ -11,6 +12,24 @@ vi.mock("react-hot-toast", () => ({
},
}));
// Mock the useSingleUseId hook
vi.mock("@/modules/survey/hooks/useSingleUseId", () => ({
useSingleUseId: vi.fn(() => ({
singleUseId: "test-single-use-id",
refreshSingleUseId: vi.fn().mockResolvedValue("test-single-use-id"),
})),
}));
// Mock the survey utils
vi.mock("../../utils", () => ({
getSurveyUrl: vi.fn((survey, publicDomain, language) => {
if (language && language !== "en") {
return `${publicDomain}/s/${survey.id}?lang=${language}`;
}
return `${publicDomain}/s/${survey.id}`;
}),
}));
const survey: TSurvey = {
id: "survey-id",
name: "Test Survey",
@@ -161,7 +180,7 @@ describe("ShareSurveyLink", () => {
expect(toast.success).toHaveBeenCalledWith("common.copied_to_clipboard");
});
test("opens the preview link in a new tab when preview button is clicked (no query params)", () => {
test("opens the preview link in a new tab when preview button is clicked (no query params)", async () => {
render(
<ShareSurveyLink
survey={survey}
@@ -175,10 +194,13 @@ describe("ShareSurveyLink", () => {
const previewButton = screen.getByLabelText("environments.surveys.preview_survey_in_a_new_tab");
fireEvent.click(previewButton);
// Wait for the async function to complete
await new Promise((resolve) => setTimeout(resolve, 0));
expect(global.open).toHaveBeenCalledWith(`${surveyUrl}?preview=true`, "_blank");
});
test("opens the preview link in a new tab when preview button is clicked (with query params)", () => {
test("opens the preview link in a new tab when preview button is clicked (with query params)", async () => {
const surveyWithParamsUrl = `${publicDomain}/s/survey-id?foo=bar`;
render(
<ShareSurveyLink
@@ -193,6 +215,9 @@ describe("ShareSurveyLink", () => {
const previewButton = screen.getByLabelText("environments.surveys.preview_survey_in_a_new_tab");
fireEvent.click(previewButton);
// Wait for the async function to complete
await new Promise((resolve) => setTimeout(resolve, 0));
expect(global.open).toHaveBeenCalledWith(`${surveyWithParamsUrl}&preview=true`, "_blank");
});
@@ -215,7 +240,9 @@ describe("ShareSurveyLink", () => {
});
test("updates the survey URL when the language is changed", () => {
const { rerender } = render(
const mockGetSurveyUrl = vi.mocked(getSurveyUrl);
render(
<ShareSurveyLink
survey={survey}
publicDomain={publicDomain}
@@ -231,16 +258,7 @@ describe("ShareSurveyLink", () => {
const germanOption = screen.getByText("German");
fireEvent.click(germanOption);
rerender(
<ShareSurveyLink
survey={survey}
publicDomain={publicDomain}
surveyUrl={surveyUrl}
setSurveyUrl={setSurveyUrl}
locale={locale}
/>
);
expect(setSurveyUrl).toHaveBeenCalled();
expect(surveyUrl).toContain("lang=de");
expect(mockGetSurveyUrl).toHaveBeenCalledWith(survey, publicDomain, "de");
expect(setSurveyUrl).toHaveBeenCalledWith(`${publicDomain}/s/${survey.id}?lang=de`);
});
});

View File

@@ -1,5 +1,6 @@
"use client";
import { useSingleUseId } from "@/modules/survey/hooks/useSingleUseId";
import { Button } from "@/modules/ui/components/button";
import { useTranslate } from "@tolgee/react";
import { Copy, SquareArrowOutUpRight } from "lucide-react";
@@ -32,6 +33,22 @@ export const ShareSurveyLink = ({
setSurveyUrl(url);
};
const { refreshSingleUseId } = useSingleUseId(survey);
const getPreviewUrl = async () => {
const previewUrl = new URL(surveyUrl);
if (survey.singleUse?.enabled) {
const newId = await refreshSingleUseId();
if (newId) {
previewUrl.searchParams.set("suId", newId);
}
}
previewUrl.searchParams.set("preview", "true");
return previewUrl.toString();
};
return (
<div className={"flex max-w-full flex-col items-center justify-center gap-2 md:flex-row"}>
<SurveyLinkDisplay surveyUrl={surveyUrl} key={surveyUrl} />
@@ -53,14 +70,9 @@ export const ShareSurveyLink = ({
title={t("environments.surveys.preview_survey_in_a_new_tab")}
aria-label={t("environments.surveys.preview_survey_in_a_new_tab")}
disabled={!surveyUrl}
onClick={() => {
let previewUrl = surveyUrl;
if (previewUrl.includes("?")) {
previewUrl += "&preview=true";
} else {
previewUrl += "?preview=true";
}
window.open(previewUrl, "_blank");
onClick={async () => {
const url = await getPreviewUrl();
window.open(url, "_blank");
}}>
{t("common.preview")}
<SquareArrowOutUpRight />

View File

@@ -20,7 +20,7 @@ vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: true,
AUDIT_LOG_ENABLED: true,
ENCRYPTION_KEY: "mocked-encryption-key",
REDIS_URL: "mock-url",
REDIS_URL: undefined,
}));
describe("utils", () => {

View File

@@ -0,0 +1,230 @@
import { getUserByEmail } from "@/modules/auth/lib/user";
// Import mocked functions
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { sendForgotPasswordEmail } from "@/modules/email";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { forgotPasswordAction } from "./actions";
// Mock dependencies
vi.mock("@/lib/constants", () => ({
PASSWORD_RESET_DISABLED: false,
}));
vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyIPRateLimit: vi.fn(),
}));
vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({
rateLimitConfigs: {
auth: {
forgotPassword: { interval: 3600, allowedPerInterval: 5, namespace: "auth:forgot" },
},
},
}));
vi.mock("@/modules/auth/lib/user", () => ({
getUserByEmail: vi.fn(),
}));
vi.mock("@/modules/email", () => ({
sendForgotPasswordEmail: vi.fn(),
}));
vi.mock("@/lib/utils/action-client", () => ({
actionClient: {
schema: vi.fn().mockReturnThis(),
action: vi.fn((fn) => fn),
},
}));
describe("forgotPasswordAction", () => {
const validInput = {
email: "test@example.com",
};
const mockUser = {
id: "user123",
email: "test@example.com",
identityProvider: "email",
};
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("Rate Limiting", () => {
test("should apply rate limiting before processing forgot password request", async () => {
vi.mocked(getUserByEmail).mockResolvedValue(mockUser as any);
await forgotPasswordAction({ parsedInput: validInput } as any);
expect(applyIPRateLimit).toHaveBeenCalledWith(rateLimitConfigs.auth.forgotPassword);
expect(applyIPRateLimit).toHaveBeenCalledBefore(getUserByEmail as any);
});
test("should throw rate limit error when limit exceeded", async () => {
vi.mocked(applyIPRateLimit).mockRejectedValue(
new Error("Maximum number of requests reached. Please try again later.")
);
await expect(forgotPasswordAction({ parsedInput: validInput } as any)).rejects.toThrow(
"Maximum number of requests reached. Please try again later."
);
expect(getUserByEmail).not.toHaveBeenCalled();
expect(sendForgotPasswordEmail).not.toHaveBeenCalled();
});
test("should use correct rate limit configuration", async () => {
vi.mocked(getUserByEmail).mockResolvedValue(mockUser as any);
await forgotPasswordAction({ parsedInput: validInput } as any);
expect(applyIPRateLimit).toHaveBeenCalledWith({
interval: 3600,
allowedPerInterval: 5,
namespace: "auth:forgot",
});
});
test("should apply rate limiting even when user doesn't exist", async () => {
vi.mocked(getUserByEmail).mockResolvedValue(null);
const result = await forgotPasswordAction({ parsedInput: validInput } as any);
expect(applyIPRateLimit).toHaveBeenCalledWith(rateLimitConfigs.auth.forgotPassword);
expect(result).toEqual({ success: true });
});
});
describe("Password Reset Flow", () => {
test("should send password reset email when user exists with email identity provider", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.mocked(getUserByEmail).mockResolvedValue(mockUser as any);
const result = await forgotPasswordAction({ parsedInput: validInput } as any);
expect(applyIPRateLimit).toHaveBeenCalled();
expect(getUserByEmail).toHaveBeenCalledWith(validInput.email);
expect(sendForgotPasswordEmail).toHaveBeenCalledWith(mockUser);
expect(result).toEqual({ success: true });
});
test("should not send email when user doesn't exist", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.mocked(getUserByEmail).mockResolvedValue(null);
const result = await forgotPasswordAction({ parsedInput: validInput } as any);
expect(applyIPRateLimit).toHaveBeenCalled();
expect(getUserByEmail).toHaveBeenCalledWith(validInput.email);
expect(sendForgotPasswordEmail).not.toHaveBeenCalled();
expect(result).toEqual({ success: true });
});
test("should not send email when user has non-email identity provider", async () => {
const ssoUser = { ...mockUser, identityProvider: "google" };
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.mocked(getUserByEmail).mockResolvedValue(ssoUser as any);
const result = await forgotPasswordAction({ parsedInput: validInput } as any);
expect(applyIPRateLimit).toHaveBeenCalled();
expect(getUserByEmail).toHaveBeenCalledWith(validInput.email);
expect(sendForgotPasswordEmail).not.toHaveBeenCalled();
expect(result).toEqual({ success: true });
});
});
describe("Password Reset Disabled", () => {
test("should check password reset is enabled in our implementation", async () => {
// This test verifies that password reset is enabled by default
// The actual PASSWORD_RESET_DISABLED check is part of the implementation
// and we've mocked it as false, so rate limiting should work normally
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.mocked(getUserByEmail).mockResolvedValue(mockUser as any);
const result = await forgotPasswordAction({ parsedInput: validInput } as any);
expect(applyIPRateLimit).toHaveBeenCalled();
expect(getUserByEmail).toHaveBeenCalled();
expect(result).toEqual({ success: true });
});
});
describe("Error Handling", () => {
test("should propagate rate limiting errors", async () => {
const rateLimitError = new Error("Maximum number of requests reached. Please try again later.");
vi.mocked(applyIPRateLimit).mockRejectedValue(rateLimitError);
await expect(forgotPasswordAction({ parsedInput: validInput } as any)).rejects.toThrow(
"Maximum number of requests reached. Please try again later."
);
});
test("should handle user lookup errors after rate limiting", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.mocked(getUserByEmail).mockRejectedValue(new Error("Database error"));
await expect(forgotPasswordAction({ parsedInput: validInput } as any)).rejects.toThrow(
"Database error"
);
expect(applyIPRateLimit).toHaveBeenCalled();
});
test("should handle email sending errors after rate limiting", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.mocked(getUserByEmail).mockResolvedValue(mockUser as any);
vi.mocked(sendForgotPasswordEmail).mockRejectedValue(new Error("Email service error"));
await expect(forgotPasswordAction({ parsedInput: validInput } as any)).rejects.toThrow(
"Email service error"
);
expect(applyIPRateLimit).toHaveBeenCalled();
expect(getUserByEmail).toHaveBeenCalled();
});
});
describe("Security Considerations", () => {
test("should always return success even for non-existent users to prevent email enumeration", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.mocked(getUserByEmail).mockResolvedValue(null);
const result = await forgotPasswordAction({ parsedInput: validInput } as any);
expect(result).toEqual({ success: true });
});
test("should always return success even for SSO users to prevent identity provider enumeration", async () => {
const ssoUser = { ...mockUser, identityProvider: "github" };
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.mocked(getUserByEmail).mockResolvedValue(ssoUser as any);
const result = await forgotPasswordAction({ parsedInput: validInput } as any);
expect(result).toEqual({ success: true });
expect(sendForgotPasswordEmail).not.toHaveBeenCalled();
});
test("should rate limit all requests regardless of user existence", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue();
// Test with existing user
vi.mocked(getUserByEmail).mockResolvedValue(mockUser as any);
await forgotPasswordAction({ parsedInput: validInput } as any);
// Test with non-existing user
vi.mocked(getUserByEmail).mockResolvedValue(null);
await forgotPasswordAction({ parsedInput: { email: "nonexistent@example.com" } } as any);
expect(applyIPRateLimit).toHaveBeenCalledTimes(2);
});
});
});

View File

@@ -3,6 +3,8 @@
import { PASSWORD_RESET_DISABLED } from "@/lib/constants";
import { actionClient } from "@/lib/utils/action-client";
import { getUserByEmail } from "@/modules/auth/lib/user";
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { sendForgotPasswordEmail } from "@/modules/email";
import { z } from "zod";
import { OperationNotAllowedError } from "@formbricks/types/errors";
@@ -15,6 +17,8 @@ const ZForgotPasswordAction = z.object({
export const forgotPasswordAction = actionClient
.schema(ZForgotPasswordAction)
.action(async ({ parsedInput }) => {
await applyIPRateLimit(rateLimitConfigs.auth.forgotPassword);
if (PASSWORD_RESET_DISABLED) {
throw new OperationNotAllowedError("Password reset is disabled");
}

View File

@@ -25,7 +25,7 @@ vi.mock("@/lib/constants", () => ({
SMTP_HOST: "smtp.example.com",
SMTP_PORT: "587",
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
REDIS_URL: undefined,
AUDIT_LOG_ENABLED: true,
}));

View File

@@ -1,5 +1,8 @@
import { EMAIL_VERIFICATION_DISABLED } from "@/lib/constants";
import { createToken } from "@/lib/jwt";
// Import mocked rate limiting functions
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { randomBytes } from "crypto";
import { Provider } from "next-auth/providers/index";
import { afterEach, describe, expect, test, vi } from "vitest";
@@ -8,6 +11,20 @@ import { authOptions } from "./authOptions";
import { mockUser } from "./mock-data";
import { hashPassword } from "./utils";
// Mock rate limiting dependencies
vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyIPRateLimit: vi.fn(),
}));
vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({
rateLimitConfigs: {
auth: {
login: { interval: 900, allowedPerInterval: 30, namespace: "auth:login" },
verifyEmail: { interval: 3600, allowedPerInterval: 10, namespace: "auth:verify" },
},
},
}));
// Mock constants that this test needs
vi.mock("@/lib/constants", () => ({
EMAIL_VERIFICATION_DISABLED: false,
@@ -21,6 +38,7 @@ vi.mock("@/lib/constants", () => ({
ENTERPRISE_LICENSE_KEY: undefined,
SENTRY_DSN: undefined,
BREVO_API_KEY: undefined,
RATE_LIMITING_DISABLED: false,
}));
// Mock next/headers
@@ -69,6 +87,7 @@ function getProviderById(id: string): Provider {
describe("authOptions", () => {
afterEach(() => {
vi.clearAllMocks();
vi.restoreAllMocks();
});
@@ -82,6 +101,7 @@ describe("authOptions", () => {
});
test("should throw error if user not found", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
vi.spyOn(prisma.user, "findUnique").mockResolvedValue(null);
const credentials = { email: mockUser.email, password: mockPassword };
@@ -92,6 +112,7 @@ describe("authOptions", () => {
});
test("should throw error if user has no password stored", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({
id: mockUser.id,
email: mockUser.email,
@@ -106,6 +127,7 @@ describe("authOptions", () => {
});
test("should throw error if password verification fails", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({
id: mockUserId,
email: mockUser.email,
@@ -120,6 +142,7 @@ describe("authOptions", () => {
});
test("should successfully login when credentials are valid", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
const fakeUser = {
id: mockUserId,
email: mockUser.email,
@@ -142,8 +165,64 @@ describe("authOptions", () => {
});
});
describe("Rate Limiting", () => {
test("should apply rate limiting before credential validation", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({
id: mockUserId,
email: mockUser.email,
password: mockHashedPassword,
emailVerified: new Date(),
twoFactorEnabled: false,
} as any);
const credentials = { email: mockUser.email, password: mockPassword };
await credentialsProvider.options.authorize(credentials, {});
expect(applyIPRateLimit).toHaveBeenCalledWith(rateLimitConfigs.auth.login);
expect(applyIPRateLimit).toHaveBeenCalledBefore(prisma.user.findUnique as any);
});
test("should block login when rate limit exceeded", async () => {
vi.mocked(applyIPRateLimit).mockRejectedValue(
new Error("Maximum number of requests reached. Please try again later.")
);
const credentials = { email: mockUser.email, password: mockPassword };
await expect(credentialsProvider.options.authorize(credentials, {})).rejects.toThrow(
"Maximum number of requests reached. Please try again later."
);
expect(prisma.user.findUnique).not.toHaveBeenCalled();
});
test("should use correct rate limit configuration", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({
id: mockUserId,
email: mockUser.email,
password: mockHashedPassword,
emailVerified: new Date(),
twoFactorEnabled: false,
} as any);
const credentials = { email: mockUser.email, password: mockPassword };
await credentialsProvider.options.authorize(credentials, {});
expect(applyIPRateLimit).toHaveBeenCalledWith({
interval: 900,
allowedPerInterval: 30,
namespace: "auth:login",
});
});
});
describe("Two-Factor Backup Code login", () => {
test("should throw error if backup codes are missing", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
const mockUser = {
id: mockUserId,
email: "2fa@example.com",
@@ -172,6 +251,7 @@ describe("authOptions", () => {
});
test("should throw error if token is invalid or user not found", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
const credentials = { token: "badtoken" };
await expect(tokenProvider.options.authorize(credentials, {})).rejects.toThrow(
@@ -180,6 +260,7 @@ describe("authOptions", () => {
});
test("should throw error if email is already verified", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
vi.spyOn(prisma.user, "findUnique").mockResolvedValue(mockUser as any);
const credentials = { token: createToken(mockUser.id, mockUser.email) };
@@ -190,6 +271,7 @@ describe("authOptions", () => {
});
test("should update user and verify email when token is valid", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({ id: mockUser.id, emailVerified: null } as any);
vi.spyOn(prisma.user, "update").mockResolvedValue({
...mockUser,
@@ -206,6 +288,70 @@ describe("authOptions", () => {
expect(result.email).toBe(mockUser.email);
expect(result.emailVerified).toBeInstanceOf(Date);
});
describe("Rate Limiting", () => {
test("should apply rate limiting before token verification", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({
id: mockUser.id,
emailVerified: null,
} as any);
vi.spyOn(prisma.user, "update").mockResolvedValue({
...mockUser,
password: mockHashedPassword,
backupCodes: null,
twoFactorSecret: null,
identityProviderAccountId: null,
groupId: null,
} as any);
const credentials = { token: createToken(mockUserId, mockUser.email) };
await tokenProvider.options.authorize(credentials, {});
expect(applyIPRateLimit).toHaveBeenCalledWith(rateLimitConfigs.auth.verifyEmail);
});
test("should block verification when rate limit exceeded", async () => {
vi.mocked(applyIPRateLimit).mockRejectedValue(
new Error("Maximum number of requests reached. Please try again later.")
);
const credentials = { token: createToken(mockUserId, mockUser.email) };
await expect(tokenProvider.options.authorize(credentials, {})).rejects.toThrow(
"Maximum number of requests reached. Please try again later."
);
expect(prisma.user.findUnique).not.toHaveBeenCalled();
});
test("should use correct rate limit configuration", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({
id: mockUser.id,
emailVerified: null,
} as any);
vi.spyOn(prisma.user, "update").mockResolvedValue({
...mockUser,
password: mockHashedPassword,
backupCodes: null,
twoFactorSecret: null,
identityProviderAccountId: null,
groupId: null,
} as any);
const credentials = { token: createToken(mockUserId, mockUser.email) };
await tokenProvider.options.authorize(credentials, {});
expect(applyIPRateLimit).toHaveBeenCalledWith({
interval: 3600,
allowedPerInterval: 10,
namespace: "auth:verify",
});
});
});
});
describe("Callbacks", () => {
@@ -275,6 +421,7 @@ describe("authOptions", () => {
const credentialsProvider = getProviderById("credentials");
test("should throw error if TOTP code is missing when 2FA is enabled", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
const mockUser = {
id: mockUserId,
email: "2fa@example.com",
@@ -292,6 +439,7 @@ describe("authOptions", () => {
});
test("should throw error if two factor secret is missing", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
const mockUser = {
id: mockUserId,
email: "2fa@example.com",

View File

@@ -16,6 +16,8 @@ import {
shouldLogAuthFailure,
verifyPassword,
} from "@/modules/auth/lib/utils";
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import { getSSOProviders } from "@/modules/ee/sso/lib/providers";
import { handleSsoCallback } from "@/modules/ee/sso/lib/sso-handlers";
@@ -52,6 +54,8 @@ export const authOptions: NextAuthOptions = {
backupCode: { label: "Backup Code", type: "input", placeholder: "Two-factor backup code" },
},
async authorize(credentials, _req) {
await applyIPRateLimit(rateLimitConfigs.auth.login);
// Use email for rate limiting when available, fall back to "unknown_user" for credential validation
const identifier = credentials?.email || "unknown_user"; // NOSONAR // We want to check for empty strings
@@ -221,6 +225,8 @@ export const authOptions: NextAuthOptions = {
},
},
async authorize(credentials, _req) {
await applyIPRateLimit(rateLimitConfigs.auth.verifyEmail);
// For token verification, we can't rate limit effectively by token (single-use)
// So we use a generic identifier for token abuse attempts
const identifier = "email_verification_attempts";

View File

@@ -1,7 +1,7 @@
import { validateInputs } from "@/lib/utils/validate";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
import { createBrevoCustomer, updateBrevoCustomer } from "./brevo";
import { createBrevoCustomer, deleteBrevoCustomerByEmail, updateBrevoCustomer } from "./brevo";
vi.mock("@/lib/constants", () => ({
BREVO_API_KEY: "mock_api_key",
@@ -125,3 +125,63 @@ describe("updateBrevoCustomer", () => {
expect(validateInputs).toHaveBeenCalled();
});
});
describe("deleteBrevoCustomerByEmail", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("should return early if BREVO_API_KEY is not defined", async () => {
vi.doMock("@/lib/constants", () => ({
BREVO_API_KEY: undefined,
BREVO_LIST_ID: "123",
}));
const { deleteBrevoCustomerByEmail } = await import("./brevo"); // Re-import to get the mocked version
const result = await deleteBrevoCustomerByEmail({ email: "test@example.com" });
expect(result).toBeUndefined();
expect(global.fetch).not.toHaveBeenCalled();
expect(validateInputs).not.toHaveBeenCalled();
});
test("should log an error if fetch fails", async () => {
const loggerSpy = vi.spyOn(logger, "error");
vi.mocked(global.fetch).mockRejectedValueOnce(new Error("Fetch failed"));
await deleteBrevoCustomerByEmail({ email: "test@example.com" });
expect(loggerSpy).toHaveBeenCalledWith(expect.any(Error), "Error deleting user from Brevo");
});
test("should log the error response if fetch status is not 204", async () => {
const loggerSpy = vi.spyOn(logger, "error");
vi.mocked(global.fetch).mockResolvedValueOnce(
new global.Response("Bad Request", { status: 400, statusText: "Bad Request" })
);
await deleteBrevoCustomerByEmail({ email: "test@example.com" });
expect(loggerSpy).toHaveBeenCalledWith({ errorText: "Bad Request" }, "Error deleting user from Brevo");
});
test("should successfully delete a Brevo customer", async () => {
vi.mocked(global.fetch).mockResolvedValueOnce(new global.Response(null, { status: 204 }));
await deleteBrevoCustomerByEmail({ email: "test@example.com" });
expect(global.fetch).toHaveBeenCalledWith(
"https://api.brevo.com/v3/contacts/test%40example.com?identifierType=email_id",
expect.objectContaining({
method: "DELETE",
headers: {
Accept: "application/json",
"api-key": "mock_api_key",
},
})
);
});
});

View File

@@ -95,3 +95,28 @@ export const updateBrevoCustomer = async ({ id, email }: { id: string; email: TU
logger.error(error, "Error updating user in Brevo");
}
};
export const deleteBrevoCustomerByEmail = async ({ email }: { email: TUserEmail }) => {
if (!BREVO_API_KEY) {
return;
}
const encodedEmail = encodeURIComponent(email.toLowerCase());
try {
const res = await fetch(`https://api.brevo.com/v3/contacts/${encodedEmail}?identifierType=email_id`, {
method: "DELETE",
headers: {
Accept: "application/json",
"api-key": BREVO_API_KEY,
},
});
if (res.status !== 204) {
const errorText = await res.text();
logger.error({ errorText }, "Error deleting user from Brevo");
}
} catch (error) {
logger.error(error, "Error deleting user from Brevo");
}
};

View File

@@ -27,6 +27,7 @@ vi.mock("crypto", () => ({
digest: vi.fn(() => "a".repeat(32)), // Mock 64-char hex string
})),
})),
randomUUID: vi.fn(() => "test-uuid-123"),
}));
// Mock Sentry
@@ -42,8 +43,12 @@ vi.mock("@/lib/constants", () => ({
}));
// Mock Redis client
const { mockGetRedisClient } = vi.hoisted(() => ({
mockGetRedisClient: vi.fn(),
}));
vi.mock("@/modules/cache/redis", () => ({
default: null, // Intentionally simulate Redis unavailability to test fail-closed security behavior
getRedisClient: mockGetRedisClient,
}));
describe("Auth Utils", () => {
@@ -109,11 +114,17 @@ describe("Auth Utils", () => {
describe("Rate Limiting", () => {
test("should always allow successful authentication logging", async () => {
// This test doesn't need Redis to be available as it short-circuits for success
mockGetRedisClient.mockResolvedValue(null);
expect(await shouldLogAuthFailure("user@example.com", true)).toBe(true);
expect(await shouldLogAuthFailure("user@example.com", true)).toBe(true);
});
test("should implement fail-closed behavior when Redis is unavailable", async () => {
// Set Redis unavailable for this test
mockGetRedisClient.mockResolvedValue(null);
const email = "rate-limit-test@example.com";
// When Redis is unavailable (mocked as null), the system fails closed for security.
@@ -131,6 +142,254 @@ describe("Auth Utils", () => {
expect(await shouldLogAuthFailure(email, false)).toBe(false); // 9th failure - blocked
expect(await shouldLogAuthFailure(email, false)).toBe(false); // 10th failure - blocked
});
describe("Redis Available - All Branch Coverage", () => {
let mockRedis: any;
let mockMulti: any;
beforeEach(() => {
// Clear mocks first
vi.clearAllMocks();
// Create comprehensive Redis mock
mockMulti = {
zRemRangeByScore: vi.fn().mockReturnThis(),
zCard: vi.fn().mockReturnThis(),
zAdd: vi.fn().mockReturnThis(),
expire: vi.fn().mockReturnThis(),
exec: vi.fn(),
};
mockRedis = {
multi: vi.fn().mockReturnValue(mockMulti),
zRange: vi.fn(),
isReady: true, // Add isReady property
};
// Reset the Redis mock for these specific tests
mockGetRedisClient.mockReset();
mockGetRedisClient.mockReturnValue(mockRedis); // Use mockReturnValue instead of mockResolvedValue
});
test("should handle Redis transaction failure - !results branch", async () => {
// Create fresh mock objects for this test
const testMockMulti = {
zRemRangeByScore: vi.fn().mockReturnThis(),
zCard: vi.fn().mockReturnThis(),
zAdd: vi.fn().mockReturnThis(),
expire: vi.fn().mockReturnThis(),
exec: vi.fn().mockResolvedValue(null), // Mock transaction returning null
};
const testMockRedis = {
multi: vi.fn().mockReturnValue(testMockMulti),
zRange: vi.fn(),
isReady: true,
};
// Reset and setup mock for this specific test
mockGetRedisClient.mockReset();
mockGetRedisClient.mockReturnValue(testMockRedis);
const email = "transaction-failure@example.com";
const result = await shouldLogAuthFailure(email, false);
// Function should return false when Redis transaction fails (fail-closed behavior)
expect(result).toBe(false);
expect(mockGetRedisClient).toHaveBeenCalled();
expect(testMockRedis.multi).toHaveBeenCalled();
expect(testMockMulti.zRemRangeByScore).toHaveBeenCalled();
expect(testMockMulti.zCard).toHaveBeenCalled();
expect(testMockMulti.zAdd).toHaveBeenCalled();
expect(testMockMulti.expire).toHaveBeenCalled();
expect(testMockMulti.exec).toHaveBeenCalled();
});
test("should allow logging when currentCount <= AGGREGATION_THRESHOLD", async () => {
// Mock Redis transaction returning count <= threshold (assuming threshold is 3)
mockMulti.exec.mockResolvedValue([
null, // zRemRangeByScore result
2, // zCard result - below threshold
null, // zAdd result
null, // expire result
]);
const email = "below-threshold@example.com";
const result = await shouldLogAuthFailure(email, false);
expect(result).toBe(true);
expect(mockMulti.exec).toHaveBeenCalled();
});
test("should allow logging when recentEntries.length === 0", async () => {
// Mock Redis transaction returning count above threshold
mockMulti.exec.mockResolvedValue([
null, // zRemRangeByScore result
5, // zCard result - above threshold
null, // zAdd result
null, // expire result
]);
// Mock zRange returning empty array
mockRedis.zRange.mockResolvedValue([]);
const email = "no-recent-entries@example.com";
const result = await shouldLogAuthFailure(email, false);
expect(result).toBe(true);
expect(mockRedis.zRange).toHaveBeenCalledWith(expect.stringContaining("rate_limit:auth:"), -10, -1);
});
test("should allow logging on every 10th attempt - currentCount % 10 === 0", async () => {
const now = Date.now();
// Mock Redis transaction returning count that is divisible by 10
mockMulti.exec.mockResolvedValue([
null, // zRemRangeByScore result
10, // zCard result - 10th attempt
null, // zAdd result
null, // expire result
]);
// Mock zRange returning recent entries
mockRedis.zRange.mockResolvedValue([
`${now - 30000}:uuid1`, // 30 seconds ago
]);
const email = "tenth-attempt@example.com";
const result = await shouldLogAuthFailure(email, false);
expect(result).toBe(true);
expect(mockRedis.zRange).toHaveBeenCalled();
});
test("should allow logging after 1 minute gap - timeSinceLastLog > 60000", async () => {
const now = Date.now();
// Mock Redis transaction returning count not divisible by 10
mockMulti.exec.mockResolvedValue([
null, // zRemRangeByScore result
7, // zCard result - 7th attempt (not divisible by 10)
null, // zAdd result
null, // expire result
]);
// Mock zRange returning entry older than 1 minute
mockRedis.zRange.mockResolvedValue([
`${now - 120000}:uuid1`, // 2 minutes ago
]);
const email = "one-minute-gap@example.com";
const result = await shouldLogAuthFailure(email, false);
expect(result).toBe(true);
expect(mockRedis.zRange).toHaveBeenCalled();
});
test("should block logging when neither condition is met", async () => {
const now = Date.now();
// Mock Redis transaction returning count not divisible by 10
mockMulti.exec.mockResolvedValue([
null, // zRemRangeByScore result
7, // zCard result - 7th attempt (not divisible by 10)
null, // zAdd result
null, // expire result
]);
// Mock zRange returning recent entry (less than 1 minute)
mockRedis.zRange.mockResolvedValue([
`${now - 30000}:uuid1`, // 30 seconds ago
]);
const email = "blocked-logging@example.com";
const result = await shouldLogAuthFailure(email, false);
expect(result).toBe(false);
expect(mockRedis.zRange).toHaveBeenCalled();
});
test("should handle Redis operation errors gracefully", async () => {
// Mock Redis multi throwing an error
mockMulti.exec.mockRejectedValue(new Error("Redis operation failed"));
const email = "redis-error@example.com";
const result = await shouldLogAuthFailure(email, false);
expect(result).toBe(false);
expect(mockMulti.exec).toHaveBeenCalled();
});
test("should handle zRange errors gracefully", async () => {
// Mock successful transaction but zRange failing
mockMulti.exec.mockResolvedValue([
null, // zRemRangeByScore result
5, // zCard result - above threshold
null, // zAdd result
null, // expire result
]);
mockRedis.zRange.mockRejectedValue(new Error("zRange failed"));
const email = "zrange-error@example.com";
const result = await shouldLogAuthFailure(email, false);
expect(result).toBe(false);
expect(mockRedis.zRange).toHaveBeenCalled();
});
test("should handle malformed timestamp in recent entries", async () => {
// Mock Redis transaction returning count not divisible by 10
mockMulti.exec.mockResolvedValue([
null, // zRemRangeByScore result
7, // zCard result - 7th attempt
null, // zAdd result
null, // expire result
]);
// Mock zRange returning entry with malformed timestamp
mockRedis.zRange.mockResolvedValue(["invalid-timestamp:uuid1"]);
const email = "malformed-timestamp@example.com";
const result = await shouldLogAuthFailure(email, false);
// Should handle parseInt(NaN) gracefully and still make a decision
expect(typeof result).toBe("boolean");
expect(mockRedis.zRange).toHaveBeenCalled();
});
test("should verify correct Redis key generation and operations", async () => {
mockMulti.exec.mockResolvedValue([
null, // zRemRangeByScore result
2, // zCard result - below threshold
null, // zAdd result
null, // expire result
]);
const email = "key-generation@example.com";
await shouldLogAuthFailure(email, false);
// Verify correct Redis operations were called
expect(mockRedis.multi).toHaveBeenCalled();
expect(mockMulti.zRemRangeByScore).toHaveBeenCalledWith(
expect.stringContaining("rate_limit:auth:"),
0,
expect.any(Number)
);
expect(mockMulti.zCard).toHaveBeenCalledWith(expect.stringContaining("rate_limit:auth:"));
expect(mockMulti.zAdd).toHaveBeenCalledWith(
expect.stringContaining("rate_limit:auth:"),
expect.objectContaining({
score: expect.any(Number),
value: expect.stringMatching(/^\d+:.+$/),
})
);
expect(mockMulti.expire).toHaveBeenCalledWith(
expect.stringContaining("rate_limit:auth:"),
expect.any(Number)
);
});
});
});
describe("Audit Logging Functions", () => {

View File

@@ -1,5 +1,5 @@
import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
import redis from "@/modules/cache/redis";
import { getRedisClient } from "@/modules/cache/redis";
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditAction, TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import * as Sentry from "@sentry/nextjs";
@@ -228,46 +228,47 @@ export const shouldLogAuthFailure = async (
const rateLimitKey = `rate_limit:auth:${createAuditIdentifier(identifier, "ratelimit")}`;
const now = Date.now();
if (redis) {
try {
// Use Redis for distributed rate limiting
const multi = redis.multi();
const windowStart = now - RATE_LIMIT_WINDOW;
// Remove expired entries and count recent failures
multi.zremrangebyscore(rateLimitKey, 0, windowStart);
multi.zcard(rateLimitKey);
multi.zadd(rateLimitKey, now, `${now}:${randomUUID()}`);
multi.expire(rateLimitKey, Math.ceil(RATE_LIMIT_WINDOW / 1000));
const results = await multi.exec();
if (!results) {
throw new Error("Redis transaction failed");
}
const currentCount = results[1][1] as number;
// Apply throttling logic
if (currentCount <= AGGREGATION_THRESHOLD) {
return true;
}
// Check if we should log (every 10th or after 1 minute gap)
const recentEntries = await redis.zrange(rateLimitKey, -10, -1);
if (recentEntries.length === 0) return true;
const lastLogTime = parseInt(recentEntries[recentEntries.length - 1].split(":")[0]);
const timeSinceLastLog = now - lastLogTime;
return currentCount % 10 === 0 || timeSinceLastLog > 60000;
} catch (error) {
logger.warn("Redis rate limiting failed, not logging due to Redis requirement", { error });
// If Redis fails, do not log as Redis is required for audit logs
try {
// Get Redis client
const redis = getRedisClient();
if (!redis) {
logger.warn("Redis not available for rate limiting, not logging due to Redis requirement");
return false;
}
} else {
logger.warn("Redis not available for rate limiting, not logging due to Redis requirement");
// If Redis not configured, do not log as Redis is required for audit logs
// Use Redis for distributed rate limiting
const multi = redis.multi();
const windowStart = now - RATE_LIMIT_WINDOW;
// Remove expired entries and count recent failures
multi.zRemRangeByScore(rateLimitKey, 0, windowStart);
multi.zCard(rateLimitKey);
multi.zAdd(rateLimitKey, { score: now, value: `${now}:${randomUUID()}` });
multi.expire(rateLimitKey, Math.ceil(RATE_LIMIT_WINDOW / 1000));
const results = await multi.exec();
if (!results) {
throw new Error("Redis transaction failed");
}
const currentCount = results[1] as number;
// Apply throttling logic
if (currentCount <= AGGREGATION_THRESHOLD) {
return true;
}
// Check if we should log (every 10th or after 1 minute gap)
const recentEntries = await redis.zRange(rateLimitKey, -10, -1);
if (recentEntries.length === 0) return true;
const lastLogTime = Number.parseInt(recentEntries[recentEntries.length - 1].split(":")[0]);
const timeSinceLastLog = now - lastLogTime;
return currentCount % 10 === 0 || timeSinceLastLog > 60000;
} catch (error) {
logger.warn("Redis rate limiting failed, not logging due to Redis requirement", { error });
// If Redis fails, do not log as Redis is required for audit logs
return false;
}
};

View File

@@ -11,6 +11,8 @@ import { createUser, updateUser } from "@/modules/auth/lib/user";
import { deleteInvite, getInvite } from "@/modules/auth/signup/lib/invite";
import { createTeamMembership } from "@/modules/auth/signup/lib/team";
import { captureFailedSignup, verifyTurnstileToken } from "@/modules/auth/signup/lib/utils";
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
import { sendInviteAcceptedEmail, sendVerificationEmail } from "@/modules/email";
@@ -177,6 +179,7 @@ export const createUserAction = actionClient.schema(ZCreateUserAction).action(
"created",
"user",
async ({ ctx, parsedInput }: { ctx: ActionClientCtx; parsedInput: Record<string, any> }) => {
await applyIPRateLimit(rateLimitConfigs.auth.signup);
await verifyTurnstileIfConfigured(parsedInput.turnstileToken, parsedInput.email, parsedInput.name);
const hashedPassword = await hashPassword(parsedInput.password);

View File

@@ -0,0 +1,344 @@
import { getUserByEmail } from "@/modules/auth/lib/user";
// Import mocked functions
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { sendVerificationEmail } from "@/modules/email";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { resendVerificationEmailAction } from "./actions";
// Mock dependencies
vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyIPRateLimit: vi.fn(),
}));
vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({
rateLimitConfigs: {
auth: {
verifyEmail: { interval: 3600, allowedPerInterval: 10, namespace: "auth:verify" },
},
},
}));
vi.mock("@/modules/auth/lib/user", () => ({
getUserByEmail: vi.fn(),
}));
vi.mock("@/modules/email", () => ({
sendVerificationEmail: vi.fn(),
}));
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
withAuditLogging: vi.fn((type, object, fn) => fn),
}));
vi.mock("@/lib/utils/action-client", () => ({
actionClient: {
schema: vi.fn().mockReturnThis(),
action: vi.fn((fn) => fn),
},
}));
describe("resendVerificationEmailAction", () => {
const validInput = {
email: "test@example.com",
};
const mockUser = {
id: "user123",
email: "test@example.com",
emailVerified: null, // Not verified
name: "Test User",
};
const mockVerifiedUser = {
id: "user123",
email: "test@example.com",
emailVerified: new Date(),
name: "Test User",
};
const mockCtx = {
auditLoggingCtx: {
organizationId: "",
userId: "",
},
};
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("Rate Limiting", () => {
test("should apply rate limiting before processing verification email resend", async () => {
vi.mocked(getUserByEmail).mockResolvedValue(mockUser as any);
await resendVerificationEmailAction({
ctx: mockCtx,
parsedInput: validInput,
} as any);
expect(applyIPRateLimit).toHaveBeenCalledWith(rateLimitConfigs.auth.verifyEmail);
expect(applyIPRateLimit).toHaveBeenCalledBefore(getUserByEmail as any);
});
test("should throw rate limit error when limit exceeded", async () => {
vi.mocked(applyIPRateLimit).mockRejectedValue(
new Error("Maximum number of requests reached. Please try again later.")
);
await expect(
resendVerificationEmailAction({
ctx: mockCtx,
parsedInput: validInput,
} as any)
).rejects.toThrow("Maximum number of requests reached. Please try again later.");
expect(getUserByEmail).not.toHaveBeenCalled();
expect(sendVerificationEmail).not.toHaveBeenCalled();
});
test("should use correct rate limit configuration", async () => {
vi.mocked(getUserByEmail).mockResolvedValue(mockUser as any);
await resendVerificationEmailAction({
ctx: mockCtx,
parsedInput: validInput,
} as any);
expect(applyIPRateLimit).toHaveBeenCalledWith({
interval: 3600,
allowedPerInterval: 10,
namespace: "auth:verify",
});
});
test("should apply rate limiting even when user doesn't exist", async () => {
vi.mocked(getUserByEmail).mockResolvedValue(null);
await expect(
resendVerificationEmailAction({
ctx: mockCtx,
parsedInput: validInput,
} as any)
).rejects.toThrow(ResourceNotFoundError);
expect(applyIPRateLimit).toHaveBeenCalledWith(rateLimitConfigs.auth.verifyEmail);
});
});
describe("Verification Email Resend Flow", () => {
test("should send verification email when user exists and email is not verified", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.mocked(getUserByEmail).mockResolvedValue(mockUser as any);
const result = await resendVerificationEmailAction({
ctx: mockCtx,
parsedInput: validInput,
} as any);
expect(applyIPRateLimit).toHaveBeenCalled();
expect(getUserByEmail).toHaveBeenCalledWith(validInput.email);
expect(sendVerificationEmail).toHaveBeenCalledWith(mockUser);
expect(result).toEqual({ success: true });
});
test("should return success without sending email when user email is already verified", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.mocked(getUserByEmail).mockResolvedValue(mockVerifiedUser as any);
const result = await resendVerificationEmailAction({
ctx: mockCtx,
parsedInput: validInput,
} as any);
expect(applyIPRateLimit).toHaveBeenCalled();
expect(getUserByEmail).toHaveBeenCalledWith(validInput.email);
expect(sendVerificationEmail).not.toHaveBeenCalled();
expect(result).toEqual({ success: true });
});
test("should throw ResourceNotFoundError when user doesn't exist", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.mocked(getUserByEmail).mockResolvedValue(null);
await expect(
resendVerificationEmailAction({
ctx: mockCtx,
parsedInput: validInput,
} as any)
).rejects.toThrow(ResourceNotFoundError);
expect(applyIPRateLimit).toHaveBeenCalled();
expect(getUserByEmail).toHaveBeenCalledWith(validInput.email);
expect(sendVerificationEmail).not.toHaveBeenCalled();
});
});
describe("Audit Logging", () => {
test("should be wrapped with audit logging decorator", () => {
// withAuditLogging is called at module load time to wrap the action
// We just verify the mock was set up correctly
expect(withAuditLogging).toBeDefined();
});
test("should set audit context userId when sending verification email", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.mocked(getUserByEmail).mockResolvedValue(mockUser as any);
const testCtx = {
auditLoggingCtx: {
organizationId: "",
userId: "",
},
};
await resendVerificationEmailAction({
ctx: testCtx,
parsedInput: validInput,
} as any);
// The userId should be set in the audit context
expect(testCtx.auditLoggingCtx.userId).toBe(mockUser.id);
});
test("should not set audit context userId when email is already verified", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.mocked(getUserByEmail).mockResolvedValue(mockVerifiedUser as any);
const testCtx = {
auditLoggingCtx: {
organizationId: "",
userId: "",
},
};
await resendVerificationEmailAction({
ctx: testCtx,
parsedInput: validInput,
} as any);
// The userId should not be set since no email was sent
expect(testCtx.auditLoggingCtx.userId).toBe("");
});
});
describe("Error Handling", () => {
test("should propagate rate limiting errors", async () => {
const rateLimitError = new Error("Maximum number of requests reached. Please try again later.");
vi.mocked(applyIPRateLimit).mockRejectedValue(rateLimitError);
await expect(
resendVerificationEmailAction({
ctx: mockCtx,
parsedInput: validInput,
} as any)
).rejects.toThrow("Maximum number of requests reached. Please try again later.");
});
test("should handle user lookup errors after rate limiting", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.mocked(getUserByEmail).mockRejectedValue(new Error("Database error"));
await expect(
resendVerificationEmailAction({
ctx: mockCtx,
parsedInput: validInput,
} as any)
).rejects.toThrow("Database error");
expect(applyIPRateLimit).toHaveBeenCalled();
});
test("should handle email sending errors after rate limiting", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.mocked(getUserByEmail).mockResolvedValue(mockUser as any);
vi.mocked(sendVerificationEmail).mockRejectedValue(new Error("Email service error"));
await expect(
resendVerificationEmailAction({
ctx: mockCtx,
parsedInput: validInput,
} as any)
).rejects.toThrow("Email service error");
expect(applyIPRateLimit).toHaveBeenCalled();
expect(getUserByEmail).toHaveBeenCalled();
});
});
describe("Input Validation", () => {
test("should handle empty email input", async () => {
const invalidInput = { email: "" };
// This would be caught by the Zod schema validation in the actual action
// but we test the behavior if it somehow gets through
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.mocked(getUserByEmail).mockResolvedValue(null);
await expect(
resendVerificationEmailAction({
ctx: mockCtx,
parsedInput: invalidInput,
} as any)
).rejects.toThrow(ResourceNotFoundError);
});
test("should handle malformed email input", async () => {
const invalidInput = { email: "invalid-email" };
// This would be caught by the Zod schema validation in the actual action
// but we test the behavior if it somehow gets through
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.mocked(getUserByEmail).mockResolvedValue(null);
await expect(
resendVerificationEmailAction({
ctx: mockCtx,
parsedInput: invalidInput,
} as any)
).rejects.toThrow(ResourceNotFoundError);
});
});
describe("Security Considerations", () => {
test("should always apply rate limiting regardless of user existence", async () => {
vi.mocked(getUserByEmail).mockResolvedValue(null);
await expect(
resendVerificationEmailAction({
ctx: mockCtx,
parsedInput: validInput,
} as any)
).rejects.toThrow(ResourceNotFoundError);
expect(applyIPRateLimit).toHaveBeenCalled();
});
test("should not leak information about user existence through different error messages", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.mocked(getUserByEmail).mockResolvedValue(null);
// Both non-existent users should throw the same ResourceNotFoundError
await expect(
resendVerificationEmailAction({
ctx: mockCtx,
parsedInput: validInput,
} as any)
).rejects.toThrow(ResourceNotFoundError);
const anotherEmail = { email: "another@example.com" };
await expect(
resendVerificationEmailAction({
ctx: mockCtx,
parsedInput: anotherEmail,
} as any)
).rejects.toThrow(ResourceNotFoundError);
});
});
});

View File

@@ -3,6 +3,8 @@
import { actionClient } from "@/lib/utils/action-client";
import { ActionClientCtx } from "@/lib/utils/action-client/types/context";
import { getUserByEmail } from "@/modules/auth/lib/user";
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { sendVerificationEmail } from "@/modules/email";
import { z } from "zod";
@@ -18,6 +20,8 @@ export const resendVerificationEmailAction = actionClient.schema(ZResendVerifica
"verificationEmailSent",
"user",
async ({ ctx, parsedInput }: { ctx: ActionClientCtx; parsedInput: Record<string, any> }) => {
await applyIPRateLimit(rateLimitConfigs.auth.verifyEmail);
const user = await getUserByEmail(parsedInput.email);
if (!user) {
throw new ResourceNotFoundError("user", parsedInput.email);

View File

@@ -0,0 +1,381 @@
import { describe, expect, test } from "vitest";
import { createCacheKey, parseCacheKey, validateCacheKey } from "./cacheKeys";
describe("cacheKeys", () => {
describe("createCacheKey", () => {
describe("environment keys", () => {
test("should create environment state key", () => {
const key = createCacheKey.environment.state("env123");
expect(key).toBe("fb:env:env123:state");
});
test("should create environment surveys key", () => {
const key = createCacheKey.environment.surveys("env456");
expect(key).toBe("fb:env:env456:surveys");
});
test("should create environment actionClasses key", () => {
const key = createCacheKey.environment.actionClasses("env789");
expect(key).toBe("fb:env:env789:action_classes");
});
test("should create environment config key", () => {
const key = createCacheKey.environment.config("env101");
expect(key).toBe("fb:env:env101:config");
});
test("should create environment segments key", () => {
const key = createCacheKey.environment.segments("env202");
expect(key).toBe("fb:env:env202:segments");
});
});
describe("organization keys", () => {
test("should create organization billing key", () => {
const key = createCacheKey.organization.billing("org123");
expect(key).toBe("fb:org:org123:billing");
});
test("should create organization environments key", () => {
const key = createCacheKey.organization.environments("org456");
expect(key).toBe("fb:org:org456:environments");
});
test("should create organization config key", () => {
const key = createCacheKey.organization.config("org789");
expect(key).toBe("fb:org:org789:config");
});
test("should create organization limits key", () => {
const key = createCacheKey.organization.limits("org101");
expect(key).toBe("fb:org:org101:limits");
});
});
describe("license keys", () => {
test("should create license status key", () => {
const key = createCacheKey.license.status("org123");
expect(key).toBe("fb:license:org123:status");
});
test("should create license features key", () => {
const key = createCacheKey.license.features("org456");
expect(key).toBe("fb:license:org456:features");
});
test("should create license usage key", () => {
const key = createCacheKey.license.usage("org789");
expect(key).toBe("fb:license:org789:usage");
});
test("should create license check key", () => {
const key = createCacheKey.license.check("org123", "feature-x");
expect(key).toBe("fb:license:org123:check:feature-x");
});
test("should create license previous_result key", () => {
const key = createCacheKey.license.previous_result("org456");
expect(key).toBe("fb:license:org456:previous_result");
});
});
describe("user keys", () => {
test("should create user profile key", () => {
const key = createCacheKey.user.profile("user123");
expect(key).toBe("fb:user:user123:profile");
});
test("should create user preferences key", () => {
const key = createCacheKey.user.preferences("user456");
expect(key).toBe("fb:user:user456:preferences");
});
test("should create user organizations key", () => {
const key = createCacheKey.user.organizations("user789");
expect(key).toBe("fb:user:user789:organizations");
});
test("should create user permissions key", () => {
const key = createCacheKey.user.permissions("user123", "org456");
expect(key).toBe("fb:user:user123:org:org456:permissions");
});
});
describe("project keys", () => {
test("should create project config key", () => {
const key = createCacheKey.project.config("proj123");
expect(key).toBe("fb:project:proj123:config");
});
test("should create project environments key", () => {
const key = createCacheKey.project.environments("proj456");
expect(key).toBe("fb:project:proj456:environments");
});
test("should create project surveys key", () => {
const key = createCacheKey.project.surveys("proj789");
expect(key).toBe("fb:project:proj789:surveys");
});
});
describe("survey keys", () => {
test("should create survey metadata key", () => {
const key = createCacheKey.survey.metadata("survey123");
expect(key).toBe("fb:survey:survey123:metadata");
});
test("should create survey responses key", () => {
const key = createCacheKey.survey.responses("survey456");
expect(key).toBe("fb:survey:survey456:responses");
});
test("should create survey stats key", () => {
const key = createCacheKey.survey.stats("survey789");
expect(key).toBe("fb:survey:survey789:stats");
});
});
describe("session keys", () => {
test("should create session data key", () => {
const key = createCacheKey.session.data("session123");
expect(key).toBe("fb:session:session123:data");
});
test("should create session permissions key", () => {
const key = createCacheKey.session.permissions("session456");
expect(key).toBe("fb:session:session456:permissions");
});
});
describe("rate limit keys", () => {
test("should create rate limit api key", () => {
const key = createCacheKey.rateLimit.api("api-key-123", "endpoint-v1");
expect(key).toBe("fb:rate_limit:api:api-key-123:endpoint-v1");
});
test("should create rate limit login key", () => {
const key = createCacheKey.rateLimit.login("user-ip-hash");
expect(key).toBe("fb:rate_limit:login:user-ip-hash");
});
test("should create rate limit core key", () => {
const key = createCacheKey.rateLimit.core("auth:login", "user123", 1703174400);
expect(key).toBe("fb:rate_limit:auth:login:user123:1703174400");
});
});
describe("custom keys", () => {
test("should create custom key without subResource", () => {
const key = createCacheKey.custom("temp", "identifier123");
expect(key).toBe("fb:temp:identifier123");
});
test("should create custom key with subResource", () => {
const key = createCacheKey.custom("analytics", "user456", "daily-stats");
expect(key).toBe("fb:analytics:user456:daily-stats");
});
test("should work with all valid namespaces", () => {
const validNamespaces = ["temp", "analytics", "webhook", "integration", "backup"];
validNamespaces.forEach((namespace) => {
const key = createCacheKey.custom(namespace, "test-id");
expect(key).toBe(`fb:${namespace}:test-id`);
});
});
test("should throw error for invalid namespace", () => {
expect(() => createCacheKey.custom("invalid", "identifier")).toThrow(
"Invalid cache namespace: invalid. Use: temp, analytics, webhook, integration, backup"
);
});
test("should throw error for empty namespace", () => {
expect(() => createCacheKey.custom("", "identifier")).toThrow(
"Invalid cache namespace: . Use: temp, analytics, webhook, integration, backup"
);
});
});
});
describe("validateCacheKey", () => {
test("should validate correct cache keys", () => {
const validKeys = [
"fb:env:env123:state",
"fb:user:user456:profile",
"fb:org:org789:billing",
"fb:rate_limit:api:key123:endpoint",
"fb:custom:namespace:identifier:sub:resource",
];
validKeys.forEach((key) => {
expect(validateCacheKey(key)).toBe(true);
});
});
test("should reject keys without fb prefix", () => {
const invalidKeys = ["env:env123:state", "user:user456:profile", "redis:key:value", "cache:item:data"];
invalidKeys.forEach((key) => {
expect(validateCacheKey(key)).toBe(false);
});
});
test("should reject keys with insufficient parts", () => {
const invalidKeys = ["fb:", "fb:env", "fb:env:", "fb:user:user123:"];
invalidKeys.forEach((key) => {
expect(validateCacheKey(key)).toBe(false);
});
});
test("should reject keys with empty parts", () => {
const invalidKeys = ["fb::env123:state", "fb:env::state", "fb:env:env123:", "fb:user::profile"];
invalidKeys.forEach((key) => {
expect(validateCacheKey(key)).toBe(false);
});
});
test("should validate minimum valid key", () => {
expect(validateCacheKey("fb:a:b")).toBe(true);
});
});
describe("parseCacheKey", () => {
test("should parse basic cache key", () => {
const result = parseCacheKey("fb:env:env123:state");
expect(result).toEqual({
prefix: "fb",
resource: "env",
identifier: "env123",
subResource: "state",
full: "fb:env:env123:state",
});
});
test("should parse key without subResource", () => {
const result = parseCacheKey("fb:user:user123");
expect(result).toEqual({
prefix: "fb",
resource: "user",
identifier: "user123",
subResource: undefined,
full: "fb:user:user123",
});
});
test("should parse key with multiple subResource parts", () => {
const result = parseCacheKey("fb:user:user123:org:org456:permissions");
expect(result).toEqual({
prefix: "fb",
resource: "user",
identifier: "user123",
subResource: "org:org456:permissions",
full: "fb:user:user123:org:org456:permissions",
});
});
test("should parse rate limit key with timestamp", () => {
const result = parseCacheKey("fb:rate_limit:auth:login:user123:1703174400");
expect(result).toEqual({
prefix: "fb",
resource: "rate_limit",
identifier: "auth",
subResource: "login:user123:1703174400",
full: "fb:rate_limit:auth:login:user123:1703174400",
});
});
test("should throw error for invalid cache key", () => {
const invalidKeys = ["invalid:key:format", "fb:env", "fb::env123:state", "redis:user:profile"];
invalidKeys.forEach((key) => {
expect(() => parseCacheKey(key)).toThrow(`Invalid cache key format: ${key}`);
});
});
});
describe("cache key patterns and consistency", () => {
test("all environment keys should follow same pattern", () => {
const envId = "test-env-123";
const envKeys = [
createCacheKey.environment.state(envId),
createCacheKey.environment.surveys(envId),
createCacheKey.environment.actionClasses(envId),
createCacheKey.environment.config(envId),
createCacheKey.environment.segments(envId),
];
envKeys.forEach((key) => {
expect(key).toMatch(/^fb:env:test-env-123:.+$/);
expect(validateCacheKey(key)).toBe(true);
});
});
test("all organization keys should follow same pattern", () => {
const orgId = "test-org-456";
const orgKeys = [
createCacheKey.organization.billing(orgId),
createCacheKey.organization.environments(orgId),
createCacheKey.organization.config(orgId),
createCacheKey.organization.limits(orgId),
];
orgKeys.forEach((key) => {
expect(key).toMatch(/^fb:org:test-org-456:.+$/);
expect(validateCacheKey(key)).toBe(true);
});
});
test("all generated keys should be parseable", () => {
const testKeys = [
createCacheKey.environment.state("env123"),
createCacheKey.user.profile("user456"),
createCacheKey.organization.billing("org789"),
createCacheKey.survey.metadata("survey101"),
createCacheKey.session.data("session202"),
createCacheKey.rateLimit.core("auth:login", "user303", 1703174400),
createCacheKey.custom("temp", "temp404", "cleanup"),
];
testKeys.forEach((key) => {
expect(() => parseCacheKey(key)).not.toThrow();
const parsed = parseCacheKey(key);
expect(parsed.prefix).toBe("fb");
expect(parsed.full).toBe(key);
expect(parsed.resource).toBeTruthy();
expect(parsed.identifier).toBeTruthy();
});
});
test("keys should be unique across different resources", () => {
const keys = [
createCacheKey.environment.state("same-id"),
createCacheKey.user.profile("same-id"),
createCacheKey.organization.billing("same-id"),
createCacheKey.project.config("same-id"),
createCacheKey.survey.metadata("same-id"),
];
const uniqueKeys = new Set(keys);
expect(uniqueKeys.size).toBe(keys.length);
});
test("namespace validation should prevent collisions", () => {
// These should not throw (valid namespaces)
expect(() => createCacheKey.custom("temp", "id")).not.toThrow();
expect(() => createCacheKey.custom("analytics", "id")).not.toThrow();
// These should throw (reserved/invalid namespaces)
expect(() => createCacheKey.custom("env", "id")).toThrow();
expect(() => createCacheKey.custom("user", "id")).toThrow();
expect(() => createCacheKey.custom("org", "id")).toThrow();
});
});
});

View File

@@ -11,6 +11,7 @@ import "server-only";
* - Predictable invalidation patterns
* - Multi-tenant safe
*/
export const createCacheKey = {
// Environment-related keys
environment: {
@@ -71,6 +72,8 @@ export const createCacheKey = {
rateLimit: {
api: (identifier: string, endpoint: string) => `fb:rate_limit:api:${identifier}:${endpoint}`,
login: (identifier: string) => `fb:rate_limit:login:${identifier}`,
core: (namespace: string, identifier: string, windowStart: number) =>
`fb:rate_limit:${namespace}:${identifier}:${windowStart}`,
},
// Custom keys with validation

261
apps/web/modules/cache/redis.test.ts vendored Normal file
View File

@@ -0,0 +1,261 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
// Mock the logger
vi.mock("@formbricks/logger", () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
// Mock the redis client
const mockRedisClient = {
connect: vi.fn(),
disconnect: vi.fn(),
on: vi.fn(),
isReady: true,
get: vi.fn(),
set: vi.fn(),
del: vi.fn(),
exists: vi.fn(),
expire: vi.fn(),
ttl: vi.fn(),
keys: vi.fn(),
flushall: vi.fn(),
};
vi.mock("redis", () => ({
createClient: vi.fn(() => mockRedisClient),
}));
// Mock crypto for UUID generation
vi.mock("crypto", () => ({
randomUUID: vi.fn(() => "test-uuid-123"),
}));
describe("Redis module", () => {
beforeEach(() => {
vi.clearAllMocks();
// Reset environment variable
process.env.REDIS_URL = "redis://localhost:6379";
// Reset isReady state
mockRedisClient.isReady = true;
// Make connect resolve successfully
mockRedisClient.connect.mockResolvedValue(undefined);
});
afterEach(() => {
vi.resetModules();
process.env.REDIS_URL = undefined;
});
describe("Module initialization", () => {
test("should create Redis client when REDIS_URL is set", async () => {
const { createClient } = await import("redis");
// Re-import the module to trigger initialization
await import("./redis");
expect(createClient).toHaveBeenCalledWith({
url: "redis://localhost:6379",
socket: {
reconnectStrategy: expect.any(Function),
},
});
});
test("should not create Redis client when REDIS_URL is not set", async () => {
delete process.env.REDIS_URL;
const { createClient } = await import("redis");
// Clear the module cache and re-import
vi.resetModules();
await import("./redis");
expect(createClient).not.toHaveBeenCalled();
});
test("should set up event listeners", async () => {
// Re-import the module to trigger initialization
await import("./redis");
expect(mockRedisClient.on).toHaveBeenCalledWith("error", expect.any(Function));
expect(mockRedisClient.on).toHaveBeenCalledWith("connect", expect.any(Function));
expect(mockRedisClient.on).toHaveBeenCalledWith("reconnecting", expect.any(Function));
expect(mockRedisClient.on).toHaveBeenCalledWith("ready", expect.any(Function));
});
test("should attempt initial connection", async () => {
// Re-import the module to trigger initialization
await import("./redis");
expect(mockRedisClient.connect).toHaveBeenCalled();
});
});
describe("getRedisClient", () => {
test("should return client when ready", async () => {
mockRedisClient.isReady = true;
const { getRedisClient } = await import("./redis");
const client = getRedisClient();
expect(client).toBe(mockRedisClient);
});
test("should return null when client is not ready", async () => {
mockRedisClient.isReady = false;
const { getRedisClient } = await import("./redis");
const client = getRedisClient();
expect(client).toBeNull();
});
test("should return null when no REDIS_URL is set", async () => {
delete process.env.REDIS_URL;
vi.resetModules();
const { getRedisClient } = await import("./redis");
const client = getRedisClient();
expect(client).toBeNull();
});
});
describe("disconnectRedis", () => {
test("should disconnect the client", async () => {
const { disconnectRedis } = await import("./redis");
await disconnectRedis();
expect(mockRedisClient.disconnect).toHaveBeenCalled();
});
test("should handle case when client is null", async () => {
delete process.env.REDIS_URL;
vi.resetModules();
const { disconnectRedis } = await import("./redis");
await expect(disconnectRedis()).resolves.toBeUndefined();
});
});
describe("Reconnection strategy", () => {
test("should configure reconnection strategy properly", async () => {
const { createClient } = await import("redis");
// Re-import the module to trigger initialization
await import("./redis");
const createClientCall = vi.mocked(createClient).mock.calls[0];
const config = createClientCall[0] as any;
expect(config.socket.reconnectStrategy).toBeDefined();
expect(typeof config.socket.reconnectStrategy).toBe("function");
});
});
describe("Event handlers", () => {
test("should log error events", async () => {
const { logger } = await import("@formbricks/logger");
// Re-import the module to trigger initialization
await import("./redis");
// Find the error event handler
const errorCall = vi.mocked(mockRedisClient.on).mock.calls.find((call) => call[0] === "error");
const errorHandler = errorCall?.[1];
const testError = new Error("Test error");
errorHandler?.(testError);
expect(logger.error).toHaveBeenCalledWith("Redis client error:", testError);
});
test("should log connect events", async () => {
const { logger } = await import("@formbricks/logger");
// Re-import the module to trigger initialization
await import("./redis");
// Find the connect event handler
const connectCall = vi.mocked(mockRedisClient.on).mock.calls.find((call) => call[0] === "connect");
const connectHandler = connectCall?.[1];
connectHandler?.();
expect(logger.info).toHaveBeenCalledWith("Redis client connected");
});
test("should log reconnecting events", async () => {
const { logger } = await import("@formbricks/logger");
// Re-import the module to trigger initialization
await import("./redis");
// Find the reconnecting event handler
const reconnectingCall = vi
.mocked(mockRedisClient.on)
.mock.calls.find((call) => call[0] === "reconnecting");
const reconnectingHandler = reconnectingCall?.[1];
reconnectingHandler?.();
expect(logger.info).toHaveBeenCalledWith("Redis client reconnecting");
});
test("should log ready events", async () => {
const { logger } = await import("@formbricks/logger");
// Re-import the module to trigger initialization
await import("./redis");
// Find the ready event handler
const readyCall = vi.mocked(mockRedisClient.on).mock.calls.find((call) => call[0] === "ready");
const readyHandler = readyCall?.[1];
readyHandler?.();
expect(logger.info).toHaveBeenCalledWith("Redis client ready");
});
test("should log end events", async () => {
const { logger } = await import("@formbricks/logger");
// Re-import the module to trigger initialization
await import("./redis");
// Find the end event handler
const endCall = vi.mocked(mockRedisClient.on).mock.calls.find((call) => call[0] === "end");
const endHandler = endCall?.[1];
endHandler?.();
expect(logger.info).toHaveBeenCalledWith("Redis client disconnected");
});
});
describe("Connection failure handling", () => {
test("should handle initial connection failure", async () => {
const { logger } = await import("@formbricks/logger");
const connectionError = new Error("Connection failed");
mockRedisClient.connect.mockRejectedValue(connectionError);
vi.resetModules();
await import("./redis");
// Wait for the connection promise to resolve
await new Promise((resolve) => setTimeout(resolve, 0));
expect(logger.error).toHaveBeenCalledWith("Initial Redis connection failed:", connectionError);
});
});
});

View File

@@ -1,11 +1,69 @@
import { REDIS_URL } from "@/lib/constants";
import Redis from "ioredis";
import { createClient } from "redis";
import { logger } from "@formbricks/logger";
const redis = REDIS_URL ? new Redis(REDIS_URL) : null;
type RedisClient = ReturnType<typeof createClient>;
if (!redis) {
logger.info("REDIS_URL is not set");
const REDIS_URL = process.env.REDIS_URL;
let client: RedisClient | null = null;
if (REDIS_URL) {
client = createClient({
url: REDIS_URL,
socket: {
reconnectStrategy: (retries) => {
logger.info(`Redis reconnection attempt ${retries}`);
// For the first 5 attempts, use exponential backoff with max 5 second delay
if (retries <= 5) {
return Math.min(retries * 1000, 5000);
}
// After 5 attempts, use a longer delay but never give up
// This ensures the client keeps trying to reconnect when Redis comes back online
logger.info("Redis reconnection using extended delay (30 seconds)");
return 30000; // 30 second delay for persistent reconnection attempts
},
},
});
client.on("error", (err) => {
logger.error("Redis client error:", err);
});
client.on("connect", () => {
logger.info("Redis client connected");
});
client.on("reconnecting", () => {
logger.info("Redis client reconnecting");
});
client.on("ready", () => {
logger.info("Redis client ready");
});
client.on("end", () => {
logger.info("Redis client disconnected");
});
// Connect immediately
client.connect().catch((err) => {
logger.error("Initial Redis connection failed:", err);
});
}
export default redis;
export const getRedisClient = (): RedisClient | null => {
if (!client?.isReady) {
logger.warn("Redis client not ready, operations will be skipped");
return null;
}
return client;
};
export const disconnectRedis = async (): Promise<void> => {
if (client) {
await client.disconnect();
client = null;
}
};

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