mirror of
https://github.com/formbricks/formbricks.git
synced 2026-01-06 05:40:02 -06:00
Compare commits
3 Commits
fix-verify
...
fix/allow-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
701539d862 | ||
|
|
a19b6dfeb4 | ||
|
|
46f38f5b67 |
@@ -1,5 +1,5 @@
|
||||
---
|
||||
description: Migrate deprecated UI components to a unified component
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
@@ -1,177 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -189,11 +189,15 @@ 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)
|
||||
# REDIS_HTTP_URL:
|
||||
|
||||
# The below is used for Rate Limiting for management API
|
||||
UNKEY_ROOT_KEY=
|
||||
|
||||
# INTERCOM_APP_ID=
|
||||
# INTERCOM_SECRET_KEY=
|
||||
|
||||
|
||||
1
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
1
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -1,7 +1,6 @@
|
||||
name: Bug report
|
||||
description: "Found a bug? Please fill out the sections below. \U0001F44D"
|
||||
type: bug
|
||||
projects: "formbricks/8"
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: textarea
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/config.yml
vendored
2
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,4 +1,4 @@
|
||||
blank_issues_enabled: true
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Questions
|
||||
url: https://github.com/formbricks/formbricks/discussions
|
||||
|
||||
@@ -11,10 +11,6 @@ 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'
|
||||
@@ -111,7 +107,7 @@ runs:
|
||||
SENTRY_ORG: formbricks
|
||||
SENTRY_PROJECT: formbricks-cloud
|
||||
with:
|
||||
environment: ${{ inputs.environment }}
|
||||
environment: production
|
||||
version: ${{ inputs.release_version }}
|
||||
sourcemaps: './extracted-next/'
|
||||
|
||||
|
||||
17
.github/workflows/deploy-formbricks-cloud.yml
vendored
17
.github/workflows/deploy-formbricks-cloud.yml
vendored
@@ -17,8 +17,8 @@ on:
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- staging
|
||||
- production
|
||||
- stage
|
||||
- prod
|
||||
workflow_call:
|
||||
inputs:
|
||||
VERSION:
|
||||
@@ -52,7 +52,6 @@ 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
|
||||
@@ -67,8 +66,8 @@ jobs:
|
||||
AWS_REGION: eu-central-1
|
||||
|
||||
- uses: helmfile/helmfile-action@v2
|
||||
name: Deploy Formbricks Cloud Production
|
||||
if: inputs.ENVIRONMENT == 'production'
|
||||
name: Deploy Formbricks Cloud Prod
|
||||
if: inputs.ENVIRONMENT == 'prod'
|
||||
env:
|
||||
VERSION: ${{ inputs.VERSION }}
|
||||
REPOSITORY: ${{ inputs.REPOSITORY }}
|
||||
@@ -85,8 +84,8 @@ jobs:
|
||||
helmfile-workdirectory: infra/formbricks-cloud-helm
|
||||
|
||||
- uses: helmfile/helmfile-action@v2
|
||||
name: Deploy Formbricks Cloud Staging
|
||||
if: inputs.ENVIRONMENT == 'staging'
|
||||
name: Deploy Formbricks Cloud Stage
|
||||
if: inputs.ENVIRONMENT == 'stage'
|
||||
env:
|
||||
VERSION: ${{ inputs.VERSION }}
|
||||
REPOSITORY: ${{ inputs.REPOSITORY }}
|
||||
@@ -102,13 +101,13 @@ jobs:
|
||||
helmfile-workdirectory: infra/formbricks-cloud-helm
|
||||
|
||||
- name: Purge Cloudflare Cache
|
||||
if: ${{ inputs.ENVIRONMENT == 'production' || inputs.ENVIRONMENT == 'staging' }}
|
||||
if: ${{ inputs.ENVIRONMENT == 'prod' || inputs.ENVIRONMENT == 'stage' }}
|
||||
env:
|
||||
CF_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }}
|
||||
CF_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
run: |
|
||||
# Set hostname based on environment
|
||||
if [[ "${{ inputs.ENVIRONMENT }}" == "production" ]]; then
|
||||
if [[ "${{ inputs.ENVIRONMENT }}" == "prod" ]]; then
|
||||
PURGE_HOST="app.formbricks.com"
|
||||
else
|
||||
PURGE_HOST="stage.app.formbricks.com"
|
||||
|
||||
7
.github/workflows/e2e.yml
vendored
7
.github/workflows/e2e.yml
vendored
@@ -89,7 +89,6 @@ 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
|
||||
@@ -103,12 +102,6 @@ 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-)
|
||||
|
||||
20
.github/workflows/formbricks-release.yml
vendored
20
.github/workflows/formbricks-release.yml
vendored
@@ -1,22 +1,17 @@
|
||||
name: Build, release & deploy Formbricks images
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
ENVIRONMENT: ${{ github.event.release.prerelease && 'staging' || 'production' }}
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
jobs:
|
||||
docker-build:
|
||||
name: Build & release docker image
|
||||
name: Build & release stable docker image
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
uses: ./.github/workflows/release-docker-github.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
IS_PRERELEASE: ${{ github.event.release.prerelease }}
|
||||
|
||||
helm-chart-release:
|
||||
name: Release Helm Chart
|
||||
@@ -36,7 +31,7 @@ jobs:
|
||||
- helm-chart-release
|
||||
with:
|
||||
VERSION: v${{ needs.docker-build.outputs.VERSION }}
|
||||
ENVIRONMENT: ${{ env.ENVIRONMENT }}
|
||||
ENVIRONMENT: "prod"
|
||||
|
||||
upload-sentry-sourcemaps:
|
||||
name: Upload Sentry Sourcemaps
|
||||
@@ -59,4 +54,3 @@ 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 }}
|
||||
|
||||
@@ -29,10 +29,6 @@ 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
|
||||
@@ -42,53 +38,6 @@ 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
|
||||
run: |
|
||||
sed -i "s/\"version\": \"0.0.0\"/\"version\": \"${{ env.VERSION }}\"/" ./apps/web/package.json
|
||||
cat ./apps/web/package.json | grep 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
|
||||
|
||||
@@ -134,21 +83,6 @@ 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
|
||||
@@ -163,25 +97,3 @@ 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
|
||||
|
||||
15
.github/workflows/release-docker-github.yml
vendored
15
.github/workflows/release-docker-github.yml
vendored
@@ -7,12 +7,6 @@ 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
|
||||
@@ -51,12 +45,10 @@ 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: |
|
||||
@@ -89,13 +81,6 @@ 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
|
||||
|
||||
1
.github/workflows/sonarqube.yml
vendored
1
.github/workflows/sonarqube.yml
vendored
@@ -43,7 +43,6 @@ 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: |
|
||||
|
||||
1
.github/workflows/test.yml
vendored
1
.github/workflows/test.yml
vendored
@@ -41,7 +41,6 @@ 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
|
||||
|
||||
@@ -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: undefined,
|
||||
REDIS_URL: "test-redis-url",
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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: undefined,
|
||||
REDIS_URL: "test-redis-url",
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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: undefined,
|
||||
REDIS_URL: "test-redis-url",
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
WEBAPP_URL: "test-webapp-url",
|
||||
IS_PRODUCTION: false,
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: undefined,
|
||||
REDIS_URL: "test-redis-url",
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
WEBAPP_URL: "test-webapp-url",
|
||||
IS_PRODUCTION: false,
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: undefined,
|
||||
REDIS_URL: "test-redis-url",
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
IS_POSTHOG_CONFIGURED: true,
|
||||
SESSION_MAX_AGE: 1000,
|
||||
AUDIT_LOG_ENABLED: 1,
|
||||
REDIS_URL: undefined,
|
||||
REDIS_URL: "redis://localhost:6379",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/env", () => ({
|
||||
|
||||
@@ -7,7 +7,6 @@ import { TProject } from "@formbricks/types/project";
|
||||
export interface EnvironmentContextType {
|
||||
environment: TEnvironment;
|
||||
project: TProject;
|
||||
organizationId: string;
|
||||
}
|
||||
|
||||
const EnvironmentContext = createContext<EnvironmentContextType | null>(null);
|
||||
@@ -36,7 +35,6 @@ export const EnvironmentContextWrapper = ({
|
||||
() => ({
|
||||
environment,
|
||||
project,
|
||||
organizationId: project.organizationId,
|
||||
}),
|
||||
[environment, project]
|
||||
);
|
||||
|
||||
@@ -30,16 +30,16 @@ interface ManageIntegrationProps {
|
||||
locale: TUserLocale;
|
||||
}
|
||||
|
||||
const tableHeaders = [
|
||||
"common.survey",
|
||||
"environments.integrations.airtable.table_name",
|
||||
"common.questions",
|
||||
"common.updated_at",
|
||||
];
|
||||
|
||||
export const ManageIntegration = (props: ManageIntegrationProps) => {
|
||||
const { airtableIntegration, environment, environmentId, setIsConnected, surveys, airtableArray } = props;
|
||||
const { t } = useTranslate();
|
||||
|
||||
const tableHeaders = [
|
||||
t("common.survey"),
|
||||
t("environments.integrations.airtable.table_name"),
|
||||
t("common.questions"),
|
||||
t("common.updated_at"),
|
||||
];
|
||||
const [isDeleting, setisDeleting] = useState(false);
|
||||
const [isDeleteIntegrationModalOpen, setIsDeleteIntegrationModalOpen] = useState(false);
|
||||
const [defaultValues, setDefaultValues] = useState<(IntegrationModalInputs & { index: number }) | null>(
|
||||
@@ -100,7 +100,7 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
|
||||
<div className="grid h-12 grid-cols-8 content-center rounded-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
|
||||
{tableHeaders.map((header) => (
|
||||
<div key={header} className={`col-span-2 hidden text-center sm:block`}>
|
||||
{header}
|
||||
{t(header)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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: undefined,
|
||||
REDIS_URL: "test-redis-url",
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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: undefined,
|
||||
REDIS_URL: "mock-redis-url",
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
IS_PRODUCTION: false,
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: undefined,
|
||||
REDIS_URL: "test-redis-url",
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
IS_PRODUCTION: false,
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: undefined,
|
||||
REDIS_URL: "redis://localhost:6379",
|
||||
AUDIT_LOG_ENABLED: 1,
|
||||
}));
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
IS_PRODUCTION: false,
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: undefined,
|
||||
REDIS_URL: "redis://localhost:6379",
|
||||
AUDIT_LOG_ENABLED: 1,
|
||||
}));
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
IS_PRODUCTION: false,
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: undefined,
|
||||
REDIS_URL: "redis://localhost:6379",
|
||||
AUDIT_LOG_ENABLED: 1,
|
||||
}));
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
IS_PRODUCTION: false,
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: undefined,
|
||||
REDIS_URL: "redis://localhost:6379",
|
||||
AUDIT_LOG_ENABLED: 1,
|
||||
}));
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
IS_PRODUCTION: false,
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: undefined,
|
||||
REDIS_URL: "test-redis-url",
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
IS_PRODUCTION: false,
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: undefined,
|
||||
REDIS_URL: "test-redis-url",
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
|
||||
@@ -10,19 +10,24 @@ import { getFileNameWithIdFromUrl } from "@/lib/storage/utils";
|
||||
import { getUser, updateUser } from "@/lib/user/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||
import { rateLimit } from "@/lib/utils/rate-limit";
|
||||
import { updateBrevoCustomer } from "@/modules/auth/lib/brevo";
|
||||
import { applyRateLimit } 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 { sendForgotPasswordEmail, sendVerificationNewEmail } from "@/modules/email";
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { AuthenticationError, AuthorizationError, OperationNotAllowedError } from "@formbricks/types/errors";
|
||||
import {
|
||||
TUserPersonalInfoUpdateInput,
|
||||
TUserUpdateInput,
|
||||
ZUserPersonalInfoUpdateInput,
|
||||
} from "@formbricks/types/user";
|
||||
AuthenticationError,
|
||||
AuthorizationError,
|
||||
OperationNotAllowedError,
|
||||
TooManyRequestsError,
|
||||
} from "@formbricks/types/errors";
|
||||
import { TUserUpdateInput, ZUserPassword, ZUserUpdateInput } from "@formbricks/types/user";
|
||||
|
||||
const limiter = rateLimit({
|
||||
interval: 60 * 60, // 1 hour
|
||||
allowedPerInterval: 3, // max 3 calls for email verification per hour
|
||||
});
|
||||
|
||||
function buildUserUpdatePayload(parsedInput: any): TUserUpdateInput {
|
||||
return {
|
||||
@@ -36,15 +41,18 @@ async function handleEmailUpdate({
|
||||
parsedInput,
|
||||
payload,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: TUserPersonalInfoUpdateInput;
|
||||
ctx: any;
|
||||
parsedInput: any;
|
||||
payload: TUserUpdateInput;
|
||||
}) {
|
||||
const inputEmail = parsedInput.email?.trim().toLowerCase();
|
||||
if (!inputEmail || ctx.user.email === inputEmail) return payload;
|
||||
|
||||
await applyRateLimit(rateLimitConfigs.actions.emailUpdate, ctx.user.id);
|
||||
|
||||
try {
|
||||
await limiter(ctx.user.id);
|
||||
} catch {
|
||||
throw new TooManyRequestsError("Too many requests");
|
||||
}
|
||||
if (ctx.user.identityProvider !== "email") {
|
||||
throw new OperationNotAllowedError("Email update is not allowed for non-credential users.");
|
||||
}
|
||||
@@ -67,35 +75,41 @@ async function handleEmailUpdate({
|
||||
return payload;
|
||||
}
|
||||
|
||||
export const updateUserAction = authenticatedActionClient.schema(ZUserPersonalInfoUpdateInput).action(
|
||||
withAuditLogging(
|
||||
"updated",
|
||||
"user",
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: TUserPersonalInfoUpdateInput;
|
||||
}) => {
|
||||
const oldObject = await getUser(ctx.user.id);
|
||||
let payload = buildUserUpdatePayload(parsedInput);
|
||||
payload = await handleEmailUpdate({ ctx, parsedInput, payload });
|
||||
|
||||
// Only proceed with updateUser if we have actual changes to make
|
||||
let newObject = oldObject;
|
||||
if (Object.keys(payload).length > 0) {
|
||||
newObject = await updateUser(ctx.user.id, payload);
|
||||
}
|
||||
|
||||
ctx.auditLoggingCtx.userId = ctx.user.id;
|
||||
ctx.auditLoggingCtx.oldObject = oldObject;
|
||||
ctx.auditLoggingCtx.newObject = newObject;
|
||||
|
||||
return true;
|
||||
}
|
||||
export const updateUserAction = authenticatedActionClient
|
||||
.schema(
|
||||
ZUserUpdateInput.pick({ name: true, email: true, locale: true }).extend({
|
||||
password: ZUserPassword.optional(),
|
||||
})
|
||||
)
|
||||
);
|
||||
.action(
|
||||
withAuditLogging(
|
||||
"updated",
|
||||
"user",
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: Record<string, any>;
|
||||
}) => {
|
||||
const oldObject = await getUser(ctx.user.id);
|
||||
let payload = buildUserUpdatePayload(parsedInput);
|
||||
payload = await handleEmailUpdate({ ctx, parsedInput, payload });
|
||||
|
||||
// Only proceed with updateUser if we have actual changes to make
|
||||
let newObject = oldObject;
|
||||
if (Object.keys(payload).length > 0) {
|
||||
newObject = await updateUser(ctx.user.id, payload);
|
||||
}
|
||||
|
||||
ctx.auditLoggingCtx.userId = ctx.user.id;
|
||||
ctx.auditLoggingCtx.oldObject = oldObject;
|
||||
ctx.auditLoggingCtx.newObject = newObject;
|
||||
|
||||
return true;
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const ZUpdateAvatarAction = z.object({
|
||||
avatarUrl: z.string(),
|
||||
|
||||
@@ -30,7 +30,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
SMTP_USER: "mock-smtp-user",
|
||||
SMTP_PASSWORD: "mock-smtp-password",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: undefined,
|
||||
REDIS_URL: "redis://localhost:6379",
|
||||
AUDIT_LOG_ENABLED: 1,
|
||||
}));
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
SMTP_USER: "mock-smtp-user",
|
||||
SMTP_PASSWORD: "mock-smtp-password",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: undefined,
|
||||
REDIS_URL: "test-redis-url",
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentI
|
||||
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
|
||||
import Page from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/page";
|
||||
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
|
||||
import { getDisplayCountBySurveyId } from "@/lib/display/service";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
@@ -74,10 +73,6 @@ vi.mock("@/lib/response/service", () => ({
|
||||
getResponseCountBySurveyId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/display/service", () => ({
|
||||
getDisplayCountBySurveyId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/survey/service", () => ({
|
||||
getSurvey: vi.fn(),
|
||||
}));
|
||||
@@ -183,7 +178,6 @@ describe("ResponsesPage", () => {
|
||||
vi.mocked(getUser).mockResolvedValue(mockUser);
|
||||
vi.mocked(getTagsByEnvironmentId).mockResolvedValue(mockTags);
|
||||
vi.mocked(getResponseCountBySurveyId).mockResolvedValue(10);
|
||||
vi.mocked(getDisplayCountBySurveyId).mockResolvedValue(5);
|
||||
vi.mocked(findMatchingLocale).mockResolvedValue(mockLocale);
|
||||
vi.mocked(getPublicDomain).mockReturnValue(mockPublicDomain);
|
||||
});
|
||||
@@ -212,8 +206,6 @@ describe("ResponsesPage", () => {
|
||||
isReadOnly: false,
|
||||
user: mockUser,
|
||||
publicDomain: mockPublicDomain,
|
||||
responseCount: 10,
|
||||
displayCount: 5,
|
||||
}),
|
||||
undefined
|
||||
);
|
||||
|
||||
@@ -2,7 +2,6 @@ import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentI
|
||||
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
|
||||
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
|
||||
import { IS_FORMBRICKS_CLOUD, RESPONSES_PER_PAGE } from "@/lib/constants";
|
||||
import { getDisplayCountBySurveyId } from "@/lib/display/service";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
@@ -41,7 +40,6 @@ const Page = async (props) => {
|
||||
|
||||
// Get response count for the CTA component
|
||||
const responseCount = await getResponseCountBySurveyId(params.surveyId);
|
||||
const displayCount = await getDisplayCountBySurveyId(params.surveyId);
|
||||
|
||||
const locale = await findMatchingLocale();
|
||||
const publicDomain = getPublicDomain();
|
||||
@@ -58,7 +56,6 @@ const Page = async (props) => {
|
||||
user={user}
|
||||
publicDomain={publicDomain}
|
||||
responseCount={responseCount}
|
||||
displayCount={displayCount}
|
||||
segments={segments}
|
||||
isContactsEnabled={isContactsEnabled}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
|
||||
@@ -18,7 +18,6 @@ import { customAlphabet } from "nanoid";
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { OperationNotAllowedError, ResourceNotFoundError, UnknownError } from "@formbricks/types/errors";
|
||||
import { deleteResponsesAndDisplaysForSurvey } from "./lib/survey";
|
||||
|
||||
const ZSendEmbedSurveyPreviewEmailAction = z.object({
|
||||
surveyId: ZId,
|
||||
@@ -203,61 +202,6 @@ export const deleteResultShareUrlAction = authenticatedActionClient
|
||||
)
|
||||
);
|
||||
|
||||
const ZResetSurveyAction = z.object({
|
||||
surveyId: ZId,
|
||||
organizationId: ZId,
|
||||
projectId: ZId,
|
||||
});
|
||||
|
||||
export const resetSurveyAction = authenticatedActionClient.schema(ZResetSurveyAction).action(
|
||||
withAuditLogging(
|
||||
"updated",
|
||||
"survey",
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZResetSurveyAction>;
|
||||
}) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: parsedInput.organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId: parsedInput.projectId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
|
||||
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
|
||||
ctx.auditLoggingCtx.oldObject = null;
|
||||
|
||||
const { deletedResponsesCount, deletedDisplaysCount } = await deleteResponsesAndDisplaysForSurvey(
|
||||
parsedInput.surveyId
|
||||
);
|
||||
|
||||
ctx.auditLoggingCtx.newObject = {
|
||||
deletedResponsesCount: deletedResponsesCount,
|
||||
deletedDisplaysCount: deletedDisplaysCount,
|
||||
};
|
||||
|
||||
return {
|
||||
success: true,
|
||||
deletedResponsesCount: deletedResponsesCount,
|
||||
deletedDisplaysCount: deletedDisplaysCount,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const ZGetEmailHtmlAction = z.object({
|
||||
surveyId: ZId,
|
||||
});
|
||||
|
||||
@@ -33,18 +33,6 @@ vi.mock("@tolgee/react", () => ({
|
||||
if (key === "environments.surveys.edit.caution_edit_duplicate") {
|
||||
return "Duplicate & Edit";
|
||||
}
|
||||
if (key === "environments.surveys.summary.reset_survey") {
|
||||
return "Reset survey";
|
||||
}
|
||||
if (key === "environments.surveys.summary.delete_all_existing_responses_and_displays") {
|
||||
return "Delete all existing responses and displays";
|
||||
}
|
||||
if (key === "environments.surveys.summary.reset_survey_warning") {
|
||||
return "Resetting a survey removes all responses and metadata of this survey. This cannot be undone.";
|
||||
}
|
||||
if (key === "environments.surveys.summary.survey_reset_successfully") {
|
||||
return "Survey reset successfully! 5 responses and 3 displays were deleted.";
|
||||
}
|
||||
return key;
|
||||
},
|
||||
}),
|
||||
@@ -52,14 +40,12 @@ vi.mock("@tolgee/react", () => ({
|
||||
|
||||
// Mock Next.js hooks
|
||||
const mockPush = vi.fn();
|
||||
const mockRefresh = vi.fn();
|
||||
const mockPathname = "/environments/test-env-id/surveys/test-survey-id/summary";
|
||||
const mockPathname = "/environments/env-id/surveys/survey-id/summary";
|
||||
const mockSearchParams = new URLSearchParams();
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({
|
||||
push: mockPush,
|
||||
refresh: mockRefresh,
|
||||
}),
|
||||
usePathname: () => mockPathname,
|
||||
useSearchParams: () => mockSearchParams,
|
||||
@@ -83,18 +69,6 @@ vi.mock("@/modules/survey/list/actions", () => ({
|
||||
copySurveyToOtherEnvironmentAction: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../actions", () => ({
|
||||
resetSurveyAction: 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",
|
||||
@@ -165,34 +139,6 @@ vi.mock("@/modules/ui/components/badge", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/confirmation-modal", () => ({
|
||||
ConfirmationModal: ({
|
||||
open,
|
||||
setOpen,
|
||||
title,
|
||||
text,
|
||||
buttonText,
|
||||
onConfirm,
|
||||
buttonVariant,
|
||||
buttonLoading,
|
||||
}: any) => (
|
||||
<div
|
||||
data-testid="confirmation-modal"
|
||||
data-open={open}
|
||||
data-loading={buttonLoading}
|
||||
data-variant={buttonVariant}>
|
||||
<div data-testid="modal-title">{title}</div>
|
||||
<div data-testid="modal-text">{text}</div>
|
||||
<button type="button" onClick={onConfirm} data-testid="confirm-button">
|
||||
{buttonText}
|
||||
</button>
|
||||
<button type="button" onClick={() => setOpen(false)} data-testid="cancel-button">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/button", () => ({
|
||||
Button: ({ children, onClick, className }: any) => (
|
||||
<button type="button" data-testid="button" onClick={onClick} className={className}>
|
||||
@@ -224,17 +170,9 @@ vi.mock("@/modules/ui/components/iconbar", () => ({
|
||||
vi.mock("lucide-react", () => ({
|
||||
BellRing: () => <svg data-testid="bell-ring-icon" />,
|
||||
Eye: () => <svg data-testid="eye-icon" />,
|
||||
ListRestart: () => <svg data-testid="list-restart-icon" />,
|
||||
SquarePenIcon: () => <svg data-testid="square-pen-icon" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/context/environment-context", () => ({
|
||||
useEnvironment: vi.fn(() => ({
|
||||
organizationId: "test-organization-id",
|
||||
project: { id: "test-project-id" },
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock data
|
||||
const mockEnvironment: TEnvironment = {
|
||||
id: "test-env-id",
|
||||
@@ -324,7 +262,6 @@ const defaultProps = {
|
||||
user: mockUser,
|
||||
publicDomain: "https://example.com",
|
||||
responseCount: 0,
|
||||
displayCount: 0,
|
||||
segments: mockSegments,
|
||||
isContactsEnabled: true,
|
||||
isFormbricksCloud: false,
|
||||
@@ -341,19 +278,19 @@ describe("SurveyAnalysisCTA", () => {
|
||||
});
|
||||
|
||||
test("renders share survey button", () => {
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
|
||||
render(<SurveyAnalysisCTA {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText("Share survey")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders success message component", () => {
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
|
||||
render(<SurveyAnalysisCTA {...defaultProps} />);
|
||||
|
||||
expect(screen.getByTestId("success-message")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders survey status dropdown when app setup is completed", () => {
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
|
||||
render(<SurveyAnalysisCTA {...defaultProps} />);
|
||||
|
||||
expect(screen.getByTestId("survey-status-dropdown")).toBeInTheDocument();
|
||||
});
|
||||
@@ -365,7 +302,7 @@ describe("SurveyAnalysisCTA", () => {
|
||||
});
|
||||
|
||||
test("renders icon bar with correct actions", () => {
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
|
||||
render(<SurveyAnalysisCTA {...defaultProps} />);
|
||||
|
||||
expect(screen.getByTestId("icon-bar")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("icon-bar-action-0")).toBeInTheDocument(); // Bell ring
|
||||
@@ -389,7 +326,7 @@ describe("SurveyAnalysisCTA", () => {
|
||||
|
||||
test("opens share modal when share button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
|
||||
render(<SurveyAnalysisCTA {...defaultProps} />);
|
||||
|
||||
await user.click(screen.getByText("Share survey"));
|
||||
|
||||
@@ -399,7 +336,7 @@ describe("SurveyAnalysisCTA", () => {
|
||||
|
||||
test("opens share modal when share param is true", () => {
|
||||
mockSearchParams.set("share", "true");
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
|
||||
render(<SurveyAnalysisCTA {...defaultProps} />);
|
||||
|
||||
expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-open", "true");
|
||||
expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-modal-view", "start");
|
||||
@@ -407,7 +344,7 @@ describe("SurveyAnalysisCTA", () => {
|
||||
|
||||
test("navigates to edit when edit button is clicked and no responses", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
|
||||
render(<SurveyAnalysisCTA {...defaultProps} />);
|
||||
|
||||
await user.click(screen.getByTestId("icon-bar-action-1"));
|
||||
|
||||
@@ -418,15 +355,14 @@ describe("SurveyAnalysisCTA", () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
|
||||
|
||||
// With responseCount > 0, the edit button should be at icon-bar-action-2 (after reset button)
|
||||
await user.click(screen.getByTestId("icon-bar-action-2"));
|
||||
await user.click(screen.getByTestId("icon-bar-action-1"));
|
||||
|
||||
expect(screen.getByTestId("edit-public-survey-alert-dialog")).toHaveAttribute("data-open", "true");
|
||||
});
|
||||
|
||||
test("navigates to notifications when bell icon is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
|
||||
render(<SurveyAnalysisCTA {...defaultProps} />);
|
||||
|
||||
await user.click(screen.getByTestId("icon-bar-action-0"));
|
||||
|
||||
@@ -447,7 +383,7 @@ describe("SurveyAnalysisCTA", () => {
|
||||
});
|
||||
|
||||
test("does not show icon bar actions when read-only", () => {
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} isReadOnly={true} />);
|
||||
render(<SurveyAnalysisCTA {...defaultProps} isReadOnly={true} />);
|
||||
|
||||
const iconBar = screen.getByTestId("icon-bar");
|
||||
expect(iconBar).toBeInTheDocument();
|
||||
@@ -458,7 +394,7 @@ describe("SurveyAnalysisCTA", () => {
|
||||
test("handles modal close correctly", async () => {
|
||||
mockSearchParams.set("share", "true");
|
||||
const user = userEvent.setup();
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
|
||||
render(<SurveyAnalysisCTA {...defaultProps} />);
|
||||
|
||||
// Verify modal is open initially
|
||||
expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-open", "true");
|
||||
@@ -485,577 +421,17 @@ describe("SurveyAnalysisCTA", () => {
|
||||
|
||||
test("does not show status dropdown when app setup is not completed", () => {
|
||||
const environmentWithoutAppSetup = { ...mockEnvironment, appSetupCompleted: false };
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} environment={environmentWithoutAppSetup} />);
|
||||
render(<SurveyAnalysisCTA {...defaultProps} environment={environmentWithoutAppSetup} />);
|
||||
|
||||
expect(screen.queryByTestId("survey-status-dropdown")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders correctly with all props", () => {
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
|
||||
render(<SurveyAnalysisCTA {...defaultProps} />);
|
||||
|
||||
expect(screen.getByTestId("icon-bar")).toBeInTheDocument();
|
||||
expect(screen.getByText("Share survey")).toBeInTheDocument();
|
||||
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 (should be icon-bar-action-2 with responses)
|
||||
await user.click(screen.getByTestId("icon-bar-action-2"));
|
||||
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} displayCount={0} />);
|
||||
|
||||
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} displayCount={0} user={null as any} />);
|
||||
|
||||
expect(screen.queryByTestId("share-survey-modal")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders with different isFormbricksCloud values", () => {
|
||||
const { rerender } = render(
|
||||
<SurveyAnalysisCTA {...defaultProps} displayCount={0} isFormbricksCloud={true} />
|
||||
);
|
||||
expect(screen.getByTestId("share-survey-modal")).toBeInTheDocument();
|
||||
|
||||
rerender(<SurveyAnalysisCTA {...defaultProps} displayCount={0} isFormbricksCloud={false} />);
|
||||
expect(screen.getByTestId("share-survey-modal")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders with different isContactsEnabled values", () => {
|
||||
const { rerender } = render(
|
||||
<SurveyAnalysisCTA {...defaultProps} displayCount={0} isContactsEnabled={true} />
|
||||
);
|
||||
expect(screen.getByTestId("share-survey-modal")).toBeInTheDocument();
|
||||
|
||||
rerender(<SurveyAnalysisCTA {...defaultProps} displayCount={0} 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} displayCount={0} />);
|
||||
|
||||
// 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} displayCount={0} />);
|
||||
|
||||
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} displayCount={0} />);
|
||||
|
||||
// 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} displayCount={0} segments={[]} />);
|
||||
|
||||
expect(screen.getByTestId("share-survey-modal")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("handles zero response count", () => {
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} 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} displayCount={0} />);
|
||||
|
||||
// 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");
|
||||
});
|
||||
|
||||
// Reset Survey Feature Tests
|
||||
test("shows reset survey button when responses exist", () => {
|
||||
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
|
||||
|
||||
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
|
||||
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
|
||||
expect(resetButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows reset survey button when displays exist", () => {
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={3} />);
|
||||
|
||||
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
|
||||
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
|
||||
expect(resetButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("hides reset survey button when no responses or displays exist", () => {
|
||||
render(<SurveyAnalysisCTA {...defaultProps} responseCount={0} displayCount={0} />);
|
||||
|
||||
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
|
||||
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
|
||||
expect(resetButton).toBeUndefined();
|
||||
});
|
||||
|
||||
test("hides reset survey button for read-only users", () => {
|
||||
render(<SurveyAnalysisCTA {...defaultProps} isReadOnly={true} responseCount={5} displayCount={3} />);
|
||||
|
||||
// For read-only users, there should be no icon bar actions
|
||||
expect(screen.queryAllByTestId(/icon-bar-action-/)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("opens reset confirmation modal when reset button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
|
||||
|
||||
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
|
||||
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
|
||||
|
||||
expect(resetButton).toBeDefined();
|
||||
await user.click(resetButton!);
|
||||
|
||||
expect(screen.getByTestId("confirmation-modal")).toHaveAttribute("data-open", "true");
|
||||
expect(screen.getByTestId("modal-title")).toHaveTextContent("Delete all existing responses and displays");
|
||||
expect(screen.getByTestId("modal-text")).toHaveTextContent(
|
||||
"Resetting a survey removes all responses and metadata of this survey. This cannot be undone."
|
||||
);
|
||||
});
|
||||
|
||||
test("executes reset survey action when confirmed", async () => {
|
||||
const mockResetSurveyAction = vi.mocked(await import("../actions")).resetSurveyAction;
|
||||
mockResetSurveyAction.mockResolvedValue({
|
||||
data: {
|
||||
success: true,
|
||||
deletedResponsesCount: 5,
|
||||
deletedDisplaysCount: 3,
|
||||
},
|
||||
});
|
||||
|
||||
const toast = await import("react-hot-toast");
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
|
||||
|
||||
// Open reset modal
|
||||
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
|
||||
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
|
||||
expect(resetButton).toBeDefined();
|
||||
await user.click(resetButton!);
|
||||
|
||||
// Confirm reset
|
||||
await user.click(screen.getByTestId("confirm-button"));
|
||||
|
||||
expect(mockResetSurveyAction).toHaveBeenCalledWith({
|
||||
surveyId: "test-survey-id",
|
||||
organizationId: "test-organization-id",
|
||||
projectId: "test-project-id",
|
||||
});
|
||||
expect(toast.default.success).toHaveBeenCalledWith(
|
||||
"Survey reset successfully! 5 responses and 3 displays were deleted."
|
||||
);
|
||||
});
|
||||
|
||||
test("handles reset survey action error", async () => {
|
||||
const mockResetSurveyAction = vi.mocked(await import("../actions")).resetSurveyAction;
|
||||
mockResetSurveyAction.mockResolvedValue({
|
||||
data: undefined,
|
||||
serverError: "Reset failed",
|
||||
validationErrors: undefined,
|
||||
bindArgsValidationErrors: [],
|
||||
});
|
||||
|
||||
const toast = await import("react-hot-toast");
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
|
||||
|
||||
// Open reset modal
|
||||
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
|
||||
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
|
||||
expect(resetButton).toBeDefined();
|
||||
await user.click(resetButton!);
|
||||
|
||||
// Confirm reset
|
||||
await user.click(screen.getByTestId("confirm-button"));
|
||||
|
||||
expect(toast.default.error).toHaveBeenCalledWith("Error message");
|
||||
});
|
||||
|
||||
test("shows loading state during reset operation", async () => {
|
||||
const mockResetSurveyAction = vi.mocked(await import("../actions")).resetSurveyAction;
|
||||
|
||||
// Mock a delayed response
|
||||
mockResetSurveyAction.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) =>
|
||||
setTimeout(
|
||||
() =>
|
||||
resolve({
|
||||
data: {
|
||||
success: true,
|
||||
deletedResponsesCount: 5,
|
||||
deletedDisplaysCount: 3,
|
||||
},
|
||||
}),
|
||||
100
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
|
||||
|
||||
// Open reset modal
|
||||
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
|
||||
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
|
||||
expect(resetButton).toBeDefined();
|
||||
await user.click(resetButton!);
|
||||
|
||||
// Confirm reset
|
||||
await user.click(screen.getByTestId("confirm-button"));
|
||||
|
||||
// Check loading state
|
||||
expect(screen.getByTestId("confirmation-modal")).toHaveAttribute("data-loading", "true");
|
||||
});
|
||||
|
||||
test("closes reset modal after successful reset", async () => {
|
||||
const mockResetSurveyAction = vi.mocked(await import("../actions")).resetSurveyAction;
|
||||
mockResetSurveyAction.mockResolvedValue({
|
||||
data: {
|
||||
success: true,
|
||||
deletedResponsesCount: 5,
|
||||
deletedDisplaysCount: 3,
|
||||
},
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
|
||||
|
||||
// Open reset modal
|
||||
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
|
||||
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
|
||||
expect(resetButton).toBeDefined();
|
||||
await user.click(resetButton!);
|
||||
expect(screen.getByTestId("confirmation-modal")).toHaveAttribute("data-open", "true");
|
||||
|
||||
// Confirm reset - wait for the action to complete
|
||||
await user.click(screen.getByTestId("confirm-button"));
|
||||
|
||||
// Wait for the action to complete and the modal to close
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByTestId("confirmation-modal")).toHaveAttribute("data-open", "false");
|
||||
});
|
||||
});
|
||||
|
||||
test("cancels reset operation when cancel button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
|
||||
|
||||
// Open reset modal
|
||||
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
|
||||
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
|
||||
expect(resetButton).toBeDefined();
|
||||
await user.click(resetButton!);
|
||||
expect(screen.getByTestId("confirmation-modal")).toHaveAttribute("data-open", "true");
|
||||
|
||||
// Cancel reset
|
||||
await user.click(screen.getByTestId("cancel-button"));
|
||||
|
||||
// Modal should be closed
|
||||
expect(screen.getByTestId("confirmation-modal")).toHaveAttribute("data-open", "false");
|
||||
});
|
||||
|
||||
test("shows destructive button variant for reset confirmation", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
|
||||
|
||||
// Open reset modal
|
||||
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
|
||||
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
|
||||
expect(resetButton).toBeDefined();
|
||||
await user.click(resetButton!);
|
||||
|
||||
expect(screen.getByTestId("confirmation-modal")).toHaveAttribute("data-variant", "destructive");
|
||||
});
|
||||
|
||||
test("refreshes page after successful reset", async () => {
|
||||
const mockResetSurveyAction = vi.mocked(await import("../actions")).resetSurveyAction;
|
||||
|
||||
mockResetSurveyAction.mockResolvedValue({
|
||||
data: {
|
||||
success: true,
|
||||
deletedResponsesCount: 5,
|
||||
deletedDisplaysCount: 3,
|
||||
},
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
|
||||
|
||||
// Open reset modal
|
||||
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
|
||||
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
|
||||
expect(resetButton).toBeDefined();
|
||||
await user.click(resetButton!);
|
||||
|
||||
// Confirm reset
|
||||
await user.click(screen.getByTestId("confirm-button"));
|
||||
|
||||
expect(mockRefresh).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,27 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context";
|
||||
import { SuccessMessage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SuccessMessage";
|
||||
import { ShareSurveyModal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/share-survey-modal";
|
||||
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";
|
||||
import { ConfirmationModal } from "@/modules/ui/components/confirmation-modal";
|
||||
import { IconBar } from "@/modules/ui/components/iconbar";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { BellRing, Eye, ListRestart, SquarePenIcon } from "lucide-react";
|
||||
import { BellRing, Eye, SquarePenIcon } from "lucide-react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { resetSurveyAction } from "../actions";
|
||||
|
||||
interface SurveyAnalysisCTAProps {
|
||||
survey: TSurvey;
|
||||
@@ -30,7 +26,6 @@ interface SurveyAnalysisCTAProps {
|
||||
user: TUser;
|
||||
publicDomain: string;
|
||||
responseCount: number;
|
||||
displayCount: number;
|
||||
segments: TSegment[];
|
||||
isContactsEnabled: boolean;
|
||||
isFormbricksCloud: boolean;
|
||||
@@ -48,25 +43,22 @@ export const SurveyAnalysisCTA = ({
|
||||
user,
|
||||
publicDomain,
|
||||
responseCount,
|
||||
displayCount,
|
||||
segments,
|
||||
isContactsEnabled,
|
||||
isFormbricksCloud,
|
||||
}: SurveyAnalysisCTAProps) => {
|
||||
const { t } = useTranslate();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [modalState, setModalState] = useState<ModalState>({
|
||||
start: searchParams.get("share") === "true",
|
||||
share: false,
|
||||
});
|
||||
const [isResetModalOpen, setIsResetModalOpen] = useState(false);
|
||||
const [isResetting, setIsResetting] = useState(false);
|
||||
|
||||
const { organizationId, project } = useEnvironment();
|
||||
const { refreshSingleUseId } = useSingleUseId(survey);
|
||||
const surveyUrl = useMemo(() => `${publicDomain}/s/${survey.id}`, [survey.id, publicDomain]);
|
||||
|
||||
const widgetSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
|
||||
|
||||
@@ -110,45 +102,13 @@ export const SurveyAnalysisCTA = ({
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
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 getPreviewUrl = () => {
|
||||
const separator = surveyUrl.includes("?") ? "&" : "?";
|
||||
return `${surveyUrl}${separator}preview=true`;
|
||||
};
|
||||
|
||||
const [isCautionDialogOpen, setIsCautionDialogOpen] = useState(false);
|
||||
|
||||
const handleResetSurvey = async () => {
|
||||
setIsResetting(true);
|
||||
const result = await resetSurveyAction({
|
||||
surveyId: survey.id,
|
||||
organizationId: organizationId,
|
||||
projectId: project.id,
|
||||
});
|
||||
if (result?.data) {
|
||||
toast.success(
|
||||
t("environments.surveys.summary.survey_reset_successfully", {
|
||||
responseCount: result.data.deletedResponsesCount,
|
||||
displayCount: result.data.deletedDisplaysCount,
|
||||
})
|
||||
);
|
||||
router.refresh();
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(result);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
setIsResetting(false);
|
||||
setIsResetModalOpen(false);
|
||||
};
|
||||
|
||||
const iconActions = [
|
||||
{
|
||||
icon: BellRing,
|
||||
@@ -159,18 +119,9 @@ export const SurveyAnalysisCTA = ({
|
||||
{
|
||||
icon: Eye,
|
||||
tooltip: t("common.preview"),
|
||||
onClick: async () => {
|
||||
const previewUrl = await getPreviewUrl();
|
||||
window.open(previewUrl, "_blank");
|
||||
},
|
||||
onClick: () => window.open(getPreviewUrl(), "_blank"),
|
||||
isVisible: survey.type === "link",
|
||||
},
|
||||
{
|
||||
icon: ListRestart,
|
||||
tooltip: t("environments.surveys.summary.reset_survey"),
|
||||
onClick: () => setIsResetModalOpen(true),
|
||||
isVisible: !isReadOnly && (responseCount > 0 || displayCount > 0),
|
||||
},
|
||||
{
|
||||
icon: SquarePenIcon,
|
||||
tooltip: t("common.edit"),
|
||||
@@ -239,17 +190,6 @@ export const SurveyAnalysisCTA = ({
|
||||
secondaryButtonText={t("common.edit")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ConfirmationModal
|
||||
open={isResetModalOpen}
|
||||
setOpen={setIsResetModalOpen}
|
||||
title={t("environments.surveys.summary.delete_all_existing_responses_and_displays")}
|
||||
text={t("environments.surveys.summary.reset_survey_warning")}
|
||||
buttonText={t("environments.surveys.summary.reset_survey")}
|
||||
onConfirm={handleResetSurvey}
|
||||
buttonVariant="destructive"
|
||||
buttonLoading={isResetting}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"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";
|
||||
@@ -7,14 +8,21 @@ 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";
|
||||
@@ -197,11 +205,17 @@ 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 rounded-lg bg-slate-50 p-6`}>
|
||||
<div className={`h-full w-full bg-slate-50 p-6 rounded-lg`}>
|
||||
<TabContainer
|
||||
title={t("environments.surveys.summary.in_app.title")}
|
||||
description={t("environments.surveys.summary.in_app.description")}>
|
||||
@@ -221,7 +235,7 @@ export const ShareSurveyModal = ({
|
||||
width={survey.type === "link" ? "wide" : "default"}
|
||||
aria-describedby={undefined}
|
||||
unconstrained>
|
||||
{renderContent()}
|
||||
{renderContent()}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -378,56 +378,4 @@ 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,13 +4,14 @@ 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, CopyIcon } from "lucide-react";
|
||||
import { CirclePlayIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
@@ -32,7 +33,6 @@ export const AnonymousLinksTab = ({
|
||||
setSurveyUrl,
|
||||
locale,
|
||||
}: AnonymousLinksTabProps) => {
|
||||
const surveyUrlWithCustomSuid = `${surveyUrl}?suId=CUSTOM-ID`;
|
||||
const router = useRouter();
|
||||
const { t } = useTranslate();
|
||||
|
||||
@@ -173,9 +173,11 @@ export const AnonymousLinksTab = ({
|
||||
count,
|
||||
});
|
||||
|
||||
const baseSurveyUrl = getSurveyUrl(survey, publicDomain, "default");
|
||||
|
||||
if (!!response?.data?.length) {
|
||||
const singleUseIds = response.data;
|
||||
const surveyLinks = singleUseIds.map((singleUseId) => `${surveyUrl}?suId=${singleUseId}`);
|
||||
const surveyLinks = singleUseIds.map((singleUseId) => `${baseSurveyUrl}?suId=${singleUseId}`);
|
||||
|
||||
// Create content with just the links
|
||||
const csvContent = surveyLinks.join("\n");
|
||||
@@ -256,33 +258,14 @@ export const AnonymousLinksTab = ({
|
||||
/>
|
||||
|
||||
{!singleUseEncryption ? (
|
||||
<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>
|
||||
<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>
|
||||
) : null}
|
||||
|
||||
{singleUseEncryption && (
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
|
||||
@@ -22,12 +21,10 @@ export const DisableLinkModal = ({ open, onOpenChange, type, onDisable }: Disabl
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent width="narrow" className="flex flex-col" hideCloseButton disableCloseOnOutsideClick>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm font-medium text-slate-900">
|
||||
{type === "multi-use"
|
||||
? t("environments.surveys.share.anonymous_links.disable_multi_use_link_modal_title")
|
||||
: t("common.are_you_sure")}
|
||||
</DialogTitle>
|
||||
<DialogHeader className="text-sm font-medium text-slate-900">
|
||||
{type === "multi-use"
|
||||
? t("environments.surveys.share.anonymous_links.disable_multi_use_link_modal_title")
|
||||
: t("common.are_you_sure")}
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody>
|
||||
|
||||
@@ -43,7 +43,6 @@ export const SuccessView: React.FC<SuccessViewProps> = ({
|
||||
publicDomain={publicDomain}
|
||||
setSurveyUrl={setSurveyUrl}
|
||||
locale={user.locale}
|
||||
enforceSurveyUrlWidth
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { deleteResponsesAndDisplaysForSurvey } from "./survey";
|
||||
|
||||
// Mock prisma
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
response: {
|
||||
deleteMany: vi.fn(),
|
||||
},
|
||||
display: {
|
||||
deleteMany: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const surveyId = "clq5n7p1q0000m7z0h5p6g3r2";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("Tests for deleteResponsesAndDisplaysForSurvey service", () => {
|
||||
describe("Happy Path", () => {
|
||||
test("Deletes all responses and displays for a survey", async () => {
|
||||
const { prisma } = await import("@formbricks/database");
|
||||
|
||||
// Mock $transaction to return the results directly
|
||||
vi.mocked(prisma.$transaction).mockResolvedValue([{ count: 5 }, { count: 3 }]);
|
||||
|
||||
const result = await deleteResponsesAndDisplaysForSurvey(surveyId);
|
||||
|
||||
expect(prisma.$transaction).toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
deletedResponsesCount: 5,
|
||||
deletedDisplaysCount: 3,
|
||||
});
|
||||
});
|
||||
|
||||
test("Handles case with no responses or displays to delete", async () => {
|
||||
const { prisma } = await import("@formbricks/database");
|
||||
|
||||
// Mock $transaction to return zero counts
|
||||
vi.mocked(prisma.$transaction).mockResolvedValue([{ count: 0 }, { count: 0 }]);
|
||||
|
||||
const result = await deleteResponsesAndDisplaysForSurvey(surveyId);
|
||||
|
||||
expect(result).toEqual({
|
||||
deletedResponsesCount: 0,
|
||||
deletedDisplaysCount: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Sad Path", () => {
|
||||
test("Throws DatabaseError on PrismaClientKnownRequestError occurrence", async () => {
|
||||
const { prisma } = await import("@formbricks/database");
|
||||
|
||||
const mockErrorMessage = "Mock error message";
|
||||
const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
|
||||
code: PrismaErrorType.UniqueConstraintViolation,
|
||||
clientVersion: "0.0.1",
|
||||
});
|
||||
|
||||
vi.mocked(prisma.$transaction).mockRejectedValue(errToThrow);
|
||||
|
||||
await expect(deleteResponsesAndDisplaysForSurvey(surveyId)).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
test("Throws a generic Error for other exceptions", async () => {
|
||||
const { prisma } = await import("@formbricks/database");
|
||||
|
||||
const mockErrorMessage = "Mock error message";
|
||||
vi.mocked(prisma.$transaction).mockRejectedValue(new Error(mockErrorMessage));
|
||||
|
||||
await expect(deleteResponsesAndDisplaysForSurvey(surveyId)).rejects.toThrow(Error);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
import "server-only";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
|
||||
export const deleteResponsesAndDisplaysForSurvey = async (
|
||||
surveyId: string
|
||||
): Promise<{ deletedResponsesCount: number; deletedDisplaysCount: number }> => {
|
||||
try {
|
||||
// Delete all responses for this survey
|
||||
|
||||
const [deletedResponsesCount, deletedDisplaysCount] = await prisma.$transaction([
|
||||
prisma.response.deleteMany({
|
||||
where: {
|
||||
surveyId: surveyId,
|
||||
},
|
||||
}),
|
||||
prisma.display.deleteMany({
|
||||
where: {
|
||||
surveyId: surveyId,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
deletedResponsesCount: deletedResponsesCount.count,
|
||||
deletedDisplaysCount: deletedDisplaysCount.count,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -58,7 +58,6 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
|
||||
user={user}
|
||||
publicDomain={publicDomain}
|
||||
responseCount={initialSurveySummary?.meta.totalResponses ?? 0}
|
||||
displayCount={initialSurveySummary?.meta.displayCount ?? 0}
|
||||
segments={segments}
|
||||
isContactsEnabled={isContactsEnabled}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
|
||||
@@ -281,7 +281,7 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
|
||||
? `${dateRange?.from ? format(dateRange?.from, "dd LLL") : "Select first date"} - ${
|
||||
dateRange?.to ? format(dateRange.to, "dd LLL") : "Select last date"
|
||||
}`
|
||||
: filterRange}
|
||||
: t(filterRange)}
|
||||
</span>
|
||||
{isFilterDropDownOpen ? (
|
||||
<ChevronUp className="ml-2 h-4 w-4 opacity-50" />
|
||||
@@ -296,28 +296,28 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
|
||||
setFilterRange(getFilterDropDownLabels(t).ALL_TIME);
|
||||
setDateRange({ from: undefined, to: getTodayDate() });
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).ALL_TIME}</p>
|
||||
<p className="text-slate-700">{t(getFilterDropDownLabels(t).ALL_TIME)}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).LAST_7_DAYS);
|
||||
setDateRange({ from: startOfDay(subDays(new Date(), 7)), to: getTodayDate() });
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_7_DAYS}</p>
|
||||
<p className="text-slate-700">{t(getFilterDropDownLabels(t).LAST_7_DAYS)}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).LAST_30_DAYS);
|
||||
setDateRange({ from: startOfDay(subDays(new Date(), 30)), to: getTodayDate() });
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_30_DAYS}</p>
|
||||
<p className="text-slate-700">{t(getFilterDropDownLabels(t).LAST_30_DAYS)}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).THIS_MONTH);
|
||||
setDateRange({ from: startOfMonth(new Date()), to: getTodayDate() });
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).THIS_MONTH}</p>
|
||||
<p className="text-slate-700">{t(getFilterDropDownLabels(t).THIS_MONTH)}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
@@ -327,14 +327,14 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
|
||||
to: endOfMonth(subMonths(getTodayDate(), 1)),
|
||||
});
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_MONTH}</p>
|
||||
<p className="text-slate-700">{t(getFilterDropDownLabels(t).LAST_MONTH)}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).THIS_QUARTER);
|
||||
setDateRange({ from: startOfQuarter(new Date()), to: endOfQuarter(getTodayDate()) });
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).THIS_QUARTER}</p>
|
||||
<p className="text-slate-700">{t(getFilterDropDownLabels(t).THIS_QUARTER)}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
@@ -344,7 +344,7 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
|
||||
to: endOfQuarter(subQuarters(getTodayDate(), 1)),
|
||||
});
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_QUARTER}</p>
|
||||
<p className="text-slate-700">{t(getFilterDropDownLabels(t).LAST_QUARTER)}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
@@ -354,14 +354,14 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
|
||||
to: endOfMonth(getTodayDate()),
|
||||
});
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_6_MONTHS}</p>
|
||||
<p className="text-slate-700">{t(getFilterDropDownLabels(t).LAST_6_MONTHS)}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).THIS_YEAR);
|
||||
setDateRange({ from: startOfYear(new Date()), to: endOfYear(getTodayDate()) });
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).THIS_YEAR}</p>
|
||||
<p className="text-slate-700">{t(getFilterDropDownLabels(t).THIS_YEAR)}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
@@ -371,7 +371,7 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
|
||||
to: endOfYear(subYears(getTodayDate(), 1)),
|
||||
});
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_YEAR}</p>
|
||||
<p className="text-slate-700">{t(getFilterDropDownLabels(t).LAST_YEAR)}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
@@ -380,7 +380,7 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
|
||||
setSelectingDate(DateSelected.FROM);
|
||||
}}>
|
||||
<p className="text-sm text-slate-700 hover:ring-0">
|
||||
{getFilterDropDownLabels(t).CUSTOM_RANGE}
|
||||
{t(getFilterDropDownLabels(t).CUSTOM_RANGE)}
|
||||
</p>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -15,13 +15,11 @@ import { useTranslate } from "@tolgee/react";
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
AirplayIcon,
|
||||
ArrowUpFromDotIcon,
|
||||
CheckIcon,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
ContactIcon,
|
||||
EyeOff,
|
||||
FlagIcon,
|
||||
GlobeIcon,
|
||||
GridIcon,
|
||||
HashIcon,
|
||||
@@ -91,9 +89,8 @@ const questionIcons = {
|
||||
device: SmartphoneIcon,
|
||||
os: AirplayIcon,
|
||||
browser: GlobeIcon,
|
||||
source: ArrowUpFromDotIcon,
|
||||
source: GlobeIcon,
|
||||
action: MousePointerClickIcon,
|
||||
country: FlagIcon,
|
||||
|
||||
// others
|
||||
Language: LanguagesIcon,
|
||||
@@ -135,16 +132,10 @@ 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={clsx("ml-3 truncate text-sm text-slate-600", getLabelStyle())}>
|
||||
<p className="ml-3 truncate text-sm text-slate-600">
|
||||
{typeof label === "string" ? label : getLocalizedValue(label, "default")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -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: undefined,
|
||||
REDIS_URL: "test-redis-url",
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
|
||||
@@ -9,46 +9,16 @@ vi.mock("@/modules/ui/components/button", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/error-component", () => ({
|
||||
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>
|
||||
),
|
||||
ErrorComponent: () => <div data-testid="ErrorComponent">ErrorComponent</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");
|
||||
@@ -59,12 +29,6 @@ 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(() => {
|
||||
@@ -78,13 +42,7 @@ 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} />);
|
||||
render(<ErrorBoundary error={{ ...dummyError }} reset={resetMock} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(Sentry.captureException).toHaveBeenCalled();
|
||||
@@ -92,95 +50,23 @@ describe("ErrorBoundary", () => {
|
||||
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
test("calls reset when try again button is clicked", async () => {
|
||||
render(<ErrorBoundary error={{ ...dummyError }} reset={resetMock} />);
|
||||
const tryAgainBtn = screen.getByRole("button", { name: "Try Again" });
|
||||
const tryAgainBtn = screen.getByRole("button", { name: "common.try_again" });
|
||||
userEvent.click(tryAgainBtn);
|
||||
await waitFor(() => expect(resetMock).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
test("sets window.location.href to '/' when dashboard button is clicked", async () => {
|
||||
const originalLocation = window.location;
|
||||
(window as any).location = undefined;
|
||||
delete (window as any).location;
|
||||
(window as any).location = { href: "" };
|
||||
render(<ErrorBoundary error={{ ...dummyError }} reset={resetMock} />);
|
||||
const dashBtn = screen.getByRole("button", { name: "Go to Dashboard" });
|
||||
const dashBtn = screen.getByRole("button", { name: "common.go_to_dashboard" });
|
||||
userEvent.click(dashBtn);
|
||||
await waitFor(() => {
|
||||
expect(window.location.href).toBe("/");
|
||||
});
|
||||
(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();
|
||||
window.location = originalLocation;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,31 +5,9 @@ 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 {
|
||||
@@ -38,15 +16,13 @@ const ErrorBoundary = ({ error, reset }: { error: Error; reset: () => void }) =>
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center">
|
||||
<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>
|
||||
)}
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -21,11 +21,8 @@ import {
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { TTemplate, TTemplateRole } from "@formbricks/types/templates";
|
||||
|
||||
const getDefaultButtonLabel = (label: string | undefined, t: TFnType) =>
|
||||
createI18nString(label || t("common.next"), []);
|
||||
|
||||
const getDefaultBackButtonLabel = (label: string | undefined, t: TFnType) =>
|
||||
createI18nString(label || t("common.back"), []);
|
||||
const defaultButtonLabel = "common.next";
|
||||
const defaultBackButtonLabel = "common.back";
|
||||
|
||||
export const buildMultipleChoiceQuestion = ({
|
||||
id,
|
||||
@@ -66,8 +63,8 @@ export const buildMultipleChoiceQuestion = ({
|
||||
const id = containsOther && isLastIndex ? "other" : choiceIds ? choiceIds[index] : createId();
|
||||
return { id, label: createI18nString(choice, []) };
|
||||
}),
|
||||
buttonLabel: getDefaultButtonLabel(buttonLabel, t),
|
||||
backButtonLabel: getDefaultBackButtonLabel(backButtonLabel, t),
|
||||
buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []),
|
||||
backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []),
|
||||
shuffleOption: shuffleOption || "none",
|
||||
required: required ?? false,
|
||||
logic,
|
||||
@@ -106,8 +103,8 @@ export const buildOpenTextQuestion = ({
|
||||
subheader: subheader ? createI18nString(subheader, []) : undefined,
|
||||
placeholder: placeholder ? createI18nString(placeholder, []) : undefined,
|
||||
headline: createI18nString(headline, []),
|
||||
buttonLabel: getDefaultButtonLabel(buttonLabel, t),
|
||||
backButtonLabel: getDefaultBackButtonLabel(backButtonLabel, t),
|
||||
buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []),
|
||||
backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []),
|
||||
required: required ?? false,
|
||||
longAnswer,
|
||||
logic,
|
||||
@@ -154,8 +151,8 @@ export const buildRatingQuestion = ({
|
||||
headline: createI18nString(headline, []),
|
||||
scale,
|
||||
range,
|
||||
buttonLabel: getDefaultButtonLabel(buttonLabel, t),
|
||||
backButtonLabel: getDefaultBackButtonLabel(backButtonLabel, t),
|
||||
buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []),
|
||||
backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []),
|
||||
required: required ?? false,
|
||||
isColorCodingEnabled,
|
||||
lowerLabel: lowerLabel ? createI18nString(lowerLabel, []) : undefined,
|
||||
@@ -195,8 +192,8 @@ export const buildNPSQuestion = ({
|
||||
type: TSurveyQuestionTypeEnum.NPS,
|
||||
subheader: subheader ? createI18nString(subheader, []) : undefined,
|
||||
headline: createI18nString(headline, []),
|
||||
buttonLabel: getDefaultButtonLabel(buttonLabel, t),
|
||||
backButtonLabel: getDefaultBackButtonLabel(backButtonLabel, t),
|
||||
buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []),
|
||||
backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []),
|
||||
required: required ?? false,
|
||||
isColorCodingEnabled,
|
||||
lowerLabel: lowerLabel ? createI18nString(lowerLabel, []) : undefined,
|
||||
@@ -231,8 +228,8 @@ export const buildConsentQuestion = ({
|
||||
type: TSurveyQuestionTypeEnum.Consent,
|
||||
subheader: subheader ? createI18nString(subheader, []) : undefined,
|
||||
headline: createI18nString(headline, []),
|
||||
buttonLabel: getDefaultButtonLabel(buttonLabel, t),
|
||||
backButtonLabel: getDefaultBackButtonLabel(backButtonLabel, t),
|
||||
buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []),
|
||||
backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []),
|
||||
required: required ?? false,
|
||||
label: createI18nString(label, []),
|
||||
logic,
|
||||
@@ -269,8 +266,8 @@ export const buildCTAQuestion = ({
|
||||
type: TSurveyQuestionTypeEnum.CTA,
|
||||
html: html ? createI18nString(html, []) : undefined,
|
||||
headline: createI18nString(headline, []),
|
||||
buttonLabel: getDefaultButtonLabel(buttonLabel, t),
|
||||
backButtonLabel: getDefaultBackButtonLabel(backButtonLabel, t),
|
||||
buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []),
|
||||
backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []),
|
||||
dismissButtonLabel: dismissButtonLabel ? createI18nString(dismissButtonLabel, []) : undefined,
|
||||
required: required ?? false,
|
||||
buttonExternal,
|
||||
|
||||
@@ -13,6 +13,54 @@ 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({
|
||||
@@ -25,6 +73,18 @@ 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({
|
||||
|
||||
@@ -1,11 +1,40 @@
|
||||
import { CLIENT_SIDE_API_RATE_LIMIT, SYNC_USER_IDENTIFICATION_RATE_LIMIT } from "@/lib/constants";
|
||||
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 { 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,
|
||||
|
||||
@@ -3,13 +3,63 @@ 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);
|
||||
@@ -41,6 +91,20 @@ 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);
|
||||
|
||||
@@ -4,6 +4,15 @@ 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;
|
||||
@@ -19,6 +28,11 @@ 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"];
|
||||
|
||||
@@ -7,8 +7,6 @@ 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";
|
||||
@@ -23,8 +21,6 @@ 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);
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,8 +6,6 @@ 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";
|
||||
@@ -22,8 +20,6 @@ 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);
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -154,6 +154,15 @@ 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,
|
||||
@@ -162,6 +171,23 @@ 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,
|
||||
@@ -175,6 +201,7 @@ export const ENTERPRISE_LICENSE_KEY = env.ENTERPRISE_LICENSE_KEY;
|
||||
export const REDIS_URL = env.REDIS_URL;
|
||||
export const REDIS_HTTP_URL = env.REDIS_HTTP_URL;
|
||||
export const RATE_LIMITING_DISABLED = env.RATE_LIMITING_DISABLED === "1";
|
||||
export const UNKEY_ROOT_KEY = env.UNKEY_ROOT_KEY;
|
||||
|
||||
export const BREVO_API_KEY = env.BREVO_API_KEY;
|
||||
export const BREVO_LIST_ID = env.BREVO_LIST_ID;
|
||||
|
||||
@@ -116,7 +116,7 @@ export const env = createEnv({
|
||||
VERCEL_URL: z.string().optional(),
|
||||
WEBAPP_URL: z.string().url().optional(),
|
||||
UNSPLASH_ACCESS_KEY: z.string().optional(),
|
||||
|
||||
UNKEY_ROOT_KEY: z.string().optional(),
|
||||
NODE_ENV: z.enum(["development", "production", "test"]).optional(),
|
||||
PROMETHEUS_EXPORTER_PORT: z.string().optional(),
|
||||
PROMETHEUS_ENABLED: z.enum(["1", "0"]).optional(),
|
||||
@@ -218,6 +218,7 @@ export const env = createEnv({
|
||||
VERCEL_URL: process.env.VERCEL_URL,
|
||||
WEBAPP_URL: process.env.WEBAPP_URL,
|
||||
UNSPLASH_ACCESS_KEY: process.env.UNSPLASH_ACCESS_KEY,
|
||||
UNKEY_ROOT_KEY: process.env.UNKEY_ROOT_KEY,
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
PROMETHEUS_ENABLED: process.env.PROMETHEUS_ENABLED,
|
||||
PROMETHEUS_EXPORTER_PORT: process.env.PROMETHEUS_EXPORTER_PORT,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { hashString } from "./hash-string";
|
||||
import { hashString } from "./hashString";
|
||||
|
||||
describe("hashString", () => {
|
||||
test("should return a string", () => {
|
||||
@@ -1,7 +1,6 @@
|
||||
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";
|
||||
@@ -134,7 +133,6 @@ export const deleteUser = async (id: string): Promise<TUser> => {
|
||||
}
|
||||
|
||||
const deletedUser = await deleteUserById(id);
|
||||
await deleteBrevoCustomerByEmail({ email: deletedUser.email });
|
||||
|
||||
return deletedUser;
|
||||
} catch (error) {
|
||||
|
||||
@@ -199,6 +199,8 @@
|
||||
"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",
|
||||
@@ -236,6 +238,7 @@
|
||||
"limits_reached": "Limits erreicht",
|
||||
"link": "Link",
|
||||
"link_and_email": "Link & E-Mail",
|
||||
"link_copied": "Link in die Zwischenablage kopiert!",
|
||||
"link_survey": "Link-Umfrage",
|
||||
"link_surveys": "Umfragen verknüpfen",
|
||||
"load_more": "Mehr laden",
|
||||
@@ -302,6 +305,7 @@
|
||||
"privacy": "Datenschutz",
|
||||
"product_manager": "Produktmanager",
|
||||
"profile": "Profil",
|
||||
"project": "Projekt",
|
||||
"project_configuration": "Projektkonfiguration",
|
||||
"project_id": "Projekt-ID",
|
||||
"project_name": "Projektname",
|
||||
@@ -411,6 +415,7 @@
|
||||
"website_and_app_connection": "Website & App Verbindung",
|
||||
"website_app_survey": "Website- & App-Umfrage",
|
||||
"website_survey": "Website-Umfrage",
|
||||
"weekly_summary": "Wöchentliche Zusammenfassung",
|
||||
"welcome_card": "Willkommenskarte",
|
||||
"you": "Du",
|
||||
"you_are_downgraded_to_the_community_edition": "Du wurdest auf die Community Edition herabgestuft.",
|
||||
@@ -451,7 +456,29 @@
|
||||
"invite_email_text_par1": "Dein Kollege",
|
||||
"invite_email_text_par2": "hat Dich eingeladen, Formbricks zu nutzen. Um die Einladung anzunehmen, klicke bitte auf den untenstehenden Link:",
|
||||
"invite_member_email_subject": "Du wurdest eingeladen, Formbricks zu nutzen!",
|
||||
"live_survey_notification_completed": "Abgeschlossen",
|
||||
"live_survey_notification_draft": "Entwurf",
|
||||
"live_survey_notification_in_progress": "In Bearbeitung",
|
||||
"live_survey_notification_no_new_response": "Diese Woche keine neue Antwort erhalten \uD83D\uDD75️",
|
||||
"live_survey_notification_no_responses_yet": "Noch keine Antworten!",
|
||||
"live_survey_notification_paused": "Pausiert",
|
||||
"live_survey_notification_scheduled": "Geplant",
|
||||
"live_survey_notification_view_more_responses": "Zeige {responseCount} weitere Antworten",
|
||||
"live_survey_notification_view_previous_responses": "Vorherige Antworten anzeigen",
|
||||
"live_survey_notification_view_response": "Antwort anzeigen",
|
||||
"new_email_verification_text": "Um Ihre neue E-Mail-Adresse zu bestätigen, klicken Sie bitte auf die Schaltfläche unten:",
|
||||
"notification_footer_all_the_best": "Alles Gute,",
|
||||
"notification_footer_in_your_settings": "in deinen Einstellungen \uD83D\uDE4F",
|
||||
"notification_footer_please_turn_them_off": "Bitte ausstellen",
|
||||
"notification_footer_the_formbricks_team": "Dein Formbricks Team \uD83E\uDD0D",
|
||||
"notification_footer_to_halt_weekly_updates": "Um wöchentliche Updates zu stoppen,",
|
||||
"notification_header_hey": "Hey \uD83D\uDC4B",
|
||||
"notification_header_weekly_report_for": "Wöchentlicher Bericht für",
|
||||
"notification_insight_completed": "Abgeschlossen",
|
||||
"notification_insight_completion_rate": "Completion Rate %",
|
||||
"notification_insight_displays": "Displays",
|
||||
"notification_insight_responses": "Antworten",
|
||||
"notification_insight_surveys": "Umfragen",
|
||||
"password_changed_email_heading": "Passwort geändert",
|
||||
"password_changed_email_text": "Dein Passwort wurde erfolgreich geändert.",
|
||||
"password_reset_notify_email_subject": "Dein Formbricks-Passwort wurde geändert",
|
||||
@@ -484,7 +511,14 @@
|
||||
"verification_email_verify_email": "E-Mail bestätigen",
|
||||
"verification_new_email_subject": "E-Mail-Änderungsbestätigung",
|
||||
"verification_security_notice": "Wenn du diese E-Mail-Änderung nicht angefordert hast, ignoriere bitte diese E-Mail oder kontaktiere sofort den Support.",
|
||||
"verified_link_survey_email_subject": "Deine Umfrage ist bereit zum Ausfüllen."
|
||||
"verified_link_survey_email_subject": "Deine Umfrage ist bereit zum Ausfüllen.",
|
||||
"weekly_summary_create_reminder_notification_body_cal_slot": "Wähle einen 15-minütigen Termin im Kalender unseres Gründers aus.",
|
||||
"weekly_summary_create_reminder_notification_body_dont_let_a_week_pass": "Lass keine Woche vergehen, ohne etwas über deine Nutzer zu lernen:",
|
||||
"weekly_summary_create_reminder_notification_body_need_help": "Brauchst Du Hilfe, die richtige Umfrage für dein Produkt zu finden?",
|
||||
"weekly_summary_create_reminder_notification_body_reply_email": "oder antworte auf diese E-Mail :)",
|
||||
"weekly_summary_create_reminder_notification_body_setup_a_new_survey": "Neue Umfrage einrichten",
|
||||
"weekly_summary_create_reminder_notification_body_text": "Wir würden dir gerne eine wöchentliche Zusammenfassung schicken, aber momentan laufen keine Umfragen für {projectName}.",
|
||||
"weekly_summary_email_subject": "{projectName} Nutzer-Insights – Letzte Woche von Formbricks"
|
||||
},
|
||||
"environments": {
|
||||
"actions": {
|
||||
@@ -1086,8 +1120,10 @@
|
||||
"need_slack_or_discord_notifications": "Brauchst Du Slack- oder Discord-Benachrichtigungen",
|
||||
"notification_settings_updated": "Benachrichtigungseinstellungen aktualisiert",
|
||||
"set_up_an_alert_to_get_an_email_on_new_responses": "Richte eine Benachrichtigung ein, um eine E-Mail bei neuen Antworten zu erhalten",
|
||||
"stay_up_to_date_with_a_Weekly_every_Monday": "Bleib auf dem Laufenden mit einem wöchentlichen Update jeden Montag",
|
||||
"use_the_integration": "Integration nutzen",
|
||||
"want_to_loop_in_organization_mates": "Willst Du die Organisationskollegen einbeziehen?",
|
||||
"weekly_summary_projects": "Wöchentliche Zusammenfassung (Projekte)",
|
||||
"you_will_not_be_auto_subscribed_to_this_organizations_surveys_anymore": "Du wirst nicht mehr automatisch zu den Umfragen dieser Organisation angemeldet!",
|
||||
"you_will_not_receive_any_more_emails_for_responses_on_this_survey": "Du wirst keine weiteren E-Mails für Antworten auf diese Umfrage erhalten!"
|
||||
},
|
||||
@@ -1246,6 +1282,8 @@
|
||||
"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",
|
||||
@@ -1330,6 +1368,7 @@
|
||||
"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",
|
||||
@@ -1405,6 +1444,7 @@
|
||||
"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",
|
||||
@@ -1442,6 +1482,7 @@
|
||||
"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",
|
||||
@@ -1533,6 +1574,8 @@
|
||||
"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",
|
||||
@@ -1549,6 +1592,8 @@
|
||||
"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",
|
||||
@@ -1579,6 +1624,7 @@
|
||||
"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",
|
||||
@@ -1602,6 +1648,8 @@
|
||||
"zip": "Postleitzahl"
|
||||
},
|
||||
"error_deleting_survey": "Beim Löschen der Umfrage ist ein Fehler aufgetreten",
|
||||
"failed_to_copy_link_to_results": "Kopieren des Links zu den Ergebnissen fehlgeschlagen",
|
||||
"failed_to_copy_url": "Kopieren der URL fehlgeschlagen: nicht in einer Browserumgebung.",
|
||||
"new_survey": "Neue Umfrage",
|
||||
"no_surveys_created_yet": "Noch keine Umfragen erstellt",
|
||||
"open_options": "Optionen öffnen",
|
||||
@@ -1642,6 +1690,7 @@
|
||||
"this_response_is_in_progress": "Diese Antwort ist in Bearbeitung.",
|
||||
"zip_post_code": "PLZ / Postleitzahl"
|
||||
},
|
||||
"results_unpublished_successfully": "Ergebnisse wurden nicht erfolgreich veröffentlicht.",
|
||||
"search_by_survey_name": "Nach Umfragenamen suchen",
|
||||
"share": {
|
||||
"anonymous_links": {
|
||||
@@ -1740,8 +1789,8 @@
|
||||
"configure_alerts": "Benachrichtigungen konfigurieren",
|
||||
"congrats": "Glückwunsch! Deine Umfrage ist jetzt live.",
|
||||
"connect_your_website_or_app_with_formbricks_to_get_started": "Verbinde deine Website oder App mit Formbricks, um loszulegen.",
|
||||
"copy_link_to_public_results": "Link zu öffentlichen Ergebnissen kopieren",
|
||||
"custom_range": "Benutzerdefinierter Bereich...",
|
||||
"delete_all_existing_responses_and_displays": "Alle bestehenden Antworten und Anzeigen löschen",
|
||||
"download_qr_code": "QR Code herunterladen",
|
||||
"drop_offs": "Drop-Off Rate",
|
||||
"drop_offs_tooltip": "So oft wurde die Umfrage gestartet, aber nicht abgeschlossen.",
|
||||
@@ -1793,33 +1842,40 @@
|
||||
"last_month": "Letztes Monat",
|
||||
"last_quarter": "Letztes Quartal",
|
||||
"last_year": "Letztes Jahr",
|
||||
"link_to_public_results_copied": "Link zu öffentlichen Ergebnissen kopiert",
|
||||
"no_responses_found": "Keine Antworten gefunden",
|
||||
"only_completed": "Nur vollständige Antworten",
|
||||
"other_values_found": "Andere Werte gefunden",
|
||||
"overall": "Insgesamt",
|
||||
"publish_to_web": "Im Web veröffentlichen",
|
||||
"publish_to_web_warning": "Du bist dabei, diese Umfrageergebnisse öffentlich zugänglich zu machen.",
|
||||
"publish_to_web_warning_description": "Deine Umfrageergebnisse werden öffentlich sein. Jeder außerhalb deiner Organisation kann darauf zugreifen, wenn er den Link hat.",
|
||||
"qr_code": "QR-Code",
|
||||
"qr_code_description": "Antworten, die per QR-Code gesammelt werden, sind anonym.",
|
||||
"qr_code_download_failed": "QR-Code-Download fehlgeschlagen",
|
||||
"qr_code_download_with_start_soon": "QR Code-Download startet bald",
|
||||
"qr_code_generation_failed": "Es gab ein Problem beim Laden des QR-Codes für die Umfrage. Bitte versuchen Sie es erneut.",
|
||||
"reset_survey": "Umfrage zurücksetzen",
|
||||
"reset_survey_warning": "Das Zurücksetzen einer Umfrage entfernt alle Antworten und Anzeigen, die mit dieser Umfrage verbunden sind. Dies kann nicht rückgängig gemacht werden.",
|
||||
"results_are_public": "Ergebnisse sind öffentlich",
|
||||
"selected_responses_csv": "Ausgewählte Antworten (CSV)",
|
||||
"selected_responses_excel": "Ausgewählte Antworten (Excel)",
|
||||
"setup_integrations": "Integrationen einrichten",
|
||||
"share_results": "Ergebnisse teilen",
|
||||
"share_survey": "Umfrage teilen",
|
||||
"show_all_responses_that_match": "Zeige alle Antworten, die übereinstimmen",
|
||||
"show_all_responses_where": "Zeige alle Antworten, bei denen...",
|
||||
"starts": "Startet",
|
||||
"starts_tooltip": "So oft wurde die Umfrage gestartet.",
|
||||
"survey_reset_successfully": "Umfrage erfolgreich zurückgesetzt! {responseCount} Antworten und {displayCount} Anzeigen wurden gelöscht.",
|
||||
"survey_results_are_public": "Deine Umfrageergebnisse sind öffentlich",
|
||||
"survey_results_are_shared_with_anyone_who_has_the_link": "Deine Umfrageergebnisse stehen allen zur Verfügung, die den Link haben. Die Ergebnisse werden nicht von Suchmaschinen indexiert.",
|
||||
"this_month": "Dieser Monat",
|
||||
"this_quarter": "Dieses Quartal",
|
||||
"this_year": "Dieses Jahr",
|
||||
"time_to_complete": "Zeit zur Fertigstellung",
|
||||
"ttc_tooltip": "Durchschnittliche Zeit bis zum Abschluss der Umfrage.",
|
||||
"unknown_question_type": "Unbekannter Fragetyp",
|
||||
"unpublish_from_web": "Aus dem Web entfernen",
|
||||
"use_personal_links": "Nutze persönliche Links",
|
||||
"view_site": "Seite ansehen",
|
||||
"waiting_for_response": "Warte auf eine Antwort \uD83E\uDDD8♂️",
|
||||
"whats_next": "Was kommt als Nächstes?",
|
||||
"your_survey_is_public": "Deine Umfrage ist öffentlich",
|
||||
@@ -1950,6 +2006,11 @@
|
||||
"this_user_has_all_the_power": "Dieser Benutzer hat alle Rechte."
|
||||
}
|
||||
},
|
||||
"share": {
|
||||
"back_to_home": "Zurück zur Startseite",
|
||||
"page_not_found": "Seite nicht gefunden",
|
||||
"page_not_found_description": "Entschuldigung, wir konnten die gesuchten Antworten mit der geteilten ID nicht finden."
|
||||
},
|
||||
"templates": {
|
||||
"address": "Adresse",
|
||||
"address_description": "Frag nach einer Adresse",
|
||||
|
||||
@@ -199,6 +199,8 @@
|
||||
"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",
|
||||
@@ -236,6 +238,7 @@
|
||||
"limits_reached": "Limits Reached",
|
||||
"link": "Link",
|
||||
"link_and_email": "Link & Email",
|
||||
"link_copied": "Link copied to clipboard!",
|
||||
"link_survey": "Link Survey",
|
||||
"link_surveys": "Link Surveys",
|
||||
"load_more": "Load more",
|
||||
@@ -302,6 +305,7 @@
|
||||
"privacy": "Privacy Policy",
|
||||
"product_manager": "Product Manager",
|
||||
"profile": "Profile",
|
||||
"project": "Project",
|
||||
"project_configuration": "Project's Configuration",
|
||||
"project_id": "Project ID",
|
||||
"project_name": "Project Name",
|
||||
@@ -411,6 +415,7 @@
|
||||
"website_and_app_connection": "Website & App Connection",
|
||||
"website_app_survey": "Website & App Survey",
|
||||
"website_survey": "Website Survey",
|
||||
"weekly_summary": "Weekly summary",
|
||||
"welcome_card": "Welcome card",
|
||||
"you": "You",
|
||||
"you_are_downgraded_to_the_community_edition": "You are downgraded to the Community Edition.",
|
||||
@@ -451,7 +456,29 @@
|
||||
"invite_email_text_par1": "Your colleague",
|
||||
"invite_email_text_par2": "invited you to join them at Formbricks. To accept the invitation, please click the link below:",
|
||||
"invite_member_email_subject": "You're invited to collaborate on Formbricks!",
|
||||
"live_survey_notification_completed": "Completed",
|
||||
"live_survey_notification_draft": "Draft",
|
||||
"live_survey_notification_in_progress": "In Progress",
|
||||
"live_survey_notification_no_new_response": "No new response received this week \uD83D\uDD75️",
|
||||
"live_survey_notification_no_responses_yet": "No Responses yet!",
|
||||
"live_survey_notification_paused": "Paused",
|
||||
"live_survey_notification_scheduled": "Scheduled",
|
||||
"live_survey_notification_view_more_responses": "View {responseCount} more Responses",
|
||||
"live_survey_notification_view_previous_responses": "View previous responses",
|
||||
"live_survey_notification_view_response": "View Response",
|
||||
"new_email_verification_text": "To verify your new email address, please click the button below:",
|
||||
"notification_footer_all_the_best": "All the best,",
|
||||
"notification_footer_in_your_settings": "in your settings \uD83D\uDE4F",
|
||||
"notification_footer_please_turn_them_off": "please turn them off",
|
||||
"notification_footer_the_formbricks_team": "The Formbricks Team \uD83E\uDD0D",
|
||||
"notification_footer_to_halt_weekly_updates": "To halt Weekly Updates,",
|
||||
"notification_header_hey": "Hey \uD83D\uDC4B",
|
||||
"notification_header_weekly_report_for": "Weekly Report for",
|
||||
"notification_insight_completed": "Completed",
|
||||
"notification_insight_completion_rate": "Completion %",
|
||||
"notification_insight_displays": "Displays",
|
||||
"notification_insight_responses": "Responses",
|
||||
"notification_insight_surveys": "Surveys",
|
||||
"password_changed_email_heading": "Password changed",
|
||||
"password_changed_email_text": "Your password has been changed successfully.",
|
||||
"password_reset_notify_email_subject": "Your Formbricks password has been changed",
|
||||
@@ -484,7 +511,14 @@
|
||||
"verification_email_verify_email": "Verify email",
|
||||
"verification_new_email_subject": "Email change verification",
|
||||
"verification_security_notice": "If you did not request this email change, please ignore this email or contact support immediately.",
|
||||
"verified_link_survey_email_subject": "Your survey is ready to be filled out."
|
||||
"verified_link_survey_email_subject": "Your survey is ready to be filled out.",
|
||||
"weekly_summary_create_reminder_notification_body_cal_slot": "Pick a 15-minute slot in our CEOs calendar",
|
||||
"weekly_summary_create_reminder_notification_body_dont_let_a_week_pass": "Don't let a week pass without learning about your users:",
|
||||
"weekly_summary_create_reminder_notification_body_need_help": "Need help finding the right survey for your product?",
|
||||
"weekly_summary_create_reminder_notification_body_reply_email": "or reply to this email :)",
|
||||
"weekly_summary_create_reminder_notification_body_setup_a_new_survey": "Setup a new survey",
|
||||
"weekly_summary_create_reminder_notification_body_text": "We'd love to send you a Weekly Summary, but currently there are no surveys running for {projectName}.",
|
||||
"weekly_summary_email_subject": "{projectName} User Insights - Last Week by Formbricks"
|
||||
},
|
||||
"environments": {
|
||||
"actions": {
|
||||
@@ -1086,8 +1120,10 @@
|
||||
"need_slack_or_discord_notifications": "Need Slack or Discord notifications",
|
||||
"notification_settings_updated": "Notification settings updated",
|
||||
"set_up_an_alert_to_get_an_email_on_new_responses": "Set up an alert to get an email on new responses",
|
||||
"stay_up_to_date_with_a_Weekly_every_Monday": "Stay up-to-date with a Weekly every Monday",
|
||||
"use_the_integration": "Use the integration",
|
||||
"want_to_loop_in_organization_mates": "Want to loop in organization mates",
|
||||
"weekly_summary_projects": "Weekly summary (Projects)",
|
||||
"you_will_not_be_auto_subscribed_to_this_organizations_surveys_anymore": "You will not be auto-subscribed to this organization's surveys anymore!",
|
||||
"you_will_not_receive_any_more_emails_for_responses_on_this_survey": "You will not receive any more emails for responses on this survey!"
|
||||
},
|
||||
@@ -1246,6 +1282,8 @@
|
||||
"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",
|
||||
@@ -1330,6 +1368,7 @@
|
||||
"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",
|
||||
@@ -1405,6 +1444,7 @@
|
||||
"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",
|
||||
@@ -1442,6 +1482,7 @@
|
||||
"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",
|
||||
@@ -1533,6 +1574,8 @@
|
||||
"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",
|
||||
@@ -1549,6 +1592,8 @@
|
||||
"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",
|
||||
@@ -1579,6 +1624,7 @@
|
||||
"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",
|
||||
@@ -1602,6 +1648,8 @@
|
||||
"zip": "Zip"
|
||||
},
|
||||
"error_deleting_survey": "An error occured while deleting survey",
|
||||
"failed_to_copy_link_to_results": "Failed to copy link to results",
|
||||
"failed_to_copy_url": "Failed to copy URL: not in a browser environment.",
|
||||
"new_survey": "New Survey",
|
||||
"no_surveys_created_yet": "No surveys created yet",
|
||||
"open_options": "Open options",
|
||||
@@ -1642,6 +1690,7 @@
|
||||
"this_response_is_in_progress": "This response is in progress.",
|
||||
"zip_post_code": "ZIP / Post code"
|
||||
},
|
||||
"results_unpublished_successfully": "Results unpublished successfully.",
|
||||
"search_by_survey_name": "Search by survey name",
|
||||
"share": {
|
||||
"anonymous_links": {
|
||||
@@ -1740,8 +1789,8 @@
|
||||
"configure_alerts": "Configure alerts",
|
||||
"congrats": "Congrats! Your survey is live.",
|
||||
"connect_your_website_or_app_with_formbricks_to_get_started": "Connect your website or app with Formbricks to get started.",
|
||||
"copy_link_to_public_results": "Copy link to public results",
|
||||
"custom_range": "Custom range...",
|
||||
"delete_all_existing_responses_and_displays": "Delete all existing responses and displays",
|
||||
"download_qr_code": "Download QR code",
|
||||
"drop_offs": "Drop-Offs",
|
||||
"drop_offs_tooltip": "Number of times the survey has been started but not completed.",
|
||||
@@ -1793,33 +1842,40 @@
|
||||
"last_month": "Last month",
|
||||
"last_quarter": "Last quarter",
|
||||
"last_year": "Last year",
|
||||
"link_to_public_results_copied": "Link to public results copied",
|
||||
"no_responses_found": "No responses found",
|
||||
"only_completed": "Only completed",
|
||||
"other_values_found": "Other values found",
|
||||
"overall": "Overall",
|
||||
"publish_to_web": "Publish to web",
|
||||
"publish_to_web_warning": "You are about to release these survey results to the public.",
|
||||
"publish_to_web_warning_description": "Your survey results will be public. Anyone outside your organization can access them if they have the link.",
|
||||
"qr_code": "QR code",
|
||||
"qr_code_description": "Responses collected via QR code are anonymous.",
|
||||
"qr_code_download_failed": "QR code download failed",
|
||||
"qr_code_download_with_start_soon": "QR code download will start soon",
|
||||
"qr_code_generation_failed": "There was a problem, loading the survey QR Code. Please try again.",
|
||||
"reset_survey": "Reset survey",
|
||||
"reset_survey_warning": "Resetting a survey removes all responses and displays associated with this survey. This cannot be undone.",
|
||||
"results_are_public": "Results are public",
|
||||
"selected_responses_csv": "Selected responses (CSV)",
|
||||
"selected_responses_excel": "Selected responses (Excel)",
|
||||
"setup_integrations": "Setup integrations",
|
||||
"share_results": "Share results",
|
||||
"share_survey": "Share survey",
|
||||
"show_all_responses_that_match": "Show all responses that match",
|
||||
"show_all_responses_where": "Show all responses where...",
|
||||
"starts": "Starts",
|
||||
"starts_tooltip": "Number of times the survey has been started.",
|
||||
"survey_reset_successfully": "Survey reset successfully! {responseCount} responses and {displayCount} displays were deleted.",
|
||||
"survey_results_are_public": "Your survey results are public!",
|
||||
"survey_results_are_shared_with_anyone_who_has_the_link": "Your survey results are shared with anyone who has the link. The results will not be indexed by search engines.",
|
||||
"this_month": "This month",
|
||||
"this_quarter": "This quarter",
|
||||
"this_year": "This year",
|
||||
"time_to_complete": "Time to Complete",
|
||||
"ttc_tooltip": "Average time to complete the survey.",
|
||||
"unknown_question_type": "Unknown Question Type",
|
||||
"unpublish_from_web": "Unpublish from web",
|
||||
"use_personal_links": "Use personal links",
|
||||
"view_site": "View site",
|
||||
"waiting_for_response": "Waiting for a response \uD83E\uDDD8♂️",
|
||||
"whats_next": "What's next?",
|
||||
"your_survey_is_public": "Your survey is public",
|
||||
@@ -1950,6 +2006,11 @@
|
||||
"this_user_has_all_the_power": "This user has all the power."
|
||||
}
|
||||
},
|
||||
"share": {
|
||||
"back_to_home": "Back to home",
|
||||
"page_not_found": "Page not found",
|
||||
"page_not_found_description": "Sorry, we couldn't find the responses sharing ID you're looking for."
|
||||
},
|
||||
"templates": {
|
||||
"address": "Address",
|
||||
"address_description": "Ask for a mailing address",
|
||||
|
||||
@@ -199,6 +199,8 @@
|
||||
"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",
|
||||
@@ -236,6 +238,7 @@
|
||||
"limits_reached": "Limites atteints",
|
||||
"link": "Lien",
|
||||
"link_and_email": "Liens et e-mail",
|
||||
"link_copied": " lien copié dans le presse-papiers !",
|
||||
"link_survey": "Enquête de lien",
|
||||
"link_surveys": "Sondages de lien",
|
||||
"load_more": "Charger plus",
|
||||
@@ -302,6 +305,7 @@
|
||||
"privacy": "Politique de confidentialité",
|
||||
"product_manager": "Chef de produit",
|
||||
"profile": "Profil",
|
||||
"project": "Projet",
|
||||
"project_configuration": "Configuration du projet",
|
||||
"project_id": "ID de projet",
|
||||
"project_name": "Nom du projet",
|
||||
@@ -411,6 +415,7 @@
|
||||
"website_and_app_connection": "Connexion Site Web & Application",
|
||||
"website_app_survey": "Sondage sur le site Web et l'application",
|
||||
"website_survey": "Sondage de site web",
|
||||
"weekly_summary": "Résumé hebdomadaire",
|
||||
"welcome_card": "Carte de bienvenue",
|
||||
"you": "Vous",
|
||||
"you_are_downgraded_to_the_community_edition": "Vous êtes rétrogradé à l'édition communautaire.",
|
||||
@@ -451,7 +456,29 @@
|
||||
"invite_email_text_par1": "Votre collègue",
|
||||
"invite_email_text_par2": "vous a invité à les rejoindre sur Formbricks. Pour accepter l'invitation, veuillez cliquer sur le lien ci-dessous :",
|
||||
"invite_member_email_subject": "Vous avez été invité à collaborer sur Formbricks !",
|
||||
"live_survey_notification_completed": "Terminé",
|
||||
"live_survey_notification_draft": "Brouillon",
|
||||
"live_survey_notification_in_progress": "En cours",
|
||||
"live_survey_notification_no_new_response": "Aucune nouvelle réponse reçue cette semaine \uD83D\uDD75️",
|
||||
"live_survey_notification_no_responses_yet": "Aucune réponse pour le moment !",
|
||||
"live_survey_notification_paused": "En pause",
|
||||
"live_survey_notification_scheduled": "Programmé",
|
||||
"live_survey_notification_view_more_responses": "Voir {responseCount} réponses supplémentaires",
|
||||
"live_survey_notification_view_previous_responses": "Voir les réponses précédentes",
|
||||
"live_survey_notification_view_response": "Voir la réponse",
|
||||
"new_email_verification_text": "Pour confirmer votre nouvelle adresse e-mail, veuillez cliquer sur le bouton ci-dessous :",
|
||||
"notification_footer_all_the_best": "Tous mes vœux,",
|
||||
"notification_footer_in_your_settings": "dans vos paramètres \uD83D\uDE4F",
|
||||
"notification_footer_please_turn_them_off": "veuillez les éteindre",
|
||||
"notification_footer_the_formbricks_team": "L'équipe Formbricks \uD83E\uDD0D",
|
||||
"notification_footer_to_halt_weekly_updates": "Pour arrêter les mises à jour hebdomadaires,",
|
||||
"notification_header_hey": "Salut \uD83D\uDC4B",
|
||||
"notification_header_weekly_report_for": "Rapport hebdomadaire pour",
|
||||
"notification_insight_completed": "Terminé",
|
||||
"notification_insight_completion_rate": "Pourcentage d'achèvement",
|
||||
"notification_insight_displays": "Affichages",
|
||||
"notification_insight_responses": "Réponses",
|
||||
"notification_insight_surveys": "Enquêtes",
|
||||
"password_changed_email_heading": "Mot de passe changé",
|
||||
"password_changed_email_text": "Votre mot de passe a été changé avec succès.",
|
||||
"password_reset_notify_email_subject": "Ton mot de passe Formbricks a été changé",
|
||||
@@ -484,7 +511,14 @@
|
||||
"verification_email_verify_email": "Vérifier l'email",
|
||||
"verification_new_email_subject": "Vérification du changement d'email",
|
||||
"verification_security_notice": "Si vous n'avez pas demandé ce changement d'email, veuillez ignorer cet email ou contacter le support immédiatement.",
|
||||
"verified_link_survey_email_subject": "Votre enquête est prête à être remplie."
|
||||
"verified_link_survey_email_subject": "Votre enquête est prête à être remplie.",
|
||||
"weekly_summary_create_reminder_notification_body_cal_slot": "Choisissez un créneau de 15 minutes dans le calendrier de notre PDG.",
|
||||
"weekly_summary_create_reminder_notification_body_dont_let_a_week_pass": "Ne laissez pas une semaine passer sans en apprendre davantage sur vos utilisateurs :",
|
||||
"weekly_summary_create_reminder_notification_body_need_help": "Besoin d'aide pour trouver le bon sondage pour votre produit ?",
|
||||
"weekly_summary_create_reminder_notification_body_reply_email": "ou répondez à cet e-mail :)",
|
||||
"weekly_summary_create_reminder_notification_body_setup_a_new_survey": "Configurer une nouvelle enquête",
|
||||
"weekly_summary_create_reminder_notification_body_text": "Nous aimerions vous envoyer un résumé hebdomadaire, mais actuellement, il n'y a pas d'enquêtes en cours pour {projectName}.",
|
||||
"weekly_summary_email_subject": "Aperçu des utilisateurs de {projectName} – La semaine dernière par Formbricks"
|
||||
},
|
||||
"environments": {
|
||||
"actions": {
|
||||
@@ -1086,8 +1120,10 @@
|
||||
"need_slack_or_discord_notifications": "Besoin de notifications Slack ou Discord",
|
||||
"notification_settings_updated": "Paramètres de notification mis à jour",
|
||||
"set_up_an_alert_to_get_an_email_on_new_responses": "Configurez une alerte pour recevoir un e-mail lors de nouvelles réponses.",
|
||||
"stay_up_to_date_with_a_Weekly_every_Monday": "Restez à jour avec un hebdomadaire chaque lundi.",
|
||||
"use_the_integration": "Utilisez l'intégration",
|
||||
"want_to_loop_in_organization_mates": "Voulez-vous inclure des collègues de l'organisation ?",
|
||||
"weekly_summary_projects": "Résumé hebdomadaire (Projets)",
|
||||
"you_will_not_be_auto_subscribed_to_this_organizations_surveys_anymore": "Vous ne serez plus automatiquement abonné aux enquêtes de cette organisation !",
|
||||
"you_will_not_receive_any_more_emails_for_responses_on_this_survey": "Vous ne recevrez plus d'e-mails concernant les réponses à cette enquête !"
|
||||
},
|
||||
@@ -1246,6 +1282,8 @@
|
||||
"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",
|
||||
@@ -1330,6 +1368,7 @@
|
||||
"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",
|
||||
@@ -1405,6 +1444,7 @@
|
||||
"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",
|
||||
@@ -1442,6 +1482,7 @@
|
||||
"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.",
|
||||
@@ -1533,6 +1574,8 @@
|
||||
"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",
|
||||
@@ -1549,6 +1592,8 @@
|
||||
"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",
|
||||
@@ -1579,6 +1624,7 @@
|
||||
"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",
|
||||
@@ -1602,6 +1648,8 @@
|
||||
"zip": "Zip"
|
||||
},
|
||||
"error_deleting_survey": "Une erreur est survenue lors de la suppression de l'enquête.",
|
||||
"failed_to_copy_link_to_results": "Échec de la copie du lien vers les résultats",
|
||||
"failed_to_copy_url": "Échec de la copie de l'URL : pas dans un environnement de navigateur.",
|
||||
"new_survey": "Nouveau Sondage",
|
||||
"no_surveys_created_yet": "Aucun sondage créé pour le moment",
|
||||
"open_options": "Ouvrir les options",
|
||||
@@ -1642,6 +1690,7 @@
|
||||
"this_response_is_in_progress": "Cette réponse est en cours.",
|
||||
"zip_post_code": "Code postal"
|
||||
},
|
||||
"results_unpublished_successfully": "Résultats publiés avec succès.",
|
||||
"search_by_survey_name": "Recherche par nom d'enquête",
|
||||
"share": {
|
||||
"anonymous_links": {
|
||||
@@ -1740,8 +1789,8 @@
|
||||
"configure_alerts": "Configurer les alertes",
|
||||
"congrats": "Félicitations ! Votre enquête est en ligne.",
|
||||
"connect_your_website_or_app_with_formbricks_to_get_started": "Connectez votre site web ou votre application à Formbricks pour commencer.",
|
||||
"copy_link_to_public_results": "Copier le lien vers les résultats publics",
|
||||
"custom_range": "Plage personnalisée...",
|
||||
"delete_all_existing_responses_and_displays": "Supprimer toutes les réponses existantes et les affichages",
|
||||
"download_qr_code": "Télécharger code QR",
|
||||
"drop_offs": "Dépôts",
|
||||
"drop_offs_tooltip": "Nombre de fois que l'enquête a été commencée mais non terminée.",
|
||||
@@ -1793,33 +1842,40 @@
|
||||
"last_month": "Le mois dernier",
|
||||
"last_quarter": "dernier trimestre",
|
||||
"last_year": "l'année dernière",
|
||||
"link_to_public_results_copied": "Lien vers les résultats publics copié",
|
||||
"no_responses_found": "Aucune réponse trouvée",
|
||||
"only_completed": "Uniquement terminé",
|
||||
"other_values_found": "D'autres valeurs trouvées",
|
||||
"overall": "Globalement",
|
||||
"publish_to_web": "Publier sur le web",
|
||||
"publish_to_web_warning": "Vous êtes sur le point de rendre ces résultats d'enquête publics.",
|
||||
"publish_to_web_warning_description": "Les résultats de votre enquête seront publics. Toute personne en dehors de votre organisation pourra y accéder si elle a le lien.",
|
||||
"qr_code": "Code QR",
|
||||
"qr_code_description": "Les réponses collectées via le code QR sont anonymes.",
|
||||
"qr_code_download_failed": "Échec du téléchargement du code QR",
|
||||
"qr_code_download_with_start_soon": "Le téléchargement du code QR débutera bientôt",
|
||||
"qr_code_generation_failed": "\"Un problème est survenu lors du chargement du code QR du sondage. Veuillez réessayer.\"",
|
||||
"reset_survey": "Réinitialiser l'enquête",
|
||||
"reset_survey_warning": "Réinitialiser un sondage supprime toutes les réponses et les affichages associés à ce sondage. Cela ne peut pas être annulé.",
|
||||
"results_are_public": "Les résultats sont publics.",
|
||||
"selected_responses_csv": "Réponses sélectionnées (CSV)",
|
||||
"selected_responses_excel": "Réponses sélectionnées (Excel)",
|
||||
"setup_integrations": "Configurer les intégrations",
|
||||
"share_results": "Partager les résultats",
|
||||
"share_survey": "Partager l'enquête",
|
||||
"show_all_responses_that_match": "Afficher toutes les réponses correspondantes",
|
||||
"show_all_responses_where": "Afficher toutes les réponses où...",
|
||||
"starts": "Commence",
|
||||
"starts_tooltip": "Nombre de fois que l'enquête a été commencée.",
|
||||
"survey_reset_successfully": "Réinitialisation du sondage réussie ! {responseCount} réponses et {displayCount} affichages ont été supprimés.",
|
||||
"survey_results_are_public": "Les résultats de votre enquête sont publics !",
|
||||
"survey_results_are_shared_with_anyone_who_has_the_link": "Les résultats de votre enquête sont partagés avec quiconque possède le lien. Les résultats ne seront pas indexés par les moteurs de recherche.",
|
||||
"this_month": "Ce mois-ci",
|
||||
"this_quarter": "Ce trimestre",
|
||||
"this_year": "Cette année",
|
||||
"time_to_complete": "Temps à compléter",
|
||||
"ttc_tooltip": "Temps moyen pour compléter l'enquête.",
|
||||
"unknown_question_type": "Type de question inconnu",
|
||||
"unpublish_from_web": "Désactiver la publication sur le web",
|
||||
"use_personal_links": "Utilisez des liens personnels",
|
||||
"view_site": "Voir le site",
|
||||
"waiting_for_response": "En attente d'une réponse \uD83E\uDDD8♂️",
|
||||
"whats_next": "Qu'est-ce qui vient ensuite ?",
|
||||
"your_survey_is_public": "Votre enquête est publique.",
|
||||
@@ -1950,6 +2006,11 @@
|
||||
"this_user_has_all_the_power": "Cet utilisateur a tout le pouvoir."
|
||||
}
|
||||
},
|
||||
"share": {
|
||||
"back_to_home": "Retour à l'accueil",
|
||||
"page_not_found": "Page non trouvée",
|
||||
"page_not_found_description": "Désolé, nous n'avons pas pu trouver l'ID de partage des réponses que vous recherchez."
|
||||
},
|
||||
"templates": {
|
||||
"address": "Adresse",
|
||||
"address_description": "Demander une adresse postale",
|
||||
|
||||
@@ -199,6 +199,8 @@
|
||||
"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",
|
||||
@@ -236,6 +238,7 @@
|
||||
"limits_reached": "Limites Atingidos",
|
||||
"link": "link",
|
||||
"link_and_email": "Link & E-mail",
|
||||
"link_copied": "Link copiado para a área de transferência!",
|
||||
"link_survey": "Pesquisa de Link",
|
||||
"link_surveys": "Link de Pesquisas",
|
||||
"load_more": "Carregar mais",
|
||||
@@ -302,6 +305,7 @@
|
||||
"privacy": "Política de Privacidade",
|
||||
"product_manager": "Gerente de Produto",
|
||||
"profile": "Perfil",
|
||||
"project": "Projeto",
|
||||
"project_configuration": "Configuração do Projeto",
|
||||
"project_id": "ID do Projeto",
|
||||
"project_name": "Nome do Projeto",
|
||||
@@ -411,6 +415,7 @@
|
||||
"website_and_app_connection": "Conexão de Site e App",
|
||||
"website_app_survey": "Pesquisa de Site e App",
|
||||
"website_survey": "Pesquisa de Site",
|
||||
"weekly_summary": "Resumo semanal",
|
||||
"welcome_card": "Cartão de boas-vindas",
|
||||
"you": "Você",
|
||||
"you_are_downgraded_to_the_community_edition": "Você foi rebaixado para a Edição Comunitária.",
|
||||
@@ -451,7 +456,29 @@
|
||||
"invite_email_text_par1": "Seu colega",
|
||||
"invite_email_text_par2": "te convidou para se juntar a eles na Formbricks. Para aceitar o convite, por favor clique no link abaixo:",
|
||||
"invite_member_email_subject": "Você foi convidado a colaborar no Formbricks!",
|
||||
"live_survey_notification_completed": "Concluído",
|
||||
"live_survey_notification_draft": "Rascunho",
|
||||
"live_survey_notification_in_progress": "Em andamento",
|
||||
"live_survey_notification_no_new_response": "Nenhuma resposta nova recebida essa semana \uD83D\uDD75️",
|
||||
"live_survey_notification_no_responses_yet": "Ainda sem respostas!",
|
||||
"live_survey_notification_paused": "Pausado",
|
||||
"live_survey_notification_scheduled": "agendado",
|
||||
"live_survey_notification_view_more_responses": "Ver mais {responseCount} respostas",
|
||||
"live_survey_notification_view_previous_responses": "Ver respostas anteriores",
|
||||
"live_survey_notification_view_response": "Ver Resposta",
|
||||
"new_email_verification_text": "Para verificar seu novo endereço de e-mail, clique no botão abaixo:",
|
||||
"notification_footer_all_the_best": "Tudo de bom,",
|
||||
"notification_footer_in_your_settings": "nas suas configurações \uD83D\uDE4F",
|
||||
"notification_footer_please_turn_them_off": "por favor, desliga eles",
|
||||
"notification_footer_the_formbricks_team": "A Equipe Formbricks \uD83E\uDD0D",
|
||||
"notification_footer_to_halt_weekly_updates": "Para parar as Atualizações Semanais,",
|
||||
"notification_header_hey": "Oi \uD83D\uDC4B",
|
||||
"notification_header_weekly_report_for": "Relatório Semanal de",
|
||||
"notification_insight_completed": "Concluído",
|
||||
"notification_insight_completion_rate": "Conclusão %",
|
||||
"notification_insight_displays": "telas",
|
||||
"notification_insight_responses": "Respostas",
|
||||
"notification_insight_surveys": "pesquisas",
|
||||
"password_changed_email_heading": "Senha alterada",
|
||||
"password_changed_email_text": "Sua senha foi alterada com sucesso.",
|
||||
"password_reset_notify_email_subject": "Sua senha Formbricks foi alterada",
|
||||
@@ -484,7 +511,14 @@
|
||||
"verification_email_verify_email": "Verificar e-mail",
|
||||
"verification_new_email_subject": "Verificação de alteração de e-mail",
|
||||
"verification_security_notice": "Se você não solicitou essa mudança de email, por favor ignore este email ou entre em contato com o suporte imediatamente.",
|
||||
"verified_link_survey_email_subject": "Sua pesquisa está pronta para ser preenchida."
|
||||
"verified_link_survey_email_subject": "Sua pesquisa está pronta para ser preenchida.",
|
||||
"weekly_summary_create_reminder_notification_body_cal_slot": "Escolha um horário de 15 minutos na agenda do nosso CEO",
|
||||
"weekly_summary_create_reminder_notification_body_dont_let_a_week_pass": "Não deixe uma semana passar sem aprender sobre seus usuários:",
|
||||
"weekly_summary_create_reminder_notification_body_need_help": "Precisa de ajuda pra encontrar a pesquisa certa pro seu produto?",
|
||||
"weekly_summary_create_reminder_notification_body_reply_email": "ou responde a esse e-mail :)",
|
||||
"weekly_summary_create_reminder_notification_body_setup_a_new_survey": "Configurar uma nova pesquisa",
|
||||
"weekly_summary_create_reminder_notification_body_text": "Adoraríamos te enviar um Resumo Semanal, mas no momento não há pesquisas em andamento para {projectName}.",
|
||||
"weekly_summary_email_subject": "Insights de usuários do {projectName} – Semana passada por Formbricks"
|
||||
},
|
||||
"environments": {
|
||||
"actions": {
|
||||
@@ -1086,8 +1120,10 @@
|
||||
"need_slack_or_discord_notifications": "Preciso de notificações no Slack ou Discord",
|
||||
"notification_settings_updated": "Configurações de notificação atualizadas",
|
||||
"set_up_an_alert_to_get_an_email_on_new_responses": "Configura um alerta pra receber um e-mail com novas respostas",
|
||||
"stay_up_to_date_with_a_Weekly_every_Monday": "Fique por dentro com um resumo semanal toda segunda-feira",
|
||||
"use_the_integration": "Use a integração",
|
||||
"want_to_loop_in_organization_mates": "Quero incluir os colegas da organização",
|
||||
"weekly_summary_projects": "Resumo semanal (Projetos)",
|
||||
"you_will_not_be_auto_subscribed_to_this_organizations_surveys_anymore": "Você não vai ser mais inscrito automaticamente nas pesquisas dessa organização!",
|
||||
"you_will_not_receive_any_more_emails_for_responses_on_this_survey": "Você não vai receber mais e-mails sobre respostas dessa pesquisa!"
|
||||
},
|
||||
@@ -1246,6 +1282,8 @@
|
||||
"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",
|
||||
@@ -1330,6 +1368,7 @@
|
||||
"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",
|
||||
@@ -1405,6 +1444,7 @@
|
||||
"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",
|
||||
@@ -1442,6 +1482,7 @@
|
||||
"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",
|
||||
@@ -1533,6 +1574,8 @@
|
||||
"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",
|
||||
@@ -1549,6 +1592,8 @@
|
||||
"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",
|
||||
@@ -1579,6 +1624,7 @@
|
||||
"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",
|
||||
@@ -1602,6 +1648,8 @@
|
||||
"zip": "Fecho éclair"
|
||||
},
|
||||
"error_deleting_survey": "Ocorreu um erro ao deletar a pesquisa",
|
||||
"failed_to_copy_link_to_results": "Falha ao copiar link dos resultados",
|
||||
"failed_to_copy_url": "Falha ao copiar URL: não está em um ambiente de navegador.",
|
||||
"new_survey": "Nova Pesquisa",
|
||||
"no_surveys_created_yet": "Ainda não foram criadas pesquisas",
|
||||
"open_options": "Abre opções",
|
||||
@@ -1642,6 +1690,7 @@
|
||||
"this_response_is_in_progress": "Essa resposta está em andamento.",
|
||||
"zip_post_code": "CEP / Código postal"
|
||||
},
|
||||
"results_unpublished_successfully": "Resultados não publicados com sucesso.",
|
||||
"search_by_survey_name": "Buscar pelo nome da pesquisa",
|
||||
"share": {
|
||||
"anonymous_links": {
|
||||
@@ -1740,8 +1789,8 @@
|
||||
"configure_alerts": "Configurar alertas",
|
||||
"congrats": "Parabéns! Sua pesquisa está no ar.",
|
||||
"connect_your_website_or_app_with_formbricks_to_get_started": "Conecte seu site ou app com o Formbricks para começar.",
|
||||
"copy_link_to_public_results": "Copiar link para resultados públicos",
|
||||
"custom_range": "Intervalo personalizado...",
|
||||
"delete_all_existing_responses_and_displays": "Excluir todas as respostas e exibições existentes",
|
||||
"download_qr_code": "baixar código QR",
|
||||
"drop_offs": "Pontos de Entrega",
|
||||
"drop_offs_tooltip": "Número de vezes que a pesquisa foi iniciada mas não concluída.",
|
||||
@@ -1793,33 +1842,40 @@
|
||||
"last_month": "Último mês",
|
||||
"last_quarter": "Último trimestre",
|
||||
"last_year": "Último ano",
|
||||
"link_to_public_results_copied": "Link pros resultados públicos copiado",
|
||||
"no_responses_found": "Nenhuma resposta encontrada",
|
||||
"only_completed": "Somente concluído",
|
||||
"other_values_found": "Outros valores encontrados",
|
||||
"overall": "No geral",
|
||||
"publish_to_web": "Publicar na web",
|
||||
"publish_to_web_warning": "Você está prestes a divulgar esses resultados da pesquisa para o público.",
|
||||
"publish_to_web_warning_description": "Os resultados da sua pesquisa serão públicos. Qualquer pessoa fora da sua organização pode acessá-los se tiver o link.",
|
||||
"qr_code": "Código QR",
|
||||
"qr_code_description": "Respostas coletadas via código QR são anônimas.",
|
||||
"qr_code_download_failed": "falha no download do código QR",
|
||||
"qr_code_download_with_start_soon": "O download do código QR começará em breve",
|
||||
"qr_code_generation_failed": "Houve um problema ao carregar o Código QR do questionário. Por favor, tente novamente.",
|
||||
"reset_survey": "Redefinir pesquisa",
|
||||
"reset_survey_warning": "Redefinir uma pesquisa remove todas as respostas e exibições associadas a esta pesquisa. Isto não pode ser desfeito.",
|
||||
"results_are_public": "Os resultados são públicos",
|
||||
"selected_responses_csv": "Respostas selecionadas (CSV)",
|
||||
"selected_responses_excel": "Respostas selecionadas (Excel)",
|
||||
"setup_integrations": "Configurar integrações",
|
||||
"share_results": "Compartilhar resultados",
|
||||
"share_survey": "Compartilhar pesquisa",
|
||||
"show_all_responses_that_match": "Mostrar todas as respostas que correspondem",
|
||||
"show_all_responses_where": "Mostre todas as respostas onde...",
|
||||
"starts": "começa",
|
||||
"starts_tooltip": "Número de vezes que a pesquisa foi iniciada.",
|
||||
"survey_reset_successfully": "Pesquisa redefinida com sucesso! {responseCount} respostas e {displayCount} exibições foram deletadas.",
|
||||
"survey_results_are_public": "Os resultados da sua pesquisa são públicos!",
|
||||
"survey_results_are_shared_with_anyone_who_has_the_link": "Os resultados da sua pesquisa são compartilhados com quem tiver o link. Os resultados não serão indexados por motores de busca.",
|
||||
"this_month": "Este mês",
|
||||
"this_quarter": "Este trimestre",
|
||||
"this_year": "Este ano",
|
||||
"time_to_complete": "Tempo para Concluir",
|
||||
"ttc_tooltip": "Tempo médio para completar a pesquisa.",
|
||||
"unknown_question_type": "Tipo de pergunta desconhecido",
|
||||
"unpublish_from_web": "Despublicar da web",
|
||||
"use_personal_links": "Use links pessoais",
|
||||
"view_site": "Ver site",
|
||||
"waiting_for_response": "Aguardando uma resposta \uD83E\uDDD8♂️",
|
||||
"whats_next": "E agora?",
|
||||
"your_survey_is_public": "Sua pesquisa é pública",
|
||||
@@ -1950,6 +2006,11 @@
|
||||
"this_user_has_all_the_power": "Esse usuário tem todo o poder."
|
||||
}
|
||||
},
|
||||
"share": {
|
||||
"back_to_home": "Voltar pra casa",
|
||||
"page_not_found": "Página não encontrada",
|
||||
"page_not_found_description": "Desculpa, não conseguimos encontrar as respostas com o ID que você está procurando."
|
||||
},
|
||||
"templates": {
|
||||
"address": "endereço",
|
||||
"address_description": "Pede um endereço pra correspondência",
|
||||
|
||||
@@ -199,6 +199,8 @@
|
||||
"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",
|
||||
@@ -236,6 +238,7 @@
|
||||
"limits_reached": "Limites Atingidos",
|
||||
"link": "Link",
|
||||
"link_and_email": "Link e Email",
|
||||
"link_copied": "Link copiado para a área de transferência!",
|
||||
"link_survey": "Ligar Inquérito",
|
||||
"link_surveys": "Ligar Inquéritos",
|
||||
"load_more": "Carregar mais",
|
||||
@@ -302,6 +305,7 @@
|
||||
"privacy": "Política de Privacidade",
|
||||
"product_manager": "Gestor de Produto",
|
||||
"profile": "Perfil",
|
||||
"project": "Projeto",
|
||||
"project_configuration": "Configuração do Projeto",
|
||||
"project_id": "ID do Projeto",
|
||||
"project_name": "Nome do Projeto",
|
||||
@@ -411,6 +415,7 @@
|
||||
"website_and_app_connection": "Ligação de Website e Aplicação",
|
||||
"website_app_survey": "Inquérito do Website e da Aplicação",
|
||||
"website_survey": "Inquérito do Website",
|
||||
"weekly_summary": "Resumo semanal",
|
||||
"welcome_card": "Cartão de boas-vindas",
|
||||
"you": "Você",
|
||||
"you_are_downgraded_to_the_community_edition": "Foi rebaixado para a Edição Comunitária.",
|
||||
@@ -451,7 +456,29 @@
|
||||
"invite_email_text_par1": "O seu colega",
|
||||
"invite_email_text_par2": "convidou-o a juntar-se a eles no Formbricks. Para aceitar o convite, por favor clique no link abaixo:",
|
||||
"invite_member_email_subject": "Está convidado a colaborar no Formbricks!",
|
||||
"live_survey_notification_completed": "Concluído",
|
||||
"live_survey_notification_draft": "Rascunho",
|
||||
"live_survey_notification_in_progress": "Em Progresso",
|
||||
"live_survey_notification_no_new_response": "Nenhuma nova resposta recebida esta semana \uD83D\uDD75️",
|
||||
"live_survey_notification_no_responses_yet": "Ainda sem respostas!",
|
||||
"live_survey_notification_paused": "Pausado",
|
||||
"live_survey_notification_scheduled": "Agendado",
|
||||
"live_survey_notification_view_more_responses": "Ver mais {responseCount} respostas",
|
||||
"live_survey_notification_view_previous_responses": "Ver respostas anteriores",
|
||||
"live_survey_notification_view_response": "Ver Resposta",
|
||||
"new_email_verification_text": "Para verificar o seu novo endereço de email, por favor clique no botão abaixo:",
|
||||
"notification_footer_all_the_best": "Tudo de bom,",
|
||||
"notification_footer_in_your_settings": "nas suas definições \uD83D\uDE4F",
|
||||
"notification_footer_please_turn_them_off": "por favor, desative-os",
|
||||
"notification_footer_the_formbricks_team": "A Equipa Formbricks \uD83E\uDD0D",
|
||||
"notification_footer_to_halt_weekly_updates": "Para parar as Atualizações Semanais,",
|
||||
"notification_header_hey": "Olá \uD83D\uDC4B",
|
||||
"notification_header_weekly_report_for": "Relatório Semanal para",
|
||||
"notification_insight_completed": "Concluído",
|
||||
"notification_insight_completion_rate": "Conclusão %",
|
||||
"notification_insight_displays": "Ecrãs",
|
||||
"notification_insight_responses": "Respostas",
|
||||
"notification_insight_surveys": "Inquéritos",
|
||||
"password_changed_email_heading": "Palavra-passe alterada",
|
||||
"password_changed_email_text": "A sua palavra-passe foi alterada com sucesso.",
|
||||
"password_reset_notify_email_subject": "A sua palavra-passe do Formbricks foi alterada",
|
||||
@@ -484,7 +511,14 @@
|
||||
"verification_email_verify_email": "Verificar email",
|
||||
"verification_new_email_subject": "Verificação de alteração de email",
|
||||
"verification_security_notice": "Se não solicitou esta alteração de email, ignore este email ou contacte o suporte imediatamente.",
|
||||
"verified_link_survey_email_subject": "O seu inquérito está pronto para ser preenchido."
|
||||
"verified_link_survey_email_subject": "O seu inquérito está pronto para ser preenchido.",
|
||||
"weekly_summary_create_reminder_notification_body_cal_slot": "Escolha um intervalo de 15 minutos no calendário do nosso CEO",
|
||||
"weekly_summary_create_reminder_notification_body_dont_let_a_week_pass": "Não deixe passar uma semana sem aprender sobre os seus utilizadores:",
|
||||
"weekly_summary_create_reminder_notification_body_need_help": "Precisa de ajuda para encontrar o inquérito certo para o seu produto?",
|
||||
"weekly_summary_create_reminder_notification_body_reply_email": "ou responda a este email :)",
|
||||
"weekly_summary_create_reminder_notification_body_setup_a_new_survey": "Configurar um novo inquérito",
|
||||
"weekly_summary_create_reminder_notification_body_text": "Gostaríamos de lhe enviar um Resumo Semanal, mas de momento não há inquéritos a decorrer para {projectName}.",
|
||||
"weekly_summary_email_subject": "{projectName} Informações do Utilizador - Última Semana por Formbricks"
|
||||
},
|
||||
"environments": {
|
||||
"actions": {
|
||||
@@ -1086,8 +1120,10 @@
|
||||
"need_slack_or_discord_notifications": "Precisa de notificações do Slack ou Discord",
|
||||
"notification_settings_updated": "Definições de notificações atualizadas",
|
||||
"set_up_an_alert_to_get_an_email_on_new_responses": "Configurar um alerta para receber um e-mail sobre novas respostas",
|
||||
"stay_up_to_date_with_a_Weekly_every_Monday": "Mantenha-se atualizado com um Resumo semanal todas as segundas-feiras",
|
||||
"use_the_integration": "Use a integração",
|
||||
"want_to_loop_in_organization_mates": "Quer incluir colegas da organização",
|
||||
"weekly_summary_projects": "Resumo semanal (Projetos)",
|
||||
"you_will_not_be_auto_subscribed_to_this_organizations_surveys_anymore": "Já não será automaticamente subscrito aos inquéritos desta organização!",
|
||||
"you_will_not_receive_any_more_emails_for_responses_on_this_survey": "Não receberá mais emails para respostas a este inquérito!"
|
||||
},
|
||||
@@ -1246,6 +1282,8 @@
|
||||
"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",
|
||||
@@ -1330,6 +1368,7 @@
|
||||
"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",
|
||||
@@ -1405,6 +1444,7 @@
|
||||
"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",
|
||||
@@ -1442,6 +1482,7 @@
|
||||
"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",
|
||||
@@ -1533,6 +1574,8 @@
|
||||
"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",
|
||||
@@ -1549,6 +1592,8 @@
|
||||
"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",
|
||||
@@ -1579,6 +1624,7 @@
|
||||
"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",
|
||||
@@ -1602,6 +1648,8 @@
|
||||
"zip": "Comprimir"
|
||||
},
|
||||
"error_deleting_survey": "Ocorreu um erro ao eliminar o questionário",
|
||||
"failed_to_copy_link_to_results": "Falha ao copiar link para resultados",
|
||||
"failed_to_copy_url": "Falha ao copiar URL: não está num ambiente de navegador.",
|
||||
"new_survey": "Novo inquérito",
|
||||
"no_surveys_created_yet": "Ainda não foram criados questionários",
|
||||
"open_options": "Abrir opções",
|
||||
@@ -1642,6 +1690,7 @@
|
||||
"this_response_is_in_progress": "Esta resposta está em progresso.",
|
||||
"zip_post_code": "Código Postal"
|
||||
},
|
||||
"results_unpublished_successfully": "Resultados despublicados com sucesso.",
|
||||
"search_by_survey_name": "Pesquisar por nome do inquérito",
|
||||
"share": {
|
||||
"anonymous_links": {
|
||||
@@ -1740,8 +1789,8 @@
|
||||
"configure_alerts": "Configurar alertas",
|
||||
"congrats": "Parabéns! O seu inquérito está ativo.",
|
||||
"connect_your_website_or_app_with_formbricks_to_get_started": "Ligue o seu website ou aplicação ao Formbricks para começar.",
|
||||
"copy_link_to_public_results": "Copiar link para resultados públicos",
|
||||
"custom_range": "Intervalo personalizado...",
|
||||
"delete_all_existing_responses_and_displays": "Excluir todas as respostas existentes e exibições",
|
||||
"download_qr_code": "Transferir código QR",
|
||||
"drop_offs": "Desistências",
|
||||
"drop_offs_tooltip": "Número de vezes que o inquérito foi iniciado mas não concluído.",
|
||||
@@ -1793,33 +1842,40 @@
|
||||
"last_month": "Último mês",
|
||||
"last_quarter": "Último trimestre",
|
||||
"last_year": "Ano passado",
|
||||
"link_to_public_results_copied": "Link para resultados públicos copiado",
|
||||
"no_responses_found": "Nenhuma resposta encontrada",
|
||||
"only_completed": "Apenas concluído",
|
||||
"other_values_found": "Outros valores encontrados",
|
||||
"overall": "Geral",
|
||||
"publish_to_web": "Publicar na web",
|
||||
"publish_to_web_warning": "Está prestes a divulgar estes resultados do inquérito ao público.",
|
||||
"publish_to_web_warning_description": "Os resultados do seu inquérito serão públicos. Qualquer pessoa fora da sua organização pode aceder a eles se tiver o link.",
|
||||
"qr_code": "Código QR",
|
||||
"qr_code_description": "Respostas recolhidas através de código QR são anónimas.",
|
||||
"qr_code_download_failed": "Falha ao transferir o código QR",
|
||||
"qr_code_download_with_start_soon": "O download do código QR começará em breve",
|
||||
"qr_code_generation_failed": "Ocorreu um problema ao carregar o Código QR do questionário. Por favor, tente novamente.",
|
||||
"reset_survey": "Reiniciar inquérito",
|
||||
"reset_survey_warning": "Repor um inquérito remove todas as respostas e visualizações associadas a este inquérito. Isto não pode ser desfeito.",
|
||||
"results_are_public": "Os resultados são públicos",
|
||||
"selected_responses_csv": "Respostas selecionadas (CSV)",
|
||||
"selected_responses_excel": "Respostas selecionadas (Excel)",
|
||||
"setup_integrations": "Configurar integrações",
|
||||
"share_results": "Partilhar resultados",
|
||||
"share_survey": "Partilhar inquérito",
|
||||
"show_all_responses_that_match": "Mostrar todas as respostas que correspondem",
|
||||
"show_all_responses_where": "Mostrar todas as respostas onde...",
|
||||
"starts": "Começa",
|
||||
"starts_tooltip": "Número de vezes que o inquérito foi iniciado.",
|
||||
"survey_reset_successfully": "Inquérito reiniciado com sucesso! {responseCount} respostas e {displayCount} exibições foram eliminadas.",
|
||||
"survey_results_are_public": "Os resultados do seu inquérito são públicos!",
|
||||
"survey_results_are_shared_with_anyone_who_has_the_link": "Os resultados do seu inquérito são partilhados com qualquer pessoa que tenha o link. Os resultados não serão indexados pelos motores de busca.",
|
||||
"this_month": "Este mês",
|
||||
"this_quarter": "Este trimestre",
|
||||
"this_year": "Este ano",
|
||||
"time_to_complete": "Tempo para Concluir",
|
||||
"ttc_tooltip": "Tempo médio para concluir o inquérito.",
|
||||
"unknown_question_type": "Tipo de Pergunta Desconhecido",
|
||||
"unpublish_from_web": "Despublicar da web",
|
||||
"use_personal_links": "Utilize links pessoais",
|
||||
"view_site": "Ver site",
|
||||
"waiting_for_response": "A aguardar uma resposta \uD83E\uDDD8♂️",
|
||||
"whats_next": "O que se segue?",
|
||||
"your_survey_is_public": "O seu inquérito é público",
|
||||
@@ -1950,6 +2006,11 @@
|
||||
"this_user_has_all_the_power": "Este utilizador tem todo o poder."
|
||||
}
|
||||
},
|
||||
"share": {
|
||||
"back_to_home": "Voltar para casa",
|
||||
"page_not_found": "Página não encontrada",
|
||||
"page_not_found_description": "Desculpe, não conseguimos encontrar o ID de partilha de respostas que está a procurar."
|
||||
},
|
||||
"templates": {
|
||||
"address": "Endereço",
|
||||
"address_description": "Pedir um endereço de correspondência",
|
||||
|
||||
@@ -199,6 +199,8 @@
|
||||
"environment_not_found": "找不到環境",
|
||||
"environment_notice": "您目前在 '{'environment'}' 環境中。",
|
||||
"error": "錯誤",
|
||||
"error_component_description": "此資源不存在或您沒有存取權限。",
|
||||
"error_component_title": "載入資源錯誤",
|
||||
"expand_rows": "展開列",
|
||||
"finish": "完成",
|
||||
"follow_these": "按照這些步驟",
|
||||
@@ -236,6 +238,7 @@
|
||||
"limits_reached": "已達上限",
|
||||
"link": "連結",
|
||||
"link_and_email": "連結與電子郵件",
|
||||
"link_copied": "連結已複製到剪貼簿!",
|
||||
"link_survey": "連結問卷",
|
||||
"link_surveys": "連結問卷",
|
||||
"load_more": "載入更多",
|
||||
@@ -302,6 +305,7 @@
|
||||
"privacy": "隱私權政策",
|
||||
"product_manager": "產品經理",
|
||||
"profile": "個人資料",
|
||||
"project": "專案",
|
||||
"project_configuration": "專案組態",
|
||||
"project_id": "專案 ID",
|
||||
"project_name": "專案名稱",
|
||||
@@ -411,6 +415,7 @@
|
||||
"website_and_app_connection": "網站與應用程式連線",
|
||||
"website_app_survey": "網站與應用程式問卷",
|
||||
"website_survey": "網站問卷",
|
||||
"weekly_summary": "每週摘要",
|
||||
"welcome_card": "歡迎卡片",
|
||||
"you": "您",
|
||||
"you_are_downgraded_to_the_community_edition": "您已降級至社群版。",
|
||||
@@ -451,7 +456,29 @@
|
||||
"invite_email_text_par1": "您的同事",
|
||||
"invite_email_text_par2": "邀請您加入 Formbricks。若要接受邀請,請點擊以下連結:",
|
||||
"invite_member_email_subject": "您被邀請協作 Formbricks!",
|
||||
"live_survey_notification_completed": "已完成",
|
||||
"live_survey_notification_draft": "草稿",
|
||||
"live_survey_notification_in_progress": "進行中",
|
||||
"live_survey_notification_no_new_response": "本週沒有收到新的回應 \uD83D\uDD75️",
|
||||
"live_survey_notification_no_responses_yet": "尚無回應!",
|
||||
"live_survey_notification_paused": "已暫停",
|
||||
"live_survey_notification_scheduled": "已排程",
|
||||
"live_survey_notification_view_more_responses": "檢視另外 '{'responseCount'}' 個回應",
|
||||
"live_survey_notification_view_previous_responses": "檢視先前的回應",
|
||||
"live_survey_notification_view_response": "檢視回應",
|
||||
"new_email_verification_text": "要驗證您的新電子郵件地址,請點擊下面的按鈕:",
|
||||
"notification_footer_all_the_best": "祝您一切順利,",
|
||||
"notification_footer_in_your_settings": "在您的設定中 \uD83D\uDE4F",
|
||||
"notification_footer_please_turn_them_off": "請關閉它們",
|
||||
"notification_footer_the_formbricks_team": "Formbricks 團隊 \uD83E\uDD0D",
|
||||
"notification_footer_to_halt_weekly_updates": "若要停止每週更新,",
|
||||
"notification_header_hey": "嗨 \uD83D\uDC4B",
|
||||
"notification_header_weekly_report_for": "每週報告,適用於",
|
||||
"notification_insight_completed": "已完成",
|
||||
"notification_insight_completion_rate": "完成率 %",
|
||||
"notification_insight_displays": "顯示次數",
|
||||
"notification_insight_responses": "回應數",
|
||||
"notification_insight_surveys": "問卷數",
|
||||
"password_changed_email_heading": "密碼已變更",
|
||||
"password_changed_email_text": "您的密碼已成功變更。",
|
||||
"password_reset_notify_email_subject": "您的 Formbricks 密碼已變更",
|
||||
@@ -484,7 +511,14 @@
|
||||
"verification_email_verify_email": "驗證電子郵件",
|
||||
"verification_new_email_subject": "電子郵件更改驗證",
|
||||
"verification_security_notice": "如果您沒有要求更改此電子郵件,請忽略此電子郵件或立即聯繫支援。",
|
||||
"verified_link_survey_email_subject": "您的 survey 已準備好填寫。"
|
||||
"verified_link_survey_email_subject": "您的 survey 已準備好填寫。",
|
||||
"weekly_summary_create_reminder_notification_body_cal_slot": "在我們 CEO 的日曆中選擇一個 15 分鐘的時段",
|
||||
"weekly_summary_create_reminder_notification_body_dont_let_a_week_pass": "不要讓一週過去而沒有了解您的使用者:",
|
||||
"weekly_summary_create_reminder_notification_body_need_help": "需要協助找到適合您產品的問卷嗎?",
|
||||
"weekly_summary_create_reminder_notification_body_reply_email": "或回覆此電子郵件 :)",
|
||||
"weekly_summary_create_reminder_notification_body_setup_a_new_survey": "設定新的問卷",
|
||||
"weekly_summary_create_reminder_notification_body_text": "我們很樂意向您發送每週摘要,但目前 '{'projectName'}' 沒有正在執行的問卷。",
|
||||
"weekly_summary_email_subject": "{projectName} 用戶洞察 - 上週 by Formbricks"
|
||||
},
|
||||
"environments": {
|
||||
"actions": {
|
||||
@@ -1086,8 +1120,10 @@
|
||||
"need_slack_or_discord_notifications": "需要 Slack 或 Discord 通知嗎?",
|
||||
"notification_settings_updated": "通知設定已更新",
|
||||
"set_up_an_alert_to_get_an_email_on_new_responses": "設定警示以在收到新回應時收到電子郵件",
|
||||
"stay_up_to_date_with_a_Weekly_every_Monday": "每週一使用每週摘要保持最新資訊",
|
||||
"use_the_integration": "使用整合",
|
||||
"want_to_loop_in_organization_mates": "想要讓組織夥伴也參與嗎?",
|
||||
"weekly_summary_projects": "每週摘要(專案)",
|
||||
"you_will_not_be_auto_subscribed_to_this_organizations_surveys_anymore": "您將不會再自動訂閱此組織的問卷!",
|
||||
"you_will_not_receive_any_more_emails_for_responses_on_this_survey": "您將不會再收到此問卷回應的電子郵件!"
|
||||
},
|
||||
@@ -1246,6 +1282,8 @@
|
||||
"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": "按鈕標籤",
|
||||
@@ -1330,6 +1368,7 @@
|
||||
"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": "垃圾郵件保護",
|
||||
@@ -1405,6 +1444,7 @@
|
||||
"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": "忽略問卷之間的等待時間",
|
||||
@@ -1442,6 +1482,7 @@
|
||||
"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": "變更問題類型將會從此問題中移除邏輯條件",
|
||||
@@ -1533,6 +1574,8 @@
|
||||
"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": "表情符號",
|
||||
@@ -1549,6 +1592,8 @@
|
||||
"subheading": "副標題",
|
||||
"subtract": "減 -",
|
||||
"suggest_colors": "建議顏色",
|
||||
"survey_already_answered_heading": "問卷已回答。",
|
||||
"survey_already_answered_subheading": "您只能使用此連結一次。",
|
||||
"survey_completed_heading": "問卷已完成",
|
||||
"survey_completed_subheading": "此免費且開源的問卷已關閉",
|
||||
"survey_display_settings": "問卷顯示設定",
|
||||
@@ -1579,6 +1624,7 @@
|
||||
"upload": "上傳",
|
||||
"upload_at_least_2_images": "上傳至少 2 張圖片",
|
||||
"upper_label": "上標籤",
|
||||
"url_encryption": "網址加密",
|
||||
"url_filters": "網址篩選器",
|
||||
"url_not_supported": "不支援網址",
|
||||
"use_with_caution": "謹慎使用",
|
||||
@@ -1602,6 +1648,8 @@
|
||||
"zip": "郵遞區號"
|
||||
},
|
||||
"error_deleting_survey": "刪除問卷時發生錯誤",
|
||||
"failed_to_copy_link_to_results": "無法複製結果連結",
|
||||
"failed_to_copy_url": "無法複製網址:不在瀏覽器環境中。",
|
||||
"new_survey": "新增問卷",
|
||||
"no_surveys_created_yet": "尚未建立任何問卷",
|
||||
"open_options": "開啟選項",
|
||||
@@ -1642,6 +1690,7 @@
|
||||
"this_response_is_in_progress": "此回應正在進行中。",
|
||||
"zip_post_code": "郵遞區號"
|
||||
},
|
||||
"results_unpublished_successfully": "結果已成功取消發布。",
|
||||
"search_by_survey_name": "依問卷名稱搜尋",
|
||||
"share": {
|
||||
"anonymous_links": {
|
||||
@@ -1740,8 +1789,8 @@
|
||||
"configure_alerts": "設定警示",
|
||||
"congrats": "恭喜!您的問卷已上線。",
|
||||
"connect_your_website_or_app_with_formbricks_to_get_started": "將您的網站或應用程式與 Formbricks 連線以開始使用。",
|
||||
"copy_link_to_public_results": "複製公開結果的連結",
|
||||
"custom_range": "自訂範圍...",
|
||||
"delete_all_existing_responses_and_displays": "刪除 所有 現有 回應 和 顯示",
|
||||
"download_qr_code": "下載 QR code",
|
||||
"drop_offs": "放棄",
|
||||
"drop_offs_tooltip": "問卷已開始但未完成的次數。",
|
||||
@@ -1793,33 +1842,40 @@
|
||||
"last_month": "上個月",
|
||||
"last_quarter": "上一季",
|
||||
"last_year": "去年",
|
||||
"link_to_public_results_copied": "已複製公開結果的連結",
|
||||
"no_responses_found": "找不到回應",
|
||||
"only_completed": "僅已完成",
|
||||
"other_values_found": "找到其他值",
|
||||
"overall": "整體",
|
||||
"publish_to_web": "發布至網站",
|
||||
"publish_to_web_warning": "您即將將這些問卷結果發布到公共領域。",
|
||||
"publish_to_web_warning_description": "您的問卷結果將會是公開的。任何組織外的人員都可以存取這些結果(如果他們有連結)。",
|
||||
"qr_code": "QR 碼",
|
||||
"qr_code_description": "透過 QR code 收集的回應都是匿名的。",
|
||||
"qr_code_download_failed": "QR code 下載失敗",
|
||||
"qr_code_download_with_start_soon": "QR code 下載即將開始",
|
||||
"qr_code_generation_failed": "載入調查 QR Code 時發生問題。請再試一次。",
|
||||
"reset_survey": "重設問卷",
|
||||
"reset_survey_warning": "重置 調查 會 移除 與 此 調查 相關 的 所有 回應 和 顯示 。 這 是 不可 撤銷 的 。",
|
||||
"results_are_public": "結果是公開的",
|
||||
"selected_responses_csv": "選擇的回應 (CSV)",
|
||||
"selected_responses_excel": "選擇的回應 (Excel)",
|
||||
"setup_integrations": "設定整合",
|
||||
"share_results": "分享結果",
|
||||
"share_survey": "分享問卷",
|
||||
"show_all_responses_that_match": "顯示所有相符的回應",
|
||||
"show_all_responses_where": "顯示所有回應,其中...",
|
||||
"starts": "開始次數",
|
||||
"starts_tooltip": "問卷已開始的次數。",
|
||||
"survey_reset_successfully": "調查 重置 成功!{responseCount} 條回應和 {displayCount} 個顯示被刪除。",
|
||||
"survey_results_are_public": "您的問卷結果是公開的!",
|
||||
"survey_results_are_shared_with_anyone_who_has_the_link": "您的問卷結果與任何擁有連結的人員分享。這些結果將不會被搜尋引擎編入索引。",
|
||||
"this_month": "本月",
|
||||
"this_quarter": "本季",
|
||||
"this_year": "今年",
|
||||
"time_to_complete": "完成時間",
|
||||
"ttc_tooltip": "完成問卷的平均時間。",
|
||||
"unknown_question_type": "未知的問題類型",
|
||||
"unpublish_from_web": "從網站取消發布",
|
||||
"use_personal_links": "使用 個人 連結",
|
||||
"view_site": "檢視網站",
|
||||
"waiting_for_response": "正在等待回應 \uD83E\uDDD8♂️",
|
||||
"whats_next": "下一步是什麼?",
|
||||
"your_survey_is_public": "您的問卷是公開的",
|
||||
@@ -1950,6 +2006,11 @@
|
||||
"this_user_has_all_the_power": "此使用者擁有所有權限。"
|
||||
}
|
||||
},
|
||||
"share": {
|
||||
"back_to_home": "返回首頁",
|
||||
"page_not_found": "找不到頁面",
|
||||
"page_not_found_description": "抱歉,我們找不到您要尋找的回應分享 ID。"
|
||||
},
|
||||
"templates": {
|
||||
"address": "地址",
|
||||
"address_description": "要求郵寄地址",
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
import { clientSideApiEndpointsLimiter, syncUserIdentificationLimiter } from "@/app/middleware/bucket";
|
||||
import {
|
||||
clientSideApiEndpointsLimiter,
|
||||
forgotPasswordLimiter,
|
||||
loginLimiter,
|
||||
shareUrlLimiter,
|
||||
signupLimiter,
|
||||
syncUserIdentificationLimiter,
|
||||
verifyEmailLimiter,
|
||||
} 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";
|
||||
@@ -38,13 +51,23 @@ const handleAuth = async (request: NextRequest): Promise<Response | null> => {
|
||||
};
|
||||
|
||||
const applyRateLimiting = async (request: NextRequest, ip: string) => {
|
||||
if (isClientSideApiRoute(request.nextUrl.pathname)) {
|
||||
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)) {
|
||||
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}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -111,7 +134,7 @@ export const middleware = async (originalRequest: NextRequest) => {
|
||||
await applyRateLimiting(request, ip);
|
||||
return nextResponseWithCustomHeader;
|
||||
} catch (e) {
|
||||
logger.error(e, "Error applying rate limiting");
|
||||
// NOSONAR - This is a catch all for rate limiting errors
|
||||
const apiError: ApiErrorResponseV2 = {
|
||||
type: "too_many_requests",
|
||||
details: [{ field: "", issue: "Too many requests. Please try again later." }],
|
||||
|
||||
@@ -1,37 +1,25 @@
|
||||
import { cn } from "@/lib/cn";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
|
||||
interface SurveyLinkDisplayProps {
|
||||
surveyUrl: string;
|
||||
enforceSurveyUrlWidth?: boolean;
|
||||
}
|
||||
|
||||
export const SurveyLinkDisplay = ({ surveyUrl, enforceSurveyUrlWidth = false }: SurveyLinkDisplayProps) => {
|
||||
export const SurveyLinkDisplay = ({ surveyUrl }: SurveyLinkDisplayProps) => {
|
||||
return (
|
||||
<>
|
||||
{surveyUrl ? (
|
||||
<Input
|
||||
data-testid="survey-url-input"
|
||||
className={cn(
|
||||
"h-9 w-full text-ellipsis rounded-lg border bg-white px-3 py-1 text-slate-800 caret-transparent",
|
||||
{
|
||||
"min-w-96": enforceSurveyUrlWidth,
|
||||
}
|
||||
)}
|
||||
autoFocus={true}
|
||||
className="h-9 w-full text-ellipsis rounded-lg border bg-white px-3 py-1 text-slate-800 caret-transparent"
|
||||
value={surveyUrl}
|
||||
readOnly
|
||||
aria-label="Survey URL"
|
||||
/>
|
||||
) : (
|
||||
//loading state
|
||||
<div
|
||||
data-testid="loading-div"
|
||||
className={cn(
|
||||
"h-9 w-full animate-pulse rounded-lg bg-slate-100 px-3 py-1 text-slate-800 caret-transparent",
|
||||
{
|
||||
"min-w-96": enforceSurveyUrlWidth,
|
||||
}
|
||||
)}
|
||||
className="h-9 w-full min-w-96 animate-pulse rounded-lg bg-slate-100 px-3 py-1 text-slate-800 caret-transparent"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -4,7 +4,6 @@ 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: {
|
||||
@@ -12,24 +11,6 @@ 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",
|
||||
@@ -180,7 +161,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)", async () => {
|
||||
test("opens the preview link in a new tab when preview button is clicked (no query params)", () => {
|
||||
render(
|
||||
<ShareSurveyLink
|
||||
survey={survey}
|
||||
@@ -194,13 +175,10 @@ 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)", async () => {
|
||||
test("opens the preview link in a new tab when preview button is clicked (with query params)", () => {
|
||||
const surveyWithParamsUrl = `${publicDomain}/s/survey-id?foo=bar`;
|
||||
render(
|
||||
<ShareSurveyLink
|
||||
@@ -215,9 +193,6 @@ 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");
|
||||
});
|
||||
|
||||
@@ -240,9 +215,7 @@ describe("ShareSurveyLink", () => {
|
||||
});
|
||||
|
||||
test("updates the survey URL when the language is changed", () => {
|
||||
const mockGetSurveyUrl = vi.mocked(getSurveyUrl);
|
||||
|
||||
render(
|
||||
const { rerender } = render(
|
||||
<ShareSurveyLink
|
||||
survey={survey}
|
||||
publicDomain={publicDomain}
|
||||
@@ -258,7 +231,16 @@ describe("ShareSurveyLink", () => {
|
||||
const germanOption = screen.getByText("German");
|
||||
fireEvent.click(germanOption);
|
||||
|
||||
expect(mockGetSurveyUrl).toHaveBeenCalledWith(survey, publicDomain, "de");
|
||||
expect(setSurveyUrl).toHaveBeenCalledWith(`${publicDomain}/s/${survey.id}?lang=de`);
|
||||
rerender(
|
||||
<ShareSurveyLink
|
||||
survey={survey}
|
||||
publicDomain={publicDomain}
|
||||
surveyUrl={surveyUrl}
|
||||
setSurveyUrl={setSurveyUrl}
|
||||
locale={locale}
|
||||
/>
|
||||
);
|
||||
expect(setSurveyUrl).toHaveBeenCalled();
|
||||
expect(surveyUrl).toContain("lang=de");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"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";
|
||||
@@ -17,7 +16,6 @@ interface ShareSurveyLinkProps {
|
||||
surveyUrl: string;
|
||||
setSurveyUrl: (url: string) => void;
|
||||
locale: TUserLocale;
|
||||
enforceSurveyUrlWidth?: boolean;
|
||||
}
|
||||
|
||||
export const ShareSurveyLink = ({
|
||||
@@ -26,7 +24,6 @@ export const ShareSurveyLink = ({
|
||||
publicDomain,
|
||||
setSurveyUrl,
|
||||
locale,
|
||||
enforceSurveyUrlWidth = false,
|
||||
}: ShareSurveyLinkProps) => {
|
||||
const { t } = useTranslate();
|
||||
|
||||
@@ -35,29 +32,9 @@ 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 items-center justify-center gap-2"}>
|
||||
<SurveyLinkDisplay
|
||||
surveyUrl={surveyUrl}
|
||||
key={surveyUrl}
|
||||
enforceSurveyUrlWidth={enforceSurveyUrlWidth}
|
||||
/>
|
||||
<div className={"flex max-w-full flex-col items-center justify-center gap-2 md:flex-row"}>
|
||||
<SurveyLinkDisplay surveyUrl={surveyUrl} key={surveyUrl} />
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<LanguageDropdown survey={survey} setLanguage={handleLanguageChange} locale={locale} />
|
||||
<Button
|
||||
@@ -76,9 +53,14 @@ 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={async () => {
|
||||
const url = await getPreviewUrl();
|
||||
window.open(url, "_blank");
|
||||
onClick={() => {
|
||||
let previewUrl = surveyUrl;
|
||||
if (previewUrl.includes("?")) {
|
||||
previewUrl += "&preview=true";
|
||||
} else {
|
||||
previewUrl += "?preview=true";
|
||||
}
|
||||
window.open(previewUrl, "_blank");
|
||||
}}>
|
||||
{t("common.preview")}
|
||||
<SquareArrowOutUpRight />
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { ApiAuditLog } from "@/app/lib/api/with-api-logging";
|
||||
import { checkRateLimitAndThrowError } from "@/modules/api/v2/lib/rate-limit";
|
||||
import { formatZodError, handleApiError } from "@/modules/api/v2/lib/utils";
|
||||
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import { ZodRawShape, z } from "zod";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { authenticateRequest } from "./authenticate-request";
|
||||
@@ -105,10 +104,11 @@ export const apiWrapper = async <S extends ExtendedSchemas>({
|
||||
}
|
||||
|
||||
if (rateLimit) {
|
||||
try {
|
||||
await applyRateLimit(rateLimitConfigs.api.v2, authentication.data.hashedApiKey);
|
||||
} catch (error) {
|
||||
return handleApiError(request, { type: "too_many_requests", details: error.message });
|
||||
const rateLimitResponse = await checkRateLimitAndThrowError({
|
||||
identifier: authentication.data.hashedApiKey,
|
||||
});
|
||||
if (!rateLimitResponse.ok) {
|
||||
return handleApiError(request, rateLimitResponse.error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,26 +1,22 @@
|
||||
import { apiWrapper } from "@/modules/api/v2/auth/api-wrapper";
|
||||
import { authenticateRequest } from "@/modules/api/v2/auth/authenticate-request";
|
||||
import { checkRateLimitAndThrowError } from "@/modules/api/v2/lib/rate-limit";
|
||||
import { handleApiError } from "@/modules/api/v2/lib/utils";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { checkRateLimit } from "@/modules/core/rate-limit/rate-limit";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { err, ok } from "@formbricks/types/error-handlers";
|
||||
import { err, ok, okVoid } from "@formbricks/types/error-handlers";
|
||||
|
||||
vi.mock("../authenticate-request", () => ({
|
||||
authenticateRequest: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/core/rate-limit/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
vi.mock("@/modules/api/v2/lib/rate-limit", () => ({
|
||||
checkRateLimitAndThrowError: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({
|
||||
rateLimitConfigs: {
|
||||
api: {
|
||||
v2: { interval: 60, allowedPerInterval: 100, namespace: "api:v2" },
|
||||
},
|
||||
},
|
||||
vi.mock("@/modules/api/v2/lib/utils", () => ({
|
||||
handleApiError: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/api/v2/lib/utils", () => ({
|
||||
@@ -28,31 +24,20 @@ vi.mock("@/modules/api/v2/lib/utils", () => ({
|
||||
handleApiError: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockAuthentication = {
|
||||
type: "apiKey" as const,
|
||||
environmentPermissions: [
|
||||
{
|
||||
environmentId: "env-id",
|
||||
environmentType: "development" as const,
|
||||
projectId: "project-id",
|
||||
projectName: "Project Name",
|
||||
permission: "manage" as const,
|
||||
},
|
||||
],
|
||||
hashedApiKey: "hashed-api-key",
|
||||
apiKeyId: "api-key-id",
|
||||
organizationId: "org-id",
|
||||
organizationAccess: {} as any,
|
||||
} as any;
|
||||
|
||||
describe("apiWrapper", () => {
|
||||
test("should handle request and return response", async () => {
|
||||
const request = new Request("http://localhost", {
|
||||
headers: { "x-api-key": "valid-api-key" },
|
||||
});
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
|
||||
vi.mocked(checkRateLimit).mockResolvedValue(ok({ allowed: true }));
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(
|
||||
ok({
|
||||
type: "apiKey",
|
||||
environmentId: "env-id",
|
||||
hashedApiKey: "hashed-api-key",
|
||||
})
|
||||
);
|
||||
vi.mocked(checkRateLimitAndThrowError).mockResolvedValue(okVoid());
|
||||
|
||||
const handler = vi.fn().mockResolvedValue(new Response("ok", { status: 200 }));
|
||||
const response = await apiWrapper({
|
||||
@@ -89,7 +74,13 @@ describe("apiWrapper", () => {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(
|
||||
ok({
|
||||
type: "apiKey",
|
||||
environmentId: "env-id",
|
||||
hashedApiKey: "hashed-api-key",
|
||||
})
|
||||
);
|
||||
|
||||
const bodySchema = z.object({ key: z.string() });
|
||||
const handler = vi.fn().mockResolvedValue(new Response("ok", { status: 200 }));
|
||||
@@ -116,7 +107,14 @@ describe("apiWrapper", () => {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(
|
||||
ok({
|
||||
type: "apiKey",
|
||||
environmentId: "env-id",
|
||||
hashedApiKey: "hashed-api-key",
|
||||
})
|
||||
);
|
||||
|
||||
vi.mocked(handleApiError).mockResolvedValue(new Response("error", { status: 400 }));
|
||||
|
||||
const bodySchema = z.object({ key: z.string() });
|
||||
@@ -136,7 +134,13 @@ describe("apiWrapper", () => {
|
||||
test("should parse query schema correctly", async () => {
|
||||
const request = new Request("http://localhost?key=value");
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(
|
||||
ok({
|
||||
type: "apiKey",
|
||||
environmentId: "env-id",
|
||||
hashedApiKey: "hashed-api-key",
|
||||
})
|
||||
);
|
||||
|
||||
const querySchema = z.object({ key: z.string() });
|
||||
const handler = vi.fn().mockResolvedValue(new Response("ok", { status: 200 }));
|
||||
@@ -159,7 +163,14 @@ describe("apiWrapper", () => {
|
||||
test("should handle query schema errors", async () => {
|
||||
const request = new Request("http://localhost?foo%ZZ=abc");
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(
|
||||
ok({
|
||||
type: "apiKey",
|
||||
environmentId: "env-id",
|
||||
hashedApiKey: "hashed-api-key",
|
||||
})
|
||||
);
|
||||
|
||||
vi.mocked(handleApiError).mockResolvedValue(new Response("error", { status: 400 }));
|
||||
|
||||
const querySchema = z.object({ key: z.string() });
|
||||
@@ -179,7 +190,13 @@ describe("apiWrapper", () => {
|
||||
test("should parse params schema correctly", async () => {
|
||||
const request = new Request("http://localhost");
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(
|
||||
ok({
|
||||
type: "apiKey",
|
||||
environmentId: "env-id",
|
||||
hashedApiKey: "hashed-api-key",
|
||||
})
|
||||
);
|
||||
|
||||
const paramsSchema = z.object({ key: z.string() });
|
||||
const handler = vi.fn().mockResolvedValue(new Response("ok", { status: 200 }));
|
||||
@@ -203,7 +220,14 @@ describe("apiWrapper", () => {
|
||||
test("should handle no external params", async () => {
|
||||
const request = new Request("http://localhost");
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(
|
||||
ok({
|
||||
type: "apiKey",
|
||||
environmentId: "env-id",
|
||||
hashedApiKey: "hashed-api-key",
|
||||
})
|
||||
);
|
||||
|
||||
vi.mocked(handleApiError).mockResolvedValue(new Response("error", { status: 400 }));
|
||||
|
||||
const paramsSchema = z.object({ key: z.string() });
|
||||
@@ -224,7 +248,14 @@ describe("apiWrapper", () => {
|
||||
test("should handle params schema errors", async () => {
|
||||
const request = new Request("http://localhost");
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(
|
||||
ok({
|
||||
type: "apiKey",
|
||||
environmentId: "env-id",
|
||||
hashedApiKey: "hashed-api-key",
|
||||
})
|
||||
);
|
||||
|
||||
vi.mocked(handleApiError).mockResolvedValue(new Response("error", { status: 400 }));
|
||||
|
||||
const paramsSchema = z.object({ key: z.string() });
|
||||
@@ -242,13 +273,21 @@ describe("apiWrapper", () => {
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should handle rate limit exceeded", async () => {
|
||||
test("should handle rate limit errors", async () => {
|
||||
const request = new Request("http://localhost", {
|
||||
headers: { "x-api-key": "valid-api-key" },
|
||||
});
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
|
||||
vi.mocked(checkRateLimit).mockResolvedValue(ok({ allowed: false }));
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(
|
||||
ok({
|
||||
type: "apiKey",
|
||||
environmentId: "env-id",
|
||||
hashedApiKey: "hashed-api-key",
|
||||
})
|
||||
);
|
||||
vi.mocked(checkRateLimitAndThrowError).mockResolvedValue(
|
||||
err({ type: "rateLimitExceeded" } as unknown as ApiErrorResponseV2)
|
||||
);
|
||||
vi.mocked(handleApiError).mockImplementation(
|
||||
(_request: Request, _error: ApiErrorResponseV2): Response =>
|
||||
new Response("rate limit exceeded", { status: 429 })
|
||||
@@ -263,24 +302,4 @@ describe("apiWrapper", () => {
|
||||
expect(response.status).toBe(429);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should handle rate limit check failure gracefully", async () => {
|
||||
const request = new Request("http://localhost", {
|
||||
headers: { "x-api-key": "valid-api-key" },
|
||||
});
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
|
||||
// When rate limiting fails (e.g., Redis connection issues), checkRateLimit fails open by returning allowed: true
|
||||
vi.mocked(checkRateLimit).mockResolvedValue(ok({ allowed: true }));
|
||||
|
||||
const handler = vi.fn().mockResolvedValue(new Response("ok", { status: 200 }));
|
||||
const response = await apiWrapper({
|
||||
request,
|
||||
handler,
|
||||
});
|
||||
|
||||
// Should fail open for availability
|
||||
expect(response.status).toBe(200);
|
||||
expect(handler).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
71
apps/web/modules/api/v2/lib/rate-limit.ts
Normal file
71
apps/web/modules/api/v2/lib/rate-limit.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { MANAGEMENT_API_RATE_LIMIT, RATE_LIMITING_DISABLED, UNKEY_ROOT_KEY } from "@/lib/constants";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { type LimitOptions, Ratelimit, type RatelimitResponse } from "@unkey/ratelimit";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { Result, err, okVoid } from "@formbricks/types/error-handlers";
|
||||
|
||||
export type RateLimitHelper = {
|
||||
identifier: string;
|
||||
opts?: LimitOptions;
|
||||
/**
|
||||
* Using a callback instead of a regular return to provide headers even
|
||||
* when the rate limit is reached and an error is thrown.
|
||||
**/
|
||||
onRateLimiterResponse?: (response: RatelimitResponse) => void;
|
||||
};
|
||||
|
||||
let warningDisplayed = false;
|
||||
|
||||
/** Prevent flooding the logs while testing/building */
|
||||
function logOnce(message: string) {
|
||||
if (warningDisplayed) return;
|
||||
logger.warn(message);
|
||||
warningDisplayed = true;
|
||||
}
|
||||
|
||||
export function rateLimiter() {
|
||||
if (RATE_LIMITING_DISABLED) {
|
||||
logOnce("Rate limiting disabled");
|
||||
return () => ({ success: true, limit: 10, remaining: 999, reset: 0 }) as RatelimitResponse;
|
||||
}
|
||||
|
||||
if (!UNKEY_ROOT_KEY) {
|
||||
logOnce("Disabled due to not finding UNKEY_ROOT_KEY env variable");
|
||||
return () => ({ success: true, limit: 10, remaining: 999, reset: 0 }) as RatelimitResponse;
|
||||
}
|
||||
const timeout = {
|
||||
fallback: { success: true, limit: 10, remaining: 999, reset: 0 },
|
||||
ms: 5000,
|
||||
};
|
||||
|
||||
const limiter = {
|
||||
api: new Ratelimit({
|
||||
rootKey: UNKEY_ROOT_KEY,
|
||||
namespace: "api",
|
||||
limit: MANAGEMENT_API_RATE_LIMIT.allowedPerInterval,
|
||||
duration: MANAGEMENT_API_RATE_LIMIT.interval * 1000,
|
||||
timeout,
|
||||
}),
|
||||
};
|
||||
|
||||
async function rateLimit({ identifier, opts }: RateLimitHelper) {
|
||||
return await limiter.api.limit(identifier, opts);
|
||||
}
|
||||
|
||||
return rateLimit;
|
||||
}
|
||||
|
||||
export const checkRateLimitAndThrowError = async ({
|
||||
identifier,
|
||||
opts,
|
||||
}: RateLimitHelper): Promise<Result<void, ApiErrorResponseV2>> => {
|
||||
const response = await rateLimiter()({ identifier, opts });
|
||||
const { success } = response;
|
||||
|
||||
if (!success) {
|
||||
return err({
|
||||
type: "too_many_requests",
|
||||
});
|
||||
}
|
||||
return okVoid();
|
||||
};
|
||||
114
apps/web/modules/api/v2/lib/tests/rate-limit.test.ts
Normal file
114
apps/web/modules/api/v2/lib/tests/rate-limit.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { logger } from "@formbricks/logger";
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
warn: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@unkey/ratelimit", () => ({
|
||||
Ratelimit: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("when rate limiting is disabled", () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
const constants = await vi.importActual("@/lib/constants");
|
||||
vi.doMock("@/lib/constants", () => ({
|
||||
...constants,
|
||||
MANAGEMENT_API_RATE_LIMIT: { allowedPerInterval: 5, interval: 60 },
|
||||
RATE_LIMITING_DISABLED: true,
|
||||
}));
|
||||
});
|
||||
|
||||
test("should log a warning once and return a stubbed response", async () => {
|
||||
const loggerSpy = vi.spyOn(logger, "warn");
|
||||
const { rateLimiter } = await import("@/modules/api/v2/lib/rate-limit");
|
||||
|
||||
const res1 = await rateLimiter()({ identifier: "test-id" });
|
||||
expect(res1).toEqual({ success: true, limit: 10, remaining: 999, reset: 0 });
|
||||
expect(loggerSpy).toHaveBeenCalled();
|
||||
|
||||
// Subsequent calls won't log again.
|
||||
await rateLimiter()({ identifier: "another-id" });
|
||||
|
||||
expect(loggerSpy).toHaveBeenCalledTimes(1);
|
||||
loggerSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("when UNKEY_ROOT_KEY is missing", () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
const constants = await vi.importActual("@/lib/constants");
|
||||
vi.doMock("@/lib/constants", () => ({
|
||||
...constants,
|
||||
MANAGEMENT_API_RATE_LIMIT: { allowedPerInterval: 5, interval: 60 },
|
||||
RATE_LIMITING_DISABLED: false,
|
||||
UNKEY_ROOT_KEY: "",
|
||||
}));
|
||||
});
|
||||
|
||||
test("should log a warning about missing UNKEY_ROOT_KEY and return stub response", async () => {
|
||||
const loggerSpy = vi.spyOn(logger, "warn");
|
||||
const { rateLimiter } = await import("@/modules/api/v2/lib/rate-limit");
|
||||
const limiterFunc = rateLimiter();
|
||||
|
||||
const res = await limiterFunc({ identifier: "test-id" });
|
||||
expect(res).toEqual({ success: true, limit: 10, remaining: 999, reset: 0 });
|
||||
expect(loggerSpy).toHaveBeenCalled();
|
||||
loggerSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("when rate limiting is active (enabled)", () => {
|
||||
const mockResponse = { success: true, limit: 5, remaining: 2, reset: 1000 };
|
||||
let limitMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
const constants = await vi.importActual("@/lib/constants");
|
||||
vi.doMock("@/lib/constants", () => ({
|
||||
...constants,
|
||||
MANAGEMENT_API_RATE_LIMIT: { allowedPerInterval: 5, interval: 60 },
|
||||
RATE_LIMITING_DISABLED: false,
|
||||
UNKEY_ROOT_KEY: "valid-key",
|
||||
}));
|
||||
|
||||
limitMock = vi.fn().mockResolvedValue(mockResponse);
|
||||
const RatelimitMock = vi.fn().mockImplementation(() => {
|
||||
return { limit: limitMock };
|
||||
});
|
||||
vi.doMock("@unkey/ratelimit", () => ({
|
||||
Ratelimit: RatelimitMock,
|
||||
}));
|
||||
});
|
||||
|
||||
test("should create a rate limiter that calls the limit method with the proper arguments", async () => {
|
||||
const { rateLimiter } = await import("../rate-limit");
|
||||
const limiterFunc = rateLimiter();
|
||||
const res = await limiterFunc({ identifier: "abc", opts: { cost: 1 } });
|
||||
expect(limitMock).toHaveBeenCalledWith("abc", { cost: 1 });
|
||||
expect(res).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
test("checkRateLimitAndThrowError returns okVoid when rate limit is not exceeded", async () => {
|
||||
limitMock.mockResolvedValueOnce({ success: true, limit: 5, remaining: 3, reset: 1000 });
|
||||
|
||||
const { checkRateLimitAndThrowError } = await import("../rate-limit");
|
||||
const result = await checkRateLimitAndThrowError({ identifier: "abc" });
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
test("checkRateLimitAndThrowError returns an error when the rate limit is exceeded", async () => {
|
||||
limitMock.mockResolvedValueOnce({ success: false, limit: 5, remaining: 0, reset: 1000 });
|
||||
|
||||
const { checkRateLimitAndThrowError } = await import("../rate-limit");
|
||||
const result = await checkRateLimitAndThrowError({ identifier: "abc" });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({ type: "too_many_requests" });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -20,7 +20,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
IS_PRODUCTION: true,
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
ENCRYPTION_KEY: "mocked-encryption-key",
|
||||
REDIS_URL: undefined,
|
||||
REDIS_URL: "mock-url",
|
||||
}));
|
||||
|
||||
describe("utils", () => {
|
||||
|
||||
@@ -92,7 +92,7 @@ export const GET = async (request: Request, props: { params: Promise<TContactLin
|
||||
});
|
||||
}
|
||||
|
||||
const surveyUrlResult = await getContactSurveyLink(params.contactId, params.surveyId, 7);
|
||||
const surveyUrlResult = getContactSurveyLink(params.contactId, params.surveyId, 7);
|
||||
|
||||
if (!surveyUrlResult.ok) {
|
||||
return handleApiError(request, surveyUrlResult.error);
|
||||
|
||||
@@ -82,11 +82,11 @@ export const GET = async (
|
||||
}
|
||||
|
||||
// Generate survey links for each contact
|
||||
const contactLinks = await Promise.all(
|
||||
contacts.map(async (contact) => {
|
||||
const contactLinks = contacts
|
||||
.map((contact) => {
|
||||
const { contactId, attributes } = contact;
|
||||
|
||||
const surveyUrlResult = await getContactSurveyLink(
|
||||
const surveyUrlResult = getContactSurveyLink(
|
||||
contactId,
|
||||
params.surveyId,
|
||||
query?.expirationDays || undefined
|
||||
@@ -107,11 +107,10 @@ export const GET = async (
|
||||
expiresAt,
|
||||
};
|
||||
})
|
||||
);
|
||||
.filter(Boolean);
|
||||
|
||||
const filteredContactLinks = contactLinks.filter(Boolean);
|
||||
return responses.successResponse({
|
||||
data: filteredContactLinks,
|
||||
data: contactLinks,
|
||||
meta,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1,230 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,8 +3,6 @@
|
||||
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";
|
||||
@@ -17,8 +15,6 @@ 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");
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
SMTP_HOST: "smtp.example.com",
|
||||
SMTP_PORT: "587",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: undefined,
|
||||
REDIS_URL: "test-redis-url",
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
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";
|
||||
@@ -11,20 +8,6 @@ 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,
|
||||
@@ -38,7 +21,6 @@ vi.mock("@/lib/constants", () => ({
|
||||
ENTERPRISE_LICENSE_KEY: undefined,
|
||||
SENTRY_DSN: undefined,
|
||||
BREVO_API_KEY: undefined,
|
||||
RATE_LIMITING_DISABLED: false,
|
||||
}));
|
||||
|
||||
// Mock next/headers
|
||||
@@ -87,7 +69,6 @@ function getProviderById(id: string): Provider {
|
||||
|
||||
describe("authOptions", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -101,7 +82,6 @@ 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 };
|
||||
@@ -112,7 +92,6 @@ 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,
|
||||
@@ -127,7 +106,6 @@ 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,
|
||||
@@ -142,7 +120,6 @@ 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,
|
||||
@@ -165,64 +142,8 @@ 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",
|
||||
@@ -251,7 +172,6 @@ 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(
|
||||
@@ -260,7 +180,6 @@ 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) };
|
||||
@@ -271,7 +190,6 @@ 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,
|
||||
@@ -288,70 +206,6 @@ 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", () => {
|
||||
@@ -421,7 +275,6 @@ 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",
|
||||
@@ -439,7 +292,6 @@ 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",
|
||||
|
||||
@@ -16,8 +16,6 @@ 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";
|
||||
@@ -54,8 +52,6 @@ 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
|
||||
|
||||
@@ -225,8 +221,6 @@ 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";
|
||||
|
||||
@@ -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, deleteBrevoCustomerByEmail, updateBrevoCustomer } from "./brevo";
|
||||
import { createBrevoCustomer, updateBrevoCustomer } from "./brevo";
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
BREVO_API_KEY: "mock_api_key",
|
||||
@@ -125,63 +125,3 @@ 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",
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -95,28 +95,3 @@ 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");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -27,7 +27,6 @@ vi.mock("crypto", () => ({
|
||||
digest: vi.fn(() => "a".repeat(32)), // Mock 64-char hex string
|
||||
})),
|
||||
})),
|
||||
randomUUID: vi.fn(() => "test-uuid-123"),
|
||||
}));
|
||||
|
||||
// Mock Sentry
|
||||
@@ -43,12 +42,8 @@ vi.mock("@/lib/constants", () => ({
|
||||
}));
|
||||
|
||||
// Mock Redis client
|
||||
const { mockGetRedisClient } = vi.hoisted(() => ({
|
||||
mockGetRedisClient: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/cache/redis", () => ({
|
||||
getRedisClient: mockGetRedisClient,
|
||||
default: null, // Intentionally simulate Redis unavailability to test fail-closed security behavior
|
||||
}));
|
||||
|
||||
describe("Auth Utils", () => {
|
||||
@@ -114,17 +109,11 @@ 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.
|
||||
@@ -142,254 +131,6 @@ 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", () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
|
||||
import { getRedisClient } from "@/modules/cache/redis";
|
||||
import redis 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,47 +228,46 @@ export const shouldLogAuthFailure = async (
|
||||
const rateLimitKey = `rate_limit:auth:${createAuditIdentifier(identifier, "ratelimit")}`;
|
||||
const now = Date.now();
|
||||
|
||||
try {
|
||||
// Get Redis client
|
||||
const redis = getRedisClient();
|
||||
if (!redis) {
|
||||
logger.warn("Redis not available for rate limiting, not logging due to Redis requirement");
|
||||
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
|
||||
return false;
|
||||
}
|
||||
|
||||
// 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
|
||||
} 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
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -11,8 +11,6 @@ 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";
|
||||
@@ -179,7 +177,6 @@ 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);
|
||||
|
||||
@@ -1,344 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,8 +3,6 @@
|
||||
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";
|
||||
@@ -20,8 +18,6 @@ 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);
|
||||
|
||||
381
apps/web/modules/cache/lib/cacheKeys.test.ts
vendored
381
apps/web/modules/cache/lib/cacheKeys.test.ts
vendored
@@ -1,381 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
3
apps/web/modules/cache/lib/cacheKeys.ts
vendored
3
apps/web/modules/cache/lib/cacheKeys.ts
vendored
@@ -11,7 +11,6 @@ import "server-only";
|
||||
* - Predictable invalidation patterns
|
||||
* - Multi-tenant safe
|
||||
*/
|
||||
|
||||
export const createCacheKey = {
|
||||
// Environment-related keys
|
||||
environment: {
|
||||
@@ -72,8 +71,6 @@ 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
261
apps/web/modules/cache/redis.test.ts
vendored
@@ -1,261 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
70
apps/web/modules/cache/redis.ts
vendored
70
apps/web/modules/cache/redis.ts
vendored
@@ -1,69 +1,11 @@
|
||||
import { createClient } from "redis";
|
||||
import { REDIS_URL } from "@/lib/constants";
|
||||
import Redis from "ioredis";
|
||||
import { logger } from "@formbricks/logger";
|
||||
|
||||
type RedisClient = ReturnType<typeof createClient>;
|
||||
const redis = REDIS_URL ? new Redis(REDIS_URL) : null;
|
||||
|
||||
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);
|
||||
});
|
||||
if (!redis) {
|
||||
logger.info("REDIS_URL is not set");
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
export default redis;
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
import { hashString } from "@/lib/hash-string";
|
||||
// Import modules after mocking
|
||||
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { err, ok } from "@formbricks/types/error-handlers";
|
||||
import { applyIPRateLimit, applyRateLimit, getClientIdentifier } from "./helpers";
|
||||
import { checkRateLimit } from "./rate-limit";
|
||||
|
||||
// Mock all dependencies
|
||||
vi.mock("@/lib/utils/client-ip", () => ({
|
||||
getClientIpFromHeaders: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/hash-string", () => ({
|
||||
hashString: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("helpers", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("getClientIdentifier", () => {
|
||||
test("should get client IP and return hashed identifier", async () => {
|
||||
const mockIp = "192.168.1.1";
|
||||
const mockHashedIp = "abc123hashedip";
|
||||
|
||||
(getClientIpFromHeaders as any).mockResolvedValue(mockIp);
|
||||
(hashString as any).mockReturnValue(mockHashedIp);
|
||||
|
||||
const result = await getClientIdentifier();
|
||||
|
||||
expect(getClientIpFromHeaders).toHaveBeenCalledOnce();
|
||||
expect(hashString).toHaveBeenCalledWith(mockIp);
|
||||
expect(result).toBe(mockHashedIp);
|
||||
|
||||
// Verify no error was logged on successful hashing
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should handle IP retrieval errors", async () => {
|
||||
const mockError = new Error("Failed to get IP");
|
||||
(getClientIpFromHeaders as any).mockRejectedValue(mockError);
|
||||
|
||||
await expect(getClientIdentifier()).rejects.toThrow("Failed to get IP");
|
||||
});
|
||||
|
||||
test("should handle hashing errors with proper logging", async () => {
|
||||
const mockIp = "192.168.1.1";
|
||||
const originalError = new Error("Hashing failed");
|
||||
|
||||
(getClientIpFromHeaders as any).mockResolvedValue(mockIp);
|
||||
(hashString as any).mockImplementation(() => {
|
||||
throw originalError;
|
||||
});
|
||||
|
||||
await expect(getClientIdentifier()).rejects.toThrow("Failed to hash IP");
|
||||
|
||||
// Verify that the error was logged with proper context
|
||||
expect(logger.error).toHaveBeenCalledWith("Failed to hash IP", { error: originalError });
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyRateLimit", () => {
|
||||
const mockConfig = {
|
||||
interval: 300,
|
||||
allowedPerInterval: 5,
|
||||
namespace: "test",
|
||||
};
|
||||
|
||||
const mockIdentifier = "test-identifier";
|
||||
|
||||
test("should allow request when rate limit check passes", async () => {
|
||||
(checkRateLimit as any).mockResolvedValue(ok({ allowed: true }));
|
||||
|
||||
await expect(applyRateLimit(mockConfig, mockIdentifier)).resolves.toBeUndefined();
|
||||
|
||||
expect(checkRateLimit).toHaveBeenCalledWith(mockConfig, mockIdentifier);
|
||||
});
|
||||
|
||||
test("should throw error when rate limit is exceeded", async () => {
|
||||
(checkRateLimit as any).mockResolvedValue(ok({ allowed: false }));
|
||||
|
||||
await expect(applyRateLimit(mockConfig, mockIdentifier)).rejects.toThrow(
|
||||
"Maximum number of requests reached. Please try again later."
|
||||
);
|
||||
|
||||
expect(checkRateLimit).toHaveBeenCalledWith(mockConfig, mockIdentifier);
|
||||
});
|
||||
|
||||
test("should throw error when rate limit check fails", async () => {
|
||||
(checkRateLimit as any).mockResolvedValue(err("Redis connection failed"));
|
||||
|
||||
await expect(applyRateLimit(mockConfig, mockIdentifier)).rejects.toThrow(
|
||||
"Maximum number of requests reached. Please try again later."
|
||||
);
|
||||
|
||||
expect(checkRateLimit).toHaveBeenCalledWith(mockConfig, mockIdentifier);
|
||||
});
|
||||
|
||||
test("should throw error when rate limit check throws exception", async () => {
|
||||
const mockError = new Error("Unexpected error");
|
||||
(checkRateLimit as any).mockRejectedValue(mockError);
|
||||
|
||||
await expect(applyRateLimit(mockConfig, mockIdentifier)).rejects.toThrow("Unexpected error");
|
||||
|
||||
expect(checkRateLimit).toHaveBeenCalledWith(mockConfig, mockIdentifier);
|
||||
});
|
||||
|
||||
test("should work with different configurations", async () => {
|
||||
const customConfig = {
|
||||
interval: 3600,
|
||||
allowedPerInterval: 100,
|
||||
namespace: "api:v1",
|
||||
};
|
||||
|
||||
(checkRateLimit as any).mockResolvedValue(ok({ allowed: true }));
|
||||
|
||||
await expect(applyRateLimit(customConfig, "api-key-identifier")).resolves.toBeUndefined();
|
||||
|
||||
expect(checkRateLimit).toHaveBeenCalledWith(customConfig, "api-key-identifier");
|
||||
});
|
||||
|
||||
test("should work with different identifiers", async () => {
|
||||
(checkRateLimit as any).mockResolvedValue(ok({ allowed: true }));
|
||||
|
||||
const identifiers = ["user-123", "ip-192.168.1.1", "auth-login-hashedip", "api-key-abc123"];
|
||||
|
||||
for (const identifier of identifiers) {
|
||||
await expect(applyRateLimit(mockConfig, identifier)).resolves.toBeUndefined();
|
||||
expect(checkRateLimit).toHaveBeenCalledWith(mockConfig, identifier);
|
||||
}
|
||||
|
||||
expect(checkRateLimit).toHaveBeenCalledTimes(identifiers.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyIPRateLimit", () => {
|
||||
test("should be a convenience function that gets IP and applies rate limit", async () => {
|
||||
// This is an integration test - the function calls getClientIdentifier internally
|
||||
// and then calls applyRateLimit, which we've already tested extensively
|
||||
const mockConfig = {
|
||||
interval: 3600,
|
||||
allowedPerInterval: 100,
|
||||
namespace: "test:page",
|
||||
};
|
||||
|
||||
// Mock the IP getting functions
|
||||
(getClientIpFromHeaders as any).mockResolvedValue("192.168.1.1");
|
||||
(hashString as any).mockReturnValue("hashed-ip-123");
|
||||
(checkRateLimit as any).mockResolvedValue(ok({ allowed: true }));
|
||||
|
||||
await expect(applyIPRateLimit(mockConfig)).resolves.toBeUndefined();
|
||||
|
||||
expect(getClientIpFromHeaders).toHaveBeenCalledTimes(1);
|
||||
expect(hashString).toHaveBeenCalledWith("192.168.1.1");
|
||||
expect(checkRateLimit).toHaveBeenCalledWith(mockConfig, "hashed-ip-123");
|
||||
});
|
||||
|
||||
test("should propagate errors from getClientIdentifier", async () => {
|
||||
const mockConfig = {
|
||||
interval: 3600,
|
||||
allowedPerInterval: 100,
|
||||
namespace: "test:page",
|
||||
};
|
||||
|
||||
(getClientIpFromHeaders as any).mockRejectedValue(new Error("IP fetch failed"));
|
||||
|
||||
await expect(applyIPRateLimit(mockConfig)).rejects.toThrow("IP fetch failed");
|
||||
});
|
||||
|
||||
test("should propagate rate limit exceeded errors", async () => {
|
||||
const mockConfig = {
|
||||
interval: 3600,
|
||||
allowedPerInterval: 100,
|
||||
namespace: "test:page",
|
||||
};
|
||||
|
||||
(getClientIpFromHeaders as any).mockResolvedValue("192.168.1.1");
|
||||
(hashString as any).mockReturnValue("hashed-ip-123");
|
||||
(checkRateLimit as any).mockResolvedValue(ok({ allowed: false }));
|
||||
|
||||
await expect(applyIPRateLimit(mockConfig)).rejects.toThrow(
|
||||
"Maximum number of requests reached. Please try again later."
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,52 +0,0 @@
|
||||
import { hashString } from "@/lib/hash-string";
|
||||
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TooManyRequestsError } from "@formbricks/types/errors";
|
||||
import { checkRateLimit } from "./rate-limit";
|
||||
import { type TRateLimitConfig } from "./types/rate-limit";
|
||||
|
||||
/**
|
||||
* Get client identifier for rate limiting with IP hashing
|
||||
* Used when the user is not authenticated or the api is called from the client
|
||||
*
|
||||
* @returns {Promise<string>} Hashed IP address for rate limiting
|
||||
* @throws {Error} When IP hashing fails due to invalid IP format or hashing algorithm issues
|
||||
*/
|
||||
export const getClientIdentifier = async (): Promise<string> => {
|
||||
const ip = await getClientIpFromHeaders();
|
||||
|
||||
try {
|
||||
return hashString(ip);
|
||||
} catch (error) {
|
||||
const errorMessage = "Failed to hash IP";
|
||||
logger.error(errorMessage, { error });
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Generic rate limit application function
|
||||
*
|
||||
* @param config - Rate limit configuration
|
||||
* @param identifier - Unique identifier for rate limiting (IP hash, user ID, API key, etc.)
|
||||
* @throws {Error} When rate limit is exceeded or rate limiting system fails
|
||||
*/
|
||||
export const applyRateLimit = async (config: TRateLimitConfig, identifier: string): Promise<void> => {
|
||||
const result = await checkRateLimit(config, identifier);
|
||||
|
||||
if (!result.ok || !result.data.allowed) {
|
||||
throw new TooManyRequestsError("Maximum number of requests reached. Please try again later.");
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Apply IP-based rate limiting for unauthenticated requests
|
||||
* Generic function for IP-based rate limiting in authentication flows and public pages
|
||||
*
|
||||
* @param config - Rate limit configuration to apply
|
||||
* @throws {Error} When rate limit is exceeded or IP hashing fails
|
||||
*/
|
||||
export const applyIPRateLimit = async (config: TRateLimitConfig): Promise<void> => {
|
||||
const identifier = await getClientIdentifier();
|
||||
await applyRateLimit(config, identifier);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user