Compare commits

..

44 Commits

Author SHA1 Message Date
Harsh Bhat
639e25d679 chore: canonical seo issue (#5852) 2025-05-21 13:38:41 +00:00
Piyush Gupta
f7e5ef96d2 feat: added email change feature (#5837)
Co-authored-by: Paribesh01 <nepalparibesh01@gmail.com>
Co-authored-by: Paribesh Nepal <100255987+Paribesh01@users.noreply.github.com>
2025-05-21 11:23:12 +00:00
Dhruwang Jariwala
745f5487e9 fix: tweaks in open text question (#5841)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-05-21 06:20:40 +00:00
devin-ai-integration[bot]
0e7f3adf53 feat: Make session maxAge configurable with environment variable (#5830)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Matti Nannt <mail@matti.sh>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-05-21 05:49:18 +00:00
Dhruwang Jariwala
342d2b1fc4 fix: response getting stuck (#5849) 2025-05-21 05:33:13 +00:00
Piyush Gupta
15279685f7 fix: delete pre-filled value (#5839) 2025-05-21 04:23:05 +00:00
Matti Nannt
12aa959f50 fix: slow responses query slowing down database (#5846) 2025-05-21 04:13:31 +00:00
Johannes
9478946c7a fix: fix icon in new docs page (#5836) 2025-05-19 04:53:57 -07:00
Johannes
8560bbf28b docs: documentation of multi-tenancy of Formbricks Cloud (#5835) 2025-05-19 04:47:26 -07:00
victorvhs017
df7afe1b64 fix: non-interactive elements without roles (#5804)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-05-19 10:10:13 +00:00
Piyush Gupta
df52b60d61 fix: env-var-generation in mac os for self-hosting (#5814) 2025-05-17 07:50:15 +00:00
Jakob Schott
65b051f0eb feat: download selection of responses (#5488)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-05-17 00:59:14 +00:00
Dhruwang Jariwala
7678084061 fix: unknown property warnings (#5800) 2025-05-16 13:45:48 +00:00
victorvhs017
022d33d06f chore: track server action with sentry and general fixes (#5799) 2025-05-16 12:02:06 +00:00
Anshuman Pandey
4d157bf8dc fix: user attributes updates api email fix (#5827) 2025-05-16 11:48:34 +00:00
Dhruwang Jariwala
9fcbe4e8c5 chore: swap next and back button input (#5748)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-05-16 08:51:12 +00:00
Piyush Gupta
5aeb92eb4f chore: removes https enforcement from management api (#5810) 2025-05-15 19:40:04 +00:00
Matti Nannt
00dfa629b5 fix: build process warnings (#5734)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-05-15 15:46:05 +00:00
Piyush Gupta
3ca471b6a2 feat: implement user management role configuration and access control (#5808) 2025-05-15 15:09:33 +00:00
Dhruwang Jariwala
a525589186 fix: token permisson issues (#4986) 2025-05-15 08:29:40 +00:00
Piyush Gupta
59ed10398d fix: suid bugs (#5780)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-05-14 12:09:41 +00:00
Piyush Gupta
25a86e31df fix: promise misuse error (#5783) 2025-05-14 12:06:41 +00:00
Matti Nannt
7d6743a81a chore(deps): upgrade npm dependencies in /packages (#5807) 2025-05-14 14:09:26 +02:00
Matti Nannt
6616f62da5 chore: remove unused prisma accelerate extension (#5682) 2025-05-14 12:34:08 +02:00
dependabot[bot]
a3cbc05e12 chore(deps): bump marocchino/sticky-pull-request-comment from 2.9.1 to 2.9.2 (#5760)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-14 12:29:43 +02:00
dependabot[bot]
97095a627a chore(deps): bump aws-actions/configure-aws-credentials from 4.0.2 to 4.2.0 (#5757)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-14 12:27:59 +02:00
dependabot[bot]
910d257c56 chore(deps): bump SonarSource/sonarqube-scan-action from 5.1.0 to 5.2.0 (#5758)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-14 12:27:39 +02:00
dependabot[bot]
0c0a008b28 chore(deps): bump actions/dependency-review-action from 4.6.0 to 4.7.0 (#5759)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-14 12:27:14 +02:00
dependabot[bot]
9879458353 chore(deps): bump borchero/terraform-plan-comment from 2.4.0 to 2.4.1 (#5761)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-14 12:26:46 +02:00
Matti Nannt
d44f1f3b4b chore: remove dependabot action and return to simple repository setup (#5806) 2025-05-14 12:23:38 +02:00
Dhruwang Jariwala
c5d387a7e5 fix: multi choice issues (#5802) 2025-05-14 09:59:22 +00:00
Anshuman Pandey
a6aacd5c55 fix: transaction timeout and max contacts (#5415)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-05-14 11:01:39 +02:00
Piyush Gupta
57e7485564 fix: reliability issues (#5781) 2025-05-13 16:40:16 +00:00
Matti Nannt
42a38a6f47 chore: fix environment surveys filter not working properly (#5793) 2025-05-13 13:34:27 +02:00
Dhruwang Jariwala
34bb9c2127 fix: multi select question (#5792)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-05-13 13:12:22 +02:00
Dhruwang Jariwala
6442b5e4aa fix: Vietnamese char interpretation (#5747) 2025-05-13 11:54:30 +02:00
Piyush Gupta
dde5a55446 fix: CTA and consent question breaking the survey editor (#5745) 2025-05-13 04:18:34 +00:00
Piyush Gupta
13e615a798 fix: duplicate switch cases (#5752)
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-05-12 15:00:55 +00:00
victorvhs017
9c81961b0b chore: remove redundant code (#5751) 2025-05-12 12:23:05 +00:00
Matti Nannt
c1a35e2d75 chore: introduce new reliable cache for enterprise license check (#5740)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-05-12 10:41:53 +02:00
Piyush Gupta
13415c75c2 docs: adds auth-behaviour docs (#5743) 2025-05-12 05:28:12 +00:00
Johannes
300557a0e6 docs: update ee feature table (#5744) 2025-05-11 22:10:40 -07:00
Anshuman Pandey
fcbb97010c fix: follow ups ending card (#5732) 2025-05-10 10:30:49 +02:00
Matti Nannt
6be46b16b2 fix: limit number of surveys in environment state (#5715) 2025-05-09 16:10:01 +00:00
349 changed files with 10970 additions and 6496 deletions

View File

@@ -0,0 +1,6 @@
---
description: Whenever the user asks to write or update a test file for .tsx or .ts files.
globs:
alwaysApply: false
---
Use the rules in this file when writing tests [copilot-instructions.md](mdc:.github/copilot-instructions.md)

View File

@@ -172,7 +172,6 @@ ENTERPRISE_LICENSE_KEY=
# Automatically assign new users to a specific organization and role within that organization # Automatically assign new users to a specific organization and role within that organization
# Insert an existing organization id or generate a valid CUID for a new one at https://www.getuniqueid.com/cuid (e.g. cjld2cjxh0000qzrmn831i7rn) # Insert an existing organization id or generate a valid CUID for a new one at https://www.getuniqueid.com/cuid (e.g. cjld2cjxh0000qzrmn831i7rn)
# (Role Management is an Enterprise feature) # (Role Management is an Enterprise feature)
# DEFAULT_ORGANIZATION_ROLE=owner
# AUTH_SSO_DEFAULT_TEAM_ID= # AUTH_SSO_DEFAULT_TEAM_ID=
# AUTH_SKIP_INVITE_FOR_SSO= # AUTH_SKIP_INVITE_FOR_SSO=
@@ -212,5 +211,8 @@ UNKEY_ROOT_KEY=
# It's used automatically by Sentry during the build for authentication when uploading source maps. # It's used automatically by Sentry during the build for authentication when uploading source maps.
# SENTRY_AUTH_TOKEN= # SENTRY_AUTH_TOKEN=
# Disable the user management from UI # Configure the minimum role for user management from UI(owner, manager, disabled)
# DISABLE_USER_MANAGEMENT=1 # USER_MANAGEMENT_MINIMUM_ROLE="manager"
# Configure the maximum age for the session in seconds. Default is 86400 (24 hours)
# SESSION_MAX_AGE=86400

View File

@@ -1,84 +0,0 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
updates:
- package-ecosystem: "npm" # For pnpm monorepos, use npm ecosystem
directory: "/" # Root package.json
schedule:
interval: "weekly"
versioning-strategy: increase
# Apps directory packages
- package-ecosystem: "npm"
directory: "/apps/demo"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/apps/demo-react-native"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/apps/storybook"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/apps/web"
schedule:
interval: "weekly"
# Packages directory
- package-ecosystem: "npm"
directory: "/packages/database"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/packages/lib"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/packages/types"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/packages/config-eslint"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/packages/config-prettier"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/packages/config-typescript"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/packages/js-core"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/packages/surveys"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/packages/logger"
schedule:
interval: "weekly"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"

View File

@@ -10,6 +10,11 @@ jobs:
chromatic: chromatic:
name: Run Chromatic name: Run Chromatic
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
actions: read
steps: steps:
- name: Harden the runner (Audit all outbound calls) - name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0

View File

@@ -24,4 +24,4 @@ jobs:
- name: 'Checkout Repository' - name: 'Checkout Repository'
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: 'Dependency Review' - name: 'Dependency Review'
uses: actions/dependency-review-action@ce3cf9537a52e8119d91fd484ab5b8a807627bf8 # v4.6.0 uses: actions/dependency-review-action@38ecb5b593bf0eb19e335c03f97670f792489a8b # v4.7.0

View File

@@ -54,7 +54,7 @@ jobs:
tags: tag:github tags: tag:github
- name: Configure AWS Credentials - name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2 uses: aws-actions/configure-aws-credentials@f24d7193d98baebaeacc7e2227925dd47cc267f5 # v4.2.0
with: with:
role-to-assume: ${{ secrets.AWS_ASSUME_ROLE_ARN }} role-to-assume: ${{ secrets.AWS_ASSUME_ROLE_ARN }}
aws-region: "eu-central-1" aws-region: "eu-central-1"

View File

@@ -11,6 +11,8 @@ on:
required: false required: false
PLAYWRIGHT_SERVICE_URL: PLAYWRIGHT_SERVICE_URL:
required: false required: false
ENTERPRISE_LICENSE_KEY:
required: true
# Add other secrets if necessary # Add other secrets if necessary
workflow_dispatch: workflow_dispatch:
@@ -23,7 +25,6 @@ permissions:
id-token: write id-token: write
contents: read contents: read
actions: read actions: read
checks: write
jobs: jobs:
build: build:
@@ -48,15 +49,17 @@ jobs:
- name: Harden the runner (Audit all outbound calls) - name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with: with:
egress-policy: audit egress-policy: allow
allowed-endpoints: |
ee.formbricks.com:443
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: ./.github/actions/dangerous-git-checkout - uses: ./.github/actions/dangerous-git-checkout
- name: Setup Node.js 20.x - name: Setup Node.js 22.x
uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # v3.8.2 uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # v3.8.2
with: with:
node-version: 20.x node-version: 22.x
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
@@ -75,7 +78,7 @@ jobs:
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${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/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
sed -i "s/ENTERPRISE_LICENSE_KEY=.*/ENTERPRISE_LICENSE_KEY=${RANDOM_KEY}/" .env sed -i "s/ENTERPRISE_LICENSE_KEY=.*/ENTERPRISE_LICENSE_KEY=${{ secrets.ENTERPRISE_LICENSE_KEY }}/" .env
echo "" >> .env echo "" >> .env
echo "E2E_TESTING=1" >> .env echo "E2E_TESTING=1" >> .env
shell: bash shell: bash
@@ -89,8 +92,18 @@ jobs:
# pnpm prisma migrate deploy # pnpm prisma migrate deploy
pnpm db:migrate:dev pnpm db:migrate:dev
- name: Check for Enterprise License
run: |
LICENSE_KEY=$(grep '^ENTERPRISE_LICENSE_KEY=' .env | cut -d'=' -f2-)
if [ -z "$LICENSE_KEY" ]; then
echo "::error::ENTERPRISE_LICENSE_KEY in .env is empty. Please check your secret configuration."
exit 1
fi
echo "License key length: ${#LICENSE_KEY}"
- name: Run App - name: Run App
run: | run: |
echo "Starting app with enterprise license..."
NODE_ENV=test pnpm start --filter=@formbricks/web | tee app.log 2>&1 & NODE_ENV=test pnpm start --filter=@formbricks/web | tee app.log 2>&1 &
sleep 10 # Optional: gives some buffer for the app to start sleep 10 # Optional: gives some buffer for the app to start
for attempt in {1..10}; do for attempt in {1..10}; do

View File

@@ -20,18 +20,15 @@ env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }} TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
permissions:
contents: read
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read
packages: write packages: write
id-token: write
# This is used to complete the identity challenge # This is used to complete the identity challenge
# with sigstore/fulcio when running outside of PRs. # with sigstore/fulcio when running outside of PRs.
id-token: write
outputs: outputs:
VERSION: ${{ steps.extract_release_tag.outputs.VERSION }} VERSION: ${{ steps.extract_release_tag.outputs.VERSION }}

View File

@@ -4,7 +4,7 @@ on:
workflow_call: workflow_call:
inputs: inputs:
VERSION: VERSION:
description: 'The version of the Helm chart to release' description: "The version of the Helm chart to release"
required: true required: true
type: string type: string

View File

@@ -40,7 +40,7 @@ jobs:
revert revert
ossgg ossgg
- uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1 - uses: marocchino/sticky-pull-request-comment@67d0dec7b07ed060a405f9b2a64b8ab319fdd7db # v2.9.2
# When the previous steps fails, the workflow would stop. By adding this # When the previous steps fails, the workflow would stop. By adding this
# condition you can continue the execution with the populated error message. # condition you can continue the execution with the populated error message.
if: always() && (steps.lint_pr_title.outputs.error_message != null) if: always() && (steps.lint_pr_title.outputs.error_message != null)
@@ -59,7 +59,7 @@ jobs:
# Delete a previous comment when the issue has been resolved # Delete a previous comment when the issue has been resolved
- if: ${{ steps.lint_pr_title.outputs.error_message == null }} - if: ${{ steps.lint_pr_title.outputs.error_message == null }}
uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1 uses: marocchino/sticky-pull-request-comment@67d0dec7b07ed060a405f9b2a64b8ab319fdd7db # v2.9.2
with: with:
header: pr-title-lint-error header: pr-title-lint-error
message: | message: |

View File

@@ -48,7 +48,7 @@ jobs:
run: | run: |
pnpm test:coverage pnpm test:coverage
- name: SonarQube Scan - name: SonarQube Scan
uses: SonarSource/sonarqube-scan-action@aa494459d7c39c106cc77b166de8b4250a32bb97 uses: SonarSource/sonarqube-scan-action@2500896589ef8f7247069a56136f8dc177c27ccf
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}

View File

@@ -1,8 +1,8 @@
name: 'Terraform' name: "Terraform"
on: on:
workflow_dispatch: workflow_dispatch:
# TODO: enable it back when migration is completed. # TODO: enable it back when migration is completed.
push: push:
branches: branches:
- main - main
@@ -14,14 +14,13 @@ on:
paths: paths:
- "infra/terraform/**" - "infra/terraform/**"
permissions:
id-token: write
contents: write
pull-requests: write
jobs: jobs:
terraform: terraform:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
pull-requests: write
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps: steps:
@@ -41,7 +40,7 @@ jobs:
tags: tag:github tags: tag:github
- name: Configure AWS Credentials - name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2 uses: aws-actions/configure-aws-credentials@f24d7193d98baebaeacc7e2227925dd47cc267f5 # v4.2.0
with: with:
role-to-assume: ${{ secrets.AWS_ASSUME_ROLE_ARN }} role-to-assume: ${{ secrets.AWS_ASSUME_ROLE_ARN }}
aws-region: "eu-central-1" aws-region: "eu-central-1"
@@ -71,7 +70,7 @@ jobs:
working-directory: infra/terraform working-directory: infra/terraform
- name: Post PR comment - name: Post PR comment
uses: borchero/terraform-plan-comment@3399d8dbae8b05185e815e02361ede2949cd99c4 # v2.4.0 uses: borchero/terraform-plan-comment@434458316f8f24dd073cd2561c436cce41dc8f34 # v2.4.1
if: always() && github.ref != 'refs/heads/main' && (steps.plan.outcome == 'success' || steps.plan.outcome == 'failure') if: always() && github.ref != 'refs/heads/main' && (steps.plan.outcome == 'success' || steps.plan.outcome == 'failure')
with: with:
token: ${{ github.token }} token: ${{ github.token }}
@@ -83,4 +82,3 @@ jobs:
if: github.ref == 'refs/heads/main' && github.event_name == 'push' if: github.ref == 'refs/heads/main' && github.event_name == 'push'
run: terraform apply .planfile run: terraform apply .planfile
working-directory: "infra/terraform" working-directory: "infra/terraform"

View File

@@ -11,9 +11,7 @@
"clean": "rimraf .turbo node_modules dist storybook-static" "clean": "rimraf .turbo node_modules dist storybook-static"
}, },
"dependencies": { "dependencies": {
"eslint-plugin-react-refresh": "0.4.20", "eslint-plugin-react-refresh": "0.4.20"
"react": "19.1.0",
"react-dom": "19.1.0"
}, },
"devDependencies": { "devDependencies": {
"@chromatic-com/storybook": "3.2.6", "@chromatic-com/storybook": "3.2.6",

View File

@@ -44,7 +44,7 @@ const Page = async (props: ConnectPageProps) => {
channel={channel} channel={channel}
/> />
<Button <Button
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700" className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost" variant="ghost"
asChild> asChild>
<Link href={`/environments/${environment.id}`}> <Link href={`/environments/${environment.id}`}>

View File

@@ -85,6 +85,7 @@ vi.mock("@/lib/constants", () => ({
OIDC_AUTH_URL: "https://mock-oidc-auth-url.com", OIDC_AUTH_URL: "https://mock-oidc-auth-url.com",
OIDC_ISSUER: "https://mock-oidc-issuer.com", OIDC_ISSUER: "https://mock-oidc-issuer.com",
OIDC_SIGNING_ALGORITHM: "RS256", OIDC_SIGNING_ALGORITHM: "RS256",
SESSION_MAX_AGE: 1000,
})); }));
vi.mock("next/navigation", () => ({ vi.mock("next/navigation", () => ({

View File

@@ -49,7 +49,7 @@ const Page = async (props: XMTemplatePageProps) => {
<XMTemplateList project={project} user={user} environmentId={environment.id} /> <XMTemplateList project={project} user={user} environmentId={environment.id} />
{projects.length >= 2 && ( {projects.length >= 2 && (
<Button <Button
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700" className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost" variant="ghost"
asChild> asChild>
<Link href={`/environments/${environment.id}/surveys`}> <Link href={`/environments/${environment.id}/surveys`}>

View File

@@ -88,6 +88,7 @@ vi.mock("@/lib/constants", () => ({
OIDC_AUTH_URL: "https://mock-oidc-auth-url.com", OIDC_AUTH_URL: "https://mock-oidc-auth-url.com",
OIDC_ISSUER: "https://mock-oidc-issuer.com", OIDC_ISSUER: "https://mock-oidc-issuer.com",
OIDC_SIGNING_ALGORITHM: "RS256", OIDC_SIGNING_ALGORITHM: "RS256",
SESSION_MAX_AGE: 1000,
})); }));
vi.mock("@/lib/environment/service"); vi.mock("@/lib/environment/service");

View File

@@ -1,13 +1,21 @@
import { getOrganizationsByUserId } from "@/lib/organization/service"; import { getOrganizationsByUserId } from "@/lib/organization/service";
import { getUser } from "@/lib/user/service"; import { getUser } from "@/lib/user/service";
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationAuth } from "@/modules/organization/lib/utils"; import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { getTranslate } from "@/tolgee/server"; import { getTranslate } from "@/tolgee/server";
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react"; import { cleanup, render, screen } from "@testing-library/react";
import { notFound, redirect } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import { afterEach, describe, expect, test, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import Page from "./page";
vi.mock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({
active: true,
features: { isMultiOrgEnabled: true },
lastChecked: new Date(),
isPendingDowngrade: false,
fallbackLevel: "live",
}),
}));
vi.mock("@/lib/constants", () => ({ vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false, IS_FORMBRICKS_CLOUD: false,
@@ -89,6 +97,7 @@ vi.mock("@/lib/constants", () => ({
OIDC_AUTH_URL: "https://mock-oidc-auth-url.com", OIDC_AUTH_URL: "https://mock-oidc-auth-url.com",
OIDC_ISSUER: "https://mock-oidc-issuer.com", OIDC_ISSUER: "https://mock-oidc-issuer.com",
OIDC_SIGNING_ALGORITHM: "RS256", OIDC_SIGNING_ALGORITHM: "RS256",
SESSION_MAX_AGE: 1000,
})); }));
vi.mock("@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar", () => ({ vi.mock("@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar", () => ({
@@ -97,23 +106,44 @@ vi.mock("@/app/(app)/(onboarding)/organizations/[organizationId]/landing/compone
vi.mock("@/modules/organization/lib/utils"); vi.mock("@/modules/organization/lib/utils");
vi.mock("@/lib/user/service"); vi.mock("@/lib/user/service");
vi.mock("@/lib/organization/service"); vi.mock("@/lib/organization/service");
vi.mock("@/modules/ee/license-check/lib/utils");
vi.mock("@/tolgee/server"); vi.mock("@/tolgee/server");
vi.mock("next/navigation", () => ({ vi.mock("next/navigation", () => ({
redirect: vi.fn(() => "REDIRECT_STUB"), redirect: vi.fn(() => "REDIRECT_STUB"),
notFound: vi.fn(() => "NOT_FOUND_STUB"), notFound: vi.fn(() => "NOT_FOUND_STUB"),
})); }));
// Mock the React cache function
vi.mock("react", async () => {
const actual = await vi.importActual("react");
return {
...actual,
cache: (fn: any) => fn,
};
});
describe("Page component", () => { describe("Page component", () => {
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
vi.clearAllMocks();
});
beforeEach(() => {
vi.resetModules();
}); });
test("redirects to login if no user session", async () => { test("redirects to login if no user session", async () => {
vi.mocked(getOrganizationAuth).mockResolvedValue({ session: {}, organization: {} } as any); vi.mocked(getOrganizationAuth).mockResolvedValue({ session: {}, organization: {} } as any);
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({
active: true,
features: { isMultiOrgEnabled: true },
lastChecked: new Date(),
isPendingDowngrade: false,
fallbackLevel: "live",
}),
}));
const { default: Page } = await import("./page");
const result = await Page({ params: { organizationId: "org1" } }); const result = await Page({ params: { organizationId: "org1" } });
expect(redirect).toHaveBeenCalledWith("/auth/login"); expect(redirect).toHaveBeenCalledWith("/auth/login");
expect(result).toBe("REDIRECT_STUB"); expect(result).toBe("REDIRECT_STUB");
}); });
@@ -124,9 +154,17 @@ describe("Page component", () => {
organization: {}, organization: {},
} as any); } as any);
vi.mocked(getUser).mockResolvedValue(null); vi.mocked(getUser).mockResolvedValue(null);
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({
active: true,
features: { isMultiOrgEnabled: true },
lastChecked: new Date(),
isPendingDowngrade: false,
fallbackLevel: "live",
}),
}));
const { default: Page } = await import("./page");
const result = await Page({ params: { organizationId: "org1" } }); const result = await Page({ params: { organizationId: "org1" } });
expect(notFound).toHaveBeenCalled(); expect(notFound).toHaveBeenCalled();
expect(result).toBe("NOT_FOUND_STUB"); expect(result).toBe("NOT_FOUND_STUB");
}); });
@@ -138,14 +176,21 @@ describe("Page component", () => {
} as any); } as any);
vi.mocked(getUser).mockResolvedValue({ id: "user1", name: "Test User" } as any); vi.mocked(getUser).mockResolvedValue({ id: "user1", name: "Test User" } as any);
vi.mocked(getOrganizationsByUserId).mockResolvedValue([{ id: "org1", name: "Org One" } as any]); vi.mocked(getOrganizationsByUserId).mockResolvedValue([{ id: "org1", name: "Org One" } as any]);
vi.mocked(getEnterpriseLicense).mockResolvedValue({ features: { isMultiOrgEnabled: true } } as any);
vi.mocked(getTranslate).mockResolvedValue((props: any) => vi.mocked(getTranslate).mockResolvedValue((props: any) =>
typeof props === "string" ? props : props.key || "" typeof props === "string" ? props : props.key || ""
); );
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({
active: true,
features: { isMultiOrgEnabled: true },
lastChecked: new Date(),
isPendingDowngrade: false,
fallbackLevel: "live",
}),
}));
const { default: Page } = await import("./page");
const element = await Page({ params: { organizationId: "org1" } }); const element = await Page({ params: { organizationId: "org1" } });
render(element as React.ReactElement); render(element as React.ReactElement);
expect(screen.getByTestId("landing-sidebar")).toBeInTheDocument(); expect(screen.getByTestId("landing-sidebar")).toBeInTheDocument();
expect(screen.getByText("organizations.landing.no_projects_warning_title")).toBeInTheDocument(); expect(screen.getByText("organizations.landing.no_projects_warning_title")).toBeInTheDocument();
expect(screen.getByText("organizations.landing.no_projects_warning_subtitle")).toBeInTheDocument(); expect(screen.getByText("organizations.landing.no_projects_warning_subtitle")).toBeInTheDocument();

View File

@@ -1,7 +1,7 @@
import { LandingSidebar } from "@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar"; import { LandingSidebar } from "@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar";
import { getOrganizationsByUserId } from "@/lib/organization/service"; import { getOrganizationsByUserId } from "@/lib/organization/service";
import { getUser } from "@/lib/user/service"; import { getUser } from "@/lib/user/service";
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/utils"; import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
import { getOrganizationAuth } from "@/modules/organization/lib/utils"; import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { Header } from "@/modules/ui/components/header"; import { Header } from "@/modules/ui/components/header";
import { getTranslate } from "@/tolgee/server"; import { getTranslate } from "@/tolgee/server";

View File

@@ -34,6 +34,7 @@ vi.mock("@/lib/constants", () => ({
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm", OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
WEBAPP_URL: "test-webapp-url", WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false, IS_PRODUCTION: false,
SESSION_MAX_AGE: 1000,
})); }));
vi.mock("next-auth", () => ({ vi.mock("next-auth", () => ({

View File

@@ -50,7 +50,7 @@ const Page = async (props: ChannelPageProps) => {
<OnboardingOptionsContainer options={channelOptions} /> <OnboardingOptionsContainer options={channelOptions} />
{projects.length >= 1 && ( {projects.length >= 1 && (
<Button <Button
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700" className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost" variant="ghost"
asChild> asChild>
<Link href={"/"}> <Link href={"/"}>

View File

@@ -33,6 +33,7 @@ vi.mock("@/lib/constants", () => ({
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm", OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
WEBAPP_URL: "test-webapp-url", WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false, IS_PRODUCTION: false,
SESSION_MAX_AGE: 1000,
})); }));
// Mock dependencies // Mock dependencies

View File

@@ -225,7 +225,7 @@ export const ProjectSettings = ({
alt="Logo" alt="Logo"
width={256} width={256}
height={56} height={56}
className="absolute top-2 left-2 -mb-6 h-20 w-auto max-w-64 rounded-lg border object-contain p-1" className="absolute left-2 top-2 -mb-6 h-20 w-auto max-w-64 rounded-lg border object-contain p-1"
/> />
)} )}
<p className="text-sm text-slate-400">{t("common.preview")}</p> <p className="text-sm text-slate-400">{t("common.preview")}</p>

View File

@@ -65,7 +65,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
/> />
{projects.length >= 1 && ( {projects.length >= 1 && (
<Button <Button
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700" className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost" variant="ghost"
asChild> asChild>
<Link href={"/"}> <Link href={"/"}>

View File

@@ -25,6 +25,7 @@ vi.mock("@/lib/constants", () => ({
SMTP_HOST: "mock-smtp-host", SMTP_HOST: "mock-smtp-host",
SMTP_PORT: "mock-smtp-port", SMTP_PORT: "mock-smtp-port",
IS_POSTHOG_CONFIGURED: true, IS_POSTHOG_CONFIGURED: true,
SESSION_MAX_AGE: 1000,
})); }));
describe("Contact Page Re-export", () => { describe("Contact Page Re-export", () => {

View File

@@ -23,7 +23,7 @@ export const ActionClassDataRow = ({
</div> </div>
</div> </div>
</div> </div>
<div className="col-span-2 my-auto text-center text-sm whitespace-nowrap text-slate-500"> <div className="col-span-2 my-auto whitespace-nowrap text-center text-sm text-slate-500">
{timeSince(actionClass.createdAt.toString(), locale)} {timeSince(actionClass.createdAt.toString(), locale)}
</div> </div>
<div className="text-center"></div> <div className="text-center"></div>

View File

@@ -1,4 +1,3 @@
import { EnvironmentLayout } from "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout";
import { getEnvironment, getEnvironments } from "@/lib/environment/service"; import { getEnvironment, getEnvironments } from "@/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service"; import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils"; import { getAccessFlags } from "@/lib/membership/utils";
@@ -10,7 +9,7 @@ import {
} from "@/lib/organization/service"; } from "@/lib/organization/service";
import { getUserProjects } from "@/lib/project/service"; import { getUserProjects } from "@/lib/project/service";
import { getUser } from "@/lib/user/service"; import { getUser } from "@/lib/user/service";
import { getEnterpriseLicense, getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils"; import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles"; import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { cleanup, render, screen } from "@testing-library/react"; import { cleanup, render, screen } from "@testing-library/react";
import type { Session } from "next-auth"; import type { Session } from "next-auth";
@@ -49,7 +48,6 @@ vi.mock("@/lib/membership/utils", () => ({
getAccessFlags: vi.fn(() => ({ isMember: true })), // Default to member for simplicity getAccessFlags: vi.fn(() => ({ isMember: true })), // Default to member for simplicity
})); }));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({ vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getEnterpriseLicense: vi.fn(),
getOrganizationProjectsLimit: vi.fn(), getOrganizationProjectsLimit: vi.fn(),
})); }));
vi.mock("@/modules/ee/teams/lib/roles", () => ({ vi.mock("@/modules/ee/teams/lib/roles", () => ({
@@ -176,7 +174,6 @@ describe("EnvironmentLayout", () => {
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership); vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
vi.mocked(getMonthlyActiveOrganizationPeopleCount).mockResolvedValue(100); vi.mocked(getMonthlyActiveOrganizationPeopleCount).mockResolvedValue(100);
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(500); vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(500);
vi.mocked(getEnterpriseLicense).mockResolvedValue(mockLicense);
vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(null as any); vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(null as any);
vi.mocked(getProjectPermissionByUserId).mockResolvedValue(mockProjectPermission); vi.mocked(getProjectPermissionByUserId).mockResolvedValue(mockProjectPermission);
mockIsDevelopment = false; mockIsDevelopment = false;
@@ -189,13 +186,19 @@ describe("EnvironmentLayout", () => {
}); });
test("renders correctly with default props", async () => { test("renders correctly with default props", async () => {
// Ensure the default mockLicense has isPendingDowngrade: false and active: false vi.resetModules();
vi.mocked(getEnterpriseLicense).mockResolvedValue({ await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
...mockLicense, getEnterpriseLicense: vi.fn().mockResolvedValue({
isPendingDowngrade: false, active: false,
active: false, isPendingDowngrade: false,
}); features: { isMultiOrgEnabled: false },
lastChecked: new Date(),
fallbackLevel: "live",
}),
}));
const { EnvironmentLayout } = await import(
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
);
render( render(
await EnvironmentLayout({ await EnvironmentLayout({
environmentId: "env-1", environmentId: "env-1",
@@ -203,20 +206,31 @@ describe("EnvironmentLayout", () => {
children: <div>Child Content</div>, children: <div>Child Content</div>,
}) })
); );
expect(screen.getByTestId("main-navigation")).toBeInTheDocument(); expect(screen.getByTestId("main-navigation")).toBeInTheDocument();
expect(screen.getByTestId("top-control-bar")).toBeInTheDocument(); expect(screen.getByTestId("top-control-bar")).toBeInTheDocument();
expect(screen.getByText("Child Content")).toBeInTheDocument(); expect(screen.getByText("Child Content")).toBeInTheDocument();
expect(screen.queryByTestId("dev-banner")).not.toBeInTheDocument(); expect(screen.queryByTestId("dev-banner")).not.toBeInTheDocument();
expect(screen.queryByTestId("limits-banner")).not.toBeInTheDocument(); expect(screen.queryByTestId("limits-banner")).not.toBeInTheDocument();
expect(screen.queryByTestId("downgrade-banner")).not.toBeInTheDocument(); // This should now pass expect(screen.queryByTestId("downgrade-banner")).not.toBeInTheDocument();
}); });
test("renders DevEnvironmentBanner in development environment", async () => { test("renders DevEnvironmentBanner in development environment", async () => {
const devEnvironment = { ...mockEnvironment, type: "development" as const }; const devEnvironment = { ...mockEnvironment, type: "development" as const };
vi.mocked(getEnvironment).mockResolvedValue(devEnvironment); vi.mocked(getEnvironment).mockResolvedValue(devEnvironment);
mockIsDevelopment = true; mockIsDevelopment = true;
vi.resetModules();
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({
active: false,
isPendingDowngrade: false,
features: { isMultiOrgEnabled: false },
lastChecked: new Date(),
fallbackLevel: "live",
}),
}));
const { EnvironmentLayout } = await import(
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
);
render( render(
await EnvironmentLayout({ await EnvironmentLayout({
environmentId: "env-1", environmentId: "env-1",
@@ -224,13 +238,24 @@ describe("EnvironmentLayout", () => {
children: <div>Child Content</div>, children: <div>Child Content</div>,
}) })
); );
expect(screen.getByTestId("dev-banner")).toBeInTheDocument(); expect(screen.getByTestId("dev-banner")).toBeInTheDocument();
}); });
test("renders LimitsReachedBanner in Formbricks Cloud", async () => { test("renders LimitsReachedBanner in Formbricks Cloud", async () => {
mockIsFormbricksCloud = true; mockIsFormbricksCloud = true;
vi.resetModules();
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({
active: false,
isPendingDowngrade: false,
features: { isMultiOrgEnabled: false },
lastChecked: new Date(),
fallbackLevel: "live",
}),
}));
const { EnvironmentLayout } = await import(
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
);
render( render(
await EnvironmentLayout({ await EnvironmentLayout({
environmentId: "env-1", environmentId: "env-1",
@@ -238,17 +263,21 @@ describe("EnvironmentLayout", () => {
children: <div>Child Content</div>, children: <div>Child Content</div>,
}) })
); );
expect(screen.getByTestId("limits-banner")).toBeInTheDocument(); expect(screen.getByTestId("limits-banner")).toBeInTheDocument();
expect(vi.mocked(getMonthlyActiveOrganizationPeopleCount)).toHaveBeenCalledWith(mockOrganization.id); expect(vi.mocked(getMonthlyActiveOrganizationPeopleCount)).toHaveBeenCalledWith(mockOrganization.id);
expect(vi.mocked(getMonthlyOrganizationResponseCount)).toHaveBeenCalledWith(mockOrganization.id); expect(vi.mocked(getMonthlyOrganizationResponseCount)).toHaveBeenCalledWith(mockOrganization.id);
}); });
test("renders PendingDowngradeBanner when pending downgrade", async () => { test("renders PendingDowngradeBanner when pending downgrade", async () => {
// Ensure the license mock reflects the condition needed for the banner
const pendingLicense = { ...mockLicense, isPendingDowngrade: true, active: true }; const pendingLicense = { ...mockLicense, isPendingDowngrade: true, active: true };
vi.mocked(getEnterpriseLicense).mockResolvedValue(pendingLicense); vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(null as any);
vi.resetModules();
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue(pendingLicense),
}));
const { EnvironmentLayout } = await import(
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
);
render( render(
await EnvironmentLayout({ await EnvironmentLayout({
environmentId: "env-1", environmentId: "env-1",
@@ -256,12 +285,24 @@ describe("EnvironmentLayout", () => {
children: <div>Child Content</div>, children: <div>Child Content</div>,
}) })
); );
expect(screen.getByTestId("downgrade-banner")).toBeInTheDocument(); expect(screen.getByTestId("downgrade-banner")).toBeInTheDocument();
}); });
test("throws error if user not found", async () => { test("throws error if user not found", async () => {
vi.mocked(getUser).mockResolvedValue(null); vi.mocked(getUser).mockResolvedValue(null);
vi.resetModules();
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({
active: false,
isPendingDowngrade: false,
features: { isMultiOrgEnabled: false },
lastChecked: new Date(),
fallbackLevel: "live",
}),
}));
const { EnvironmentLayout } = await import(
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
);
await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow( await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
"common.user_not_found" "common.user_not_found"
); );
@@ -269,6 +310,19 @@ describe("EnvironmentLayout", () => {
test("throws error if organization not found", async () => { test("throws error if organization not found", async () => {
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null); vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null);
vi.resetModules();
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({
active: false,
isPendingDowngrade: false,
features: { isMultiOrgEnabled: false },
lastChecked: new Date(),
fallbackLevel: "live",
}),
}));
const { EnvironmentLayout } = await import(
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
);
await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow( await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
"common.organization_not_found" "common.organization_not_found"
); );
@@ -276,13 +330,39 @@ describe("EnvironmentLayout", () => {
test("throws error if environment not found", async () => { test("throws error if environment not found", async () => {
vi.mocked(getEnvironment).mockResolvedValue(null); vi.mocked(getEnvironment).mockResolvedValue(null);
vi.resetModules();
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({
active: false,
isPendingDowngrade: false,
features: { isMultiOrgEnabled: false },
lastChecked: new Date(),
fallbackLevel: "live",
}),
}));
const { EnvironmentLayout } = await import(
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
);
await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow( await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
"common.environment_not_found" "common.environment_not_found"
); );
}); });
test("throws error if projects, environments or organizations not found", async () => { test("throws error if projects, environments or organizations not found", async () => {
vi.mocked(getUserProjects).mockResolvedValue(null as any); // Simulate one of the promises failing vi.mocked(getUserProjects).mockResolvedValue(null as any);
vi.resetModules();
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({
active: false,
isPendingDowngrade: false,
features: { isMultiOrgEnabled: false },
lastChecked: new Date(),
fallbackLevel: "live",
}),
}));
const { EnvironmentLayout } = await import(
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
);
await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow( await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
"environments.projects_environments_organizations_not_found" "environments.projects_environments_organizations_not_found"
); );
@@ -291,6 +371,19 @@ describe("EnvironmentLayout", () => {
test("throws error if member has no project permission", async () => { test("throws error if member has no project permission", async () => {
vi.mocked(getAccessFlags).mockReturnValue({ isMember: true } as any); vi.mocked(getAccessFlags).mockReturnValue({ isMember: true } as any);
vi.mocked(getProjectPermissionByUserId).mockResolvedValue(null); vi.mocked(getProjectPermissionByUserId).mockResolvedValue(null);
vi.resetModules();
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({
active: false,
isPendingDowngrade: false,
features: { isMultiOrgEnabled: false },
lastChecked: new Date(),
fallbackLevel: "live",
}),
}));
const { EnvironmentLayout } = await import(
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
);
await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow( await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
"common.project_permission_not_found" "common.project_permission_not_found"
); );

View File

@@ -12,7 +12,8 @@ import {
} from "@/lib/organization/service"; } from "@/lib/organization/service";
import { getUserProjects } from "@/lib/project/service"; import { getUserProjects } from "@/lib/project/service";
import { getUser } from "@/lib/user/service"; import { getUser } from "@/lib/user/service";
import { getEnterpriseLicense, getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils"; import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles"; import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { DevEnvironmentBanner } from "@/modules/ui/components/dev-environment-banner"; import { DevEnvironmentBanner } from "@/modules/ui/components/dev-environment-banner";
import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-banner"; import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-banner";

View File

@@ -109,7 +109,7 @@ export const MainNavigation = ({
useEffect(() => { useEffect(() => {
const toggleTextOpacity = () => { const toggleTextOpacity = () => {
setIsTextVisible(isCollapsed ? true : false); setIsTextVisible(isCollapsed);
}; };
const timeoutId = setTimeout(toggleTextOpacity, 150); const timeoutId = setTimeout(toggleTextOpacity, 150);
return () => clearTimeout(timeoutId); return () => clearTimeout(timeoutId);
@@ -170,7 +170,7 @@ export const MainNavigation = ({
name: t("common.actions"), name: t("common.actions"),
href: `/environments/${environment.id}/actions`, href: `/environments/${environment.id}/actions`,
icon: MousePointerClick, icon: MousePointerClick,
isActive: pathname?.includes("/actions") || pathname?.includes("/actions"), isActive: pathname?.includes("/actions"),
}, },
{ {
name: t("common.integrations"), name: t("common.integrations"),

View File

@@ -53,7 +53,7 @@ export const WidgetStatusIndicator = ({ environment }: WidgetStatusIndicatorProp
<currentStatus.icon /> <currentStatus.icon />
</div> </div>
<p className="text-md font-bold text-slate-800 md:text-xl">{currentStatus.title}</p> <p className="text-md font-bold text-slate-800 md:text-xl">{currentStatus.title}</p>
<p className="w-2/3 text-sm text-balance text-slate-600">{currentStatus.subtitle}</p> <p className="w-2/3 text-balance text-sm text-slate-600">{currentStatus.subtitle}</p>
{status === "notImplemented" && ( {status === "notImplemented" && (
<Button variant="outline" size="sm" className="bg-white" onClick={() => router.refresh()}> <Button variant="outline" size="sm" className="bg-white" onClick={() => router.refresh()}>
<RotateCcwIcon /> <RotateCcwIcon />

View File

@@ -48,6 +48,7 @@ vi.mock("@/lib/constants", () => ({
OIDC_CLIENT_SECRET: "test-oidc-client-secret", OIDC_CLIENT_SECRET: "test-oidc-client-secret",
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm", OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
SENTRY_DSN: "mock-sentry-dsn", SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
})); }));
vi.mock("@/lib/integration/service"); vi.mock("@/lib/integration/service");

View File

@@ -255,7 +255,7 @@ export const AddIntegrationModal = ({
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<Label htmlFor="Surveys">{t("common.questions")}</Label> <Label htmlFor="Surveys">{t("common.questions")}</Label>
<div className="mt-1 max-h-[15vh] overflow-x-hidden overflow-y-auto rounded-lg border border-slate-200"> <div className="mt-1 max-h-[15vh] overflow-y-auto overflow-x-hidden rounded-lg border border-slate-200">
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900"> <div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
{replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((question) => ( {replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((question) => (
<div key={question.id} className="my-1 flex items-center space-x-2"> <div key={question.id} className="my-1 flex items-center space-x-2">

View File

@@ -31,6 +31,7 @@ vi.mock("@/lib/constants", () => ({
SENTRY_DSN: "mock-sentry-dsn", SENTRY_DSN: "mock-sentry-dsn",
GOOGLE_SHEETS_CLIENT_SECRET: "test-client-secret", GOOGLE_SHEETS_CLIENT_SECRET: "test-client-secret",
GOOGLE_SHEETS_REDIRECT_URL: "test-redirect-url", GOOGLE_SHEETS_REDIRECT_URL: "test-redirect-url",
SESSION_MAX_AGE: 1000,
})); }));
// Mock child components // Mock child components

View File

@@ -24,6 +24,7 @@ vi.mock("@/lib/constants", () => ({
WEBAPP_URL: "test-webapp-url", WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false, IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn", SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
})); }));
describe("AppConnectionPage Re-export", () => { describe("AppConnectionPage Re-export", () => {

View File

@@ -24,6 +24,7 @@ vi.mock("@/lib/constants", () => ({
WEBAPP_URL: "test-webapp-url", WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false, IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn", SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
})); }));
describe("GeneralSettingsPage re-export", () => { describe("GeneralSettingsPage re-export", () => {

View File

@@ -24,6 +24,7 @@ vi.mock("@/lib/constants", () => ({
WEBAPP_URL: "test-webapp-url", WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false, IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn", SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
})); }));
describe("LanguagesPage re-export", () => { describe("LanguagesPage re-export", () => {

View File

@@ -24,6 +24,7 @@ vi.mock("@/lib/constants", () => ({
WEBAPP_URL: "test-webapp-url", WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false, IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn", SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
})); }));
describe("ProjectLookSettingsPage re-export", () => { describe("ProjectLookSettingsPage re-export", () => {

View File

@@ -24,6 +24,7 @@ vi.mock("@/lib/constants", () => ({
WEBAPP_URL: "test-webapp-url", WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false, IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn", SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
})); }));
describe("TagsPage re-export", () => { describe("TagsPage re-export", () => {

View File

@@ -24,6 +24,7 @@ vi.mock("@/lib/constants", () => ({
WEBAPP_URL: "test-webapp-url", WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false, IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn", SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
})); }));
describe("ProjectTeams re-export", () => { describe("ProjectTeams re-export", () => {

View File

@@ -40,6 +40,7 @@ vi.mock("@/lib/constants", () => ({
WEBAPP_URL: "test-webapp-url", WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false, IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn", SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
})); }));
const mockGetOrganizationByEnvironmentId = vi.mocked(getOrganizationByEnvironmentId); const mockGetOrganizationByEnvironmentId = vi.mocked(getOrganizationByEnvironmentId);

View File

@@ -1,17 +1,80 @@
"use server"; "use server";
import {
checkUserExistsByEmail,
verifyUserPassword,
} from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user";
import { EMAIL_VERIFICATION_DISABLED } from "@/lib/constants";
import { deleteFile } from "@/lib/storage/service"; import { deleteFile } from "@/lib/storage/service";
import { getFileNameWithIdFromUrl } from "@/lib/storage/utils"; import { getFileNameWithIdFromUrl } from "@/lib/storage/utils";
import { updateUser } from "@/lib/user/service"; import { updateUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client"; import { authenticatedActionClient } from "@/lib/utils/action-client";
import { rateLimit } from "@/lib/utils/rate-limit";
import { sendVerificationNewEmail } from "@/modules/email";
import { z } from "zod"; import { z } from "zod";
import { ZId } from "@formbricks/types/common"; import { ZId } from "@formbricks/types/common";
import { ZUserUpdateInput } from "@formbricks/types/user"; import {
AuthenticationError,
AuthorizationError,
InvalidInputError,
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
});
export const updateUserAction = authenticatedActionClient export const updateUserAction = authenticatedActionClient
.schema(ZUserUpdateInput.pick({ name: true, locale: true })) .schema(
ZUserUpdateInput.pick({ name: true, email: true, locale: true }).extend({
password: ZUserPassword.optional(),
})
)
.action(async ({ parsedInput, ctx }) => { .action(async ({ parsedInput, ctx }) => {
return await updateUser(ctx.user.id, parsedInput); const inputEmail = parsedInput.email?.trim().toLowerCase();
let payload: TUserUpdateInput = {
name: parsedInput.name,
locale: parsedInput.locale,
};
if (inputEmail && ctx.user.email !== inputEmail) {
// Check rate limit
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.");
}
if (!parsedInput.password) {
throw new AuthenticationError("Password is required to update email.");
}
const isCorrectPassword = await verifyUserPassword(ctx.user.id, parsedInput.password);
if (!isCorrectPassword) {
throw new AuthorizationError("Incorrect credentials");
}
const doesUserExist = await checkUserExistsByEmail(inputEmail);
if (doesUserExist) {
throw new InvalidInputError("This email is already in use");
}
if (EMAIL_VERIFICATION_DISABLED) {
payload.email = inputEmail;
} else {
await sendVerificationNewEmail(ctx.user.id, inputEmail);
}
}
return await updateUser(ctx.user.id, payload);
}); });
const ZUpdateAvatarAction = z.object({ const ZUpdateAvatarAction = z.object({

View File

@@ -50,11 +50,10 @@ describe("EditProfileDetailsForm", () => {
test("renders with initial user data and updates successfully", async () => { test("renders with initial user data and updates successfully", async () => {
vi.mocked(updateUserAction).mockResolvedValue({ ...mockUser, name: "New Name" } as any); vi.mocked(updateUserAction).mockResolvedValue({ ...mockUser, name: "New Name" } as any);
render(<EditProfileDetailsForm user={mockUser} />); render(<EditProfileDetailsForm user={mockUser} emailVerificationDisabled={true} />);
const nameInput = screen.getByPlaceholderText("common.full_name"); const nameInput = screen.getByPlaceholderText("common.full_name");
expect(nameInput).toHaveValue(mockUser.name); expect(nameInput).toHaveValue(mockUser.name);
expect(screen.getByDisplayValue(mockUser.email)).toBeDisabled();
// Check initial language (English) // Check initial language (English)
expect(screen.getByText("English (US)")).toBeInTheDocument(); expect(screen.getByText("English (US)")).toBeInTheDocument();
@@ -72,7 +71,11 @@ describe("EditProfileDetailsForm", () => {
await userEvent.click(updateButton); await userEvent.click(updateButton);
await waitFor(() => { await waitFor(() => {
expect(updateUserAction).toHaveBeenCalledWith({ name: "New Name", locale: "de-DE" }); expect(updateUserAction).toHaveBeenCalledWith({
name: "New Name",
locale: "de-DE",
email: mockUser.email,
});
}); });
await waitFor(() => { await waitFor(() => {
expect(toast.success).toHaveBeenCalledWith( expect(toast.success).toHaveBeenCalledWith(
@@ -88,7 +91,7 @@ describe("EditProfileDetailsForm", () => {
const errorMessage = "Update failed"; const errorMessage = "Update failed";
vi.mocked(updateUserAction).mockRejectedValue(new Error(errorMessage)); vi.mocked(updateUserAction).mockRejectedValue(new Error(errorMessage));
render(<EditProfileDetailsForm user={mockUser} />); render(<EditProfileDetailsForm user={mockUser} emailVerificationDisabled={false} />);
const nameInput = screen.getByPlaceholderText("common.full_name"); const nameInput = screen.getByPlaceholderText("common.full_name");
await userEvent.clear(nameInput); await userEvent.clear(nameInput);
@@ -106,7 +109,7 @@ describe("EditProfileDetailsForm", () => {
}); });
test("update button is disabled initially and enables on change", async () => { test("update button is disabled initially and enables on change", async () => {
render(<EditProfileDetailsForm user={mockUser} />); render(<EditProfileDetailsForm user={mockUser} emailVerificationDisabled={false} />);
const updateButton = screen.getByText("common.update"); const updateButton = screen.getByText("common.update");
expect(updateButton).toBeDisabled(); expect(updateButton).toBeDisabled();

View File

@@ -1,6 +1,8 @@
"use client"; "use client";
import { PasswordConfirmationModal } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/password-confirmation-modal";
import { appLanguages } from "@/lib/i18n/utils"; import { appLanguages } from "@/lib/i18n/utils";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { import {
DropdownMenu, DropdownMenu,
@@ -8,129 +10,214 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu"; } from "@/modules/ui/components/dropdown-menu";
import { import { FormControl, FormError, FormField, FormItem, FormLabel } from "@/modules/ui/components/form";
FormControl,
FormError,
FormField,
FormItem,
FormLabel,
FormProvider,
} from "@/modules/ui/components/form";
import { Input } from "@/modules/ui/components/input"; import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslate } from "@tolgee/react"; import { useTranslate } from "@tolgee/react";
import { ChevronDownIcon } from "lucide-react"; import { ChevronDownIcon } from "lucide-react";
import { SubmitHandler, useForm } from "react-hook-form"; import { signOut } from "next-auth/react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { z } from "zod"; import { z } from "zod";
import { TUser, ZUser } from "@formbricks/types/user"; import { TUser, TUserUpdateInput, ZUser } from "@formbricks/types/user";
import { updateUserAction } from "../actions"; import { updateUserAction } from "../actions";
const ZEditProfileNameFormSchema = ZUser.pick({ name: true, locale: true }); // Schema & types
const ZEditProfileNameFormSchema = ZUser.pick({ name: true, locale: true, email: true });
type TEditProfileNameForm = z.infer<typeof ZEditProfileNameFormSchema>; type TEditProfileNameForm = z.infer<typeof ZEditProfileNameFormSchema>;
export const EditProfileDetailsForm = ({ user }: { user: TUser }) => { export const EditProfileDetailsForm = ({
user,
emailVerificationDisabled,
}: {
user: TUser;
emailVerificationDisabled: boolean;
}) => {
const { t } = useTranslate();
const router = useRouter();
const form = useForm<TEditProfileNameForm>({ const form = useForm<TEditProfileNameForm>({
defaultValues: { name: user.name, locale: user.locale || "en" }, defaultValues: {
name: user.name,
locale: user.locale,
email: user.email,
},
mode: "onChange", mode: "onChange",
resolver: zodResolver(ZEditProfileNameFormSchema), resolver: zodResolver(ZEditProfileNameFormSchema),
}); });
const { isSubmitting, isDirty } = form.formState; const { isSubmitting, isDirty } = form.formState;
const { t } = useTranslate(); const [showModal, setShowModal] = useState(false);
const handleConfirmPassword = async (password: string) => {
const values = form.getValues();
const dirtyFields = form.formState.dirtyFields;
const emailChanged = "email" in dirtyFields;
const nameChanged = "name" in dirtyFields;
const localeChanged = "locale" in dirtyFields;
const name = values.name.trim();
const email = values.email.trim().toLowerCase();
const locale = values.locale;
const data: TUserUpdateInput = {};
if (emailChanged) {
data.email = email;
data.password = password;
}
if (nameChanged) {
data.name = name;
}
if (localeChanged) {
data.locale = locale;
}
const updatedUserResult = await updateUserAction(data);
if (updatedUserResult?.data) {
if (!emailVerificationDisabled) {
toast.success(t("auth.verification-requested.verification_email_successfully_sent", { email }));
} else {
toast.success(t("environments.settings.profile.profile_updated_successfully"));
await signOut({ redirect: false });
router.push(`/email-change-without-verification-success`);
return;
}
} else {
const errorMessage = getFormattedErrorMessage(updatedUserResult);
toast.error(errorMessage);
return;
}
window.location.reload();
setShowModal(false);
};
const onSubmit: SubmitHandler<TEditProfileNameForm> = async (data) => { const onSubmit: SubmitHandler<TEditProfileNameForm> = async (data) => {
try { if (data.email !== user.email && data.email.toLowerCase() === user.email.toLowerCase()) {
const name = data.name.trim(); toast.error(t("auth.email-change.email_already_exists"));
const locale = data.locale; return;
await updateUserAction({ name, locale }); }
toast.success(t("environments.settings.profile.profile_updated_successfully"));
window.location.reload(); if (data.email !== user.email) {
form.reset({ name, locale }); setShowModal(true);
} catch (error) { } else {
toast.error(`${t("common.error")}: ${error.message}`); try {
await updateUserAction({
...data,
name: data.name.trim(),
});
toast.success(t("environments.settings.profile.profile_updated_successfully"));
window.location.reload();
form.reset(data);
} catch (error: any) {
toast.error(`${t("common.error")}: ${error.message}`);
}
} }
}; };
return ( return (
<FormProvider {...form}> <>
<form className="w-full max-w-sm items-center" onSubmit={form.handleSubmit(onSubmit)}> <FormProvider {...form}>
<FormField <form className="w-full max-w-sm" onSubmit={form.handleSubmit(onSubmit)}>
control={form.control} <FormField
name="name" control={form.control}
render={({ field }) => ( name="name"
<FormItem> render={({ field }) => (
<FormLabel>{t("common.full_name")}</FormLabel> <FormItem>
<FormControl> <FormLabel>{t("common.full_name")}</FormLabel>
<Input <FormControl>
{...field} <Input
type="text" {...field}
placeholder={t("common.full_name")} type="text"
required required
isInvalid={!!form.formState.errors.name} placeholder={t("common.full_name")}
/> isInvalid={!!form.formState.errors.name}
</FormControl> />
<FormError /> </FormControl>
</FormItem> <FormError />
)} </FormItem>
/> )}
/>
{/* disabled email field */} <FormField
<div className="mt-4 space-y-2"> control={form.control}
<Label htmlFor="email">{t("common.email")}</Label> name="email"
<Input type="email" id="email" defaultValue={user.email} disabled /> render={({ field }) => (
</div> <FormItem className="mt-4">
<FormLabel>{t("common.email")}</FormLabel>
<FormControl>
<Input
{...field}
type="email"
required
isInvalid={!!form.formState.errors.email}
disabled={user.identityProvider !== "email"}
/>
</FormControl>
<FormError />
</FormItem>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
name="locale" name="locale"
render={({ field }) => ( render={({ field }) => (
<FormItem className="mt-4"> <FormItem className="mt-4">
<FormLabel>{t("common.language")}</FormLabel> <FormLabel>{t("common.language")}</FormLabel>
<FormControl> <FormControl>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
type="button" type="button"
className="h-10 w-full border border-slate-300 px-3 text-left" variant="ghost"
variant="ghost"> className="h-10 w-full border border-slate-300 px-3 text-left">
<div className="flex w-full items-center justify-between"> <div className="flex w-full items-center justify-between">
{appLanguages.find((language) => language.code === field.value)?.label[field.value] || {appLanguages.find((l) => l.code === field.value)?.label[field.value] ?? "NA"}
"NA"} <ChevronDownIcon className="h-4 w-4 text-slate-500" />
<ChevronDownIcon className="h-4 w-4 text-slate-500" /> </div>
</div> </Button>
</Button> </DropdownMenuTrigger>
</DropdownMenuTrigger> <DropdownMenuContent className="w-40 bg-slate-50 text-slate-700" align="start">
<DropdownMenuContent {appLanguages.map((lang) => (
className="w-40 bg-slate-50 text-slate-700" <DropdownMenuItem
align="start" key={lang.code}
side="bottom"> onClick={() => field.onChange(lang.code)}
{appLanguages.map((language) => ( className="min-h-8 cursor-pointer">
<DropdownMenuItem {lang.label[field.value]}
key={language.code} </DropdownMenuItem>
onClick={() => field.onChange(language.code)} ))}
className="min-h-8 cursor-pointer"> </DropdownMenuContent>
{language.label[field.value]} </DropdownMenu>
</DropdownMenuItem> </FormControl>
))} <FormError />
</DropdownMenuContent> </FormItem>
</DropdownMenu> )}
</FormControl> />
<FormError />
</FormItem>
)}
/>
<Button <Button
type="submit" type="submit"
className="mt-4" className="mt-4"
size="sm" size="sm"
loading={isSubmitting} loading={isSubmitting}
disabled={isSubmitting || !isDirty}> disabled={isSubmitting || !isDirty}>
{t("common.update")} {t("common.update")}
</Button> </Button>
</form> </form>
</FormProvider> </FormProvider>
<PasswordConfirmationModal
open={showModal}
setOpen={setShowModal}
oldEmail={user.email}
newEmail={form.getValues("email") || user.email}
onConfirm={handleConfirmPassword}
/>
</>
); );
}; };

View File

@@ -0,0 +1,132 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { PasswordConfirmationModal } from "./password-confirmation-modal";
// Mock the Modal component
vi.mock("@/modules/ui/components/modal", () => ({
Modal: ({ children, open, setOpen, title }: any) =>
open ? (
<div data-testid="modal">
<div data-testid="modal-title">{title}</div>
{children}
<button data-testid="modal-close" onClick={() => setOpen(false)}>
Close
</button>
</div>
) : null,
}));
// Mock the PasswordInput component
vi.mock("@/modules/ui/components/password-input", () => ({
PasswordInput: ({ onChange, value, placeholder }: any) => (
<input
type="password"
value={value || ""}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
data-testid="password-input"
/>
),
}));
// Mock the useTranslate hook
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => key,
}),
}));
describe("PasswordConfirmationModal", () => {
const defaultProps = {
open: true,
setOpen: vi.fn(),
oldEmail: "old@example.com",
newEmail: "new@example.com",
onConfirm: vi.fn(),
};
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders nothing when open is false", () => {
render(<PasswordConfirmationModal {...defaultProps} open={false} />);
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
});
test("renders modal content when open is true", () => {
render(<PasswordConfirmationModal {...defaultProps} />);
expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(screen.getByTestId("modal-title")).toBeInTheDocument();
});
test("displays old and new email addresses", () => {
render(<PasswordConfirmationModal {...defaultProps} />);
expect(screen.getByText("old@example.com")).toBeInTheDocument();
expect(screen.getByText("new@example.com")).toBeInTheDocument();
});
test("shows password input field", () => {
render(<PasswordConfirmationModal {...defaultProps} />);
const passwordInput = screen.getByTestId("password-input");
expect(passwordInput).toBeInTheDocument();
expect(passwordInput).toHaveAttribute("placeholder", "*******");
});
test("disables confirm button when form is not dirty", () => {
render(<PasswordConfirmationModal {...defaultProps} />);
const confirmButton = screen.getByText("common.confirm");
expect(confirmButton).toBeDisabled();
});
test("disables confirm button when old and new emails are the same", () => {
render(
<PasswordConfirmationModal {...defaultProps} oldEmail="same@example.com" newEmail="same@example.com" />
);
const confirmButton = screen.getByText("common.confirm");
expect(confirmButton).toBeDisabled();
});
test("enables confirm button when password is entered and emails are different", async () => {
const user = userEvent.setup();
render(<PasswordConfirmationModal {...defaultProps} />);
const passwordInput = screen.getByTestId("password-input");
await user.type(passwordInput, "password123");
const confirmButton = screen.getByText("common.confirm");
expect(confirmButton).not.toBeDisabled();
});
test("shows error message when password is too short", async () => {
const user = userEvent.setup();
render(<PasswordConfirmationModal {...defaultProps} />);
const passwordInput = screen.getByTestId("password-input");
await user.type(passwordInput, "short");
const confirmButton = screen.getByText("common.confirm");
await user.click(confirmButton);
expect(screen.getByText("String must contain at least 8 character(s)")).toBeInTheDocument();
});
test("handles cancel button click and resets form", async () => {
const user = userEvent.setup();
render(<PasswordConfirmationModal {...defaultProps} />);
const passwordInput = screen.getByTestId("password-input");
await user.type(passwordInput, "password123");
const cancelButton = screen.getByText("common.cancel");
await user.click(cancelButton);
expect(defaultProps.setOpen).toHaveBeenCalledWith(false);
await waitFor(() => {
expect(passwordInput).toHaveValue("");
});
});
});

View File

@@ -0,0 +1,117 @@
"use client";
import { Button } from "@/modules/ui/components/button";
import { FormControl, FormError, FormField, FormItem } from "@/modules/ui/components/form";
import { Modal } from "@/modules/ui/components/modal";
import { PasswordInput } from "@/modules/ui/components/password-input";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslate } from "@tolgee/react";
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
import { z } from "zod";
import { ZUserPassword } from "@formbricks/types/user";
interface PasswordConfirmationModalProps {
open: boolean;
setOpen: (open: boolean) => void;
oldEmail: string;
newEmail: string;
onConfirm: (password: string) => Promise<void>;
}
const PasswordConfirmationSchema = z.object({
password: ZUserPassword,
});
type FormValues = z.infer<typeof PasswordConfirmationSchema>;
export const PasswordConfirmationModal = ({
open,
setOpen,
oldEmail,
newEmail,
onConfirm,
}: PasswordConfirmationModalProps) => {
const { t } = useTranslate();
const form = useForm<FormValues>({
resolver: zodResolver(PasswordConfirmationSchema),
});
const { isSubmitting, isDirty } = form.formState;
const onSubmit: SubmitHandler<FormValues> = async (data) => {
try {
await onConfirm(data.password);
form.reset();
} catch (error) {
form.setError("password", {
message: error instanceof Error ? error.message : "Authentication failed",
});
}
};
const handleCancel = () => {
form.reset();
setOpen(false);
};
return (
<Modal open={open} setOpen={setOpen} title={t("auth.forgot-password.reset.confirm_password")}>
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<p className="text-muted-foreground text-sm">
{t("auth.email-change.confirm_password_description")}
</p>
<div className="flex flex-col gap-2 text-sm sm:flex-row sm:justify-between sm:gap-4">
<p>
<strong>{t("auth.email-change.old_email")}:</strong>
<br /> {oldEmail.toLowerCase()}
</p>
<p>
<strong>{t("auth.email-change.new_email")}:</strong>
<br /> {newEmail.toLowerCase()}
</p>
</div>
<FormField
control={form.control}
name="password"
render={({ field, fieldState: { error } }) => (
<FormItem className="w-full">
<FormControl>
<div>
<PasswordInput
id="password"
autoComplete="current-password"
placeholder="*******"
aria-placeholder="password"
aria-label="password"
aria-required="true"
required
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
value={field.value}
onChange={(password) => field.onChange(password)}
/>
{error?.message && <FormError className="text-left">{error.message}</FormError>}
</div>
</FormControl>
</FormItem>
)}
/>
<div className="mt-4 space-x-2 text-right">
<Button type="button" variant="secondary" onClick={handleCancel}>
{t("common.cancel")}
</Button>
<Button
type="submit"
variant="default"
loading={isSubmitting}
disabled={isSubmitting || !isDirty || oldEmail.toLowerCase() === newEmail.toLowerCase()}>
{t("common.confirm")}
</Button>
</div>
</form>
</FormProvider>
</Modal>
);
};

View File

@@ -0,0 +1,146 @@
import { verifyPassword as mockVerifyPasswordImported } from "@/modules/auth/lib/utils";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { checkUserExistsByEmail, verifyUserPassword } from "./user";
// Mock dependencies
vi.mock("@/lib/user/cache", () => ({
userCache: {
tag: {
byId: vi.fn((id) => `user-${id}-tag`),
byEmail: vi.fn((email) => `user-email-${email}-tag`),
},
},
}));
vi.mock("@/modules/auth/lib/utils", () => ({
verifyPassword: vi.fn(),
}));
vi.mock("@formbricks/database", () => ({
prisma: {
user: {
findUnique: vi.fn(),
},
},
}));
// reactCache (from "react") and unstable_cache (from "next/cache") are mocked in vitestSetup.ts
// to be pass-through, so the inner logic of cached functions is tested.
const mockPrismaUserFindUnique = vi.mocked(prisma.user.findUnique);
const mockVerifyPasswordUtil = vi.mocked(mockVerifyPasswordImported);
describe("User Library Tests", () => {
beforeEach(() => {
vi.resetAllMocks();
});
describe("verifyUserPassword", () => {
const userId = "test-user-id";
const password = "test-password";
test("should return true for correct password", async () => {
mockPrismaUserFindUnique.mockResolvedValue({
password: "hashed-password",
identityProvider: "email",
} as any);
mockVerifyPasswordUtil.mockResolvedValue(true);
const result = await verifyUserPassword(userId, password);
expect(result).toBe(true);
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { id: userId },
select: { password: true, identityProvider: true },
});
expect(mockVerifyPasswordUtil).toHaveBeenCalledWith(password, "hashed-password");
});
test("should return false for incorrect password", async () => {
mockPrismaUserFindUnique.mockResolvedValue({
password: "hashed-password",
identityProvider: "email",
} as any);
mockVerifyPasswordUtil.mockResolvedValue(false);
const result = await verifyUserPassword(userId, password);
expect(result).toBe(false);
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { id: userId },
select: { password: true, identityProvider: true },
});
expect(mockVerifyPasswordUtil).toHaveBeenCalledWith(password, "hashed-password");
});
test("should throw ResourceNotFoundError if user not found", async () => {
mockPrismaUserFindUnique.mockResolvedValue(null);
await expect(verifyUserPassword(userId, password)).rejects.toThrow(ResourceNotFoundError);
await expect(verifyUserPassword(userId, password)).rejects.toThrow(`user with ID ${userId} not found`);
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { id: userId },
select: { password: true, identityProvider: true },
});
expect(mockVerifyPasswordUtil).not.toHaveBeenCalled();
});
test("should throw InvalidInputError if identityProvider is not email", async () => {
mockPrismaUserFindUnique.mockResolvedValue({
password: "hashed-password",
identityProvider: "google", // Not 'email'
} as any);
await expect(verifyUserPassword(userId, password)).rejects.toThrow(InvalidInputError);
await expect(verifyUserPassword(userId, password)).rejects.toThrow("Password is not set for this user");
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { id: userId },
select: { password: true, identityProvider: true },
});
expect(mockVerifyPasswordUtil).not.toHaveBeenCalled();
});
test("should throw InvalidInputError if password is not set for email provider", async () => {
mockPrismaUserFindUnique.mockResolvedValue({
password: null, // Password not set
identityProvider: "email",
} as any);
await expect(verifyUserPassword(userId, password)).rejects.toThrow(InvalidInputError);
await expect(verifyUserPassword(userId, password)).rejects.toThrow("Password is not set for this user");
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { id: userId },
select: { password: true, identityProvider: true },
});
expect(mockVerifyPasswordUtil).not.toHaveBeenCalled();
});
});
describe("checkUserExistsByEmail", () => {
const email = "test@example.com";
test("should return true if user exists", async () => {
mockPrismaUserFindUnique.mockResolvedValue({
id: "some-user-id",
} as any);
const result = await checkUserExistsByEmail(email);
expect(result).toBe(true);
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { email },
select: { id: true },
});
});
test("should return false if user does not exist", async () => {
mockPrismaUserFindUnique.mockResolvedValue(null);
const result = await checkUserExistsByEmail(email);
expect(result).toBe(false);
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { email },
select: { id: true },
});
});
});
});

View File

@@ -0,0 +1,70 @@
import { cache } from "@/lib/cache";
import { userCache } from "@/lib/user/cache";
import { verifyPassword } from "@/modules/auth/lib/utils";
import { User } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
export const getUserById = reactCache(
async (userId: string): Promise<Pick<User, "password" | "identityProvider">> =>
cache(
async () => {
const user = await prisma.user.findUnique({
where: {
id: userId,
},
select: {
password: true,
identityProvider: true,
},
});
if (!user) {
throw new ResourceNotFoundError("user", userId);
}
return user;
},
[`getUserById-${userId}`],
{
tags: [userCache.tag.byId(userId)],
}
)()
);
export const verifyUserPassword = async (userId: string, password: string): Promise<boolean> => {
const user = await getUserById(userId);
if (user.identityProvider !== "email" || !user.password) {
throw new InvalidInputError("Password is not set for this user");
}
const isCorrectPassword = await verifyPassword(password, user.password);
if (!isCorrectPassword) {
return false;
}
return true;
};
export const checkUserExistsByEmail = reactCache(
async (email: string): Promise<boolean> =>
cache(
async () => {
const user = await prisma.user.findUnique({
where: {
email: email.toLowerCase(),
},
select: {
id: true,
},
});
return !!user;
},
[`checkUserExistsByEmail-${email}`],
{
tags: [userCache.tag.byEmail(email)],
}
)()
);

View File

@@ -13,6 +13,7 @@ import Page from "./page";
// Mock services and utils // Mock services and utils
vi.mock("@/lib/constants", () => ({ vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: true, IS_FORMBRICKS_CLOUD: true,
EMAIL_VERIFICATION_DISABLED: true,
})); }));
vi.mock("@/lib/organization/service", () => ({ vi.mock("@/lib/organization/service", () => ({
getOrganizationsWhereUserIsSingleOwner: vi.fn(), getOrganizationsWhereUserIsSingleOwner: vi.fn(),

View File

@@ -1,6 +1,6 @@
import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar"; import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
import { AccountSecurity } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/AccountSecurity"; import { AccountSecurity } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/AccountSecurity";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants"; import { EMAIL_VERIFICATION_DISABLED, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service"; import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
import { getUser } from "@/lib/user/service"; import { getUser } from "@/lib/user/service";
import { getIsMultiOrgEnabled, getIsTwoFactorAuthEnabled } from "@/modules/ee/license-check/lib/utils"; import { getIsMultiOrgEnabled, getIsTwoFactorAuthEnabled } from "@/modules/ee/license-check/lib/utils";
@@ -42,7 +42,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
<SettingsCard <SettingsCard
title={t("environments.settings.profile.personal_information")} title={t("environments.settings.profile.personal_information")}
description={t("environments.settings.profile.update_personal_info")}> description={t("environments.settings.profile.update_personal_info")}>
<EditProfileDetailsForm user={user} /> <EditProfileDetailsForm emailVerificationDisabled={EMAIL_VERIFICATION_DISABLED} user={user} />
</SettingsCard> </SettingsCard>
<SettingsCard <SettingsCard
title={t("common.avatar")} title={t("common.avatar")}

View File

@@ -10,7 +10,6 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TMembership } from "@formbricks/types/memberships"; import { TMembership } from "@formbricks/types/memberships";
import { TOrganization, TOrganizationBilling } from "@formbricks/types/organizations"; import { TOrganization, TOrganizationBilling } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user"; import { TUser } from "@formbricks/types/user";
import EnterpriseSettingsPage from "./page";
vi.mock("@formbricks/database", () => ({ vi.mock("@formbricks/database", () => ({
prisma: { prisma: {
@@ -179,15 +178,23 @@ describe("EnterpriseSettingsPage", () => {
}); });
test("renders correctly for an owner when not on Formbricks Cloud", async () => { test("renders correctly for an owner when not on Formbricks Cloud", async () => {
vi.resetModules();
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({
active: false,
isPendingDowngrade: false,
features: { isMultiOrgEnabled: false },
lastChecked: new Date(),
fallbackLevel: "live",
}),
}));
const { default: EnterpriseSettingsPage } = await import("./page");
const Page = await EnterpriseSettingsPage({ params: { environmentId: mockEnvironmentId } }); const Page = await EnterpriseSettingsPage({ params: { environmentId: mockEnvironmentId } });
render(Page); render(Page);
expect(screen.getByTestId("page-content-wrapper")).toBeInTheDocument(); expect(screen.getByTestId("page-content-wrapper")).toBeInTheDocument();
expect(screen.getByTestId("page-header")).toBeInTheDocument(); expect(screen.getByTestId("page-header")).toBeInTheDocument();
expect(screen.getByText("environments.settings.enterprise.sso")).toBeInTheDocument(); expect(screen.getByText("environments.settings.enterprise.sso")).toBeInTheDocument();
expect(screen.getByText("environments.settings.billing.remove_branding")).toBeInTheDocument(); expect(screen.getByText("environments.settings.billing.remove_branding")).toBeInTheDocument();
expect(redirect).not.toHaveBeenCalled(); expect(redirect).not.toHaveBeenCalled();
}); });
}); });

View File

@@ -1,6 +1,6 @@
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar"; import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants"; import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/utils"; import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
@@ -118,7 +118,7 @@ const Page = async (props) => {
<div className="relative isolate mt-8 overflow-hidden rounded-lg bg-slate-900 px-3 pt-8 shadow-2xl sm:px-8 md:pt-12 lg:flex lg:gap-x-10 lg:px-12 lg:pt-0"> <div className="relative isolate mt-8 overflow-hidden rounded-lg bg-slate-900 px-3 pt-8 shadow-2xl sm:px-8 md:pt-12 lg:flex lg:gap-x-10 lg:px-12 lg:pt-0">
<svg <svg
viewBox="0 0 1024 1024" viewBox="0 0 1024 1024"
className="absolute top-1/2 left-1/2 -z-10 h-[64rem] w-[64rem] -translate-y-1/2 [mask-image:radial-gradient(closest-side,white,transparent)] sm:left-full sm:-ml-80 lg:left-1/2 lg:ml-0 lg:-translate-x-1/2 lg:translate-y-0" className="absolute left-1/2 top-1/2 -z-10 h-[64rem] w-[64rem] -translate-y-1/2 [mask-image:radial-gradient(closest-side,white,transparent)] sm:left-full sm:-ml-80 lg:left-1/2 lg:ml-0 lg:-translate-x-1/2 lg:translate-y-0"
aria-hidden="true"> aria-hidden="true">
<circle <circle
cx={512} cx={512}

View File

@@ -29,6 +29,7 @@ vi.mock("@/lib/constants", () => ({
SMTP_PORT: 587, SMTP_PORT: 587,
SMTP_USER: "mock-smtp-user", SMTP_USER: "mock-smtp-user",
SMTP_PASSWORD: "mock-smtp-password", SMTP_PASSWORD: "mock-smtp-password",
SESSION_MAX_AGE: 1000,
})); }));
describe("TeamsPage re-export", () => { describe("TeamsPage re-export", () => {

View File

@@ -31,7 +31,7 @@ export const SettingsCard = ({
id={title}> id={title}>
<div className="border-b border-slate-200 px-4 pb-4"> <div className="border-b border-slate-200 px-4 pb-4">
<div className="flex"> <div className="flex">
<h3 className="text-lg leading-6 font-medium text-slate-900 capitalize">{title}</h3> <h3 className="text-lg font-medium capitalize leading-6 text-slate-900">{title}</h3>
<div className="ml-2"> <div className="ml-2">
{beta && <Badge size="normal" type="warning" text="Beta" />} {beta && <Badge size="normal" type="warning" text="Beta" />}
{soon && ( {soon && (

View File

@@ -45,6 +45,7 @@ vi.mock("@/lib/constants", () => ({
SMTP_PORT: 587, SMTP_PORT: 587,
SMTP_USER: "mock-smtp-user", SMTP_USER: "mock-smtp-user",
SMTP_PASSWORD: "mock-smtp-password", SMTP_PASSWORD: "mock-smtp-password",
SESSION_MAX_AGE: 1000,
})); }));
vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext"); vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext");

View File

@@ -1,487 +1,494 @@
import { generateResponseTableColumns } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns"; import { ResponseTable } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTable";
import { deleteResponseAction } from "@/modules/analysis/components/SingleResponseCard/actions"; import { getResponsesDownloadUrlAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
import type { DragEndEvent } from "@dnd-kit/core"; import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { act, cleanup, render, screen } from "@testing-library/react"; import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest"; import toast from "react-hot-toast";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment"; import { TEnvironment } from "@formbricks/types/environment";
import { TResponse, TResponseTableData } from "@formbricks/types/responses"; import { TResponse } from "@formbricks/types/responses";
import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types";
import { TTag } from "@formbricks/types/tags"; import { TTag } from "@formbricks/types/tags";
import { TUser, TUserLocale } from "@formbricks/types/user"; import { TUserLocale } from "@formbricks/types/user";
import { ResponseTable } from "./ResponseTable";
// Hoist variables used in mock factories // Mock react-hot-toast
const { DndContextMock, SortableContextMock, arrayMoveMock } = vi.hoisted(() => { vi.mock("react-hot-toast", () => ({
const dndMock = vi.fn(({ children, onDragEnd }) => { default: {
// Store the onDragEnd prop to allow triggering it in tests error: vi.fn(),
(dndMock as any).lastOnDragEnd = onDragEnd; success: vi.fn(),
return <div data-testid="dnd-context">{children}</div>; dismiss: vi.fn(),
}); },
const sortableMock = vi.fn(({ children }) => <>{children}</>); }));
const moveMock = vi.fn((array, from, to) => {
const newArray = [...array];
const [item] = newArray.splice(from, 1);
newArray.splice(to, 0, item);
return newArray;
});
return {
DndContextMock: dndMock,
SortableContextMock: sortableMock,
arrayMoveMock: moveMock,
};
});
vi.mock("@dnd-kit/core", async (importOriginal) => { // Mock components
const actual = await importOriginal<typeof import("@dnd-kit/core")>(); vi.mock("@/modules/ui/components/button", () => ({
return { Button: ({ children, onClick, ...props }: any) => (
...actual, <button onClick={onClick} data-testid="button" {...props}>
DndContext: DndContextMock, {children}
useSensor: vi.fn(), </button>
useSensors: vi.fn(), ),
closestCenter: vi.fn(), }));
};
}); // Mock DndContext/SortableContext
vi.mock("@dnd-kit/core", () => ({
DndContext: ({ children }: any) => <div>{children}</div>,
useSensor: vi.fn(),
useSensors: vi.fn(() => "sensors"),
closestCenter: vi.fn(),
MouseSensor: vi.fn(),
TouchSensor: vi.fn(),
KeyboardSensor: vi.fn(),
}));
vi.mock("@dnd-kit/modifiers", () => ({ vi.mock("@dnd-kit/modifiers", () => ({
restrictToHorizontalAxis: vi.fn(), restrictToHorizontalAxis: "restrictToHorizontalAxis",
})); }));
vi.mock("@dnd-kit/sortable", () => ({ vi.mock("@dnd-kit/sortable", () => ({
SortableContext: SortableContextMock, SortableContext: ({ children }: any) => <>{children}</>,
arrayMove: arrayMoveMock, horizontalListSortingStrategy: "horizontalListSortingStrategy",
horizontalListSortingStrategy: vi.fn(), arrayMove: vi.fn((arr, oldIndex, newIndex) => {
const result = [...arr];
const [removed] = result.splice(oldIndex, 1);
result.splice(newIndex, 0, removed);
return result;
}),
}));
// Mock AutoAnimate
vi.mock("@formkit/auto-animate/react", () => ({
useAutoAnimate: () => [vi.fn()],
}));
// Mock UI components
vi.mock("@/modules/ui/components/data-table", () => ({
DataTableHeader: ({ header }: any) => <th data-testid={`header-${header.id}`}>{header.id}</th>,
DataTableSettingsModal: ({ open, setOpen }: any) =>
open ? (
<div data-testid="settings-modal">
Settings Modal <button onClick={() => setOpen(false)}>Close</button>
</div>
) : null,
DataTableToolbar: ({
table,
deleteRowsAction,
downloadRowsAction,
setIsTableSettingsModalOpen,
setIsExpanded,
isExpanded,
}: any) => (
<div data-testid="table-toolbar">
<button
data-testid="toggle-expand"
onClick={() => setIsExpanded(!isExpanded)}
aria-pressed={isExpanded}>
Toggle Expand
</button>
<button data-testid="open-settings" onClick={() => setIsTableSettingsModalOpen(true)}>
Open Settings
</button>
<button
data-testid="delete-rows"
onClick={() => deleteRowsAction(Object.keys(table.getState().rowSelection))}>
Delete Selected
</button>
<button
data-testid="download-csv"
onClick={() => downloadRowsAction(Object.keys(table.getState().rowSelection), "csv")}>
Download CSV
</button>
<button
data-testid="download-xlsx"
onClick={() => downloadRowsAction(Object.keys(table.getState().rowSelection), "xlsx")}>
Download XLSX
</button>
</div>
),
})); }));
// Mock child components and hooks
vi.mock( vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseCardModal", "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseCardModal",
() => ({ () => ({
ResponseCardModal: vi.fn(({ open, setOpen, selectedResponseId }) => ResponseCardModal: ({ open, setOpen }: any) =>
open ? ( open ? (
<div data-testid="response-card-modal"> <div data-testid="response-modal">
Selected Response ID: {selectedResponseId} Response Modal <button onClick={() => setOpen(false)}>Close</button>
<button onClick={() => setOpen(false)}>Close ResponseCardModal</button>
</div> </div>
) : null ) : null,
),
}) })
); );
vi.mock( vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableCell", "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableCell",
() => ({ () => ({
ResponseTableCell: vi.fn(({ cell, row, setSelectedResponseId }) => ( ResponseTableCell: ({ cell, row, setSelectedResponseId }: any) => (
<td data-testid={`cell-${cell.id}`} onClick={() => setSelectedResponseId(row.original.responseId)}> <td data-testid={`cell-${cell.id}-${row.id}`} onClick={() => setSelectedResponseId(row.id)}>
{typeof cell.getValue === "function" ? cell.getValue() : JSON.stringify(cell.getValue())} Cell Content
</td> </td>
)), ),
}) })
); );
const mockGeneratedColumns = [
{
id: "select",
header: () => "Select",
cell: vi.fn(() => "SelectCell"),
enableSorting: false,
meta: { type: "select", questionType: null, hidden: false },
},
{
id: "createdAt",
header: () => "Created At",
cell: vi.fn(({ row }) => new Date(row.original.createdAt).toISOString()),
enableSorting: true,
meta: { type: "createdAt", questionType: null, hidden: false },
},
{
id: "q1",
header: () => "Question 1",
cell: vi.fn(({ row }) => row.original.responseData.q1),
enableSorting: true,
meta: { type: "question", questionType: "openText", hidden: false },
},
];
vi.mock( vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns", "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns",
() => ({ () => ({
generateResponseTableColumns: vi.fn(() => mockGeneratedColumns), generateResponseTableColumns: vi.fn(() => [
{ id: "select", accessorKey: "select", header: "Select" },
{ id: "createdAt", accessorKey: "createdAt", header: "Created At" },
{ id: "person", accessorKey: "person", header: "Person" },
{ id: "status", accessorKey: "status", header: "Status" },
]),
}) })
); );
vi.mock("@/modules/ui/components/table", () => ({
Table: ({ children, ...props }: any) => <table {...props}>{children}</table>,
TableBody: ({ children, ...props }: any) => <tbody {...props}>{children}</tbody>,
TableCell: ({ children, ...props }: any) => <td {...props}>{children}</td>,
TableHeader: ({ children, ...props }: any) => <thead {...props}>{children}</thead>,
TableRow: ({ children, ...props }: any) => <tr {...props}>{children}</tr>,
}));
vi.mock("@/modules/ui/components/skeleton", () => ({
Skeleton: ({ children }: any) => <div data-testid="skeleton">{children}</div>,
}));
// Mock the actions
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions", () => ({
getResponsesDownloadUrlAction: vi.fn(),
}));
vi.mock("@/modules/analysis/components/SingleResponseCard/actions", () => ({ vi.mock("@/modules/analysis/components/SingleResponseCard/actions", () => ({
deleteResponseAction: vi.fn(), deleteResponseAction: vi.fn(),
})); }));
vi.mock("@/modules/ui/components/data-table", async (importOriginal) => { // Mock helper functions
const actual = await importOriginal<typeof import("@/modules/ui/components/data-table")>(); vi.mock("@/lib/utils/helper", () => ({
return { getFormattedErrorMessage: vi.fn(),
...actual,
DataTableToolbar: vi.fn((props) => (
<div data-testid="data-table-toolbar">
<button data-testid="toolbar-expand-toggle" onClick={() => props.setIsExpanded(!props.isExpanded)}>
Toggle Expand
</button>
<button data-testid="toolbar-open-settings" onClick={() => props.setIsTableSettingsModalOpen(true)}>
Open Settings
</button>
<button
data-testid="toolbar-delete-selected"
onClick={() => props.deleteRows(props.table.getSelectedRowModel().rows.map((r) => r.id))}>
Delete Selected
</button>
<button data-testid="toolbar-delete-single" onClick={() => props.deleteAction("single_response_id")}>
Delete Single Action
</button>
</div>
)),
DataTableHeader: vi.fn(({ header }) => (
<th
data-testid={`header-${header.id}`}
onClick={() => header.column.getToggleSortingHandler()?.(new MouseEvent("click"))}>
{typeof header.column.columnDef.header === "function"
? header.column.columnDef.header(header.getContext())
: header.column.columnDef.header}
<button
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
data-testid={`resize-${header.id}`}>
Resize
</button>
</th>
)),
DataTableSettingsModal: vi.fn(({ open, setOpen }) =>
open ? (
<div data-testid="data-table-settings-modal">
<button onClick={() => setOpen(false)}>Close Settings</button>
</div>
) : null
),
};
});
vi.mock("@formkit/auto-animate/react", () => ({
useAutoAnimate: vi.fn(() => [vi.fn()]),
})); }));
vi.mock("@tolgee/react", () => ({ // Mock localStorage
useTranslate: () => ({ const mockLocalStorage = (() => {
t: vi.fn((key) => key), // Simple pass-through mock
}),
}));
const localStorageMock = (() => {
let store: Record<string, string> = {}; let store: Record<string, string> = {};
return { return {
getItem: vi.fn((key: string) => store[key] || null), getItem: vi.fn((key) => store[key] || null),
setItem: vi.fn((key: string, value: string) => { setItem: vi.fn((key, value) => {
store[key] = value.toString(); store[key] = String(value);
}), }),
clear: () => { clear: vi.fn(() => {
store = {}; store = {};
}, }),
removeItem: vi.fn((key: string) => { removeItem: vi.fn((key) => {
delete store[key]; delete store[key];
}), }),
}; };
})(); })();
Object.defineProperty(window, "localStorage", { value: localStorageMock }); Object.defineProperty(window, "localStorage", { value: mockLocalStorage });
const mockSurvey = { // Mock Tolgee
id: "survey1", vi.mock("@tolgee/react", () => ({
name: "Test Survey", useTranslate: () => ({
type: "app", t: (key: string) => key,
status: "inProgress", }),
questions: [ }));
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Question 1" },
required: true,
} as unknown as TSurveyQuestion,
],
hiddenFields: { enabled: true, fieldIds: ["hidden1"] },
variables: [{ id: "var1", name: "Variable 1", type: "text", value: "default" }],
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "env1",
welcomeCard: {
enabled: false,
headline: { default: "" },
html: { default: "" },
timeToFinish: false,
showResponseCount: false,
},
autoClose: null,
delay: 0,
autoComplete: null,
closeOnDate: null,
displayOption: "displayOnce",
recontactDays: null,
singleUse: { enabled: false, isEncrypted: true },
triggers: [],
languages: [],
styling: null,
surveyClosedMessage: null,
resultShareKey: null,
displayPercentage: null,
} as unknown as TSurvey;
const mockResponses: TResponse[] = [ // Define mock data for tests
{ const mockProps = {
id: "res1", data: [
surveyId: "survey1", { responseId: "resp1", createdAt: new Date().toISOString(), status: "completed", person: "Person 1" },
finished: true, { responseId: "resp2", createdAt: new Date().toISOString(), status: "completed", person: "Person 2" },
data: { q1: "Response 1 Text" }, ] as any[],
createdAt: new Date("2023-01-01T10:00:00.000Z"), survey: {
id: "survey1",
createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
meta: {}, name: "name",
singleUseId: null, type: "link",
ttc: {}, environmentId: "env-1",
tags: [], createdBy: null,
notes: [], status: "draft",
variables: {}, } as TSurvey,
language: "en", responses: [
contact: null, { id: "resp1", surveyId: "survey1", data: {}, createdAt: new Date(), updatedAt: new Date() },
contactAttributes: null, { id: "resp2", surveyId: "survey1", data: {}, createdAt: new Date(), updatedAt: new Date() },
}, ] as TResponse[],
{ environment: { id: "env1" } as TEnvironment,
id: "res2", environmentTags: [] as TTag[],
surveyId: "survey1",
finished: false,
data: { q1: "Response 2 Text" },
createdAt: new Date("2023-01-02T10:00:00.000Z"),
updatedAt: new Date(),
meta: {},
singleUseId: null,
ttc: {},
tags: [],
notes: [],
variables: {},
language: "en",
contact: null,
contactAttributes: null,
},
];
const mockResponseTableData: TResponseTableData[] = [
{
responseId: "res1",
responseData: { q1: "Response 1 Text" },
createdAt: new Date("2023-01-01T10:00:00.000Z"),
status: "Completed",
tags: [],
notes: [],
variables: {},
verifiedEmail: "",
language: "en",
person: null,
contactAttributes: null,
},
{
responseId: "res2",
responseData: { q1: "Response 2 Text" },
createdAt: new Date("2023-01-02T10:00:00.000Z"),
status: "Not Completed",
tags: [],
notes: [],
variables: {},
verifiedEmail: "",
language: "en",
person: null,
contactAttributes: null,
},
];
const mockEnvironment = {
id: "env1",
createdAt: new Date(),
updatedAt: new Date(),
type: "development",
appSetupCompleted: false,
} as unknown as TEnvironment;
const mockUser = {
id: "user1",
name: "Test User",
email: "user@test.com",
emailVerified: new Date(),
imageUrl: "",
twoFactorEnabled: false,
identityProvider: "email",
createdAt: new Date(),
updatedAt: new Date(),
role: "project_manager",
objective: "other",
notificationSettings: { alert: {}, weeklySummary: {} },
} as unknown as TUser;
const mockEnvironmentTags: TTag[] = [
{ id: "tag1", name: "Tag 1", environmentId: "env1", createdAt: new Date(), updatedAt: new Date() },
];
const mockLocale: TUserLocale = "en-US";
const defaultProps = {
data: mockResponseTableData,
survey: mockSurvey,
responses: mockResponses,
environment: mockEnvironment,
user: mockUser,
environmentTags: mockEnvironmentTags,
isReadOnly: false, isReadOnly: false,
fetchNextPage: vi.fn(), fetchNextPage: vi.fn(),
hasMore: true, hasMore: false,
deleteResponses: vi.fn(), deleteResponses: vi.fn(),
updateResponse: vi.fn(), updateResponse: vi.fn(),
isFetchingFirstPage: false, isFetchingFirstPage: false,
locale: mockLocale, locale: "en" as TUserLocale,
}; };
// Setup a container for React Testing Library before each test
beforeEach(() => {
const container = document.createElement("div");
container.id = "test-container";
document.body.appendChild(container);
// Reset all toast mocks before each test
vi.mocked(toast.error).mockClear();
vi.mocked(toast.success).mockClear();
// Create a mock anchor element for download tests
const mockAnchor = {
href: "",
click: vi.fn(),
style: {},
};
// Update how we mock the document methods to avoid infinite recursion
const originalCreateElement = document.createElement.bind(document);
vi.spyOn(document, "createElement").mockImplementation((tagName) => {
if (tagName === "a") return mockAnchor as any;
return originalCreateElement(tagName);
});
vi.spyOn(document.body, "appendChild").mockReturnValue(null as any);
vi.spyOn(document.body, "removeChild").mockReturnValue(null as any);
});
// Cleanup after each test
afterEach(() => {
const container = document.getElementById("test-container");
if (container) {
document.body.removeChild(container);
}
cleanup();
vi.restoreAllMocks(); // Restore mocks after each test
});
describe("ResponseTable", () => { describe("ResponseTable", () => {
afterEach(() => { afterEach(() => {
cleanup(); cleanup(); // Keep cleanup within describe as per instructions
localStorageMock.clear();
vi.clearAllMocks();
}); });
test("renders skeleton when isFetchingFirstPage is true", () => { test("renders the table with data", () => {
render(<ResponseTable {...defaultProps} isFetchingFirstPage={true} />); const container = document.getElementById("test-container");
// Check for skeleton elements (implementation detail, might need adjustment) render(<ResponseTable {...mockProps} />, { container: container! });
// For now, check that data is not directly rendered expect(screen.getByRole("table")).toBeInTheDocument();
expect(screen.queryByText("Response 1 Text")).not.toBeInTheDocument(); expect(screen.getByTestId("table-toolbar")).toBeInTheDocument();
// Check if table headers are still there
expect(screen.getByText("Created At")).toBeInTheDocument();
}); });
test("loads settings from localStorage on mount", () => { test("renders no results message when data is empty", () => {
const savedOrder = ["q1", "createdAt", "select"]; const container = document.getElementById("test-container");
const savedVisibility = { createdAt: false }; render(<ResponseTable {...mockProps} data={[]} responses={[]} />, { container: container! });
const savedExpanded = true;
localStorageMock.setItem(`${mockSurvey.id}-columnOrder`, JSON.stringify(savedOrder));
localStorageMock.setItem(`${mockSurvey.id}-columnVisibility`, JSON.stringify(savedVisibility));
localStorageMock.setItem(`${mockSurvey.id}-rowExpand`, JSON.stringify(savedExpanded));
render(<ResponseTable {...defaultProps} />);
// Check if generateResponseTableColumns was called with the loaded expanded state
expect(vi.mocked(generateResponseTableColumns)).toHaveBeenCalledWith(
mockSurvey,
savedExpanded,
false,
expect.any(Function)
);
});
test("saves settings to localStorage when they change", async () => {
const { rerender } = render(<ResponseTable {...defaultProps} />);
// Simulate column order change via DND
const dragEvent: DragEndEvent = {
active: { id: "createdAt" },
over: { id: "q1" },
delta: { x: 0, y: 0 },
activators: { x: 0, y: 0 },
collisions: null,
overNode: null,
activeNode: null,
} as any;
act(() => {
(DndContextMock as any).lastOnDragEnd?.(dragEvent);
});
rerender(<ResponseTable {...defaultProps} />); // Rerender to reflect state change if necessary for useEffect
expect(localStorageMock.setItem).toHaveBeenCalledWith(
`${mockSurvey.id}-columnOrder`,
JSON.stringify(["select", "q1", "createdAt"])
);
// Simulate visibility change (e.g. via settings modal - direct state change for test)
// This would typically happen via table.setColumnVisibility, which is internal to useReactTable
// For this test, we'll assume a mechanism changes columnVisibility state
// This part is hard to test without deeper mocking of useReactTable or exposing setColumnVisibility
// Simulate row expansion change
await userEvent.click(screen.getByTestId("toolbar-expand-toggle")); // Toggle to true
expect(localStorageMock.setItem).toHaveBeenCalledWith(`${mockSurvey.id}-rowExpand`, "true");
});
test("handles column drag and drop", () => {
render(<ResponseTable {...defaultProps} />);
const dragEvent: DragEndEvent = {
active: { id: "createdAt" },
over: { id: "q1" },
delta: { x: 0, y: 0 },
activators: { x: 0, y: 0 },
collisions: null,
overNode: null,
activeNode: null,
} as any;
act(() => {
(DndContextMock as any).lastOnDragEnd?.(dragEvent);
});
expect(arrayMoveMock).toHaveBeenCalledWith(expect.arrayContaining(["createdAt", "q1"]), 1, 2); // Example indices
expect(localStorageMock.setItem).toHaveBeenCalledWith(
`${mockSurvey.id}-columnOrder`,
JSON.stringify(["select", "q1", "createdAt"]) // Based on initial ['select', 'createdAt', 'q1']
);
});
test("interacts with DataTableToolbar: toggle expand, open settings, delete", async () => {
const deleteResponsesMock = vi.fn();
const deleteResponseActionMock = vi.mocked(deleteResponseAction);
render(<ResponseTable {...defaultProps} deleteResponses={deleteResponsesMock} />);
// Toggle expand
await userEvent.click(screen.getByTestId("toolbar-expand-toggle"));
expect(vi.mocked(generateResponseTableColumns)).toHaveBeenCalledWith(
mockSurvey,
true,
false,
expect.any(Function)
);
expect(localStorageMock.setItem).toHaveBeenCalledWith(`${mockSurvey.id}-rowExpand`, "true");
// Open settings
await userEvent.click(screen.getByTestId("toolbar-open-settings"));
expect(screen.getByTestId("data-table-settings-modal")).toBeInTheDocument();
await userEvent.click(screen.getByText("Close Settings"));
expect(screen.queryByTestId("data-table-settings-modal")).not.toBeInTheDocument();
// Delete selected (mock table selection)
// This requires mocking table.getSelectedRowModel().rows
// For simplicity, we assume the toolbar button calls deleteRows correctly
// The mock for DataTableToolbar calls props.deleteRows with hardcoded IDs for now.
// To test properly, we'd need to mock table.getSelectedRowModel
// For now, let's assume the mock toolbar calls it.
// await userEvent.click(screen.getByTestId("toolbar-delete-selected"));
// expect(deleteResponsesMock).toHaveBeenCalledWith(["row1_id", "row2_id"]); // From mock toolbar
// Delete single action
await userEvent.click(screen.getByTestId("toolbar-delete-single"));
expect(deleteResponseActionMock).toHaveBeenCalledWith({ responseId: "single_response_id" });
});
test("calls fetchNextPage when 'Load More' is clicked", async () => {
const fetchNextPageMock = vi.fn();
render(<ResponseTable {...defaultProps} fetchNextPage={fetchNextPageMock} />);
await userEvent.click(screen.getByText("common.load_more"));
expect(fetchNextPageMock).toHaveBeenCalled();
});
test("does not show 'Load More' if hasMore is false", () => {
render(<ResponseTable {...defaultProps} hasMore={false} />);
expect(screen.queryByText("common.load_more")).not.toBeInTheDocument();
});
test("shows 'No results' when data is empty", () => {
render(<ResponseTable {...defaultProps} data={[]} responses={[]} />);
expect(screen.getByText("common.no_results")).toBeInTheDocument(); expect(screen.getByText("common.no_results")).toBeInTheDocument();
}); });
test("deleteResponse function calls deleteResponseAction", async () => { test("renders load more button when hasMore is true", () => {
render(<ResponseTable {...defaultProps} />); const container = document.getElementById("test-container");
// This function is called by DataTableToolbar's deleteAction prop render(<ResponseTable {...mockProps} hasMore={true} />, { container: container! });
// We can trigger it via the mocked DataTableToolbar expect(screen.getByText("common.load_more")).toBeInTheDocument();
await userEvent.click(screen.getByTestId("toolbar-delete-single")); });
expect(vi.mocked(deleteResponseAction)).toHaveBeenCalledWith({ responseId: "single_response_id" });
test("calls fetchNextPage when load more button is clicked", async () => {
const container = document.getElementById("test-container");
render(<ResponseTable {...mockProps} hasMore={true} />, { container: container! });
const loadMoreButton = screen.getByText("common.load_more");
await userEvent.click(loadMoreButton);
expect(mockProps.fetchNextPage).toHaveBeenCalledTimes(1);
});
test("opens settings modal when toolbar button is clicked", async () => {
const container = document.getElementById("test-container");
render(<ResponseTable {...mockProps} />, { container: container! });
const openSettingsButton = screen.getByTestId("open-settings");
await userEvent.click(openSettingsButton);
expect(screen.getByTestId("settings-modal")).toBeInTheDocument();
});
test("toggles expanded state when toolbar button is clicked", async () => {
const container = document.getElementById("test-container");
render(<ResponseTable {...mockProps} />, { container: container! });
const toggleExpandButton = screen.getByTestId("toggle-expand");
// Initially might be null, first click should set it to true
await userEvent.click(toggleExpandButton);
expect(mockLocalStorage.setItem).toHaveBeenCalledWith("survey1-rowExpand", expect.any(String));
});
test("calls downloadSelectedRows with csv format when toolbar button is clicked", async () => {
vi.mocked(getResponsesDownloadUrlAction).mockResolvedValueOnce({
data: "https://download.url/file.csv",
});
const container = document.getElementById("test-container");
render(<ResponseTable {...mockProps} />, { container: container! });
const downloadCsvButton = screen.getByTestId("download-csv");
await userEvent.click(downloadCsvButton);
expect(getResponsesDownloadUrlAction).toHaveBeenCalledWith({
surveyId: "survey1",
format: "csv",
filterCriteria: { responseIds: [] },
});
// Check if link was created and clicked
expect(document.createElement).toHaveBeenCalledWith("a");
const mockLink = document.createElement("a");
expect(mockLink.href).toBe("https://download.url/file.csv");
expect(document.body.appendChild).toHaveBeenCalled();
expect(mockLink.click).toHaveBeenCalled();
expect(document.body.removeChild).toHaveBeenCalled();
});
test("calls downloadSelectedRows with xlsx format when toolbar button is clicked", async () => {
vi.mocked(getResponsesDownloadUrlAction).mockResolvedValueOnce({
data: "https://download.url/file.xlsx",
});
const container = document.getElementById("test-container");
render(<ResponseTable {...mockProps} />, { container: container! });
const downloadXlsxButton = screen.getByTestId("download-xlsx");
await userEvent.click(downloadXlsxButton);
expect(getResponsesDownloadUrlAction).toHaveBeenCalledWith({
surveyId: "survey1",
format: "xlsx",
filterCriteria: { responseIds: [] },
});
// Check if link was created and clicked
expect(document.createElement).toHaveBeenCalledWith("a");
const mockLink = document.createElement("a");
expect(mockLink.href).toBe("https://download.url/file.xlsx");
expect(document.body.appendChild).toHaveBeenCalled();
expect(mockLink.click).toHaveBeenCalled();
expect(document.body.removeChild).toHaveBeenCalled();
});
// Test response modal
test("opens and closes response modal when a cell is clicked", async () => {
const container = document.getElementById("test-container");
render(<ResponseTable {...mockProps} />, { container: container! });
const cell = screen.getByTestId("cell-resp1_select-resp1");
await userEvent.click(cell);
expect(screen.getByTestId("response-modal")).toBeInTheDocument();
// Close the modal
const closeButton = screen.getByText("Close");
await userEvent.click(closeButton);
// Modal should be closed now
expect(screen.queryByTestId("response-modal")).not.toBeInTheDocument();
});
test("shows error toast when download action returns error", async () => {
const errorMsg = "Download failed";
vi.mocked(getResponsesDownloadUrlAction).mockResolvedValueOnce({
data: undefined,
serverError: errorMsg,
});
vi.mocked(getFormattedErrorMessage).mockReturnValueOnce(errorMsg);
// Reset document.createElement spy to fix the last test
vi.mocked(document.createElement).mockClear();
const container = document.getElementById("test-container");
render(<ResponseTable {...mockProps} />, { container: container! });
const downloadCsvButton = screen.getByTestId("download-csv");
await userEvent.click(downloadCsvButton);
await waitFor(() => {
expect(getResponsesDownloadUrlAction).toHaveBeenCalled();
expect(toast.error).toHaveBeenCalledWith("environments.surveys.responses.error_downloading_responses");
});
});
test("shows default error toast when download action returns no data", async () => {
vi.mocked(getResponsesDownloadUrlAction).mockResolvedValueOnce({
data: undefined,
});
vi.mocked(getFormattedErrorMessage).mockReturnValueOnce("");
const container = document.getElementById("test-container");
render(<ResponseTable {...mockProps} />, { container: container! });
const downloadCsvButton = screen.getByTestId("download-csv");
await userEvent.click(downloadCsvButton);
await waitFor(() => {
expect(getResponsesDownloadUrlAction).toHaveBeenCalled();
expect(toast.error).toHaveBeenCalledWith("environments.surveys.responses.error_downloading_responses");
});
});
test("shows error toast when download action throws exception", async () => {
vi.mocked(getResponsesDownloadUrlAction).mockRejectedValueOnce(new Error("Network error"));
const container = document.getElementById("test-container");
render(<ResponseTable {...mockProps} />, { container: container! });
const downloadCsvButton = screen.getByTestId("download-csv");
await userEvent.click(downloadCsvButton);
await waitFor(() => {
expect(getResponsesDownloadUrlAction).toHaveBeenCalled();
expect(toast.error).toHaveBeenCalledWith("environments.surveys.responses.error_downloading_responses");
});
});
test("does not create download link when download action fails", async () => {
// Clear any previous calls to document.createElement
vi.mocked(document.createElement).mockClear();
vi.mocked(getResponsesDownloadUrlAction).mockResolvedValueOnce({
data: undefined,
serverError: "Download failed",
});
// Create a fresh spy for createElement for this test only
const createElementSpy = vi.spyOn(document, "createElement");
const container = document.getElementById("test-container");
render(<ResponseTable {...mockProps} />, { container: container! });
const downloadCsvButton = screen.getByTestId("download-csv");
await userEvent.click(downloadCsvButton);
await waitFor(() => {
expect(getResponsesDownloadUrlAction).toHaveBeenCalled();
// Check specifically for "a" element creation, not any element
expect(createElementSpy).not.toHaveBeenCalledWith("a");
});
});
test("loads saved settings from localStorage on mount", () => {
const columnOrder = ["status", "person", "createdAt", "select"];
const columnVisibility = { status: false };
const isExpanded = true;
mockLocalStorage.getItem.mockImplementation((key) => {
if (key === "survey1-columnOrder") return JSON.stringify(columnOrder);
if (key === "survey1-columnVisibility") return JSON.stringify(columnVisibility);
if (key === "survey1-rowExpand") return JSON.stringify(isExpanded);
return null;
});
const container = document.getElementById("test-container");
render(<ResponseTable {...mockProps} />, { container: container! });
// Verify localStorage calls
expect(mockLocalStorage.getItem).toHaveBeenCalledWith("survey1-columnOrder");
expect(mockLocalStorage.getItem).toHaveBeenCalledWith("survey1-columnVisibility");
expect(mockLocalStorage.getItem).toHaveBeenCalledWith("survey1-rowExpand");
// The mock for generateResponseTableColumns returns this order:
// ["select", "createdAt", "person", "status"]
// Only visible columns should be rendered, in this order
const expectedHeaders = ["select", "createdAt", "person"];
const headers = screen.getAllByTestId(/^header-/);
expect(headers).toHaveLength(expectedHeaders.length);
expectedHeaders.forEach((columnId, index) => {
expect(headers[index]).toHaveAttribute("data-testid", `header-${columnId}`);
});
// Verify column visibility is applied
const statusHeader = screen.queryByTestId("header-status");
expect(statusHeader).not.toBeInTheDocument();
// Verify row expansion is applied
const toggleExpandButton = screen.getByTestId("toggle-expand");
expect(toggleExpandButton).toHaveAttribute("aria-pressed", "true");
}); });
}); });

View File

@@ -3,6 +3,7 @@
import { ResponseCardModal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseCardModal"; import { ResponseCardModal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseCardModal";
import { ResponseTableCell } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableCell"; import { ResponseTableCell } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableCell";
import { generateResponseTableColumns } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns"; import { generateResponseTableColumns } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns";
import { getResponsesDownloadUrlAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
import { deleteResponseAction } from "@/modules/analysis/components/SingleResponseCard/actions"; import { deleteResponseAction } from "@/modules/analysis/components/SingleResponseCard/actions";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { import {
@@ -25,15 +26,16 @@ import {
import { restrictToHorizontalAxis } from "@dnd-kit/modifiers"; import { restrictToHorizontalAxis } from "@dnd-kit/modifiers";
import { SortableContext, arrayMove, horizontalListSortingStrategy } from "@dnd-kit/sortable"; import { SortableContext, arrayMove, horizontalListSortingStrategy } from "@dnd-kit/sortable";
import { useAutoAnimate } from "@formkit/auto-animate/react"; import { useAutoAnimate } from "@formkit/auto-animate/react";
import * as Sentry from "@sentry/nextjs";
import { VisibilityState, getCoreRowModel, useReactTable } from "@tanstack/react-table"; import { VisibilityState, getCoreRowModel, useReactTable } from "@tanstack/react-table";
import { useTranslate } from "@tolgee/react"; import { useTranslate } from "@tolgee/react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { TEnvironment } from "@formbricks/types/environment"; import { TEnvironment } from "@formbricks/types/environment";
import { TResponse, TResponseTableData } from "@formbricks/types/responses"; import { TResponse, TResponseTableData } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types";
import { TTag } from "@formbricks/types/tags"; import { TTag } from "@formbricks/types/tags";
import { TUser } from "@formbricks/types/user"; import { TUser, TUserLocale } from "@formbricks/types/user";
import { TUserLocale } from "@formbricks/types/user";
interface ResponseTableProps { interface ResponseTableProps {
data: TResponseTableData[]; data: TResponseTableData[];
@@ -180,6 +182,32 @@ export const ResponseTable = ({
await deleteResponseAction({ responseId }); await deleteResponseAction({ responseId });
}; };
// Handle downloading selected responses
const downloadSelectedRows = async (responseIds: string[], format: "csv" | "xlsx") => {
try {
const downloadResponse = await getResponsesDownloadUrlAction({
surveyId: survey.id,
format: format,
filterCriteria: { responseIds },
});
if (downloadResponse?.data) {
const link = document.createElement("a");
link.href = downloadResponse.data;
link.download = "";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} else {
toast.error(t("environments.surveys.responses.error_downloading_responses"));
}
} catch (error) {
Sentry.captureException(error);
toast.error(t("environments.surveys.responses.error_downloading_responses"));
}
};
return ( return (
<div> <div>
<DndContext <DndContext
@@ -193,9 +221,10 @@ export const ResponseTable = ({
setIsTableSettingsModalOpen={setIsTableSettingsModalOpen} setIsTableSettingsModalOpen={setIsTableSettingsModalOpen}
isExpanded={isExpanded ?? false} isExpanded={isExpanded ?? false}
table={table} table={table}
deleteRows={deleteResponses} deleteRowsAction={deleteResponses}
type="response" type="response"
deleteAction={deleteResponse} deleteAction={deleteResponse}
downloadRowsAction={downloadSelectedRows}
/> />
<div className="w-fit max-w-full overflow-hidden overflow-x-auto rounded-xl border border-slate-200"> <div className="w-fit max-w-full overflow-hidden overflow-x-auto rounded-xl border border-slate-200">
<div className="w-full overflow-x-auto"> <div className="w-full overflow-x-auto">

View File

@@ -38,7 +38,7 @@ export const ResponseTableCell = ({
<button <button
type="button" type="button"
aria-label="Expand response" aria-label="Expand response"
className="hidden flex-shrink-0 cursor-pointer items-center rounded-md border border-slate-200 bg-white p-2 group-hover:flex hover:border-slate-300 focus:outline-none" className="hidden flex-shrink-0 cursor-pointer items-center rounded-md border border-slate-200 bg-white p-2 hover:border-slate-300 focus:outline-none group-hover:flex"
onClick={handleCellClick}> onClick={handleCellClick}>
<Maximize2Icon className="h-4 w-4" /> <Maximize2Icon className="h-4 w-4" />
</button> </button>

View File

@@ -41,7 +41,7 @@ export const ConsentSummary = ({ questionSummary, survey, setFilter }: ConsentSu
return ( return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm"> <div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} /> <QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base"> <div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{summaryItems.map((summaryItem) => { {summaryItems.map((summaryItem) => {
return ( return (
<button <button

View File

@@ -80,7 +80,7 @@ export const DateQuestionSummary = ({
</div> </div>
)} )}
</div> </div>
<div className="ph-no-capture col-span-2 pl-6 font-semibold whitespace-pre-wrap"> <div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
{renderResponseValue(response.value)} {renderResponseValue(response.value)}
</div> </div>
<div className="px-4 text-slate-500 md:px-6"> <div className="px-4 text-slate-500 md:px-6">

View File

@@ -80,7 +80,7 @@ export const FileUploadSummary = ({
return ( return (
<div className="relative m-2 rounded-lg bg-slate-200" key={fileUrl}> <div className="relative m-2 rounded-lg bg-slate-200" key={fileUrl}>
<a href={fileUrl} key={fileUrl} target="_blank" rel="noopener noreferrer"> <a href={fileUrl} key={fileUrl} target="_blank" rel="noopener noreferrer">
<div className="absolute top-0 right-0 m-2"> <div className="absolute right-0 top-0 m-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-50 hover:bg-white"> <div className="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-50 hover:bg-white">
<DownloadIcon className="h-6 text-slate-500" /> <DownloadIcon className="h-6 text-slate-500" />
</div> </div>

View File

@@ -28,7 +28,7 @@ export const HiddenFieldsSummary = ({ environment, questionSummary, locale }: Hi
}; };
return ( return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm"> <div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="space-y-2 px-4 pt-6 pb-5 md:px-6"> <div className="space-y-2 px-4 pb-5 pt-6 md:px-6">
<div className={"align-center flex justify-between gap-4"}> <div className={"align-center flex justify-between gap-4"}>
<h3 className="pb-1 text-lg font-semibold text-slate-900 md:text-xl">{questionSummary.id}</h3> <h3 className="pb-1 text-lg font-semibold text-slate-900 md:text-xl">{questionSummary.id}</h3>
</div> </div>
@@ -76,7 +76,7 @@ export const HiddenFieldsSummary = ({ environment, questionSummary, locale }: Hi
</div> </div>
)} )}
</div> </div>
<div className="ph-no-capture col-span-2 pl-6 font-semibold whitespace-pre-wrap"> <div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
{response.value} {response.value}
</div> </div>
<div className="px-4 text-slate-500 md:px-6"> <div className="px-4 text-slate-500 md:px-6">

View File

@@ -52,7 +52,7 @@ export const MatrixQuestionSummary = ({ questionSummary, survey, setFilter }: Ma
<table className="mx-auto border-collapse cursor-default text-left"> <table className="mx-auto border-collapse cursor-default text-left">
<thead> <thead>
<tr> <tr>
<th className="p-4 pt-0 pb-3 font-medium text-slate-400 dark:border-slate-600 dark:text-slate-200"></th> <th className="p-4 pb-3 pt-0 font-medium text-slate-400 dark:border-slate-600 dark:text-slate-200"></th>
{columns.map((column) => ( {columns.map((column) => (
<th key={column} className="text-center font-medium"> <th key={column} className="text-center font-medium">
<TooltipRenderer tooltipContent={getTooltipContent(column)} shouldRender={true}> <TooltipRenderer tooltipContent={getTooltipContent(column)} shouldRender={true}>
@@ -65,7 +65,7 @@ export const MatrixQuestionSummary = ({ questionSummary, survey, setFilter }: Ma
<tbody> <tbody>
{questionSummary.data.map(({ rowLabel, columnPercentages }, rowIndex) => ( {questionSummary.data.map(({ rowLabel, columnPercentages }, rowIndex) => (
<tr key={rowLabel}> <tr key={rowLabel}>
<td className="max-w-60 overflow-hidden p-4 text-ellipsis whitespace-nowrap"> <td className="max-w-60 overflow-hidden text-ellipsis whitespace-nowrap p-4">
<TooltipRenderer tooltipContent={getTooltipContent(rowLabel)} shouldRender={true}> <TooltipRenderer tooltipContent={getTooltipContent(rowLabel)} shouldRender={true}>
<p className="max-w-40 overflow-hidden text-ellipsis whitespace-nowrap">{rowLabel}</p> <p className="max-w-40 overflow-hidden text-ellipsis whitespace-nowrap">{rowLabel}</p>
</TooltipRenderer> </TooltipRenderer>

View File

@@ -83,7 +83,7 @@ export const MultipleChoiceSummary = ({
) : undefined ) : undefined
} }
/> />
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base"> <div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{results.map((result, resultsIdx) => ( {results.map((result, resultsIdx) => (
<Fragment key={result.value}> <Fragment key={result.value}>
<button <button

View File

@@ -62,7 +62,7 @@ export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryPro
return ( return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm"> <div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} /> <QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base"> <div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{["promoters", "passives", "detractors", "dismissed"].map((group) => ( {["promoters", "passives", "detractors", "dismissed"].map((group) => (
<button <button
className="w-full cursor-pointer hover:opacity-80" className="w-full cursor-pointer hover:opacity-80"
@@ -72,7 +72,7 @@ export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryPro
className={`mb-2 flex justify-between ${group === "dismissed" ? "mb-2 border-t bg-white pt-4 text-sm md:text-base" : ""}`}> className={`mb-2 flex justify-between ${group === "dismissed" ? "mb-2 border-t bg-white pt-4 text-sm md:text-base" : ""}`}>
<div className="mr-8 flex space-x-1"> <div className="mr-8 flex space-x-1">
<p <p
className={`font-semibold text-slate-700 capitalize ${group === "dismissed" ? "" : "text-slate-700"}`}> className={`font-semibold capitalize text-slate-700 ${group === "dismissed" ? "" : "text-slate-700"}`}>
{group} {group}
</p> </p>
<div> <div>
@@ -94,7 +94,7 @@ export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryPro
))} ))}
</div> </div>
<div className="flex justify-center pt-4 pb-4"> <div className="flex justify-center pb-4 pt-4">
<HalfCircle value={questionSummary.score} /> <HalfCircle value={questionSummary.score} />
</div> </div>
</div> </div>

View File

@@ -43,7 +43,7 @@ export const PictureChoiceSummary = ({ questionSummary, survey, setFilter }: Pic
) : undefined ) : undefined
} }
/> />
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base"> <div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{results.map((result, index) => ( {results.map((result, index) => (
<button <button
className="w-full cursor-pointer hover:opacity-80" className="w-full cursor-pointer hover:opacity-80"

View File

@@ -26,7 +26,7 @@ export const QuestionSummaryHeader = ({
const questionType = getQuestionTypes(t).find((type) => type.id === questionSummary.question.type); const questionType = getQuestionTypes(t).find((type) => type.id === questionSummary.question.type);
return ( return (
<div className="space-y-2 px-4 pt-6 pb-5 md:px-6"> <div className="space-y-2 px-4 pb-5 pt-6 md:px-6">
<div className={"align-center flex justify-between gap-4"}> <div className={"align-center flex justify-between gap-4"}>
<h3 className="pb-1 text-lg font-semibold text-slate-900 md:text-xl"> <h3 className="pb-1 text-lg font-semibold text-slate-900 md:text-xl">
{formatTextWithSlashes( {formatTextWithSlashes(

View File

@@ -50,7 +50,7 @@ export const RatingSummary = ({ questionSummary, survey, setFilter }: RatingSumm
</div> </div>
} }
/> />
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base"> <div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{questionSummary.choices.map((result) => ( {questionSummary.choices.map((result) => (
<button <button
className="w-full cursor-pointer hover:opacity-80" className="w-full cursor-pointer hover:opacity-80"

View File

@@ -61,10 +61,10 @@ export const SummaryDropOffs = ({ dropOff, survey }: SummaryDropOffsProps) => {
)} )}
</p> </p>
</div> </div>
<div className="text-center font-semibold whitespace-pre-wrap"> <div className="whitespace-pre-wrap text-center font-semibold">
{quesDropOff.ttc > 0 ? (quesDropOff.ttc / 1000).toFixed(2) + "s" : "N/A"} {quesDropOff.ttc > 0 ? (quesDropOff.ttc / 1000).toFixed(2) + "s" : "N/A"}
</div> </div>
<div className="text-center font-semibold whitespace-pre-wrap">{quesDropOff.impressions}</div> <div className="whitespace-pre-wrap text-center font-semibold">{quesDropOff.impressions}</div>
<div className="pl-6 text-center md:px-6"> <div className="pl-6 text-center md:px-6">
<span className="mr-1.5 font-semibold">{quesDropOff.dropOffCount}</span> <span className="mr-1.5 font-semibold">{quesDropOff.dropOffCount}</span>
<span>({Math.round(quesDropOff.dropOffPercentage)}%)</span> <span>({Math.round(quesDropOff.dropOffPercentage)}%)</span>

View File

@@ -11,7 +11,11 @@ vi.mock("lucide-react", () => ({
vi.mock("@/modules/ui/components/tooltip", () => ({ vi.mock("@/modules/ui/components/tooltip", () => ({
TooltipProvider: ({ children }) => <>{children}</>, TooltipProvider: ({ children }) => <>{children}</>,
Tooltip: ({ children }) => <>{children}</>, Tooltip: ({ children }) => <>{children}</>,
TooltipTrigger: ({ children }) => <>{children}</>, TooltipTrigger: ({ children, onClick }) => (
<button tabIndex={0} onClick={onClick} style={{ display: "inline-block" }}>
{children}
</button>
),
TooltipContent: ({ children }) => <>{children}</>, TooltipContent: ({ children }) => <>{children}</>,
})); }));
@@ -67,8 +71,10 @@ describe("SummaryMetadata", () => {
expect(screen.getByText("25%")).toBeInTheDocument(); expect(screen.getByText("25%")).toBeInTheDocument();
expect(screen.getByText("1")).toBeInTheDocument(); expect(screen.getByText("1")).toBeInTheDocument();
expect(screen.getByText("1m 5.00s")).toBeInTheDocument(); expect(screen.getByText("1m 5.00s")).toBeInTheDocument();
const btn = screen.getByRole("button"); const btn = screen
expect(screen.queryByTestId("down")).toBeInTheDocument(); .getAllByRole("button")
.find((el) => el.textContent?.includes("environments.surveys.summary.drop_offs"));
if (!btn) throw new Error("DropOffs toggle button not found");
await userEvent.click(btn); await userEvent.click(btn);
expect(screen.queryByTestId("up")).toBeInTheDocument(); expect(screen.queryByTestId("up")).toBeInTheDocument();
}); });
@@ -101,8 +107,10 @@ describe("SummaryMetadata", () => {
}; };
render(<Wrapper />); render(<Wrapper />);
expect(screen.getAllByText("-")).toHaveLength(1); expect(screen.getAllByText("-")).toHaveLength(1);
const btn = screen.getByRole("button"); const btn = screen
expect(screen.queryByTestId("down")).toBeInTheDocument(); .getAllByRole("button")
.find((el) => el.textContent?.includes("environments.surveys.summary.drop_offs"));
if (!btn) throw new Error("DropOffs toggle button not found");
await userEvent.click(btn); await userEvent.click(btn);
expect(screen.queryByTestId("up")).toBeInTheDocument(); expect(screen.queryByTestId("up")).toBeInTheDocument();
}); });

View File

@@ -100,8 +100,8 @@ export const SummaryMetadata = ({
<TooltipProvider delayDuration={50}> <TooltipProvider delayDuration={50}>
<Tooltip> <Tooltip>
<TooltipTrigger> <TooltipTrigger onClick={() => setShowDropOffs(!showDropOffs)} data-testid="dropoffs-toggle">
<div className="flex h-full cursor-default flex-col justify-between space-y-2 rounded-xl border border-slate-200 bg-white p-4 text-left shadow-sm"> <div className="flex h-full cursor-pointer flex-col justify-between space-y-2 rounded-xl border border-slate-200 bg-white p-4 text-left shadow-sm">
<span className="text-sm text-slate-600"> <span className="text-sm text-slate-600">
{t("environments.surveys.summary.drop_offs")} {t("environments.surveys.summary.drop_offs")}
{`${Math.round(dropOffPercentage)}%` !== "NaN%" && !isLoading && ( {`${Math.round(dropOffPercentage)}%` !== "NaN%" && !isLoading && (
@@ -117,15 +117,13 @@ export const SummaryMetadata = ({
)} )}
</span> </span>
{!isLoading && ( {!isLoading && (
<button <span className="ml-1 flex items-center rounded-md bg-slate-800 px-2 py-1 text-xs text-slate-50 group-hover:bg-slate-700">
className="ml-1 flex items-center rounded-md bg-slate-800 px-2 py-1 text-xs text-slate-50 group-hover:bg-slate-700"
onClick={() => setShowDropOffs(!showDropOffs)}>
{showDropOffs ? ( {showDropOffs ? (
<ChevronUpIcon className="h-4 w-4" /> <ChevronUpIcon className="h-4 w-4" />
) : ( ) : (
<ChevronDownIcon className="h-4 w-4" /> <ChevronDownIcon className="h-4 w-4" />
)} )}
</button> </span>
)} )}
</div> </div>
</div> </div>

View File

@@ -30,6 +30,7 @@ vi.mock("@/lib/constants", () => ({
SMTP_HOST: "mock-smtp-host", SMTP_HOST: "mock-smtp-host",
SMTP_PORT: "mock-smtp-port", SMTP_PORT: "mock-smtp-port",
IS_POSTHOG_CONFIGURED: true, IS_POSTHOG_CONFIGURED: true,
SESSION_MAX_AGE: 1000,
})); }));
// Create a spy for refreshSingleUseId so we can override it in tests // Create a spy for refreshSingleUseId so we can override it in tests

View File

@@ -28,7 +28,7 @@ export const useSurveyQRCode = (surveyUrl: string) => {
} catch (error) { } catch (error) {
toast.error(t("environments.surveys.summary.failed_to_generate_qr_code")); toast.error(t("environments.surveys.summary.failed_to_generate_qr_code"));
} }
}, [surveyUrl]); }, [surveyUrl, t]);
const downloadQRCode = () => { const downloadQRCode = () => {
try { try {

View File

@@ -96,7 +96,7 @@ const checkSurveyFollowUpsPermission = async (organizationId: string): Promise<v
throw new ResourceNotFoundError("Organization not found", organizationId); throw new ResourceNotFoundError("Organization not found", organizationId);
} }
const isSurveyFollowUpsEnabled = getSurveyFollowUpsPermission(organization.billing.plan); const isSurveyFollowUpsEnabled = await getSurveyFollowUpsPermission(organization.billing.plan);
if (!isSurveyFollowUpsEnabled) { if (!isSurveyFollowUpsEnabled) {
throw new OperationNotAllowedError("Survey follow ups are not enabled for this organization"); throw new OperationNotAllowedError("Survey follow ups are not enabled for this organization");
} }

View File

@@ -250,6 +250,7 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
if (responsesDownloadUrlResponse?.data) { if (responsesDownloadUrlResponse?.data) {
const link = document.createElement("a"); const link = document.createElement("a");
link.href = responsesDownloadUrlResponse.data; link.href = responsesDownloadUrlResponse.data;
link.download = "";
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
document.body.removeChild(link); document.body.removeChild(link);
@@ -390,7 +391,7 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
value && handleDatePickerClose(); value && handleDatePickerClose();
}}> }}>
<DropdownMenuTrigger asChild className="focus:bg-muted cursor-pointer outline-none"> <DropdownMenuTrigger asChild className="focus:bg-muted cursor-pointer outline-none">
<div className="h-auto min-w-auto rounded-md border border-slate-200 bg-white p-3 hover:border-slate-300 sm:flex sm:px-6 sm:py-3"> <div className="min-w-auto h-auto rounded-md border border-slate-200 bg-white p-3 hover:border-slate-300 sm:flex sm:px-6 sm:py-3">
<div className="hidden w-full items-center justify-between sm:flex"> <div className="hidden w-full items-center justify-between sm:flex">
<span className="text-sm text-slate-700">{t("common.download")}</span> <span className="text-sm text-slate-700">{t("common.download")}</span>
<ArrowDownToLineIcon className="ml-2 h-4 w-4" /> <ArrowDownToLineIcon className="ml-2 h-4 w-4" />

View File

@@ -91,7 +91,7 @@ export const QuestionFilterComboBox = ({
key={`${o}-${index}`} key={`${o}-${index}`}
type="button" type="button"
onClick={() => handleRemoveMultiSelect(filterComboBoxValue.filter((i) => i !== o))} onClick={() => handleRemoveMultiSelect(filterComboBoxValue.filter((i) => i !== o))}
className="flex w-30 items-center bg-slate-100 px-2 whitespace-nowrap text-slate-600"> className="w-30 flex items-center whitespace-nowrap bg-slate-100 px-2 text-slate-600">
{o} {o}
<X width={14} height={14} className="ml-2" /> <X width={14} height={14} className="ml-2" />
</button> </button>
@@ -129,7 +129,7 @@ export const QuestionFilterComboBox = ({
<DropdownMenuTrigger <DropdownMenuTrigger
disabled={disabled} disabled={disabled}
className={clsx( className={clsx(
"h-9 max-w-fit rounded-md rounded-r-none border-r-[1px] border-slate-300 bg-white p-2 text-sm text-slate-600 focus:ring-0 focus:outline-transparent", "h-9 max-w-fit rounded-md rounded-r-none border-r-[1px] border-slate-300 bg-white p-2 text-sm text-slate-600 focus:outline-transparent focus:ring-0",
!disabled ? "cursor-pointer" : "opacity-50" !disabled ? "cursor-pointer" : "opacity-50"
)}> )}>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">

View File

@@ -1,7 +1,14 @@
import { cleanup, render, screen } from "@testing-library/react"; import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest"; import { afterEach, describe, expect, test, vi } from "vitest";
import { OptionsType, QuestionOption, QuestionOptions, QuestionsComboBox } from "./QuestionsComboBox"; import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import {
OptionsType,
QuestionOption,
QuestionOptions,
QuestionsComboBox,
SelectedCommandItem,
} from "./QuestionsComboBox";
describe("QuestionsComboBox", () => { describe("QuestionsComboBox", () => {
afterEach(() => { afterEach(() => {
@@ -53,3 +60,67 @@ describe("QuestionsComboBox", () => {
expect(screen.getByText("Q1")).toBeInTheDocument(); // Verify the selected item is now displayed expect(screen.getByText("Q1")).toBeInTheDocument(); // Verify the selected item is now displayed
}); });
}); });
describe("SelectedCommandItem", () => {
test("renders question icon and color for QUESTIONS with questionType", () => {
const { container } = render(
<SelectedCommandItem
label="Q1"
type={OptionsType.QUESTIONS}
questionType={TSurveyQuestionTypeEnum.OpenText}
/>
);
expect(container.querySelector(".bg-brand-dark")).toBeInTheDocument();
expect(container.querySelector("svg")).toBeInTheDocument();
expect(container.textContent).toContain("Q1");
});
test("renders attribute icon and color for ATTRIBUTES", () => {
const { container } = render(<SelectedCommandItem label="Attr" type={OptionsType.ATTRIBUTES} />);
expect(container.querySelector(".bg-indigo-500")).toBeInTheDocument();
expect(container.querySelector("svg")).toBeInTheDocument();
expect(container.textContent).toContain("Attr");
});
test("renders hidden field icon and color for HIDDEN_FIELDS", () => {
const { container } = render(<SelectedCommandItem label="Hidden" type={OptionsType.HIDDEN_FIELDS} />);
expect(container.querySelector(".bg-amber-500")).toBeInTheDocument();
expect(container.querySelector("svg")).toBeInTheDocument();
expect(container.textContent).toContain("Hidden");
});
test("renders meta icon and color for META with label", () => {
const { container } = render(<SelectedCommandItem label="device" type={OptionsType.META} />);
expect(container.querySelector(".bg-amber-500")).toBeInTheDocument();
expect(container.querySelector("svg")).toBeInTheDocument();
expect(container.textContent).toContain("device");
});
test("renders other icon and color for OTHERS with label", () => {
const { container } = render(<SelectedCommandItem label="Language" type={OptionsType.OTHERS} />);
expect(container.querySelector(".bg-amber-500")).toBeInTheDocument();
expect(container.querySelector("svg")).toBeInTheDocument();
expect(container.textContent).toContain("Language");
});
test("renders tag icon and color for TAGS", () => {
const { container } = render(<SelectedCommandItem label="Tag1" type={OptionsType.TAGS} />);
expect(container.querySelector(".bg-indigo-500")).toBeInTheDocument();
expect(container.querySelector("svg")).toBeInTheDocument();
expect(container.textContent).toContain("Tag1");
});
test("renders fallback color and no icon for unknown type", () => {
const { container } = render(<SelectedCommandItem label="Unknown" type={"UNKNOWN"} />);
expect(container.querySelector(".bg-amber-500")).toBeInTheDocument();
expect(container.querySelector("svg")).not.toBeInTheDocument();
expect(container.textContent).toContain("Unknown");
});
test("renders fallback for non-string label", () => {
const { container } = render(
<SelectedCommandItem label={{ default: "NonString" }} type={OptionsType.QUESTIONS} />
);
expect(container.textContent).toContain("NonString");
});
});

View File

@@ -18,11 +18,12 @@ import {
CheckIcon, CheckIcon,
ChevronDown, ChevronDown,
ChevronUp, ChevronUp,
ContactIcon,
EyeOff, EyeOff,
GlobeIcon, GlobeIcon,
GridIcon, GridIcon,
HashIcon, HashIcon,
HelpCircleIcon, HomeIcon,
ImageIcon, ImageIcon,
LanguagesIcon, LanguagesIcon,
ListIcon, ListIcon,
@@ -63,59 +64,60 @@ interface QuestionComboBoxProps {
onChangeValue: (option: QuestionOption) => void; onChangeValue: (option: QuestionOption) => void;
} }
const SelectedCommandItem = ({ label, questionType, type }: Partial<QuestionOption>) => { const questionIcons = {
const getIconType = () => { // questions
switch (type) { [TSurveyQuestionTypeEnum.OpenText]: MessageSquareTextIcon,
case OptionsType.QUESTIONS: [TSurveyQuestionTypeEnum.Rating]: StarIcon,
switch (questionType) { [TSurveyQuestionTypeEnum.CTA]: MousePointerClickIcon,
case TSurveyQuestionTypeEnum.OpenText: [TSurveyQuestionTypeEnum.MultipleChoiceMulti]: ListIcon,
return <MessageSquareTextIcon width={18} height={18} className="text-white" />; [TSurveyQuestionTypeEnum.MultipleChoiceSingle]: Rows3Icon,
case TSurveyQuestionTypeEnum.Rating: [TSurveyQuestionTypeEnum.NPS]: NetPromoterScoreIcon,
return <StarIcon width={18} height={18} className="text-white" />; [TSurveyQuestionTypeEnum.Consent]: CheckIcon,
case TSurveyQuestionTypeEnum.CTA: [TSurveyQuestionTypeEnum.PictureSelection]: ImageIcon,
return <MousePointerClickIcon width={18} height={18} className="text-white" />; [TSurveyQuestionTypeEnum.Matrix]: GridIcon,
case TSurveyQuestionTypeEnum.OpenText: [TSurveyQuestionTypeEnum.Ranking]: ListOrderedIcon,
return <HelpCircleIcon width={18} height={18} className="text-white" />; [TSurveyQuestionTypeEnum.Address]: HomeIcon,
case TSurveyQuestionTypeEnum.MultipleChoiceMulti: [TSurveyQuestionTypeEnum.ContactInfo]: ContactIcon,
return <ListIcon width={18} height={18} className="text-white" />;
case TSurveyQuestionTypeEnum.MultipleChoiceSingle:
return <Rows3Icon width={18} height={18} className="text-white" />;
case TSurveyQuestionTypeEnum.NPS:
return <NetPromoterScoreIcon width={18} height={18} className="text-white" />;
case TSurveyQuestionTypeEnum.Consent:
return <CheckIcon width={18} height={18} className="text-white" />;
case TSurveyQuestionTypeEnum.PictureSelection:
return <ImageIcon width={18} height={18} className="text-white" />;
case TSurveyQuestionTypeEnum.Matrix:
return <GridIcon width={18} height={18} className="text-white" />;
case TSurveyQuestionTypeEnum.Ranking:
return <ListOrderedIcon width={18} height={18} className="text-white" />;
}
case OptionsType.ATTRIBUTES:
return <User width={18} height={18} className="text-white" />;
case OptionsType.HIDDEN_FIELDS: // attributes
return <EyeOff width={18} height={18} className="text-white" />; [OptionsType.ATTRIBUTES]: User,
case OptionsType.META:
switch (label) { // hidden fields
case "device": [OptionsType.HIDDEN_FIELDS]: EyeOff,
return <SmartphoneIcon width={18} height={18} className="text-white" />;
case "os": // meta
return <AirplayIcon width={18} height={18} className="text-white" />; device: SmartphoneIcon,
case "browser": os: AirplayIcon,
return <GlobeIcon width={18} height={18} className="text-white" />; browser: GlobeIcon,
case "source": source: GlobeIcon,
return <GlobeIcon width={18} height={18} className="text-white" />; action: MousePointerClickIcon,
case "action":
return <MousePointerClickIcon width={18} height={18} className="text-white" />; // others
} Language: LanguagesIcon,
case OptionsType.OTHERS:
switch (label) { // tags
case "Language": [OptionsType.TAGS]: HashIcon,
return <LanguagesIcon width={18} height={18} className="text-white" />; };
}
case OptionsType.TAGS: const getIcon = (type: string) => {
return <HashIcon width={18} height={18} className="text-white" />; const IconComponent = questionIcons[type];
return IconComponent ? <IconComponent width={18} height={18} className="text-white" /> : null;
};
export const SelectedCommandItem = ({ label, questionType, type }: Partial<QuestionOption>) => {
const getIconType = () => {
if (type) {
if (type === OptionsType.QUESTIONS && questionType) {
return getIcon(questionType);
} else if (type === OptionsType.ATTRIBUTES) {
return getIcon(OptionsType.ATTRIBUTES);
} else if (type === OptionsType.HIDDEN_FIELDS) {
return getIcon(OptionsType.HIDDEN_FIELDS);
} else if ([OptionsType.META, OptionsType.OTHERS].includes(type) && label) {
return getIcon(label);
} else if (type === OptionsType.TAGS) {
return getIcon(OptionsType.TAGS);
}
} }
}; };
@@ -164,7 +166,7 @@ export const QuestionsComboBox = ({ options, selected, onChangeValue }: Question
value={inputValue} value={inputValue}
onValueChange={setInputValue} onValueChange={setInputValue}
placeholder={t("common.search") + "..."} placeholder={t("common.search") + "..."}
className="h-5 border-none border-transparent p-0 shadow-none ring-offset-transparent outline-0 focus:border-none focus:border-transparent focus:shadow-none focus:ring-offset-transparent focus:outline-0" className="h-5 border-none border-transparent p-0 shadow-none outline-0 ring-offset-transparent focus:border-none focus:border-transparent focus:shadow-none focus:outline-0 focus:ring-offset-transparent"
/> />
)} )}
<div> <div>

View File

@@ -38,6 +38,7 @@ vi.mock("@/lib/constants", () => ({
POSTHOG_API_KEY: "test-posthog-api-key", POSTHOG_API_KEY: "test-posthog-api-key",
FORMBRICKS_ENVIRONMENT_ID: "mock-formbricks-environment-id", FORMBRICKS_ENVIRONMENT_ID: "mock-formbricks-environment-id",
IS_FORMBRICKS_ENABLED: true, IS_FORMBRICKS_ENABLED: true,
SESSION_MAX_AGE: 1000,
})); }));
vi.mock("@/app/intercom/IntercomClientWrapper", () => ({ vi.mock("@/app/intercom/IntercomClientWrapper", () => ({

View File

@@ -0,0 +1,20 @@
import { cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import EmailChangeWithoutVerificationSuccessPage from "./page";
vi.mock("@/modules/auth/email-change-without-verification-success/page", () => ({
EmailChangeWithoutVerificationSuccessPage: ({ children }) => (
<div data-testid="email-change-success-page">{children}</div>
),
}));
describe("EmailChangeWithoutVerificationSuccessPage", () => {
afterEach(() => {
cleanup();
});
test("renders EmailChangeWithoutVerificationSuccessPage", () => {
const { getByTestId } = render(<EmailChangeWithoutVerificationSuccessPage />);
expect(getByTestId("email-change-success-page")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,3 @@
import { EmailChangeWithoutVerificationSuccessPage } from "@/modules/auth/email-change-without-verification-success/page";
export default EmailChangeWithoutVerificationSuccessPage;

View File

@@ -0,0 +1,3 @@
import { VerifyEmailChangePage } from "@/modules/auth/verify-email-change/page";
export default VerifyEmailChangePage;

View File

@@ -1,235 +0,0 @@
import {
mockContactEmailFollowUp,
mockDirectEmailFollowUp,
mockEndingFollowUp,
mockEndingId2,
mockResponse,
mockResponseEmailFollowUp,
mockResponseWithContactQuestion,
mockSurvey,
mockSurveyWithContactQuestion,
} from "@/app/api/(internal)/pipeline/lib/__mocks__/survey-follow-up.mock";
import { sendFollowUpEmail } from "@/modules/email";
import { describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
import { TOrganization } from "@formbricks/types/organizations";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { evaluateFollowUp, sendSurveyFollowUps } from "./survey-follow-up";
// Mock dependencies
vi.mock("@/modules/email", () => ({
sendFollowUpEmail: vi.fn(),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
describe("Survey Follow Up", () => {
const mockOrganization: Partial<TOrganization> = {
id: "org1",
name: "Test Org",
whitelabel: {
logoUrl: "https://example.com/logo.png",
},
};
describe("evaluateFollowUp", () => {
test("sends email when to is a direct email address", async () => {
const followUpId = mockDirectEmailFollowUp.id;
const followUpAction = mockDirectEmailFollowUp.action;
await evaluateFollowUp(
followUpId,
followUpAction,
mockSurvey,
mockResponse,
mockOrganization as TOrganization
);
expect(sendFollowUpEmail).toHaveBeenCalledWith({
html: mockDirectEmailFollowUp.action.properties.body,
subject: mockDirectEmailFollowUp.action.properties.subject,
to: mockDirectEmailFollowUp.action.properties.to,
replyTo: mockDirectEmailFollowUp.action.properties.replyTo,
survey: mockSurvey,
response: mockResponse,
attachResponseData: true,
logoUrl: "https://example.com/logo.png",
});
});
test("sends email when to is a question ID with valid email", async () => {
const followUpId = mockResponseEmailFollowUp.id;
const followUpAction = mockResponseEmailFollowUp.action;
await evaluateFollowUp(
followUpId,
followUpAction,
mockSurvey as TSurvey,
mockResponse as TResponse,
mockOrganization as TOrganization
);
expect(sendFollowUpEmail).toHaveBeenCalledWith({
html: mockResponseEmailFollowUp.action.properties.body,
subject: mockResponseEmailFollowUp.action.properties.subject,
to: mockResponse.data[mockResponseEmailFollowUp.action.properties.to],
replyTo: mockResponseEmailFollowUp.action.properties.replyTo,
survey: mockSurvey,
response: mockResponse,
attachResponseData: true,
logoUrl: "https://example.com/logo.png",
});
});
test("sends email when to is a question ID with valid email in array", async () => {
const followUpId = mockContactEmailFollowUp.id;
const followUpAction = mockContactEmailFollowUp.action;
await evaluateFollowUp(
followUpId,
followUpAction,
mockSurveyWithContactQuestion,
mockResponseWithContactQuestion,
mockOrganization as TOrganization
);
expect(sendFollowUpEmail).toHaveBeenCalledWith({
html: mockContactEmailFollowUp.action.properties.body,
subject: mockContactEmailFollowUp.action.properties.subject,
to: mockResponseWithContactQuestion.data[mockContactEmailFollowUp.action.properties.to][2],
replyTo: mockContactEmailFollowUp.action.properties.replyTo,
survey: mockSurveyWithContactQuestion,
response: mockResponseWithContactQuestion,
attachResponseData: true,
logoUrl: "https://example.com/logo.png",
});
});
test("throws error when to value is not found in response data", async () => {
const followUpId = "followup1";
const followUpAction = {
...mockSurvey.followUps![0].action,
properties: {
...mockSurvey.followUps![0].action.properties,
to: "nonExistentField",
},
};
await expect(
evaluateFollowUp(
followUpId,
followUpAction,
mockSurvey as TSurvey,
mockResponse as TResponse,
mockOrganization as TOrganization
)
).rejects.toThrow(`"To" value not found in response data for followup: ${followUpId}`);
});
test("throws error when email address is invalid", async () => {
const followUpId = mockResponseEmailFollowUp.id;
const followUpAction = mockResponseEmailFollowUp.action;
const invalidResponse = {
...mockResponse,
data: {
[mockResponseEmailFollowUp.action.properties.to]: "invalid-email",
},
};
await expect(
evaluateFollowUp(
followUpId,
followUpAction,
mockSurvey,
invalidResponse,
mockOrganization as TOrganization
)
).rejects.toThrow(`Email address is not valid for followup: ${followUpId}`);
});
});
describe("sendSurveyFollowUps", () => {
test("skips follow-up when ending Id doesn't match", async () => {
const responseWithDifferentEnding = {
...mockResponse,
endingId: mockEndingId2,
};
const mockSurveyWithEndingFollowUp: TSurvey = {
...mockSurvey,
followUps: [mockEndingFollowUp],
};
const results = await sendSurveyFollowUps(
mockSurveyWithEndingFollowUp,
responseWithDifferentEnding as TResponse,
mockOrganization as TOrganization
);
expect(results).toEqual([
{
followUpId: mockEndingFollowUp.id,
status: "skipped",
},
]);
expect(sendFollowUpEmail).not.toHaveBeenCalled();
});
test("processes follow-ups and log errors", async () => {
const error = new Error("Test error");
vi.mocked(sendFollowUpEmail).mockRejectedValueOnce(error);
const mockSurveyWithFollowUps: TSurvey = {
...mockSurvey,
followUps: [mockResponseEmailFollowUp],
};
const results = await sendSurveyFollowUps(
mockSurveyWithFollowUps,
mockResponse,
mockOrganization as TOrganization
);
expect(results).toEqual([
{
followUpId: mockResponseEmailFollowUp.id,
status: "error",
error: "Test error",
},
]);
expect(logger.error).toHaveBeenCalledWith(
[`FollowUp ${mockResponseEmailFollowUp.id} failed: Test error`],
"Follow-up processing errors"
);
});
test("successfully processes follow-ups", async () => {
vi.mocked(sendFollowUpEmail).mockResolvedValueOnce(undefined);
const mockSurveyWithFollowUp: TSurvey = {
...mockSurvey,
followUps: [mockDirectEmailFollowUp],
};
const results = await sendSurveyFollowUps(
mockSurveyWithFollowUp,
mockResponse,
mockOrganization as TOrganization
);
expect(results).toEqual([
{
followUpId: mockDirectEmailFollowUp.id,
status: "success",
},
]);
expect(logger.error).not.toHaveBeenCalled();
});
});
});

View File

@@ -1,135 +0,0 @@
import { sendFollowUpEmail } from "@/modules/email";
import { z } from "zod";
import { TSurveyFollowUpAction } from "@formbricks/database/types/survey-follow-up";
import { logger } from "@formbricks/logger";
import { TOrganization } from "@formbricks/types/organizations";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
type FollowUpResult = {
followUpId: string;
status: "success" | "error" | "skipped";
error?: string;
};
export const evaluateFollowUp = async (
followUpId: string,
followUpAction: TSurveyFollowUpAction,
survey: TSurvey,
response: TResponse,
organization: TOrganization
): Promise<void> => {
const { properties } = followUpAction;
const { to, subject, body, replyTo } = properties;
const toValueFromResponse = response.data[to];
const logoUrl = organization.whitelabel?.logoUrl || "";
// Check if 'to' is a direct email address (team member or user email)
const parsedEmailTo = z.string().email().safeParse(to);
if (parsedEmailTo.success) {
// 'to' is a valid email address, send email directly
await sendFollowUpEmail({
html: body,
subject,
to: parsedEmailTo.data,
replyTo,
survey,
response,
attachResponseData: properties.attachResponseData,
logoUrl,
});
return;
}
// If not a direct email, check if it's a question ID or hidden field ID
if (!toValueFromResponse) {
throw new Error(`"To" value not found in response data for followup: ${followUpId}`);
}
if (typeof toValueFromResponse === "string") {
// parse this string to check for an email:
const parsedResult = z.string().email().safeParse(toValueFromResponse);
if (parsedResult.data) {
// send email to this email address
await sendFollowUpEmail({
html: body,
subject,
to: parsedResult.data,
replyTo,
logoUrl,
survey,
response,
attachResponseData: properties.attachResponseData,
});
} else {
throw new Error(`Email address is not valid for followup: ${followUpId}`);
}
} else if (Array.isArray(toValueFromResponse)) {
const emailAddress = toValueFromResponse[2];
if (!emailAddress) {
throw new Error(`Email address not found in response data for followup: ${followUpId}`);
}
const parsedResult = z.string().email().safeParse(emailAddress);
if (parsedResult.data) {
await sendFollowUpEmail({
html: body,
subject,
to: parsedResult.data,
replyTo,
logoUrl,
survey,
response,
attachResponseData: properties.attachResponseData,
});
} else {
throw new Error(`Email address is not valid for followup: ${followUpId}`);
}
}
};
export const sendSurveyFollowUps = async (
survey: TSurvey,
response: TResponse,
organization: TOrganization
): Promise<FollowUpResult[]> => {
const followUpPromises = survey.followUps.map(async (followUp): Promise<FollowUpResult> => {
const { trigger } = followUp;
// Check if we should skip this follow-up based on ending IDs
if (trigger.properties) {
const { endingIds } = trigger.properties;
const { endingId } = response;
if (!endingId || !endingIds.includes(endingId)) {
return Promise.resolve({
followUpId: followUp.id,
status: "skipped",
});
}
}
return evaluateFollowUp(followUp.id, followUp.action, survey, response, organization)
.then(() => ({
followUpId: followUp.id,
status: "success" as const,
}))
.catch((error) => ({
followUpId: followUp.id,
status: "error" as const,
error: error instanceof Error ? error.message : "Something went wrong",
}));
});
const followUpResults = await Promise.all(followUpPromises);
// Log all errors
const errors = followUpResults
.filter((result): result is FollowUpResult & { status: "error" } => result.status === "error")
.map((result) => `FollowUp ${result.followUpId} failed: ${result.error}`);
if (errors.length > 0) {
logger.error(errors, "Follow-up processing errors");
}
return followUpResults;
};

View File

@@ -1,4 +1,3 @@
import { sendSurveyFollowUps } from "@/app/api/(internal)/pipeline/lib/survey-follow-up";
import { ZPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines"; import { ZPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
import { responses } from "@/app/lib/api/response"; import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator"; import { transformErrorToDetails } from "@/app/lib/api/validator";
@@ -11,7 +10,8 @@ import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey, updateSurvey } from "@/lib/survey/service"; import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { convertDatesInObject } from "@/lib/time"; import { convertDatesInObject } from "@/lib/time";
import { sendResponseFinishedEmail } from "@/modules/email"; import { sendResponseFinishedEmail } from "@/modules/email";
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils"; import { sendFollowUpsForResponse } from "@/modules/survey/follow-ups/lib/follow-ups";
import { FollowUpSendError } from "@/modules/survey/follow-ups/types/follow-up";
import { PipelineTriggers, Webhook } from "@prisma/client"; import { PipelineTriggers, Webhook } from "@prisma/client";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
@@ -164,11 +164,15 @@ export const POST = async (request: Request) => {
select: { email: true, locale: true }, select: { email: true, locale: true },
}); });
// send follow up emails if (survey.followUps?.length > 0) {
const surveyFollowUpsPermission = await getSurveyFollowUpsPermission(organization.billing.plan); // send follow up emails
const followUpsResult = await sendFollowUpsForResponse(response.id);
if (surveyFollowUpsPermission) { if (!followUpsResult.ok) {
await sendSurveyFollowUps(survey, response, organization); const { error: followUpsError } = followUpsResult;
if (followUpsError.code !== FollowUpSendError.FOLLOW_UP_NOT_ALLOWED) {
logger.error({ error: followUpsError }, `Failed to send follow-up emails for survey ${surveyId}`);
}
}
} }
const emailPromises = usersWithNotifications.map((user) => const emailPromises = usersWithNotifications.map((user) =>

View File

@@ -254,7 +254,7 @@ describe("getEnvironmentState", () => {
vi.mocked(getEnvironment).mockResolvedValue(mockEnvironment); vi.mocked(getEnvironment).mockResolvedValue(mockEnvironment);
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization); vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization);
vi.mocked(getProjectForEnvironmentState).mockResolvedValue(mockProject); vi.mocked(getProjectForEnvironmentState).mockResolvedValue(mockProject);
vi.mocked(getSurveysForEnvironmentState).mockResolvedValue(mockSurveys); vi.mocked(getSurveysForEnvironmentState).mockResolvedValue([mockSurveys[0]]); // Only return the app, inProgress survey
vi.mocked(getActionClassesForEnvironmentState).mockResolvedValue(mockActionClasses); vi.mocked(getActionClassesForEnvironmentState).mockResolvedValue(mockActionClasses);
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(50); // Default below limit vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(50); // Default below limit
}); });

View File

@@ -99,12 +99,8 @@ export const getEnvironmentState = async (
getActionClassesForEnvironmentState(environmentId), getActionClassesForEnvironmentState(environmentId),
]); ]);
const filteredSurveys = surveys.filter(
(survey) => survey.type === "app" && survey.status === "inProgress"
);
const data: TJsEnvironmentState["data"] = { const data: TJsEnvironmentState["data"] = {
surveys: !isMonthlyResponsesLimitReached ? filteredSurveys : [], surveys: !isMonthlyResponsesLimitReached ? surveys : [],
actionClasses, actionClasses,
project: project, project: project,
...(IS_RECAPTCHA_CONFIGURED ? { recaptchaSiteKey: RECAPTCHA_SITE_KEY } : {}), ...(IS_RECAPTCHA_CONFIGURED ? { recaptchaSiteKey: RECAPTCHA_SITE_KEY } : {}),

View File

@@ -100,8 +100,14 @@ describe("getSurveysForEnvironmentState", () => {
expect(validateInputs).toHaveBeenCalledWith([environmentId, expect.any(Object)]); expect(validateInputs).toHaveBeenCalledWith([environmentId, expect.any(Object)]);
expect(prisma.survey.findMany).toHaveBeenCalledWith({ expect(prisma.survey.findMany).toHaveBeenCalledWith({
where: { environmentId }, where: {
environmentId,
type: "app",
status: "inProgress",
},
select: expect.any(Object), // Check if select is called, specific fields are in the original code select: expect.any(Object), // Check if select is called, specific fields are in the original code
orderBy: { createdAt: "desc" },
take: 30,
}); });
expect(transformPrismaSurvey).toHaveBeenCalledWith(mockPrismaSurvey); expect(transformPrismaSurvey).toHaveBeenCalledWith(mockPrismaSurvey);
expect(result).toEqual([mockTransformedSurvey]); expect(result).toEqual([mockTransformedSurvey]);
@@ -114,8 +120,14 @@ describe("getSurveysForEnvironmentState", () => {
const result = await getSurveysForEnvironmentState(environmentId); const result = await getSurveysForEnvironmentState(environmentId);
expect(prisma.survey.findMany).toHaveBeenCalledWith({ expect(prisma.survey.findMany).toHaveBeenCalledWith({
where: { environmentId }, where: {
environmentId,
type: "app",
status: "inProgress",
},
select: expect.any(Object), select: expect.any(Object),
orderBy: { createdAt: "desc" },
take: 30,
}); });
expect(transformPrismaSurvey).not.toHaveBeenCalled(); expect(transformPrismaSurvey).not.toHaveBeenCalled();
expect(result).toEqual([]); expect(result).toEqual([]);

View File

@@ -20,7 +20,13 @@ export const getSurveysForEnvironmentState = reactCache(
const surveysPrisma = await prisma.survey.findMany({ const surveysPrisma = await prisma.survey.findMany({
where: { where: {
environmentId, environmentId,
type: "app",
status: "inProgress",
}, },
orderBy: {
createdAt: "desc",
},
take: 30,
select: { select: {
id: true, id: true,
welcomeCard: true, welcomeCard: true,

View File

@@ -57,6 +57,10 @@ export const PUT = async (
return handleDatabaseError(error, request.url, endpoint, responseId); return handleDatabaseError(error, request.url, endpoint, responseId);
} }
if (response.finished) {
return responses.badRequestResponse("Response is already finished", undefined, true);
}
// get survey to get environmentId // get survey to get environmentId
let survey; let survey;
try { try {

View File

@@ -33,6 +33,7 @@ export const responseSelection = {
singleUseId: true, singleUseId: true,
language: true, language: true,
displayId: true, displayId: true,
endingId: true,
contact: { contact: {
select: { select: {
id: true, id: true,

View File

@@ -7,39 +7,133 @@ export const GET = async (req: NextRequest) => {
return new ImageResponse( return new ImageResponse(
( (
<div tw={`flex flex-col w-full h-full items-center bg-[${brandColor}]/75 rounded-xl `}> <div
style={{
display: "flex",
flexDirection: "column",
width: "100%",
height: "100%",
alignItems: "center",
backgroundColor: brandColor ? brandColor + "BF" : "#0000BFBF", // /75 opacity is approximately BF in hex
borderRadius: "0.75rem",
}}>
<div <div
tw="flex flex-col w-[80%] h-[60%] bg-white rounded-xl mt-13 absolute left-12 top-3 opacity-20"
style={{ style={{
display: "flex",
flexDirection: "column",
width: "80%",
height: "60%",
backgroundColor: "white",
borderRadius: "0.75rem",
marginTop: "3.25rem",
position: "absolute",
left: "3rem",
top: "0.75rem",
opacity: 0.2,
transform: "rotate(356deg)", transform: "rotate(356deg)",
}}></div> }}></div>
<div <div
tw="flex flex-col w-[84%] h-[60%] bg-white rounded-xl mt-12 absolute top-5 left-13 border-2 opacity-60"
style={{ style={{
display: "flex",
flexDirection: "column",
width: "84%",
height: "60%",
backgroundColor: "white",
borderRadius: "0.75rem",
marginTop: "3rem",
position: "absolute",
top: "1.25rem",
left: "3.25rem",
borderWidth: "2px",
opacity: 0.6,
transform: "rotate(357deg)", transform: "rotate(357deg)",
}}></div> }}></div>
<div <div
tw="flex flex-col w-[85%] h-[67%] items-center bg-white rounded-xl mt-8 absolute top-[2.3rem] left-14"
style={{ style={{
display: "flex",
flexDirection: "column",
width: "85%",
height: "67%",
alignItems: "center",
backgroundColor: "white",
borderRadius: "0.75rem",
marginTop: "2rem",
position: "absolute",
top: "2.3rem",
left: "3.5rem",
transform: "rotate(360deg)", transform: "rotate(360deg)",
}}> }}>
<div tw="flex flex-col w-full"> <div style={{ display: "flex", flexDirection: "column", width: "100%" }}>
<div tw="flex flex-col md:flex-row w-full md:items-center justify-between "> <div
<div tw="flex flex-col px-8"> style={{
<h2 tw="flex flex-col text-[8] sm:text-4xl font-bold tracking-tight text-slate-900 text-left mt-15"> display: "flex",
flexDirection: "column",
width: "100%",
justifyContent: "space-between",
}}>
<div
style={{
display: "flex",
flexDirection: "column",
paddingLeft: "2rem",
paddingRight: "2rem",
}}>
<h2
style={{
display: "flex",
flexDirection: "column",
fontSize: "2rem",
fontWeight: "700",
letterSpacing: "-0.025em",
color: "#0f172a",
textAlign: "left",
marginTop: "3.75rem",
}}>
{name} {name}
</h2> </h2>
</div> </div>
</div> </div>
<div tw="flex justify-end mr-10 "> <div style={{ display: "flex", justifyContent: "flex-end", marginRight: "2.5rem" }}>
<div tw="flex rounded-2xl absolute -right-2 mt-2"> <div
<a tw={`rounded-xl border border-transparent bg-[${brandColor}] h-18 w-38 opacity-50`}></a> style={{
display: "flex",
borderRadius: "1rem",
position: "absolute",
right: "-0.5rem",
marginTop: "0.5rem",
}}>
<div
content=""
style={{
borderRadius: "0.75rem",
border: "1px solid transparent",
backgroundColor: brandColor ?? "#000",
height: "4.5rem",
width: "9.5rem",
opacity: 0.5,
}}></div>
</div> </div>
<div tw="flex rounded-2xl shadow "> <div
<a style={{
tw={`flex items-center justify-center rounded-xl border border-transparent bg-[${brandColor}] text-2xl text-white h-18 w-38`}> display: "flex",
borderRadius: "1rem",
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
}}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: "0.75rem",
border: "1px solid transparent",
backgroundColor: brandColor ?? "#000",
fontSize: "1.5rem",
color: "white",
height: "4.5rem",
width: "9.5rem",
}}>
Begin! Begin!
</a> </div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -31,6 +31,7 @@ export const createResponse = async (responseInput: TResponseInputV2): Promise<T
contactId, contactId,
surveyId, surveyId,
displayId, displayId,
endingId,
finished, finished,
data, data,
meta, meta,
@@ -64,7 +65,8 @@ export const createResponse = async (responseInput: TResponseInputV2): Promise<T
}, },
}, },
display: displayId ? { connect: { id: displayId } } : undefined, display: displayId ? { connect: { id: displayId } } : undefined,
finished: finished, finished,
endingId,
data: data, data: data,
language: language, language: language,
...(contact?.id && { ...(contact?.id && {

View File

@@ -3,6 +3,7 @@ import { verifyRecaptchaToken } from "@/app/api/v2/client/[environmentId]/respon
import { checkSurveyValidity } from "@/app/api/v2/client/[environmentId]/responses/lib/utils"; import { checkSurveyValidity } from "@/app/api/v2/client/[environmentId]/responses/lib/utils";
import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/types/response"; import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/types/response";
import { responses } from "@/app/lib/api/response"; import { responses } from "@/app/lib/api/response";
import { symmetricDecrypt } from "@/lib/crypto";
import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils"; import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils";
import { Organization } from "@prisma/client"; import { Organization } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest"; import { beforeEach, describe, expect, test, vi } from "vitest";
@@ -40,6 +41,13 @@ vi.mock("@formbricks/logger", () => ({
}, },
})); }));
vi.mock("@/lib/crypto", () => ({
symmetricDecrypt: vi.fn(),
}));
vi.mock("@/lib/constants", () => ({
ENCRYPTION_KEY: "test-key",
}));
const mockSurvey: TSurvey = { const mockSurvey: TSurvey = {
id: "survey-1", id: "survey-1",
createdAt: new Date(), createdAt: new Date(),
@@ -206,4 +214,119 @@ describe("checkSurveyValidity", () => {
const result = await checkSurveyValidity(survey, "env-1", mockResponseInput); const result = await checkSurveyValidity(survey, "env-1", mockResponseInput);
expect(result).toBeNull(); expect(result).toBeNull();
}); });
test("should return badRequestResponse if singleUse is enabled and singleUseId is missing", async () => {
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false } };
const result = await checkSurveyValidity(survey, "env-1", { ...mockResponseInput });
expect(result).toBeInstanceOf(Response);
expect(result?.status).toBe(400);
expect(responses.badRequestResponse).toHaveBeenCalledWith("Missing single use id", {
surveyId: survey.id,
environmentId: "env-1",
});
});
test("should return badRequestResponse if singleUse is enabled and meta.url is missing", async () => {
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false } };
const result = await checkSurveyValidity(survey, "env-1", {
...mockResponseInput,
singleUseId: "su-1",
meta: {},
});
expect(result).toBeInstanceOf(Response);
expect(result?.status).toBe(400);
expect(responses.badRequestResponse).toHaveBeenCalledWith("Missing or invalid URL in response metadata", {
surveyId: survey.id,
environmentId: "env-1",
});
});
test("should return badRequestResponse if meta.url is invalid", async () => {
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false } };
const result = await checkSurveyValidity(survey, "env-1", {
...mockResponseInput,
singleUseId: "su-1",
meta: { url: "not-a-url" },
});
expect(result).toBeInstanceOf(Response);
expect(result?.status).toBe(400);
expect(responses.badRequestResponse).toHaveBeenCalledWith(
"Invalid URL in response metadata",
expect.objectContaining({ surveyId: survey.id, environmentId: "env-1" })
);
});
test("should return badRequestResponse if suId is missing from url", async () => {
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false } };
const url = "https://example.com/?foo=bar";
const result = await checkSurveyValidity(survey, "env-1", {
...mockResponseInput,
singleUseId: "su-1",
meta: { url },
});
expect(result).toBeInstanceOf(Response);
expect(result?.status).toBe(400);
expect(responses.badRequestResponse).toHaveBeenCalledWith("Missing single use id", {
surveyId: survey.id,
environmentId: "env-1",
});
});
test("should return badRequestResponse if isEncrypted and decrypted suId does not match singleUseId", async () => {
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: true } };
const url = "https://example.com/?suId=encrypted-id";
vi.mocked(symmetricDecrypt).mockReturnValue("decrypted-id");
const resultEncryptedMismatch = await checkSurveyValidity(survey, "env-1", {
...mockResponseInput,
singleUseId: "su-1",
meta: { url },
});
expect(symmetricDecrypt).toHaveBeenCalledWith("encrypted-id", "test-key");
expect(resultEncryptedMismatch).toBeInstanceOf(Response);
expect(resultEncryptedMismatch?.status).toBe(400);
expect(responses.badRequestResponse).toHaveBeenCalledWith("Invalid single use id", {
surveyId: survey.id,
environmentId: "env-1",
});
});
test("should return badRequestResponse if not encrypted and suId does not match singleUseId", async () => {
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false } };
const url = "https://example.com/?suId=su-2";
const result = await checkSurveyValidity(survey, "env-1", {
...mockResponseInput,
singleUseId: "su-1",
meta: { url },
});
expect(result).toBeInstanceOf(Response);
expect(result?.status).toBe(400);
expect(responses.badRequestResponse).toHaveBeenCalledWith("Invalid single use id", {
surveyId: survey.id,
environmentId: "env-1",
});
});
test("should return null if singleUse is enabled, not encrypted, and suId matches singleUseId", async () => {
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false } };
const url = "https://example.com/?suId=su-1";
const result = await checkSurveyValidity(survey, "env-1", {
...mockResponseInput,
singleUseId: "su-1",
meta: { url },
});
expect(result).toBeNull();
});
test("should return null if singleUse is enabled, encrypted, and decrypted suId matches singleUseId", async () => {
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: true } };
const url = "https://example.com/?suId=encrypted-id";
vi.mocked(symmetricDecrypt).mockReturnValue("su-1");
const _resultEncryptedMatch = await checkSurveyValidity(survey, "env-1", {
...mockResponseInput,
singleUseId: "su-1",
meta: { url },
});
expect(symmetricDecrypt).toHaveBeenCalledWith("encrypted-id", "test-key");
expect(_resultEncryptedMatch).toBeNull();
});
}); });

View File

@@ -2,6 +2,8 @@ import { getOrganizationBillingByEnvironmentId } from "@/app/api/v2/client/[envi
import { verifyRecaptchaToken } from "@/app/api/v2/client/[environmentId]/responses/lib/recaptcha"; import { verifyRecaptchaToken } from "@/app/api/v2/client/[environmentId]/responses/lib/recaptcha";
import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/types/response"; import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/types/response";
import { responses } from "@/app/lib/api/response"; import { responses } from "@/app/lib/api/response";
import { ENCRYPTION_KEY } from "@/lib/constants";
import { symmetricDecrypt } from "@/lib/crypto";
import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils"; import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils";
import { logger } from "@formbricks/logger"; import { logger } from "@formbricks/logger";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types";
@@ -24,6 +26,55 @@ export const checkSurveyValidity = async (
); );
} }
if (survey.type === "link" && survey.singleUse?.enabled) {
if (!responseInput.singleUseId) {
return responses.badRequestResponse("Missing single use id", {
surveyId: survey.id,
environmentId,
});
}
if (!responseInput.meta?.url) {
return responses.badRequestResponse("Missing or invalid URL in response metadata", {
surveyId: survey.id,
environmentId,
});
}
let url;
try {
url = new URL(responseInput.meta.url);
} catch (error) {
return responses.badRequestResponse("Invalid URL in response metadata", {
surveyId: survey.id,
environmentId,
error: error.message,
});
}
const suId = url.searchParams.get("suId");
if (!suId) {
return responses.badRequestResponse("Missing single use id", {
surveyId: survey.id,
environmentId,
});
}
if (survey.singleUse.isEncrypted) {
const decryptedSuId = symmetricDecrypt(suId, ENCRYPTION_KEY);
if (decryptedSuId !== responseInput.singleUseId) {
return responses.badRequestResponse("Invalid single use id", {
surveyId: survey.id,
environmentId,
});
}
} else if (responseInput.singleUseId !== suId) {
return responses.badRequestResponse("Invalid single use id", {
surveyId: survey.id,
environmentId,
});
}
}
if (survey.recaptcha?.enabled) { if (survey.recaptcha?.enabled) {
if (!responseInput.recaptchaToken) { if (!responseInput.recaptchaToken) {
logger.error("Missing recaptcha token"); logger.error("Missing recaptcha token");

View File

@@ -1,70 +1,99 @@
import * as constants from "@/lib/constants";
import { rateLimit } from "@/lib/utils/rate-limit";
import { beforeEach, describe, expect, test, vi } from "vitest"; import { beforeEach, describe, expect, test, vi } from "vitest";
import { loginLimiter, signupLimiter } from "./bucket"; import type { Mock } from "vitest";
// Mock constants vi.mock("@/lib/utils/rate-limit", () => ({ rateLimit: vi.fn() }));
vi.mock("@/lib/constants", () => ({
ENTERPRISE_LICENSE_KEY: undefined,
REDIS_HTTP_URL: undefined,
LOGIN_RATE_LIMIT: {
interval: 15 * 60,
allowedPerInterval: 5,
},
SIGNUP_RATE_LIMIT: {
interval: 60 * 60,
allowedPerInterval: 5,
},
VERIFY_EMAIL_RATE_LIMIT: {
interval: 60 * 60,
allowedPerInterval: 5,
},
FORGET_PASSWORD_RATE_LIMIT: {
interval: 60 * 60,
allowedPerInterval: 5,
},
CLIENT_SIDE_API_RATE_LIMIT: {
interval: 60,
allowedPerInterval: 5,
},
SHARE_RATE_LIMIT: {
interval: 60,
allowedPerInterval: 5,
},
SYNC_USER_IDENTIFICATION_RATE_LIMIT: {
interval: 60,
allowedPerInterval: 5,
},
}));
describe("Rate Limiters", () => { describe("bucket middleware rate limiters", () => {
beforeEach(() => { beforeEach(() => {
vi.resetModules();
vi.clearAllMocks(); vi.clearAllMocks();
const mockedRateLimit = rateLimit as unknown as Mock;
mockedRateLimit.mockImplementation((config) => config);
}); });
test("loginLimiter allows requests within limit", () => { test("loginLimiter uses LOGIN_RATE_LIMIT settings", async () => {
const token = "test-token-1"; const { loginLimiter } = await import("./bucket");
// Should not throw for first request expect(rateLimit).toHaveBeenCalledWith({
expect(() => loginLimiter(token)).not.toThrow(); 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("loginLimiter throws when limit exceeded", () => { test("signupLimiter uses SIGNUP_RATE_LIMIT settings", async () => {
const token = "test-token-2"; const { signupLimiter } = await import("./bucket");
// Make multiple requests to exceed the limit expect(rateLimit).toHaveBeenCalledWith({
for (let i = 0; i < 5; i++) { interval: constants.SIGNUP_RATE_LIMIT.interval,
expect(() => loginLimiter(token)).not.toThrow(); allowedPerInterval: constants.SIGNUP_RATE_LIMIT.allowedPerInterval,
} });
// Next request should throw expect(signupLimiter).toEqual({
expect(() => loginLimiter(token)).toThrow("Rate limit exceeded"); interval: constants.SIGNUP_RATE_LIMIT.interval,
allowedPerInterval: constants.SIGNUP_RATE_LIMIT.allowedPerInterval,
});
}); });
test("different limiters use different counters", () => { test("verifyEmailLimiter uses VERIFY_EMAIL_RATE_LIMIT settings", async () => {
const token = "test-token-3"; const { verifyEmailLimiter } = await import("./bucket");
// Exceed login limit expect(rateLimit).toHaveBeenCalledWith({
for (let i = 0; i < 5; i++) { interval: constants.VERIFY_EMAIL_RATE_LIMIT.interval,
expect(() => loginLimiter(token)).not.toThrow(); allowedPerInterval: constants.VERIFY_EMAIL_RATE_LIMIT.allowedPerInterval,
} });
// Should throw for login expect(verifyEmailLimiter).toEqual({
expect(() => loginLimiter(token)).toThrow("Rate limit exceeded"); interval: constants.VERIFY_EMAIL_RATE_LIMIT.interval,
// Should still be able to use signup limiter allowedPerInterval: constants.VERIFY_EMAIL_RATE_LIMIT.allowedPerInterval,
expect(() => signupLimiter(token)).not.toThrow(); });
});
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({
interval: constants.CLIENT_SIDE_API_RATE_LIMIT.interval,
allowedPerInterval: constants.CLIENT_SIDE_API_RATE_LIMIT.allowedPerInterval,
});
expect(clientSideApiEndpointsLimiter).toEqual({
interval: constants.CLIENT_SIDE_API_RATE_LIMIT.interval,
allowedPerInterval: constants.CLIENT_SIDE_API_RATE_LIMIT.allowedPerInterval,
});
});
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({
interval: constants.SYNC_USER_IDENTIFICATION_RATE_LIMIT.interval,
allowedPerInterval: constants.SYNC_USER_IDENTIFICATION_RATE_LIMIT.allowedPerInterval,
});
expect(syncUserIdentificationLimiter).toEqual({
interval: constants.SYNC_USER_IDENTIFICATION_RATE_LIMIT.interval,
allowedPerInterval: constants.SYNC_USER_IDENTIFICATION_RATE_LIMIT.allowedPerInterval,
});
}); });
}); });

View File

@@ -1,4 +1,3 @@
import { rateLimit } from "@/app/middleware/rate-limit";
import { import {
CLIENT_SIDE_API_RATE_LIMIT, CLIENT_SIDE_API_RATE_LIMIT,
FORGET_PASSWORD_RATE_LIMIT, FORGET_PASSWORD_RATE_LIMIT,
@@ -8,6 +7,7 @@ import {
SYNC_USER_IDENTIFICATION_RATE_LIMIT, SYNC_USER_IDENTIFICATION_RATE_LIMIT,
VERIFY_EMAIL_RATE_LIMIT, VERIFY_EMAIL_RATE_LIMIT,
} from "@/lib/constants"; } from "@/lib/constants";
import { rateLimit } from "@/lib/utils/rate-limit";
export const loginLimiter = rateLimit({ export const loginLimiter = rateLimit({
interval: LOGIN_RATE_LIMIT.interval, interval: LOGIN_RATE_LIMIT.interval,

View File

@@ -38,7 +38,7 @@ describe("SentryProvider", () => {
expect(initSpy).toHaveBeenCalledWith( expect(initSpy).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
dsn: sentryDsn, dsn: sentryDsn,
tracesSampleRate: 1, tracesSampleRate: 0,
debug: false, debug: false,
replaysOnErrorSampleRate: 1.0, replaysOnErrorSampleRate: 1.0,
replaysSessionSampleRate: 0.1, replaysSessionSampleRate: 0.1,
@@ -81,6 +81,26 @@ describe("SentryProvider", () => {
expect(screen.getByTestId("child")).toHaveTextContent("Test Content"); expect(screen.getByTestId("child")).toHaveTextContent("Test Content");
}); });
test("does not reinitialize Sentry when props change after initial render", () => {
const initSpy = vi.spyOn(Sentry, "init").mockImplementation(() => undefined);
const { rerender } = render(
<SentryProvider sentryDsn={sentryDsn} isEnabled>
<div data-testid="child">Test Content</div>
</SentryProvider>
);
expect(initSpy).toHaveBeenCalledTimes(1);
rerender(
<SentryProvider sentryDsn="https://newDsn@o0.ingest.sentry.io/0" isEnabled={false}>
<div data-testid="child">Test Content</div>
</SentryProvider>
);
expect(initSpy).toHaveBeenCalledTimes(1);
});
test("processes beforeSend correctly", () => { test("processes beforeSend correctly", () => {
const initSpy = vi.spyOn(Sentry, "init").mockImplementation(() => undefined); const initSpy = vi.spyOn(Sentry, "init").mockImplementation(() => undefined);
@@ -109,4 +129,36 @@ describe("SentryProvider", () => {
const hintWithoutError = { originalException: undefined }; const hintWithoutError = { originalException: undefined };
expect(beforeSend(dummyEvent, hintWithoutError)).toEqual(dummyEvent); expect(beforeSend(dummyEvent, hintWithoutError)).toEqual(dummyEvent);
}); });
test("processes beforeSend correctly when hint.originalException is not an Error object", () => {
const initSpy = vi.spyOn(Sentry, "init").mockImplementation(() => undefined);
render(
<SentryProvider sentryDsn={sentryDsn} isEnabled>
<div data-testid="child">Test Content</div>
</SentryProvider>
);
const config = initSpy.mock.calls[0][0];
expect(config).toHaveProperty("beforeSend");
const beforeSend = config.beforeSend;
if (!beforeSend) {
throw new Error("beforeSend is not defined");
}
const dummyEvent = { some: "event" } as unknown as Sentry.ErrorEvent;
const hintWithString = { originalException: "string exception" };
expect(() => beforeSend(dummyEvent, hintWithString)).not.toThrow();
expect(beforeSend(dummyEvent, hintWithString)).toEqual(dummyEvent);
const hintWithNumber = { originalException: 123 };
expect(() => beforeSend(dummyEvent, hintWithNumber)).not.toThrow();
expect(beforeSend(dummyEvent, hintWithNumber)).toEqual(dummyEvent);
const hintWithNull = { originalException: null };
expect(() => beforeSend(dummyEvent, hintWithNull)).not.toThrow();
expect(beforeSend(dummyEvent, hintWithNull)).toEqual(dummyEvent);
});
}); });

View File

@@ -15,8 +15,8 @@ export const SentryProvider = ({ children, sentryDsn, isEnabled }: SentryProvide
Sentry.init({ Sentry.init({
dsn: sentryDsn, dsn: sentryDsn,
// Adjust this value in production, or use tracesSampler for greater control // No tracing while Sentry doesn't update to telemetry 2.0.0 - https://github.com/getsentry/sentry-javascript/issues/15737
tracesSampleRate: 1, tracesSampleRate: 0,
// Setting this option to true will print useful information to the console while you're setting up Sentry. // Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false, debug: false,

View File

@@ -150,7 +150,12 @@ export const createActionClass = async (
...actionClassInput, ...actionClassInput,
environment: { connect: { id: environmentId } }, environment: { connect: { id: environmentId } },
key: actionClassInput.type === "code" ? actionClassInput.key : undefined, key: actionClassInput.type === "code" ? actionClassInput.key : undefined,
noCodeConfig: actionClassInput.type === "noCode" ? actionClassInput.noCodeConfig || {} : undefined, noCodeConfig:
actionClassInput.type === "noCode"
? actionClassInput.noCodeConfig === null
? undefined
: actionClassInput.noCodeConfig
: undefined,
}, },
select: selectActionClass, select: selectActionClass,
}); });
@@ -193,7 +198,12 @@ export const updateActionClass = async (
...actionClassInput, ...actionClassInput,
environment: { connect: { id: environmentId } }, environment: { connect: { id: environmentId } },
key: actionClassInput.type === "code" ? actionClassInput.key : undefined, key: actionClassInput.type === "code" ? actionClassInput.key : undefined,
noCodeConfig: actionClassInput.type === "noCode" ? actionClassInput.noCodeConfig || {} : undefined, noCodeConfig:
actionClassInput.type === "noCode"
? actionClassInput.noCodeConfig === null
? undefined
: actionClassInput.noCodeConfig
: undefined,
}, },
select: { select: {
...selectActionClass, ...selectActionClass,
@@ -212,7 +222,6 @@ export const updateActionClass = async (
id: result.id, id: result.id,
}); });
// @ts-expect-error
const surveyIds = result.surveyTriggers.map((survey) => survey.surveyId); const surveyIds = result.surveyTriggers.map((survey) => survey.surveyId);
for (const surveyId of surveyIds) { for (const surveyId of surveyIds) {
surveyCache.revalidate({ surveyCache.revalidate({

View File

@@ -282,4 +282,6 @@ export const SENTRY_DSN = env.SENTRY_DSN;
export const PROMETHEUS_ENABLED = env.PROMETHEUS_ENABLED === "1"; export const PROMETHEUS_ENABLED = env.PROMETHEUS_ENABLED === "1";
export const DISABLE_USER_MANAGEMENT = env.DISABLE_USER_MANAGEMENT === "1"; export const USER_MANAGEMENT_MINIMUM_ROLE = env.USER_MANAGEMENT_MINIMUM_ROLE ?? "manager";
export const SESSION_MAX_AGE = Number(env.SESSION_MAX_AGE) || 86400;

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