Compare commits

...

5 Commits

Author SHA1 Message Date
Matti Nannt
c1a35e2d75 chore: introduce new reliable cache for enterprise license check (#5740)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-05-12 10:41:53 +02:00
Piyush Gupta
13415c75c2 docs: adds auth-behaviour docs (#5743) 2025-05-12 05:28:12 +00:00
Johannes
300557a0e6 docs: update ee feature table (#5744) 2025-05-11 22:10:40 -07:00
Anshuman Pandey
fcbb97010c fix: follow ups ending card (#5732) 2025-05-10 10:30:49 +02:00
Matti Nannt
6be46b16b2 fix: limit number of surveys in environment state (#5715) 2025-05-09 16:10:01 +00:00
54 changed files with 2867 additions and 1490 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

@@ -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

@@ -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

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

View File

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

View File

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

View File

@@ -102,6 +102,8 @@ describe("getSurveysForEnvironmentState", () => {
expect(prisma.survey.findMany).toHaveBeenCalledWith({
where: { environmentId },
select: expect.any(Object), // Check if select is called, specific fields are in the original code
orderBy: { createdAt: "desc" },
take: 30,
});
expect(transformPrismaSurvey).toHaveBeenCalledWith(mockPrismaSurvey);
expect(result).toEqual([mockTransformedSurvey]);
@@ -116,6 +118,8 @@ describe("getSurveysForEnvironmentState", () => {
expect(prisma.survey.findMany).toHaveBeenCalledWith({
where: { environmentId },
select: expect.any(Object),
orderBy: { createdAt: "desc" },
take: 30,
});
expect(transformPrismaSurvey).not.toHaveBeenCalled();
expect(result).toEqual([]);

View File

@@ -21,6 +21,10 @@ export const getSurveysForEnvironmentState = reactCache(
where: {
environmentId,
},
orderBy: {
createdAt: "desc",
},
take: 30,
select: {
id: true,
welcomeCard: true,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -67,24 +67,24 @@ const handleAuth = async (request: NextRequest): Promise<Response | null> => {
return null;
};
const applyRateLimiting = (request: NextRequest, ip: string) => {
const applyRateLimiting = async (request: NextRequest, ip: string) => {
if (isLoginRoute(request.nextUrl.pathname)) {
loginLimiter(`login-${ip}`);
await loginLimiter(`login-${ip}`);
} else if (isSignupRoute(request.nextUrl.pathname)) {
signupLimiter(`signup-${ip}`);
await signupLimiter(`signup-${ip}`);
} else if (isVerifyEmailRoute(request.nextUrl.pathname)) {
verifyEmailLimiter(`verify-email-${ip}`);
await verifyEmailLimiter(`verify-email-${ip}`);
} else if (isForgotPasswordRoute(request.nextUrl.pathname)) {
forgotPasswordLimiter(`forgot-password-${ip}`);
await forgotPasswordLimiter(`forgot-password-${ip}`);
} else if (isClientSideApiRoute(request.nextUrl.pathname)) {
clientSideApiEndpointsLimiter(`client-side-api-${ip}`);
await clientSideApiEndpointsLimiter(`client-side-api-${ip}`);
const envIdAndUserId = isSyncWithUserIdentificationEndpoint(request.nextUrl.pathname);
if (envIdAndUserId) {
const { environmentId, userId } = envIdAndUserId;
syncUserIdentificationLimiter(`sync-${environmentId}-${userId}`);
await syncUserIdentificationLimiter(`sync-${environmentId}-${userId}`);
}
} else if (isShareUrlRoute(request.nextUrl.pathname)) {
shareUrlLimiter(`share-${ip}`);
await shareUrlLimiter(`share-${ip}`);
}
};
@@ -153,7 +153,7 @@ export const middleware = async (originalRequest: NextRequest) => {
if (ip) {
try {
applyRateLimiting(request, ip);
await applyRateLimiting(request, ip);
return nextResponseWithCustomHeader;
} catch (e) {
const apiError: ApiErrorResponseV2 = {

View File

@@ -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

@@ -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

@@ -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

@@ -33,7 +33,6 @@ import { InviteAcceptedEmail } from "./emails/invite/invite-accepted-email";
import { InviteEmail } from "./emails/invite/invite-email";
import { OnboardingInviteEmail } from "./emails/invite/onboarding-invite-email";
import { EmbedSurveyPreviewEmail } from "./emails/survey/embed-survey-preview-email";
import { FollowUpEmail } from "./emails/survey/follow-up";
import { LinkSurveyEmail } from "./emails/survey/link-survey-email";
import { ResponseFinishedEmail } from "./emails/survey/response-finished-email";
import { NoLiveSurveyNotificationEmail } from "./emails/weekly-summary/no-live-survey-notification-email";
@@ -355,40 +354,3 @@ export const sendNoLiveSurveyNotificationEmail = async (
html,
});
};
export const sendFollowUpEmail = async ({
html,
replyTo,
subject,
to,
survey,
response,
attachResponseData = false,
logoUrl,
}: {
html: string;
subject: string;
to: string;
replyTo: string[];
attachResponseData: boolean;
survey: TSurvey;
response: TResponse;
logoUrl?: string;
}): Promise<void> => {
const emailHtmlBody = await render(
await FollowUpEmail({
html,
logoUrl,
attachResponseData,
survey,
response,
})
);
await sendEmail({
to,
replyTo: replyTo.join(", "),
subject,
html: emailHtmlBody,
});
};

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

@@ -5,7 +5,7 @@ import { DefaultParamType, TFnType, TranslationKey } from "@tolgee/react/server"
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { FollowUpEmail } from "./follow-up";
import { FollowUpEmail } from "./follow-up-email";
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
@@ -24,7 +24,28 @@ vi.mock("@/modules/email/emails/lib/utils", () => ({
}));
const defaultProps = {
html: "<p>Test HTML Content</p>",
followUp: {
id: "followupid",
createdAt: new Date(),
updatedAt: new Date(),
name: "Follow Up Email",
trigger: {
type: "response" as const,
properties: null,
},
action: {
type: "send-email" as const,
properties: {
to: "",
from: "test@test.com",
replyTo: ["test@test.com"],
subject: "Subject",
body: "<p>Test HTML Content</p>",
attachResponseData: true,
},
},
surveyId: "surveyid",
},
logoUrl: "https://example.com/custom-logo.png",
attachResponseData: false,
survey: {

View File

@@ -17,6 +17,7 @@ import {
} from "@react-email/components";
import dompurify from "isomorphic-dompurify";
import React from "react";
import { TSurveyFollowUp } from "@formbricks/database/types/survey-follow-up";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
@@ -24,23 +25,20 @@ const fbLogoUrl = FB_LOGO_URL;
const logoLink = "https://formbricks.com?utm_source=email_header&utm_medium=email";
interface FollowUpEmailProps {
html: string;
logoUrl?: string;
attachResponseData: boolean;
survey: TSurvey;
response: TResponse;
readonly followUp: TSurveyFollowUp;
readonly logoUrl?: string;
readonly attachResponseData: boolean;
readonly survey: TSurvey;
readonly response: TResponse;
}
export async function FollowUpEmail({
html,
logoUrl,
attachResponseData,
survey,
response,
}: FollowUpEmailProps): Promise<React.JSX.Element> {
const questions = attachResponseData ? getQuestionResponseMapping(survey, response) : [];
export async function FollowUpEmail(props: FollowUpEmailProps): Promise<React.JSX.Element> {
const { properties } = props.followUp.action;
const { body } = properties;
const questions = props.attachResponseData ? getQuestionResponseMapping(props.survey, props.response) : [];
const t = await getTranslate();
const isDefaultLogo = !logoUrl || logoUrl === fbLogoUrl;
const isDefaultLogo = !props.logoUrl || props.logoUrl === fbLogoUrl;
return (
<Html>
@@ -56,13 +54,13 @@ export async function FollowUpEmail({
<Img alt="Logo" className="mx-auto w-60" src={fbLogoUrl} />
</Link>
) : (
<Img alt="Logo" className="mx-auto max-h-[100px] w-60 object-contain" src={logoUrl} />
<Img alt="Logo" className="mx-auto max-h-[100px] w-60 object-contain" src={props.logoUrl} />
)}
</Section>
<Container className="mx-auto my-8 max-w-xl rounded-md bg-white p-4 text-left text-sm">
<div
dangerouslySetInnerHTML={{
__html: dompurify.sanitize(html, {
__html: dompurify.sanitize(body, {
ALLOWED_TAGS: ["p", "span", "b", "strong", "i", "em", "a", "br"],
ALLOWED_ATTR: ["href", "rel", "dir", "class"],
ALLOWED_URI_REGEXP: /^https?:\/\//, // Only allow safe URLs starting with http or https

View File

@@ -0,0 +1,47 @@
import { sendEmail } from "@/modules/email";
import { FollowUpEmail } from "@/modules/survey/follow-ups/components/follow-up-email";
import { render } from "@react-email/components";
import { TSurveyFollowUp } from "@formbricks/database/types/survey-follow-up";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
export const sendFollowUpEmail = async ({
followUp,
to,
replyTo,
survey,
response,
attachResponseData = false,
logoUrl,
}: {
followUp: TSurveyFollowUp;
to: string;
replyTo: string[];
attachResponseData: boolean;
survey: TSurvey;
response: TResponse;
logoUrl?: string;
}): Promise<void> => {
const {
action: {
properties: { subject },
},
} = followUp;
const emailHtmlBody = await render(
await FollowUpEmail({
followUp,
logoUrl,
attachResponseData,
survey,
response,
})
);
await sendEmail({
to,
replyTo: replyTo.join(", "),
subject,
html: emailHtmlBody,
});
};

View File

@@ -0,0 +1,370 @@
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getResponse } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { FollowUpSendError } from "@/modules/survey/follow-ups/types/follow-up";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TOrganization } from "@formbricks/types/organizations";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { sendFollowUpEmail } from "./email";
import { sendFollowUpsForResponse } from "./follow-ups";
import { getSurveyFollowUpsPermission } from "./utils";
// Mock all dependencies
vi.mock("@/lib/organization/service", () => ({
getOrganizationByEnvironmentId: vi.fn(),
}));
vi.mock("@/lib/response/service", () => ({
getResponse: vi.fn(),
}));
vi.mock("@/lib/survey/service", () => ({
getSurvey: vi.fn(),
}));
vi.mock("./email", () => ({
sendFollowUpEmail: vi.fn(),
}));
vi.mock("./utils", () => ({
getSurveyFollowUpsPermission: vi.fn(),
}));
describe("Follow-ups", () => {
const mockResponse = {
id: "response1",
surveyId: "survey1",
data: {
email: "test@example.com",
question1: "answer1",
},
endingId: "ending1",
} as unknown as TResponse;
const mockSurvey = {
id: "survey1",
environmentId: "env1",
followUps: [
{
id: "followup1",
action: {
type: "email",
properties: {
to: "email",
replyTo: "noreply@example.com",
attachResponseData: true,
},
},
trigger: {
type: "response",
properties: {
endingIds: ["ending1"],
},
},
},
],
} as unknown as TSurvey;
const mockOrganization = {
id: "org1",
billing: {
plan: "scale",
limits: {
monthly: { miu: 1000, responses: 1000 },
projects: 3,
},
period: "monthly",
periodStart: new Date(),
stripeCustomerId: "cus123",
},
whitelabel: {
logoUrl: "https://example.com/logo.png",
},
} as unknown as TOrganization;
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getResponse).mockResolvedValue(mockResponse);
vi.mocked(getSurvey).mockResolvedValue(mockSurvey);
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization);
vi.mocked(getSurveyFollowUpsPermission).mockResolvedValue(true);
vi.mocked(sendFollowUpEmail).mockResolvedValue(undefined);
});
afterEach(() => {
vi.resetAllMocks();
});
describe("sendFollowUpsForResponse", () => {
test("should successfully send follow-up emails", async () => {
const result = await sendFollowUpsForResponse("response1");
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toHaveLength(1);
expect(result.data[0]).toEqual({
followUpId: "followup1",
status: "success",
});
expect(sendFollowUpEmail).toHaveBeenCalledWith({
followUp: mockSurvey.followUps[0],
to: "test@example.com",
replyTo: "noreply@example.com",
survey: mockSurvey,
response: mockResponse,
attachResponseData: true,
logoUrl: "https://example.com/logo.png",
});
}
});
test("should return error when response is not found", async () => {
vi.mocked(getResponse).mockResolvedValue(null);
const result = await sendFollowUpsForResponse("nonexistentresponse");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({
code: FollowUpSendError.RESPONSE_NOT_FOUND,
message: "Response not found",
meta: { responseId: "nonexistentresponse" },
});
}
});
test("should return error when survey is not found", async () => {
vi.mocked(getSurvey).mockResolvedValue(null);
const result = await sendFollowUpsForResponse("response1");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({
code: FollowUpSendError.SURVEY_NOT_FOUND,
message: "Survey not found",
meta: { responseId: "response1", surveyId: "survey1" },
});
}
});
test("should return error when organization is not found", async () => {
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null);
const result = await sendFollowUpsForResponse("response1");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({
code: FollowUpSendError.ORG_NOT_FOUND,
message: "Organization not found",
meta: { responseId: "response1", surveyId: "survey1", environmentId: "env1" },
});
}
});
test("should return error when follow-ups are not allowed", async () => {
vi.mocked(getSurveyFollowUpsPermission).mockResolvedValue(false);
const result = await sendFollowUpsForResponse("response1");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({
code: FollowUpSendError.FOLLOW_UP_NOT_ALLOWED,
message: "Survey follow-ups are not allowed for this organization",
meta: { responseId: "response1", surveyId: "survey1", organizationId: "org1" },
});
}
});
test("should skip follow-up when ending ID doesn't match", async () => {
const modifiedResponse = {
...mockResponse,
endingId: "different-ending",
};
vi.mocked(getResponse).mockResolvedValue(modifiedResponse);
const result = await sendFollowUpsForResponse("response1");
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toHaveLength(1);
expect(result.data[0]).toEqual({
followUpId: "followup1",
status: "skipped",
});
expect(sendFollowUpEmail).not.toHaveBeenCalled();
}
});
test("should handle direct email address in follow-up", async () => {
const modifiedSurvey = {
...mockSurvey,
followUps: [
{
...mockSurvey.followUps[0],
action: {
...mockSurvey.followUps[0].action,
properties: {
...mockSurvey.followUps[0].action.properties,
to: "direct@example.com",
},
},
},
],
};
vi.mocked(getSurvey).mockResolvedValue(modifiedSurvey);
const result = await sendFollowUpsForResponse("response1");
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toHaveLength(1);
expect(result.data[0]).toEqual({
followUpId: "followup1",
status: "success",
});
expect(sendFollowUpEmail).toHaveBeenCalledWith({
followUp: modifiedSurvey.followUps[0],
to: "direct@example.com",
replyTo: "noreply@example.com",
survey: modifiedSurvey,
response: mockResponse,
attachResponseData: true,
logoUrl: "https://example.com/logo.png",
});
}
});
test("should handle invalid email address in response data", async () => {
const modifiedResponse = {
...mockResponse,
data: {
email: "invalid-email",
},
};
vi.mocked(getResponse).mockResolvedValue(modifiedResponse);
const result = await sendFollowUpsForResponse("response1");
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toHaveLength(1);
expect(result.data[0]).toEqual({
followUpId: "followup1",
status: "error",
error: "Email address is not valid for followup: followup1",
});
expect(sendFollowUpEmail).not.toHaveBeenCalled();
}
});
test("should handle missing email value in response data", async () => {
const modifiedResponse = {
...mockResponse,
data: {},
};
vi.mocked(getSurvey).mockResolvedValue({
...mockSurvey,
followUps: [
{
id: "followup1",
action: {
type: "email",
properties: {
to: "email",
replyTo: "noreply@example.com",
attachResponseData: true,
},
},
trigger: {
type: "response",
properties: {
endingIds: ["ending1"],
},
},
},
],
} as unknown as TSurvey);
vi.mocked(getResponse).mockResolvedValue(modifiedResponse as unknown as any);
const result = await sendFollowUpsForResponse("response1");
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toHaveLength(1);
expect(result.data[0]).toEqual({
followUpId: "followup1",
status: "error",
error: "To value not found in response data for followup: followup1",
});
expect(sendFollowUpEmail).not.toHaveBeenCalled();
}
});
test("should handle email sending error", async () => {
vi.mocked(getSurvey).mockResolvedValue({
...mockSurvey,
followUps: [
{
id: "followup1",
action: {
type: "email",
properties: {
to: "hello@example.com",
replyTo: "noreply@example.com",
attachResponseData: true,
},
},
trigger: {
type: "response",
properties: {
endingIds: ["ending1"],
},
},
},
],
} as unknown as TSurvey);
vi.mocked(sendFollowUpEmail).mockRejectedValue(new Error("Failed to send email"));
const result = await sendFollowUpsForResponse("response1");
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toHaveLength(1);
expect(result.data[0]).toEqual({
followUpId: "followup1",
status: "error",
error: "Failed to send email",
});
}
});
test("should return empty array when no follow-ups are configured", async () => {
const modifiedSurvey = {
...mockSurvey,
followUps: [],
} as unknown as TSurvey;
vi.mocked(getSurvey).mockResolvedValue(modifiedSurvey);
const result = await sendFollowUpsForResponse("response1");
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual([]);
expect(sendFollowUpEmail).not.toHaveBeenCalled();
}
});
});
});

View File

@@ -0,0 +1,276 @@
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getResponse } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { rateLimit } from "@/lib/utils/rate-limit";
import { validateInputs } from "@/lib/utils/validate";
import { sendFollowUpEmail } from "@/modules/survey/follow-ups/lib/email";
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
import { FollowUpResult, FollowUpSendError } from "@/modules/survey/follow-ups/types/follow-up";
import { z } from "zod";
import { TSurveyFollowUp } from "@formbricks/database/types/survey-follow-up";
import { logger } from "@formbricks/logger";
import { ZId } from "@formbricks/types/common";
import { Result, err } from "@formbricks/types/error-handlers";
import { ValidationError } from "@formbricks/types/errors";
import { TOrganization } from "@formbricks/types/organizations";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
const limiter = rateLimit({
interval: 60 * 60, // 1 hour
allowedPerInterval: 50, // max 50 calls per org per hour
});
const evaluateFollowUp = async (
followUp: TSurveyFollowUp,
survey: TSurvey,
response: TResponse,
organization: TOrganization
): Promise<FollowUpResult> => {
try {
const { properties } = followUp.action;
const { to, replyTo } = properties;
const toValueFromResponse = response.data[to];
const logoUrl = organization.whitelabel?.logoUrl ?? "";
// Check if 'to' is a direct email address (team member or user email)
const parsedEmailTo = z.string().email().safeParse(to);
if (parsedEmailTo.success) {
// 'to' is a valid email address, send email directly
await sendFollowUpEmail({
followUp,
to: parsedEmailTo.data,
replyTo,
survey,
response,
attachResponseData: properties.attachResponseData,
logoUrl,
});
return {
followUpId: followUp.id,
status: "success" as const,
};
}
// If not a direct email, check if it's a question ID or hidden field ID
if (!toValueFromResponse) {
return {
followUpId: followUp.id,
status: "error",
error: `To value not found in response data for followup: ${followUp.id}`,
};
}
if (typeof toValueFromResponse === "string") {
// parse this string to check for an email:
const parsedResult = z.string().email().safeParse(toValueFromResponse);
if (parsedResult.success) {
// send email to this email address
await sendFollowUpEmail({
followUp,
to: parsedResult.data,
replyTo,
logoUrl,
survey,
response,
attachResponseData: properties.attachResponseData,
});
return {
followUpId: followUp.id,
status: "success" as const,
};
}
return {
followUpId: followUp.id,
status: "error",
error: `Email address is not valid for followup: ${followUp.id}`,
};
} else if (Array.isArray(toValueFromResponse)) {
const emailAddress = toValueFromResponse[2];
if (!emailAddress) {
return {
followUpId: followUp.id,
status: "error",
error: `Email address not found in response data for followup: ${followUp.id}`,
};
}
const parsedResult = z.string().email().safeParse(emailAddress);
if (parsedResult.data) {
await sendFollowUpEmail({
followUp,
to: parsedResult.data,
replyTo,
logoUrl,
survey,
response,
attachResponseData: properties.attachResponseData,
});
return {
followUpId: followUp.id,
status: "success" as const,
};
}
return {
followUpId: followUp.id,
status: "error",
error: `Email address is not valid for followup: ${followUp.id}`,
};
}
return {
followUpId: followUp.id,
status: "error",
error: "Something went wrong",
};
} catch (error) {
return {
followUpId: followUp.id,
status: "error",
error: error instanceof Error ? error.message : "Something went wrong",
};
}
};
/**
* Sends follow-up emails for a survey response.
* This is the main entry point for sending follow-ups - it handles all the logic internally
* and only requires a response ID.
*/
export const sendFollowUpsForResponse = async (
responseId: string
): Promise<Result<FollowUpResult[], { code: FollowUpSendError; message: string; meta?: any }>> => {
try {
validateInputs([responseId, ZId]);
// Get the response first to get the survey ID
const response = await getResponse(responseId);
if (!response) {
return err({
code: FollowUpSendError.RESPONSE_NOT_FOUND,
message: "Response not found",
meta: { responseId },
});
}
const surveyId = response.surveyId;
const survey = await getSurvey(surveyId);
if (!survey) {
return err({
code: FollowUpSendError.SURVEY_NOT_FOUND,
message: "Survey not found",
meta: { responseId, surveyId },
});
}
// Get organization from survey's environmentId
const organization = await getOrganizationByEnvironmentId(survey.environmentId);
if (!organization) {
return err({
code: FollowUpSendError.ORG_NOT_FOUND,
message: "Organization not found",
meta: { responseId, surveyId, environmentId: survey.environmentId },
});
}
// Check if follow-ups are allowed for this organization
const surveyFollowUpsPermission = await getSurveyFollowUpsPermission(organization.billing.plan);
if (!surveyFollowUpsPermission) {
return err({
code: FollowUpSendError.FOLLOW_UP_NOT_ALLOWED,
message: "Survey follow-ups are not allowed for this organization",
meta: { responseId, surveyId, organizationId: organization.id },
});
}
// Check rate limit
try {
await limiter(organization.id);
} catch {
return err({
code: FollowUpSendError.RATE_LIMIT_EXCEEDED,
message: "Too many followup requests; please wait a bit and try again.",
});
}
// If no follow-ups configured, return empty array
if (!survey.followUps?.length) {
return {
ok: true,
data: [],
};
}
// Process each follow-up
const followUpPromises = survey.followUps.map(async (followUp): Promise<FollowUpResult> => {
const { trigger } = followUp;
// Check if we should skip this follow-up based on ending IDs
if (trigger.properties) {
const { endingIds } = trigger.properties;
const { endingId } = response;
if (!endingId || !endingIds.includes(endingId)) {
return {
followUpId: followUp.id,
status: "skipped",
};
}
}
return evaluateFollowUp(followUp, survey, response, organization);
});
const followUpResults = await Promise.all(followUpPromises);
// Log all errors
const errors = followUpResults
.filter((result): result is FollowUpResult & { status: "error" } => result.status === "error")
.map((result) => `FollowUp ${result.followUpId} failed: ${result.error}`);
if (errors.length > 0) {
logger.error(
{
errors,
meta: {
responseId,
surveyId,
organizationId: organization.id,
},
},
"Follow-up processing errors"
);
}
return {
ok: true,
data: followUpResults,
};
} catch (error) {
logger.error(
{
error,
meta: { responseId },
},
"Unexpected error while sending follow-ups"
);
if (error instanceof ValidationError) {
return err({
code: FollowUpSendError.VALIDATION_ERROR,
message: error.message,
meta: { responseId },
});
}
return err({
code: FollowUpSendError.UNEXPECTED_ERROR,
message: "An unexpected error occurred while sending follow-ups",
meta: { responseId },
});
}
};

View File

@@ -0,0 +1,16 @@
export type FollowUpResult = {
followUpId: string;
status: "success" | "error" | "skipped";
error?: string;
};
export enum FollowUpSendError {
VALIDATION_ERROR = "validation_error",
ORG_NOT_FOUND = "organization_not_found",
SURVEY_NOT_FOUND = "survey_not_found",
RESPONSE_NOT_FOUND = "response_not_found",
RESPONSE_SURVEY_MISMATCH = "response_survey_mismatch",
FOLLOW_UP_NOT_ALLOWED = "follow_up_not_allowed",
RATE_LIMIT_EXCEEDED = "rate_limit_exceeded",
UNEXPECTED_ERROR = "unexpected_error",
}

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

@@ -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

@@ -464,6 +464,7 @@ export function Survey({
},
variables: responseUpdate.variables,
displayId: surveyState.displayId,
endingId: responseUpdate.endingId,
hiddenFields: hiddenFieldsRecord,
});

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");
});
});
});

