Compare commits

..

19 Commits

Author SHA1 Message Date
Matti Nannt
6616f62da5 chore: remove unused prisma accelerate extension (#5682) 2025-05-14 12:34:08 +02:00
dependabot[bot]
a3cbc05e12 chore(deps): bump marocchino/sticky-pull-request-comment from 2.9.1 to 2.9.2 (#5760)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-14 12:29:43 +02:00
dependabot[bot]
97095a627a chore(deps): bump aws-actions/configure-aws-credentials from 4.0.2 to 4.2.0 (#5757)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-14 12:27:59 +02:00
dependabot[bot]
910d257c56 chore(deps): bump SonarSource/sonarqube-scan-action from 5.1.0 to 5.2.0 (#5758)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-14 12:27:39 +02:00
dependabot[bot]
0c0a008b28 chore(deps): bump actions/dependency-review-action from 4.6.0 to 4.7.0 (#5759)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-14 12:27:14 +02:00
dependabot[bot]
9879458353 chore(deps): bump borchero/terraform-plan-comment from 2.4.0 to 2.4.1 (#5761)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-14 12:26:46 +02:00
Matti Nannt
d44f1f3b4b chore: remove dependabot action and return to simple repository setup (#5806) 2025-05-14 12:23:38 +02:00
Dhruwang Jariwala
c5d387a7e5 fix: multi choice issues (#5802) 2025-05-14 09:59:22 +00:00
Anshuman Pandey
a6aacd5c55 fix: transaction timeout and max contacts (#5415)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-05-14 11:01:39 +02:00
Piyush Gupta
57e7485564 fix: reliability issues (#5781) 2025-05-13 16:40:16 +00:00
Matti Nannt
42a38a6f47 chore: fix environment surveys filter not working properly (#5793) 2025-05-13 13:34:27 +02:00
Dhruwang Jariwala
34bb9c2127 fix: multi select question (#5792)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-05-13 13:12:22 +02:00
Dhruwang Jariwala
6442b5e4aa fix: Vietnamese char interpretation (#5747) 2025-05-13 11:54:30 +02:00
Piyush Gupta
dde5a55446 fix: CTA and consent question breaking the survey editor (#5745) 2025-05-13 04:18:34 +00:00
Piyush Gupta
13e615a798 fix: duplicate switch cases (#5752)
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-05-12 15:00:55 +00:00
victorvhs017
9c81961b0b chore: remove redundant code (#5751) 2025-05-12 12:23:05 +00:00
Matti Nannt
c1a35e2d75 chore: introduce new reliable cache for enterprise license check (#5740)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-05-12 10:41:53 +02:00
Piyush Gupta
13415c75c2 docs: adds auth-behaviour docs (#5743) 2025-05-12 05:28:12 +00:00
Johannes
300557a0e6 docs: update ee feature table (#5744) 2025-05-11 22:10:40 -07:00
63 changed files with 2534 additions and 1425 deletions

View File

@@ -172,7 +172,6 @@ ENTERPRISE_LICENSE_KEY=
# Automatically assign new users to a specific organization and role within that organization
# Insert an existing organization id or generate a valid CUID for a new one at https://www.getuniqueid.com/cuid (e.g. cjld2cjxh0000qzrmn831i7rn)
# (Role Management is an Enterprise feature)
# DEFAULT_ORGANIZATION_ROLE=owner
# AUTH_SSO_DEFAULT_TEAM_ID=
# AUTH_SKIP_INVITE_FOR_SSO=

View File

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

View File

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

View File

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

View File

@@ -11,6 +11,8 @@ on:
required: false
PLAYWRIGHT_SERVICE_URL:
required: false
ENTERPRISE_LICENSE_KEY:
required: true
# Add other secrets if necessary
workflow_dispatch:
@@ -48,15 +50,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 +79,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 +93,18 @@ jobs:
# pnpm prisma migrate deploy
pnpm db:migrate:dev
- name: Check for Enterprise License
run: |
LICENSE_KEY=$(grep '^ENTERPRISE_LICENSE_KEY=' .env | cut -d'=' -f2-)
if [ -z "$LICENSE_KEY" ]; then
echo "::error::ENTERPRISE_LICENSE_KEY in .env is empty. Please check your secret configuration."
exit 1
fi
echo "License key length: ${#LICENSE_KEY}"
- name: Run App
run: |
echo "Starting app with enterprise license..."
NODE_ENV=test pnpm start --filter=@formbricks/web | tee app.log 2>&1 &
sleep 10 # Optional: gives some buffer for the app to start
for attempt in {1..10}; do

View File

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

View File

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

View File

@@ -41,7 +41,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 +71,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 }}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -100,7 +100,11 @@ 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,
@@ -116,7 +120,11 @@ 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,

View File

@@ -20,6 +20,8 @@ export const getSurveysForEnvironmentState = reactCache(
const surveysPrisma = await prisma.survey.findMany({
where: {
environmentId,
type: "app",
status: "inProgress",
},
orderBy: {
createdAt: "desc",

97
apps/web/cache-handler.js Normal file
View File

@@ -0,0 +1,97 @@
// This cache handler follows the @fortedigital/nextjs-cache-handler example
// Read more at: https://github.com/fortedigital/nextjs-cache-handler
// @neshca/cache-handler dependencies
const { CacheHandler } = require("@neshca/cache-handler");
const createLruHandler = require("@neshca/cache-handler/local-lru").default;
// Next/Redis dependencies
const { createClient } = require("redis");
const { PHASE_PRODUCTION_BUILD } = require("next/constants");
// @fortedigital/nextjs-cache-handler dependencies
const createRedisHandler = require("@fortedigital/nextjs-cache-handler/redis-strings").default;
const { Next15CacheHandler } = require("@fortedigital/nextjs-cache-handler/next-15-cache-handler");
// Usual onCreation from @neshca/cache-handler
CacheHandler.onCreation(() => {
// Important - It's recommended to use global scope to ensure only one Redis connection is made
// This ensures only one instance get created
if (global.cacheHandlerConfig) {
return global.cacheHandlerConfig;
}
// Important - It's recommended to use global scope to ensure only one Redis connection is made
// This ensures new instances are not created in a race condition
if (global.cacheHandlerConfigPromise) {
return global.cacheHandlerConfigPromise;
}
// If REDIS_URL is not set, we will use LRU cache only
if (!process.env.REDIS_URL) {
const lruCache = createLruHandler();
return { handlers: [lruCache] };
}
// Main promise initializing the handler
global.cacheHandlerConfigPromise = (async () => {
/** @type {import("redis").RedisClientType | null} */
let redisClient = null;
// eslint-disable-next-line turbo/no-undeclared-env-vars -- Next.js will inject this variable
if (PHASE_PRODUCTION_BUILD !== process.env.NEXT_PHASE) {
const settings = {
url: process.env.REDIS_URL, // Make sure you configure this variable
pingInterval: 10000,
};
try {
redisClient = createClient(settings);
redisClient.on("error", (e) => {
console.error("Redis error", e);
global.cacheHandlerConfig = null;
global.cacheHandlerConfigPromise = null;
});
} catch (error) {
console.error("Failed to create Redis client:", error);
}
}
if (redisClient) {
try {
console.info("Connecting Redis client...");
await redisClient.connect();
console.info("Redis client connected.");
} catch (error) {
console.error("Failed to connect Redis client:", error);
await redisClient
.disconnect()
.catch(() => console.error("Failed to quit the Redis client after failing to connect."));
}
}
const lruCache = createLruHandler();
if (!redisClient?.isReady) {
console.error("Failed to initialize caching layer.");
global.cacheHandlerConfigPromise = null;
global.cacheHandlerConfig = { handlers: [lruCache] };
return global.cacheHandlerConfig;
}
const redisCacheHandler = createRedisHandler({
client: redisClient,
keyPrefix: "nextjs:",
});
global.cacheHandlerConfigPromise = null;
global.cacheHandlerConfig = {
handlers: [redisCacheHandler],
};
return global.cacheHandlerConfig;
})();
return global.cacheHandlerConfigPromise;
});
module.exports = new Next15CacheHandler();

View File

@@ -1,76 +0,0 @@
import { Next15CacheHandler } from "@fortedigital/nextjs-cache-handler/next-15-cache-handler";
import createRedisHandler from "@fortedigital/nextjs-cache-handler/redis-strings";
import { CacheHandler } from "@neshca/cache-handler";
import createLruHandler from "@neshca/cache-handler/local-lru";
import { createClient } from "redis";
// Function to create a timeout promise
const createTimeoutPromise = (ms, rejectReason) => {
return new Promise((_, reject) => setTimeout(() => reject(new Error(rejectReason)), ms));
};
CacheHandler.onCreation(async () => {
let client;
if (process.env.REDIS_URL) {
try {
// Create a Redis client.
client = createClient({
url: process.env.REDIS_URL,
});
// Redis won't work without error handling.
client.on("error", () => {});
} catch (error) {
console.warn("Failed to create Redis client:", error);
}
if (client) {
try {
// Wait for the client to connect with a timeout of 5000ms.
const connectPromise = client.connect();
const timeoutPromise = createTimeoutPromise(5000, "Redis connection timed out"); // 5000ms timeout
await Promise.race([connectPromise, timeoutPromise]);
} catch (error) {
console.warn("Failed to connect Redis client:", error);
console.warn("Disconnecting the Redis client...");
// Try to disconnect the client to stop it from reconnecting.
client
.disconnect()
.then(() => {
console.info("Redis client disconnected.");
})
.catch(() => {
console.warn("Failed to quit the Redis client after failing to connect.");
});
}
}
}
/** @type {import("@neshca/cache-handler").Handler | null} */
let handler;
if (client?.isReady) {
const redisHandlerOptions = {
client,
keyPrefix: "fb:",
timeoutMs: 1000,
};
// Create the `redis-stack` Handler if the client is available and connected.
handler = await createRedisHandler(redisHandlerOptions);
} else {
// Fallback to LRU handler if Redis client is not available.
// The application will still work, but the cache will be in memory only and not shared.
handler = createLruHandler();
console.log("Using LRU handler for caching.");
}
return {
handlers: [handler],
};
});
const cacheHandler = new Next15CacheHandler();
export default cacheHandler;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,15 +5,14 @@ import { verifyInviteToken } from "@/lib/jwt";
import { createMembership } from "@/lib/membership/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { createBrevoCustomer } from "@/modules/auth/lib/brevo";
import { getUserByEmail, updateUser } from "@/modules/auth/lib/user";
import { createUser } from "@/modules/auth/lib/user";
import { createUser, getUserByEmail, updateUser } from "@/modules/auth/lib/user";
import { getIsValidInviteToken } from "@/modules/auth/signup/lib/invite";
import { TOidcNameFields, TSamlNameFields } from "@/modules/auth/types/auth";
import {
getIsMultiOrgEnabled,
getIsSamlSsoEnabled,
getIsSsoEnabled,
getRoleManagementPermission,
getisSsoEnabled,
} from "@/modules/ee/license-check/lib/utils";
import { getFirstOrganization } from "@/modules/ee/sso/lib/organization";
import { createDefaultTeamMembership, getOrganizationByTeamId } from "@/modules/ee/sso/lib/team";
@@ -32,7 +31,7 @@ export const handleSsoCallback = async ({
account: Account;
callbackUrl: string;
}) => {
const isSsoEnabled = await getisSsoEnabled();
const isSsoEnabled = await getIsSsoEnabled();
if (!isSsoEnabled) {
return false;
}

View File

@@ -7,8 +7,8 @@ import type { TSamlNameFields } from "@/modules/auth/types/auth";
import {
getIsMultiOrgEnabled,
getIsSamlSsoEnabled,
getIsSsoEnabled,
getRoleManagementPermission,
getisSsoEnabled,
} from "@/modules/ee/license-check/lib/utils";
import { createDefaultTeamMembership, getOrganizationByTeamId } from "@/modules/ee/sso/lib/team";
import { beforeEach, describe, expect, test, vi } from "vitest";
@@ -42,7 +42,7 @@ vi.mock("@/modules/auth/signup/lib/invite", () => ({
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getIsSamlSsoEnabled: vi.fn(),
getisSsoEnabled: vi.fn(),
getIsSsoEnabled: vi.fn(),
getRoleManagementPermission: vi.fn(),
getIsMultiOrgEnabled: vi.fn(),
}));
@@ -93,7 +93,6 @@ vi.mock("@/lib/constants", () => ({
SKIP_INVITE_FOR_SSO: 0,
DEFAULT_TEAM_ID: "team-123",
DEFAULT_ORGANIZATION_ID: "org-123",
DEFAULT_ORGANIZATION_ROLE: "member",
ENCRYPTION_KEY: "test-encryption-key-32-chars-long",
}));
@@ -103,7 +102,7 @@ describe("handleSsoCallback", () => {
vi.resetModules();
// Default mock implementations
vi.mocked(getisSsoEnabled).mockResolvedValue(true);
vi.mocked(getIsSsoEnabled).mockResolvedValue(true);
vi.mocked(getIsSamlSsoEnabled).mockResolvedValue(true);
vi.mocked(findMatchingLocale).mockResolvedValue("en-US");
vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(true);
@@ -123,7 +122,7 @@ describe("handleSsoCallback", () => {
describe("Early return conditions", () => {
test("should return false if SSO is not enabled", async () => {
vi.mocked(getisSsoEnabled).mockResolvedValue(false);
vi.mocked(getIsSsoEnabled).mockResolvedValue(false);
const result = await handleSsoCallback({
user: mockUser,

View File

@@ -1,5 +1,5 @@
import { findMatchingLocale } from "@/lib/utils/locale";
import { getIsSamlSsoEnabled, getisSsoEnabled } from "@/modules/ee/license-check/lib/utils";
import { getIsSamlSsoEnabled, getIsSsoEnabled } from "@/modules/ee/license-check/lib/utils";
import { getTranslate } from "@/tolgee/server";
import "@testing-library/jest-dom/vitest";
import { render, screen } from "@testing-library/react";
@@ -47,7 +47,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",
@@ -55,7 +54,7 @@ vi.mock("@/lib/constants", () => ({
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getisSsoEnabled: vi.fn(),
getIsSsoEnabled: vi.fn(),
getIsSamlSsoEnabled: vi.fn(),
}));
@@ -78,7 +77,7 @@ vi.mock("@/modules/auth/signup/components/signup-form", () => ({
describe("SignupPage", () => {
beforeEach(() => {
vi.mocked(getisSsoEnabled).mockResolvedValue(true);
vi.mocked(getIsSsoEnabled).mockResolvedValue(true);
vi.mocked(getIsSamlSsoEnabled).mockResolvedValue(false);
vi.mocked(findMatchingLocale).mockResolvedValue("en-US");
vi.mocked(getTranslate).mockResolvedValue((key) => key);

View File

@@ -17,7 +17,7 @@ import {
} from "@/lib/constants";
import { findMatchingLocale } from "@/lib/utils/locale";
import { SignupForm } from "@/modules/auth/signup/components/signup-form";
import { getIsSamlSsoEnabled, getisSsoEnabled } from "@/modules/ee/license-check/lib/utils";
import { getIsSamlSsoEnabled, getIsSsoEnabled } from "@/modules/ee/license-check/lib/utils";
import { getTranslate } from "@/tolgee/server";
import { Metadata } from "next";
@@ -29,7 +29,7 @@ export const metadata: Metadata = {
export const SignupPage = async () => {
const locale = await findMatchingLocale();
const [isSsoEnabled, isSamlSsoEnabled] = await Promise.all([getisSsoEnabled(), getIsSamlSsoEnabled()]);
const [isSsoEnabled, isSamlSsoEnabled] = await Promise.all([getIsSsoEnabled(), getIsSamlSsoEnabled()]);
const samlSsoEnabled = isSamlSsoEnabled && SAML_OAUTH_ENABLED;

View File

@@ -48,7 +48,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",

View File

@@ -50,7 +50,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",

View File

@@ -99,7 +99,7 @@ export const FileUploadQuestionForm = ({
const removeExtension = (event, index: number) => {
event.preventDefault();
if (question.allowedFileExtensions) {
const updatedExtensions = [...question?.allowedFileExtensions];
const updatedExtensions = [...(question.allowedFileExtensions || [])];
updatedExtensions.splice(index, 1);
// Ensure array is set to undefined if empty, matching toggle behavior
updateQuestion(questionIdx, {
@@ -178,7 +178,7 @@ export const FileUploadQuestionForm = ({
</Button>
)}
</div>
<div className="mt-6 mb-8 space-y-6">
<div className="mb-8 mt-6 space-y-6">
<AdvancedOptionToggle
isChecked={question.allowMultipleFiles}
onToggle={() => updateQuestion(questionIdx, { allowMultipleFiles: !question.allowMultipleFiles })}
@@ -218,7 +218,7 @@ export const FileUploadQuestionForm = ({
updateQuestion(questionIdx, { maxSizeInMB: parseInt(e.target.value, 10) });
}}
className="mr-2 ml-2 inline w-20 bg-white text-center text-sm"
className="ml-2 mr-2 inline w-20 bg-white text-center text-sm"
/>
MB
</p>

View File

@@ -35,7 +35,6 @@ export const ResponseOptionsCard = ({
const autoComplete = localSurvey.autoComplete !== null;
const [runOnDateToggle, setRunOnDateToggle] = useState(false);
const [closeOnDateToggle, setCloseOnDateToggle] = useState(false);
useState;
const [surveyClosedMessageToggle, setSurveyClosedMessageToggle] = useState(false);
const [verifyEmailToggle, setVerifyEmailToggle] = useState(localSurvey.isVerifyEmailEnabled);
const [recaptchaToggle, setRecaptchaToggle] = useState(localSurvey.recaptcha?.enabled ?? false);
@@ -318,7 +317,7 @@ export const ResponseOptionsCard = ({
)}>
<Collapsible.CollapsibleTrigger asChild className="h-full w-full cursor-pointer">
<div className="inline-flex px-4 py-4">
<div className="flex items-center pr-5 pl-2">
<div className="flex items-center pl-2 pr-5">
<CheckIcon
strokeWidth={3}
className="h-7 w-7 rounded-full border border-green-300 bg-green-100 p-1.5 text-green-600"
@@ -356,7 +355,7 @@ export const ResponseOptionsCard = ({
value={localSurvey.autoComplete?.toString()}
onChange={handleInputResponse}
onBlur={handleInputResponseBlur}
className="mr-2 ml-2 inline w-20 bg-white text-center text-sm"
className="ml-2 mr-2 inline w-20 bg-white text-center text-sm"
/>
{t("environments.surveys.edit.completed_responses")}
</p>
@@ -451,7 +450,7 @@ export const ResponseOptionsCard = ({
<Input
autoFocus
id="heading"
className="mt-2 mb-4 bg-white"
className="mb-4 mt-2 bg-white"
name="heading"
defaultValue={surveyClosedMessage.heading}
onChange={(e) => handleClosedSurveyMessageChange({ heading: e.target.value })}
@@ -506,7 +505,7 @@ export const ResponseOptionsCard = ({
<Input
autoFocus
id="heading"
className="mt-2 mb-4 bg-white"
className="mb-4 mt-2 bg-white"
name="heading"
value={singleUseMessage.heading}
onChange={(e) => handleSingleUseSurveyMessageChange({ heading: e.target.value })}
@@ -514,7 +513,7 @@ export const ResponseOptionsCard = ({
<Label htmlFor="headline">{t("environments.surveys.edit.subheading")}</Label>
<Input
className="mt-2 mb-4 bg-white"
className="mb-4 mt-2 bg-white"
id="subheading"
name="subheading"
value={singleUseMessage.subheading}

View File

@@ -17,7 +17,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,
},
});

View File

@@ -16,7 +16,7 @@ const getHostname = (url) => {
const nextConfig = {
assetPrefix: process.env.ASSET_PREFIX_URL || undefined,
cacheHandler: require.resolve("./cache-handler.mjs"),
cacheHandler: require.resolve("./cache-handler.js"),
cacheMaxMemorySize: 0, // disable default in-memory caching
output: "standalone",
poweredByHeader: false,

View File

@@ -36,6 +36,7 @@
"@hookform/resolvers": "5.0.1",
"@intercom/messenger-js-sdk": "0.0.14",
"@json2csv/node": "7.0.6",
"@keyv/redis": "4.4.0",
"@lexical/code": "0.31.0",
"@lexical/link": "0.31.0",
"@lexical/list": "0.31.0",
@@ -83,6 +84,7 @@
"@vercel/og": "0.6.8",
"bcryptjs": "3.0.2",
"boring-avatars": "1.11.2",
"cache-manager": "6.4.3",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"cmdk": "1.1.1",
@@ -95,6 +97,7 @@
"https-proxy-agent": "7.0.6",
"jiti": "2.4.2",
"jsonwebtoken": "9.0.2",
"keyv": "5.3.3",
"lexical": "0.31.0",
"lodash": "4.17.21",
"lru-cache": "11.1.0",

View File

@@ -260,6 +260,7 @@
"group": "Auth & SSO",
"icon": "lock",
"pages": [
"self-hosting/auth-behavior",
"self-hosting/configuration/auth-sso/open-id-connect",
"self-hosting/configuration/auth-sso/azure-ad-oauth",
"self-hosting/configuration/auth-sso/google-oauth",

View File

@@ -88,8 +88,11 @@ The Enterprise Edition allows us to fund the development of Formbricks sustainab
| Teams & access roles | ❌ | ✅ |
| Contact management & segments | ❌ | ✅ |
| Multi-language surveys | ❌ | ✅ |
| OAuth SSO (AzureAD, Google, OpenID) | ❌ | ✅ |
| OIDC SSO (AzureAD, Google, OpenID) | ❌ | ✅ |
| SAML SSO | ❌ | ✅ |
| Spam protection (ReCaptchaV3) | ❌ | ✅ |
| Two-factor authentication | ❌ | ✅ |
| Custom 'Project' count | ❌ | ✅ |
| White-glove onboarding | ❌ | ✅ |
| Support SLAs | ❌ | ✅ |

View File

@@ -0,0 +1,64 @@
---
title: "Authentication Behavior"
description: "Learn how authentication and user invitation work in self-hosted Formbricks deployments."
icon: "user"
---
## Overview
In self-hosted Formbricks, user management and authentication can be customized using environment variables. By default, self-hosted instances have user signup disabled, and only organization owners or admins can invite new users. The behavior of the authentication and invitation flow can be further controlled using the following environment variables:
- `AUTH_SKIP_INVITE_FOR_SSO`
- `AUTH_DEFAULT_TEAM_ID`
## License Requirement for Role Management and SSO Behavior
<Note>
To control advanced role management features and environment-based SSO behavior, your self-hosted Formbricks
instance must have a valid enterprise license.
</Note>
## Environment Variables
### `AUTH_SKIP_INVITE_FOR_SSO`
- **Type:** Boolean (0 or 1)
- **Default:** 0 (invite required)
- **Description:**
- When set to `1`, users who sign up via SSO (Single Sign-On) providers (such as Google, Azure AD, SAML, or OIDC) can create an account without requiring an invitation.
- When set to `0` (default), all users—including those signing up via SSO—must be invited by an organization owner or admin before they can create an account.
- **Use case:**
- Set this to `1` if you want to allow anyone with access to your SSO provider to join your Formbricks instance without a manual invite.
- Keep it at `0` for stricter access control, where only invited users can join, regardless of SSO.
### `AUTH_DEFAULT_TEAM_ID`
- **Type:** String (Team ID, a valid cuid)
- **Default:** None (must be set if you want to use default team assignment)
- **Description:**
- When a new user is invited or signs up (if allowed), they will automatically be added to the team with the ID specified in this variable.
- This is useful for onboarding users into a default team, ensuring they have access to relevant projects and resources immediately after joining.
- **Use case:**
- Set this to the ID of your default team to streamline onboarding for new users.
- If not set, users will not be automatically assigned to any team upon signup or invite acceptance.
## Example `.env` Configuration
```env
# Allow SSO users to join without invite
AUTH_SKIP_INVITE_FOR_SSO=1
# Automatically assign new users to this team
AUTH_DEFAULT_TEAM_ID=team-123
```
Refer to the [Environment Variables documentation](./configuration/environment-variables) for a full list and details.
---
For more information on SSO setup, see:
- [Google OAuth](./configuration/auth-sso/google-oauth)
- [Azure AD OAuth](./configuration/auth-sso/azure-ad-oauth)
- [Open ID Connect](./configuration/auth-sso/open-id-connect)
- [SAML SSO](./configuration/auth-sso/saml-sso)

View File

@@ -55,7 +55,6 @@ These variables are present inside your machine's docker-compose file. Restart t
| TELEMETRY_DISABLED | Disables telemetry if set to 1. | optional | |
| DEFAULT_BRAND_COLOR | Default brand color for your app (Can be overwritten from the UI as well). | optional | #64748b |
| DEFAULT_ORGANIZATION_ID | Automatically assign new users to a specific organization when joining | optional | |
| DEFAULT_ORGANIZATION_ROLE | Role of the user in the default organization. | optional | owner |
| OIDC_DISPLAY_NAME | Display name for Custom OpenID Connect Provider | optional | |
| OIDC_CLIENT_ID | Client ID for Custom OpenID Connect Provider | optional (required if OIDC auth is enabled) | |
| OIDC_CLIENT_SECRET | Secret for Custom OpenID Connect Provider | optional (required if OIDC auth is enabled) | |

View File

@@ -66,8 +66,5 @@
"budgetPercentIncreaseRed": 20,
"minimumChangeThreshold": 0,
"showDetails": true
},
"dependencies": {
"@changesets/cli": "2.29.3"
}
}

View File

@@ -27,9 +27,8 @@
"@formbricks/logger": "workspace:*",
"@paralleldrive/cuid2": "2.2.2",
"@prisma/client": "6.7.0",
"@prisma/extension-accelerate": "1.3.0",
"zod-openapi": "4.2.4",
"zod": "3.24.4"
"zod": "3.24.4",
"zod-openapi": "4.2.4"
},
"devDependencies": {
"@formbricks/config-typescript": "workspace:*",

View File

@@ -1,5 +1,4 @@
import { PrismaClient } from "@prisma/client";
import { withAccelerate } from "@prisma/extension-accelerate";
const prismaClientSingleton = () => {
return new PrismaClient({
@@ -7,7 +6,7 @@ const prismaClientSingleton = () => {
...(process.env.DEBUG === "1" && {
log: ["query", "info"],
}),
}).$extends(withAccelerate());
});
};
type PrismaClientSingleton = ReturnType<typeof prismaClientSingleton>;

View File

@@ -1,5 +1,5 @@
import Pino, { type Logger, type LoggerOptions, stdSerializers } from "pino";
import { LOG_LEVELS, type TLogLevel, ZLogLevel } from "../types/logger";
import { type TLogLevel, ZLogLevel } from "../types/logger";
const IS_PRODUCTION = !process.env.NODE_ENV || process.env.NODE_ENV === "production";
const getLogLevel = (): TLogLevel => {
@@ -58,12 +58,17 @@ const productionConfig: LoggerOptions = {
const logger: Logger = IS_PRODUCTION ? Pino(productionConfig) : Pino(developmentConfig);
LOG_LEVELS.forEach((level) => {
logger[level] = logger[level].bind(logger);
});
// Ensure all log levels are properly bound
const boundLogger = {
debug: logger.debug.bind(logger),
info: logger.info.bind(logger),
warn: logger.warn.bind(logger),
error: logger.error.bind(logger),
fatal: logger.fatal.bind(logger),
};
const extendedLogger = {
...logger,
...boundLogger,
withContext: (context: Record<string, unknown>) => logger.child(context),
request: (req: Request) =>
logger.child({

View File

@@ -1,6 +1,7 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/preact";
import userEvent from "@testing-library/user-event";
import { ComponentChildren } from "preact";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import type { TSurveyMultipleChoiceQuestion } from "@formbricks/types/surveys/types";
import { MultipleChoiceMultiQuestion } from "./multiple-choice-multi-question";
@@ -31,11 +32,13 @@ vi.mock("@/components/general/subheader", () => ({
}));
vi.mock("@/components/general/question-media", () => ({
QuestionMedia: () => <div data-testid="question-media" />,
QuestionMedia: ({ imgUrl, videoUrl }: { imgUrl?: string; videoUrl?: string }) => (
<div data-testid="question-media" data-img-url={imgUrl} data-video-url={videoUrl} />
),
}));
vi.mock("@/components/wrappers/scrollable-container", () => ({
ScrollableContainer: ({ children }: { children: React.ReactNode }) => (
ScrollableContainer: ({ children }: { children: ComponentChildren }) => (
<div data-testid="scrollable-container">{children}</div>
),
}));
@@ -52,6 +55,18 @@ vi.mock("@/lib/i18n", () => ({
},
}));
// Mock the utils for shuffling
vi.mock("@/lib/utils", () => ({
cn: (...args: any[]) => args.filter(Boolean).join(" "),
getShuffledChoicesIds: vi.fn((choices: Array<{ id: string }>, option: string) => {
if (option === "all") {
// Return in reverse to simulate shuffling
return choices.map((choice: { id: string }) => choice.id).reverse();
}
return choices.map((choice: { id: string }) => choice.id);
}),
}));
describe("MultipleChoiceMultiQuestion", () => {
afterEach(() => {
cleanup();
@@ -135,25 +150,6 @@ describe("MultipleChoiceMultiQuestion", () => {
expect(onChange3).toHaveBeenCalledWith({ q1: ["Option 2"] });
});
test("handles 'Other' option correctly", async () => {
const onChange = vi.fn();
render(<MultipleChoiceMultiQuestion {...defaultProps} onChange={onChange} />);
// When clicking Other, it calls onChange with an empty string first
await userEvent.click(screen.getByLabelText("Other"));
expect(screen.getByPlaceholderText("Please specify")).toBeInTheDocument();
expect(onChange).toHaveBeenCalledWith({ q1: [""] });
// Clear the mock to focus on typing behavior
onChange.mockClear();
// Enter text in the field and use fireEvent directly which doesn't trigger onChange for each character
const otherInput = screen.getByPlaceholderText("Please specify");
fireEvent.change(otherInput, { target: { value: "Custom response" } });
expect(onChange).toHaveBeenCalledWith({ q1: ["Custom response"] });
});
test("handles form submission", async () => {
const onSubmit = vi.fn();
const { container } = render(
@@ -186,25 +182,159 @@ describe("MultipleChoiceMultiQuestion", () => {
expect(screen.queryByTestId("back-button")).not.toBeInTheDocument();
});
test("renders media when available", () => {
const questionWithMedia = {
test("renders image media when available", () => {
const questionWithImage = {
...defaultProps.question,
imageUrl: "https://example.com/image.jpg",
};
render(<MultipleChoiceMultiQuestion {...defaultProps} question={questionWithMedia} />);
render(<MultipleChoiceMultiQuestion {...defaultProps} question={questionWithImage} />);
expect(screen.getByTestId("question-media")).toBeInTheDocument();
expect(screen.getByTestId("question-media")).toHaveAttribute(
"data-img-url",
"https://example.com/image.jpg"
);
});
test("handles shuffled choices correctly", () => {
test("renders video media when available", () => {
const questionWithVideo = {
...defaultProps.question,
videoUrl: "https://example.com/video.mp4",
};
render(<MultipleChoiceMultiQuestion {...defaultProps} question={questionWithVideo} />);
expect(screen.getByTestId("question-media")).toBeInTheDocument();
expect(screen.getByTestId("question-media")).toHaveAttribute(
"data-video-url",
"https://example.com/video.mp4"
);
});
test("handles shuffled choices correctly with 'all' option", () => {
const shuffledQuestion = {
...defaultProps.question,
shuffleOption: "all",
} as TSurveyMultipleChoiceQuestion;
render(<MultipleChoiceMultiQuestion {...defaultProps} question={shuffledQuestion} />);
// All options should still be rendered regardless of shuffle
expect(screen.getByLabelText("Option 1")).toBeInTheDocument();
expect(screen.getByLabelText("Option 2")).toBeInTheDocument();
expect(screen.getByLabelText("Option 3")).toBeInTheDocument();
expect(screen.getByLabelText("Other")).toBeInTheDocument();
});
test("handles keyboard accessibility with spacebar", async () => {
render(<MultipleChoiceMultiQuestion {...defaultProps} />);
// Find the label for the first option
const option1Label = screen.getByText("Option 1").closest("label");
expect(option1Label).toBeInTheDocument();
// Simulate pressing spacebar on the label
fireEvent.keyDown(option1Label!, { key: " " });
// Check if onChange was called with the correct value
expect(defaultProps.onChange).toHaveBeenCalledWith({ q1: ["Option 1"] });
});
test("handles deselecting 'Other' option", async () => {
const onChange = vi.fn();
// Initial render with 'Other' already selected and a custom value
const { rerender } = render(
<MultipleChoiceMultiQuestion {...defaultProps} value={["Custom response"]} onChange={onChange} />
);
// Verify 'Other' is checked using id
const otherCheckbox = screen.getByRole("checkbox", { name: "Other" });
expect(otherCheckbox).toBeInTheDocument();
expect(otherCheckbox).toBeChecked();
// Also verify the input has the custom value
expect(screen.getByDisplayValue("Custom response")).toBeInTheDocument();
// Click to deselect the 'Other' option
await userEvent.click(otherCheckbox);
// Check if onChange was called with empty array
expect(onChange).toHaveBeenCalledWith({ q1: [] });
// Rerender to update the component with new value
rerender(<MultipleChoiceMultiQuestion {...defaultProps} value={[]} onChange={onChange} />);
// Verify the input field is not displayed anymore
expect(screen.queryByPlaceholderText("Please specify")).not.toBeInTheDocument();
});
test("initializes with 'Other' selected when value doesn't match any choice", () => {
render(<MultipleChoiceMultiQuestion {...defaultProps} value={["Custom answer"]} />);
// Verify 'Other' is checked
const otherCheckbox = screen.getByRole("checkbox", { name: "Other" });
expect(otherCheckbox).toBeChecked();
// Verify the input has the custom value
expect(screen.getByDisplayValue("Custom answer")).toBeInTheDocument();
});
test("combines regular choices and 'Other' value on submission", async () => {
const onSubmit = vi.fn();
const { container } = render(
<MultipleChoiceMultiQuestion
{...defaultProps}
value={["Option 1", "Custom answer"]}
onSubmit={onSubmit}
/>
);
// Verify both Option 1 and Other are checked
const option1Checkbox = screen.getByRole("checkbox", { name: "Option 1" });
const otherCheckbox = screen.getByRole("checkbox", { name: "Other" });
expect(option1Checkbox).toBeChecked();
expect(otherCheckbox).toBeChecked();
// Get the form and submit it
const form = container.querySelector("form");
expect(form).toBeInTheDocument();
fireEvent.submit(form!);
// Check if onSubmit was called with both values
expect(onSubmit).toHaveBeenCalledWith({ q1: ["Option 1", "Custom answer"] }, { questionId: "ttc-value" });
});
test("handles required validation correctly", async () => {
const onSubmit = vi.fn();
// Create a non-required question
const nonRequiredQuestion = {
...defaultProps.question,
required: false,
};
const { container, rerender } = render(
<MultipleChoiceMultiQuestion
{...defaultProps}
question={nonRequiredQuestion}
value={[]}
onSubmit={onSubmit}
/>
);
// Get the form and submit it with empty selection
const form = container.querySelector("form");
expect(form).toBeInTheDocument();
fireEvent.submit(form!);
// Check if onSubmit was called even with empty value
expect(onSubmit).toHaveBeenCalledWith({ q1: [] }, { questionId: "ttc-value" });
// Now test with required=true
vi.clearAllMocks();
rerender(<MultipleChoiceMultiQuestion {...defaultProps} value={[]} onSubmit={onSubmit} />);
// Check if at least one checkbox has the required attribute
const checkboxes = screen.getAllByRole("checkbox");
const hasRequiredCheckbox = checkboxes.some((checkbox) => checkbox.hasAttribute("required"));
expect(hasRequiredCheckbox).toBe(true);
});
});

View File

@@ -61,22 +61,17 @@ export function MultipleChoiceMultiQuestion({
.map((item) => getLocalizedValue(item.label, languageCode)),
[question, languageCode]
);
const [otherSelected, setOtherSelected] = useState<boolean>(false);
const [otherValue, setOtherValue] = useState("");
useEffect(() => {
setOtherSelected(
Boolean(value) &&
(Array.isArray(value) ? value : [value]).some((item) => {
return !getChoicesWithoutOtherLabels().includes(item);
})
);
setOtherValue(
(Array.isArray(value) &&
value.filter((v) => !question.choices.find((c) => c.label[languageCode] === v))[0]) ||
""
);
}, [question.id, getChoicesWithoutOtherLabels, question.choices, value, languageCode]);
const [otherSelected, setOtherSelected] = useState<boolean>(
Boolean(value) &&
(Array.isArray(value) ? value : [value]).some((item) => {
return !getChoicesWithoutOtherLabels().includes(item);
})
);
const [otherValue, setOtherValue] = useState(
(Array.isArray(value) &&
value.filter((v) => !question.choices.find((c) => c.label[languageCode] === v))[0]) ||
""
);
const questionChoices = useMemo(() => {
if (!question.choices) {
@@ -135,6 +130,16 @@ export function MultipleChoiceMultiQuestion({
onChange({ [question.id]: [] }); // if not array, make it an array
};
const getIsRequired = () => {
const responseValues = [...value];
if (otherSelected && otherValue) {
responseValues.push(otherValue);
}
return question.required && Array.isArray(responseValues) && responseValues.length
? false
: question.required;
};
return (
<form
key={question.id}
@@ -143,10 +148,11 @@ export function MultipleChoiceMultiQuestion({
const newValue = value.filter((item) => {
return getChoicesWithoutOtherLabels().includes(item) || item === otherValue;
}); // filter out all those values which are either in getChoicesWithoutOtherLabels() (i.e. selected by checkbox) or the latest entered otherValue
if (otherValue && otherSelected && !newValue.includes(otherValue)) newValue.push(otherValue);
onChange({ [question.id]: newValue });
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedTtcObj);
onSubmit({ [question.id]: value }, updatedTtcObj);
onSubmit({ [question.id]: newValue }, updatedTtcObj);
}}
className="fb-w-full">
<ScrollableContainer>
@@ -208,11 +214,7 @@ export function MultipleChoiceMultiQuestion({
Array.isArray(value) &&
value.includes(getLocalizedValue(choice.label, languageCode))
}
required={
question.required && Array.isArray(value) && value.length
? false
: question.required
}
required={getIsRequired()}
/>
<span id={`${choice.id}-label`} className="fb-ml-3 fb-mr-3 fb-grow fb-font-medium">
{getLocalizedValue(choice.label, languageCode)}
@@ -225,9 +227,7 @@ export function MultipleChoiceMultiQuestion({
<label
tabIndex={isCurrent ? 0 : -1}
className={cn(
value.includes(getLocalizedValue(otherOption.label, languageCode))
? "fb-border-brand fb-bg-input-bg-selected fb-z-10"
: "fb-border-border",
otherSelected ? "fb-border-brand fb-bg-input-bg-selected fb-z-10" : "fb-border-border",
"fb-text-heading focus-within:fb-border-brand fb-bg-input-bg focus-within:fb-bg-input-bg-selected hover:fb-bg-input-bg-selected fb-rounded-custom fb-relative fb-flex fb-cursor-pointer fb-flex-col fb-border fb-p-4 focus:fb-outline-none"
)}
onKeyDown={(e) => {
@@ -248,12 +248,15 @@ export function MultipleChoiceMultiQuestion({
className="fb-border-brand fb-text-brand fb-h-4 fb-w-4 fb-border focus:fb-ring-0 focus:fb-ring-offset-0"
aria-labelledby={`${otherOption.id}-label`}
onChange={() => {
setOtherSelected(!otherSelected);
if (!value.includes(otherValue)) {
addItem(otherValue);
} else {
removeItem(otherValue);
if (otherSelected) {
setOtherValue("");
onChange({
[question.id]: value.filter((item) => {
return getChoicesWithoutOtherLabels().includes(item);
}),
});
}
setOtherSelected(!otherSelected);
}}
checked={otherSelected}
/>
@@ -270,9 +273,9 @@ export function MultipleChoiceMultiQuestion({
name={question.id}
tabIndex={isCurrent ? 0 : -1}
value={otherValue}
pattern=".*\S+.*"
onChange={(e) => {
setOtherValue(e.currentTarget.value);
addItem(e.currentTarget.value);
}}
className="placeholder:fb-text-placeholder fb-border-border fb-bg-survey-bg fb-text-heading focus:fb-ring-focus fb-rounded-custom fb-mt-3 fb-flex fb-h-10 fb-w-full fb-border fb-px-3 fb-py-2 fb-text-sm focus:fb-outline-none focus:fb-ring-2 focus:fb-ring-offset-2 disabled:fb-cursor-not-allowed disabled:fb-opacity-50"
placeholder={
@@ -280,7 +283,15 @@ export function MultipleChoiceMultiQuestion({
}
required={question.required}
aria-labelledby={`${otherOption.id}-label`}
pattern=".*\S+.*"
onBlur={() => {
const newValue = value.filter((item) => {
return getChoicesWithoutOtherLabels().includes(item);
});
if (otherValue && otherSelected) {
newValue.push(otherValue);
onChange({ [question.id]: newValue });
}
}}
/>
) : null}
</label>

View File

@@ -225,6 +225,7 @@ export function MultipleChoiceSingleQuestion({
id={`${otherOption.id}-label`}
dir="auto"
name={question.id}
pattern=".*\S+.*"
value={value}
onChange={(e) => {
onChange({ [question.id]: e.currentTarget.value });
@@ -238,7 +239,6 @@ export function MultipleChoiceSingleQuestion({
required={question.required}
aria-labelledby={`${otherOption.id}-label`}
maxLength={250}
pattern=".*\S+.*"
/>
) : null}
</label>

View File

@@ -0,0 +1,32 @@
import { describe, expect, test } from "vitest";
import { TI18nString } from "@formbricks/types/surveys/types";
import { getLocalizedValue } from "./i18n";
describe("i18n", () => {
describe("getLocalizedValue", () => {
test("should return empty string for undefined value", () => {
expect(getLocalizedValue(undefined, "en")).toBe("");
});
test("should return empty string for non-i18n string", () => {
expect(getLocalizedValue("not an i18n string" as any, "en")).toBe("");
});
test("should return default value when language not found", () => {
const i18nString: TI18nString = {
default: "Default text",
en: "English text",
};
expect(getLocalizedValue(i18nString, "fr")).toBe("Default text");
});
test("should return localized value when language found", () => {
const i18nString: TI18nString = {
default: "Default text",
en: "English text",
fr: "French text",
};
expect(getLocalizedValue(i18nString, "fr")).toBe("French text");
});
});
});

497
pnpm-lock.yaml generated
View File

@@ -7,10 +7,6 @@ settings:
importers:
.:
dependencies:
'@changesets/cli':
specifier: 2.29.3
version: 2.29.3
devDependencies:
'@azure/microsoft-playwright-testing':
specifier: 1.0.0-beta.7
@@ -163,6 +159,9 @@ importers:
'@json2csv/node':
specifier: 7.0.6
version: 7.0.6
'@keyv/redis':
specifier: 4.4.0
version: 4.4.0(keyv@5.3.3)
'@lexical/code':
specifier: 0.31.0
version: 0.31.0
@@ -304,6 +303,9 @@ importers:
boring-avatars:
specifier: 1.11.2
version: 1.11.2
cache-manager:
specifier: 6.4.3
version: 6.4.3
class-variance-authority:
specifier: 0.7.1
version: 0.7.1
@@ -340,6 +342,9 @@ importers:
jsonwebtoken:
specifier: 9.0.2
version: 9.0.2
keyv:
specifier: 5.3.3
version: 5.3.3
lexical:
specifier: 0.31.0
version: 0.31.0
@@ -626,9 +631,6 @@ importers:
'@prisma/client':
specifier: 6.7.0
version: 6.7.0(prisma@6.7.0(typescript@5.8.3))(typescript@5.8.3)
'@prisma/extension-accelerate':
specifier: 1.3.0
version: 1.3.0(@prisma/client@6.7.0(prisma@6.7.0(typescript@5.8.3))(typescript@5.8.3))
zod:
specifier: 3.24.4
version: 3.24.4
@@ -1342,61 +1344,6 @@ packages:
'@calcom/embed-snippet@1.3.3':
resolution: {integrity: sha512-pqqKaeLB8R6BvyegcpI9gAyY6Xyx1bKYfWvIGOvIbTpguWyM1BBBVcT9DCeGe8Zw7Ujp5K56ci7isRUrT2Uadg==}
'@changesets/apply-release-plan@7.0.12':
resolution: {integrity: sha512-EaET7As5CeuhTzvXTQCRZeBUcisoYPDDcXvgTE/2jmmypKp0RC7LxKj/yzqeh/1qFTZI7oDGFcL1PHRuQuketQ==}
'@changesets/assemble-release-plan@6.0.7':
resolution: {integrity: sha512-vS5J92Rm7ZUcrvtu6WvggGWIdohv8s1/3ypRYQX8FsPO+KPDx6JaNC3YwSfh2umY/faGGfNnq42A7PRT0aZPFw==}
'@changesets/changelog-git@0.2.1':
resolution: {integrity: sha512-x/xEleCFLH28c3bQeQIyeZf8lFXyDFVn1SgcBiR2Tw/r4IAWlk1fzxCEZ6NxQAjF2Nwtczoen3OA2qR+UawQ8Q==}
'@changesets/cli@2.29.3':
resolution: {integrity: sha512-TNhKr6Loc7I0CSD9LpAyVNSxWBHElXVmmvQYIZQvaMan5jddmL7geo3+08Wi7ImgHFVNB0Nhju/LzXqlrkoOxg==}
hasBin: true
'@changesets/config@3.1.1':
resolution: {integrity: sha512-bd+3Ap2TKXxljCggI0mKPfzCQKeV/TU4yO2h2C6vAihIo8tzseAn2e7klSuiyYYXvgu53zMN1OeYMIQkaQoWnA==}
'@changesets/errors@0.2.0':
resolution: {integrity: sha512-6BLOQUscTpZeGljvyQXlWOItQyU71kCdGz7Pi8H8zdw6BI0g3m43iL4xKUVPWtG+qrrL9DTjpdn8eYuCQSRpow==}
'@changesets/get-dependents-graph@2.1.3':
resolution: {integrity: sha512-gphr+v0mv2I3Oxt19VdWRRUxq3sseyUpX9DaHpTUmLj92Y10AGy+XOtV+kbM6L/fDcpx7/ISDFK6T8A/P3lOdQ==}
'@changesets/get-release-plan@4.0.11':
resolution: {integrity: sha512-4DZpsewsc/1m5TArVg5h1c0U94am+cJBnu3izAM3yYIZr8+zZwa3AXYdEyCNURzjx0wWr80u/TWoxshbwdZXOA==}
'@changesets/get-version-range-type@0.4.0':
resolution: {integrity: sha512-hwawtob9DryoGTpixy1D3ZXbGgJu1Rhr+ySH2PvTLHvkZuQ7sRT4oQwMh0hbqZH1weAooedEjRsbrWcGLCeyVQ==}
'@changesets/git@3.0.4':
resolution: {integrity: sha512-BXANzRFkX+XcC1q/d27NKvlJ1yf7PSAgi8JG6dt8EfbHFHi4neau7mufcSca5zRhwOL8j9s6EqsxmT+s+/E6Sw==}
'@changesets/logger@0.1.1':
resolution: {integrity: sha512-OQtR36ZlnuTxKqoW4Sv6x5YIhOmClRd5pWsjZsddYxpWs517R0HkyiefQPIytCVh4ZcC5x9XaG8KTdd5iRQUfg==}
'@changesets/parse@0.4.1':
resolution: {integrity: sha512-iwksMs5Bf/wUItfcg+OXrEpravm5rEd9Bf4oyIPL4kVTmJQ7PNDSd6MDYkpSJR1pn7tz/k8Zf2DhTCqX08Ou+Q==}
'@changesets/pre@2.0.2':
resolution: {integrity: sha512-HaL/gEyFVvkf9KFg6484wR9s0qjAXlZ8qWPDkTyKF6+zqjBe/I2mygg3MbpZ++hdi0ToqNUF8cjj7fBy0dg8Ug==}
'@changesets/read@0.6.5':
resolution: {integrity: sha512-UPzNGhsSjHD3Veb0xO/MwvasGe8eMyNrR/sT9gR8Q3DhOQZirgKhhXv/8hVsI0QpPjR004Z9iFxoJU6in3uGMg==}
'@changesets/should-skip-package@0.1.2':
resolution: {integrity: sha512-qAK/WrqWLNCP22UDdBTMPH5f41elVDlsNyat180A33dWxuUDyNpg6fPi/FyTZwRriVjg0L8gnjJn2F9XAoF0qw==}
'@changesets/types@4.1.0':
resolution: {integrity: sha512-LDQvVDv5Kb50ny2s25Fhm3d9QSZimsoUGBsUioj6MC3qbMUCuC8GPIvk/M6IvXx3lYhAs0lwWUQLb+VIEUCECw==}
'@changesets/types@6.1.0':
resolution: {integrity: sha512-rKQcJ+o1nKNgeoYRHKOS07tAMNd3YSN0uHaJOZYjBAgxfV7TUE7JE+z4BzZdQwb5hKaYbayKN5KrYV7ODb2rAA==}
'@changesets/write@0.4.0':
resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==}
'@chromatic-com/storybook@3.2.6':
resolution: {integrity: sha512-FDmn5Ry2DzQdik+eq2sp/kJMMT36Ewe7ONXUXM2Izd97c7r6R/QyGli8eyh/F0iyqVvbLveNYFyF0dBOJNwLqw==}
engines: {node: '>=16.0.0', yarn: '>=1.22.18'}
@@ -1894,6 +1841,15 @@ packages:
'@json2csv/plainjs@7.0.6':
resolution: {integrity: sha512-4Md7RPDCSYpmW1HWIpWBOqCd4vWfIqm53S3e/uzQ62iGi7L3r34fK/8nhOMEe+/eVfCx8+gdSCt1d74SlacQHw==}
'@keyv/redis@4.4.0':
resolution: {integrity: sha512-n/KEj3S7crVkoykggqsMUtcjNGvjagGPlJYgO/r6m9hhGZfhp1txJElHxcdJ1ANi/LJoBuOSILj15g6HD2ucqQ==}
engines: {node: '>= 18'}
peerDependencies:
keyv: ^5.3.3
'@keyv/serialize@1.0.3':
resolution: {integrity: sha512-qnEovoOp5Np2JDGonIDL6Ayihw0RhnRh6vxPuHo4RDn1UOzwEo4AeIfpL6UGIrsceWrCMiVPgwRjbHu4vYFc3g==}
'@lexical/clipboard@0.31.0':
resolution: {integrity: sha512-oOZgDnzD68uZMy99H1dx6Le/uJNxmvXgbHb2Qp6Zrej7OUnCV/X9kzVSgLqWKytgrjsyUjTLjRhkQgwLhu61sA==}
@@ -1977,12 +1933,6 @@ packages:
'@libsql/sqlite3@0.3.1':
resolution: {integrity: sha512-KOOBUuKDjqzteM6QA0W1vnZDfOt5MNMyo2yr4TaH1RcCd7Fsts4lpzfty6FmE1d6QDrRxjRq2ZciO6VdQ9ZF3A==}
'@manypkg/find-root@1.1.0':
resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==}
'@manypkg/get-packages@1.1.3':
resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==}
'@mdx-js/react@3.1.0':
resolution: {integrity: sha512-QjHtSaoameoalGnKDT3FoIl4+9RwyTmo9ZJGBdLOks/YOiWHoRDI3PUwEzOE7kEmGcV3AFcp9K6dYu9rEuKLAQ==}
peerDependencies:
@@ -2491,12 +2441,6 @@ packages:
'@prisma/engines@6.7.0':
resolution: {integrity: sha512-3wDMesnOxPrOsq++e5oKV9LmIiEazFTRFZrlULDQ8fxdub5w4NgRBoxtWbvXmj2nJVCnzuz6eFix3OhIqsZ1jw==}
'@prisma/extension-accelerate@1.3.0':
resolution: {integrity: sha512-W41D+kWejVJvMFEh45oYIU0VY+lIE3hh5vyHLHxgpyOfF+EHzSr8EIPT9kPRJg1FTYR7WGutXi64nyaNFUJ06A==}
engines: {node: '>=16'}
peerDependencies:
'@prisma/client': '>=4.16.1'
'@prisma/fetch-engine@6.7.0':
resolution: {integrity: sha512-zLlAGnrkmioPKJR4Yf7NfW3hftcvqeNNEHleMZK9yX7RZSkhmxacAYyfGsCcqRt47jiZ7RKdgE0Wh2fWnm7WsQ==}
@@ -4227,9 +4171,6 @@ packages:
'@types/node-fetch@2.6.12':
resolution: {integrity: sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==}
'@types/node@12.20.55':
resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==}
'@types/node@22.14.0':
resolution: {integrity: sha512-Kmpl+z84ILoG+3T/zQFyAJsU6EPTmOCj8/2+83fSN6djd6I4o7uOuGIH6vq3PrjY5BGitSbFuMN18j3iknubbA==}
@@ -5064,10 +5005,6 @@ packages:
resolution: {integrity: sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ==}
engines: {node: '>=12.0.0'}
better-path-resolve@1.0.0:
resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==}
engines: {node: '>=4'}
big.js@5.2.2:
resolution: {integrity: sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==}
@@ -5161,6 +5098,9 @@ packages:
resolution: {integrity: sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ==}
engines: {node: '>= 10'}
cache-manager@6.4.3:
resolution: {integrity: sha512-VV5eq/QQ5rIVix7/aICO4JyvSeEv9eIQuKL5iFwgM2BrcYoE0A/D1mNsAHJAsB0WEbNdBlKkn6Tjz6fKzh/cKQ==}
call-bind-apply-helpers@1.0.2:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'}
@@ -5227,9 +5167,6 @@ packages:
resolution: {integrity: sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==}
engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
chardet@0.7.0:
resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==}
check-error@2.1.1:
resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==}
engines: {node: '>= 16'}
@@ -5261,10 +5198,6 @@ packages:
resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==}
engines: {node: '>=6.0'}
ci-info@3.9.0:
resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==}
engines: {node: '>=8'}
ci-info@4.2.0:
resolution: {integrity: sha512-cYY9mypksY8NRqgDB1XD1RiJL338v/551niynFTGkZOO2LHuB2OmOYxDIe/ttN9AHwrqdum1360G3ald0W9kCg==}
engines: {node: '>=8'}
@@ -5653,10 +5586,6 @@ packages:
detect-europe-js@0.1.2:
resolution: {integrity: sha512-lgdERlL3u0aUdHocoouzT10d9I89VVhk0qNRmll7mXdGfJT1/wqZ2ZLA4oJAjeACPY5fT1wsbq2AT+GkuInsow==}
detect-indent@6.1.0:
resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==}
engines: {node: '>=8'}
detect-indent@7.0.1:
resolution: {integrity: sha512-Mc7QhQ8s+cLrnUfU/Ji94vG/r8M26m8f++vyres4ZoojaRDpZ1eSIh/EpzLNwlWuvzSZ3UbDFspjFvTDXe6e/g==}
engines: {node: '>=12.20'}
@@ -5781,10 +5710,6 @@ packages:
resolution: {integrity: sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==}
engines: {node: '>=10.13.0'}
enquirer@2.4.1:
resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==}
engines: {node: '>=8.6'}
entities@4.5.0:
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
engines: {node: '>=0.12'}
@@ -6147,13 +6072,6 @@ packages:
extend@3.0.2:
resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
extendable-error@0.1.7:
resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==}
external-editor@3.1.0:
resolution: {integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==}
engines: {node: '>=4'}
fast-copy@3.0.2:
resolution: {integrity: sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==}
@@ -6314,14 +6232,6 @@ packages:
resolution: {integrity: sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==}
engines: {node: '>=14.14'}
fs-extra@7.0.1:
resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==}
engines: {node: '>=6 <7 || >=8'}
fs-extra@8.1.0:
resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==}
engines: {node: '>=6 <7 || >=8'}
fs-minipass@2.1.0:
resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==}
engines: {node: '>= 8'}
@@ -6606,10 +6516,6 @@ packages:
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
engines: {node: '>= 14'}
human-id@4.1.1:
resolution: {integrity: sha512-3gKm/gCSUipeLsRYZbbdA1BD83lBoWUkZ7G9VFrhWPAU76KwYo5KR8V28bpoPm/ygy0x5/GCbpRQdY7VLYCoIg==}
hasBin: true
human-signals@2.1.0:
resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==}
engines: {node: '>=10.17.0'}
@@ -6629,10 +6535,6 @@ packages:
hyphenate-style-name@1.1.0:
resolution: {integrity: sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==}
iconv-lite@0.4.24:
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
engines: {node: '>=0.10.0'}
iconv-lite@0.6.3:
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
engines: {node: '>=0.10.0'}
@@ -6858,10 +6760,6 @@ packages:
resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==}
engines: {node: '>= 0.4'}
is-subdir@1.2.0:
resolution: {integrity: sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw==}
engines: {node: '>=4'}
is-symbol@1.1.1:
resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==}
engines: {node: '>= 0.4'}
@@ -6886,10 +6784,6 @@ packages:
resolution: {integrity: sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==}
engines: {node: '>=12.13'}
is-windows@1.0.2:
resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==}
engines: {node: '>=0.10.0'}
is-wsl@2.2.0:
resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==}
engines: {node: '>=8'}
@@ -6998,10 +6892,6 @@ packages:
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
js-yaml@3.14.1:
resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==}
hasBin: true
js-yaml@4.1.0:
resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==}
hasBin: true
@@ -7058,9 +6948,6 @@ packages:
engines: {node: '>=6'}
hasBin: true
jsonfile@4.0.0:
resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==}
jsonfile@6.1.0:
resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==}
@@ -7090,6 +6977,9 @@ packages:
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
keyv@5.3.3:
resolution: {integrity: sha512-Rwu4+nXI9fqcxiEHtbkvoes2X+QfkTRo1TMkPfwzipGsJlJO/z69vqB4FNl9xJ3xCpAcbkvmEabZfPzrwN3+gQ==}
kolorist@1.8.0:
resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==}
@@ -7259,9 +7149,6 @@ packages:
lodash.once@4.1.1:
resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
lodash.startcase@4.4.0:
resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==}
lodash@4.17.21:
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
@@ -7572,10 +7459,6 @@ packages:
engines: {node: '>=10.0.0'}
hasBin: true
mri@1.2.0:
resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
engines: {node: '>=4'}
ms@2.0.0:
resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==}
@@ -7884,16 +7767,9 @@ packages:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
os-tmpdir@1.0.2:
resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==}
engines: {node: '>=0.10.0'}
otplib@12.0.1:
resolution: {integrity: sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg==}
outdent@0.5.0:
resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==}
own-keys@1.0.1:
resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
engines: {node: '>= 0.4'}
@@ -7902,10 +7778,6 @@ packages:
resolution: {integrity: sha512-wB3wfAxZpk2AzOfUMJNL+d36xothRSyj8EXOa4f6GMqYDN9BJaaSISbsk+wS9abmnebVw95C2Kb5t85UmpCxuw==}
engines: {node: '>=4'}
p-filter@2.1.0:
resolution: {integrity: sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw==}
engines: {node: '>=8'}
p-limit@2.3.0:
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
engines: {node: '>=6'}
@@ -7922,10 +7794,6 @@ packages:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
engines: {node: '>=10'}
p-map@2.1.0:
resolution: {integrity: sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==}
engines: {node: '>=6'}
p-map@4.0.0:
resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==}
engines: {node: '>=10'}
@@ -7937,9 +7805,6 @@ packages:
package-json-from-dist@1.0.1:
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
package-manager-detector@0.2.11:
resolution: {integrity: sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==}
pako@0.2.9:
resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==}
@@ -8070,10 +7935,6 @@ packages:
resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==}
engines: {node: '>=0.10.0'}
pify@4.0.1:
resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==}
engines: {node: '>=6'}
pino-abstract-transport@2.0.0:
resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==}
@@ -8301,11 +8162,6 @@ packages:
prettier-plugin-svelte:
optional: true
prettier@2.8.8:
resolution: {integrity: sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==}
engines: {node: '>=10.13.0'}
hasBin: true
prettier@3.5.3:
resolution: {integrity: sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==}
engines: {node: '>=14'}
@@ -8586,10 +8442,6 @@ packages:
resolution: {integrity: sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==}
engines: {node: '>=8'}
read-yaml-file@1.1.0:
resolution: {integrity: sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==}
engines: {node: '>=6'}
readable-stream@2.3.8:
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
@@ -8674,10 +8526,6 @@ packages:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
resolve-from@5.0.0:
resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==}
engines: {node: '>=8'}
resolve-pkg-maps@1.0.0:
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
@@ -8979,9 +8827,6 @@ packages:
sparse-bitfield@3.0.3:
resolution: {integrity: sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==}
spawndamnit@3.0.1:
resolution: {integrity: sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg==}
spdx-correct@3.2.0:
resolution: {integrity: sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==}
@@ -9257,10 +9102,6 @@ packages:
resolution: {integrity: sha512-9AvErXXQTd6l7TDd5EmM+nxbOGyhnmdbp/8c3pw+tjaiSXW9usME90ET/CRG1LN1Y9tPMtz/p83z4Q97B4DDpw==}
engines: {node: '>=18'}
term-size@2.2.1:
resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==}
engines: {node: '>=8'}
terser-webpack-plugin@5.3.14:
resolution: {integrity: sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==}
engines: {node: '>= 10.13.0'}
@@ -9346,10 +9187,6 @@ packages:
resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==}
hasBin: true
tmp@0.0.33:
resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==}
engines: {node: '>=0.6.0'}
to-regex-range@5.0.1:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'}
@@ -9634,10 +9471,6 @@ packages:
unique-slug@2.0.2:
resolution: {integrity: sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w==}
universalify@0.1.2:
resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==}
engines: {node: '>= 4.0.0'}
universalify@2.0.1:
resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==}
engines: {node: '>= 10.0.0'}
@@ -11436,148 +11269,6 @@ snapshots:
dependencies:
'@calcom/embed-core': 1.5.3
'@changesets/apply-release-plan@7.0.12':
dependencies:
'@changesets/config': 3.1.1
'@changesets/get-version-range-type': 0.4.0
'@changesets/git': 3.0.4
'@changesets/should-skip-package': 0.1.2
'@changesets/types': 6.1.0
'@manypkg/get-packages': 1.1.3
detect-indent: 6.1.0
fs-extra: 7.0.1
lodash.startcase: 4.4.0
outdent: 0.5.0
prettier: 2.8.8
resolve-from: 5.0.0
semver: 7.7.1
'@changesets/assemble-release-plan@6.0.7':
dependencies:
'@changesets/errors': 0.2.0
'@changesets/get-dependents-graph': 2.1.3
'@changesets/should-skip-package': 0.1.2
'@changesets/types': 6.1.0
'@manypkg/get-packages': 1.1.3
semver: 7.7.1
'@changesets/changelog-git@0.2.1':
dependencies:
'@changesets/types': 6.1.0
'@changesets/cli@2.29.3':
dependencies:
'@changesets/apply-release-plan': 7.0.12
'@changesets/assemble-release-plan': 6.0.7
'@changesets/changelog-git': 0.2.1
'@changesets/config': 3.1.1
'@changesets/errors': 0.2.0
'@changesets/get-dependents-graph': 2.1.3
'@changesets/get-release-plan': 4.0.11
'@changesets/git': 3.0.4
'@changesets/logger': 0.1.1
'@changesets/pre': 2.0.2
'@changesets/read': 0.6.5
'@changesets/should-skip-package': 0.1.2
'@changesets/types': 6.1.0
'@changesets/write': 0.4.0
'@manypkg/get-packages': 1.1.3
ansi-colors: 4.1.3
ci-info: 3.9.0
enquirer: 2.4.1
external-editor: 3.1.0
fs-extra: 7.0.1
mri: 1.2.0
p-limit: 2.3.0
package-manager-detector: 0.2.11
picocolors: 1.1.1
resolve-from: 5.0.0
semver: 7.7.1
spawndamnit: 3.0.1
term-size: 2.2.1
'@changesets/config@3.1.1':
dependencies:
'@changesets/errors': 0.2.0
'@changesets/get-dependents-graph': 2.1.3
'@changesets/logger': 0.1.1
'@changesets/types': 6.1.0
'@manypkg/get-packages': 1.1.3
fs-extra: 7.0.1
micromatch: 4.0.8
'@changesets/errors@0.2.0':
dependencies:
extendable-error: 0.1.7
'@changesets/get-dependents-graph@2.1.3':
dependencies:
'@changesets/types': 6.1.0
'@manypkg/get-packages': 1.1.3
picocolors: 1.1.1
semver: 7.7.1
'@changesets/get-release-plan@4.0.11':
dependencies:
'@changesets/assemble-release-plan': 6.0.7
'@changesets/config': 3.1.1
'@changesets/pre': 2.0.2
'@changesets/read': 0.6.5
'@changesets/types': 6.1.0
'@manypkg/get-packages': 1.1.3
'@changesets/get-version-range-type@0.4.0': {}
'@changesets/git@3.0.4':
dependencies:
'@changesets/errors': 0.2.0
'@manypkg/get-packages': 1.1.3
is-subdir: 1.2.0
micromatch: 4.0.8
spawndamnit: 3.0.1
'@changesets/logger@0.1.1':
dependencies:
picocolors: 1.1.1
'@changesets/parse@0.4.1':
dependencies:
'@changesets/types': 6.1.0
js-yaml: 3.14.1
'@changesets/pre@2.0.2':
dependencies:
'@changesets/errors': 0.2.0
'@changesets/types': 6.1.0
'@manypkg/get-packages': 1.1.3
fs-extra: 7.0.1
'@changesets/read@0.6.5':
dependencies:
'@changesets/git': 3.0.4
'@changesets/logger': 0.1.1
'@changesets/parse': 0.4.1
'@changesets/types': 6.1.0
fs-extra: 7.0.1
p-filter: 2.1.0
picocolors: 1.1.1
'@changesets/should-skip-package@0.1.2':
dependencies:
'@changesets/types': 6.1.0
'@manypkg/get-packages': 1.1.3
'@changesets/types@4.1.0': {}
'@changesets/types@6.1.0': {}
'@changesets/write@0.4.0':
dependencies:
'@changesets/types': 6.1.0
fs-extra: 7.0.1
human-id: 4.1.1
prettier: 2.8.8
'@chromatic-com/storybook@3.2.6(react@19.1.0)(storybook@8.6.12(prettier@3.5.3))':
dependencies:
chromatic: 11.28.2
@@ -12019,6 +11710,16 @@ snapshots:
'@json2csv/formatters': 7.0.6
'@streamparser/json': 0.0.20
'@keyv/redis@4.4.0(keyv@5.3.3)':
dependencies:
'@redis/client': 1.6.0
cluster-key-slot: 1.1.2
keyv: 5.3.3
'@keyv/serialize@1.0.3':
dependencies:
buffer: 6.0.3
'@lexical/clipboard@0.31.0':
dependencies:
'@lexical/html': 0.31.0
@@ -12197,22 +11898,6 @@ snapshots:
- encoding
- utf-8-validate
'@manypkg/find-root@1.1.0':
dependencies:
'@babel/runtime': 7.27.0
'@types/node': 12.20.55
find-up: 4.1.0
fs-extra: 8.1.0
'@manypkg/get-packages@1.1.3':
dependencies:
'@babel/runtime': 7.27.0
'@changesets/types': 4.1.0
'@manypkg/find-root': 1.1.0
fs-extra: 8.1.0
globby: 11.1.0
read-yaml-file: 1.1.0
'@mdx-js/react@3.1.0(@types/react@19.1.3)(react@19.1.0)':
dependencies:
'@types/mdx': 2.0.13
@@ -12826,10 +12511,6 @@ snapshots:
'@prisma/fetch-engine': 6.7.0
'@prisma/get-platform': 6.7.0
'@prisma/extension-accelerate@1.3.0(@prisma/client@6.7.0(prisma@6.7.0(typescript@5.8.3))(typescript@5.8.3))':
dependencies:
'@prisma/client': 6.7.0(prisma@6.7.0(typescript@5.8.3))(typescript@5.8.3)
'@prisma/fetch-engine@6.7.0':
dependencies:
'@prisma/debug': 6.7.0
@@ -14767,8 +14448,6 @@ snapshots:
'@types/node': 22.15.14
form-data: 4.0.2
'@types/node@12.20.55': {}
'@types/node@22.14.0':
dependencies:
undici-types: 6.21.0
@@ -15766,10 +15445,6 @@ snapshots:
dependencies:
open: 8.4.2
better-path-resolve@1.0.0:
dependencies:
is-windows: 1.0.2
big.js@5.2.2: {}
bignumber.js@9.3.0: {}
@@ -15888,6 +15563,10 @@ snapshots:
- bluebird
optional: true
cache-manager@6.4.3:
dependencies:
keyv: 5.3.3
call-bind-apply-helpers@1.0.2:
dependencies:
es-errors: 1.3.0
@@ -15954,8 +15633,6 @@ snapshots:
chalk@5.4.1: {}
chardet@0.7.0: {}
check-error@2.1.1: {}
chokidar@3.6.0:
@@ -15978,8 +15655,6 @@ snapshots:
chrome-trace-event@1.0.4: {}
ci-info@3.9.0: {}
ci-info@4.2.0: {}
cjs-module-lexer@1.4.3: {}
@@ -16351,8 +16026,6 @@ snapshots:
detect-europe-js@0.1.2: {}
detect-indent@6.1.0: {}
detect-indent@7.0.1: {}
detect-libc@2.0.3: {}
@@ -16473,11 +16146,6 @@ snapshots:
graceful-fs: 4.2.11
tapable: 2.2.1
enquirer@2.4.1:
dependencies:
ansi-colors: 4.1.3
strip-ansi: 6.0.1
entities@4.5.0: {}
env-paths@2.2.1: {}
@@ -17067,14 +16735,6 @@ snapshots:
extend@3.0.2: {}
extendable-error@0.1.7: {}
external-editor@3.1.0:
dependencies:
chardet: 0.7.0
iconv-lite: 0.4.24
tmp: 0.0.33
fast-copy@3.0.2: {}
fast-deep-equal@2.0.1: {}
@@ -17217,18 +16877,6 @@ snapshots:
jsonfile: 6.1.0
universalify: 2.0.1
fs-extra@7.0.1:
dependencies:
graceful-fs: 4.2.11
jsonfile: 4.0.0
universalify: 0.1.2
fs-extra@8.1.0:
dependencies:
graceful-fs: 4.2.11
jsonfile: 4.0.0
universalify: 0.1.2
fs-minipass@2.1.0:
dependencies:
minipass: 3.3.6
@@ -17582,8 +17230,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
human-id@4.1.1: {}
human-signals@2.1.0: {}
human-signals@5.0.0: {}
@@ -17597,10 +17243,6 @@ snapshots:
hyphenate-style-name@1.1.0: {}
iconv-lite@0.4.24:
dependencies:
safer-buffer: 2.1.2
iconv-lite@0.6.3:
dependencies:
safer-buffer: 2.1.2
@@ -17805,10 +17447,6 @@ snapshots:
call-bound: 1.0.4
has-tostringtag: 1.0.2
is-subdir@1.2.0:
dependencies:
better-path-resolve: 1.0.0
is-symbol@1.1.1:
dependencies:
call-bound: 1.0.4
@@ -17832,8 +17470,6 @@ snapshots:
is-what@4.1.16: {}
is-windows@1.0.2: {}
is-wsl@2.2.0:
dependencies:
is-docker: 2.2.1
@@ -17948,11 +17584,6 @@ snapshots:
js-tokens@4.0.0: {}
js-yaml@3.14.1:
dependencies:
argparse: 1.0.10
esprima: 4.0.1
js-yaml@4.1.0:
dependencies:
argparse: 2.0.1
@@ -18013,10 +17644,6 @@ snapshots:
json5@2.2.3: {}
jsonfile@4.0.0:
optionalDependencies:
graceful-fs: 4.2.11
jsonfile@6.1.0:
dependencies:
universalify: 2.0.1
@@ -18071,6 +17698,10 @@ snapshots:
dependencies:
json-buffer: 3.0.1
keyv@5.3.3:
dependencies:
'@keyv/serialize': 1.0.3
kolorist@1.8.0: {}
language-subtag-registry@0.3.23: {}
@@ -18222,8 +17853,6 @@ snapshots:
lodash.once@4.1.1: {}
lodash.startcase@4.4.0: {}
lodash@4.17.21: {}
log-symbols@2.2.0:
@@ -18540,8 +18169,6 @@ snapshots:
- utf-8-validate
optional: true
mri@1.2.0: {}
ms@2.0.0: {}
ms@2.1.3: {}
@@ -18878,16 +18505,12 @@ snapshots:
type-check: 0.4.0
word-wrap: 1.2.5
os-tmpdir@1.0.2: {}
otplib@12.0.1:
dependencies:
'@otplib/core': 12.0.1
'@otplib/preset-default': 12.0.1
'@otplib/preset-v11': 12.0.1
outdent@0.5.0: {}
own-keys@1.0.1:
dependencies:
get-intrinsic: 1.3.0
@@ -18896,10 +18519,6 @@ snapshots:
p-defer@1.0.0: {}
p-filter@2.1.0:
dependencies:
p-map: 2.1.0
p-limit@2.3.0:
dependencies:
p-try: 2.2.0
@@ -18916,8 +18535,6 @@ snapshots:
dependencies:
p-limit: 3.1.0
p-map@2.1.0: {}
p-map@4.0.0:
dependencies:
aggregate-error: 3.1.0
@@ -18927,10 +18544,6 @@ snapshots:
package-json-from-dist@1.0.1: {}
package-manager-detector@0.2.11:
dependencies:
quansync: 0.2.10
pako@0.2.9: {}
papaparse@5.5.2: {}
@@ -19041,8 +18654,6 @@ snapshots:
pify@2.3.0: {}
pify@4.0.1: {}
pino-abstract-transport@2.0.0:
dependencies:
split2: 4.2.0
@@ -19232,8 +18843,6 @@ snapshots:
optionalDependencies:
'@trivago/prettier-plugin-sort-imports': 5.2.2(prettier@3.5.3)
prettier@2.8.8: {}
prettier@3.5.3: {}
pretty-format@22.4.3:
@@ -19531,13 +19140,6 @@ snapshots:
parse-json: 5.2.0
type-fest: 0.6.0
read-yaml-file@1.1.0:
dependencies:
graceful-fs: 4.2.11
js-yaml: 3.14.1
pify: 4.0.1
strip-bom: 3.0.0
readable-stream@2.3.8:
dependencies:
core-util-is: 1.0.3
@@ -19651,8 +19253,6 @@ snapshots:
resolve-from@4.0.0: {}
resolve-from@5.0.0: {}
resolve-pkg-maps@1.0.0: {}
resolve@1.19.0:
@@ -20067,11 +19667,6 @@ snapshots:
dependencies:
memory-pager: 1.5.0
spawndamnit@3.0.1:
dependencies:
cross-spawn: 7.0.6
signal-exit: 4.1.0
spdx-correct@3.2.0:
dependencies:
spdx-expression-parse: 3.0.1
@@ -20405,8 +20000,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
term-size@2.2.1: {}
terser-webpack-plugin@5.3.14(webpack@5.99.8):
dependencies:
'@jridgewell/trace-mapping': 0.3.25
@@ -20474,10 +20067,6 @@ snapshots:
dependencies:
tldts-core: 6.1.86
tmp@0.0.33:
dependencies:
os-tmpdir: 1.0.2
to-regex-range@5.0.1:
dependencies:
is-number: 7.0.0
@@ -20723,8 +20312,6 @@ snapshots:
imurmurhash: 0.1.4
optional: true
universalify@0.1.2: {}
universalify@2.0.1: {}
unplugin@1.0.1: