mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-25 07:50:19 -06:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1a35e2d75 | ||
|
|
13415c75c2 | ||
|
|
300557a0e6 | ||
|
|
fcbb97010c | ||
|
|
6be46b16b2 |
@@ -172,7 +172,6 @@ ENTERPRISE_LICENSE_KEY=
|
||||
# Automatically assign new users to a specific organization and role within that organization
|
||||
# Insert an existing organization id or generate a valid CUID for a new one at https://www.getuniqueid.com/cuid (e.g. cjld2cjxh0000qzrmn831i7rn)
|
||||
# (Role Management is an Enterprise feature)
|
||||
# DEFAULT_ORGANIZATION_ROLE=owner
|
||||
# AUTH_SSO_DEFAULT_TEAM_ID=
|
||||
# AUTH_SKIP_INVITE_FOR_SSO=
|
||||
|
||||
|
||||
22
.github/workflows/e2e.yml
vendored
22
.github/workflows/e2e.yml
vendored
@@ -11,6 +11,8 @@ on:
|
||||
required: false
|
||||
PLAYWRIGHT_SERVICE_URL:
|
||||
required: false
|
||||
ENTERPRISE_LICENSE_KEY:
|
||||
required: true
|
||||
# Add other secrets if necessary
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import { getOrganizationsByUserId } from "@/lib/organization/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import Page from "./page";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
vi.mock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: true,
|
||||
features: { isMultiOrgEnabled: true },
|
||||
lastChecked: new Date(),
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
@@ -97,23 +105,44 @@ vi.mock("@/app/(app)/(onboarding)/organizations/[organizationId]/landing/compone
|
||||
vi.mock("@/modules/organization/lib/utils");
|
||||
vi.mock("@/lib/user/service");
|
||||
vi.mock("@/lib/organization/service");
|
||||
vi.mock("@/modules/ee/license-check/lib/utils");
|
||||
vi.mock("@/tolgee/server");
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: vi.fn(() => "REDIRECT_STUB"),
|
||||
notFound: vi.fn(() => "NOT_FOUND_STUB"),
|
||||
}));
|
||||
|
||||
// Mock the React cache function
|
||||
vi.mock("react", async () => {
|
||||
const actual = await vi.importActual("react");
|
||||
return {
|
||||
...actual,
|
||||
cache: (fn: any) => fn,
|
||||
};
|
||||
});
|
||||
|
||||
describe("Page component", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
test("redirects to login if no user session", async () => {
|
||||
vi.mocked(getOrganizationAuth).mockResolvedValue({ session: {}, organization: {} } as any);
|
||||
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: true,
|
||||
features: { isMultiOrgEnabled: true },
|
||||
lastChecked: new Date(),
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { default: Page } = await import("./page");
|
||||
const result = await Page({ params: { organizationId: "org1" } });
|
||||
|
||||
expect(redirect).toHaveBeenCalledWith("/auth/login");
|
||||
expect(result).toBe("REDIRECT_STUB");
|
||||
});
|
||||
@@ -124,9 +153,17 @@ describe("Page component", () => {
|
||||
organization: {},
|
||||
} as any);
|
||||
vi.mocked(getUser).mockResolvedValue(null);
|
||||
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: true,
|
||||
features: { isMultiOrgEnabled: true },
|
||||
lastChecked: new Date(),
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { default: Page } = await import("./page");
|
||||
const result = await Page({ params: { organizationId: "org1" } });
|
||||
|
||||
expect(notFound).toHaveBeenCalled();
|
||||
expect(result).toBe("NOT_FOUND_STUB");
|
||||
});
|
||||
@@ -138,14 +175,21 @@ describe("Page component", () => {
|
||||
} as any);
|
||||
vi.mocked(getUser).mockResolvedValue({ id: "user1", name: "Test User" } as any);
|
||||
vi.mocked(getOrganizationsByUserId).mockResolvedValue([{ id: "org1", name: "Org One" } as any]);
|
||||
vi.mocked(getEnterpriseLicense).mockResolvedValue({ features: { isMultiOrgEnabled: true } } as any);
|
||||
vi.mocked(getTranslate).mockResolvedValue((props: any) =>
|
||||
typeof props === "string" ? props : props.key || ""
|
||||
);
|
||||
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: true,
|
||||
features: { isMultiOrgEnabled: true },
|
||||
lastChecked: new Date(),
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { default: Page } = await import("./page");
|
||||
const element = await Page({ params: { organizationId: "org1" } });
|
||||
render(element as React.ReactElement);
|
||||
|
||||
expect(screen.getByTestId("landing-sidebar")).toBeInTheDocument();
|
||||
expect(screen.getByText("organizations.landing.no_projects_warning_title")).toBeInTheDocument();
|
||||
expect(screen.getByText("organizations.landing.no_projects_warning_subtitle")).toBeInTheDocument();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { LandingSidebar } from "@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar";
|
||||
import { getOrganizationsByUserId } from "@/lib/organization/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
|
||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||
import { Header } from "@/modules/ui/components/header";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
|
||||
@@ -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={"/"}>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { EnvironmentLayout } from "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout";
|
||||
import { getEnvironment, getEnvironments } from "@/lib/environment/service";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
@@ -10,7 +9,7 @@ import {
|
||||
} from "@/lib/organization/service";
|
||||
import { getUserProjects } from "@/lib/project/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getEnterpriseLicense, getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import type { Session } from "next-auth";
|
||||
@@ -49,7 +48,6 @@ vi.mock("@/lib/membership/utils", () => ({
|
||||
getAccessFlags: vi.fn(() => ({ isMember: true })), // Default to member for simplicity
|
||||
}));
|
||||
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
|
||||
getEnterpriseLicense: vi.fn(),
|
||||
getOrganizationProjectsLimit: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/modules/ee/teams/lib/roles", () => ({
|
||||
@@ -176,7 +174,6 @@ describe("EnvironmentLayout", () => {
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
|
||||
vi.mocked(getMonthlyActiveOrganizationPeopleCount).mockResolvedValue(100);
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(500);
|
||||
vi.mocked(getEnterpriseLicense).mockResolvedValue(mockLicense);
|
||||
vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(null as any);
|
||||
vi.mocked(getProjectPermissionByUserId).mockResolvedValue(mockProjectPermission);
|
||||
mockIsDevelopment = false;
|
||||
@@ -189,13 +186,19 @@ describe("EnvironmentLayout", () => {
|
||||
});
|
||||
|
||||
test("renders correctly with default props", async () => {
|
||||
// Ensure the default mockLicense has isPendingDowngrade: false and active: false
|
||||
vi.mocked(getEnterpriseLicense).mockResolvedValue({
|
||||
...mockLicense,
|
||||
isPendingDowngrade: false,
|
||||
active: false,
|
||||
});
|
||||
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: false,
|
||||
isPendingDowngrade: false,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { EnvironmentLayout } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||
);
|
||||
render(
|
||||
await EnvironmentLayout({
|
||||
environmentId: "env-1",
|
||||
@@ -203,20 +206,31 @@ describe("EnvironmentLayout", () => {
|
||||
children: <div>Child Content</div>,
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("main-navigation")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("top-control-bar")).toBeInTheDocument();
|
||||
expect(screen.getByText("Child Content")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("dev-banner")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("limits-banner")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("downgrade-banner")).not.toBeInTheDocument(); // This should now pass
|
||||
expect(screen.queryByTestId("downgrade-banner")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders DevEnvironmentBanner in development environment", async () => {
|
||||
const devEnvironment = { ...mockEnvironment, type: "development" as const };
|
||||
vi.mocked(getEnvironment).mockResolvedValue(devEnvironment);
|
||||
mockIsDevelopment = true;
|
||||
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: false,
|
||||
isPendingDowngrade: false,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { EnvironmentLayout } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||
);
|
||||
render(
|
||||
await EnvironmentLayout({
|
||||
environmentId: "env-1",
|
||||
@@ -224,13 +238,24 @@ describe("EnvironmentLayout", () => {
|
||||
children: <div>Child Content</div>,
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("dev-banner")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders LimitsReachedBanner in Formbricks Cloud", async () => {
|
||||
mockIsFormbricksCloud = true;
|
||||
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: false,
|
||||
isPendingDowngrade: false,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { EnvironmentLayout } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||
);
|
||||
render(
|
||||
await EnvironmentLayout({
|
||||
environmentId: "env-1",
|
||||
@@ -238,17 +263,21 @@ describe("EnvironmentLayout", () => {
|
||||
children: <div>Child Content</div>,
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("limits-banner")).toBeInTheDocument();
|
||||
expect(vi.mocked(getMonthlyActiveOrganizationPeopleCount)).toHaveBeenCalledWith(mockOrganization.id);
|
||||
expect(vi.mocked(getMonthlyOrganizationResponseCount)).toHaveBeenCalledWith(mockOrganization.id);
|
||||
});
|
||||
|
||||
test("renders PendingDowngradeBanner when pending downgrade", async () => {
|
||||
// Ensure the license mock reflects the condition needed for the banner
|
||||
const pendingLicense = { ...mockLicense, isPendingDowngrade: true, active: true };
|
||||
vi.mocked(getEnterpriseLicense).mockResolvedValue(pendingLicense);
|
||||
|
||||
vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(null as any);
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue(pendingLicense),
|
||||
}));
|
||||
const { EnvironmentLayout } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||
);
|
||||
render(
|
||||
await EnvironmentLayout({
|
||||
environmentId: "env-1",
|
||||
@@ -256,12 +285,24 @@ describe("EnvironmentLayout", () => {
|
||||
children: <div>Child Content</div>,
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("downgrade-banner")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("throws error if user not found", async () => {
|
||||
vi.mocked(getUser).mockResolvedValue(null);
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: false,
|
||||
isPendingDowngrade: false,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { EnvironmentLayout } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||
);
|
||||
await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
|
||||
"common.user_not_found"
|
||||
);
|
||||
@@ -269,6 +310,19 @@ describe("EnvironmentLayout", () => {
|
||||
|
||||
test("throws error if organization not found", async () => {
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null);
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: false,
|
||||
isPendingDowngrade: false,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { EnvironmentLayout } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||
);
|
||||
await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
|
||||
"common.organization_not_found"
|
||||
);
|
||||
@@ -276,13 +330,39 @@ describe("EnvironmentLayout", () => {
|
||||
|
||||
test("throws error if environment not found", async () => {
|
||||
vi.mocked(getEnvironment).mockResolvedValue(null);
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: false,
|
||||
isPendingDowngrade: false,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { EnvironmentLayout } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||
);
|
||||
await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
|
||||
"common.environment_not_found"
|
||||
);
|
||||
});
|
||||
|
||||
test("throws error if projects, environments or organizations not found", async () => {
|
||||
vi.mocked(getUserProjects).mockResolvedValue(null as any); // Simulate one of the promises failing
|
||||
vi.mocked(getUserProjects).mockResolvedValue(null as any);
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: false,
|
||||
isPendingDowngrade: false,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { EnvironmentLayout } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||
);
|
||||
await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
|
||||
"environments.projects_environments_organizations_not_found"
|
||||
);
|
||||
@@ -291,6 +371,19 @@ describe("EnvironmentLayout", () => {
|
||||
test("throws error if member has no project permission", async () => {
|
||||
vi.mocked(getAccessFlags).mockReturnValue({ isMember: true } as any);
|
||||
vi.mocked(getProjectPermissionByUserId).mockResolvedValue(null);
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: false,
|
||||
isPendingDowngrade: false,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { EnvironmentLayout } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||
);
|
||||
await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
|
||||
"common.project_permission_not_found"
|
||||
);
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
} from "@/lib/organization/service";
|
||||
import { getUserProjects } from "@/lib/project/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getEnterpriseLicense, getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
|
||||
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
||||
import { DevEnvironmentBanner } from "@/modules/ui/components/dev-environment-banner";
|
||||
import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-banner";
|
||||
|
||||
@@ -10,7 +10,6 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TMembership } from "@formbricks/types/memberships";
|
||||
import { TOrganization, TOrganizationBilling } from "@formbricks/types/organizations";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import EnterpriseSettingsPage from "./page";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
@@ -179,15 +178,23 @@ describe("EnterpriseSettingsPage", () => {
|
||||
});
|
||||
|
||||
test("renders correctly for an owner when not on Formbricks Cloud", async () => {
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: false,
|
||||
isPendingDowngrade: false,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { default: EnterpriseSettingsPage } = await import("./page");
|
||||
const Page = await EnterpriseSettingsPage({ params: { environmentId: mockEnvironmentId } });
|
||||
render(Page);
|
||||
|
||||
expect(screen.getByTestId("page-content-wrapper")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("page-header")).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText("environments.settings.enterprise.sso")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.settings.billing.remove_branding")).toBeInTheDocument();
|
||||
|
||||
expect(redirect).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
@@ -118,7 +118,7 @@ const Page = async (props) => {
|
||||
<div className="relative isolate mt-8 overflow-hidden rounded-lg bg-slate-900 px-3 pt-8 shadow-2xl sm:px-8 md:pt-12 lg:flex lg:gap-x-10 lg:px-12 lg:pt-0">
|
||||
<svg
|
||||
viewBox="0 0 1024 1024"
|
||||
className="absolute top-1/2 left-1/2 -z-10 h-[64rem] w-[64rem] -translate-y-1/2 [mask-image:radial-gradient(closest-side,white,transparent)] sm:left-full sm:-ml-80 lg:left-1/2 lg:ml-0 lg:-translate-x-1/2 lg:translate-y-0"
|
||||
className="absolute left-1/2 top-1/2 -z-10 h-[64rem] w-[64rem] -translate-y-1/2 [mask-image:radial-gradient(closest-side,white,transparent)] sm:left-full sm:-ml-80 lg:left-1/2 lg:ml-0 lg:-translate-x-1/2 lg:translate-y-0"
|
||||
aria-hidden="true">
|
||||
<circle
|
||||
cx={512}
|
||||
|
||||
@@ -1,235 +0,0 @@
|
||||
import {
|
||||
mockContactEmailFollowUp,
|
||||
mockDirectEmailFollowUp,
|
||||
mockEndingFollowUp,
|
||||
mockEndingId2,
|
||||
mockResponse,
|
||||
mockResponseEmailFollowUp,
|
||||
mockResponseWithContactQuestion,
|
||||
mockSurvey,
|
||||
mockSurveyWithContactQuestion,
|
||||
} from "@/app/api/(internal)/pipeline/lib/__mocks__/survey-follow-up.mock";
|
||||
import { sendFollowUpEmail } from "@/modules/email";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { evaluateFollowUp, sendSurveyFollowUps } from "./survey-follow-up";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/modules/email", () => ({
|
||||
sendFollowUpEmail: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("Survey Follow Up", () => {
|
||||
const mockOrganization: Partial<TOrganization> = {
|
||||
id: "org1",
|
||||
name: "Test Org",
|
||||
whitelabel: {
|
||||
logoUrl: "https://example.com/logo.png",
|
||||
},
|
||||
};
|
||||
|
||||
describe("evaluateFollowUp", () => {
|
||||
test("sends email when to is a direct email address", async () => {
|
||||
const followUpId = mockDirectEmailFollowUp.id;
|
||||
const followUpAction = mockDirectEmailFollowUp.action;
|
||||
|
||||
await evaluateFollowUp(
|
||||
followUpId,
|
||||
followUpAction,
|
||||
mockSurvey,
|
||||
mockResponse,
|
||||
mockOrganization as TOrganization
|
||||
);
|
||||
|
||||
expect(sendFollowUpEmail).toHaveBeenCalledWith({
|
||||
html: mockDirectEmailFollowUp.action.properties.body,
|
||||
subject: mockDirectEmailFollowUp.action.properties.subject,
|
||||
to: mockDirectEmailFollowUp.action.properties.to,
|
||||
replyTo: mockDirectEmailFollowUp.action.properties.replyTo,
|
||||
survey: mockSurvey,
|
||||
response: mockResponse,
|
||||
attachResponseData: true,
|
||||
logoUrl: "https://example.com/logo.png",
|
||||
});
|
||||
});
|
||||
|
||||
test("sends email when to is a question ID with valid email", async () => {
|
||||
const followUpId = mockResponseEmailFollowUp.id;
|
||||
const followUpAction = mockResponseEmailFollowUp.action;
|
||||
|
||||
await evaluateFollowUp(
|
||||
followUpId,
|
||||
followUpAction,
|
||||
mockSurvey as TSurvey,
|
||||
mockResponse as TResponse,
|
||||
mockOrganization as TOrganization
|
||||
);
|
||||
|
||||
expect(sendFollowUpEmail).toHaveBeenCalledWith({
|
||||
html: mockResponseEmailFollowUp.action.properties.body,
|
||||
subject: mockResponseEmailFollowUp.action.properties.subject,
|
||||
to: mockResponse.data[mockResponseEmailFollowUp.action.properties.to],
|
||||
replyTo: mockResponseEmailFollowUp.action.properties.replyTo,
|
||||
survey: mockSurvey,
|
||||
response: mockResponse,
|
||||
attachResponseData: true,
|
||||
logoUrl: "https://example.com/logo.png",
|
||||
});
|
||||
});
|
||||
|
||||
test("sends email when to is a question ID with valid email in array", async () => {
|
||||
const followUpId = mockContactEmailFollowUp.id;
|
||||
const followUpAction = mockContactEmailFollowUp.action;
|
||||
|
||||
await evaluateFollowUp(
|
||||
followUpId,
|
||||
followUpAction,
|
||||
mockSurveyWithContactQuestion,
|
||||
mockResponseWithContactQuestion,
|
||||
mockOrganization as TOrganization
|
||||
);
|
||||
|
||||
expect(sendFollowUpEmail).toHaveBeenCalledWith({
|
||||
html: mockContactEmailFollowUp.action.properties.body,
|
||||
subject: mockContactEmailFollowUp.action.properties.subject,
|
||||
to: mockResponseWithContactQuestion.data[mockContactEmailFollowUp.action.properties.to][2],
|
||||
replyTo: mockContactEmailFollowUp.action.properties.replyTo,
|
||||
survey: mockSurveyWithContactQuestion,
|
||||
response: mockResponseWithContactQuestion,
|
||||
attachResponseData: true,
|
||||
logoUrl: "https://example.com/logo.png",
|
||||
});
|
||||
});
|
||||
|
||||
test("throws error when to value is not found in response data", async () => {
|
||||
const followUpId = "followup1";
|
||||
const followUpAction = {
|
||||
...mockSurvey.followUps![0].action,
|
||||
properties: {
|
||||
...mockSurvey.followUps![0].action.properties,
|
||||
to: "nonExistentField",
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
evaluateFollowUp(
|
||||
followUpId,
|
||||
followUpAction,
|
||||
mockSurvey as TSurvey,
|
||||
mockResponse as TResponse,
|
||||
mockOrganization as TOrganization
|
||||
)
|
||||
).rejects.toThrow(`"To" value not found in response data for followup: ${followUpId}`);
|
||||
});
|
||||
|
||||
test("throws error when email address is invalid", async () => {
|
||||
const followUpId = mockResponseEmailFollowUp.id;
|
||||
const followUpAction = mockResponseEmailFollowUp.action;
|
||||
|
||||
const invalidResponse = {
|
||||
...mockResponse,
|
||||
data: {
|
||||
[mockResponseEmailFollowUp.action.properties.to]: "invalid-email",
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
evaluateFollowUp(
|
||||
followUpId,
|
||||
followUpAction,
|
||||
mockSurvey,
|
||||
invalidResponse,
|
||||
mockOrganization as TOrganization
|
||||
)
|
||||
).rejects.toThrow(`Email address is not valid for followup: ${followUpId}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendSurveyFollowUps", () => {
|
||||
test("skips follow-up when ending Id doesn't match", async () => {
|
||||
const responseWithDifferentEnding = {
|
||||
...mockResponse,
|
||||
endingId: mockEndingId2,
|
||||
};
|
||||
|
||||
const mockSurveyWithEndingFollowUp: TSurvey = {
|
||||
...mockSurvey,
|
||||
followUps: [mockEndingFollowUp],
|
||||
};
|
||||
|
||||
const results = await sendSurveyFollowUps(
|
||||
mockSurveyWithEndingFollowUp,
|
||||
responseWithDifferentEnding as TResponse,
|
||||
mockOrganization as TOrganization
|
||||
);
|
||||
|
||||
expect(results).toEqual([
|
||||
{
|
||||
followUpId: mockEndingFollowUp.id,
|
||||
status: "skipped",
|
||||
},
|
||||
]);
|
||||
expect(sendFollowUpEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("processes follow-ups and log errors", async () => {
|
||||
const error = new Error("Test error");
|
||||
vi.mocked(sendFollowUpEmail).mockRejectedValueOnce(error);
|
||||
|
||||
const mockSurveyWithFollowUps: TSurvey = {
|
||||
...mockSurvey,
|
||||
followUps: [mockResponseEmailFollowUp],
|
||||
};
|
||||
|
||||
const results = await sendSurveyFollowUps(
|
||||
mockSurveyWithFollowUps,
|
||||
mockResponse,
|
||||
mockOrganization as TOrganization
|
||||
);
|
||||
|
||||
expect(results).toEqual([
|
||||
{
|
||||
followUpId: mockResponseEmailFollowUp.id,
|
||||
status: "error",
|
||||
error: "Test error",
|
||||
},
|
||||
]);
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
[`FollowUp ${mockResponseEmailFollowUp.id} failed: Test error`],
|
||||
"Follow-up processing errors"
|
||||
);
|
||||
});
|
||||
|
||||
test("successfully processes follow-ups", async () => {
|
||||
vi.mocked(sendFollowUpEmail).mockResolvedValueOnce(undefined);
|
||||
|
||||
const mockSurveyWithFollowUp: TSurvey = {
|
||||
...mockSurvey,
|
||||
followUps: [mockDirectEmailFollowUp],
|
||||
};
|
||||
|
||||
const results = await sendSurveyFollowUps(
|
||||
mockSurveyWithFollowUp,
|
||||
mockResponse,
|
||||
mockOrganization as TOrganization
|
||||
);
|
||||
|
||||
expect(results).toEqual([
|
||||
{
|
||||
followUpId: mockDirectEmailFollowUp.id,
|
||||
status: "success",
|
||||
},
|
||||
]);
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,135 +0,0 @@
|
||||
import { sendFollowUpEmail } from "@/modules/email";
|
||||
import { z } from "zod";
|
||||
import { TSurveyFollowUpAction } from "@formbricks/database/types/survey-follow-up";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
|
||||
type FollowUpResult = {
|
||||
followUpId: string;
|
||||
status: "success" | "error" | "skipped";
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export const evaluateFollowUp = async (
|
||||
followUpId: string,
|
||||
followUpAction: TSurveyFollowUpAction,
|
||||
survey: TSurvey,
|
||||
response: TResponse,
|
||||
organization: TOrganization
|
||||
): Promise<void> => {
|
||||
const { properties } = followUpAction;
|
||||
const { to, subject, body, replyTo } = properties;
|
||||
const toValueFromResponse = response.data[to];
|
||||
const logoUrl = organization.whitelabel?.logoUrl || "";
|
||||
|
||||
// Check if 'to' is a direct email address (team member or user email)
|
||||
const parsedEmailTo = z.string().email().safeParse(to);
|
||||
if (parsedEmailTo.success) {
|
||||
// 'to' is a valid email address, send email directly
|
||||
await sendFollowUpEmail({
|
||||
html: body,
|
||||
subject,
|
||||
to: parsedEmailTo.data,
|
||||
replyTo,
|
||||
survey,
|
||||
response,
|
||||
attachResponseData: properties.attachResponseData,
|
||||
logoUrl,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// If not a direct email, check if it's a question ID or hidden field ID
|
||||
if (!toValueFromResponse) {
|
||||
throw new Error(`"To" value not found in response data for followup: ${followUpId}`);
|
||||
}
|
||||
|
||||
if (typeof toValueFromResponse === "string") {
|
||||
// parse this string to check for an email:
|
||||
const parsedResult = z.string().email().safeParse(toValueFromResponse);
|
||||
if (parsedResult.data) {
|
||||
// send email to this email address
|
||||
await sendFollowUpEmail({
|
||||
html: body,
|
||||
subject,
|
||||
to: parsedResult.data,
|
||||
replyTo,
|
||||
logoUrl,
|
||||
survey,
|
||||
response,
|
||||
attachResponseData: properties.attachResponseData,
|
||||
});
|
||||
} else {
|
||||
throw new Error(`Email address is not valid for followup: ${followUpId}`);
|
||||
}
|
||||
} else if (Array.isArray(toValueFromResponse)) {
|
||||
const emailAddress = toValueFromResponse[2];
|
||||
if (!emailAddress) {
|
||||
throw new Error(`Email address not found in response data for followup: ${followUpId}`);
|
||||
}
|
||||
const parsedResult = z.string().email().safeParse(emailAddress);
|
||||
if (parsedResult.data) {
|
||||
await sendFollowUpEmail({
|
||||
html: body,
|
||||
subject,
|
||||
to: parsedResult.data,
|
||||
replyTo,
|
||||
logoUrl,
|
||||
survey,
|
||||
response,
|
||||
attachResponseData: properties.attachResponseData,
|
||||
});
|
||||
} else {
|
||||
throw new Error(`Email address is not valid for followup: ${followUpId}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const sendSurveyFollowUps = async (
|
||||
survey: TSurvey,
|
||||
response: TResponse,
|
||||
organization: TOrganization
|
||||
): Promise<FollowUpResult[]> => {
|
||||
const followUpPromises = survey.followUps.map(async (followUp): Promise<FollowUpResult> => {
|
||||
const { trigger } = followUp;
|
||||
|
||||
// Check if we should skip this follow-up based on ending IDs
|
||||
if (trigger.properties) {
|
||||
const { endingIds } = trigger.properties;
|
||||
const { endingId } = response;
|
||||
|
||||
if (!endingId || !endingIds.includes(endingId)) {
|
||||
return Promise.resolve({
|
||||
followUpId: followUp.id,
|
||||
status: "skipped",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return evaluateFollowUp(followUp.id, followUp.action, survey, response, organization)
|
||||
.then(() => ({
|
||||
followUpId: followUp.id,
|
||||
status: "success" as const,
|
||||
}))
|
||||
.catch((error) => ({
|
||||
followUpId: followUp.id,
|
||||
status: "error" as const,
|
||||
error: error instanceof Error ? error.message : "Something went wrong",
|
||||
}));
|
||||
});
|
||||
|
||||
const followUpResults = await Promise.all(followUpPromises);
|
||||
|
||||
// Log all errors
|
||||
const errors = followUpResults
|
||||
.filter((result): result is FollowUpResult & { status: "error" } => result.status === "error")
|
||||
.map((result) => `FollowUp ${result.followUpId} failed: ${result.error}`);
|
||||
|
||||
if (errors.length > 0) {
|
||||
logger.error(errors, "Follow-up processing errors");
|
||||
}
|
||||
|
||||
return followUpResults;
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
import { sendSurveyFollowUps } from "@/app/api/(internal)/pipeline/lib/survey-follow-up";
|
||||
import { ZPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
@@ -11,7 +10,8 @@ import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||
import { getSurvey, updateSurvey } from "@/lib/survey/service";
|
||||
import { convertDatesInObject } from "@/lib/time";
|
||||
import { sendResponseFinishedEmail } from "@/modules/email";
|
||||
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
|
||||
import { sendFollowUpsForResponse } from "@/modules/survey/follow-ups/lib/follow-ups";
|
||||
import { FollowUpSendError } from "@/modules/survey/follow-ups/types/follow-up";
|
||||
import { PipelineTriggers, Webhook } from "@prisma/client";
|
||||
import { headers } from "next/headers";
|
||||
import { prisma } from "@formbricks/database";
|
||||
@@ -164,11 +164,15 @@ export const POST = async (request: Request) => {
|
||||
select: { email: true, locale: true },
|
||||
});
|
||||
|
||||
// send follow up emails
|
||||
const surveyFollowUpsPermission = await getSurveyFollowUpsPermission(organization.billing.plan);
|
||||
|
||||
if (surveyFollowUpsPermission) {
|
||||
await sendSurveyFollowUps(survey, response, organization);
|
||||
if (survey.followUps?.length > 0) {
|
||||
// send follow up emails
|
||||
const followUpsResult = await sendFollowUpsForResponse(response.id);
|
||||
if (!followUpsResult.ok) {
|
||||
const { error: followUpsError } = followUpsResult;
|
||||
if (followUpsError.code !== FollowUpSendError.FOLLOW_UP_NOT_ALLOWED) {
|
||||
logger.error({ error: followUpsError }, `Failed to send follow-up emails for survey ${surveyId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const emailPromises = usersWithNotifications.map((user) =>
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -21,6 +21,10 @@ export const getSurveysForEnvironmentState = reactCache(
|
||||
where: {
|
||||
environmentId,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
take: 30,
|
||||
select: {
|
||||
id: true,
|
||||
welcomeCard: true,
|
||||
|
||||
@@ -33,6 +33,7 @@ export const responseSelection = {
|
||||
singleUseId: true,
|
||||
language: true,
|
||||
displayId: true,
|
||||
endingId: true,
|
||||
contact: {
|
||||
select: {
|
||||
id: true,
|
||||
|
||||
@@ -31,6 +31,7 @@ export const createResponse = async (responseInput: TResponseInputV2): Promise<T
|
||||
contactId,
|
||||
surveyId,
|
||||
displayId,
|
||||
endingId,
|
||||
finished,
|
||||
data,
|
||||
meta,
|
||||
@@ -64,7 +65,8 @@ export const createResponse = async (responseInput: TResponseInputV2): Promise<T
|
||||
},
|
||||
},
|
||||
display: displayId ? { connect: { id: displayId } } : undefined,
|
||||
finished: finished,
|
||||
finished,
|
||||
endingId,
|
||||
data: data,
|
||||
language: language,
|
||||
...(contact?.id && {
|
||||
|
||||
@@ -1,70 +1,99 @@
|
||||
import * as constants from "@/lib/constants";
|
||||
import { rateLimit } from "@/lib/utils/rate-limit";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { loginLimiter, signupLimiter } from "./bucket";
|
||||
import type { Mock } from "vitest";
|
||||
|
||||
// Mock constants
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
ENTERPRISE_LICENSE_KEY: undefined,
|
||||
REDIS_HTTP_URL: undefined,
|
||||
LOGIN_RATE_LIMIT: {
|
||||
interval: 15 * 60,
|
||||
allowedPerInterval: 5,
|
||||
},
|
||||
SIGNUP_RATE_LIMIT: {
|
||||
interval: 60 * 60,
|
||||
allowedPerInterval: 5,
|
||||
},
|
||||
VERIFY_EMAIL_RATE_LIMIT: {
|
||||
interval: 60 * 60,
|
||||
allowedPerInterval: 5,
|
||||
},
|
||||
FORGET_PASSWORD_RATE_LIMIT: {
|
||||
interval: 60 * 60,
|
||||
allowedPerInterval: 5,
|
||||
},
|
||||
CLIENT_SIDE_API_RATE_LIMIT: {
|
||||
interval: 60,
|
||||
allowedPerInterval: 5,
|
||||
},
|
||||
SHARE_RATE_LIMIT: {
|
||||
interval: 60,
|
||||
allowedPerInterval: 5,
|
||||
},
|
||||
SYNC_USER_IDENTIFICATION_RATE_LIMIT: {
|
||||
interval: 60,
|
||||
allowedPerInterval: 5,
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/utils/rate-limit", () => ({ rateLimit: vi.fn() }));
|
||||
|
||||
describe("Rate Limiters", () => {
|
||||
describe("bucket middleware rate limiters", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
const mockedRateLimit = rateLimit as unknown as Mock;
|
||||
mockedRateLimit.mockImplementation((config) => config);
|
||||
});
|
||||
|
||||
test("loginLimiter allows requests within limit", () => {
|
||||
const token = "test-token-1";
|
||||
// Should not throw for first request
|
||||
expect(() => loginLimiter(token)).not.toThrow();
|
||||
test("loginLimiter uses LOGIN_RATE_LIMIT settings", async () => {
|
||||
const { loginLimiter } = await import("./bucket");
|
||||
expect(rateLimit).toHaveBeenCalledWith({
|
||||
interval: constants.LOGIN_RATE_LIMIT.interval,
|
||||
allowedPerInterval: constants.LOGIN_RATE_LIMIT.allowedPerInterval,
|
||||
});
|
||||
expect(loginLimiter).toEqual({
|
||||
interval: constants.LOGIN_RATE_LIMIT.interval,
|
||||
allowedPerInterval: constants.LOGIN_RATE_LIMIT.allowedPerInterval,
|
||||
});
|
||||
});
|
||||
|
||||
test("loginLimiter throws when limit exceeded", () => {
|
||||
const token = "test-token-2";
|
||||
// Make multiple requests to exceed the limit
|
||||
for (let i = 0; i < 5; i++) {
|
||||
expect(() => loginLimiter(token)).not.toThrow();
|
||||
}
|
||||
// Next request should throw
|
||||
expect(() => loginLimiter(token)).toThrow("Rate limit exceeded");
|
||||
test("signupLimiter uses SIGNUP_RATE_LIMIT settings", async () => {
|
||||
const { signupLimiter } = await import("./bucket");
|
||||
expect(rateLimit).toHaveBeenCalledWith({
|
||||
interval: constants.SIGNUP_RATE_LIMIT.interval,
|
||||
allowedPerInterval: constants.SIGNUP_RATE_LIMIT.allowedPerInterval,
|
||||
});
|
||||
expect(signupLimiter).toEqual({
|
||||
interval: constants.SIGNUP_RATE_LIMIT.interval,
|
||||
allowedPerInterval: constants.SIGNUP_RATE_LIMIT.allowedPerInterval,
|
||||
});
|
||||
});
|
||||
|
||||
test("different limiters use different counters", () => {
|
||||
const token = "test-token-3";
|
||||
// Exceed login limit
|
||||
for (let i = 0; i < 5; i++) {
|
||||
expect(() => loginLimiter(token)).not.toThrow();
|
||||
}
|
||||
// Should throw for login
|
||||
expect(() => loginLimiter(token)).toThrow("Rate limit exceeded");
|
||||
// Should still be able to use signup limiter
|
||||
expect(() => signupLimiter(token)).not.toThrow();
|
||||
test("verifyEmailLimiter uses VERIFY_EMAIL_RATE_LIMIT settings", async () => {
|
||||
const { verifyEmailLimiter } = await import("./bucket");
|
||||
expect(rateLimit).toHaveBeenCalledWith({
|
||||
interval: constants.VERIFY_EMAIL_RATE_LIMIT.interval,
|
||||
allowedPerInterval: constants.VERIFY_EMAIL_RATE_LIMIT.allowedPerInterval,
|
||||
});
|
||||
expect(verifyEmailLimiter).toEqual({
|
||||
interval: constants.VERIFY_EMAIL_RATE_LIMIT.interval,
|
||||
allowedPerInterval: constants.VERIFY_EMAIL_RATE_LIMIT.allowedPerInterval,
|
||||
});
|
||||
});
|
||||
|
||||
test("forgotPasswordLimiter uses FORGET_PASSWORD_RATE_LIMIT settings", async () => {
|
||||
const { forgotPasswordLimiter } = await import("./bucket");
|
||||
expect(rateLimit).toHaveBeenCalledWith({
|
||||
interval: constants.FORGET_PASSWORD_RATE_LIMIT.interval,
|
||||
allowedPerInterval: constants.FORGET_PASSWORD_RATE_LIMIT.allowedPerInterval,
|
||||
});
|
||||
expect(forgotPasswordLimiter).toEqual({
|
||||
interval: constants.FORGET_PASSWORD_RATE_LIMIT.interval,
|
||||
allowedPerInterval: constants.FORGET_PASSWORD_RATE_LIMIT.allowedPerInterval,
|
||||
});
|
||||
});
|
||||
|
||||
test("clientSideApiEndpointsLimiter uses CLIENT_SIDE_API_RATE_LIMIT settings", async () => {
|
||||
const { clientSideApiEndpointsLimiter } = await import("./bucket");
|
||||
expect(rateLimit).toHaveBeenCalledWith({
|
||||
interval: constants.CLIENT_SIDE_API_RATE_LIMIT.interval,
|
||||
allowedPerInterval: constants.CLIENT_SIDE_API_RATE_LIMIT.allowedPerInterval,
|
||||
});
|
||||
expect(clientSideApiEndpointsLimiter).toEqual({
|
||||
interval: constants.CLIENT_SIDE_API_RATE_LIMIT.interval,
|
||||
allowedPerInterval: constants.CLIENT_SIDE_API_RATE_LIMIT.allowedPerInterval,
|
||||
});
|
||||
});
|
||||
|
||||
test("shareUrlLimiter uses SHARE_RATE_LIMIT settings", async () => {
|
||||
const { shareUrlLimiter } = await import("./bucket");
|
||||
expect(rateLimit).toHaveBeenCalledWith({
|
||||
interval: constants.SHARE_RATE_LIMIT.interval,
|
||||
allowedPerInterval: constants.SHARE_RATE_LIMIT.allowedPerInterval,
|
||||
});
|
||||
expect(shareUrlLimiter).toEqual({
|
||||
interval: constants.SHARE_RATE_LIMIT.interval,
|
||||
allowedPerInterval: constants.SHARE_RATE_LIMIT.allowedPerInterval,
|
||||
});
|
||||
});
|
||||
|
||||
test("syncUserIdentificationLimiter uses SYNC_USER_IDENTIFICATION_RATE_LIMIT settings", async () => {
|
||||
const { syncUserIdentificationLimiter } = await import("./bucket");
|
||||
expect(rateLimit).toHaveBeenCalledWith({
|
||||
interval: constants.SYNC_USER_IDENTIFICATION_RATE_LIMIT.interval,
|
||||
allowedPerInterval: constants.SYNC_USER_IDENTIFICATION_RATE_LIMIT.allowedPerInterval,
|
||||
});
|
||||
expect(syncUserIdentificationLimiter).toEqual({
|
||||
interval: constants.SYNC_USER_IDENTIFICATION_RATE_LIMIT.interval,
|
||||
allowedPerInterval: constants.SYNC_USER_IDENTIFICATION_RATE_LIMIT.allowedPerInterval,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { rateLimit } from "@/app/middleware/rate-limit";
|
||||
import {
|
||||
CLIENT_SIDE_API_RATE_LIMIT,
|
||||
FORGET_PASSWORD_RATE_LIMIT,
|
||||
@@ -8,6 +7,7 @@ import {
|
||||
SYNC_USER_IDENTIFICATION_RATE_LIMIT,
|
||||
VERIFY_EMAIL_RATE_LIMIT,
|
||||
} from "@/lib/constants";
|
||||
import { rateLimit } from "@/lib/utils/rate-limit";
|
||||
|
||||
export const loginLimiter = rateLimit({
|
||||
interval: LOGIN_RATE_LIMIT.interval,
|
||||
|
||||
58
apps/web/lib/utils/rate-limit.test.ts
Normal file
58
apps/web/lib/utils/rate-limit.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
describe("in-memory rate limiter", () => {
|
||||
test("allows requests within limit and throws after limit", async () => {
|
||||
const { rateLimit } = await import("./rate-limit");
|
||||
const limiterFn = rateLimit({ interval: 1, allowedPerInterval: 2 });
|
||||
await expect(limiterFn("a")).resolves.toBeUndefined();
|
||||
await expect(limiterFn("a")).resolves.toBeUndefined();
|
||||
await expect(limiterFn("a")).rejects.toThrow("Rate limit exceeded");
|
||||
});
|
||||
|
||||
test("separate tokens have separate counts", async () => {
|
||||
const { rateLimit } = await import("./rate-limit");
|
||||
const limiterFn = rateLimit({ interval: 1, allowedPerInterval: 2 });
|
||||
await expect(limiterFn("x")).resolves.toBeUndefined();
|
||||
await expect(limiterFn("y")).resolves.toBeUndefined();
|
||||
await expect(limiterFn("x")).resolves.toBeUndefined();
|
||||
await expect(limiterFn("y")).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("redis rate limiter", () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
const constants = await vi.importActual("@/lib/constants");
|
||||
vi.doMock("@/lib/constants", () => ({
|
||||
...constants,
|
||||
REDIS_HTTP_URL: "http://redis",
|
||||
}));
|
||||
});
|
||||
|
||||
test("sets expire on first use and does not throw", async () => {
|
||||
global.fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => ({ INCR: 1 }) })
|
||||
.mockResolvedValueOnce({ ok: true });
|
||||
const { rateLimit } = await import("./rate-limit");
|
||||
const limiter = rateLimit({ interval: 10, allowedPerInterval: 2 });
|
||||
await expect(limiter("t")).resolves.toBeUndefined();
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
expect(fetch).toHaveBeenCalledWith("http://redis/INCR/t");
|
||||
expect(fetch).toHaveBeenCalledWith("http://redis/EXPIRE/t/10");
|
||||
});
|
||||
|
||||
test("does not throw when redis INCR response not ok", async () => {
|
||||
global.fetch = vi.fn().mockResolvedValueOnce({ ok: false });
|
||||
const { rateLimit } = await import("./rate-limit");
|
||||
const limiter = rateLimit({ interval: 10, allowedPerInterval: 2 });
|
||||
await expect(limiter("t")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test("throws when INCR exceeds limit", async () => {
|
||||
global.fetch = vi.fn().mockResolvedValueOnce({ ok: true, json: async () => ({ INCR: 3 }) });
|
||||
const { rateLimit } = await import("./rate-limit");
|
||||
const limiter = rateLimit({ interval: 10, allowedPerInterval: 2 });
|
||||
await expect(limiter("t")).rejects.toThrow("Rate limit exceeded for IP: t");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ENTERPRISE_LICENSE_KEY, REDIS_HTTP_URL } from "@/lib/constants";
|
||||
import { REDIS_HTTP_URL } from "@/lib/constants";
|
||||
import { LRUCache } from "lru-cache";
|
||||
import { logger } from "@formbricks/logger";
|
||||
|
||||
@@ -13,7 +13,7 @@ const inMemoryRateLimiter = (options: Options) => {
|
||||
ttl: options.interval * 1000, // converts to expected input of milliseconds
|
||||
});
|
||||
|
||||
return (token: string) => {
|
||||
return async (token: string) => {
|
||||
const currentUsage = tokenCache.get(token) ?? 0;
|
||||
if (currentUsage >= options.allowedPerInterval) {
|
||||
throw new Error("Rate limit exceeded");
|
||||
@@ -40,12 +40,13 @@ const redisRateLimiter = (options: Options) => async (token: string) => {
|
||||
throw new Error();
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error({ error: e }, "Rate limit exceeded");
|
||||
throw new Error("Rate limit exceeded for IP: " + token);
|
||||
}
|
||||
};
|
||||
|
||||
export const rateLimit = (options: Options) => {
|
||||
if (REDIS_HTTP_URL && ENTERPRISE_LICENSE_KEY) {
|
||||
if (REDIS_HTTP_URL) {
|
||||
return redisRateLimiter(options);
|
||||
} else {
|
||||
return inMemoryRateLimiter(options);
|
||||
@@ -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 = {
|
||||
|
||||
@@ -56,6 +56,10 @@ vi.mock("@tolgee/react", async () => {
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
fatal: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import { Testimonial } from "@/modules/auth/components/testimonial";
|
||||
import {
|
||||
getIsMultiOrgEnabled,
|
||||
getIsSamlSsoEnabled,
|
||||
getisSsoEnabled,
|
||||
getIsSsoEnabled,
|
||||
} from "@/modules/ee/license-check/lib/utils";
|
||||
import { Metadata } from "next";
|
||||
import { LoginForm } from "./components/login-form";
|
||||
@@ -29,7 +29,7 @@ export const metadata: Metadata = {
|
||||
export const LoginPage = async () => {
|
||||
const [isMultiOrgEnabled, isSsoEnabled, isSamlSsoEnabled] = await Promise.all([
|
||||
getIsMultiOrgEnabled(),
|
||||
getisSsoEnabled(),
|
||||
getIsSsoEnabled(),
|
||||
getIsSamlSsoEnabled(),
|
||||
]);
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { getIsValidInviteToken } from "@/modules/auth/signup/lib/invite";
|
||||
import {
|
||||
getIsMultiOrgEnabled,
|
||||
getIsSamlSsoEnabled,
|
||||
getisSsoEnabled,
|
||||
getIsSsoEnabled,
|
||||
} from "@/modules/ee/license-check/lib/utils";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
@@ -29,8 +29,14 @@ vi.mock("@/modules/auth/signup/components/signup-form", () => ({
|
||||
|
||||
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
|
||||
getIsMultiOrgEnabled: vi.fn(),
|
||||
getIsSsoEnabled: vi.fn(),
|
||||
getIsSamlSsoEnabled: vi.fn(),
|
||||
getisSsoEnabled: vi.fn(),
|
||||
getIsWhitelabelEnabled: vi.fn(),
|
||||
getIsRemoveBrandingEnabled: vi.fn(),
|
||||
getIsContactsEnabled: vi.fn(),
|
||||
getIsAiEnabled: vi.fn(),
|
||||
getIsSpamProtectionEnabled: vi.fn(),
|
||||
getIsPendingDowngrade: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/auth/signup/lib/invite", () => ({
|
||||
@@ -89,7 +95,6 @@ vi.mock("@/lib/constants", () => ({
|
||||
AZURE_OAUTH_ENABLED: true,
|
||||
OIDC_OAUTH_ENABLED: true,
|
||||
DEFAULT_ORGANIZATION_ID: "test-default-organization-id",
|
||||
DEFAULT_ORGANIZATION_ROLE: "test-default-organization-role",
|
||||
IS_TURNSTILE_CONFIGURED: true,
|
||||
SAML_TENANT: "test-saml-tenant",
|
||||
SAML_PRODUCT: "test-saml-product",
|
||||
@@ -114,7 +119,7 @@ describe("SignupPage", () => {
|
||||
test("renders the signup page with all components when signup is enabled", async () => {
|
||||
// Mock the license check functions to return true
|
||||
vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(true);
|
||||
vi.mocked(getisSsoEnabled).mockResolvedValue(true);
|
||||
vi.mocked(getIsSsoEnabled).mockResolvedValue(true);
|
||||
vi.mocked(getIsSamlSsoEnabled).mockResolvedValue(true);
|
||||
vi.mocked(findMatchingLocale).mockResolvedValue("en-US");
|
||||
vi.mocked(verifyInviteToken).mockReturnValue({
|
||||
@@ -173,7 +178,7 @@ describe("SignupPage", () => {
|
||||
test("renders the page with email from search params", async () => {
|
||||
// Mock the license check functions to return true
|
||||
vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(true);
|
||||
vi.mocked(getisSsoEnabled).mockResolvedValue(true);
|
||||
vi.mocked(getIsSsoEnabled).mockResolvedValue(true);
|
||||
vi.mocked(getIsSamlSsoEnabled).mockResolvedValue(true);
|
||||
vi.mocked(findMatchingLocale).mockResolvedValue("en-US");
|
||||
vi.mocked(verifyInviteToken).mockReturnValue({
|
||||
|
||||
@@ -24,7 +24,7 @@ import { getIsValidInviteToken } from "@/modules/auth/signup/lib/invite";
|
||||
import {
|
||||
getIsMultiOrgEnabled,
|
||||
getIsSamlSsoEnabled,
|
||||
getisSsoEnabled,
|
||||
getIsSsoEnabled,
|
||||
} from "@/modules/ee/license-check/lib/utils";
|
||||
import { notFound } from "next/navigation";
|
||||
import { SignupForm } from "./components/signup-form";
|
||||
@@ -34,7 +34,7 @@ export const SignupPage = async ({ searchParams: searchParamsProps }) => {
|
||||
const inviteToken = searchParams["inviteToken"] ?? null;
|
||||
const [isMultOrgEnabled, isSsoEnabled, isSamlSsoEnabled] = await Promise.all([
|
||||
getIsMultiOrgEnabled(),
|
||||
getisSsoEnabled(),
|
||||
getIsSsoEnabled(),
|
||||
getIsSamlSsoEnabled(),
|
||||
]);
|
||||
|
||||
|
||||
117
apps/web/modules/cache/lib/service.test.ts
vendored
Normal file
117
apps/web/modules/cache/lib/service.test.ts
vendored
Normal file
@@ -0,0 +1,117 @@
|
||||
import KeyvRedis from "@keyv/redis";
|
||||
import { createCache } from "cache-manager";
|
||||
import { Keyv } from "keyv";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { logger } from "@formbricks/logger";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("keyv");
|
||||
vi.mock("@keyv/redis");
|
||||
vi.mock("cache-manager");
|
||||
vi.mock("@formbricks/logger");
|
||||
|
||||
const mockCacheInstance = {
|
||||
set: vi.fn(),
|
||||
get: vi.fn(),
|
||||
del: vi.fn(),
|
||||
};
|
||||
|
||||
const CACHE_TTL_SECONDS = 60 * 60 * 24; // 24 hours
|
||||
const CACHE_TTL_MS = CACHE_TTL_SECONDS * 1000;
|
||||
|
||||
describe("Cache Service", () => {
|
||||
let originalRedisUrl: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
originalRedisUrl = process.env.REDIS_URL;
|
||||
vi.resetAllMocks();
|
||||
vi.resetModules(); // Crucial for re-running module initialization logic
|
||||
|
||||
// Setup default mock implementations
|
||||
vi.mocked(createCache).mockReturnValue(mockCacheInstance as any);
|
||||
vi.mocked(Keyv).mockClear(); // Clear any previous calls
|
||||
vi.mocked(KeyvRedis).mockClear(); // Clear any previous calls
|
||||
vi.mocked(logger.warn).mockClear(); // Clear logger warnings
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.REDIS_URL = originalRedisUrl;
|
||||
});
|
||||
|
||||
describe("Initialization and getCache", () => {
|
||||
test("should use Redis store and return it via getCache if REDIS_URL is set", async () => {
|
||||
process.env.REDIS_URL = "redis://localhost:6379";
|
||||
const { getCache } = await import("./service"); // Dynamically import
|
||||
|
||||
expect(KeyvRedis).toHaveBeenCalledWith("redis://localhost:6379");
|
||||
expect(Keyv).toHaveBeenCalledWith({
|
||||
store: expect.any(KeyvRedis),
|
||||
ttl: CACHE_TTL_MS,
|
||||
});
|
||||
expect(createCache).toHaveBeenCalledWith({
|
||||
stores: [expect.any(Keyv)],
|
||||
ttl: CACHE_TTL_MS,
|
||||
});
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
expect(logger.info).toHaveBeenCalledWith("Successfully connected to Redis cache");
|
||||
expect(getCache()).toBe(mockCacheInstance);
|
||||
});
|
||||
|
||||
test("should fall back to memory store if Redis connection fails", async () => {
|
||||
process.env.REDIS_URL = "redis://localhost:6379";
|
||||
const mockError = new Error("Connection refused");
|
||||
vi.mocked(KeyvRedis).mockImplementation(() => {
|
||||
throw mockError;
|
||||
});
|
||||
|
||||
const { getCache } = await import("./service"); // Dynamically import
|
||||
|
||||
expect(KeyvRedis).toHaveBeenCalledWith("redis://localhost:6379");
|
||||
expect(logger.error).toHaveBeenCalledWith("Failed to connect to Redis cache:", mockError);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
"Falling back to in-memory cache due to Redis connection failure"
|
||||
);
|
||||
expect(Keyv).toHaveBeenCalledWith({
|
||||
ttl: CACHE_TTL_MS,
|
||||
});
|
||||
expect(createCache).toHaveBeenCalledWith({
|
||||
stores: [expect.any(Keyv)],
|
||||
ttl: CACHE_TTL_MS,
|
||||
});
|
||||
expect(getCache()).toBe(mockCacheInstance);
|
||||
});
|
||||
|
||||
test("should use memory store, log warning, and return it via getCache if REDIS_URL is not set", async () => {
|
||||
delete process.env.REDIS_URL;
|
||||
const { getCache } = await import("./service"); // Dynamically import
|
||||
|
||||
expect(KeyvRedis).not.toHaveBeenCalled();
|
||||
expect(Keyv).toHaveBeenCalledWith({
|
||||
ttl: CACHE_TTL_MS,
|
||||
});
|
||||
expect(createCache).toHaveBeenCalledWith({
|
||||
stores: [expect.any(Keyv)],
|
||||
ttl: CACHE_TTL_MS,
|
||||
});
|
||||
expect(logger.warn).toHaveBeenCalledWith("REDIS_URL not found, falling back to in-memory cache.");
|
||||
expect(getCache()).toBe(mockCacheInstance);
|
||||
});
|
||||
|
||||
test("should use memory store, log warning, and return it via getCache if REDIS_URL is an empty string", async () => {
|
||||
process.env.REDIS_URL = ""; // Test with empty string
|
||||
const { getCache } = await import("./service"); // Dynamically import
|
||||
|
||||
// If REDIS_URL is "", it's falsy, so it should fall back to memory store
|
||||
expect(KeyvRedis).not.toHaveBeenCalled();
|
||||
expect(Keyv).toHaveBeenCalledWith({
|
||||
ttl: CACHE_TTL_MS, // Expect memory store configuration
|
||||
});
|
||||
expect(createCache).toHaveBeenCalledWith({
|
||||
stores: [expect.any(Keyv)],
|
||||
ttl: CACHE_TTL_MS,
|
||||
});
|
||||
expect(logger.warn).toHaveBeenCalledWith("REDIS_URL not found, falling back to in-memory cache.");
|
||||
expect(getCache()).toBe(mockCacheInstance);
|
||||
});
|
||||
});
|
||||
});
|
||||
55
apps/web/modules/cache/lib/service.ts
vendored
Normal file
55
apps/web/modules/cache/lib/service.ts
vendored
Normal file
@@ -0,0 +1,55 @@
|
||||
import "server-only";
|
||||
import KeyvRedis from "@keyv/redis";
|
||||
import { type Cache, createCache } from "cache-manager";
|
||||
import { Keyv } from "keyv";
|
||||
import { logger } from "@formbricks/logger";
|
||||
|
||||
const CACHE_TTL_SECONDS = 60 * 60 * 24; // 24 hours
|
||||
const CACHE_TTL_MS = CACHE_TTL_SECONDS * 1000;
|
||||
|
||||
let cache: Cache;
|
||||
|
||||
const initializeMemoryCache = (): void => {
|
||||
const memoryKeyvStore = new Keyv({
|
||||
ttl: CACHE_TTL_MS,
|
||||
});
|
||||
cache = createCache({
|
||||
stores: [memoryKeyvStore],
|
||||
ttl: CACHE_TTL_MS,
|
||||
});
|
||||
logger.info("Using in-memory cache");
|
||||
};
|
||||
|
||||
if (process.env.REDIS_URL) {
|
||||
try {
|
||||
const redisStore = new KeyvRedis(process.env.REDIS_URL);
|
||||
|
||||
// Gracefully fall back if Redis dies later on
|
||||
redisStore.on("error", (err) => {
|
||||
logger.error("Redis connection lost – switching to in-memory cache", { error: err });
|
||||
initializeMemoryCache();
|
||||
});
|
||||
|
||||
const redisKeyvStore = new Keyv({
|
||||
store: redisStore,
|
||||
ttl: CACHE_TTL_MS,
|
||||
});
|
||||
|
||||
cache = createCache({
|
||||
stores: [redisKeyvStore],
|
||||
ttl: CACHE_TTL_MS,
|
||||
});
|
||||
logger.info("Successfully connected to Redis cache");
|
||||
} catch (error) {
|
||||
logger.error("Failed to connect to Redis cache:", error);
|
||||
logger.warn("Falling back to in-memory cache due to Redis connection failure");
|
||||
initializeMemoryCache();
|
||||
}
|
||||
} else {
|
||||
logger.warn("REDIS_URL not found, falling back to in-memory cache.");
|
||||
initializeMemoryCache();
|
||||
}
|
||||
|
||||
export const getCache = (): Cache => {
|
||||
return cache;
|
||||
};
|
||||
475
apps/web/modules/ee/license-check/lib/license.test.ts
Normal file
475
apps/web/modules/ee/license-check/lib/license.test.ts
Normal file
@@ -0,0 +1,475 @@
|
||||
import {
|
||||
TEnterpriseLicenseDetails,
|
||||
TEnterpriseLicenseFeatures,
|
||||
} from "@/modules/ee/license-check/types/enterprise-license";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import type { Mock } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
|
||||
// Mock declarations must be at the top level
|
||||
vi.mock("@/lib/env", () => ({
|
||||
env: {
|
||||
ENTERPRISE_LICENSE_KEY: "test-license-key",
|
||||
VERCEL_URL: "some.vercel.url",
|
||||
FORMBRICKS_COM_URL: "https://app.formbricks.com",
|
||||
HTTPS_PROXY: undefined,
|
||||
HTTP_PROXY: undefined,
|
||||
},
|
||||
}));
|
||||
|
||||
const mockCache = {
|
||||
get: vi.fn(),
|
||||
set: vi.fn(),
|
||||
del: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
store: { name: "memory" },
|
||||
};
|
||||
|
||||
vi.mock("@/modules/cache/lib/service", () => ({
|
||||
getCache: () => mockCache,
|
||||
}));
|
||||
|
||||
vi.mock("node-fetch", () => ({
|
||||
default: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
response: {
|
||||
count: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock constants as they are used in the original license.ts indirectly
|
||||
vi.mock("@/lib/constants", async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
...(typeof actual === "object" && actual !== null ? actual : {}),
|
||||
IS_FORMBRICKS_CLOUD: false, // Default to self-hosted for most tests
|
||||
REVALIDATION_INTERVAL: 3600, // Example value
|
||||
ENTERPRISE_LICENSE_KEY: "test-license-key",
|
||||
};
|
||||
});
|
||||
|
||||
describe("License Core Logic", () => {
|
||||
let originalProcessEnv: NodeJS.ProcessEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
originalProcessEnv = { ...process.env };
|
||||
vi.resetAllMocks();
|
||||
mockCache.get.mockReset();
|
||||
mockCache.set.mockReset();
|
||||
mockCache.del.mockReset();
|
||||
vi.mocked(prisma.response.count).mockResolvedValue(100);
|
||||
vi.clearAllMocks();
|
||||
// Mock window to be undefined for server-side tests
|
||||
vi.stubGlobal("window", undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalProcessEnv;
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("getEnterpriseLicense", () => {
|
||||
const mockFetchedLicenseDetailsFeatures: TEnterpriseLicenseFeatures = {
|
||||
isMultiOrgEnabled: true,
|
||||
contacts: true,
|
||||
projects: 10,
|
||||
whitelabel: true,
|
||||
removeBranding: true,
|
||||
twoFactorAuth: true,
|
||||
sso: true,
|
||||
saml: true,
|
||||
spamProtection: true,
|
||||
ai: false,
|
||||
};
|
||||
const mockFetchedLicenseDetails: TEnterpriseLicenseDetails = {
|
||||
status: "active",
|
||||
features: mockFetchedLicenseDetailsFeatures,
|
||||
};
|
||||
|
||||
const expectedActiveLicenseState = {
|
||||
active: true,
|
||||
features: mockFetchedLicenseDetails.features,
|
||||
lastChecked: expect.any(Date),
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "live" as const,
|
||||
};
|
||||
|
||||
test("should return cached license from FETCH_LICENSE_CACHE_KEY if available and valid", async () => {
|
||||
const { getEnterpriseLicense } = await import("./license");
|
||||
const fetch = (await import("node-fetch")).default as Mock;
|
||||
|
||||
mockCache.get.mockImplementation(async (key) => {
|
||||
if (key.startsWith("formbricksEnterpriseLicense-details")) {
|
||||
return mockFetchedLicenseDetails;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const license = await getEnterpriseLicense();
|
||||
expect(license).toEqual(expectedActiveLicenseState);
|
||||
expect(mockCache.get).toHaveBeenCalledWith(
|
||||
expect.stringContaining("formbricksEnterpriseLicense-details")
|
||||
);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should fetch license if not in FETCH_LICENSE_CACHE_KEY", async () => {
|
||||
const { getEnterpriseLicense } = await import("./license");
|
||||
const fetch = (await import("node-fetch")).default as Mock;
|
||||
|
||||
mockCache.get.mockResolvedValue(null);
|
||||
(fetch as Mock).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: mockFetchedLicenseDetails }),
|
||||
} as any);
|
||||
|
||||
const license = await getEnterpriseLicense();
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
expect(mockCache.set).toHaveBeenCalledWith(
|
||||
expect.stringContaining("formbricksEnterpriseLicense-details"),
|
||||
mockFetchedLicenseDetails,
|
||||
expect.any(Number)
|
||||
);
|
||||
expect(mockCache.set).toHaveBeenCalledWith(
|
||||
expect.stringContaining("formbricksEnterpriseLicense-previousResult"),
|
||||
{
|
||||
active: true,
|
||||
features: mockFetchedLicenseDetails.features,
|
||||
lastChecked: expect.any(Date),
|
||||
version: 1,
|
||||
},
|
||||
expect.any(Number)
|
||||
);
|
||||
expect(license).toEqual(expectedActiveLicenseState);
|
||||
});
|
||||
|
||||
test("should use previous result if fetch fails and previous result exists and is within grace period", async () => {
|
||||
const { getEnterpriseLicense } = await import("./license");
|
||||
const fetch = (await import("node-fetch")).default as Mock;
|
||||
|
||||
const previousTime = new Date(Date.now() - 1 * 24 * 60 * 60 * 1000); // 1 day ago, within grace period
|
||||
const mockPreviousResult = {
|
||||
active: true,
|
||||
features: { removeBranding: true, projects: 5 },
|
||||
lastChecked: previousTime,
|
||||
version: 1,
|
||||
};
|
||||
mockCache.get.mockImplementation(async (key) => {
|
||||
if (key.startsWith("formbricksEnterpriseLicense-details")) return null;
|
||||
if (key.startsWith("formbricksEnterpriseLicense-previousResult")) return mockPreviousResult;
|
||||
return null;
|
||||
});
|
||||
(fetch as Mock).mockResolvedValueOnce({ ok: false, status: 500 } as any);
|
||||
|
||||
const license = await getEnterpriseLicense();
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
expect(license).toEqual({
|
||||
active: true,
|
||||
features: mockPreviousResult.features,
|
||||
lastChecked: previousTime,
|
||||
isPendingDowngrade: true,
|
||||
fallbackLevel: "grace" as const,
|
||||
});
|
||||
});
|
||||
|
||||
test("should return inactive and set new previousResult if fetch fails and previous result is outside grace period", async () => {
|
||||
const { getEnterpriseLicense } = await import("./license");
|
||||
const fetch = (await import("node-fetch")).default as Mock;
|
||||
|
||||
const previousTime = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000); // 5 days ago, outside grace period
|
||||
const mockPreviousResult = {
|
||||
active: true,
|
||||
features: { removeBranding: true },
|
||||
lastChecked: previousTime,
|
||||
version: 1,
|
||||
};
|
||||
mockCache.get.mockImplementation(async (key) => {
|
||||
if (key.startsWith("formbricksEnterpriseLicense-details")) return null;
|
||||
if (key.startsWith("formbricksEnterpriseLicense-previousResult")) return mockPreviousResult;
|
||||
return null;
|
||||
});
|
||||
(fetch as Mock).mockResolvedValueOnce({ ok: false, status: 500 } as any);
|
||||
|
||||
const license = await getEnterpriseLicense();
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
expect(mockCache.set).toHaveBeenCalledWith(
|
||||
expect.stringContaining("formbricksEnterpriseLicense-previousResult"),
|
||||
{
|
||||
active: false,
|
||||
features: {
|
||||
isMultiOrgEnabled: false,
|
||||
projects: 3,
|
||||
twoFactorAuth: false,
|
||||
sso: false,
|
||||
whitelabel: false,
|
||||
removeBranding: false,
|
||||
contacts: false,
|
||||
ai: false,
|
||||
saml: false,
|
||||
spamProtection: false,
|
||||
},
|
||||
lastChecked: expect.any(Date),
|
||||
version: 1,
|
||||
},
|
||||
expect.any(Number)
|
||||
);
|
||||
expect(license).toEqual({
|
||||
active: false,
|
||||
features: {
|
||||
isMultiOrgEnabled: false,
|
||||
projects: 3,
|
||||
twoFactorAuth: false,
|
||||
sso: false,
|
||||
whitelabel: false,
|
||||
removeBranding: false,
|
||||
contacts: false,
|
||||
ai: false,
|
||||
saml: false,
|
||||
spamProtection: false,
|
||||
},
|
||||
lastChecked: expect.any(Date),
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "default" as const,
|
||||
});
|
||||
});
|
||||
|
||||
test("should return inactive with default features if fetch fails and no previous result (initial fail)", async () => {
|
||||
const { getEnterpriseLicense } = await import("./license");
|
||||
const fetch = (await import("node-fetch")).default as Mock;
|
||||
|
||||
mockCache.get.mockResolvedValue(null);
|
||||
(fetch as Mock).mockRejectedValueOnce(new Error("Network error"));
|
||||
|
||||
const license = await getEnterpriseLicense();
|
||||
const expectedFeatures: TEnterpriseLicenseFeatures = {
|
||||
isMultiOrgEnabled: false,
|
||||
projects: 3,
|
||||
twoFactorAuth: false,
|
||||
sso: false,
|
||||
whitelabel: false,
|
||||
removeBranding: false,
|
||||
contacts: false,
|
||||
ai: false,
|
||||
saml: false,
|
||||
spamProtection: false,
|
||||
};
|
||||
expect(mockCache.set).toHaveBeenCalledWith(
|
||||
expect.stringContaining("formbricksEnterpriseLicense-previousResult"),
|
||||
{
|
||||
active: false,
|
||||
features: expectedFeatures,
|
||||
lastChecked: expect.any(Date),
|
||||
version: 1,
|
||||
},
|
||||
expect.any(Number)
|
||||
);
|
||||
expect(license).toEqual({
|
||||
active: false,
|
||||
features: expectedFeatures,
|
||||
lastChecked: expect.any(Date),
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "default" as const,
|
||||
});
|
||||
});
|
||||
|
||||
test("should return inactive license if ENTERPRISE_LICENSE_KEY is not set in env", async () => {
|
||||
// Reset all mocks first
|
||||
vi.resetAllMocks();
|
||||
mockCache.get.mockReset();
|
||||
mockCache.set.mockReset();
|
||||
const fetch = (await import("node-fetch")).default as Mock;
|
||||
fetch.mockReset();
|
||||
|
||||
// Mock the env module with empty license key
|
||||
vi.doMock("@/lib/env", () => ({
|
||||
env: {
|
||||
ENTERPRISE_LICENSE_KEY: "",
|
||||
VERCEL_URL: "some.vercel.url",
|
||||
FORMBRICKS_COM_URL: "https://app.formbricks.com",
|
||||
HTTPS_PROXY: undefined,
|
||||
HTTP_PROXY: undefined,
|
||||
},
|
||||
}));
|
||||
|
||||
// Re-import the module to apply the new mock
|
||||
const { getEnterpriseLicense } = await import("./license");
|
||||
const license = await getEnterpriseLicense();
|
||||
|
||||
expect(license).toEqual({
|
||||
active: false,
|
||||
features: null,
|
||||
lastChecked: expect.any(Date),
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "default" as const,
|
||||
});
|
||||
expect(mockCache.get).not.toHaveBeenCalled();
|
||||
expect(mockCache.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should handle fetch throwing an error and use grace period or return inactive", async () => {
|
||||
const { getEnterpriseLicense } = await import("./license");
|
||||
const fetch = (await import("node-fetch")).default as Mock;
|
||||
|
||||
mockCache.get.mockResolvedValue(null);
|
||||
(fetch as Mock).mockRejectedValueOnce(new Error("Network error"));
|
||||
|
||||
const license = await getEnterpriseLicense();
|
||||
expect(license).toEqual({
|
||||
active: false,
|
||||
features: null,
|
||||
lastChecked: expect.any(Date),
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "default" as const,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getLicenseFeatures", () => {
|
||||
test("should return features if license is active", async () => {
|
||||
// Set up environment before import
|
||||
vi.stubGlobal("window", undefined);
|
||||
vi.doMock("@/lib/env", () => ({
|
||||
env: {
|
||||
ENTERPRISE_LICENSE_KEY: "test-license-key",
|
||||
VERCEL_URL: "some.vercel.url",
|
||||
FORMBRICKS_COM_URL: "https://app.formbricks.com",
|
||||
HTTPS_PROXY: undefined,
|
||||
HTTP_PROXY: undefined,
|
||||
},
|
||||
}));
|
||||
// Import hashString to compute the expected cache key
|
||||
const { hashString } = await import("@/lib/hashString");
|
||||
const hashedKey = hashString("test-license-key");
|
||||
const detailsKey = `formbricksEnterpriseLicense-details-${hashedKey}`;
|
||||
// Patch the cache mock to match the actual key logic
|
||||
mockCache.get.mockImplementation(async (key) => {
|
||||
if (key === detailsKey) {
|
||||
return {
|
||||
status: "active",
|
||||
features: {
|
||||
isMultiOrgEnabled: true,
|
||||
contacts: true,
|
||||
projects: 5,
|
||||
whitelabel: true,
|
||||
removeBranding: true,
|
||||
twoFactorAuth: true,
|
||||
sso: true,
|
||||
saml: true,
|
||||
spamProtection: true,
|
||||
ai: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
// Import after env and mocks are set
|
||||
const { getLicenseFeatures } = await import("./license");
|
||||
const features = await getLicenseFeatures();
|
||||
expect(features).toEqual({
|
||||
isMultiOrgEnabled: true,
|
||||
contacts: true,
|
||||
projects: 5,
|
||||
whitelabel: true,
|
||||
removeBranding: true,
|
||||
twoFactorAuth: true,
|
||||
sso: true,
|
||||
saml: true,
|
||||
spamProtection: true,
|
||||
ai: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("should return null if license is inactive", async () => {
|
||||
const { getLicenseFeatures } = await import("./license");
|
||||
mockCache.get.mockImplementation(async (key) => {
|
||||
if (key.startsWith("formbricksEnterpriseLicense-details")) {
|
||||
return { status: "expired", features: null };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const features = await getLicenseFeatures();
|
||||
expect(features).toBeNull();
|
||||
});
|
||||
|
||||
test("should return null if getEnterpriseLicense throws", async () => {
|
||||
const { getLicenseFeatures } = await import("./license");
|
||||
mockCache.get.mockRejectedValue(new Error("Cache error"));
|
||||
|
||||
const features = await getLicenseFeatures();
|
||||
expect(features).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Cache Key Generation", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
mockCache.get.mockReset();
|
||||
mockCache.set.mockReset();
|
||||
mockCache.del.mockReset();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
test("should use 'browser' as cache key in browser environment", async () => {
|
||||
vi.stubGlobal("window", {});
|
||||
const { getEnterpriseLicense } = await import("./license");
|
||||
await getEnterpriseLicense();
|
||||
expect(mockCache.get).toHaveBeenCalledWith(
|
||||
expect.stringContaining("formbricksEnterpriseLicense-details-browser")
|
||||
);
|
||||
});
|
||||
|
||||
test("should use 'no-license' as cache key when ENTERPRISE_LICENSE_KEY is not set", async () => {
|
||||
vi.resetModules();
|
||||
vi.stubGlobal("window", undefined);
|
||||
vi.doMock("@/lib/env", () => ({
|
||||
env: {
|
||||
ENTERPRISE_LICENSE_KEY: undefined,
|
||||
VERCEL_URL: "some.vercel.url",
|
||||
FORMBRICKS_COM_URL: "https://app.formbricks.com",
|
||||
HTTPS_PROXY: undefined,
|
||||
HTTP_PROXY: undefined,
|
||||
},
|
||||
}));
|
||||
const { getEnterpriseLicense } = await import("./license");
|
||||
await getEnterpriseLicense();
|
||||
// The cache should NOT be accessed if there is no license key
|
||||
expect(mockCache.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should use hashed license key as cache key when ENTERPRISE_LICENSE_KEY is set", async () => {
|
||||
vi.resetModules();
|
||||
const testLicenseKey = "test-license-key";
|
||||
vi.stubGlobal("window", undefined);
|
||||
vi.doMock("@/lib/env", () => ({
|
||||
env: {
|
||||
ENTERPRISE_LICENSE_KEY: testLicenseKey,
|
||||
VERCEL_URL: "some.vercel.url",
|
||||
FORMBRICKS_COM_URL: "https://app.formbricks.com",
|
||||
HTTPS_PROXY: undefined,
|
||||
HTTP_PROXY: undefined,
|
||||
},
|
||||
}));
|
||||
const { hashString } = await import("@/lib/hashString");
|
||||
const expectedHash = hashString(testLicenseKey);
|
||||
const { getEnterpriseLicense } = await import("./license");
|
||||
await getEnterpriseLicense();
|
||||
expect(mockCache.get).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`formbricksEnterpriseLicense-details-${expectedHash}`)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Helper mock for process.env if not already globally available in test environment
|
||||
if (typeof process === "undefined") {
|
||||
global.process = { env: {} } as any;
|
||||
}
|
||||
vi.stubGlobal("process", global.process);
|
||||
395
apps/web/modules/ee/license-check/lib/license.ts
Normal file
395
apps/web/modules/ee/license-check/lib/license.ts
Normal file
@@ -0,0 +1,395 @@
|
||||
import { env } from "@/lib/env";
|
||||
import { hashString } from "@/lib/hashString";
|
||||
import { getCache } from "@/modules/cache/lib/service";
|
||||
import {
|
||||
TEnterpriseLicenseDetails,
|
||||
TEnterpriseLicenseFeatures,
|
||||
} from "@/modules/ee/license-check/types/enterprise-license";
|
||||
import { HttpsProxyAgent } from "https-proxy-agent";
|
||||
import fetch from "node-fetch";
|
||||
import { cache as reactCache } from "react";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
|
||||
// Configuration
|
||||
const CONFIG = {
|
||||
CACHE: {
|
||||
FETCH_LICENSE_TTL_MS: 24 * 60 * 60 * 1000, // 24 hours
|
||||
PREVIOUS_RESULT_TTL_MS: 4 * 24 * 60 * 60 * 1000, // 4 days
|
||||
GRACE_PERIOD_MS: 3 * 24 * 60 * 60 * 1000, // 3 days
|
||||
MAX_RETRIES: 3,
|
||||
RETRY_DELAY_MS: 1000,
|
||||
},
|
||||
API: {
|
||||
ENDPOINT: "https://ee.formbricks.com/api/licenses/check",
|
||||
TIMEOUT_MS: 5000,
|
||||
},
|
||||
} as const;
|
||||
|
||||
// Types
|
||||
type FallbackLevel = "live" | "cached" | "grace" | "default";
|
||||
|
||||
type TPreviousResult = {
|
||||
active: boolean;
|
||||
lastChecked: Date;
|
||||
features: TEnterpriseLicenseFeatures | null;
|
||||
version: number; // For cache versioning
|
||||
};
|
||||
|
||||
// Validation schemas
|
||||
const LicenseFeaturesSchema = z.object({
|
||||
isMultiOrgEnabled: z.boolean(),
|
||||
projects: z.number().nullable(),
|
||||
twoFactorAuth: z.boolean(),
|
||||
sso: z.boolean(),
|
||||
whitelabel: z.boolean(),
|
||||
removeBranding: z.boolean(),
|
||||
contacts: z.boolean(),
|
||||
ai: z.boolean(),
|
||||
saml: z.boolean(),
|
||||
spamProtection: z.boolean(),
|
||||
});
|
||||
|
||||
const LicenseDetailsSchema = z.object({
|
||||
status: z.enum(["active", "expired"]),
|
||||
features: LicenseFeaturesSchema,
|
||||
});
|
||||
|
||||
// Error types
|
||||
class LicenseError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly code: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = "LicenseError";
|
||||
}
|
||||
}
|
||||
|
||||
class LicenseApiError extends LicenseError {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly status: number
|
||||
) {
|
||||
super(message, "API_ERROR");
|
||||
this.name = "LicenseApiError";
|
||||
}
|
||||
}
|
||||
|
||||
// Cache keys
|
||||
const getHashedKey = () => {
|
||||
if (typeof window !== "undefined") {
|
||||
return "browser"; // Browser environment
|
||||
}
|
||||
if (!env.ENTERPRISE_LICENSE_KEY) {
|
||||
return "no-license"; // No license key provided
|
||||
}
|
||||
return hashString(env.ENTERPRISE_LICENSE_KEY); // Valid license key
|
||||
};
|
||||
|
||||
export const getCacheKeys = () => {
|
||||
const hashedKey = getHashedKey();
|
||||
return {
|
||||
FETCH_LICENSE_CACHE_KEY: `formbricksEnterpriseLicense-details-${hashedKey}`,
|
||||
PREVIOUS_RESULT_CACHE_KEY: `formbricksEnterpriseLicense-previousResult-${hashedKey}`,
|
||||
};
|
||||
};
|
||||
|
||||
// Default features
|
||||
const DEFAULT_FEATURES: TEnterpriseLicenseFeatures = {
|
||||
isMultiOrgEnabled: false,
|
||||
projects: 3,
|
||||
twoFactorAuth: false,
|
||||
sso: false,
|
||||
whitelabel: false,
|
||||
removeBranding: false,
|
||||
contacts: false,
|
||||
ai: false,
|
||||
saml: false,
|
||||
spamProtection: false,
|
||||
};
|
||||
|
||||
// Helper functions
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const validateConfig = () => {
|
||||
const errors: string[] = [];
|
||||
if (CONFIG.CACHE.GRACE_PERIOD_MS >= CONFIG.CACHE.PREVIOUS_RESULT_TTL_MS) {
|
||||
errors.push("Grace period must be shorter than previous result TTL");
|
||||
}
|
||||
if (CONFIG.CACHE.MAX_RETRIES < 0) {
|
||||
errors.push("Max retries must be non-negative");
|
||||
}
|
||||
if (errors.length > 0) {
|
||||
throw new LicenseError(errors.join(", "), "CONFIG_ERROR");
|
||||
}
|
||||
};
|
||||
|
||||
// Cache functions
|
||||
const getPreviousResult = async (): Promise<TPreviousResult> => {
|
||||
if (typeof window !== "undefined") {
|
||||
return {
|
||||
active: false,
|
||||
lastChecked: new Date(0),
|
||||
features: DEFAULT_FEATURES,
|
||||
version: 1,
|
||||
};
|
||||
}
|
||||
|
||||
const formbricksCache = getCache();
|
||||
const cachedData = await formbricksCache.get<TPreviousResult>(getCacheKeys().PREVIOUS_RESULT_CACHE_KEY);
|
||||
if (cachedData) {
|
||||
return {
|
||||
...cachedData,
|
||||
lastChecked: new Date(cachedData.lastChecked),
|
||||
};
|
||||
}
|
||||
return {
|
||||
active: false,
|
||||
lastChecked: new Date(0),
|
||||
features: DEFAULT_FEATURES,
|
||||
version: 1,
|
||||
};
|
||||
};
|
||||
|
||||
const setPreviousResult = async (previousResult: TPreviousResult) => {
|
||||
if (typeof window !== "undefined") return;
|
||||
|
||||
const formbricksCache = getCache();
|
||||
await formbricksCache.set(
|
||||
getCacheKeys().PREVIOUS_RESULT_CACHE_KEY,
|
||||
previousResult,
|
||||
CONFIG.CACHE.PREVIOUS_RESULT_TTL_MS
|
||||
);
|
||||
};
|
||||
|
||||
// Monitoring functions
|
||||
const trackFallbackUsage = (level: FallbackLevel) => {
|
||||
logger.info(`Using license fallback level: ${level}`, {
|
||||
fallbackLevel: level,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
};
|
||||
|
||||
const trackApiError = (error: LicenseApiError) => {
|
||||
logger.error(`License API error: ${error.message}`, {
|
||||
status: error.status,
|
||||
code: error.code,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
};
|
||||
|
||||
// Validation functions
|
||||
const validateFallback = (previousResult: TPreviousResult): boolean => {
|
||||
if (!previousResult.features) return false;
|
||||
if (previousResult.lastChecked.getTime() === new Date(0).getTime()) return false;
|
||||
if (previousResult.version !== 1) return false; // Add version check
|
||||
return true;
|
||||
};
|
||||
|
||||
const validateLicenseDetails = (data: unknown): TEnterpriseLicenseDetails => {
|
||||
return LicenseDetailsSchema.parse(data);
|
||||
};
|
||||
|
||||
// Fallback functions
|
||||
const getFallbackLevel = (
|
||||
liveLicense: TEnterpriseLicenseDetails | null,
|
||||
previousResult: TPreviousResult,
|
||||
currentTime: Date
|
||||
): FallbackLevel => {
|
||||
if (liveLicense) return "live";
|
||||
if (previousResult.active) {
|
||||
const elapsedTime = currentTime.getTime() - previousResult.lastChecked.getTime();
|
||||
return elapsedTime < CONFIG.CACHE.GRACE_PERIOD_MS ? "grace" : "default";
|
||||
}
|
||||
return "default";
|
||||
};
|
||||
|
||||
const handleInitialFailure = async (currentTime: Date) => {
|
||||
const initialFailResult: TPreviousResult = {
|
||||
active: false,
|
||||
features: DEFAULT_FEATURES,
|
||||
lastChecked: currentTime,
|
||||
version: 1,
|
||||
};
|
||||
await setPreviousResult(initialFailResult);
|
||||
return {
|
||||
active: false,
|
||||
features: DEFAULT_FEATURES,
|
||||
lastChecked: currentTime,
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "default" as const,
|
||||
};
|
||||
};
|
||||
|
||||
// API functions
|
||||
const fetchLicenseFromServerInternal = async (retryCount = 0): Promise<TEnterpriseLicenseDetails | null> => {
|
||||
if (!env.ENTERPRISE_LICENSE_KEY) return null;
|
||||
|
||||
// Skip license checks during build time
|
||||
// eslint-disable-next-line turbo/no-undeclared-env-vars -- NEXT_PHASE is a next.js env variable
|
||||
if (process.env.NEXT_PHASE === "phase-production-build") {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const now = new Date();
|
||||
const startOfYear = new Date(now.getFullYear(), 0, 1);
|
||||
// first millisecond of next year => current year is fully included
|
||||
const startOfNextYear = new Date(now.getFullYear() + 1, 0, 1);
|
||||
|
||||
const responseCount = await prisma.response.count({
|
||||
where: {
|
||||
createdAt: {
|
||||
gte: startOfYear,
|
||||
lt: startOfNextYear,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const proxyUrl = env.HTTPS_PROXY ?? env.HTTP_PROXY;
|
||||
const agent = proxyUrl ? new HttpsProxyAgent(proxyUrl) : undefined;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), CONFIG.API.TIMEOUT_MS);
|
||||
|
||||
const res = await fetch(CONFIG.API.ENDPOINT, {
|
||||
body: JSON.stringify({
|
||||
licenseKey: env.ENTERPRISE_LICENSE_KEY,
|
||||
usage: { responseCount },
|
||||
}),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
agent,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (res.ok) {
|
||||
const responseJson = (await res.json()) as { data: unknown };
|
||||
return validateLicenseDetails(responseJson.data);
|
||||
}
|
||||
|
||||
const error = new LicenseApiError(`License check API responded with status: ${res.status}`, res.status);
|
||||
trackApiError(error);
|
||||
|
||||
// Retry on specific status codes
|
||||
if (retryCount < CONFIG.CACHE.MAX_RETRIES && [429, 502, 503, 504].includes(res.status)) {
|
||||
await sleep(CONFIG.CACHE.RETRY_DELAY_MS * Math.pow(2, retryCount));
|
||||
return fetchLicenseFromServerInternal(retryCount + 1);
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
if (error instanceof LicenseApiError) {
|
||||
throw error;
|
||||
}
|
||||
logger.error(error, "Error while fetching license from server");
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchLicense = async (): Promise<TEnterpriseLicenseDetails | null> => {
|
||||
if (!env.ENTERPRISE_LICENSE_KEY) return null;
|
||||
|
||||
const formbricksCache = getCache();
|
||||
const cachedLicense = await formbricksCache.get<TEnterpriseLicenseDetails>(
|
||||
getCacheKeys().FETCH_LICENSE_CACHE_KEY
|
||||
);
|
||||
|
||||
if (cachedLicense) {
|
||||
return cachedLicense;
|
||||
}
|
||||
|
||||
const licenseDetails = await fetchLicenseFromServerInternal();
|
||||
|
||||
if (licenseDetails) {
|
||||
await formbricksCache.set(
|
||||
getCacheKeys().FETCH_LICENSE_CACHE_KEY,
|
||||
licenseDetails,
|
||||
CONFIG.CACHE.FETCH_LICENSE_TTL_MS
|
||||
);
|
||||
}
|
||||
return licenseDetails;
|
||||
};
|
||||
|
||||
export const getEnterpriseLicense = reactCache(
|
||||
async (): Promise<{
|
||||
active: boolean;
|
||||
features: TEnterpriseLicenseFeatures | null;
|
||||
lastChecked: Date;
|
||||
isPendingDowngrade: boolean;
|
||||
fallbackLevel: FallbackLevel;
|
||||
}> => {
|
||||
validateConfig();
|
||||
|
||||
if (!env.ENTERPRISE_LICENSE_KEY || env.ENTERPRISE_LICENSE_KEY.length === 0) {
|
||||
return {
|
||||
active: false,
|
||||
features: null,
|
||||
lastChecked: new Date(),
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "default" as const,
|
||||
};
|
||||
}
|
||||
|
||||
const currentTime = new Date();
|
||||
const liveLicenseDetails = await fetchLicense();
|
||||
const previousResult = await getPreviousResult();
|
||||
const fallbackLevel = getFallbackLevel(liveLicenseDetails, previousResult, currentTime);
|
||||
|
||||
trackFallbackUsage(fallbackLevel);
|
||||
|
||||
let currentLicenseState: TPreviousResult | undefined;
|
||||
|
||||
switch (fallbackLevel) {
|
||||
case "live":
|
||||
if (!liveLicenseDetails) throw new Error("Invalid state: live license expected");
|
||||
currentLicenseState = {
|
||||
active: liveLicenseDetails.status === "active",
|
||||
features: liveLicenseDetails.features,
|
||||
lastChecked: currentTime,
|
||||
version: 1,
|
||||
};
|
||||
await setPreviousResult(currentLicenseState);
|
||||
return {
|
||||
active: currentLicenseState.active,
|
||||
features: currentLicenseState.features,
|
||||
lastChecked: currentTime,
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "live" as const,
|
||||
};
|
||||
|
||||
case "grace":
|
||||
if (!validateFallback(previousResult)) {
|
||||
return handleInitialFailure(currentTime);
|
||||
}
|
||||
return {
|
||||
active: previousResult.active,
|
||||
features: previousResult.features,
|
||||
lastChecked: previousResult.lastChecked,
|
||||
isPendingDowngrade: true,
|
||||
fallbackLevel: "grace" as const,
|
||||
};
|
||||
|
||||
case "default":
|
||||
return handleInitialFailure(currentTime);
|
||||
}
|
||||
|
||||
return handleInitialFailure(currentTime);
|
||||
}
|
||||
);
|
||||
|
||||
export const getLicenseFeatures = async (): Promise<TEnterpriseLicenseFeatures | null> => {
|
||||
try {
|
||||
const licenseState = await getEnterpriseLicense();
|
||||
return licenseState.active ? licenseState.features : null;
|
||||
} catch (e) {
|
||||
logger.error(e, "Error getting license features");
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// All permission checking functions and their helpers have been moved to utils.ts
|
||||
@@ -1,140 +1,479 @@
|
||||
import * as constants from "@/lib/constants";
|
||||
import { TEnterpriseLicenseFeatures } from "@/modules/ee/license-check/types/enterprise-license";
|
||||
import { Organization } from "@prisma/client";
|
||||
import fetch from "node-fetch";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import * as licenseModule from "./license";
|
||||
import {
|
||||
getBiggerUploadFileSizePermission,
|
||||
getIsContactsEnabled,
|
||||
getIsMultiOrgEnabled,
|
||||
getIsSamlSsoEnabled,
|
||||
getIsSpamProtectionEnabled,
|
||||
getIsSsoEnabled,
|
||||
getIsTwoFactorAuthEnabled,
|
||||
getLicenseFeatures,
|
||||
getMultiLanguagePermission,
|
||||
getOrganizationProjectsLimit,
|
||||
getRemoveBrandingPermission,
|
||||
getRoleManagementPermission,
|
||||
getWhiteLabelPermission,
|
||||
getisSsoEnabled,
|
||||
} from "./utils";
|
||||
|
||||
// Mock declarations must be at the top level
|
||||
vi.mock("@/lib/env", () => ({
|
||||
env: {
|
||||
ENTERPRISE_LICENSE_KEY: "test-license-key",
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/constants");
|
||||
vi.mock("./license");
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
E2E_TESTING: false,
|
||||
ENTERPRISE_LICENSE_KEY: "test-license-key",
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
IS_RECAPTCHA_CONFIGURED: true,
|
||||
PROJECT_FEATURE_KEYS: {
|
||||
removeBranding: "remove-branding",
|
||||
whiteLabel: "white-label",
|
||||
roleManagement: "role-management",
|
||||
biggerUploadFileSize: "bigger-upload-file-size",
|
||||
multiLanguage: "multi-language",
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/cache", () => ({
|
||||
cache: vi.fn((fn) => fn),
|
||||
revalidateTag: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/server", () => ({
|
||||
after: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node-fetch", () => ({
|
||||
default: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("License Check Utils", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("Feature Permissions", () => {
|
||||
const mockOrganization = {
|
||||
billing: {
|
||||
plan: "enterprise" as Organization["billing"]["plan"],
|
||||
limits: {
|
||||
projects: 3,
|
||||
},
|
||||
const mockOrganization = {
|
||||
billing: {
|
||||
plan: constants.PROJECT_FEATURE_KEYS.FREE,
|
||||
limits: {
|
||||
projects: 3,
|
||||
monthly: {
|
||||
responses: null,
|
||||
miu: null,
|
||||
},
|
||||
} as Organization;
|
||||
},
|
||||
},
|
||||
} as Organization;
|
||||
|
||||
test("getRemoveBrandingPermission", async () => {
|
||||
const defaultFeatures: TEnterpriseLicenseFeatures = {
|
||||
whitelabel: false,
|
||||
projects: null,
|
||||
isMultiOrgEnabled: false,
|
||||
contacts: false,
|
||||
removeBranding: false,
|
||||
twoFactorAuth: false,
|
||||
sso: false,
|
||||
saml: false,
|
||||
spamProtection: false,
|
||||
ai: false,
|
||||
};
|
||||
|
||||
const defaultLicense = {
|
||||
active: true,
|
||||
features: defaultFeatures,
|
||||
lastChecked: new Date(),
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "live" as const,
|
||||
};
|
||||
|
||||
describe("License Utils", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
// Set default values for constants
|
||||
vi.mocked(constants).IS_FORMBRICKS_CLOUD = false;
|
||||
vi.mocked(constants).IS_RECAPTCHA_CONFIGURED = true;
|
||||
vi.mocked(constants).PROJECT_FEATURE_KEYS = constants.PROJECT_FEATURE_KEYS;
|
||||
// Set default mocks for license
|
||||
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense);
|
||||
vi.mocked(licenseModule.getLicenseFeatures).mockResolvedValue(defaultFeatures);
|
||||
});
|
||||
|
||||
describe("getRemoveBrandingPermission", () => {
|
||||
test("should return true if license active and feature enabled (self-hosted)", async () => {
|
||||
vi.mocked(constants).IS_FORMBRICKS_CLOUD = false;
|
||||
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
|
||||
...defaultLicense,
|
||||
features: { ...defaultFeatures, removeBranding: true },
|
||||
});
|
||||
const result = await getRemoveBrandingPermission(mockOrganization.billing.plan);
|
||||
expect(result).toBe(false); // Default value when no license is active
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("getWhiteLabelPermission", async () => {
|
||||
const result = await getWhiteLabelPermission(mockOrganization.billing.plan);
|
||||
expect(result).toBe(false); // Default value when no license is active
|
||||
test("should return false if license active but feature disabled (self-hosted)", async () => {
|
||||
vi.mocked(constants).IS_FORMBRICKS_CLOUD = false;
|
||||
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
|
||||
...defaultLicense,
|
||||
features: { ...defaultFeatures, removeBranding: false },
|
||||
});
|
||||
const result = await getRemoveBrandingPermission(mockOrganization.billing.plan);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test("getRoleManagementPermission", async () => {
|
||||
const result = await getRoleManagementPermission(mockOrganization.billing.plan);
|
||||
expect(result).toBe(false); // Default value when no license is active
|
||||
test("should return true if license active and plan is not FREE (cloud)", async () => {
|
||||
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
|
||||
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense);
|
||||
const result = await getRemoveBrandingPermission(constants.PROJECT_FEATURE_KEYS.SCALE);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("getBiggerUploadFileSizePermission", async () => {
|
||||
const result = await getBiggerUploadFileSizePermission(mockOrganization.billing.plan);
|
||||
expect(result).toBe(false); // Default value when no license is active
|
||||
test("should return false if license active and plan is FREE (cloud)", async () => {
|
||||
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
|
||||
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense);
|
||||
const result = await getRemoveBrandingPermission(constants.PROJECT_FEATURE_KEYS.FREE);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test("getMultiLanguagePermission", async () => {
|
||||
const result = await getMultiLanguagePermission(mockOrganization.billing.plan);
|
||||
expect(result).toBe(false); // Default value when no license is active
|
||||
});
|
||||
|
||||
test("getIsMultiOrgEnabled", async () => {
|
||||
const result = await getIsMultiOrgEnabled();
|
||||
expect(typeof result).toBe("boolean");
|
||||
});
|
||||
|
||||
test("getIsContactsEnabled", async () => {
|
||||
const result = await getIsContactsEnabled();
|
||||
expect(typeof result).toBe("boolean");
|
||||
});
|
||||
|
||||
test("getIsTwoFactorAuthEnabled", async () => {
|
||||
const result = await getIsTwoFactorAuthEnabled();
|
||||
expect(typeof result).toBe("boolean");
|
||||
});
|
||||
|
||||
test("getisSsoEnabled", async () => {
|
||||
const result = await getisSsoEnabled();
|
||||
expect(typeof result).toBe("boolean");
|
||||
});
|
||||
|
||||
test("getIsSamlSsoEnabled", async () => {
|
||||
const result = await getIsSamlSsoEnabled();
|
||||
expect(typeof result).toBe("boolean");
|
||||
});
|
||||
|
||||
test("getIsSpamProtectionEnabled", async () => {
|
||||
const result = await getIsSpamProtectionEnabled(mockOrganization.billing.plan);
|
||||
expect(result).toBe(false); // Default value when no license is active
|
||||
});
|
||||
|
||||
test("getOrganizationProjectsLimit", async () => {
|
||||
const result = await getOrganizationProjectsLimit(mockOrganization.billing.limits);
|
||||
expect(result).toBe(3); // Default value from mock organization
|
||||
test("should return false if license is inactive", async () => {
|
||||
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
|
||||
...defaultLicense,
|
||||
active: false,
|
||||
});
|
||||
const result = await getRemoveBrandingPermission(mockOrganization.billing.plan);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("License Features", () => {
|
||||
test("getLicenseFeatures returns null when no license is active", async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
} as any);
|
||||
describe("getWhiteLabelPermission", () => {
|
||||
test("should return true if license active and feature enabled (self-hosted)", async () => {
|
||||
vi.mocked(constants).IS_FORMBRICKS_CLOUD = false;
|
||||
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
|
||||
...defaultLicense,
|
||||
features: { ...defaultFeatures, whitelabel: true },
|
||||
});
|
||||
const result = await getWhiteLabelPermission(mockOrganization.billing.plan);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
const result = await getLicenseFeatures();
|
||||
expect(result).toBeNull();
|
||||
test("should return true if license active and plan is not FREE (cloud)", async () => {
|
||||
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
|
||||
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense);
|
||||
const result = await getWhiteLabelPermission(constants.PROJECT_FEATURE_KEYS.SCALE);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false if license is inactive", async () => {
|
||||
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
|
||||
...defaultLicense,
|
||||
active: false,
|
||||
});
|
||||
const result = await getWhiteLabelPermission(mockOrganization.billing.plan);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRoleManagementPermission", () => {
|
||||
test("should return true if license active (self-hosted)", async () => {
|
||||
vi.mocked(constants).IS_FORMBRICKS_CLOUD = false;
|
||||
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense);
|
||||
const result = await getRoleManagementPermission(mockOrganization.billing.plan);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("should return true if license active and plan is SCALE (cloud)", async () => {
|
||||
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
|
||||
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense);
|
||||
const result = await getRoleManagementPermission(constants.PROJECT_FEATURE_KEYS.SCALE);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("should return true if license active and plan is ENTERPRISE (cloud)", async () => {
|
||||
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
|
||||
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense);
|
||||
const result = await getRoleManagementPermission(constants.PROJECT_FEATURE_KEYS.ENTERPRISE);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false if license active and plan is not SCALE or ENTERPRISE (cloud)", async () => {
|
||||
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
|
||||
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense);
|
||||
const result = await getRoleManagementPermission(constants.PROJECT_FEATURE_KEYS.STARTUP);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test("should return false if license is inactive", async () => {
|
||||
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
|
||||
...defaultLicense,
|
||||
active: false,
|
||||
});
|
||||
const result = await getRoleManagementPermission(mockOrganization.billing.plan);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBiggerUploadFileSizePermission", () => {
|
||||
test("should return true if license active (self-hosted)", async () => {
|
||||
vi.mocked(constants).IS_FORMBRICKS_CLOUD = false;
|
||||
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense);
|
||||
const result = await getBiggerUploadFileSizePermission(mockOrganization.billing.plan);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("should return true if license active and plan is not FREE (cloud)", async () => {
|
||||
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
|
||||
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense);
|
||||
const result = await getBiggerUploadFileSizePermission(constants.PROJECT_FEATURE_KEYS.SCALE);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false if license active and plan is FREE (cloud)", async () => {
|
||||
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
|
||||
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense);
|
||||
const result = await getBiggerUploadFileSizePermission(constants.PROJECT_FEATURE_KEYS.FREE);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test("should return false if license is inactive", async () => {
|
||||
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
|
||||
...defaultLicense,
|
||||
active: false,
|
||||
});
|
||||
const result = await getBiggerUploadFileSizePermission(mockOrganization.billing.plan);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMultiLanguagePermission", () => {
|
||||
test("should return true if license active (self-hosted)", async () => {
|
||||
vi.mocked(constants).IS_FORMBRICKS_CLOUD = false;
|
||||
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense);
|
||||
const result = await getMultiLanguagePermission(mockOrganization.billing.plan);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("should return true if license active and plan is SCALE (cloud)", async () => {
|
||||
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
|
||||
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense);
|
||||
const result = await getMultiLanguagePermission(constants.PROJECT_FEATURE_KEYS.SCALE);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false if license is inactive", async () => {
|
||||
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
|
||||
...defaultLicense,
|
||||
active: false,
|
||||
});
|
||||
const result = await getMultiLanguagePermission(mockOrganization.billing.plan);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getIsMultiOrgEnabled", () => {
|
||||
test("should return true if feature flag isMultiOrgEnabled is true", async () => {
|
||||
vi.mocked(licenseModule.getLicenseFeatures).mockResolvedValue({
|
||||
...defaultFeatures,
|
||||
isMultiOrgEnabled: true,
|
||||
});
|
||||
const result = await getIsMultiOrgEnabled();
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false if feature flag isMultiOrgEnabled is false", async () => {
|
||||
vi.mocked(licenseModule.getLicenseFeatures).mockResolvedValue({
|
||||
...defaultFeatures,
|
||||
isMultiOrgEnabled: false,
|
||||
});
|
||||
const result = await getIsMultiOrgEnabled();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test("should return false if licenseFeatures is null", async () => {
|
||||
vi.mocked(licenseModule.getLicenseFeatures).mockResolvedValue(null);
|
||||
const result = await getIsMultiOrgEnabled();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getIsContactsEnabled", () => {
|
||||
test("should return true if feature flag contacts is true", async () => {
|
||||
vi.mocked(licenseModule.getLicenseFeatures).mockResolvedValue({
|
||||
...defaultFeatures,
|
||||
contacts: true,
|
||||
});
|
||||
const result = await getIsContactsEnabled();
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false if feature flag contacts is false", async () => {
|
||||
vi.mocked(licenseModule.getLicenseFeatures).mockResolvedValue({
|
||||
...defaultFeatures,
|
||||
contacts: false,
|
||||
});
|
||||
const result = await getIsContactsEnabled();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getIsTwoFactorAuthEnabled", () => {
|
||||
test("should return true if feature flag twoFactorAuth is true", async () => {
|
||||
vi.mocked(licenseModule.getLicenseFeatures).mockResolvedValue({
|
||||
...defaultFeatures,
|
||||
twoFactorAuth: true,
|
||||
});
|
||||
const result = await getIsTwoFactorAuthEnabled();
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false if feature flag twoFactorAuth is false", async () => {
|
||||
vi.mocked(licenseModule.getLicenseFeatures).mockResolvedValue({
|
||||
...defaultFeatures,
|
||||
twoFactorAuth: false,
|
||||
});
|
||||
const result = await getIsTwoFactorAuthEnabled();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getIsSsoEnabled", () => {
|
||||
test("should return true if feature flag sso is true", async () => {
|
||||
vi.mocked(licenseModule.getLicenseFeatures).mockResolvedValue({
|
||||
...defaultFeatures,
|
||||
sso: true,
|
||||
});
|
||||
const result = await getIsSsoEnabled();
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false if feature flag sso is false", async () => {
|
||||
vi.mocked(licenseModule.getLicenseFeatures).mockResolvedValue({
|
||||
...defaultFeatures,
|
||||
sso: false,
|
||||
});
|
||||
const result = await getIsSsoEnabled();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getIsSamlSsoEnabled", () => {
|
||||
test("should return false if IS_FORMBRICKS_CLOUD is true", async () => {
|
||||
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
|
||||
const result = await getIsSamlSsoEnabled();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test("should return true if sso and saml flags are true (self-hosted)", async () => {
|
||||
vi.mocked(constants).IS_FORMBRICKS_CLOUD = false;
|
||||
vi.mocked(licenseModule.getLicenseFeatures).mockResolvedValue({
|
||||
...defaultFeatures,
|
||||
sso: true,
|
||||
saml: true,
|
||||
});
|
||||
const result = await getIsSamlSsoEnabled();
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false if sso is true but saml is false (self-hosted)", async () => {
|
||||
vi.mocked(constants).IS_FORMBRICKS_CLOUD = false;
|
||||
vi.mocked(licenseModule.getLicenseFeatures).mockResolvedValue({
|
||||
...defaultFeatures,
|
||||
sso: true,
|
||||
saml: false,
|
||||
});
|
||||
const result = await getIsSamlSsoEnabled();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test("should return false if licenseFeatures is null (self-hosted)", async () => {
|
||||
vi.mocked(constants).IS_FORMBRICKS_CLOUD = false;
|
||||
vi.mocked(licenseModule.getLicenseFeatures).mockResolvedValue(null);
|
||||
const result = await getIsSamlSsoEnabled();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getIsSpamProtectionEnabled", () => {
|
||||
test("should return false if IS_RECAPTCHA_CONFIGURED is false", async () => {
|
||||
vi.mocked(constants).IS_RECAPTCHA_CONFIGURED = false;
|
||||
const result = await getIsSpamProtectionEnabled(mockOrganization.billing.plan);
|
||||
expect(result).toBe(false);
|
||||
vi.mocked(constants).IS_RECAPTCHA_CONFIGURED = true; // reset for other tests
|
||||
});
|
||||
|
||||
test("should return true if license active, feature enabled, and plan is SCALE (cloud)", async () => {
|
||||
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
|
||||
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
|
||||
...defaultLicense,
|
||||
features: { ...defaultFeatures, spamProtection: true },
|
||||
});
|
||||
const result = await getIsSpamProtectionEnabled(constants.PROJECT_FEATURE_KEYS.SCALE);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false if license active, feature enabled, but plan is not SCALE or ENTERPRISE (cloud)", async () => {
|
||||
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
|
||||
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
|
||||
...defaultLicense,
|
||||
features: { ...defaultFeatures, spamProtection: true },
|
||||
});
|
||||
const result = await getIsSpamProtectionEnabled(constants.PROJECT_FEATURE_KEYS.STARTUP);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test("should return true if license active and feature enabled (self-hosted)", async () => {
|
||||
vi.mocked(constants).IS_FORMBRICKS_CLOUD = false;
|
||||
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
|
||||
...defaultLicense,
|
||||
features: { ...defaultFeatures, spamProtection: true },
|
||||
});
|
||||
const result = await getIsSpamProtectionEnabled(mockOrganization.billing.plan);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false if license is inactive", async () => {
|
||||
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
|
||||
...defaultLicense,
|
||||
active: false,
|
||||
});
|
||||
const result = await getIsSpamProtectionEnabled(mockOrganization.billing.plan);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getOrganizationProjectsLimit", () => {
|
||||
test("should return limits.projects if license active (cloud)", async () => {
|
||||
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
|
||||
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense);
|
||||
const limits = {
|
||||
projects: 10,
|
||||
monthly: {
|
||||
responses: null,
|
||||
miu: null,
|
||||
},
|
||||
};
|
||||
const result = await getOrganizationProjectsLimit(limits);
|
||||
expect(result).toBe(10);
|
||||
});
|
||||
|
||||
test("should return Infinity if limits.projects is null and license active (cloud)", async () => {
|
||||
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
|
||||
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense);
|
||||
const limits = {
|
||||
projects: null,
|
||||
monthly: {
|
||||
responses: null,
|
||||
miu: null,
|
||||
},
|
||||
};
|
||||
const result = await getOrganizationProjectsLimit(limits);
|
||||
expect(result).toBe(Infinity);
|
||||
});
|
||||
|
||||
test("should return 3 if license inactive (cloud)", async () => {
|
||||
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
|
||||
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
|
||||
...defaultLicense,
|
||||
active: false,
|
||||
});
|
||||
const result = await getOrganizationProjectsLimit(mockOrganization.billing.limits);
|
||||
expect(result).toBe(3);
|
||||
});
|
||||
|
||||
test("should return license.features.projects if defined and license active (self-hosted)", async () => {
|
||||
vi.mocked(constants).IS_FORMBRICKS_CLOUD = false;
|
||||
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
|
||||
...defaultLicense,
|
||||
features: { ...defaultFeatures, projects: 5 },
|
||||
});
|
||||
const result = await getOrganizationProjectsLimit(mockOrganization.billing.limits);
|
||||
expect(result).toBe(5);
|
||||
});
|
||||
|
||||
test("should return 3 if license.features.projects is undefined and license active (self-hosted)", async () => {
|
||||
vi.mocked(constants).IS_FORMBRICKS_CLOUD = false;
|
||||
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
|
||||
...defaultLicense,
|
||||
features: { ...defaultFeatures, projects: null },
|
||||
});
|
||||
const result = await getOrganizationProjectsLimit(mockOrganization.billing.limits);
|
||||
expect(result).toBe(3);
|
||||
});
|
||||
|
||||
test("should return 3 if license inactive (self-hosted)", async () => {
|
||||
vi.mocked(constants).IS_FORMBRICKS_CLOUD = false;
|
||||
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
|
||||
...defaultLicense,
|
||||
active: false,
|
||||
});
|
||||
const result = await getOrganizationProjectsLimit(mockOrganization.billing.limits);
|
||||
expect(result).toBe(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,388 +1,102 @@
|
||||
import "server-only";
|
||||
import { cache, revalidateTag } from "@/lib/cache";
|
||||
import {
|
||||
E2E_TESTING,
|
||||
ENTERPRISE_LICENSE_KEY,
|
||||
IS_FORMBRICKS_CLOUD,
|
||||
IS_RECAPTCHA_CONFIGURED,
|
||||
PROJECT_FEATURE_KEYS,
|
||||
} from "@/lib/constants";
|
||||
import { env } from "@/lib/env";
|
||||
import { hashString } from "@/lib/hashString";
|
||||
import {
|
||||
TEnterpriseLicenseDetails,
|
||||
TEnterpriseLicenseFeatures,
|
||||
} from "@/modules/ee/license-check/types/enterprise-license";
|
||||
import { IS_FORMBRICKS_CLOUD, IS_RECAPTCHA_CONFIGURED, PROJECT_FEATURE_KEYS } from "@/lib/constants";
|
||||
import { TEnterpriseLicenseFeatures } from "@/modules/ee/license-check/types/enterprise-license";
|
||||
import { Organization } from "@prisma/client";
|
||||
import { HttpsProxyAgent } from "https-proxy-agent";
|
||||
import { after } from "next/server";
|
||||
import fetch from "node-fetch";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { getEnterpriseLicense, getLicenseFeatures } from "./license";
|
||||
|
||||
const hashedKey = ENTERPRISE_LICENSE_KEY ? hashString(ENTERPRISE_LICENSE_KEY) : undefined;
|
||||
const PREVIOUS_RESULTS_CACHE_TAG_KEY = `getPreviousResult-${hashedKey}` as const;
|
||||
// Helper function for feature permissions (e.g., removeBranding, whitelabel)
|
||||
const getFeaturePermission = async (
|
||||
billingPlan: Organization["billing"]["plan"],
|
||||
featureKey: keyof Pick<TEnterpriseLicenseFeatures, "removeBranding" | "whitelabel">
|
||||
): Promise<boolean> => {
|
||||
const license = await getEnterpriseLicense();
|
||||
|
||||
// This function is used to get the previous result of the license check from the cache
|
||||
// This might seem confusing at first since we only return the default value from this function,
|
||||
// but since we are using a cache and the cache key is the same, the cache will return the previous result - so this function acts as a cache getter
|
||||
const getPreviousResult = (): Promise<{
|
||||
active: boolean | null;
|
||||
lastChecked: Date;
|
||||
features: TEnterpriseLicenseFeatures | null;
|
||||
}> =>
|
||||
cache(
|
||||
async () => ({
|
||||
active: null,
|
||||
lastChecked: new Date(0),
|
||||
features: null,
|
||||
}),
|
||||
[PREVIOUS_RESULTS_CACHE_TAG_KEY],
|
||||
{
|
||||
tags: [PREVIOUS_RESULTS_CACHE_TAG_KEY],
|
||||
}
|
||||
)();
|
||||
|
||||
// This function is used to set the previous result of the license check to the cache so that we can use it in the next call
|
||||
// Uses the same cache key as the getPreviousResult function
|
||||
const setPreviousResult = async (previousResult: {
|
||||
active: boolean | null;
|
||||
lastChecked: Date;
|
||||
features: TEnterpriseLicenseFeatures | null;
|
||||
}) => {
|
||||
const { lastChecked, active, features } = previousResult;
|
||||
|
||||
await cache(
|
||||
async () => ({
|
||||
active,
|
||||
lastChecked,
|
||||
features,
|
||||
}),
|
||||
[PREVIOUS_RESULTS_CACHE_TAG_KEY],
|
||||
{
|
||||
tags: [PREVIOUS_RESULTS_CACHE_TAG_KEY],
|
||||
}
|
||||
)();
|
||||
|
||||
after(() => {
|
||||
revalidateTag(PREVIOUS_RESULTS_CACHE_TAG_KEY);
|
||||
});
|
||||
};
|
||||
|
||||
const fetchLicenseForE2ETesting = async (): Promise<{
|
||||
active: boolean | null;
|
||||
lastChecked: Date;
|
||||
features: TEnterpriseLicenseFeatures | null;
|
||||
} | null> => {
|
||||
const currentTime = new Date();
|
||||
try {
|
||||
const previousResult = await getPreviousResult();
|
||||
if (previousResult.lastChecked.getTime() === new Date(0).getTime()) {
|
||||
// first call
|
||||
const newResult = {
|
||||
active: true,
|
||||
features: {
|
||||
isMultiOrgEnabled: true,
|
||||
twoFactorAuth: true,
|
||||
sso: true,
|
||||
contacts: true,
|
||||
projects: 3,
|
||||
whitelabel: true,
|
||||
removeBranding: true,
|
||||
spamProtection: true,
|
||||
ai: true,
|
||||
saml: true,
|
||||
},
|
||||
lastChecked: currentTime,
|
||||
};
|
||||
await setPreviousResult(newResult);
|
||||
return newResult;
|
||||
} else if (currentTime.getTime() - previousResult.lastChecked.getTime() > 60 * 60 * 1000) {
|
||||
// Fail after 1 hour
|
||||
logger.info("E2E_TESTING is enabled. Enterprise license was revoked after 1 hour.");
|
||||
return null;
|
||||
}
|
||||
return previousResult;
|
||||
} catch (error) {
|
||||
logger.error(error, "Error fetching license");
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const getEnterpriseLicense = async (): Promise<{
|
||||
active: boolean;
|
||||
features: TEnterpriseLicenseFeatures | null;
|
||||
lastChecked: Date;
|
||||
isPendingDowngrade?: boolean;
|
||||
}> => {
|
||||
if (!ENTERPRISE_LICENSE_KEY || ENTERPRISE_LICENSE_KEY.length === 0) {
|
||||
return {
|
||||
active: false,
|
||||
features: null,
|
||||
lastChecked: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
if (E2E_TESTING) {
|
||||
const previousResult = await fetchLicenseForE2ETesting();
|
||||
|
||||
return {
|
||||
active: previousResult?.active ?? false,
|
||||
features: previousResult ? previousResult.features : null,
|
||||
lastChecked: previousResult ? previousResult.lastChecked : new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
// if the server responds with a boolean, we return it
|
||||
// if the server errors, we return null
|
||||
// null signifies an error
|
||||
const license = await fetchLicense();
|
||||
|
||||
const isValid = license ? license.status === "active" : null;
|
||||
const threeDaysInMillis = 3 * 24 * 60 * 60 * 1000;
|
||||
const currentTime = new Date();
|
||||
|
||||
const previousResult = await getPreviousResult();
|
||||
|
||||
// Case: First time checking license and the server errors out
|
||||
if (previousResult.active === null) {
|
||||
if (isValid === null) {
|
||||
const newResult = {
|
||||
active: false,
|
||||
features: {
|
||||
isMultiOrgEnabled: false,
|
||||
projects: 3,
|
||||
twoFactorAuth: false,
|
||||
sso: false,
|
||||
whitelabel: false,
|
||||
removeBranding: false,
|
||||
contacts: false,
|
||||
ai: false,
|
||||
saml: false,
|
||||
spamProtection: false,
|
||||
},
|
||||
lastChecked: new Date(),
|
||||
};
|
||||
|
||||
await setPreviousResult(newResult);
|
||||
return newResult;
|
||||
}
|
||||
}
|
||||
|
||||
if (isValid !== null && license) {
|
||||
const newResult = {
|
||||
active: isValid,
|
||||
features: license.features,
|
||||
lastChecked: new Date(),
|
||||
};
|
||||
|
||||
await setPreviousResult(newResult);
|
||||
return newResult;
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
return license.active && billingPlan !== PROJECT_FEATURE_KEYS.FREE;
|
||||
} else {
|
||||
// if result is undefined -> error
|
||||
// if the last check was less than 72 hours, return the previous value:
|
||||
|
||||
const elapsedTime = currentTime.getTime() - previousResult.lastChecked.getTime();
|
||||
if (elapsedTime < threeDaysInMillis) {
|
||||
return {
|
||||
active: previousResult.active !== null ? previousResult.active : false,
|
||||
features: previousResult.features,
|
||||
lastChecked: previousResult.lastChecked,
|
||||
isPendingDowngrade: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Log error only after 72 hours
|
||||
logger.error("Error while checking license: The license check failed");
|
||||
|
||||
return {
|
||||
active: false,
|
||||
features: null,
|
||||
lastChecked: previousResult.lastChecked,
|
||||
isPendingDowngrade: true,
|
||||
};
|
||||
return license.active && !!license.features?.[featureKey];
|
||||
}
|
||||
};
|
||||
|
||||
export const getLicenseFeatures = async (): Promise<TEnterpriseLicenseFeatures | null> => {
|
||||
const previousResult = await getPreviousResult();
|
||||
if (previousResult.features) {
|
||||
return previousResult.features;
|
||||
} else {
|
||||
const license = await fetchLicense();
|
||||
if (!license || !license.features) return null;
|
||||
return license.features;
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchLicense = reactCache(
|
||||
async (): Promise<TEnterpriseLicenseDetails | null> =>
|
||||
cache(
|
||||
async () => {
|
||||
if (!env.ENTERPRISE_LICENSE_KEY) return null;
|
||||
try {
|
||||
const now = new Date();
|
||||
const startOfYear = new Date(now.getFullYear(), 0, 1); // January 1st of the current year
|
||||
const endOfYear = new Date(now.getFullYear() + 1, 0, 0); // December 31st of the current year
|
||||
|
||||
const responseCount = await prisma.response.count({
|
||||
where: {
|
||||
createdAt: {
|
||||
gte: startOfYear,
|
||||
lt: endOfYear,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const proxyUrl = env.HTTPS_PROXY || env.HTTP_PROXY;
|
||||
const agent = proxyUrl ? new HttpsProxyAgent(proxyUrl) : undefined;
|
||||
|
||||
const res = await fetch("https://ee.formbricks.com/api/licenses/check", {
|
||||
body: JSON.stringify({
|
||||
licenseKey: ENTERPRISE_LICENSE_KEY,
|
||||
usage: { responseCount: responseCount },
|
||||
}),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
agent,
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const responseJson = (await res.json()) as {
|
||||
data: TEnterpriseLicenseDetails;
|
||||
};
|
||||
return responseJson.data;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
logger.error(error, "Error while checking license");
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[`fetchLicense-${hashedKey}`],
|
||||
{ revalidate: 60 * 60 * 24 }
|
||||
)()
|
||||
);
|
||||
|
||||
export const getRemoveBrandingPermission = async (
|
||||
billingPlan: Organization["billing"]["plan"]
|
||||
): Promise<boolean> => {
|
||||
if (E2E_TESTING) {
|
||||
const previousResult = await fetchLicenseForE2ETesting();
|
||||
return previousResult?.features?.removeBranding ?? false;
|
||||
}
|
||||
|
||||
if (IS_FORMBRICKS_CLOUD && (await getEnterpriseLicense()).active) {
|
||||
return billingPlan !== PROJECT_FEATURE_KEYS.FREE;
|
||||
} else {
|
||||
const licenseFeatures = await getLicenseFeatures();
|
||||
if (!licenseFeatures) return false;
|
||||
|
||||
return licenseFeatures.removeBranding;
|
||||
}
|
||||
return getFeaturePermission(billingPlan, "removeBranding");
|
||||
};
|
||||
|
||||
export const getWhiteLabelPermission = async (
|
||||
billingPlan: Organization["billing"]["plan"]
|
||||
): Promise<boolean> => {
|
||||
if (E2E_TESTING) {
|
||||
const previousResult = await fetchLicenseForE2ETesting();
|
||||
return previousResult?.features?.whitelabel ?? false;
|
||||
}
|
||||
|
||||
if (IS_FORMBRICKS_CLOUD && (await getEnterpriseLicense()).active) {
|
||||
return billingPlan !== PROJECT_FEATURE_KEYS.FREE;
|
||||
} else {
|
||||
const licenseFeatures = await getLicenseFeatures();
|
||||
if (!licenseFeatures) return false;
|
||||
|
||||
return licenseFeatures.whitelabel;
|
||||
}
|
||||
return getFeaturePermission(billingPlan, "whitelabel");
|
||||
};
|
||||
|
||||
export const getRoleManagementPermission = async (
|
||||
billingPlan: Organization["billing"]["plan"]
|
||||
): Promise<boolean> => {
|
||||
if (E2E_TESTING) {
|
||||
const previousResult = await fetchLicenseForE2ETesting();
|
||||
return previousResult && previousResult.active !== null ? previousResult.active : false;
|
||||
}
|
||||
const license = await getEnterpriseLicense();
|
||||
|
||||
if (IS_FORMBRICKS_CLOUD)
|
||||
return billingPlan === PROJECT_FEATURE_KEYS.SCALE || billingPlan === PROJECT_FEATURE_KEYS.ENTERPRISE;
|
||||
else if (!IS_FORMBRICKS_CLOUD) return (await getEnterpriseLicense()).active;
|
||||
return (
|
||||
license.active &&
|
||||
(billingPlan === PROJECT_FEATURE_KEYS.SCALE || billingPlan === PROJECT_FEATURE_KEYS.ENTERPRISE)
|
||||
);
|
||||
else if (!IS_FORMBRICKS_CLOUD) return license.active;
|
||||
return false;
|
||||
};
|
||||
|
||||
export const getBiggerUploadFileSizePermission = async (
|
||||
billingPlan: Organization["billing"]["plan"]
|
||||
): Promise<boolean> => {
|
||||
if (IS_FORMBRICKS_CLOUD) return billingPlan !== PROJECT_FEATURE_KEYS.FREE;
|
||||
else if (!IS_FORMBRICKS_CLOUD) return (await getEnterpriseLicense()).active;
|
||||
const license = await getEnterpriseLicense();
|
||||
|
||||
if (IS_FORMBRICKS_CLOUD) return billingPlan !== PROJECT_FEATURE_KEYS.FREE && license.active;
|
||||
else if (!IS_FORMBRICKS_CLOUD) return license.active;
|
||||
return false;
|
||||
};
|
||||
|
||||
export const getMultiLanguagePermission = async (
|
||||
billingPlan: Organization["billing"]["plan"]
|
||||
): Promise<boolean> => {
|
||||
if (E2E_TESTING) {
|
||||
const previousResult = await fetchLicenseForE2ETesting();
|
||||
return previousResult && previousResult.active !== null ? previousResult.active : false;
|
||||
}
|
||||
const license = await getEnterpriseLicense();
|
||||
|
||||
if (IS_FORMBRICKS_CLOUD)
|
||||
return billingPlan === PROJECT_FEATURE_KEYS.SCALE || billingPlan === PROJECT_FEATURE_KEYS.ENTERPRISE;
|
||||
else if (!IS_FORMBRICKS_CLOUD) return (await getEnterpriseLicense()).active;
|
||||
return (
|
||||
license.active &&
|
||||
(billingPlan === PROJECT_FEATURE_KEYS.SCALE || billingPlan === PROJECT_FEATURE_KEYS.ENTERPRISE)
|
||||
);
|
||||
else if (!IS_FORMBRICKS_CLOUD) return license.active;
|
||||
return false;
|
||||
};
|
||||
|
||||
export const getIsMultiOrgEnabled = async (): Promise<boolean> => {
|
||||
if (E2E_TESTING) {
|
||||
const previousResult = await fetchLicenseForE2ETesting();
|
||||
return previousResult && previousResult.features ? previousResult.features.isMultiOrgEnabled : false;
|
||||
}
|
||||
// Helper function for simple boolean feature flags
|
||||
const getSpecificFeatureFlag = async (
|
||||
featureKey: keyof Pick<
|
||||
TEnterpriseLicenseFeatures,
|
||||
"isMultiOrgEnabled" | "contacts" | "twoFactorAuth" | "sso"
|
||||
>
|
||||
): Promise<boolean> => {
|
||||
const licenseFeatures = await getLicenseFeatures();
|
||||
if (!licenseFeatures) return false;
|
||||
return licenseFeatures.isMultiOrgEnabled;
|
||||
return typeof licenseFeatures[featureKey] === "boolean" ? licenseFeatures[featureKey] : false;
|
||||
};
|
||||
|
||||
export const getIsMultiOrgEnabled = async (): Promise<boolean> => {
|
||||
return getSpecificFeatureFlag("isMultiOrgEnabled");
|
||||
};
|
||||
|
||||
export const getIsContactsEnabled = async (): Promise<boolean> => {
|
||||
if (E2E_TESTING) {
|
||||
const previousResult = await fetchLicenseForE2ETesting();
|
||||
return previousResult && previousResult.features ? previousResult.features.contacts : false;
|
||||
}
|
||||
const licenseFeatures = await getLicenseFeatures();
|
||||
if (!licenseFeatures) return false;
|
||||
return licenseFeatures.contacts;
|
||||
return getSpecificFeatureFlag("contacts");
|
||||
};
|
||||
|
||||
export const getIsTwoFactorAuthEnabled = async (): Promise<boolean> => {
|
||||
if (E2E_TESTING) {
|
||||
const previousResult = await fetchLicenseForE2ETesting();
|
||||
return previousResult && previousResult.features ? previousResult.features.twoFactorAuth : false;
|
||||
}
|
||||
const licenseFeatures = await getLicenseFeatures();
|
||||
if (!licenseFeatures) return false;
|
||||
return licenseFeatures.twoFactorAuth;
|
||||
return getSpecificFeatureFlag("twoFactorAuth");
|
||||
};
|
||||
|
||||
export const getisSsoEnabled = async (): Promise<boolean> => {
|
||||
if (E2E_TESTING) {
|
||||
const previousResult = await fetchLicenseForE2ETesting();
|
||||
return previousResult && previousResult.features ? previousResult.features.sso : false;
|
||||
}
|
||||
const licenseFeatures = await getLicenseFeatures();
|
||||
if (!licenseFeatures) return false;
|
||||
return licenseFeatures.sso;
|
||||
export const getIsSsoEnabled = async (): Promise<boolean> => {
|
||||
return getSpecificFeatureFlag("sso");
|
||||
};
|
||||
|
||||
export const getIsSamlSsoEnabled = async (): Promise<boolean> => {
|
||||
if (E2E_TESTING) {
|
||||
const previousResult = await fetchLicenseForE2ETesting();
|
||||
return previousResult && previousResult.features
|
||||
? previousResult.features.sso && previousResult.features.saml
|
||||
: false;
|
||||
}
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
return false;
|
||||
}
|
||||
@@ -396,38 +110,30 @@ export const getIsSpamProtectionEnabled = async (
|
||||
): Promise<boolean> => {
|
||||
if (!IS_RECAPTCHA_CONFIGURED) return false;
|
||||
|
||||
if (E2E_TESTING) {
|
||||
const previousResult = await fetchLicenseForE2ETesting();
|
||||
return previousResult?.features ? previousResult.features.spamProtection : false;
|
||||
}
|
||||
if (IS_FORMBRICKS_CLOUD)
|
||||
return billingPlan === PROJECT_FEATURE_KEYS.SCALE || billingPlan === PROJECT_FEATURE_KEYS.ENTERPRISE;
|
||||
const license = await getEnterpriseLicense();
|
||||
|
||||
const licenseFeatures = await getLicenseFeatures();
|
||||
if (!licenseFeatures) return false;
|
||||
return licenseFeatures.spamProtection;
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
return (
|
||||
license.active &&
|
||||
!!license.features?.spamProtection &&
|
||||
(billingPlan === PROJECT_FEATURE_KEYS.SCALE || billingPlan === PROJECT_FEATURE_KEYS.ENTERPRISE)
|
||||
);
|
||||
}
|
||||
|
||||
return license.active && !!license.features?.spamProtection;
|
||||
};
|
||||
|
||||
export const getOrganizationProjectsLimit = async (
|
||||
limits: Organization["billing"]["limits"]
|
||||
): Promise<number> => {
|
||||
if (E2E_TESTING) {
|
||||
const previousResult = await fetchLicenseForE2ETesting();
|
||||
return previousResult && previousResult.features ? (previousResult.features.projects ?? Infinity) : 3;
|
||||
}
|
||||
const license = await getEnterpriseLicense();
|
||||
|
||||
let limit: number;
|
||||
|
||||
if (IS_FORMBRICKS_CLOUD && (await getEnterpriseLicense()).active) {
|
||||
limit = limits.projects ?? Infinity;
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
limit = license.active ? (limits.projects ?? Infinity) : 3;
|
||||
} else {
|
||||
const licenseFeatures = await getLicenseFeatures();
|
||||
if (!licenseFeatures) {
|
||||
limit = 3;
|
||||
} else {
|
||||
limit = licenseFeatures.projects ?? Infinity;
|
||||
}
|
||||
limit = license.active && license.features?.projects != null ? license.features.projects : 3;
|
||||
}
|
||||
|
||||
return limit;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: {
|
||||
@@ -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
|
||||
47
apps/web/modules/survey/follow-ups/lib/email.ts
Normal file
47
apps/web/modules/survey/follow-ups/lib/email.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
370
apps/web/modules/survey/follow-ups/lib/follow-ups.test.ts
Normal file
370
apps/web/modules/survey/follow-ups/lib/follow-ups.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
276
apps/web/modules/survey/follow-ups/lib/follow-ups.ts
Normal file
276
apps/web/modules/survey/follow-ups/lib/follow-ups.ts
Normal 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 follow‐up 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 },
|
||||
});
|
||||
}
|
||||
};
|
||||
16
apps/web/modules/survey/follow-ups/types/follow-up.ts
Normal file
16
apps/web/modules/survey/follow-ups/types/follow-up.ts
Normal 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",
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 | ❌ | ✅ |
|
||||
|
||||
|
||||
64
docs/self-hosting/auth-behavior.mdx
Normal file
64
docs/self-hosting/auth-behavior.mdx
Normal 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)
|
||||
@@ -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) | |
|
||||
|
||||
@@ -66,8 +66,5 @@
|
||||
"budgetPercentIncreaseRed": 20,
|
||||
"minimumChangeThreshold": 0,
|
||||
"showDetails": true
|
||||
},
|
||||
"dependencies": {
|
||||
"@changesets/cli": "2.29.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -464,6 +464,7 @@ export function Survey({
|
||||
},
|
||||
variables: responseUpdate.variables,
|
||||
displayId: surveyState.displayId,
|
||||
endingId: responseUpdate.endingId,
|
||||
hiddenFields: hiddenFieldsRecord,
|
||||
});
|
||||
|
||||
|
||||
32
packages/surveys/src/lib/i18n.test.ts
Normal file
32
packages/surveys/src/lib/i18n.test.ts
Normal 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
484
pnpm-lock.yaml
generated
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user