Compare commits

...

30 Commits

Author SHA1 Message Date
Matthias Nannt
a4811351be chore: improve js logging 2025-05-16 12:13:14 +02: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
214 changed files with 4829 additions and 3292 deletions

View File

@@ -172,7 +172,6 @@ ENTERPRISE_LICENSE_KEY=
# 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)
# (Role Management is an Enterprise feature)
# DEFAULT_ORGANIZATION_ROLE=owner
# AUTH_SSO_DEFAULT_TEAM_ID=
# AUTH_SKIP_INVITE_FOR_SSO=
@@ -212,5 +211,5 @@ UNKEY_ROOT_KEY=
# It's used automatically by Sentry during the build for authentication when uploading source maps.
# SENTRY_AUTH_TOKEN=
# Disable the user management from UI
# DISABLE_USER_MANAGEMENT=1
# Configure the minimum role for user management from UI(owner, manager, disabled)
# USER_MANAGEMENT_MINIMUM_ROLE="manager"

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:
name: Run Chromatic
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
actions: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0

View File

@@ -24,4 +24,4 @@ jobs:
- name: 'Checkout Repository'
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- 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
- 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:
role-to-assume: ${{ secrets.AWS_ASSUME_ROLE_ARN }}
aws-region: "eu-central-1"

View File

@@ -11,6 +11,8 @@ on:
required: false
PLAYWRIGHT_SERVICE_URL:
required: false
ENTERPRISE_LICENSE_KEY:
required: true
# Add other secrets if necessary
workflow_dispatch:
@@ -23,7 +25,6 @@ permissions:
id-token: write
contents: read
actions: read
checks: write
jobs:
build:
@@ -48,15 +49,17 @@ jobs:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
egress-policy: allow
allowed-endpoints: |
ee.formbricks.com:443
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- 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
with:
node-version: 20.x
node-version: 22.x
- name: Install pnpm
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/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
sed -i "s/ENTERPRISE_LICENSE_KEY=.*/ENTERPRISE_LICENSE_KEY=${RANDOM_KEY}/" .env
sed -i "s/ENTERPRISE_LICENSE_KEY=.*/ENTERPRISE_LICENSE_KEY=${{ secrets.ENTERPRISE_LICENSE_KEY }}/" .env
echo "" >> .env
echo "E2E_TESTING=1" >> .env
shell: bash
@@ -89,8 +92,18 @@ jobs:
# pnpm prisma migrate deploy
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
run: |
echo "Starting app with enterprise license..."
NODE_ENV=test pnpm start --filter=@formbricks/web | tee app.log 2>&1 &
sleep 10 # Optional: gives some buffer for the app to start
for attempt in {1..10}; do

View File

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

View File

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

View File

@@ -40,7 +40,7 @@ jobs:
revert
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
# condition you can continue the execution with the populated error message.
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
- 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:
header: pr-title-lint-error
message: |

View File

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

View File

@@ -1,8 +1,8 @@
name: 'Terraform'
name: "Terraform"
on:
workflow_dispatch:
# TODO: enable it back when migration is completed.
# TODO: enable it back when migration is completed.
push:
branches:
- main
@@ -14,14 +14,13 @@ on:
paths:
- "infra/terraform/**"
permissions:
id-token: write
contents: write
pull-requests: write
jobs:
terraform:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
pull-requests: write
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
@@ -41,7 +40,7 @@ jobs:
tags: tag:github
- 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:
role-to-assume: ${{ secrets.AWS_ASSUME_ROLE_ARN }}
aws-region: "eu-central-1"
@@ -71,7 +70,7 @@ jobs:
working-directory: infra/terraform
- 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')
with:
token: ${{ github.token }}
@@ -83,4 +82,3 @@ jobs:
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
run: terraform apply .planfile
working-directory: "infra/terraform"

View File