484
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
@@ -1342,61 +1347,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 +1844,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 +1936,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:
@@ -4227,9 +4180,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 +5014,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 +5107,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 +5176,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 +5207,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 +5595,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 +5719,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 +6081,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 +6241,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 +6525,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 +6544,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 +6769,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 +6793,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 +6901,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 +6957,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 +6986,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 +7158,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 +7468,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 +7776,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 +7787,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 +7803,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 +7814,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 +7944,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 +8171,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 +8451,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 +8535,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 +8836,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 +9111,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 +9196,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 +9480,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 +11278,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 +11719,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 +11907,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
@@ -14767,8 +14461,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 +15458,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 +15576,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 +15646,6 @@ snapshots:
chalk@5.4.1: {}
chardet@0.7.0: {}
check-error@2.1.1: {}
chokidar@3.6.0:
@@ -15978,8 +15668,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 +16039,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 +16159,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 +16748,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 +16890,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 +17243,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
human-id@4.1.1: {}
human-signals@2.1.0: {}
human-signals@5.0.0: {}
@@ -17597,10 +17256,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 +17460,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 +17483,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 +17597,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 +17657,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 +17711,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 +17866,6 @@ snapshots:
lodash.once@4.1.1: {}
lodash.startcase@4.4.0: {}
lodash@4.17.21: {}
log-symbols@2.2.0:
@@ -18540,8 +18182,6 @@ snapshots:
- utf-8-validate
optional: true
mri@1.2.0: {}
ms@2.0.0: {}
ms@2.1.3: {}
@@ -18878,16 +18518,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 +18532,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 +18548,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 +18557,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 +18667,6 @@ snapshots:
pify@2.3.0: {}
pify@4.0.1: {}
pino-abstract-transport@2.0.0:
dependencies:
split2: 4.2.0
@@ -19232,8 +18856,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 +19153,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 +19266,6 @@ snapshots:
resolve-from@4.0.0: {}
resolve-from@5.0.0: {}
resolve-pkg-maps@1.0.0: {}
resolve@1.19.0:
@@ -20067,11 +19680,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 +20013,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 +20080,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 +20325,6 @@ snapshots:
imurmurhash: 0.1.4
optional: true
universalify@0.1.2: {}
universalify@2.0.1: {}
unplugin@1.0.1: