mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-21 13:40:31 -06:00
Compare commits
30 Commits
v3.10.0
...
chore/impr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4811351be | ||
|
|
9fcbe4e8c5 | ||
|
|
5aeb92eb4f | ||
|
|
00dfa629b5 | ||
|
|
3ca471b6a2 | ||
|
|
a525589186 | ||
|
|
59ed10398d | ||
|
|
25a86e31df | ||
|
|
7d6743a81a | ||
|
|
6616f62da5 | ||
|
|
a3cbc05e12 | ||
|
|
97095a627a | ||
|
|
910d257c56 | ||
|
|
0c0a008b28 | ||
|
|
9879458353 | ||
|
|
d44f1f3b4b | ||
|
|
c5d387a7e5 | ||
|
|
a6aacd5c55 | ||
|
|
57e7485564 | ||
|
|
42a38a6f47 | ||
|
|
34bb9c2127 | ||
|
|
6442b5e4aa | ||
|
|
dde5a55446 | ||
|
|
13e615a798 | ||
|
|
9c81961b0b | ||
|
|
c1a35e2d75 | ||
|
|
13415c75c2 | ||
|
|
300557a0e6 | ||
|
|
fcbb97010c | ||
|
|
6be46b16b2 |
@@ -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"
|
||||
84
.github/dependabot.yml
vendored
84
.github/dependabot.yml
vendored
@@ -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"
|
||||
5
.github/workflows/chromatic.yml
vendored
5
.github/workflows/chromatic.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/dependency-review.yml
vendored
2
.github/workflows/dependency-review.yml
vendored
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
23
.github/workflows/e2e.yml
vendored
23
.github/workflows/e2e.yml
vendored
@@ -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
|
||||
|
||||
5
.github/workflows/release-docker-github.yml
vendored
5
.github/workflows/release-docker-github.yml
vendored
@@ -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 }}
|
||||
|
||||
2
.github/workflows/release-helm-chart.yml
vendored
2
.github/workflows/release-helm-chart.yml
vendored
@@ -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
|
||||
|
||||
|
||||
4
.github/workflows/semantic-pull-requests.yml
vendored
4
.github/workflows/semantic-pull-requests.yml
vendored
@@ -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: |
|
||||
|
||||
2
.github/workflows/sonarqube.yml
vendored
2
.github/workflows/sonarqube.yml
vendored
@@ -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 }}
|
||||
|
||||
18
.github/workflows/terraform-plan-and-apply.yml
vendored
18
.github/workflows/terraform-plan-and-apply.yml
vendored
@@ -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"
|
||||
|
||||
|
||||
@@ -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}`}>
|
||||
|
||||
@@ -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`}>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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={"/"}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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={"/"}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -33,6 +33,7 @@ export const responseSelection = {
|
||||
singleUseId: true,
|
||||
language: true,
|
||||
displayId: true,
|
||||
endingId: true,
|
||||
contact: {
|
||||
select: {
|
||||
id: true,
|
||||
|
||||
@@ -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 && {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
58
apps/web/lib/utils/rate-limit.test.ts
Normal file
58
apps/web/lib/utils/rate-limit.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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" && (
|
||||
|
||||
@@ -111,6 +111,7 @@ export const updateResponse = async (
|
||||
responseCache.revalidate({
|
||||
id: updatedResponse.id,
|
||||
surveyId: updatedResponse.surveyId,
|
||||
...(updatedResponse.singleUseId ? { singleUseId: updatedResponse.singleUseId } : {}),
|
||||
});
|
||||
|
||||
responseNoteCache.revalidate({
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
117
apps/web/modules/cache/lib/service.test.ts
vendored
Normal file
117
apps/web/modules/cache/lib/service.test.ts
vendored
Normal 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
55
apps/web/modules/cache/lib/service.ts
vendored
Normal 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;
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>();
|
||||
|
||||
475
apps/web/modules/ee/license-check/lib/license.test.ts
Normal file
475
apps/web/modules/ee/license-check/lib/license.test.ts
Normal 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);
|
||||
395
apps/web/modules/ee/license-check/lib/license.ts
Normal file
395
apps/web/modules/ee/license-check/lib/license.ts
Normal 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
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user