@@ -44,7 +44,7 @@ const Page = async (props: ConnectPageProps) => {
channel={channel}
/>
<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"
asChild>
<Link href={`/environments/${environment.id}`}>

View File

@@ -49,7 +49,7 @@ const Page = async (props: XMTemplatePageProps) => {
<XMTemplateList project={project} user={user} environmentId={environment.id} />
{projects.length >= 2 && (
<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"
asChild>
<Link href={`/environments/${environment.id}/surveys`}>

View File

@@ -1,13 +1,21 @@
import { getOrganizationsByUserId } from "@/lib/organization/service";
import { getUser } from "@/lib/user/service";
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { getTranslate } from "@/tolgee/server";
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { notFound, redirect } from "next/navigation";
import { afterEach, describe, expect, test, vi } from "vitest";
import Page from "./page";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
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", () => ({
IS_FORMBRICKS_CLOUD: false,
@@ -97,23 +105,44 @@ vi.mock("@/app/(app)/(onboarding)/organizations/[organizationId]/landing/compone
vi.mock("@/modules/organization/lib/utils");
vi.mock("@/lib/user/service");
vi.mock("@/lib/organization/service");
vi.mock("@/modules/ee/license-check/lib/utils");
vi.mock("@/tolgee/server");
vi.mock("next/navigation", () => ({
redirect: vi.fn(() => "REDIRECT_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", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
beforeEach(() => {
vi.resetModules();
});
test("redirects to login if no user session", async () => {
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" } });
expect(redirect).toHaveBeenCalledWith("/auth/login");
expect(result).toBe("REDIRECT_STUB");
});
@@ -124,9 +153,17 @@ describe("Page component", () => {
organization: {},
} as any);
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" } });
expect(notFound).toHaveBeenCalled();
expect(result).toBe("NOT_FOUND_STUB");
});
@@ -138,14 +175,21 @@ describe("Page component", () => {
} 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(getEnterpriseLicense).mockResolvedValue({ features: { isMultiOrgEnabled: true } } as any);
vi.mocked(getTranslate).mockResolvedValue((props: any) =>
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" } });
render(element as React.ReactElement);
expect(screen.getByTestId("landing-sidebar")).toBeInTheDocument();
expect(screen.getByText("organizations.landing.no_projects_warning_title")).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 { getOrganizationsByUserId } from "@/lib/organization/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 { Header } from "@/modules/ui/components/header";
import { getTranslate } from "@/tolgee/server";

View File

@@ -50,7 +50,7 @@ const Page = async (props: ChannelPageProps) => {
<OnboardingOptionsContainer options={channelOptions} />
{projects.length >= 1 && (
<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"
asChild>
<Link href={"/"}>

View File

@@ -225,7 +225,7 @@ export const ProjectSettings = ({
alt="Logo"
width={256}
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>

View File

@@ -65,7 +65,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
/>
{projects.length >= 1 && (
<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"
asChild>
<Link href={"/"}>

View File

@@ -23,7 +23,7 @@ export const ActionClassDataRow = ({
</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)}
</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 { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils";
@@ -10,7 +9,7 @@ import {
} from "@/lib/organization/service";
import { getUserProjects } from "@/lib/project/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 { cleanup, render, screen } from "@testing-library/react";
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
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getEnterpriseLicense: vi.fn(),
getOrganizationProjectsLimit: vi.fn(),
}));
vi.mock("@/modules/ee/teams/lib/roles", () => ({
@@ -176,7 +174,6 @@ describe("EnvironmentLayout", () => {
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
vi.mocked(getMonthlyActiveOrganizationPeopleCount).mockResolvedValue(100);
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(500);
vi.mocked(getEnterpriseLicense).mockResolvedValue(mockLicense);
vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(null as any);
vi.mocked(getProjectPermissionByUserId).mockResolvedValue(mockProjectPermission);
mockIsDevelopment = false;
@@ -189,13 +186,19 @@ describe("EnvironmentLayout", () => {
});
test("renders correctly with default props", async () => {
// Ensure the default mockLicense has isPendingDowngrade: false and active: false
vi.mocked(getEnterpriseLicense).mockResolvedValue({
...mockLicense,
isPendingDowngrade: false,
active: false,
});
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(
await EnvironmentLayout({
environmentId: "env-1",
@@ -203,20 +206,31 @@ describe("EnvironmentLayout", () => {
children: <div>Child Content</div>,
})
);
expect(screen.getByTestId("main-navigation")).toBeInTheDocument();
expect(screen.getByTestId("top-control-bar")).toBeInTheDocument();
expect(screen.getByText("Child Content")).toBeInTheDocument();
expect(screen.queryByTestId("dev-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 () => {
const devEnvironment = { ...mockEnvironment, type: "development" as const };
vi.mocked(getEnvironment).mockResolvedValue(devEnvironment);
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(
await EnvironmentLayout({
environmentId: "env-1",
@@ -224,13 +238,24 @@ describe("EnvironmentLayout", () => {
children: <div>Child Content</div>,
})
);
expect(screen.getByTestId("dev-banner")).toBeInTheDocument();
});
test("renders LimitsReachedBanner in Formbricks Cloud", async () => {
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(
await EnvironmentLayout({
environmentId: "env-1",
@@ -238,17 +263,21 @@ describe("EnvironmentLayout", () => {
children: <div>Child Content</div>,
})
);
expect(screen.getByTestId("limits-banner")).toBeInTheDocument();
expect(vi.mocked(getMonthlyActiveOrganizationPeopleCount)).toHaveBeenCalledWith(mockOrganization.id);
expect(vi.mocked(getMonthlyOrganizationResponseCount)).toHaveBeenCalledWith(mockOrganization.id);
});
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 };
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(
await EnvironmentLayout({
environmentId: "env-1",
@@ -256,12 +285,24 @@ describe("EnvironmentLayout", () => {
children: <div>Child Content</div>,
})
);
expect(screen.getByTestId("downgrade-banner")).toBeInTheDocument();
});
test("throws error if user not found", async () => {
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(
"common.user_not_found"
);
@@ -269,6 +310,19 @@ describe("EnvironmentLayout", () => {
test("throws error if organization not found", async () => {
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(
"common.organization_not_found"
);
@@ -276,13 +330,39 @@ describe("EnvironmentLayout", () => {
test("throws error if environment not found", async () => {
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(
"common.environment_not_found"
);
});
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(
"environments.projects_environments_organizations_not_found"
);
@@ -291,6 +371,19 @@ describe("EnvironmentLayout", () => {
test("throws error if member has no project permission", async () => {
vi.mocked(getAccessFlags).mockReturnValue({ isMember: true } as any);
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(
"common.project_permission_not_found"
);

View File

@@ -12,7 +12,8 @@ import {
} from "@/lib/organization/service";
import { getUserProjects } from "@/lib/project/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 { DevEnvironmentBanner } from "@/modules/ui/components/dev-environment-banner";
import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-banner";

View File

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

View File

@@ -53,7 +53,7 @@ export const WidgetStatusIndicator = ({ environment }: WidgetStatusIndicatorProp
<currentStatus.icon />
</div>
<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" && (
<Button variant="outline" size="sm" className="bg-white" onClick={() => router.refresh()}>
<RotateCcwIcon />

View File

@@ -255,7 +255,7 @@ export const AddIntegrationModal = ({
<div className="space-y-4">
<div>
<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">
{replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((question) => (
<div key={question.id} className="my-1 flex items-center space-x-2">

View File

@@ -10,7 +10,6 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TMembership } from "@formbricks/types/memberships";
import { TOrganization, TOrganizationBilling } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
import EnterpriseSettingsPage from "./page";
vi.mock("@formbricks/database", () => ({
prisma: {
@@ -179,15 +178,23 @@ describe("EnterpriseSettingsPage", () => {
});
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 } });
render(Page);
expect(screen.getByTestId("page-content-wrapper")).toBeInTheDocument();
expect(screen.getByTestId("page-header")).toBeInTheDocument();
expect(screen.getByText("environments.settings.enterprise.sso")).toBeInTheDocument();
expect(screen.getByText("environments.settings.billing.remove_branding")).toBeInTheDocument();
expect(redirect).not.toHaveBeenCalled();
});
});

View File

@@ -1,6 +1,6 @@
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
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 { Button } from "@/modules/ui/components/button";
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">
<svg
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">
<circle
cx={512}

View File

@@ -31,7 +31,7 @@ export const SettingsCard = ({
id={title}>
<div className="border-b border-slate-200 px-4 pb-4">
<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">
{beta && <Badge size="normal" type="warning" text="Beta" />}
{soon && (

View File

@@ -38,7 +38,7 @@ export const ResponseTableCell = ({
<button
type="button"
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}>
<Maximize2Icon className="h-4 w-4" />
</button>

View File

@@ -41,7 +41,7 @@ export const ConsentSummary = ({ questionSummary, survey, setFilter }: ConsentSu
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<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) => {
return (
<button

View File

@@ -80,7 +80,7 @@ export const DateQuestionSummary = ({
</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)}
</div>
<div className="px-4 text-slate-500 md:px-6">

View File

@@ -80,7 +80,7 @@ export const FileUploadSummary = ({
return (
<div className="relative m-2 rounded-lg bg-slate-200" key={fileUrl}>
<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">
<DownloadIcon className="h-6 text-slate-500" />
</div>

View File

@@ -28,7 +28,7 @@ export const HiddenFieldsSummary = ({ environment, questionSummary, locale }: Hi
};
return (
<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"}>
<h3 className="pb-1 text-lg font-semibold text-slate-900 md:text-xl">{questionSummary.id}</h3>
</div>
@@ -76,7 +76,7 @@ export const HiddenFieldsSummary = ({ environment, questionSummary, locale }: Hi
</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}
</div>
<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">
<thead>
<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) => (
<th key={column} className="text-center font-medium">
<TooltipRenderer tooltipContent={getTooltipContent(column)} shouldRender={true}>
@@ -65,7 +65,7 @@ export const MatrixQuestionSummary = ({ questionSummary, survey, setFilter }: Ma
<tbody>
{questionSummary.data.map(({ rowLabel, columnPercentages }, rowIndex) => (
<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}>
<p className="max-w-40 overflow-hidden text-ellipsis whitespace-nowrap">{rowLabel}</p>
</TooltipRenderer>

View File

@@ -83,7 +83,7 @@ export const MultipleChoiceSummary = ({
) : 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) => (
<Fragment key={result.value}>
<button

View File

@@ -62,7 +62,7 @@ export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryPro
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<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) => (
<button
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" : ""}`}>
<div className="mr-8 flex space-x-1">
<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}
</p>
<div>
@@ -94,7 +94,7 @@ export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryPro
))}
</div>
<div className="flex justify-center pt-4 pb-4">
<div className="flex justify-center pb-4 pt-4">
<HalfCircle value={questionSummary.score} />
</div>
</div>

View File

@@ -43,7 +43,7 @@ export const PictureChoiceSummary = ({ questionSummary, survey, setFilter }: Pic
) : 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) => (
<button
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);
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"}>
<h3 className="pb-1 text-lg font-semibold text-slate-900 md:text-xl">
{formatTextWithSlashes(

View File

@@ -50,7 +50,7 @@ export const RatingSummary = ({ questionSummary, survey, setFilter }: RatingSumm
</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) => (
<button
className="w-full cursor-pointer hover:opacity-80"

View File

@@ -61,10 +61,10 @@ export const SummaryDropOffs = ({ dropOff, survey }: SummaryDropOffsProps) => {
)}
</p>
</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"}
</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">
<span className="mr-1.5 font-semibold">{quesDropOff.dropOffCount}</span>
<span>({Math.round(quesDropOff.dropOffPercentage)}%)</span>

View File

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

View File

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

View File

@@ -390,7 +390,7 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
value && handleDatePickerClose();
}}>
<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">
<span className="text-sm text-slate-700">{t("common.download")}</span>
<ArrowDownToLineIcon className="ml-2 h-4 w-4" />

View File

@@ -91,7 +91,7 @@ export const QuestionFilterComboBox = ({
key={`${o}-${index}`}
type="button"
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}
<X width={14} height={14} className="ml-2" />
</button>
@@ -129,7 +129,7 @@ export const QuestionFilterComboBox = ({
<DropdownMenuTrigger
disabled={disabled}
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"
)}>
<div className="flex items-center justify-between">

View File

@@ -1,7 +1,14 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
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", () => {
afterEach(() => {
@@ -53,3 +60,67 @@ describe("QuestionsComboBox", () => {
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,
ChevronDown,
ChevronUp,
ContactIcon,
EyeOff,
GlobeIcon,
GridIcon,
HashIcon,
HelpCircleIcon,
HomeIcon,
ImageIcon,
LanguagesIcon,
ListIcon,
@@ -63,59 +64,60 @@ interface QuestionComboBoxProps {
onChangeValue: (option: QuestionOption) => void;
}
const SelectedCommandItem = ({ label, questionType, type }: Partial<QuestionOption>) => {
const getIconType = () => {
switch (type) {
case OptionsType.QUESTIONS:
switch (questionType) {
case TSurveyQuestionTypeEnum.OpenText:
return <MessageSquareTextIcon width={18} height={18} className="text-white" />;
case TSurveyQuestionTypeEnum.Rating:
return <StarIcon width={18} height={18} className="text-white" />;
case TSurveyQuestionTypeEnum.CTA:
return <MousePointerClickIcon width={18} height={18} className="text-white" />;
case TSurveyQuestionTypeEnum.OpenText:
return <HelpCircleIcon width={18} height={18} className="text-white" />;
case TSurveyQuestionTypeEnum.MultipleChoiceMulti:
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" />;
const questionIcons = {
// questions
[TSurveyQuestionTypeEnum.OpenText]: MessageSquareTextIcon,
[TSurveyQuestionTypeEnum.Rating]: StarIcon,
[TSurveyQuestionTypeEnum.CTA]: MousePointerClickIcon,
[TSurveyQuestionTypeEnum.MultipleChoiceMulti]: ListIcon,
[TSurveyQuestionTypeEnum.MultipleChoiceSingle]: Rows3Icon,
[TSurveyQuestionTypeEnum.NPS]: NetPromoterScoreIcon,
[TSurveyQuestionTypeEnum.Consent]: CheckIcon,
[TSurveyQuestionTypeEnum.PictureSelection]: ImageIcon,
[TSurveyQuestionTypeEnum.Matrix]: GridIcon,
[TSurveyQuestionTypeEnum.Ranking]: ListOrderedIcon,
[TSurveyQuestionTypeEnum.Address]: HomeIcon,
[TSurveyQuestionTypeEnum.ContactInfo]: ContactIcon,
case OptionsType.HIDDEN_FIELDS:
return <EyeOff width={18} height={18} className="text-white" />;
case OptionsType.META:
switch (label) {
case "device":
return <SmartphoneIcon width={18} height={18} className="text-white" />;
case "os":
return <AirplayIcon width={18} height={18} className="text-white" />;
case "browser":
return <GlobeIcon width={18} height={18} className="text-white" />;
case "source":
return <GlobeIcon width={18} height={18} className="text-white" />;
case "action":
return <MousePointerClickIcon width={18} height={18} className="text-white" />;
}
case OptionsType.OTHERS:
switch (label) {
case "Language":
return <LanguagesIcon width={18} height={18} className="text-white" />;
}
case OptionsType.TAGS:
return <HashIcon width={18} height={18} className="text-white" />;
// attributes
[OptionsType.ATTRIBUTES]: User,
// hidden fields
[OptionsType.HIDDEN_FIELDS]: EyeOff,
// meta
device: SmartphoneIcon,
os: AirplayIcon,
browser: GlobeIcon,
source: GlobeIcon,
action: MousePointerClickIcon,
// others
Language: LanguagesIcon,
// tags
[OptionsType.TAGS]: HashIcon,
};
const getIcon = (type: string) => {
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}
onValueChange={setInputValue}
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>

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 { responses } from "@/app/lib/api/response";
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 { convertDatesInObject } from "@/lib/time";
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 { headers } from "next/headers";
import { prisma } from "@formbricks/database";
@@ -164,11 +164,15 @@ export const POST = async (request: Request) => {
select: { email: true, locale: true },
});
// send follow up emails
const surveyFollowUpsPermission = await getSurveyFollowUpsPermission(organization.billing.plan);
if (surveyFollowUpsPermission) {
await sendSurveyFollowUps(survey, response, organization);
if (survey.followUps?.length > 0) {
// send follow up emails
const followUpsResult = await sendFollowUpsForResponse(response.id);
if (!followUpsResult.ok) {
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) =>

View File

@@ -254,7 +254,7 @@ describe("getEnvironmentState", () => {
vi.mocked(getEnvironment).mockResolvedValue(mockEnvironment);
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization);
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(getMonthlyOrganizationResponseCount).mockResolvedValue(50); // Default below limit
});

View File

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

View File

@@ -100,8 +100,14 @@ describe("getSurveysForEnvironmentState", () => {
expect(validateInputs).toHaveBeenCalledWith([environmentId, expect.any(Object)]);
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
orderBy: { createdAt: "desc" },
take: 30,
});
expect(transformPrismaSurvey).toHaveBeenCalledWith(mockPrismaSurvey);
expect(result).toEqual([mockTransformedSurvey]);
@@ -114,8 +120,14 @@ describe("getSurveysForEnvironmentState", () => {
const result = await getSurveysForEnvironmentState(environmentId);
expect(prisma.survey.findMany).toHaveBeenCalledWith({
where: { environmentId },
where: {
environmentId,
type: "app",
status: "inProgress",
},
select: expect.any(Object),
orderBy: { createdAt: "desc" },
take: 30,
});
expect(transformPrismaSurvey).not.toHaveBeenCalled();
expect(result).toEqual([]);

View File

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

View File

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

View File

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

View File

@@ -31,6 +31,7 @@ export const createResponse = async (responseInput: TResponseInputV2): Promise<T
contactId,
surveyId,
displayId,
endingId,
finished,
data,
meta,
@@ -64,7 +65,8 @@ export const createResponse = async (responseInput: TResponseInputV2): Promise<T
},
},
display: displayId ? { connect: { id: displayId } } : undefined,
finished: finished,
finished,
endingId,
data: data,
language: language,
...(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 { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/types/response";
import { responses } from "@/app/lib/api/response";
import { symmetricDecrypt } from "@/lib/crypto";
import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils";
import { Organization } from "@prisma/client";
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 = {
id: "survey-1",
createdAt: new Date(),
@@ -206,4 +214,119 @@ describe("checkSurveyValidity", () => {
const result = await checkSurveyValidity(survey, "env-1", mockResponseInput);
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 { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/types/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 { logger } from "@formbricks/logger";
import { TSurvey } from "@formbricks/types/surveys/types";
@@ -24,6 +26,55 @@ export const checkSurveyValidity = async (
);
}
if (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 (!responseInput.recaptchaToken) {
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 { loginLimiter, signupLimiter } from "./bucket";
import type { Mock } from "vitest";
// Mock constants
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,
},
}));
vi.mock("@/lib/utils/rate-limit", () => ({ rateLimit: vi.fn() }));
describe("Rate Limiters", () => {
describe("bucket middleware rate limiters", () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
const mockedRateLimit = rateLimit as unknown as Mock;
mockedRateLimit.mockImplementation((config) => config);
});
test("loginLimiter allows requests within limit", () => {
const token = "test-token-1";
// Should not throw for first request
expect(() => loginLimiter(token)).not.toThrow();
test("loginLimiter uses LOGIN_RATE_LIMIT settings", async () => {
const { loginLimiter } = await import("./bucket");
expect(rateLimit).toHaveBeenCalledWith({
interval: constants.LOGIN_RATE_LIMIT.interval,
allowedPerInterval: constants.LOGIN_RATE_LIMIT.allowedPerInterval,
});
expect(loginLimiter).toEqual({
interval: constants.LOGIN_RATE_LIMIT.interval,
allowedPerInterval: constants.LOGIN_RATE_LIMIT.allowedPerInterval,
});
});
test("loginLimiter throws when limit exceeded", () => {
const token = "test-token-2";
// Make multiple requests to exceed the limit
for (let i = 0; i < 5; i++) {
expect(() => loginLimiter(token)).not.toThrow();
}
// Next request should throw
expect(() => loginLimiter(token)).toThrow("Rate limit exceeded");
test("signupLimiter uses SIGNUP_RATE_LIMIT settings", async () => {
const { signupLimiter } = await import("./bucket");
expect(rateLimit).toHaveBeenCalledWith({
interval: constants.SIGNUP_RATE_LIMIT.interval,
allowedPerInterval: constants.SIGNUP_RATE_LIMIT.allowedPerInterval,
});
expect(signupLimiter).toEqual({
interval: constants.SIGNUP_RATE_LIMIT.interval,
allowedPerInterval: constants.SIGNUP_RATE_LIMIT.allowedPerInterval,
});
});
test("different limiters use different counters", () => {
const token = "test-token-3";
// Exceed login limit
for (let i = 0; i < 5; i++) {
expect(() => loginLimiter(token)).not.toThrow();
}
// Should throw for login
expect(() => loginLimiter(token)).toThrow("Rate limit exceeded");
// Should still be able to use signup limiter
expect(() => signupLimiter(token)).not.toThrow();
test("verifyEmailLimiter uses VERIFY_EMAIL_RATE_LIMIT settings", async () => {
const { verifyEmailLimiter } = await import("./bucket");
expect(rateLimit).toHaveBeenCalledWith({
interval: constants.VERIFY_EMAIL_RATE_LIMIT.interval,
allowedPerInterval: constants.VERIFY_EMAIL_RATE_LIMIT.allowedPerInterval,
});
expect(verifyEmailLimiter).toEqual({
interval: constants.VERIFY_EMAIL_RATE_LIMIT.interval,
allowedPerInterval: constants.VERIFY_EMAIL_RATE_LIMIT.allowedPerInterval,
});
});
test("forgotPasswordLimiter uses FORGET_PASSWORD_RATE_LIMIT settings", async () => {
const { forgotPasswordLimiter } = await import("./bucket");
expect(rateLimit).toHaveBeenCalledWith({
interval: constants.FORGET_PASSWORD_RATE_LIMIT.interval,
allowedPerInterval: constants.FORGET_PASSWORD_RATE_LIMIT.allowedPerInterval,
});
expect(forgotPasswordLimiter).toEqual({
interval: constants.FORGET_PASSWORD_RATE_LIMIT.interval,
allowedPerInterval: constants.FORGET_PASSWORD_RATE_LIMIT.allowedPerInterval,
});
});
test("clientSideApiEndpointsLimiter uses CLIENT_SIDE_API_RATE_LIMIT settings", async () => {
const { clientSideApiEndpointsLimiter } = await import("./bucket");
expect(rateLimit).toHaveBeenCalledWith({
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 {
CLIENT_SIDE_API_RATE_LIMIT,
FORGET_PASSWORD_RATE_LIMIT,
@@ -8,6 +7,7 @@ import {
SYNC_USER_IDENTIFICATION_RATE_LIMIT,
VERIFY_EMAIL_RATE_LIMIT,
} from "@/lib/constants";
import { rateLimit } from "@/lib/utils/rate-limit";
export const loginLimiter = rateLimit({
interval: LOGIN_RATE_LIMIT.interval,

View File

@@ -150,7 +150,12 @@ export const createActionClass = async (
...actionClassInput,
environment: { connect: { id: environmentId } },
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,
});
@@ -193,7 +198,12 @@ export const updateActionClass = async (
...actionClassInput,
environment: { connect: { id: environmentId } },
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,
@@ -212,7 +222,6 @@ export const updateActionClass = async (
id: result.id,
});
// @ts-expect-error
const surveyIds = result.surveyTriggers.map((survey) => survey.surveyId);
for (const surveyId of surveyIds) {
surveyCache.revalidate({

View File

@@ -282,4 +282,4 @@ export const SENTRY_DSN = env.SENTRY_DSN;
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";

View File

@@ -104,7 +104,7 @@ export const env = createEnv({
NODE_ENV: z.enum(["development", "production", "test"]).optional(),
PROMETHEUS_EXPORTER_PORT: z.string().optional(),
PROMETHEUS_ENABLED: z.enum(["1", "0"]).optional(),
DISABLE_USER_MANAGEMENT: z.enum(["1", "0"]).optional(),
USER_MANAGEMENT_MINIMUM_ROLE: z.enum(["owner", "manager", "disabled"]).optional(),
},
/*
@@ -199,6 +199,6 @@ export const env = createEnv({
NODE_ENV: process.env.NODE_ENV,
PROMETHEUS_ENABLED: process.env.PROMETHEUS_ENABLED,
PROMETHEUS_EXPORTER_PORT: process.env.PROMETHEUS_EXPORTER_PORT,
DISABLE_USER_MANAGEMENT: process.env.DISABLE_USER_MANAGEMENT,
USER_MANAGEMENT_MINIMUM_ROLE: process.env.USER_MANAGEMENT_MINIMUM_ROLE,
},
});

View File

@@ -13,3 +13,21 @@ export const getAccessFlags = (role?: TOrganizationRole) => {
isMember,
};
};
export const getUserManagementAccess = (
role: TOrganizationRole,
minimumRole: "owner" | "manager" | "disabled"
): boolean => {
// If minimum role is "disabled", no one has access
if (minimumRole === "disabled") {
return false;
}
if (minimumRole === "owner") {
return role === "owner";
}
if (minimumRole === "manager") {
return role === "owner" || role === "manager";
}
return false;
};

View File

@@ -1,12 +1,6 @@
import structuredClonePolyfill from "@ungap/structured-clone";
let structuredCloneExport: typeof structuredClonePolyfill;
if (typeof structuredClone === "undefined") {
structuredCloneExport = structuredClonePolyfill;
} else {
// @ts-expect-error
structuredCloneExport = structuredClone;
}
const structuredCloneExport =
typeof structuredClone === "undefined" ? structuredClonePolyfill : structuredClone;
export { structuredCloneExport as structuredClone };

View File

@@ -533,6 +533,7 @@ export const updateResponse = async (
id: response.id,
contactId: response.contact?.id,
surveyId: response.surveyId,
...(response.singleUseId ? { singleUseId: response.singleUseId } : {}),
});
responseNoteCache.revalidate({

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef } from "react";
import { useCallback, useEffect, useRef } from "react";
export const useIntervalWhenFocused = (
callback: () => void,
@@ -8,7 +8,7 @@ export const useIntervalWhenFocused = (
) => {
const intervalRef = useRef<NodeJS.Timeout | null>(null);
const handleFocus = () => {
const handleFocus = useCallback(() => {
if (isActive) {
if (shouldExecuteImmediately) {
// Execute the callback immediately when the tab comes into focus
@@ -20,7 +20,7 @@ export const useIntervalWhenFocused = (
callback();
}, intervalDuration);
}
};
}, [isActive, intervalDuration, callback, shouldExecuteImmediately]);
const handleBlur = () => {
// Clear the interval when the tab loses focus
@@ -46,7 +46,7 @@ export const useIntervalWhenFocused = (
window.removeEventListener("focus", handleFocus);
window.removeEventListener("blur", handleBlur);
};
}, [isActive, intervalDuration]);
}, [isActive, intervalDuration, handleFocus]);
};
export default useIntervalWhenFocused;

View File

@@ -0,0 +1,58 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
describe("in-memory rate limiter", () => {
test("allows requests within limit and throws after limit", async () => {
const { rateLimit } = await import("./rate-limit");
const limiterFn = rateLimit({ interval: 1, allowedPerInterval: 2 });
await expect(limiterFn("a")).resolves.toBeUndefined();
await expect(limiterFn("a")).resolves.toBeUndefined();
await expect(limiterFn("a")).rejects.toThrow("Rate limit exceeded");
});
test("separate tokens have separate counts", async () => {
const { rateLimit } = await import("./rate-limit");
const limiterFn = rateLimit({ interval: 1, allowedPerInterval: 2 });
await expect(limiterFn("x")).resolves.toBeUndefined();
await expect(limiterFn("y")).resolves.toBeUndefined();
await expect(limiterFn("x")).resolves.toBeUndefined();
await expect(limiterFn("y")).resolves.toBeUndefined();
});
});
describe("redis rate limiter", () => {
beforeEach(async () => {
vi.resetModules();
const constants = await vi.importActual("@/lib/constants");
vi.doMock("@/lib/constants", () => ({
...constants,
REDIS_HTTP_URL: "http://redis",
}));
});
test("sets expire on first use and does not throw", async () => {
global.fetch = vi
.fn()
.mockResolvedValueOnce({ ok: true, json: async () => ({ INCR: 1 }) })
.mockResolvedValueOnce({ ok: true });
const { rateLimit } = await import("./rate-limit");
const limiter = rateLimit({ interval: 10, allowedPerInterval: 2 });
await expect(limiter("t")).resolves.toBeUndefined();
expect(fetch).toHaveBeenCalledTimes(2);
expect(fetch).toHaveBeenCalledWith("http://redis/INCR/t");
expect(fetch).toHaveBeenCalledWith("http://redis/EXPIRE/t/10");
});
test("does not throw when redis INCR response not ok", async () => {
global.fetch = vi.fn().mockResolvedValueOnce({ ok: false });
const { rateLimit } = await import("./rate-limit");
const limiter = rateLimit({ interval: 10, allowedPerInterval: 2 });
await expect(limiter("t")).resolves.toBeUndefined();
});
test("throws when INCR exceeds limit", async () => {
global.fetch = vi.fn().mockResolvedValueOnce({ ok: true, json: async () => ({ INCR: 3 }) });
const { rateLimit } = await import("./rate-limit");
const limiter = rateLimit({ interval: 10, allowedPerInterval: 2 });
await expect(limiter("t")).rejects.toThrow("Rate limit exceeded for IP: t");
});
});

View File

@@ -1,4 +1,4 @@
import { ENTERPRISE_LICENSE_KEY, REDIS_HTTP_URL } from "@/lib/constants";
import { REDIS_HTTP_URL } from "@/lib/constants";
import { LRUCache } from "lru-cache";
import { logger } from "@formbricks/logger";
@@ -13,7 +13,7 @@ const inMemoryRateLimiter = (options: Options) => {
ttl: options.interval * 1000, // converts to expected input of milliseconds
});
return (token: string) => {
return async (token: string) => {
const currentUsage = tokenCache.get(token) ?? 0;
if (currentUsage >= options.allowedPerInterval) {
throw new Error("Rate limit exceeded");
@@ -40,12 +40,13 @@ const redisRateLimiter = (options: Options) => async (token: string) => {
throw new Error();
}
} catch (e) {
logger.error({ error: e }, "Rate limit exceeded");
throw new Error("Rate limit exceeded for IP: " + token);
}
};
export const rateLimit = (options: Options) => {
if (REDIS_HTTP_URL && ENTERPRISE_LICENSE_KEY) {
if (REDIS_HTTP_URL) {
return redisRateLimiter(options);
} else {
return inMemoryRateLimiter(options);

View File

@@ -12,13 +12,12 @@ import {
isClientSideApiRoute,
isForgotPasswordRoute,
isLoginRoute,
isManagementApiRoute,
isShareUrlRoute,
isSignupRoute,
isSyncWithUserIdentificationEndpoint,
isVerifyEmailRoute,
} from "@/app/middleware/endpoint-validator";
import { E2E_TESTING, IS_PRODUCTION, RATE_LIMITING_DISABLED, SURVEY_URL, WEBAPP_URL } from "@/lib/constants";
import { IS_PRODUCTION, RATE_LIMITING_DISABLED, SURVEY_URL, WEBAPP_URL } from "@/lib/constants";
import { isValidCallbackUrl } from "@/lib/utils/url";
import { logApiError } from "@/modules/api/v2/lib/utils";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
@@ -28,24 +27,6 @@ import { NextRequest, NextResponse } from "next/server";
import { v4 as uuidv4 } from "uuid";
import { logger } from "@formbricks/logger";
const enforceHttps = (request: NextRequest): Response | null => {
const forwardedProto = request.headers.get("x-forwarded-proto") ?? "http";
if (IS_PRODUCTION && !E2E_TESTING && forwardedProto !== "https") {
const apiError: ApiErrorResponseV2 = {
type: "forbidden",
details: [
{
field: "",
issue: "Only HTTPS connections are allowed on the management endpoints.",
},
],
};
logApiError(request, apiError);
return NextResponse.json(apiError, { status: 403 });
}
return null;
};
const handleAuth = async (request: NextRequest): Promise<Response | null> => {
const token = await getToken({ req: request as any });
@@ -67,24 +48,24 @@ const handleAuth = async (request: NextRequest): Promise<Response | null> => {
return null;
};
const applyRateLimiting = (request: NextRequest, ip: string) => {
const applyRateLimiting = async (request: NextRequest, ip: string) => {
if (isLoginRoute(request.nextUrl.pathname)) {
loginLimiter(`login-${ip}`);
await loginLimiter(`login-${ip}`);
} else if (isSignupRoute(request.nextUrl.pathname)) {
signupLimiter(`signup-${ip}`);
await signupLimiter(`signup-${ip}`);
} else if (isVerifyEmailRoute(request.nextUrl.pathname)) {
verifyEmailLimiter(`verify-email-${ip}`);
await verifyEmailLimiter(`verify-email-${ip}`);
} else if (isForgotPasswordRoute(request.nextUrl.pathname)) {
forgotPasswordLimiter(`forgot-password-${ip}`);
await forgotPasswordLimiter(`forgot-password-${ip}`);
} else if (isClientSideApiRoute(request.nextUrl.pathname)) {
clientSideApiEndpointsLimiter(`client-side-api-${ip}`);
await clientSideApiEndpointsLimiter(`client-side-api-${ip}`);
const envIdAndUserId = isSyncWithUserIdentificationEndpoint(request.nextUrl.pathname);
if (envIdAndUserId) {
const { environmentId, userId } = envIdAndUserId;
syncUserIdentificationLimiter(`sync-${environmentId}-${userId}`);
await syncUserIdentificationLimiter(`sync-${environmentId}-${userId}`);
}
} else if (isShareUrlRoute(request.nextUrl.pathname)) {
shareUrlLimiter(`share-${ip}`);
await shareUrlLimiter(`share-${ip}`);
}
};
@@ -132,12 +113,6 @@ export const middleware = async (originalRequest: NextRequest) => {
},
});
// Enforce HTTPS for management endpoints
if (isManagementApiRoute(request.nextUrl.pathname)) {
const httpsResponse = enforceHttps(request);
if (httpsResponse) return httpsResponse;
}
// Handle authentication
const authResponse = await handleAuth(request);
if (authResponse) return authResponse;
@@ -153,7 +128,7 @@ export const middleware = async (originalRequest: NextRequest) => {
if (ip) {
try {
applyRateLimiting(request, ip);
await applyRateLimiting(request, ip);
return nextResponseWithCustomHeader;
} catch (e) {
const apiError: ApiErrorResponseV2 = {

View File

@@ -11,7 +11,7 @@ export const SurveyLinkDisplay = ({ surveyUrl }: SurveyLinkDisplayProps) => {
<Input
data-testid="survey-url-input"
autoFocus={true}
className="mt-2 w-full min-w-96 rounded-lg border bg-white px-4 py-2 text-ellipsis text-slate-800 caret-transparent"
className="mt-2 w-full min-w-96 text-ellipsis rounded-lg border bg-white px-4 py-2 text-slate-800 caret-transparent"
value={surveyUrl}
/>
) : (

View File

@@ -39,7 +39,7 @@ export const QuestionSkip = ({
background:
"repeating-linear-gradient(rgb(148, 163, 184), rgb(148, 163, 184) 5px, transparent 5px, transparent 8px)", // adjust the values to fit your design
}}>
<CheckCircle2Icon className="absolute top-0 w-[1.5rem] min-w-[1.5rem] rounded-full bg-white p-0.25 text-slate-400" />
<CheckCircle2Icon className="p-0.25 absolute top-0 w-[1.5rem] min-w-[1.5rem] rounded-full bg-white text-slate-400" />
</div>
}
<div className="ml-6 flex flex-col text-slate-700">{t("common.welcome_card")}</div>

View File

@@ -101,7 +101,7 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
return (
<p
key={rowValueInSelectedLanguage}
className="ph-no-capture my-1 font-normal text-slate-700 capitalize">
className="ph-no-capture my-1 font-normal capitalize text-slate-700">
{rowValueInSelectedLanguage}:{processResponseData(responseData[rowValueInSelectedLanguage])}
</p>
);

View File

@@ -104,10 +104,10 @@ export const ResponseNotes = ({
!isOpen && unresolvedNotes.length && "group/hint cursor-pointer bg-white hover:-right-3",
!isOpen && !unresolvedNotes.length && "cursor-pointer bg-slate-50",
isOpen
? "top-0 -right-2 h-5/6 max-h-[600px] w-1/4 bg-white"
? "-right-2 top-0 h-5/6 max-h-[600px] w-1/4 bg-white"
: unresolvedNotes.length
? "top-[8.33%] right-0 h-5/6 max-h-[600px] w-1/12"
: "top-[8.333%] right-[120px] h-5/6 max-h-[600px] w-1/12 group-hover:right-[0]"
? "right-0 top-[8.33%] h-5/6 max-h-[600px] w-1/12"
: "right-[120px] top-[8.333%] h-5/6 max-h-[600px] w-1/12 group-hover:right-[0]"
)}
onClick={() => {
if (!isOpen) setIsOpen(true);
@@ -116,7 +116,7 @@ export const ResponseNotes = ({
<div className="flex h-full flex-col">
<div
className={clsx(
"space-y-2 rounded-t-lg px-2 pt-2 pb-2",
"space-y-2 rounded-t-lg px-2 pb-2 pt-2",
unresolvedNotes.length ? "flex h-12 items-center justify-end bg-amber-50" : "bg-slate-200"
)}>
{!unresolvedNotes.length ? (
@@ -127,7 +127,7 @@ export const ResponseNotes = ({
</div>
) : (
<div className="float-left mr-1.5">
<Maximize2Icon className="h-4 w-4 text-amber-500 group-hover/hint:scale-110 hover:text-amber-600" />
<Maximize2Icon className="h-4 w-4 text-amber-500 hover:text-amber-600 group-hover/hint:scale-110" />
</div>
)}
</div>
@@ -141,7 +141,7 @@ export const ResponseNotes = ({
</div>
) : (
<div className="relative flex h-full flex-col">
<div className="rounded-t-lg bg-amber-50 px-4 pt-4 pb-3">
<div className="rounded-t-lg bg-amber-50 px-4 pb-3 pt-4">
<div className="flex items-center justify-between">
<div className="group flex items-center">
<h3 className="pb-1 text-sm text-amber-500">{t("common.note")}</h3>

View File

@@ -37,7 +37,7 @@ export const SingleResponseCardBody = ({
return (
<span
key={index}
className="mr-0.5 ml-0.5 rounded-md border border-slate-200 bg-slate-50 px-1 py-0.5 text-sm first:ml-0">
className="ml-0.5 mr-0.5 rounded-md border border-slate-200 bg-slate-50 px-1 py-0.5 text-sm first:ml-0">
@{part}
</span>
);

View File

@@ -153,7 +153,7 @@ export const SingleResponseCardHeader = ({
const deleteSubmissionToolTip = <>{t("environments.surveys.responses.this_response_is_in_progress")}</>;
return (
<div className="space-y-2 border-b border-slate-200 px-6 pt-4 pb-4">
<div className="space-y-2 border-b border-slate-200 px-6 pb-4 pt-4">
<div className="flex items-center justify-between">
<div className="flex items-center justify-center space-x-4">
{pageType === "response" && (

View File

@@ -111,6 +111,7 @@ export const updateResponse = async (
responseCache.revalidate({
id: updatedResponse.id,
surveyId: updatedResponse.surveyId,
...(updatedResponse.singleUseId ? { singleUseId: updatedResponse.singleUseId } : {}),
});
responseNoteCache.revalidate({

View File

@@ -1,4 +1,5 @@
import { response, responseId, responseInput, survey } from "./__mocks__/response.mock";
import { responseCache } from "@/lib/response/cache";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
@@ -21,6 +22,16 @@ vi.mock("../utils", () => ({
findAndDeleteUploadedFilesInResponse: vi.fn(),
}));
vi.mock("@/lib/response/cache", () => ({
responseCache: {
revalidate: vi.fn(),
tag: {
byId: vi.fn(),
byResponseId: vi.fn(),
},
},
}));
vi.mock("@formbricks/database", () => ({
prisma: {
response: {
@@ -175,7 +186,7 @@ describe("Response Lib", () => {
});
describe("updateResponse", () => {
test("update the response and revalidate caches", async () => {
test("update the response and revalidate caches including singleUseId", async () => {
vi.mocked(prisma.response.update).mockResolvedValue(response);
const result = await updateResponse(responseId, responseInput);
@@ -184,12 +195,39 @@ describe("Response Lib", () => {
data: responseInput,
});
expect(responseCache.revalidate).toHaveBeenCalledWith({
id: response.id,
surveyId: response.surveyId,
singleUseId: response.singleUseId,
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual(response);
}
});
test("update the response and revalidate caches", async () => {
const responseWithoutSingleUseId = { ...response, singleUseId: null };
vi.mocked(prisma.response.update).mockResolvedValue(responseWithoutSingleUseId);
const result = await updateResponse(responseId, responseInput);
expect(prisma.response.update).toHaveBeenCalledWith({
where: { id: responseId },
data: responseInput,
});
expect(responseCache.revalidate).toHaveBeenCalledWith({
id: response.id,
surveyId: response.surveyId,
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual(responseWithoutSingleUseId);
}
});
test("return a not_found error when the response is not found", async () => {
vi.mocked(prisma.response.update).mockRejectedValue(
new PrismaClientKnownRequestError("Response not found", {

View File

@@ -56,6 +56,10 @@ vi.mock("@tolgee/react", async () => {
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
fatal: vi.fn(),
},
}));

View File

@@ -16,7 +16,7 @@ import { Testimonial } from "@/modules/auth/components/testimonial";
import {
getIsMultiOrgEnabled,
getIsSamlSsoEnabled,
getisSsoEnabled,
getIsSsoEnabled,
} from "@/modules/ee/license-check/lib/utils";
import { Metadata } from "next";
import { LoginForm } from "./components/login-form";
@@ -29,7 +29,7 @@ export const metadata: Metadata = {
export const LoginPage = async () => {
const [isMultiOrgEnabled, isSsoEnabled, isSamlSsoEnabled] = await Promise.all([
getIsMultiOrgEnabled(),
getisSsoEnabled(),
getIsSsoEnabled(),
getIsSamlSsoEnabled(),
]);

View File

@@ -4,7 +4,7 @@ import { getIsValidInviteToken } from "@/modules/auth/signup/lib/invite";
import {
getIsMultiOrgEnabled,
getIsSamlSsoEnabled,
getisSsoEnabled,
getIsSsoEnabled,
} from "@/modules/ee/license-check/lib/utils";
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
@@ -29,8 +29,14 @@ vi.mock("@/modules/auth/signup/components/signup-form", () => ({
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getIsMultiOrgEnabled: vi.fn(),
getIsSsoEnabled: vi.fn(),
getIsSamlSsoEnabled: vi.fn(),
getisSsoEnabled: vi.fn(),
getIsWhitelabelEnabled: vi.fn(),
getIsRemoveBrandingEnabled: vi.fn(),
getIsContactsEnabled: vi.fn(),
getIsAiEnabled: vi.fn(),
getIsSpamProtectionEnabled: vi.fn(),
getIsPendingDowngrade: vi.fn(),
}));
vi.mock("@/modules/auth/signup/lib/invite", () => ({
@@ -89,7 +95,6 @@ vi.mock("@/lib/constants", () => ({
AZURE_OAUTH_ENABLED: true,
OIDC_OAUTH_ENABLED: true,
DEFAULT_ORGANIZATION_ID: "test-default-organization-id",
DEFAULT_ORGANIZATION_ROLE: "test-default-organization-role",
IS_TURNSTILE_CONFIGURED: true,
SAML_TENANT: "test-saml-tenant",
SAML_PRODUCT: "test-saml-product",
@@ -114,7 +119,7 @@ describe("SignupPage", () => {
test("renders the signup page with all components when signup is enabled", async () => {
// Mock the license check functions to return true
vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(true);
vi.mocked(getisSsoEnabled).mockResolvedValue(true);
vi.mocked(getIsSsoEnabled).mockResolvedValue(true);
vi.mocked(getIsSamlSsoEnabled).mockResolvedValue(true);
vi.mocked(findMatchingLocale).mockResolvedValue("en-US");
vi.mocked(verifyInviteToken).mockReturnValue({
@@ -173,7 +178,7 @@ describe("SignupPage", () => {
test("renders the page with email from search params", async () => {
// Mock the license check functions to return true
vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(true);
vi.mocked(getisSsoEnabled).mockResolvedValue(true);
vi.mocked(getIsSsoEnabled).mockResolvedValue(true);
vi.mocked(getIsSamlSsoEnabled).mockResolvedValue(true);
vi.mocked(findMatchingLocale).mockResolvedValue("en-US");
vi.mocked(verifyInviteToken).mockReturnValue({

View File

@@ -24,7 +24,7 @@ import { getIsValidInviteToken } from "@/modules/auth/signup/lib/invite";
import {
getIsMultiOrgEnabled,
getIsSamlSsoEnabled,
getisSsoEnabled,
getIsSsoEnabled,
} from "@/modules/ee/license-check/lib/utils";
import { notFound } from "next/navigation";
import { SignupForm } from "./components/signup-form";
@@ -34,7 +34,7 @@ export const SignupPage = async ({ searchParams: searchParamsProps }) => {
const inviteToken = searchParams["inviteToken"] ?? null;
const [isMultOrgEnabled, isSsoEnabled, isSamlSsoEnabled] = await Promise.all([
getIsMultiOrgEnabled(),
getisSsoEnabled(),
getIsSsoEnabled(),
getIsSamlSsoEnabled(),
]);

View File

@@ -14,7 +14,7 @@ export const VerificationRequestedPage = async ({ searchParams }) => {
return (
<FormWrapper>
<>
<h1 className="mb-4 text-center text-lg leading-2 font-semibold text-slate-900">
<h1 className="leading-2 mb-4 text-center text-lg font-semibold text-slate-900">
{t("auth.verification-requested.please_confirm_your_email_address")}
</h1>
<p className="text-center text-sm text-slate-700">

View File

@@ -0,0 +1,117 @@
import KeyvRedis from "@keyv/redis";
import { createCache } from "cache-manager";
import { Keyv } from "keyv";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
// Mock dependencies
vi.mock("keyv");
vi.mock("@keyv/redis");
vi.mock("cache-manager");
vi.mock("@formbricks/logger");
const mockCacheInstance = {
set: vi.fn(),
get: vi.fn(),
del: vi.fn(),
};
const CACHE_TTL_SECONDS = 60 * 60 * 24; // 24 hours
const CACHE_TTL_MS = CACHE_TTL_SECONDS * 1000;
describe("Cache Service", () => {
let originalRedisUrl: string | undefined;
beforeEach(() => {
originalRedisUrl = process.env.REDIS_URL;
vi.resetAllMocks();
vi.resetModules(); // Crucial for re-running module initialization logic
// Setup default mock implementations
vi.mocked(createCache).mockReturnValue(mockCacheInstance as any);
vi.mocked(Keyv).mockClear(); // Clear any previous calls
vi.mocked(KeyvRedis).mockClear(); // Clear any previous calls
vi.mocked(logger.warn).mockClear(); // Clear logger warnings
});
afterEach(() => {
process.env.REDIS_URL = originalRedisUrl;
});
describe("Initialization and getCache", () => {
test("should use Redis store and return it via getCache if REDIS_URL is set", async () => {
process.env.REDIS_URL = "redis://localhost:6379";
const { getCache } = await import("./service"); // Dynamically import
expect(KeyvRedis).toHaveBeenCalledWith("redis://localhost:6379");
expect(Keyv).toHaveBeenCalledWith({
store: expect.any(KeyvRedis),
ttl: CACHE_TTL_MS,
});
expect(createCache).toHaveBeenCalledWith({
stores: [expect.any(Keyv)],
ttl: CACHE_TTL_MS,
});
expect(logger.warn).not.toHaveBeenCalled();
expect(logger.info).toHaveBeenCalledWith("Successfully connected to Redis cache");
expect(getCache()).toBe(mockCacheInstance);
});
test("should fall back to memory store if Redis connection fails", async () => {
process.env.REDIS_URL = "redis://localhost:6379";
const mockError = new Error("Connection refused");
vi.mocked(KeyvRedis).mockImplementation(() => {
throw mockError;
});
const { getCache } = await import("./service"); // Dynamically import
expect(KeyvRedis).toHaveBeenCalledWith("redis://localhost:6379");
expect(logger.error).toHaveBeenCalledWith("Failed to connect to Redis cache:", mockError);
expect(logger.warn).toHaveBeenCalledWith(
"Falling back to in-memory cache due to Redis connection failure"
);
expect(Keyv).toHaveBeenCalledWith({
ttl: CACHE_TTL_MS,
});
expect(createCache).toHaveBeenCalledWith({
stores: [expect.any(Keyv)],
ttl: CACHE_TTL_MS,
});
expect(getCache()).toBe(mockCacheInstance);
});
test("should use memory store, log warning, and return it via getCache if REDIS_URL is not set", async () => {
delete process.env.REDIS_URL;
const { getCache } = await import("./service"); // Dynamically import
expect(KeyvRedis).not.toHaveBeenCalled();
expect(Keyv).toHaveBeenCalledWith({
ttl: CACHE_TTL_MS,
});
expect(createCache).toHaveBeenCalledWith({
stores: [expect.any(Keyv)],
ttl: CACHE_TTL_MS,
});
expect(logger.warn).toHaveBeenCalledWith("REDIS_URL not found, falling back to in-memory cache.");
expect(getCache()).toBe(mockCacheInstance);
});
test("should use memory store, log warning, and return it via getCache if REDIS_URL is an empty string", async () => {
process.env.REDIS_URL = ""; // Test with empty string
const { getCache } = await import("./service"); // Dynamically import
// If REDIS_URL is "", it's falsy, so it should fall back to memory store
expect(KeyvRedis).not.toHaveBeenCalled();
expect(Keyv).toHaveBeenCalledWith({
ttl: CACHE_TTL_MS, // Expect memory store configuration
});
expect(createCache).toHaveBeenCalledWith({
stores: [expect.any(Keyv)],
ttl: CACHE_TTL_MS,
});
expect(logger.warn).toHaveBeenCalledWith("REDIS_URL not found, falling back to in-memory cache.");
expect(getCache()).toBe(mockCacheInstance);
});
});
});

55
apps/web/modules/cache/lib/service.ts vendored Normal file
View File

@@ -0,0 +1,55 @@
import "server-only";
import KeyvRedis from "@keyv/redis";
import { type Cache, createCache } from "cache-manager";
import { Keyv } from "keyv";
import { logger } from "@formbricks/logger";
const CACHE_TTL_SECONDS = 60 * 60 * 24; // 24 hours
const CACHE_TTL_MS = CACHE_TTL_SECONDS * 1000;
let cache: Cache;
const initializeMemoryCache = (): void => {
const memoryKeyvStore = new Keyv({
ttl: CACHE_TTL_MS,
});
cache = createCache({
stores: [memoryKeyvStore],
ttl: CACHE_TTL_MS,
});
logger.info("Using in-memory cache");
};
if (process.env.REDIS_URL) {
try {
const redisStore = new KeyvRedis(process.env.REDIS_URL);
// Gracefully fall back if Redis dies later on
redisStore.on("error", (err) => {
logger.error("Redis connection lost switching to in-memory cache", { error: err });
initializeMemoryCache();
});
const redisKeyvStore = new Keyv({
store: redisStore,
ttl: CACHE_TTL_MS,
});
cache = createCache({
stores: [redisKeyvStore],
ttl: CACHE_TTL_MS,
});
logger.info("Successfully connected to Redis cache");
} catch (error) {
logger.error("Failed to connect to Redis cache:", error);
logger.warn("Falling back to in-memory cache due to Redis connection failure");
initializeMemoryCache();
}
} else {
logger.warn("REDIS_URL not found, falling back to in-memory cache.");
initializeMemoryCache();
}
export const getCache = (): Cache => {
return cache;
};

View File

@@ -19,7 +19,7 @@ export const BillingSlider = React.forwardRef<React.ElementRef<typeof SliderPrim
return (
<SliderPrimitive.Root
ref={ref}
className={cn("relative flex w-full touch-none items-center select-none", className)}
className={cn("relative flex w-full touch-none select-none items-center", className)}
{...props}>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-r-full bg-slate-300">
<div

View File

@@ -126,7 +126,7 @@ export const PricingCard = ({
id={plan.id}
className={cn(
plan.featured ? "text-slate-900" : "text-slate-800",
"text-sm leading-6 font-semibold"
"text-sm font-semibold leading-6"
)}>
{t(plan.name)}
</h2>

View File

@@ -41,7 +41,7 @@ export const SingleContactPage = async (props: {
return (
<PageContentWrapper>
<PageHeader pageTitle={getContactIdentifier(contactAttributes)} cta={getDeletePersonButton()} />
<section className="pt-6 pb-24">
<section className="pb-24 pt-6">
<div className="grid grid-cols-4 gap-x-8">
<AttributesSection contactId={params.contactId} />
<ResponseSection

View File

@@ -229,51 +229,52 @@ export const upsertBulkContacts = async (
try {
// Execute everything in ONE transaction
await prisma.$transaction(async (tx) => {
const attributeKeyMap = existingAttributeKeys.reduce<Record<string, string>>((acc, keyObj) => {
acc[keyObj.key] = keyObj.id;
return acc;
}, {});
await prisma.$transaction(
async (tx) => {
const attributeKeyMap = existingAttributeKeys.reduce<Record<string, string>>((acc, keyObj) => {
acc[keyObj.key] = keyObj.id;
return acc;
}, {});
// Check for missing attribute keys and create them if needed.
const missingKeysMap = new Map<string, { key: string; name: string }>();
const attributeKeyNameUpdates = new Map<string, { key: string; name: string }>();
// Check for missing attribute keys and create them if needed.
const missingKeysMap = new Map<string, { key: string; name: string }>();
const attributeKeyNameUpdates = new Map<string, { key: string; name: string }>();
for (const contact of filteredContacts) {
for (const attr of contact.attributes) {
if (!attributeKeyMap[attr.attributeKey.key]) {
missingKeysMap.set(attr.attributeKey.key, attr.attributeKey);
} else {
// Check if the name has changed for existing attribute keys
const existingKey = existingAttributeKeys.find((ak) => ak.key === attr.attributeKey.key);
if (existingKey && existingKey.name !== attr.attributeKey.name) {
attributeKeyNameUpdates.set(attr.attributeKey.key, attr.attributeKey);
for (const contact of filteredContacts) {
for (const attr of contact.attributes) {
if (!attributeKeyMap[attr.attributeKey.key]) {
missingKeysMap.set(attr.attributeKey.key, attr.attributeKey);
} else {
// Check if the name has changed for existing attribute keys
const existingKey = existingAttributeKeys.find((ak) => ak.key === attr.attributeKey.key);
if (existingKey && existingKey.name !== attr.attributeKey.name) {
attributeKeyNameUpdates.set(attr.attributeKey.key, attr.attributeKey);
}
}
}
}
}
// Handle both missing keys and name updates in a single batch operation
const keysToUpsert = new Map<string, { key: string; name: string }>();
// Handle both missing keys and name updates in a single batch operation
const keysToUpsert = new Map<string, { key: string; name: string }>();
// Collect all keys that need to be created or updated
for (const [key, value] of missingKeysMap) {
keysToUpsert.set(key, value);
}
// Collect all keys that need to be created or updated
for (const [key, value] of missingKeysMap) {
keysToUpsert.set(key, value);
}
for (const [key, value] of attributeKeyNameUpdates) {
keysToUpsert.set(key, value);
}
for (const [key, value] of attributeKeyNameUpdates) {
keysToUpsert.set(key, value);
}
if (keysToUpsert.size > 0) {
const keysArray = Array.from(keysToUpsert.values());
const BATCH_SIZE = 10000;
if (keysToUpsert.size > 0) {
const keysArray = Array.from(keysToUpsert.values());
const BATCH_SIZE = 10000;
for (let i = 0; i < keysArray.length; i += BATCH_SIZE) {
const batch = keysArray.slice(i, i + BATCH_SIZE);
for (let i = 0; i < keysArray.length; i += BATCH_SIZE) {
const batch = keysArray.slice(i, i + BATCH_SIZE);
// Use raw query to perform upsert
const upsertedKeys = await tx.$queryRaw<{ id: string; key: string }[]>`
// Use raw query to perform upsert
const upsertedKeys = await tx.$queryRaw<{ id: string; key: string }[]>`
INSERT INTO "ContactAttributeKey" ("id", "key", "name", "environmentId", "created_at", "updated_at")
SELECT
unnest(${Prisma.sql`ARRAY[${batch.map(() => createId())}]`}),
@@ -289,59 +290,59 @@ export const upsertBulkContacts = async (
RETURNING "id", "key"
`;
// Update attribute key map with upserted keys
for (const key of upsertedKeys) {
attributeKeyMap[key.key] = key.id;
// Update attribute key map with upserted keys
for (const key of upsertedKeys) {
attributeKeyMap[key.key] = key.id;
}
}
}
}
// Create new contacts -- should be at most 1000, no need to batch
const newContacts = contactsToCreate.map(() => ({
id: createId(),
environmentId,
}));
if (newContacts.length > 0) {
await tx.contact.createMany({
data: newContacts,
});
}
// Prepare attributes for both new and existing contacts
const attributesUpsertForCreatedUsers = contactsToCreate.flatMap((contact, idx) =>
contact.attributes.map((attr) => ({
// Create new contacts -- should be at most 1000, no need to batch
const newContacts = contactsToCreate.map(() => ({
id: createId(),
contactId: newContacts[idx].id,
attributeKeyId: attributeKeyMap[attr.attributeKey.key],
value: attr.value,
createdAt: new Date(),
updatedAt: new Date(),
}))
);
environmentId,
}));
const attributesUpsertForExistingUsers = contactsToUpdate.flatMap((contact) =>
contact.attributes.map((attr) => ({
id: attr.id,
contactId: contact.contactId,
attributeKeyId: attributeKeyMap[attr.attributeKey.key],
value: attr.value,
createdAt: attr.createdAt,
updatedAt: new Date(),
}))
);
if (newContacts.length > 0) {
await tx.contact.createMany({
data: newContacts,
});
}
const attributesToUpsert = [...attributesUpsertForCreatedUsers, ...attributesUpsertForExistingUsers];
// Prepare attributes for both new and existing contacts
const attributesUpsertForCreatedUsers = contactsToCreate.flatMap((contact, idx) =>
contact.attributes.map((attr) => ({
id: createId(),
contactId: newContacts[idx].id,
attributeKeyId: attributeKeyMap[attr.attributeKey.key],
value: attr.value,
createdAt: new Date(),
updatedAt: new Date(),
}))
);
// Skip the raw query if there are no attributes to upsert
if (attributesToUpsert.length > 0) {
// Process attributes in batches of 10,000
const BATCH_SIZE = 10000;
for (let i = 0; i < attributesToUpsert.length; i += BATCH_SIZE) {
const batch = attributesToUpsert.slice(i, i + BATCH_SIZE);
const attributesUpsertForExistingUsers = contactsToUpdate.flatMap((contact) =>
contact.attributes.map((attr) => ({
id: attr.id,
contactId: contact.contactId,
attributeKeyId: attributeKeyMap[attr.attributeKey.key],
value: attr.value,
createdAt: attr.createdAt,
updatedAt: new Date(),
}))
);
// Use a raw query to perform a bulk insert with an ON CONFLICT clause
await tx.$executeRaw`
const attributesToUpsert = [...attributesUpsertForCreatedUsers, ...attributesUpsertForExistingUsers];
// Skip the raw query if there are no attributes to upsert
if (attributesToUpsert.length > 0) {
// Process attributes in batches of 10,000
const BATCH_SIZE = 10000;
for (let i = 0; i < attributesToUpsert.length; i += BATCH_SIZE) {
const batch = attributesToUpsert.slice(i, i + BATCH_SIZE);
// Use a raw query to perform a bulk insert with an ON CONFLICT clause
await tx.$executeRaw`
INSERT INTO "ContactAttribute" (
"id", "created_at", "updated_at", "contactId", "value", "attributeKeyId"
)
@@ -356,33 +357,37 @@ export const upsertBulkContacts = async (
"value" = EXCLUDED."value",
"updated_at" = EXCLUDED."updated_at"
`;
}
}
}
contactCache.revalidate({
environmentId,
});
// revalidate all the new contacts:
for (const newContact of newContacts) {
contactCache.revalidate({
id: newContact.id,
environmentId,
});
}
// revalidate all the existing contacts:
for (const existingContact of existingContactsByEmail) {
contactCache.revalidate({
id: existingContact.id,
// revalidate all the new contacts:
for (const newContact of newContacts) {
contactCache.revalidate({
id: newContact.id,
});
}
// revalidate all the existing contacts:
for (const existingContact of existingContactsByEmail) {
contactCache.revalidate({
id: existingContact.id,
});
}
contactAttributeKeyCache.revalidate({
environmentId,
});
contactAttributeCache.revalidate({ environmentId });
},
{
timeout: 10 * 1000, // 10 seconds
}
contactAttributeKeyCache.revalidate({
environmentId,
});
contactAttributeCache.revalidate({ environmentId });
});
);
return ok({
contactIdxWithConflictingUserIds,

View File

@@ -312,7 +312,7 @@ function AttributeSegmentFilter({
}}
value={attrKeyValue}>
<SelectTrigger
className="flex w-auto items-center justify-center bg-white whitespace-nowrap capitalize"
className="flex w-auto items-center justify-center whitespace-nowrap bg-white capitalize"
hideArrow>
<SelectValue>
<div className={cn("flex items-center gap-2", !isCapitalized(attrKeyValue ?? "") && "lowercase")}>
@@ -494,7 +494,7 @@ function PersonSegmentFilter({
}}
value={personIdentifier}>
<SelectTrigger
className="flex w-auto items-center justify-center bg-white whitespace-nowrap capitalize"
className="flex w-auto items-center justify-center whitespace-nowrap bg-white capitalize"
hideArrow>
<SelectValue>
<div className="flex items-center gap-1 lowercase">
@@ -643,7 +643,7 @@ function SegmentSegmentFilter({
}}
value={currentSegment?.id}>
<SelectTrigger
className="flex w-auto items-center justify-center bg-white whitespace-nowrap capitalize"
className="flex w-auto items-center justify-center whitespace-nowrap bg-white capitalize"
hideArrow>
<div className="flex items-center gap-1">
<Users2Icon className="h-4 w-4 text-sm" />

View File

@@ -171,7 +171,7 @@ export function TargetingCard({
asChild
className="h-full w-full cursor-pointer rounded-lg hover:bg-slate-50">
<div className="inline-flex px-4 py-6">
<div className="flex items-center pr-5 pl-2">
<div className="flex items-center pl-2 pr-5">
<CheckIcon
className="h-7 w-7 rounded-full border border-green-300 bg-green-100 p-1.5 text-green-600"
strokeWidth={3}

View File

@@ -126,7 +126,7 @@ export const ZContactBulkUploadRequest = z.object({
environmentId: z.string().cuid2(),
contacts: z
.array(ZContactBulkUploadContact)
.max(1000, { message: "Maximum 1000 contacts allowed at a time." })
.max(250, { message: "Maximum 250 contacts allowed at a time." })
.superRefine((contacts, ctx) => {
// Track all data in a single pass
const seenEmails = new Set<string>();

View File

@@ -0,0 +1,475 @@
import {
TEnterpriseLicenseDetails,
TEnterpriseLicenseFeatures,
} from "@/modules/ee/license-check/types/enterprise-license";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import type { Mock } from "vitest";
import { prisma } from "@formbricks/database";
// Mock declarations must be at the top level
vi.mock("@/lib/env", () => ({
env: {
ENTERPRISE_LICENSE_KEY: "test-license-key",
VERCEL_URL: "some.vercel.url",
FORMBRICKS_COM_URL: "https://app.formbricks.com",
HTTPS_PROXY: undefined,
HTTP_PROXY: undefined,
},
}));
const mockCache = {
get: vi.fn(),
set: vi.fn(),
del: vi.fn(),
reset: vi.fn(),
store: { name: "memory" },
};
vi.mock("@/modules/cache/lib/service", () => ({
getCache: () => mockCache,
}));
vi.mock("node-fetch", () => ({
default: vi.fn(),
}));
vi.mock("@formbricks/database", () => ({
prisma: {
response: {
count: vi.fn(),
},
},
}));
// Mock constants as they are used in the original license.ts indirectly
vi.mock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal();
return {
...(typeof actual === "object" && actual !== null ? actual : {}),
IS_FORMBRICKS_CLOUD: false, // Default to self-hosted for most tests
REVALIDATION_INTERVAL: 3600, // Example value
ENTERPRISE_LICENSE_KEY: "test-license-key",
};
});
describe("License Core Logic", () => {
let originalProcessEnv: NodeJS.ProcessEnv;
beforeEach(() => {
originalProcessEnv = { ...process.env };
vi.resetAllMocks();
mockCache.get.mockReset();
mockCache.set.mockReset();
mockCache.del.mockReset();
vi.mocked(prisma.response.count).mockResolvedValue(100);
vi.clearAllMocks();
// Mock window to be undefined for server-side tests
vi.stubGlobal("window", undefined);
});
afterEach(() => {
process.env = originalProcessEnv;
vi.unstubAllGlobals();
});
describe("getEnterpriseLicense", () => {
const mockFetchedLicenseDetailsFeatures: TEnterpriseLicenseFeatures = {
isMultiOrgEnabled: true,
contacts: true,
projects: 10,
whitelabel: true,
removeBranding: true,
twoFactorAuth: true,
sso: true,
saml: true,
spamProtection: true,
ai: false,
};
const mockFetchedLicenseDetails: TEnterpriseLicenseDetails = {
status: "active",
features: mockFetchedLicenseDetailsFeatures,
};
const expectedActiveLicenseState = {
active: true,
features: mockFetchedLicenseDetails.features,
lastChecked: expect.any(Date),
isPendingDowngrade: false,
fallbackLevel: "live" as const,
};
test("should return cached license from FETCH_LICENSE_CACHE_KEY if available and valid", async () => {
const { getEnterpriseLicense } = await import("./license");
const fetch = (await import("node-fetch")).default as Mock;
mockCache.get.mockImplementation(async (key) => {
if (key.startsWith("formbricksEnterpriseLicense-details")) {
return mockFetchedLicenseDetails;
}
return null;
});
const license = await getEnterpriseLicense();
expect(license).toEqual(expectedActiveLicenseState);
expect(mockCache.get).toHaveBeenCalledWith(
expect.stringContaining("formbricksEnterpriseLicense-details")
);
expect(fetch).not.toHaveBeenCalled();
});
test("should fetch license if not in FETCH_LICENSE_CACHE_KEY", async () => {
const { getEnterpriseLicense } = await import("./license");
const fetch = (await import("node-fetch")).default as Mock;
mockCache.get.mockResolvedValue(null);
(fetch as Mock).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: mockFetchedLicenseDetails }),
} as any);
const license = await getEnterpriseLicense();
expect(fetch).toHaveBeenCalledTimes(1);
expect(mockCache.set).toHaveBeenCalledWith(
expect.stringContaining("formbricksEnterpriseLicense-details"),
mockFetchedLicenseDetails,
expect.any(Number)
);
expect(mockCache.set).toHaveBeenCalledWith(
expect.stringContaining("formbricksEnterpriseLicense-previousResult"),
{
active: true,
features: mockFetchedLicenseDetails.features,
lastChecked: expect.any(Date),
version: 1,
},
expect.any(Number)
);
expect(license).toEqual(expectedActiveLicenseState);
});
test("should use previous result if fetch fails and previous result exists and is within grace period", async () => {
const { getEnterpriseLicense } = await import("./license");
const fetch = (await import("node-fetch")).default as Mock;
const previousTime = new Date(Date.now() - 1 * 24 * 60 * 60 * 1000); // 1 day ago, within grace period
const mockPreviousResult = {
active: true,
features: { removeBranding: true, projects: 5 },
lastChecked: previousTime,
version: 1,
};
mockCache.get.mockImplementation(async (key) => {
if (key.startsWith("formbricksEnterpriseLicense-details")) return null;
if (key.startsWith("formbricksEnterpriseLicense-previousResult")) return mockPreviousResult;
return null;
});
(fetch as Mock).mockResolvedValueOnce({ ok: false, status: 500 } as any);
const license = await getEnterpriseLicense();
expect(fetch).toHaveBeenCalledTimes(1);
expect(license).toEqual({
active: true,
features: mockPreviousResult.features,
lastChecked: previousTime,
isPendingDowngrade: true,
fallbackLevel: "grace" as const,
});
});
test("should return inactive and set new previousResult if fetch fails and previous result is outside grace period", async () => {
const { getEnterpriseLicense } = await import("./license");
const fetch = (await import("node-fetch")).default as Mock;
const previousTime = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000); // 5 days ago, outside grace period
const mockPreviousResult = {
active: true,
features: { removeBranding: true },
lastChecked: previousTime,
version: 1,
};
mockCache.get.mockImplementation(async (key) => {
if (key.startsWith("formbricksEnterpriseLicense-details")) return null;
if (key.startsWith("formbricksEnterpriseLicense-previousResult")) return mockPreviousResult;
return null;
});
(fetch as Mock).mockResolvedValueOnce({ ok: false, status: 500 } as any);
const license = await getEnterpriseLicense();
expect(fetch).toHaveBeenCalledTimes(1);
expect(mockCache.set).toHaveBeenCalledWith(
expect.stringContaining("formbricksEnterpriseLicense-previousResult"),
{
active: false,
features: {
isMultiOrgEnabled: false,
projects: 3,
twoFactorAuth: false,
sso: false,
whitelabel: false,
removeBranding: false,
contacts: false,
ai: false,
saml: false,
spamProtection: false,
},
lastChecked: expect.any(Date),
version: 1,
},
expect.any(Number)
);
expect(license).toEqual({
active: false,
features: {
isMultiOrgEnabled: false,
projects: 3,
twoFactorAuth: false,
sso: false,
whitelabel: false,
removeBranding: false,
contacts: false,
ai: false,
saml: false,
spamProtection: false,
},
lastChecked: expect.any(Date),
isPendingDowngrade: false,
fallbackLevel: "default" as const,
});
});
test("should return inactive with default features if fetch fails and no previous result (initial fail)", async () => {
const { getEnterpriseLicense } = await import("./license");
const fetch = (await import("node-fetch")).default as Mock;
mockCache.get.mockResolvedValue(null);
(fetch as Mock).mockRejectedValueOnce(new Error("Network error"));
const license = await getEnterpriseLicense();
const expectedFeatures: TEnterpriseLicenseFeatures = {
isMultiOrgEnabled: false,
projects: 3,
twoFactorAuth: false,
sso: false,
whitelabel: false,
removeBranding: false,
contacts: false,
ai: false,
saml: false,
spamProtection: false,
};
expect(mockCache.set).toHaveBeenCalledWith(
expect.stringContaining("formbricksEnterpriseLicense-previousResult"),
{
active: false,
features: expectedFeatures,
lastChecked: expect.any(Date),
version: 1,
},
expect.any(Number)
);
expect(license).toEqual({
active: false,
features: expectedFeatures,
lastChecked: expect.any(Date),
isPendingDowngrade: false,
fallbackLevel: "default" as const,
});
});
test("should return inactive license if ENTERPRISE_LICENSE_KEY is not set in env", async () => {
// Reset all mocks first
vi.resetAllMocks();
mockCache.get.mockReset();
mockCache.set.mockReset();
const fetch = (await import("node-fetch")).default as Mock;
fetch.mockReset();
// Mock the env module with empty license key
vi.doMock("@/lib/env", () => ({
env: {
ENTERPRISE_LICENSE_KEY: "",
VERCEL_URL: "some.vercel.url",
FORMBRICKS_COM_URL: "https://app.formbricks.com",
HTTPS_PROXY: undefined,
HTTP_PROXY: undefined,
},
}));
// Re-import the module to apply the new mock
const { getEnterpriseLicense } = await import("./license");
const license = await getEnterpriseLicense();
expect(license).toEqual({
active: false,
features: null,
lastChecked: expect.any(Date),
isPendingDowngrade: false,
fallbackLevel: "default" as const,
});
expect(mockCache.get).not.toHaveBeenCalled();
expect(mockCache.set).not.toHaveBeenCalled();
});
test("should handle fetch throwing an error and use grace period or return inactive", async () => {
const { getEnterpriseLicense } = await import("./license");
const fetch = (await import("node-fetch")).default as Mock;
mockCache.get.mockResolvedValue(null);
(fetch as Mock).mockRejectedValueOnce(new Error("Network error"));
const license = await getEnterpriseLicense();
expect(license).toEqual({
active: false,
features: null,
lastChecked: expect.any(Date),
isPendingDowngrade: false,
fallbackLevel: "default" as const,
});
});
});
describe("getLicenseFeatures", () => {
test("should return features if license is active", async () => {
// Set up environment before import
vi.stubGlobal("window", undefined);
vi.doMock("@/lib/env", () => ({
env: {
ENTERPRISE_LICENSE_KEY: "test-license-key",
VERCEL_URL: "some.vercel.url",
FORMBRICKS_COM_URL: "https://app.formbricks.com",
HTTPS_PROXY: undefined,
HTTP_PROXY: undefined,
},
}));
// Import hashString to compute the expected cache key
const { hashString } = await import("@/lib/hashString");
const hashedKey = hashString("test-license-key");
const detailsKey = `formbricksEnterpriseLicense-details-${hashedKey}`;
// Patch the cache mock to match the actual key logic
mockCache.get.mockImplementation(async (key) => {
if (key === detailsKey) {
return {
status: "active",
features: {
isMultiOrgEnabled: true,
contacts: true,
projects: 5,
whitelabel: true,
removeBranding: true,
twoFactorAuth: true,
sso: true,
saml: true,
spamProtection: true,
ai: true,
},
};
}
return null;
});
// Import after env and mocks are set
const { getLicenseFeatures } = await import("./license");
const features = await getLicenseFeatures();
expect(features).toEqual({
isMultiOrgEnabled: true,
contacts: true,
projects: 5,
whitelabel: true,
removeBranding: true,
twoFactorAuth: true,
sso: true,
saml: true,
spamProtection: true,
ai: true,
});
});
test("should return null if license is inactive", async () => {
const { getLicenseFeatures } = await import("./license");
mockCache.get.mockImplementation(async (key) => {
if (key.startsWith("formbricksEnterpriseLicense-details")) {
return { status: "expired", features: null };
}
return null;
});
const features = await getLicenseFeatures();
expect(features).toBeNull();
});
test("should return null if getEnterpriseLicense throws", async () => {
const { getLicenseFeatures } = await import("./license");
mockCache.get.mockRejectedValue(new Error("Cache error"));
const features = await getLicenseFeatures();
expect(features).toBeNull();
});
});
describe("Cache Key Generation", () => {
beforeEach(() => {
vi.resetAllMocks();
mockCache.get.mockReset();
mockCache.set.mockReset();
mockCache.del.mockReset();
vi.resetModules();
});
test("should use 'browser' as cache key in browser environment", async () => {
vi.stubGlobal("window", {});
const { getEnterpriseLicense } = await import("./license");
await getEnterpriseLicense();
expect(mockCache.get).toHaveBeenCalledWith(
expect.stringContaining("formbricksEnterpriseLicense-details-browser")
);
});
test("should use 'no-license' as cache key when ENTERPRISE_LICENSE_KEY is not set", async () => {
vi.resetModules();
vi.stubGlobal("window", undefined);
vi.doMock("@/lib/env", () => ({
env: {
ENTERPRISE_LICENSE_KEY: undefined,
VERCEL_URL: "some.vercel.url",
FORMBRICKS_COM_URL: "https://app.formbricks.com",
HTTPS_PROXY: undefined,
HTTP_PROXY: undefined,
},
}));
const { getEnterpriseLicense } = await import("./license");
await getEnterpriseLicense();
// The cache should NOT be accessed if there is no license key
expect(mockCache.get).not.toHaveBeenCalled();
});
test("should use hashed license key as cache key when ENTERPRISE_LICENSE_KEY is set", async () => {
vi.resetModules();
const testLicenseKey = "test-license-key";
vi.stubGlobal("window", undefined);
vi.doMock("@/lib/env", () => ({
env: {
ENTERPRISE_LICENSE_KEY: testLicenseKey,
VERCEL_URL: "some.vercel.url",
FORMBRICKS_COM_URL: "https://app.formbricks.com",
HTTPS_PROXY: undefined,
HTTP_PROXY: undefined,
},
}));
const { hashString } = await import("@/lib/hashString");
const expectedHash = hashString(testLicenseKey);
const { getEnterpriseLicense } = await import("./license");
await getEnterpriseLicense();
expect(mockCache.get).toHaveBeenCalledWith(
expect.stringContaining(`formbricksEnterpriseLicense-details-${expectedHash}`)
);
});
});
});
// Helper mock for process.env if not already globally available in test environment
if (typeof process === "undefined") {
global.process = { env: {} } as any;
}
vi.stubGlobal("process", global.process);

View File

@@ -0,0 +1,395 @@
import { env } from "@/lib/env";
import { hashString } from "@/lib/hashString";
import { getCache } from "@/modules/cache/lib/service";
import {
TEnterpriseLicenseDetails,
TEnterpriseLicenseFeatures,
} from "@/modules/ee/license-check/types/enterprise-license";
import { HttpsProxyAgent } from "https-proxy-agent";
import fetch from "node-fetch";
import { cache as reactCache } from "react";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
// Configuration
const CONFIG = {
CACHE: {
FETCH_LICENSE_TTL_MS: 24 * 60 * 60 * 1000, // 24 hours
PREVIOUS_RESULT_TTL_MS: 4 * 24 * 60 * 60 * 1000, // 4 days
GRACE_PERIOD_MS: 3 * 24 * 60 * 60 * 1000, // 3 days
MAX_RETRIES: 3,
RETRY_DELAY_MS: 1000,
},
API: {
ENDPOINT: "https://ee.formbricks.com/api/licenses/check",
TIMEOUT_MS: 5000,
},
} as const;
// Types
type FallbackLevel = "live" | "cached" | "grace" | "default";
type TPreviousResult = {
active: boolean;
lastChecked: Date;
features: TEnterpriseLicenseFeatures | null;
version: number; // For cache versioning
};
// Validation schemas
const LicenseFeaturesSchema = z.object({
isMultiOrgEnabled: z.boolean(),
projects: z.number().nullable(),
twoFactorAuth: z.boolean(),
sso: z.boolean(),
whitelabel: z.boolean(),
removeBranding: z.boolean(),
contacts: z.boolean(),
ai: z.boolean(),
saml: z.boolean(),
spamProtection: z.boolean(),
});
const LicenseDetailsSchema = z.object({
status: z.enum(["active", "expired"]),
features: LicenseFeaturesSchema,
});
// Error types
class LicenseError extends Error {
constructor(
message: string,
public readonly code: string
) {
super(message);
this.name = "LicenseError";
}
}
class LicenseApiError extends LicenseError {
constructor(
message: string,
public readonly status: number
) {
super(message, "API_ERROR");
this.name = "LicenseApiError";
}
}
// Cache keys
const getHashedKey = () => {
if (typeof window !== "undefined") {
return "browser"; // Browser environment
}
if (!env.ENTERPRISE_LICENSE_KEY) {
return "no-license"; // No license key provided
}
return hashString(env.ENTERPRISE_LICENSE_KEY); // Valid license key
};
export const getCacheKeys = () => {
const hashedKey = getHashedKey();
return {
FETCH_LICENSE_CACHE_KEY: `formbricksEnterpriseLicense-details-${hashedKey}`,
PREVIOUS_RESULT_CACHE_KEY: `formbricksEnterpriseLicense-previousResult-${hashedKey}`,
};
};
// Default features
const DEFAULT_FEATURES: TEnterpriseLicenseFeatures = {
isMultiOrgEnabled: false,
projects: 3,
twoFactorAuth: false,
sso: false,
whitelabel: false,
removeBranding: false,
contacts: false,
ai: false,
saml: false,
spamProtection: false,
};
// Helper functions
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const validateConfig = () => {
const errors: string[] = [];
if (CONFIG.CACHE.GRACE_PERIOD_MS >= CONFIG.CACHE.PREVIOUS_RESULT_TTL_MS) {
errors.push("Grace period must be shorter than previous result TTL");
}
if (CONFIG.CACHE.MAX_RETRIES < 0) {
errors.push("Max retries must be non-negative");
}
if (errors.length > 0) {
throw new LicenseError(errors.join(", "), "CONFIG_ERROR");
}
};
// Cache functions
const getPreviousResult = async (): Promise<TPreviousResult> => {
if (typeof window !== "undefined") {
return {
active: false,
lastChecked: new Date(0),
features: DEFAULT_FEATURES,
version: 1,
};
}
const formbricksCache = getCache();
const cachedData = await formbricksCache.get<TPreviousResult>(getCacheKeys().PREVIOUS_RESULT_CACHE_KEY);
if (cachedData) {
return {
...cachedData,
lastChecked: new Date(cachedData.lastChecked),
};
}
return {
active: false,
lastChecked: new Date(0),
features: DEFAULT_FEATURES,
version: 1,
};
};
const setPreviousResult = async (previousResult: TPreviousResult) => {
if (typeof window !== "undefined") return;
const formbricksCache = getCache();
await formbricksCache.set(
getCacheKeys().PREVIOUS_RESULT_CACHE_KEY,
previousResult,
CONFIG.CACHE.PREVIOUS_RESULT_TTL_MS
);
};
// Monitoring functions
const trackFallbackUsage = (level: FallbackLevel) => {
logger.info(`Using license fallback level: ${level}`, {
fallbackLevel: level,
timestamp: new Date().toISOString(),
});
};
const trackApiError = (error: LicenseApiError) => {
logger.error(`License API error: ${error.message}`, {
status: error.status,
code: error.code,
timestamp: new Date().toISOString(),
});
};
// Validation functions
const validateFallback = (previousResult: TPreviousResult): boolean => {
if (!previousResult.features) return false;
if (previousResult.lastChecked.getTime() === new Date(0).getTime()) return false;
if (previousResult.version !== 1) return false; // Add version check
return true;
};
const validateLicenseDetails = (data: unknown): TEnterpriseLicenseDetails => {
return LicenseDetailsSchema.parse(data);
};
// Fallback functions
const getFallbackLevel = (
liveLicense: TEnterpriseLicenseDetails | null,
previousResult: TPreviousResult,
currentTime: Date
): FallbackLevel => {
if (liveLicense) return "live";
if (previousResult.active) {
const elapsedTime = currentTime.getTime() - previousResult.lastChecked.getTime();
return elapsedTime < CONFIG.CACHE.GRACE_PERIOD_MS ? "grace" : "default";
}
return "default";
};
const handleInitialFailure = async (currentTime: Date) => {
const initialFailResult: TPreviousResult = {
active: false,
features: DEFAULT_FEATURES,
lastChecked: currentTime,
version: 1,
};
await setPreviousResult(initialFailResult);
return {
active: false,
features: DEFAULT_FEATURES,
lastChecked: currentTime,
isPendingDowngrade: false,
fallbackLevel: "default" as const,
};
};
// API functions
const fetchLicenseFromServerInternal = async (retryCount = 0): Promise<TEnterpriseLicenseDetails | null> => {
if (!env.ENTERPRISE_LICENSE_KEY) return null;
// Skip license checks during build time
// eslint-disable-next-line turbo/no-undeclared-env-vars -- NEXT_PHASE is a next.js env variable
if (process.env.NEXT_PHASE === "phase-production-build") {
return null;
}
try {
const now = new Date();
const startOfYear = new Date(now.getFullYear(), 0, 1);
// first millisecond of next year => current year is fully included
const startOfNextYear = new Date(now.getFullYear() + 1, 0, 1);
const responseCount = await prisma.response.count({
where: {
createdAt: {
gte: startOfYear,
lt: startOfNextYear,
},
},
});
const proxyUrl = env.HTTPS_PROXY ?? env.HTTP_PROXY;
const agent = proxyUrl ? new HttpsProxyAgent(proxyUrl) : undefined;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), CONFIG.API.TIMEOUT_MS);
const res = await fetch(CONFIG.API.ENDPOINT, {
body: JSON.stringify({
licenseKey: env.ENTERPRISE_LICENSE_KEY,
usage: { responseCount },
}),
headers: { "Content-Type": "application/json" },
method: "POST",
agent,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (res.ok) {
const responseJson = (await res.json()) as { data: unknown };
return validateLicenseDetails(responseJson.data);
}
const error = new LicenseApiError(`License check API responded with status: ${res.status}`, res.status);
trackApiError(error);
// Retry on specific status codes
if (retryCount < CONFIG.CACHE.MAX_RETRIES && [429, 502, 503, 504].includes(res.status)) {
await sleep(CONFIG.CACHE.RETRY_DELAY_MS * Math.pow(2, retryCount));
return fetchLicenseFromServerInternal(retryCount + 1);
}
return null;
} catch (error) {
if (error instanceof LicenseApiError) {
throw error;
}
logger.error(error, "Error while fetching license from server");
return null;
}
};
export const fetchLicense = async (): Promise<TEnterpriseLicenseDetails | null> => {
if (!env.ENTERPRISE_LICENSE_KEY) return null;
const formbricksCache = getCache();
const cachedLicense = await formbricksCache.get<TEnterpriseLicenseDetails>(
getCacheKeys().FETCH_LICENSE_CACHE_KEY
);
if (cachedLicense) {
return cachedLicense;
}
const licenseDetails = await fetchLicenseFromServerInternal();
if (licenseDetails) {
await formbricksCache.set(
getCacheKeys().FETCH_LICENSE_CACHE_KEY,
licenseDetails,
CONFIG.CACHE.FETCH_LICENSE_TTL_MS
);
}
return licenseDetails;
};
export const getEnterpriseLicense = reactCache(
async (): Promise<{
active: boolean;
features: TEnterpriseLicenseFeatures | null;
lastChecked: Date;
isPendingDowngrade: boolean;
fallbackLevel: FallbackLevel;
}> => {
validateConfig();
if (!env.ENTERPRISE_LICENSE_KEY || env.ENTERPRISE_LICENSE_KEY.length === 0) {
return {
active: false,
features: null,
lastChecked: new Date(),
isPendingDowngrade: false,
fallbackLevel: "default" as const,
};
}
const currentTime = new Date();
const liveLicenseDetails = await fetchLicense();
const previousResult = await getPreviousResult();
const fallbackLevel = getFallbackLevel(liveLicenseDetails, previousResult, currentTime);
trackFallbackUsage(fallbackLevel);
let currentLicenseState: TPreviousResult | undefined;
switch (fallbackLevel) {
case "live":
if (!liveLicenseDetails) throw new Error("Invalid state: live license expected");
currentLicenseState = {
active: liveLicenseDetails.status === "active",
features: liveLicenseDetails.features,
lastChecked: currentTime,
version: 1,
};
await setPreviousResult(currentLicenseState);
return {
active: currentLicenseState.active,
features: currentLicenseState.features,
lastChecked: currentTime,
isPendingDowngrade: false,
fallbackLevel: "live" as const,
};
case "grace":
if (!validateFallback(previousResult)) {
return handleInitialFailure(currentTime);
}
return {
active: previousResult.active,
features: previousResult.features,
lastChecked: previousResult.lastChecked,
isPendingDowngrade: true,
fallbackLevel: "grace" as const,
};
case "default":
return handleInitialFailure(currentTime);
}
return handleInitialFailure(currentTime);
}
);
export const getLicenseFeatures = async (): Promise<TEnterpriseLicenseFeatures | null> => {
try {
const licenseState = await getEnterpriseLicense();
return licenseState.active ? licenseState.features : null;
} catch (e) {
logger.error(e, "Error getting license features");
return null;
}
};
// All permission checking functions and their helpers have been moved to utils.ts

View File

@@ -1,140 +1,479 @@
import * as constants from "@/lib/constants";
import { TEnterpriseLicenseFeatures } from "@/modules/ee/license-check/types/enterprise-license";
import { Organization } from "@prisma/client";
import fetch from "node-fetch";
import { beforeEach, describe, expect, test, vi } from "vitest";
import * as licenseModule from "./license";
import {
getBiggerUploadFileSizePermission,
getIsContactsEnabled,
getIsMultiOrgEnabled,
getIsSamlSsoEnabled,
getIsSpamProtectionEnabled,
getIsSsoEnabled,
getIsTwoFactorAuthEnabled,
getLicenseFeatures,
getMultiLanguagePermission,
getOrganizationProjectsLimit,
getRemoveBrandingPermission,
getRoleManagementPermission,
getWhiteLabelPermission,
getisSsoEnabled,
} from "./utils";
// Mock declarations must be at the top level
vi.mock("@/lib/env", () => ({
env: {
ENTERPRISE_LICENSE_KEY: "test-license-key",
},
}));
vi.mock("@/lib/constants");
vi.mock("./license");
vi.mock("@/lib/constants", () => ({
E2E_TESTING: false,
ENTERPRISE_LICENSE_KEY: "test-license-key",
IS_FORMBRICKS_CLOUD: false,
IS_RECAPTCHA_CONFIGURED: true,
PROJECT_FEATURE_KEYS: {
removeBranding: "remove-branding",
whiteLabel: "white-label",
roleManagement: "role-management",
biggerUploadFileSize: "bigger-upload-file-size",
multiLanguage: "multi-language",
},
}));
vi.mock("@/lib/cache", () => ({
cache: vi.fn((fn) => fn),
revalidateTag: vi.fn(),
}));
vi.mock("next/server", () => ({
after: vi.fn(),
}));
vi.mock("node-fetch", () => ({
default: vi.fn(),
}));
describe("License Check Utils", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("Feature Permissions", () => {
const mockOrganization = {
billing: {
plan: "enterprise" as Organization["billing"]["plan"],
limits: {
projects: 3,
},
const mockOrganization = {
billing: {
plan: constants.PROJECT_FEATURE_KEYS.FREE,
limits: {
projects: 3,
monthly: {
responses: null,
miu: null,
},
} as Organization;
},
},
} as Organization;
test("getRemoveBrandingPermission", async () => {
const defaultFeatures: TEnterpriseLicenseFeatures = {
whitelabel: false,
projects: null,
isMultiOrgEnabled: false,
contacts: false,
removeBranding: false,
twoFactorAuth: false,
sso: false,
saml: false,
spamProtection: false,
ai: false,
};
const defaultLicense = {
active: true,
features: defaultFeatures,
lastChecked: new Date(),
isPendingDowngrade: false,
fallbackLevel: "live" as const,
};
describe("License Utils", () => {
beforeEach(() => {
vi.resetAllMocks();
// Set default values for constants
vi.mocked(constants).IS_FORMBRICKS_CLOUD = false;
vi.mocked(constants).IS_RECAPTCHA_CONFIGURED = true;
vi.mocked(constants).PROJECT_FEATURE_KEYS = constants.PROJECT_FEATURE_KEYS;
// Set default mocks for license
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense);
vi.mocked(licenseModule.getLicenseFeatures).mockResolvedValue(defaultFeatures);
});
describe("getRemoveBrandingPermission", () => {
test("should return true if license active and feature enabled (self-hosted)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = false;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
...defaultLicense,
features: { ...defaultFeatures, removeBranding: true },
});
const result = await getRemoveBrandingPermission(mockOrganization.billing.plan);
expect(result).toBe(false); // Default value when no license is active
expect(result).toBe(true);
});
test("getWhiteLabelPermission", async () => {
const result = await getWhiteLabelPermission(mockOrganization.billing.plan);
expect(result).toBe(false); // Default value when no license is active
test("should return false if license active but feature disabled (self-hosted)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = false;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
...defaultLicense,
features: { ...defaultFeatures, removeBranding: false },
});
const result = await getRemoveBrandingPermission(mockOrganization.billing.plan);
expect(result).toBe(false);
});
test("getRoleManagementPermission", async () => {
const result = await getRoleManagementPermission(mockOrganization.billing.plan);
expect(result).toBe(false); // Default value when no license is active
test("should return true if license active and plan is not FREE (cloud)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense);
const result = await getRemoveBrandingPermission(constants.PROJECT_FEATURE_KEYS.SCALE);
expect(result).toBe(true);
});
test("getBiggerUploadFileSizePermission", async () => {
const result = await getBiggerUploadFileSizePermission(mockOrganization.billing.plan);
expect(result).toBe(false); // Default value when no license is active
test("should return false if license active and plan is FREE (cloud)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense);
const result = await getRemoveBrandingPermission(constants.PROJECT_FEATURE_KEYS.FREE);
expect(result).toBe(false);
});
test("getMultiLanguagePermission", async () => {
const result = await getMultiLanguagePermission(mockOrganization.billing.plan);
expect(result).toBe(false); // Default value when no license is active
});
test("getIsMultiOrgEnabled", async () => {
const result = await getIsMultiOrgEnabled();
expect(typeof result).toBe("boolean");
});
test("getIsContactsEnabled", async () => {
const result = await getIsContactsEnabled();
expect(typeof result).toBe("boolean");
});
test("getIsTwoFactorAuthEnabled", async () => {
const result = await getIsTwoFactorAuthEnabled();
expect(typeof result).toBe("boolean");
});
test("getisSsoEnabled", async () => {
const result = await getisSsoEnabled();
expect(typeof result).toBe("boolean");
});
test("getIsSamlSsoEnabled", async () => {
const result = await getIsSamlSsoEnabled();
expect(typeof result).toBe("boolean");
});
test("getIsSpamProtectionEnabled", async () => {
const result = await getIsSpamProtectionEnabled(mockOrganization.billing.plan);
expect(result).toBe(false); // Default value when no license is active
});
test("getOrganizationProjectsLimit", async () => {
const result = await getOrganizationProjectsLimit(mockOrganization.billing.limits);
expect(result).toBe(3); // Default value from mock organization
test("should return false if license is inactive", async () => {
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
...defaultLicense,
active: false,
});
const result = await getRemoveBrandingPermission(mockOrganization.billing.plan);
expect(result).toBe(false);
});
});
describe("License Features", () => {
test("getLicenseFeatures returns null when no license is active", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: false,
} as any);
describe("getWhiteLabelPermission", () => {
test("should return true if license active and feature enabled (self-hosted)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = false;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
...defaultLicense,
features: { ...defaultFeatures, whitelabel: true },
});
const result = await getWhiteLabelPermission(mockOrganization.billing.plan);
expect(result).toBe(true);
});
const result = await getLicenseFeatures();
expect(result).toBeNull();
test("should return true if license active and plan is not FREE (cloud)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense);
const result = await getWhiteLabelPermission(constants.PROJECT_FEATURE_KEYS.SCALE);
expect(result).toBe(true);
});
test("should return false if license is inactive", async () => {
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
...defaultLicense,
active: false,
});
const result = await getWhiteLabelPermission(mockOrganization.billing.plan);
expect(result).toBe(false);
});
});
describe("getRoleManagementPermission", () => {
test("should return true if license active (self-hosted)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = false;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense);
const result = await getRoleManagementPermission(mockOrganization.billing.plan);
expect(result).toBe(true);
});
test("should return true if license active and plan is SCALE (cloud)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense);
const result = await getRoleManagementPermission(constants.PROJECT_FEATURE_KEYS.SCALE);
expect(result).toBe(true);
});
test("should return true if license active and plan is ENTERPRISE (cloud)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense);
const result = await getRoleManagementPermission(constants.PROJECT_FEATURE_KEYS.ENTERPRISE);
expect(result).toBe(true);
});
test("should return false if license active and plan is not SCALE or ENTERPRISE (cloud)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense);
const result = await getRoleManagementPermission(constants.PROJECT_FEATURE_KEYS.STARTUP);
expect(result).toBe(false);
});
test("should return false if license is inactive", async () => {
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
...defaultLicense,
active: false,
});
const result = await getRoleManagementPermission(mockOrganization.billing.plan);
expect(result).toBe(false);
});
});
describe("getBiggerUploadFileSizePermission", () => {
test("should return true if license active (self-hosted)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = false;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense);
const result = await getBiggerUploadFileSizePermission(mockOrganization.billing.plan);
expect(result).toBe(true);
});
test("should return true if license active and plan is not FREE (cloud)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense);
const result = await getBiggerUploadFileSizePermission(constants.PROJECT_FEATURE_KEYS.SCALE);
expect(result).toBe(true);
});
test("should return false if license active and plan is FREE (cloud)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense);
const result = await getBiggerUploadFileSizePermission(constants.PROJECT_FEATURE_KEYS.FREE);
expect(result).toBe(false);
});
test("should return false if license is inactive", async () => {
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
...defaultLicense,
active: false,
});
const result = await getBiggerUploadFileSizePermission(mockOrganization.billing.plan);
expect(result).toBe(false);
});
});
describe("getMultiLanguagePermission", () => {
test("should return true if license active (self-hosted)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = false;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense);
const result = await getMultiLanguagePermission(mockOrganization.billing.plan);
expect(result).toBe(true);
});
test("should return true if license active and plan is SCALE (cloud)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense);
const result = await getMultiLanguagePermission(constants.PROJECT_FEATURE_KEYS.SCALE);
expect(result).toBe(true);
});
test("should return false if license is inactive", async () => {
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
...defaultLicense,
active: false,
});
const result = await getMultiLanguagePermission(mockOrganization.billing.plan);
expect(result).toBe(false);
});
});
describe("getIsMultiOrgEnabled", () => {
test("should return true if feature flag isMultiOrgEnabled is true", async () => {
vi.mocked(licenseModule.getLicenseFeatures).mockResolvedValue({
...defaultFeatures,
isMultiOrgEnabled: true,
});
const result = await getIsMultiOrgEnabled();
expect(result).toBe(true);
});
test("should return false if feature flag isMultiOrgEnabled is false", async () => {
vi.mocked(licenseModule.getLicenseFeatures).mockResolvedValue({
...defaultFeatures,
isMultiOrgEnabled: false,
});
const result = await getIsMultiOrgEnabled();
expect(result).toBe(false);
});
test("should return false if licenseFeatures is null", async () => {
vi.mocked(licenseModule.getLicenseFeatures).mockResolvedValue(null);
const result = await getIsMultiOrgEnabled();
expect(result).toBe(false);
});
});
describe("getIsContactsEnabled", () => {
test("should return true if feature flag contacts is true", async () => {
vi.mocked(licenseModule.getLicenseFeatures).mockResolvedValue({
...defaultFeatures,
contacts: true,
});
const result = await getIsContactsEnabled();
expect(result).toBe(true);
});
test("should return false if feature flag contacts is false", async () => {
vi.mocked(licenseModule.getLicenseFeatures).mockResolvedValue({
...defaultFeatures,
contacts: false,
});
const result = await getIsContactsEnabled();
expect(result).toBe(false);
});
});
describe("getIsTwoFactorAuthEnabled", () => {
test("should return true if feature flag twoFactorAuth is true", async () => {
vi.mocked(licenseModule.getLicenseFeatures).mockResolvedValue({
...defaultFeatures,
twoFactorAuth: true,
});
const result = await getIsTwoFactorAuthEnabled();
expect(result).toBe(true);
});
test("should return false if feature flag twoFactorAuth is false", async () => {
vi.mocked(licenseModule.getLicenseFeatures).mockResolvedValue({
...defaultFeatures,
twoFactorAuth: false,
});
const result = await getIsTwoFactorAuthEnabled();
expect(result).toBe(false);
});
});
describe("getIsSsoEnabled", () => {
test("should return true if feature flag sso is true", async () => {
vi.mocked(licenseModule.getLicenseFeatures).mockResolvedValue({
...defaultFeatures,
sso: true,
});
const result = await getIsSsoEnabled();
expect(result).toBe(true);
});
test("should return false if feature flag sso is false", async () => {
vi.mocked(licenseModule.getLicenseFeatures).mockResolvedValue({
...defaultFeatures,
sso: false,
});
const result = await getIsSsoEnabled();
expect(result).toBe(false);
});
});
describe("getIsSamlSsoEnabled", () => {
test("should return false if IS_FORMBRICKS_CLOUD is true", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
const result = await getIsSamlSsoEnabled();
expect(result).toBe(false);
});
test("should return true if sso and saml flags are true (self-hosted)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = false;
vi.mocked(licenseModule.getLicenseFeatures).mockResolvedValue({
...defaultFeatures,
sso: true,
saml: true,
});
const result = await getIsSamlSsoEnabled();
expect(result).toBe(true);
});
test("should return false if sso is true but saml is false (self-hosted)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = false;
vi.mocked(licenseModule.getLicenseFeatures).mockResolvedValue({
...defaultFeatures,
sso: true,
saml: false,
});
const result = await getIsSamlSsoEnabled();
expect(result).toBe(false);
});
test("should return false if licenseFeatures is null (self-hosted)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = false;
vi.mocked(licenseModule.getLicenseFeatures).mockResolvedValue(null);
const result = await getIsSamlSsoEnabled();
expect(result).toBe(false);
});
});
describe("getIsSpamProtectionEnabled", () => {
test("should return false if IS_RECAPTCHA_CONFIGURED is false", async () => {
vi.mocked(constants).IS_RECAPTCHA_CONFIGURED = false;
const result = await getIsSpamProtectionEnabled(mockOrganization.billing.plan);
expect(result).toBe(false);
vi.mocked(constants).IS_RECAPTCHA_CONFIGURED = true; // reset for other tests
});
test("should return true if license active, feature enabled, and plan is SCALE (cloud)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
...defaultLicense,
features: { ...defaultFeatures, spamProtection: true },
});
const result = await getIsSpamProtectionEnabled(constants.PROJECT_FEATURE_KEYS.SCALE);
expect(result).toBe(true);
});
test("should return false if license active, feature enabled, but plan is not SCALE or ENTERPRISE (cloud)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
...defaultLicense,
features: { ...defaultFeatures, spamProtection: true },
});
const result = await getIsSpamProtectionEnabled(constants.PROJECT_FEATURE_KEYS.STARTUP);
expect(result).toBe(false);
});
test("should return true if license active and feature enabled (self-hosted)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = false;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
...defaultLicense,
features: { ...defaultFeatures, spamProtection: true },
});
const result = await getIsSpamProtectionEnabled(mockOrganization.billing.plan);
expect(result).toBe(true);
});
test("should return false if license is inactive", async () => {
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
...defaultLicense,
active: false,
});
const result = await getIsSpamProtectionEnabled(mockOrganization.billing.plan);
expect(result).toBe(false);
});
});
describe("getOrganizationProjectsLimit", () => {
test("should return limits.projects if license active (cloud)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense);
const limits = {
projects: 10,
monthly: {
responses: null,
miu: null,
},
};
const result = await getOrganizationProjectsLimit(limits);
expect(result).toBe(10);
});
test("should return Infinity if limits.projects is null and license active (cloud)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense);
const limits = {
projects: null,
monthly: {
responses: null,
miu: null,
},
};
const result = await getOrganizationProjectsLimit(limits);
expect(result).toBe(Infinity);
});
test("should return 3 if license inactive (cloud)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
...defaultLicense,
active: false,
});
const result = await getOrganizationProjectsLimit(mockOrganization.billing.limits);
expect(result).toBe(3);
});
test("should return license.features.projects if defined and license active (self-hosted)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = false;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
...defaultLicense,
features: { ...defaultFeatures, projects: 5 },
});
const result = await getOrganizationProjectsLimit(mockOrganization.billing.limits);
expect(result).toBe(5);
});
test("should return 3 if license.features.projects is undefined and license active (self-hosted)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = false;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
...defaultLicense,
features: { ...defaultFeatures, projects: null },
});
const result = await getOrganizationProjectsLimit(mockOrganization.billing.limits);
expect(result).toBe(3);
});
test("should return 3 if license inactive (self-hosted)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = false;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
...defaultLicense,
active: false,
});
const result = await getOrganizationProjectsLimit(mockOrganization.billing.limits);
expect(result).toBe(3);
});
});
});

View File

@@ -1,388 +1,102 @@
import "server-only";
import { cache, revalidateTag } from "@/lib/cache";
import {
E2E_TESTING,
ENTERPRISE_LICENSE_KEY,
IS_FORMBRICKS_CLOUD,
IS_RECAPTCHA_CONFIGURED,
PROJECT_FEATURE_KEYS,
} from "@/lib/constants";
import { env } from "@/lib/env";
import { hashString } from "@/lib/hashString";
import {
TEnterpriseLicenseDetails,
TEnterpriseLicenseFeatures,
} from "@/modules/ee/license-check/types/enterprise-license";
import { IS_FORMBRICKS_CLOUD, IS_RECAPTCHA_CONFIGURED, PROJECT_FEATURE_KEYS } from "@/lib/constants";
import { TEnterpriseLicenseFeatures } from "@/modules/ee/license-check/types/enterprise-license";
import { Organization } from "@prisma/client";
import { HttpsProxyAgent } from "https-proxy-agent";
import { after } from "next/server";
import fetch from "node-fetch";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { getEnterpriseLicense, getLicenseFeatures } from "./license";
const hashedKey = ENTERPRISE_LICENSE_KEY ? hashString(ENTERPRISE_LICENSE_KEY) : undefined;
const PREVIOUS_RESULTS_CACHE_TAG_KEY = `getPreviousResult-${hashedKey}` as const;
// Helper function for feature permissions (e.g., removeBranding, whitelabel)
const getFeaturePermission = async (
billingPlan: Organization["billing"]["plan"],
featureKey: keyof Pick<TEnterpriseLicenseFeatures, "removeBranding" | "whitelabel">
): Promise<boolean> => {
const license = await getEnterpriseLicense();
// This function is used to get the previous result of the license check from the cache
// This might seem confusing at first since we only return the default value from this function,
// but since we are using a cache and the cache key is the same, the cache will return the previous result - so this function acts as a cache getter
const getPreviousResult = (): Promise<{
active: boolean | null;
lastChecked: Date;
features: TEnterpriseLicenseFeatures | null;
}> =>
cache(
async () => ({
active: null,
lastChecked: new Date(0),
features: null,
}),
[PREVIOUS_RESULTS_CACHE_TAG_KEY],
{
tags: [PREVIOUS_RESULTS_CACHE_TAG_KEY],
}
)();
// This function is used to set the previous result of the license check to the cache so that we can use it in the next call
// Uses the same cache key as the getPreviousResult function
const setPreviousResult = async (previousResult: {
active: boolean | null;
lastChecked: Date;
features: TEnterpriseLicenseFeatures | null;
}) => {
const { lastChecked, active, features } = previousResult;
await cache(
async () => ({
active,
lastChecked,
features,
}),
[PREVIOUS_RESULTS_CACHE_TAG_KEY],
{
tags: [PREVIOUS_RESULTS_CACHE_TAG_KEY],
}
)();
after(() => {
revalidateTag(PREVIOUS_RESULTS_CACHE_TAG_KEY);
});
};
const fetchLicenseForE2ETesting = async (): Promise<{
active: boolean | null;
lastChecked: Date;
features: TEnterpriseLicenseFeatures | null;
} | null> => {
const currentTime = new Date();
try {
const previousResult = await getPreviousResult();
if (previousResult.lastChecked.getTime() === new Date(0).getTime()) {
// first call
const newResult = {
active: true,
features: {
isMultiOrgEnabled: true,
twoFactorAuth: true,
sso: true,
contacts: true,
projects: 3,
whitelabel: true,
removeBranding: true,
spamProtection: true,
ai: true,
saml: true,
},
lastChecked: currentTime,
};
await setPreviousResult(newResult);
return newResult;
} else if (currentTime.getTime() - previousResult.lastChecked.getTime() > 60 * 60 * 1000) {
// Fail after 1 hour
logger.info("E2E_TESTING is enabled. Enterprise license was revoked after 1 hour.");
return null;
}
return previousResult;
} catch (error) {
logger.error(error, "Error fetching license");
return null;
}
};
export const getEnterpriseLicense = async (): Promise<{
active: boolean;
features: TEnterpriseLicenseFeatures | null;
lastChecked: Date;
isPendingDowngrade?: boolean;
}> => {
if (!ENTERPRISE_LICENSE_KEY || ENTERPRISE_LICENSE_KEY.length === 0) {
return {
active: false,
features: null,
lastChecked: new Date(),
};
}
if (E2E_TESTING) {
const previousResult = await fetchLicenseForE2ETesting();
return {
active: previousResult?.active ?? false,
features: previousResult ? previousResult.features : null,
lastChecked: previousResult ? previousResult.lastChecked : new Date(),
};
}
// if the server responds with a boolean, we return it
// if the server errors, we return null
// null signifies an error
const license = await fetchLicense();
const isValid = license ? license.status === "active" : null;
const threeDaysInMillis = 3 * 24 * 60 * 60 * 1000;
const currentTime = new Date();
const previousResult = await getPreviousResult();
// Case: First time checking license and the server errors out
if (previousResult.active === null) {
if (isValid === null) {
const newResult = {
active: false,
features: {
isMultiOrgEnabled: false,
projects: 3,
twoFactorAuth: false,
sso: false,
whitelabel: false,
removeBranding: false,
contacts: false,
ai: false,
saml: false,
spamProtection: false,
},
lastChecked: new Date(),
};
await setPreviousResult(newResult);
return newResult;
}
}
if (isValid !== null && license) {
const newResult = {
active: isValid,
features: license.features,
lastChecked: new Date(),
};
await setPreviousResult(newResult);
return newResult;
if (IS_FORMBRICKS_CLOUD) {
return license.active && billingPlan !== PROJECT_FEATURE_KEYS.FREE;
} else {
// if result is undefined -> error
// if the last check was less than 72 hours, return the previous value:
const elapsedTime = currentTime.getTime() - previousResult.lastChecked.getTime();
if (elapsedTime < threeDaysInMillis) {
return {
active: previousResult.active !== null ? previousResult.active : false,
features: previousResult.features,
lastChecked: previousResult.lastChecked,
isPendingDowngrade: true,
};
}
// Log error only after 72 hours
logger.error("Error while checking license: The license check failed");
return {
active: false,
features: null,
lastChecked: previousResult.lastChecked,
isPendingDowngrade: true,
};
return license.active && !!license.features?.[featureKey];
}
};
export const getLicenseFeatures = async (): Promise<TEnterpriseLicenseFeatures | null> => {
const previousResult = await getPreviousResult();
if (previousResult.features) {
return previousResult.features;
} else {
const license = await fetchLicense();
if (!license || !license.features) return null;
return license.features;
}
};
export const fetchLicense = reactCache(
async (): Promise<TEnterpriseLicenseDetails | null> =>
cache(
async () => {
if (!env.ENTERPRISE_LICENSE_KEY) return null;
try {
const now = new Date();
const startOfYear = new Date(now.getFullYear(), 0, 1); // January 1st of the current year
const endOfYear = new Date(now.getFullYear() + 1, 0, 0); // December 31st of the current year
const responseCount = await prisma.response.count({
where: {
createdAt: {
gte: startOfYear,
lt: endOfYear,
},
},
});
const proxyUrl = env.HTTPS_PROXY || env.HTTP_PROXY;
const agent = proxyUrl ? new HttpsProxyAgent(proxyUrl) : undefined;
const res = await fetch("https://ee.formbricks.com/api/licenses/check", {
body: JSON.stringify({
licenseKey: ENTERPRISE_LICENSE_KEY,
usage: { responseCount: responseCount },
}),
headers: { "Content-Type": "application/json" },
method: "POST",
agent,
});
if (res.ok) {
const responseJson = (await res.json()) as {
data: TEnterpriseLicenseDetails;
};
return responseJson.data;
}
return null;
} catch (error) {
logger.error(error, "Error while checking license");
return null;
}
},
[`fetchLicense-${hashedKey}`],
{ revalidate: 60 * 60 * 24 }
)()
);
export const getRemoveBrandingPermission = async (
billingPlan: Organization["billing"]["plan"]
): Promise<boolean> => {
if (E2E_TESTING) {
const previousResult = await fetchLicenseForE2ETesting();
return previousResult?.features?.removeBranding ?? false;
}
if (IS_FORMBRICKS_CLOUD && (await getEnterpriseLicense()).active) {
return billingPlan !== PROJECT_FEATURE_KEYS.FREE;
} else {
const licenseFeatures = await getLicenseFeatures();
if (!licenseFeatures) return false;
return licenseFeatures.removeBranding;
}
return getFeaturePermission(billingPlan, "removeBranding");
};
export const getWhiteLabelPermission = async (
billingPlan: Organization["billing"]["plan"]
): Promise<boolean> => {
if (E2E_TESTING) {
const previousResult = await fetchLicenseForE2ETesting();
return previousResult?.features?.whitelabel ?? false;
}
if (IS_FORMBRICKS_CLOUD && (await getEnterpriseLicense()).active) {
return billingPlan !== PROJECT_FEATURE_KEYS.FREE;
} else {
const licenseFeatures = await getLicenseFeatures();
if (!licenseFeatures) return false;
return licenseFeatures.whitelabel;
}
return getFeaturePermission(billingPlan, "whitelabel");
};
export const getRoleManagementPermission = async (
billingPlan: Organization["billing"]["plan"]
): Promise<boolean> => {
if (E2E_TESTING) {
const previousResult = await fetchLicenseForE2ETesting();
return previousResult && previousResult.active !== null ? previousResult.active : false;
}
const license = await getEnterpriseLicense();
if (IS_FORMBRICKS_CLOUD)
return billingPlan === PROJECT_FEATURE_KEYS.SCALE || billingPlan === PROJECT_FEATURE_KEYS.ENTERPRISE;
else if (!IS_FORMBRICKS_CLOUD) return (await getEnterpriseLicense()).active;
return (
license.active &&
(billingPlan === PROJECT_FEATURE_KEYS.SCALE || billingPlan === PROJECT_FEATURE_KEYS.ENTERPRISE)
);
else if (!IS_FORMBRICKS_CLOUD) return license.active;
return false;
};
export const getBiggerUploadFileSizePermission = async (
billingPlan: Organization["billing"]["plan"]
): Promise<boolean> => {
if (IS_FORMBRICKS_CLOUD) return billingPlan !== PROJECT_FEATURE_KEYS.FREE;
else if (!IS_FORMBRICKS_CLOUD) return (await getEnterpriseLicense()).active;
const license = await getEnterpriseLicense();
if (IS_FORMBRICKS_CLOUD) return billingPlan !== PROJECT_FEATURE_KEYS.FREE && license.active;
else if (!IS_FORMBRICKS_CLOUD) return license.active;
return false;
};
export const getMultiLanguagePermission = async (
billingPlan: Organization["billing"]["plan"]
): Promise<boolean> => {
if (E2E_TESTING) {
const previousResult = await fetchLicenseForE2ETesting();
return previousResult && previousResult.active !== null ? previousResult.active : false;
}
const license = await getEnterpriseLicense();
if (IS_FORMBRICKS_CLOUD)
return billingPlan === PROJECT_FEATURE_KEYS.SCALE || billingPlan === PROJECT_FEATURE_KEYS.ENTERPRISE;
else if (!IS_FORMBRICKS_CLOUD) return (await getEnterpriseLicense()).active;
return (
license.active &&
(billingPlan === PROJECT_FEATURE_KEYS.SCALE || billingPlan === PROJECT_FEATURE_KEYS.ENTERPRISE)
);
else if (!IS_FORMBRICKS_CLOUD) return license.active;
return false;
};
export const getIsMultiOrgEnabled = async (): Promise<boolean> => {
if (E2E_TESTING) {
const previousResult = await fetchLicenseForE2ETesting();
return previousResult && previousResult.features ? previousResult.features.isMultiOrgEnabled : false;
}
// Helper function for simple boolean feature flags
const getSpecificFeatureFlag = async (
featureKey: keyof Pick<
TEnterpriseLicenseFeatures,
"isMultiOrgEnabled" | "contacts" | "twoFactorAuth" | "sso"
>
): Promise<boolean> => {
const licenseFeatures = await getLicenseFeatures();
if (!licenseFeatures) return false;
return licenseFeatures.isMultiOrgEnabled;
return typeof licenseFeatures[featureKey] === "boolean" ? licenseFeatures[featureKey] : false;
};
export const getIsMultiOrgEnabled = async (): Promise<boolean> => {
return getSpecificFeatureFlag("isMultiOrgEnabled");
};
export const getIsContactsEnabled = async (): Promise<boolean> => {
if (E2E_TESTING) {
const previousResult = await fetchLicenseForE2ETesting();
return previousResult && previousResult.features ? previousResult.features.contacts : false;
}
const licenseFeatures = await getLicenseFeatures();
if (!licenseFeatures) return false;
return licenseFeatures.contacts;
return getSpecificFeatureFlag("contacts");
};
export const getIsTwoFactorAuthEnabled = async (): Promise<boolean> => {
if (E2E_TESTING) {
const previousResult = await fetchLicenseForE2ETesting();
return previousResult && previousResult.features ? previousResult.features.twoFactorAuth : false;
}
const licenseFeatures = await getLicenseFeatures();
if (!licenseFeatures) return false;
return licenseFeatures.twoFactorAuth;
return getSpecificFeatureFlag("twoFactorAuth");
};
export const getisSsoEnabled = async (): Promise<boolean> => {
if (E2E_TESTING) {
const previousResult = await fetchLicenseForE2ETesting();
return previousResult && previousResult.features ? previousResult.features.sso : false;
}
const licenseFeatures = await getLicenseFeatures();
if (!licenseFeatures) return false;
return licenseFeatures.sso;
export const getIsSsoEnabled = async (): Promise<boolean> => {
return getSpecificFeatureFlag("sso");
};
export const getIsSamlSsoEnabled = async (): Promise<boolean> => {
if (E2E_TESTING) {
const previousResult = await fetchLicenseForE2ETesting();
return previousResult && previousResult.features
? previousResult.features.sso && previousResult.features.saml
: false;
}
if (IS_FORMBRICKS_CLOUD) {
return false;
}
@@ -396,38 +110,30 @@ export const getIsSpamProtectionEnabled = async (
): Promise<boolean> => {
if (!IS_RECAPTCHA_CONFIGURED) return false;
if (E2E_TESTING) {
const previousResult = await fetchLicenseForE2ETesting();
return previousResult?.features ? previousResult.features.spamProtection : false;
}
if (IS_FORMBRICKS_CLOUD)
return billingPlan === PROJECT_FEATURE_KEYS.SCALE || billingPlan === PROJECT_FEATURE_KEYS.ENTERPRISE;
const license = await getEnterpriseLicense();
const licenseFeatures = await getLicenseFeatures();
if (!licenseFeatures) return false;
return licenseFeatures.spamProtection;
if (IS_FORMBRICKS_CLOUD) {
return (
license.active &&
!!license.features?.spamProtection &&
(billingPlan === PROJECT_FEATURE_KEYS.SCALE || billingPlan === PROJECT_FEATURE_KEYS.ENTERPRISE)
);
}
return license.active && !!license.features?.spamProtection;
};
export const getOrganizationProjectsLimit = async (
limits: Organization["billing"]["limits"]
): Promise<number> => {
if (E2E_TESTING) {
const previousResult = await fetchLicenseForE2ETesting();
return previousResult && previousResult.features ? (previousResult.features.projects ?? Infinity) : 3;
}
const license = await getEnterpriseLicense();
let limit: number;
if (IS_FORMBRICKS_CLOUD && (await getEnterpriseLicense()).active) {
limit = limits.projects ?? Infinity;
if (IS_FORMBRICKS_CLOUD) {
limit = license.active ? (limits.projects ?? Infinity) : 3;
} else {
const licenseFeatures = await getLicenseFeatures();
if (!licenseFeatures) {
limit = 3;
} else {
limit = licenseFeatures.projects ?? Infinity;
}
limit = license.active && license.features?.projects != null ? license.features.projects : 3;
}
return limit;
};

View File

@@ -228,7 +228,7 @@ export function EditLanguage({
))}
</>
) : (
<p className="text-sm text-slate-500 italic">
<p className="text-sm italic text-slate-500">
{t("environments.project.languages.no_language_found")}
</p>
)}

View File

@@ -44,7 +44,7 @@ export function LanguageIndicator({
});
return (
<div className="absolute top-2 right-2">
<div className="absolute right-2 top-2">
<button
aria-expanded={showLanguageDropdown}
aria-haspopup="true"

View File

@@ -65,7 +65,7 @@ export function LanguageSelect({ language, onLanguageChange, disabled, locale }:
<ChevronDown className="h-4 w-4" />
</Button>
<div
className={`ring-opacity-5 absolute right-0 z-30 mt-2 space-y-1 rounded-md bg-white p-1 shadow-lg ring-1 ring-black ${isOpen ? "" : "hidden"}`}>
className={`absolute right-0 z-30 mt-2 space-y-1 rounded-md bg-white p-1 shadow-lg ring-1 ring-black ring-opacity-5 ${isOpen ? "" : "hidden"}`}>
<Input
autoComplete="off"
onChange={(e) => {

View File

@@ -71,16 +71,18 @@ export function LocalizedEditor({
key={`${questionIdx}-${selectedLanguageCode}`}
setFirstRender={setFirstRender}
setText={(v: string) => {
const translatedHtml = {
...value,
[selectedLanguageCode]: v,
};
if (questionIdx === -1) {
// welcome card
updateQuestion({ html: translatedHtml });
return;
if (localSurvey.questions[questionIdx] || questionIdx === -1) {
const translatedHtml = {
...value,
[selectedLanguageCode]: v,
};
if (questionIdx === -1) {
// welcome card
updateQuestion({ html: translatedHtml });
return;
}
updateQuestion(questionIdx, { html: translatedHtml });
}
updateQuestion(questionIdx, { html: translatedHtml });
}}
/>
{localSurvey.languages.length > 1 && (

View File

@@ -186,7 +186,7 @@ export const MultiLanguageCard: FC<MultiLanguageCardProps> = ({
<div
className={cn(
open ? "bg-slate-50" : "bg-white group-hover:bg-slate-50",
"flex w-10 items-center justify-center rounded-l-lg border-t border-b border-l group-aria-expanded:rounded-bl-none"
"flex w-10 items-center justify-center rounded-l-lg border-b border-l border-t group-aria-expanded:rounded-bl-none"
)}>
<p>
<Languages className="h-6 w-6 rounded-full bg-indigo-500 p-1 text-white" />
@@ -248,7 +248,7 @@ export const MultiLanguageCard: FC<MultiLanguageCardProps> = ({
) : (
<>
{projectLanguages.length <= 1 && (
<div className="mb-4 text-sm text-slate-500 italic">
<div className="mb-4 text-sm italic text-slate-500">
{projectLanguages.length === 0
? t("environments.surveys.edit.no_languages_found_add_first_one_to_get_started")
: t(
@@ -260,7 +260,7 @@ export const MultiLanguageCard: FC<MultiLanguageCardProps> = ({
<div className="my-4 space-y-4">
<div>
{isMultiLanguageAllowed && !isMultiLanguageActivated ? (
<div className="text-sm text-slate-500 italic">
<div className="text-sm italic text-slate-500">
{t("environments.surveys.edit.switch_multi_lanugage_on_to_get_started")}
</div>
) : null}

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