mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-11 04:21:01 -05:00
Compare commits
11 Commits
chore/next
...
fix/remove
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fba692626 | ||
|
|
3ace91cdd5 | ||
|
|
4ba7bf5b3c | ||
|
|
bd1402a58b | ||
|
|
c2af0c3fb6 | ||
|
|
dde5a55446 | ||
|
|
13e615a798 | ||
|
|
9c81961b0b | ||
|
|
c1a35e2d75 | ||
|
|
13415c75c2 | ||
|
|
300557a0e6 |
@@ -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";
|
||||
|
||||
@@ -109,7 +109,7 @@ export const MainNavigation = ({
|
||||
|
||||
useEffect(() => {
|
||||
const toggleTextOpacity = () => {
|
||||
setIsTextVisible(isCollapsed ? true : false);
|
||||
setIsTextVisible(isCollapsed);
|
||||
};
|
||||
const timeoutId = setTimeout(toggleTextOpacity, 150);
|
||||
return () => clearTimeout(timeoutId);
|
||||
@@ -170,7 +170,7 @@ export const MainNavigation = ({
|
||||
name: t("common.actions"),
|
||||
href: `/environments/${environment.id}/actions`,
|
||||
icon: MousePointerClick,
|
||||
isActive: pathname?.includes("/actions") || pathname?.includes("/actions"),
|
||||
isActive: pathname?.includes("/actions"),
|
||||
},
|
||||
{
|
||||
name: t("common.integrations"),
|
||||
|
||||
@@ -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,7 +1,14 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { OptionsType, QuestionOption, QuestionOptions, QuestionsComboBox } from "./QuestionsComboBox";
|
||||
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
OptionsType,
|
||||
QuestionOption,
|
||||
QuestionOptions,
|
||||
QuestionsComboBox,
|
||||
SelectedCommandItem,
|
||||
} from "./QuestionsComboBox";
|
||||
|
||||
describe("QuestionsComboBox", () => {
|
||||
afterEach(() => {
|
||||
@@ -53,3 +60,67 @@ describe("QuestionsComboBox", () => {
|
||||
expect(screen.getByText("Q1")).toBeInTheDocument(); // Verify the selected item is now displayed
|
||||
});
|
||||
});
|
||||
|
||||
describe("SelectedCommandItem", () => {
|
||||
test("renders question icon and color for QUESTIONS with questionType", () => {
|
||||
const { container } = render(
|
||||
<SelectedCommandItem
|
||||
label="Q1"
|
||||
type={OptionsType.QUESTIONS}
|
||||
questionType={TSurveyQuestionTypeEnum.OpenText}
|
||||
/>
|
||||
);
|
||||
expect(container.querySelector(".bg-brand-dark")).toBeInTheDocument();
|
||||
expect(container.querySelector("svg")).toBeInTheDocument();
|
||||
expect(container.textContent).toContain("Q1");
|
||||
});
|
||||
|
||||
test("renders attribute icon and color for ATTRIBUTES", () => {
|
||||
const { container } = render(<SelectedCommandItem label="Attr" type={OptionsType.ATTRIBUTES} />);
|
||||
expect(container.querySelector(".bg-indigo-500")).toBeInTheDocument();
|
||||
expect(container.querySelector("svg")).toBeInTheDocument();
|
||||
expect(container.textContent).toContain("Attr");
|
||||
});
|
||||
|
||||
test("renders hidden field icon and color for HIDDEN_FIELDS", () => {
|
||||
const { container } = render(<SelectedCommandItem label="Hidden" type={OptionsType.HIDDEN_FIELDS} />);
|
||||
expect(container.querySelector(".bg-amber-500")).toBeInTheDocument();
|
||||
expect(container.querySelector("svg")).toBeInTheDocument();
|
||||
expect(container.textContent).toContain("Hidden");
|
||||
});
|
||||
|
||||
test("renders meta icon and color for META with label", () => {
|
||||
const { container } = render(<SelectedCommandItem label="device" type={OptionsType.META} />);
|
||||
expect(container.querySelector(".bg-amber-500")).toBeInTheDocument();
|
||||
expect(container.querySelector("svg")).toBeInTheDocument();
|
||||
expect(container.textContent).toContain("device");
|
||||
});
|
||||
|
||||
test("renders other icon and color for OTHERS with label", () => {
|
||||
const { container } = render(<SelectedCommandItem label="Language" type={OptionsType.OTHERS} />);
|
||||
expect(container.querySelector(".bg-amber-500")).toBeInTheDocument();
|
||||
expect(container.querySelector("svg")).toBeInTheDocument();
|
||||
expect(container.textContent).toContain("Language");
|
||||
});
|
||||
|
||||
test("renders tag icon and color for TAGS", () => {
|
||||
const { container } = render(<SelectedCommandItem label="Tag1" type={OptionsType.TAGS} />);
|
||||
expect(container.querySelector(".bg-indigo-500")).toBeInTheDocument();
|
||||
expect(container.querySelector("svg")).toBeInTheDocument();
|
||||
expect(container.textContent).toContain("Tag1");
|
||||
});
|
||||
|
||||
test("renders fallback color and no icon for unknown type", () => {
|
||||
const { container } = render(<SelectedCommandItem label="Unknown" type={"UNKNOWN"} />);
|
||||
expect(container.querySelector(".bg-amber-500")).toBeInTheDocument();
|
||||
expect(container.querySelector("svg")).not.toBeInTheDocument();
|
||||
expect(container.textContent).toContain("Unknown");
|
||||
});
|
||||
|
||||
test("renders fallback for non-string label", () => {
|
||||
const { container } = render(
|
||||
<SelectedCommandItem label={{ default: "NonString" }} type={OptionsType.QUESTIONS} />
|
||||
);
|
||||
expect(container.textContent).toContain("NonString");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,11 +18,12 @@ import {
|
||||
CheckIcon,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
ContactIcon,
|
||||
EyeOff,
|
||||
GlobeIcon,
|
||||
GridIcon,
|
||||
HashIcon,
|
||||
HelpCircleIcon,
|
||||
HomeIcon,
|
||||
ImageIcon,
|
||||
LanguagesIcon,
|
||||
ListIcon,
|
||||
@@ -63,59 +64,60 @@ interface QuestionComboBoxProps {
|
||||
onChangeValue: (option: QuestionOption) => void;
|
||||
}
|
||||
|
||||
const SelectedCommandItem = ({ label, questionType, type }: Partial<QuestionOption>) => {
|
||||
const getIconType = () => {
|
||||
switch (type) {
|
||||
case OptionsType.QUESTIONS:
|
||||
switch (questionType) {
|
||||
case TSurveyQuestionTypeEnum.OpenText:
|
||||
return <MessageSquareTextIcon width={18} height={18} className="text-white" />;
|
||||
case TSurveyQuestionTypeEnum.Rating:
|
||||
return <StarIcon width={18} height={18} className="text-white" />;
|
||||
case TSurveyQuestionTypeEnum.CTA:
|
||||
return <MousePointerClickIcon width={18} height={18} className="text-white" />;
|
||||
case TSurveyQuestionTypeEnum.OpenText:
|
||||
return <HelpCircleIcon width={18} height={18} className="text-white" />;
|
||||
case TSurveyQuestionTypeEnum.MultipleChoiceMulti:
|
||||
return <ListIcon width={18} height={18} className="text-white" />;
|
||||
case TSurveyQuestionTypeEnum.MultipleChoiceSingle:
|
||||
return <Rows3Icon width={18} height={18} className="text-white" />;
|
||||
case TSurveyQuestionTypeEnum.NPS:
|
||||
return <NetPromoterScoreIcon width={18} height={18} className="text-white" />;
|
||||
case TSurveyQuestionTypeEnum.Consent:
|
||||
return <CheckIcon width={18} height={18} className="text-white" />;
|
||||
case TSurveyQuestionTypeEnum.PictureSelection:
|
||||
return <ImageIcon width={18} height={18} className="text-white" />;
|
||||
case TSurveyQuestionTypeEnum.Matrix:
|
||||
return <GridIcon width={18} height={18} className="text-white" />;
|
||||
case TSurveyQuestionTypeEnum.Ranking:
|
||||
return <ListOrderedIcon width={18} height={18} className="text-white" />;
|
||||
}
|
||||
case OptionsType.ATTRIBUTES:
|
||||
return <User width={18} height={18} className="text-white" />;
|
||||
const questionIcons = {
|
||||
// questions
|
||||
[TSurveyQuestionTypeEnum.OpenText]: MessageSquareTextIcon,
|
||||
[TSurveyQuestionTypeEnum.Rating]: StarIcon,
|
||||
[TSurveyQuestionTypeEnum.CTA]: MousePointerClickIcon,
|
||||
[TSurveyQuestionTypeEnum.MultipleChoiceMulti]: ListIcon,
|
||||
[TSurveyQuestionTypeEnum.MultipleChoiceSingle]: Rows3Icon,
|
||||
[TSurveyQuestionTypeEnum.NPS]: NetPromoterScoreIcon,
|
||||
[TSurveyQuestionTypeEnum.Consent]: CheckIcon,
|
||||
[TSurveyQuestionTypeEnum.PictureSelection]: ImageIcon,
|
||||
[TSurveyQuestionTypeEnum.Matrix]: GridIcon,
|
||||
[TSurveyQuestionTypeEnum.Ranking]: ListOrderedIcon,
|
||||
[TSurveyQuestionTypeEnum.Address]: HomeIcon,
|
||||
[TSurveyQuestionTypeEnum.ContactInfo]: ContactIcon,
|
||||
|
||||
case OptionsType.HIDDEN_FIELDS:
|
||||
return <EyeOff width={18} height={18} className="text-white" />;
|
||||
case OptionsType.META:
|
||||
switch (label) {
|
||||
case "device":
|
||||
return <SmartphoneIcon width={18} height={18} className="text-white" />;
|
||||
case "os":
|
||||
return <AirplayIcon width={18} height={18} className="text-white" />;
|
||||
case "browser":
|
||||
return <GlobeIcon width={18} height={18} className="text-white" />;
|
||||
case "source":
|
||||
return <GlobeIcon width={18} height={18} className="text-white" />;
|
||||
case "action":
|
||||
return <MousePointerClickIcon width={18} height={18} className="text-white" />;
|
||||
}
|
||||
case OptionsType.OTHERS:
|
||||
switch (label) {
|
||||
case "Language":
|
||||
return <LanguagesIcon width={18} height={18} className="text-white" />;
|
||||
}
|
||||
case OptionsType.TAGS:
|
||||
return <HashIcon width={18} height={18} className="text-white" />;
|
||||
// attributes
|
||||
[OptionsType.ATTRIBUTES]: User,
|
||||
|
||||
// hidden fields
|
||||
[OptionsType.HIDDEN_FIELDS]: EyeOff,
|
||||
|
||||
// meta
|
||||
device: SmartphoneIcon,
|
||||
os: AirplayIcon,
|
||||
browser: GlobeIcon,
|
||||
source: GlobeIcon,
|
||||
action: MousePointerClickIcon,
|
||||
|
||||
// others
|
||||
Language: LanguagesIcon,
|
||||
|
||||
// tags
|
||||
[OptionsType.TAGS]: HashIcon,
|
||||
};
|
||||
|
||||
const getIcon = (type: string) => {
|
||||
const IconComponent = questionIcons[type];
|
||||
return IconComponent ? <IconComponent width={18} height={18} className="text-white" /> : null;
|
||||
};
|
||||
|
||||
export const SelectedCommandItem = ({ label, questionType, type }: Partial<QuestionOption>) => {
|
||||
const getIconType = () => {
|
||||
if (type) {
|
||||
if (type === OptionsType.QUESTIONS && questionType) {
|
||||
return getIcon(questionType);
|
||||
} else if (type === OptionsType.ATTRIBUTES) {
|
||||
return getIcon(OptionsType.ATTRIBUTES);
|
||||
} else if (type === OptionsType.HIDDEN_FIELDS) {
|
||||
return getIcon(OptionsType.HIDDEN_FIELDS);
|
||||
} else if ([OptionsType.META, OptionsType.OTHERS].includes(type) && label) {
|
||||
return getIcon(label);
|
||||
} else if (type === OptionsType.TAGS) {
|
||||
return getIcon(OptionsType.TAGS);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -164,7 +166,7 @@ export const QuestionsComboBox = ({ options, selected, onChangeValue }: Question
|
||||
value={inputValue}
|
||||
onValueChange={setInputValue}
|
||||
placeholder={t("common.search") + "..."}
|
||||
className="h-5 border-none border-transparent p-0 shadow-none ring-offset-transparent outline-0 focus:border-none focus:border-transparent focus:shadow-none focus:ring-offset-transparent focus:outline-0"
|
||||
className="h-5 border-none border-transparent p-0 shadow-none outline-0 ring-offset-transparent focus:border-none focus:border-transparent focus:shadow-none focus:outline-0 focus:ring-offset-transparent"
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
|
||||
97
apps/web/cache-handler.js
Normal file
97
apps/web/cache-handler.js
Normal file
@@ -0,0 +1,97 @@
|
||||
// This cache handler follows the @fortedigital/nextjs-cache-handler example
|
||||
// Read more at: https://github.com/fortedigital/nextjs-cache-handler
|
||||
|
||||
// @neshca/cache-handler dependencies
|
||||
const { CacheHandler } = require("@neshca/cache-handler");
|
||||
const createLruHandler = require("@neshca/cache-handler/local-lru").default;
|
||||
|
||||
// Next/Redis dependencies
|
||||
const { createClient } = require("redis");
|
||||
const { PHASE_PRODUCTION_BUILD } = require("next/constants");
|
||||
|
||||
// @fortedigital/nextjs-cache-handler dependencies
|
||||
const createRedisHandler = require("@fortedigital/nextjs-cache-handler/redis-strings").default;
|
||||
const { Next15CacheHandler } = require("@fortedigital/nextjs-cache-handler/next-15-cache-handler");
|
||||
|
||||
// Usual onCreation from @neshca/cache-handler
|
||||
CacheHandler.onCreation(() => {
|
||||
// Important - It's recommended to use global scope to ensure only one Redis connection is made
|
||||
// This ensures only one instance get created
|
||||
if (global.cacheHandlerConfig) {
|
||||
return global.cacheHandlerConfig;
|
||||
}
|
||||
|
||||
// Important - It's recommended to use global scope to ensure only one Redis connection is made
|
||||
// This ensures new instances are not created in a race condition
|
||||
if (global.cacheHandlerConfigPromise) {
|
||||
return global.cacheHandlerConfigPromise;
|
||||
}
|
||||
|
||||
// If REDIS_URL is not set, we will use LRU cache only
|
||||
if (!process.env.REDIS_URL) {
|
||||
const lruCache = createLruHandler();
|
||||
return { handlers: [lruCache] };
|
||||
}
|
||||
|
||||
// Main promise initializing the handler
|
||||
global.cacheHandlerConfigPromise = (async () => {
|
||||
/** @type {import("redis").RedisClientType | null} */
|
||||
let redisClient = null;
|
||||
// eslint-disable-next-line turbo/no-undeclared-env-vars -- Next.js will inject this variable
|
||||
if (PHASE_PRODUCTION_BUILD !== process.env.NEXT_PHASE) {
|
||||
const settings = {
|
||||
url: process.env.REDIS_URL, // Make sure you configure this variable
|
||||
pingInterval: 10000,
|
||||
};
|
||||
|
||||
try {
|
||||
redisClient = createClient(settings);
|
||||
redisClient.on("error", (e) => {
|
||||
console.error("Redis error", e);
|
||||
global.cacheHandlerConfig = null;
|
||||
global.cacheHandlerConfigPromise = null;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to create Redis client:", error);
|
||||
}
|
||||
}
|
||||
|
||||
if (redisClient) {
|
||||
try {
|
||||
console.info("Connecting Redis client...");
|
||||
await redisClient.connect();
|
||||
console.info("Redis client connected.");
|
||||
} catch (error) {
|
||||
console.error("Failed to connect Redis client:", error);
|
||||
await redisClient
|
||||
.disconnect()
|
||||
.catch(() => console.error("Failed to quit the Redis client after failing to connect."));
|
||||
}
|
||||
}
|
||||
const lruCache = createLruHandler();
|
||||
|
||||
if (!redisClient?.isReady) {
|
||||
console.error("Failed to initialize caching layer.");
|
||||
global.cacheHandlerConfigPromise = null;
|
||||
global.cacheHandlerConfig = { handlers: [lruCache] };
|
||||
return global.cacheHandlerConfig;
|
||||
}
|
||||
|
||||
const redisCacheHandler = createRedisHandler({
|
||||
client: redisClient,
|
||||
keyPrefix: "nextjs:",
|
||||
});
|
||||
|
||||
global.cacheHandlerConfigPromise = null;
|
||||
|
||||
global.cacheHandlerConfig = {
|
||||
handlers: [redisCacheHandler],
|
||||
};
|
||||
|
||||
return global.cacheHandlerConfig;
|
||||
})();
|
||||
|
||||
return global.cacheHandlerConfigPromise;
|
||||
});
|
||||
|
||||
module.exports = new Next15CacheHandler();
|
||||
@@ -1,79 +0,0 @@
|
||||
import createRedisHandler from "@fortedigital/nextjs-cache-handler/redis-strings";
|
||||
import { CacheHandler } from "@neshca/cache-handler";
|
||||
import createLruHandler from "@neshca/cache-handler/local-lru";
|
||||
import { createClient } from "redis";
|
||||
|
||||
// Function to create a timeout promise
|
||||
const createTimeoutPromise = (ms, rejectReason) => {
|
||||
return new Promise((_, reject) => setTimeout(() => reject(new Error(rejectReason)), ms));
|
||||
};
|
||||
|
||||
CacheHandler.onCreation(async () => {
|
||||
let client;
|
||||
|
||||
if (process.env.REDIS_URL) {
|
||||
try {
|
||||
// Create a Redis client.
|
||||
client = createClient({
|
||||
url: process.env.REDIS_URL,
|
||||
});
|
||||
|
||||
// Redis won't work without error handling.
|
||||
client.on("error", () => {});
|
||||
} catch (error) {
|
||||
console.warn("Failed to create Redis client:", error);
|
||||
}
|
||||
|
||||
if (client) {
|
||||
try {
|
||||
// Wait for the client to connect with a timeout of 5000ms.
|
||||
const connectPromise = client.connect();
|
||||
const timeoutPromise = createTimeoutPromise(5000, "Redis connection timed out"); // 5000ms timeout
|
||||
await Promise.race([connectPromise, timeoutPromise]);
|
||||
} catch (error) {
|
||||
console.warn("Failed to connect Redis client:", error);
|
||||
|
||||
console.warn("Disconnecting the Redis client...");
|
||||
// Try to disconnect the client to stop it from reconnecting.
|
||||
client
|
||||
.disconnect()
|
||||
.then(() => {
|
||||
console.info("Redis client disconnected.");
|
||||
})
|
||||
.catch(() => {
|
||||
console.warn("Failed to quit the Redis client after failing to connect.");
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {import("@neshca/cache-handler").Handler | null} */
|
||||
let handler;
|
||||
|
||||
if (client?.isReady) {
|
||||
const redisHandlerOptions = {
|
||||
client,
|
||||
keyPrefix: "fb:",
|
||||
timeoutMs: 1000,
|
||||
};
|
||||
|
||||
// Create the `redis-stack` Handler if the client is available and connected.
|
||||
handler = await createRedisHandler(redisHandlerOptions);
|
||||
} else {
|
||||
// Fallback to LRU handler if Redis client is not available.
|
||||
// The application will still work, but the cache will be in memory only and not shared.
|
||||
handler = createLruHandler();
|
||||
console.log("Using LRU handler for caching.");
|
||||
}
|
||||
|
||||
return {
|
||||
handlers: [handler],
|
||||
ttl: {
|
||||
// We set the stale and the expire age to the same value, because the stale age is determined by the unstable_cache revalidation.
|
||||
defaultStaleAge: (process.env.REDIS_URL && Number(process.env.REDIS_DEFAULT_TTL)) || 86400,
|
||||
estimateExpireAge: (staleAge) => staleAge,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export default CacheHandler;
|
||||
@@ -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(),
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Button } from "@/modules/ui/components/button";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import Link from "next/link";
|
||||
import { after } from "next/server";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ContentLayout } from "./components/content-layout";
|
||||
|
||||
@@ -117,8 +118,9 @@ export const InvitePage = async (props: InvitePageProps) => {
|
||||
});
|
||||
};
|
||||
|
||||
// Execute the server action immediately
|
||||
await createMembershipAction();
|
||||
after(async () => {
|
||||
await createMembershipAction();
|
||||
});
|
||||
|
||||
return (
|
||||
<ContentLayout
|
||||
|
||||
@@ -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,386 +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 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],
|
||||
}
|
||||
)();
|
||||
|
||||
// Revalidate the cache tag immediately
|
||||
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;
|
||||
}
|
||||
@@ -394,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;
|
||||
};
|
||||
|
||||
@@ -71,16 +71,18 @@ export function LocalizedEditor({
|
||||
key={`${questionIdx}-${selectedLanguageCode}`}
|
||||
setFirstRender={setFirstRender}
|
||||
setText={(v: string) => {
|
||||
const translatedHtml = {
|
||||
...value,
|
||||
[selectedLanguageCode]: v,
|
||||
};
|
||||
if (questionIdx === -1) {
|
||||
// welcome card
|
||||
updateQuestion({ html: translatedHtml });
|
||||
return;
|
||||
if (localSurvey.questions[questionIdx] || questionIdx === -1) {
|
||||
const translatedHtml = {
|
||||
...value,
|
||||
[selectedLanguageCode]: v,
|
||||
};
|
||||
if (questionIdx === -1) {
|
||||
// welcome card
|
||||
updateQuestion({ html: translatedHtml });
|
||||
return;
|
||||
}
|
||||
updateQuestion(questionIdx, { html: translatedHtml });
|
||||
}
|
||||
updateQuestion(questionIdx, { html: translatedHtml });
|
||||
}}
|
||||
/>
|
||||
{localSurvey.languages.length > 1 && (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
2
apps/web/next-env.d.ts
vendored
2
apps/web/next-env.d.ts
vendored
@@ -2,4 +2,4 @@
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -16,23 +16,22 @@ const getHostname = (url) => {
|
||||
|
||||
const nextConfig = {
|
||||
assetPrefix: process.env.ASSET_PREFIX_URL || undefined,
|
||||
cacheHandler: require.resolve("./cache-handler.mjs"),
|
||||
//cacheMaxMemorySize: 0, // disable default in-memory caching
|
||||
cacheHandler: require.resolve("./cache-handler.js"),
|
||||
cacheMaxMemorySize: 0, // disable default in-memory caching
|
||||
output: "standalone",
|
||||
poweredByHeader: false,
|
||||
productionBrowserSourceMaps: false,
|
||||
experimental: {
|
||||
serverComponentsExternalPackages: ["@aws-sdk", "@opentelemetry/instrumentation", "pino", "pino-pretty"],
|
||||
outputFileTracingIncludes: {
|
||||
"app/api/packages": ["../../packages/js-core/dist/*", "../../packages/surveys/dist/*"],
|
||||
"/api/auth/**/*": ["../../node_modules/jose/**/*"],
|
||||
},
|
||||
serverExternalPackages: ["@aws-sdk", "@opentelemetry/instrumentation", "pino", "pino-pretty"],
|
||||
outputFileTracingIncludes: {
|
||||
"app/api/packages": ["../../packages/js-core/dist/*", "../../packages/surveys/dist/*"],
|
||||
"/api/auth/**/*": ["../../node_modules/jose/**/*"],
|
||||
},
|
||||
i18n: {
|
||||
locales: ["en-US", "de-DE", "fr-FR", "pt-BR", "zh-Hant-TW", "pt-PT"],
|
||||
localeDetection: false,
|
||||
defaultLocale: "en-US",
|
||||
},
|
||||
experimental: {},
|
||||
transpilePackages: ["@formbricks/database"],
|
||||
images: {
|
||||
remotePatterns: [
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"clean": "rimraf .turbo node_modules .next coverage",
|
||||
"dev": "next dev -p 3000",
|
||||
"go": "next dev -p 3000",
|
||||
"dev": "next dev -p 3000 --turbopack",
|
||||
"go": "next dev -p 3000 --turbopack",
|
||||
"build": "next build",
|
||||
"build:dev": "next build",
|
||||
"start": "next start",
|
||||
@@ -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",
|
||||
@@ -102,7 +105,7 @@
|
||||
"markdown-it": "14.1.0",
|
||||
"mime-types": "3.0.1",
|
||||
"nanoid": "5.1.5",
|
||||
"next": "14.2.28",
|
||||
"next": "15.3.1",
|
||||
"next-auth": "4.24.11",
|
||||
"next-safe-action": "7.10.8",
|
||||
"node-fetch": "3.3.2",
|
||||
|
||||
@@ -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,16 +1,13 @@
|
||||
package com.formbricks.demo
|
||||
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.widget.Button
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import com.formbricks.formbrickssdk.Formbricks
|
||||
import com.formbricks.formbrickssdk.FormbricksCallback
|
||||
import com.formbricks.formbrickssdk.helper.FormbricksConfig
|
||||
import com.formbricks.formbrickssdk.model.enums.SuccessType
|
||||
import java.util.UUID
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
@@ -18,33 +15,6 @@ class MainActivity : AppCompatActivity() {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
|
||||
Formbricks.callback = object: FormbricksCallback {
|
||||
override fun onSurveyStarted() {
|
||||
Log.d("FormbricksCallback", "onSurveyStarted")
|
||||
}
|
||||
|
||||
override fun onSurveyFinished() {
|
||||
Log.d("FormbricksCallback", "onSurveyFinished")
|
||||
}
|
||||
|
||||
override fun onSurveyClosed() {
|
||||
Log.d("FormbricksCallback", "onSurveyClosed")
|
||||
}
|
||||
|
||||
override fun onPageCommitVisible() {
|
||||
Log.d("FormbricksCallback", "onPageCommitVisible")
|
||||
}
|
||||
|
||||
override fun onError(error: Exception) {
|
||||
Log.d("FormbricksCallback", "onError from the CB: ${error.localizedMessage}")
|
||||
}
|
||||
|
||||
override fun onSuccess(successType: SuccessType) {
|
||||
Log.d("FormbricksCallback", "onSuccess: ${successType.name}")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
val config = FormbricksConfig.Builder("[appUrl]","[environmentId]")
|
||||
.setLoggingEnabled(true)
|
||||
.setFragmentManager(supportFragmentManager)
|
||||
|
||||
@@ -34,5 +34,4 @@
|
||||
-keep class com.formbricks.formbrickssdk.DataBinderMapperImpl { *; }
|
||||
-keep class com.formbricks.formbrickssdk.Formbricks { *; }
|
||||
-keep class com.formbricks.formbrickssdk.helper.FormbricksConfig { *; }
|
||||
-keep class com.formbricks.formbrickssdk.model.error.SDKError { *; }
|
||||
-keep interface com.formbricks.formbrickssdk.FormbricksCallback { *; }
|
||||
-keep class com.formbricks.formbrickssdk.model.error.SDKError { *; }
|
||||
@@ -10,22 +10,10 @@ import com.formbricks.formbrickssdk.helper.FormbricksConfig
|
||||
import com.formbricks.formbrickssdk.logger.Logger
|
||||
import com.formbricks.formbrickssdk.manager.SurveyManager
|
||||
import com.formbricks.formbrickssdk.manager.UserManager
|
||||
import com.formbricks.formbrickssdk.model.enums.SuccessType
|
||||
import com.formbricks.formbrickssdk.model.error.SDKError
|
||||
import com.formbricks.formbrickssdk.webview.FormbricksFragment
|
||||
import java.lang.RuntimeException
|
||||
|
||||
@Keep
|
||||
interface FormbricksCallback {
|
||||
fun onSurveyStarted()
|
||||
fun onSurveyFinished()
|
||||
fun onSurveyClosed()
|
||||
fun onPageCommitVisible()
|
||||
fun onError(error: Exception)
|
||||
fun onSuccess(successType: SuccessType)
|
||||
}
|
||||
|
||||
|
||||
@Keep
|
||||
object Formbricks {
|
||||
internal lateinit var applicationContext: Context
|
||||
@@ -37,8 +25,6 @@ object Formbricks {
|
||||
private var fragmentManager: FragmentManager? = null
|
||||
internal var isInitialized = false
|
||||
|
||||
var callback: FormbricksCallback? = null
|
||||
|
||||
/**
|
||||
* Initializes the Formbricks SDK with the given [Context] config [FormbricksConfig].
|
||||
* This method is mandatory to be called, and should be only once per application lifecycle.
|
||||
@@ -62,7 +48,6 @@ object Formbricks {
|
||||
fun setup(context: Context, config: FormbricksConfig, forceRefresh: Boolean = false) {
|
||||
if (isInitialized && !forceRefresh) {
|
||||
val error = SDKError.sdkIsAlreadyInitialized
|
||||
callback?.onError(error)
|
||||
Logger.e(error)
|
||||
return
|
||||
}
|
||||
@@ -97,14 +82,12 @@ object Formbricks {
|
||||
fun setUserId(userId: String) {
|
||||
if (!isInitialized) {
|
||||
val error = SDKError.sdkIsNotInitialized
|
||||
callback?.onError(error)
|
||||
Logger.e(error)
|
||||
return
|
||||
}
|
||||
|
||||
if(UserManager.userId != null) {
|
||||
val error = RuntimeException("A userId is already set ${UserManager.userId} - please call logout first before setting a new one")
|
||||
callback?.onError(error)
|
||||
Logger.e(error)
|
||||
return
|
||||
}
|
||||
@@ -124,7 +107,6 @@ object Formbricks {
|
||||
fun setAttribute(attribute: String, key: String) {
|
||||
if (!isInitialized) {
|
||||
val error = SDKError.sdkIsNotInitialized
|
||||
callback?.onError(error)
|
||||
Logger.e(error)
|
||||
return
|
||||
}
|
||||
@@ -143,7 +125,6 @@ object Formbricks {
|
||||
fun setAttributes(attributes: Map<String, String>) {
|
||||
if (!isInitialized) {
|
||||
val error = SDKError.sdkIsNotInitialized
|
||||
callback?.onError(error)
|
||||
Logger.e(error)
|
||||
return
|
||||
}
|
||||
@@ -162,7 +143,6 @@ object Formbricks {
|
||||
fun setLanguage(language: String) {
|
||||
if (!isInitialized) {
|
||||
val error = SDKError.sdkIsNotInitialized
|
||||
callback?.onError(error)
|
||||
Logger.e(error)
|
||||
return
|
||||
}
|
||||
@@ -182,14 +162,12 @@ object Formbricks {
|
||||
fun track(action: String) {
|
||||
if (!isInitialized) {
|
||||
val error = SDKError.sdkIsNotInitialized
|
||||
callback?.onError(error)
|
||||
Logger.e(error)
|
||||
return
|
||||
}
|
||||
|
||||
if (!isInternetAvailable()) {
|
||||
val error = SDKError.connectionIsNotAvailable
|
||||
callback?.onError(error)
|
||||
Logger.e(error)
|
||||
return
|
||||
}
|
||||
@@ -209,12 +187,10 @@ object Formbricks {
|
||||
fun logout() {
|
||||
if (!isInitialized) {
|
||||
val error = SDKError.sdkIsNotInitialized
|
||||
callback?.onError(error)
|
||||
Logger.e(error)
|
||||
return
|
||||
}
|
||||
|
||||
callback?.onSuccess(SuccessType.LOGOUT_SUCCESS)
|
||||
UserManager.logout()
|
||||
}
|
||||
|
||||
@@ -236,7 +212,6 @@ object Formbricks {
|
||||
internal fun showSurvey(id: String) {
|
||||
if (fragmentManager == null) {
|
||||
val error = SDKError.fragmentManagerIsNotSet
|
||||
callback?.onError(error)
|
||||
Logger.e(error)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import com.formbricks.formbrickssdk.logger.Logger
|
||||
import com.formbricks.formbrickssdk.model.environment.EnvironmentDataHolder
|
||||
import com.formbricks.formbrickssdk.model.environment.Survey
|
||||
import com.formbricks.formbrickssdk.model.error.SDKError
|
||||
import com.formbricks.formbrickssdk.model.enums.SuccessType
|
||||
import com.formbricks.formbrickssdk.model.user.Display
|
||||
import com.google.gson.Gson
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -60,7 +59,6 @@ object SurveyManager {
|
||||
try {
|
||||
Gson().fromJson(json, EnvironmentDataHolder::class.java)
|
||||
} catch (e: Exception) {
|
||||
Formbricks.callback?.onError(e)
|
||||
Logger.e(RuntimeException("Unable to retrieve environment data from the local storage."))
|
||||
null
|
||||
}
|
||||
@@ -118,11 +116,9 @@ object SurveyManager {
|
||||
startRefreshTimer(environmentDataHolder?.expiresAt())
|
||||
filterSurveys()
|
||||
hasApiError = false
|
||||
Formbricks.callback?.onSuccess(SuccessType.GET_ENVIRONMENT_SUCCESS)
|
||||
} catch (e: Exception) {
|
||||
hasApiError = true
|
||||
val error = SDKError.unableToRefreshEnvironment
|
||||
Formbricks.callback?.onError(error)
|
||||
Logger.e(error)
|
||||
startErrorTimer()
|
||||
}
|
||||
@@ -134,6 +130,7 @@ object SurveyManager {
|
||||
* Handles the display percentage and the delay of the survey.
|
||||
*/
|
||||
fun track(action: String) {
|
||||
print(environmentDataHolder?.data?.data?.actionClasses)
|
||||
val actionClasses = environmentDataHolder?.data?.data?.actionClasses ?: listOf()
|
||||
val codeActionClasses = actionClasses.filter { it.type == "code" }
|
||||
val actionClass = codeActionClasses.firstOrNull { it.key == action }
|
||||
@@ -143,7 +140,8 @@ object SurveyManager {
|
||||
}
|
||||
|
||||
if (firstSurveyWithActionClass == null) {
|
||||
Formbricks.callback?.onError(SDKError.surveyNotFoundError)
|
||||
val error = SDKError.surveyNotFoundError
|
||||
Logger.e(error)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -154,7 +152,6 @@ object SurveyManager {
|
||||
|
||||
if (languageCode == null) {
|
||||
val error = RuntimeException("Survey “${firstSurveyWithActionClass.name}” is not available in language “$currentLanguage”. Skipping.")
|
||||
Formbricks.callback?.onError(error)
|
||||
Logger.e(error)
|
||||
return
|
||||
}
|
||||
@@ -177,7 +174,8 @@ object SurveyManager {
|
||||
}, Date(System.currentTimeMillis() + timeout.toLong() * 1000))
|
||||
}
|
||||
} else {
|
||||
Formbricks.callback?.onError(SDKError.surveyNotDisplayedError)
|
||||
val error = SDKError.surveyNotDisplayedError
|
||||
Logger.e(error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,7 +190,6 @@ object SurveyManager {
|
||||
fun postResponse(surveyId: String?) {
|
||||
val id = surveyId.guard {
|
||||
val error = SDKError.missingSurveyId
|
||||
Formbricks.callback?.onError(error)
|
||||
Logger.e(error)
|
||||
return
|
||||
}
|
||||
@@ -206,7 +203,6 @@ object SurveyManager {
|
||||
fun onNewDisplay(surveyId: String?) {
|
||||
val id = surveyId.guard {
|
||||
val error = SDKError.missingSurveyId
|
||||
Formbricks.callback?.onError(error)
|
||||
Logger.e(error)
|
||||
return
|
||||
}
|
||||
@@ -269,7 +265,6 @@ object SurveyManager {
|
||||
|
||||
else -> {
|
||||
val error = SDKError.invalidDisplayOption
|
||||
Formbricks.callback?.onError(error)
|
||||
Logger.e(error)
|
||||
false
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import com.formbricks.formbrickssdk.extensions.guard
|
||||
import com.formbricks.formbrickssdk.extensions.lastDisplayAt
|
||||
import com.formbricks.formbrickssdk.logger.Logger
|
||||
import com.formbricks.formbrickssdk.model.error.SDKError
|
||||
import com.formbricks.formbrickssdk.model.enums.SuccessType
|
||||
import com.formbricks.formbrickssdk.model.user.Display
|
||||
import com.formbricks.formbrickssdk.network.queue.UpdateQueue
|
||||
import com.google.gson.Gson
|
||||
@@ -147,10 +146,8 @@ object UserManager {
|
||||
UpdateQueue.current.reset()
|
||||
SurveyManager.filterSurveys()
|
||||
startSyncTimer()
|
||||
Formbricks.callback?.onSuccess(SuccessType.SET_USER_SUCCESS)
|
||||
} catch (e: Exception) {
|
||||
val error = SDKError.unableToPostResponse
|
||||
Formbricks.callback?.onError(error)
|
||||
Logger.e(error)
|
||||
}
|
||||
}
|
||||
@@ -164,7 +161,6 @@ object UserManager {
|
||||
|
||||
if (!isUserIdDefined) {
|
||||
val error = SDKError.noUserIdSetError
|
||||
Formbricks.callback?.onError(error)
|
||||
Logger.e(error)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,5 +7,6 @@ import kotlinx.serialization.Serializable
|
||||
data class EnvironmentData(
|
||||
@SerializedName("surveys") val surveys: List<Survey>?,
|
||||
@SerializedName("actionClasses") val actionClasses: List<ActionClass>?,
|
||||
@SerializedName("project") val project: Project
|
||||
@SerializedName("project") val project: Project,
|
||||
@SerializedName("recaptchaSiteKey") val recaptchaSiteKey: String?
|
||||
)
|
||||
@@ -1,17 +1,183 @@
|
||||
package com.formbricks.formbrickssdk.model.environment
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.*
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
import kotlinx.serialization.json.*
|
||||
import kotlinx.serialization.descriptors.*
|
||||
import kotlinx.serialization.encoding.*
|
||||
|
||||
// MARK: - Connector
|
||||
@Serializable
|
||||
enum class SegmentConnector {
|
||||
@SerialName("and") AND,
|
||||
@SerialName("or") OR
|
||||
}
|
||||
|
||||
// MARK: - Filter Operators
|
||||
@Serializable
|
||||
enum class FilterOperator {
|
||||
@SerialName("lessThan") LESS_THAN,
|
||||
@SerialName("lessEqual") LESS_EQUAL,
|
||||
@SerialName("greaterThan") GREATER_THAN,
|
||||
@SerialName("greaterEqual") GREATER_EQUAL,
|
||||
@SerialName("equals") EQUALS,
|
||||
@SerialName("notEquals") NOT_EQUALS,
|
||||
@SerialName("contains") CONTAINS,
|
||||
@SerialName("doesNotContain") DOES_NOT_CONTAIN,
|
||||
@SerialName("startsWith") STARTS_WITH,
|
||||
@SerialName("endsWith") ENDS_WITH,
|
||||
@SerialName("isSet") IS_SET,
|
||||
@SerialName("isNotSet") IS_NOT_SET,
|
||||
@SerialName("userIsIn") USER_IS_IN,
|
||||
@SerialName("userIsNotIn") USER_IS_NOT_IN
|
||||
}
|
||||
|
||||
// MARK: - Filter Value
|
||||
@Serializable(with = SegmentFilterValueSerializer::class)
|
||||
sealed class SegmentFilterValue {
|
||||
data class StringValue(val value: String) : SegmentFilterValue()
|
||||
data class NumberValue(val value: Double) : SegmentFilterValue()
|
||||
}
|
||||
|
||||
object SegmentFilterValueSerializer : KSerializer<SegmentFilterValue> {
|
||||
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("SegmentFilterValue", PrimitiveKind.STRING)
|
||||
override fun deserialize(decoder: Decoder): SegmentFilterValue {
|
||||
val jsonInput = decoder as JsonDecoder
|
||||
val element = jsonInput.decodeJsonElement()
|
||||
return when (element) {
|
||||
is JsonPrimitive -> {
|
||||
element.doubleOrNull?.let { SegmentFilterValue.NumberValue(it) }
|
||||
?: SegmentFilterValue.StringValue(element.content)
|
||||
}
|
||||
else -> throw SerializationException("Unexpected type for SegmentFilterValue: $element")
|
||||
}
|
||||
}
|
||||
override fun serialize(encoder: Encoder, value: SegmentFilterValue) {
|
||||
val jsonOutput = encoder as JsonEncoder
|
||||
val element = when (value) {
|
||||
is SegmentFilterValue.NumberValue -> JsonPrimitive(value.value)
|
||||
is SegmentFilterValue.StringValue -> JsonPrimitive(value.value)
|
||||
}
|
||||
jsonOutput.encodeJsonElement(element)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Filter Root
|
||||
@Serializable(with = SegmentFilterRootSerializer::class)
|
||||
sealed class SegmentFilterRoot {
|
||||
data class Attribute(val contactAttributeKey: String) : SegmentFilterRoot()
|
||||
data class Person(val personIdentifier: String) : SegmentFilterRoot()
|
||||
data class Segment(val segmentId: String) : SegmentFilterRoot()
|
||||
data class Device(val deviceType: String) : SegmentFilterRoot()
|
||||
}
|
||||
|
||||
object SegmentFilterRootSerializer : KSerializer<SegmentFilterRoot> {
|
||||
override val descriptor: SerialDescriptor = buildClassSerialDescriptor("SegmentFilterRoot") {
|
||||
element<String>("type")
|
||||
}
|
||||
override fun deserialize(decoder: Decoder): SegmentFilterRoot {
|
||||
val input = decoder as JsonDecoder
|
||||
val obj = input.decodeJsonElement().jsonObject
|
||||
return when (val type = obj["type"]?.jsonPrimitive?.content) {
|
||||
"attribute" -> SegmentFilterRoot.Attribute(obj["contactAttributeKey"]!!.jsonPrimitive.content)
|
||||
"person" -> SegmentFilterRoot.Person(obj["personIdentifier"]!!.jsonPrimitive.content)
|
||||
"segment" -> SegmentFilterRoot.Segment(obj["segmentId"]!!.jsonPrimitive.content)
|
||||
"device" -> SegmentFilterRoot.Device(obj["deviceType"]!!.jsonPrimitive.content)
|
||||
else -> throw SerializationException("Unknown root type: $type")
|
||||
}
|
||||
}
|
||||
override fun serialize(encoder: Encoder, value: SegmentFilterRoot) {
|
||||
val output = encoder as JsonEncoder
|
||||
val json = buildJsonObject {
|
||||
when (value) {
|
||||
is SegmentFilterRoot.Attribute -> {
|
||||
put("type", JsonPrimitive("attribute"))
|
||||
put("contactAttributeKey", JsonPrimitive(value.contactAttributeKey))
|
||||
}
|
||||
is SegmentFilterRoot.Person -> {
|
||||
put("type", JsonPrimitive("person"))
|
||||
put("personIdentifier", JsonPrimitive(value.personIdentifier))
|
||||
}
|
||||
is SegmentFilterRoot.Segment -> {
|
||||
put("type", JsonPrimitive("segment"))
|
||||
put("segmentId", JsonPrimitive(value.segmentId))
|
||||
}
|
||||
is SegmentFilterRoot.Device -> {
|
||||
put("type", JsonPrimitive("device"))
|
||||
put("deviceType", JsonPrimitive(value.deviceType))
|
||||
}
|
||||
}
|
||||
}
|
||||
output.encodeJsonElement(json)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Qualifier
|
||||
@Serializable
|
||||
data class SegmentFilterQualifier(
|
||||
@SerialName("operator") val `operator`: FilterOperator
|
||||
)
|
||||
|
||||
// MARK: - Primitive Filter
|
||||
@Serializable
|
||||
data class SegmentPrimitiveFilter(
|
||||
val id: String,
|
||||
val root: SegmentFilterRoot,
|
||||
val value: SegmentFilterValue,
|
||||
val qualifier: SegmentFilterQualifier
|
||||
)
|
||||
|
||||
// MARK: - Recursive Resource
|
||||
@Serializable(with = SegmentFilterResourceSerializer::class)
|
||||
sealed class SegmentFilterResource {
|
||||
data class Primitive(val filter: SegmentPrimitiveFilter) : SegmentFilterResource()
|
||||
data class Group(val filters: List<SegmentFilter>) : SegmentFilterResource()
|
||||
}
|
||||
|
||||
object SegmentFilterResourceSerializer : KSerializer<SegmentFilterResource> {
|
||||
override val descriptor = buildClassSerialDescriptor("SegmentFilterResource") {
|
||||
// You can declare children here if you like,
|
||||
// or leave it empty if you’re purely passing through.
|
||||
}
|
||||
override fun deserialize(decoder: Decoder): SegmentFilterResource {
|
||||
val input = decoder as JsonDecoder
|
||||
val element = input.decodeJsonElement()
|
||||
return if (element is JsonArray) {
|
||||
val list = element.map { input.json.decodeFromJsonElement(SegmentFilter.serializer(), it) }
|
||||
SegmentFilterResource.Group(list)
|
||||
} else {
|
||||
val prim = input.json.decodeFromJsonElement(SegmentPrimitiveFilter.serializer(), element)
|
||||
SegmentFilterResource.Primitive(prim)
|
||||
}
|
||||
}
|
||||
override fun serialize(encoder: Encoder, value: SegmentFilterResource) {
|
||||
val output = encoder as JsonEncoder
|
||||
val json = when (value) {
|
||||
is SegmentFilterResource.Primitive -> output.json.encodeToJsonElement(SegmentPrimitiveFilter.serializer(), value.filter)
|
||||
is SegmentFilterResource.Group -> output.json.encodeToJsonElement(ListSerializer(SegmentFilter.serializer()), value.filters)
|
||||
}
|
||||
output.encodeJsonElement(json)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Filter Node
|
||||
@Serializable
|
||||
data class SegmentFilter(
|
||||
val id: String,
|
||||
val connector: SegmentConnector? = null,
|
||||
val resource: SegmentFilterResource
|
||||
)
|
||||
|
||||
// MARK: - Segment Model
|
||||
@Serializable
|
||||
data class Segment(
|
||||
@SerializedName("id") val id: String? = null,
|
||||
@SerializedName("createdAt") val createdAt: String? = null,
|
||||
@SerializedName("updatedAt") val updatedAt: String? = null,
|
||||
@SerializedName("title") val title: String? = null,
|
||||
@SerializedName("description") val description: String? = null,
|
||||
@SerializedName("isPrivate") val isPrivate: Boolean? = null,
|
||||
@SerializedName("filters") val filters: List<String>? = null,
|
||||
@SerializedName("environmentId") val environmentId: String? = null,
|
||||
@SerializedName("surveys") val surveys: List<String>? = null
|
||||
)
|
||||
val id: String,
|
||||
val title: String,
|
||||
val description: String? = null,
|
||||
@SerialName("isPrivate") val isPrivate: Boolean,
|
||||
val filters: List<SegmentFilter>,
|
||||
val environmentId: String,
|
||||
val createdAt: String,
|
||||
val updatedAt: String,
|
||||
val surveys: List<String>
|
||||
)
|
||||
|
||||
@@ -30,5 +30,5 @@ data class Survey(
|
||||
@SerializedName("displayOption") val displayOption: String?,
|
||||
@SerializedName("segment") val segment: Segment?,
|
||||
@SerializedName("styling") val styling: Styling?,
|
||||
@SerializedName("languages") val languages: List<SurveyLanguage>?
|
||||
@SerializedName("languages") val languages: List<SurveyLanguage>?,
|
||||
)
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.formbricks.formbrickssdk.network.queue
|
||||
|
||||
import com.formbricks.formbrickssdk.Formbricks
|
||||
import com.formbricks.formbrickssdk.logger.Logger
|
||||
import com.formbricks.formbrickssdk.manager.UserManager
|
||||
import com.formbricks.formbrickssdk.model.error.SDKError
|
||||
@@ -68,7 +67,6 @@ class UpdateQueue private constructor() {
|
||||
?: UserManager.userId
|
||||
if (effectiveUserId == null) {
|
||||
val error = SDKError.noUserIdSetError
|
||||
Formbricks.callback?.onError(error)
|
||||
Logger.e(error)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@ import android.widget.FrameLayout
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.viewModels
|
||||
import com.formbricks.formbrickssdk.Formbricks
|
||||
import com.formbricks.formbrickssdk.R
|
||||
import com.formbricks.formbrickssdk.databinding.FragmentFormbricksBinding
|
||||
import com.formbricks.formbrickssdk.logger.Logger
|
||||
@@ -37,26 +36,22 @@ import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||
import com.google.gson.JsonObject
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.InputStream
|
||||
import java.util.Timer
|
||||
|
||||
|
||||
class FormbricksFragment : BottomSheetDialogFragment() {
|
||||
|
||||
private lateinit var binding: FragmentFormbricksBinding
|
||||
private lateinit var surveyId: String
|
||||
private val closeTimer = Timer()
|
||||
private val viewModel: FormbricksViewModel by viewModels()
|
||||
|
||||
private var webAppInterface = WebAppInterface(object : WebAppInterface.WebAppCallback {
|
||||
override fun onClose() {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
Formbricks.callback?.onSurveyClosed()
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDisplayCreated() {
|
||||
Formbricks.callback?.onSurveyStarted()
|
||||
SurveyManager.onNewDisplay(surveyId)
|
||||
}
|
||||
|
||||
@@ -74,7 +69,8 @@ class FormbricksFragment : BottomSheetDialogFragment() {
|
||||
}
|
||||
|
||||
override fun onSurveyLibraryLoadError() {
|
||||
Formbricks.callback?.onError(SDKError.unableToLoadFormbicksJs)
|
||||
val error = SDKError.unableToLoadFormbicksJs
|
||||
Logger.e(error)
|
||||
dismiss()
|
||||
}
|
||||
})
|
||||
@@ -155,7 +151,8 @@ class FormbricksFragment : BottomSheetDialogFragment() {
|
||||
override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
|
||||
consoleMessage?.let { cm ->
|
||||
if (cm.messageLevel() == ConsoleMessage.MessageLevel.ERROR) {
|
||||
Formbricks.callback?.onError(SDKError.surveyDisplayFetchError)
|
||||
val error = SDKError.surveyDisplayFetchError
|
||||
Logger.e(error)
|
||||
dismiss()
|
||||
}
|
||||
val log = "[CONSOLE:${cm.messageLevel()}] \"${cm.message()}\", source: ${cm.sourceId()} (${cm.lineNumber()})"
|
||||
|
||||
@@ -99,7 +99,7 @@ class FormbricksViewModel : ViewModel() {
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
const script = document.createElement("script");
|
||||
script.src = "${Formbricks.appUrl}/js/surveys.umd.cjs";
|
||||
script.src = "${Formbricks.appUrl}/js/surveys.umd.cjs?randQuery=123445";
|
||||
script.async = true;
|
||||
script.onload = () => loadSurvey();
|
||||
script.onerror = (error) => {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.formbricks.formbrickssdk.webview
|
||||
|
||||
import android.webkit.JavascriptInterface
|
||||
import com.formbricks.formbrickssdk.Formbricks
|
||||
import com.formbricks.formbrickssdk.logger.Logger
|
||||
import com.formbricks.formbrickssdk.model.javascript.JsMessageData
|
||||
import com.formbricks.formbrickssdk.model.javascript.EventType
|
||||
@@ -36,15 +35,12 @@ class WebAppInterface(private val callback: WebAppCallback?) {
|
||||
EventType.ON_SURVEY_LIBRARY_LOAD_ERROR -> { callback?.onSurveyLibraryLoadError() }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Formbricks.callback?.onError(e)
|
||||
Logger.e(RuntimeException(e.message))
|
||||
} catch (e: JsonParseException) {
|
||||
Logger.e(RuntimeException("Failed to parse JSON message: $data"))
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Formbricks.callback?.onError(e)
|
||||
Logger.e(RuntimeException("Invalid message format: $data"))
|
||||
} catch (e: Exception) {
|
||||
Formbricks.callback?.onError(e)
|
||||
Logger.e(RuntimeException("Unexpected error processing message: $data"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +1,10 @@
|
||||
import UIKit
|
||||
import FormbricksSDK
|
||||
|
||||
class AppDelegate: NSObject, UIApplicationDelegate, FormbricksDelegate {
|
||||
class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
|
||||
func application(_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
|
||||
Formbricks.delegate = self
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - FormbricksDelegate
|
||||
|
||||
func onSurveyStarted() {
|
||||
print("from the delegate: survey started")
|
||||
}
|
||||
|
||||
func onSurveyFinished() {
|
||||
print("survey finished")
|
||||
}
|
||||
|
||||
func onSurveyClosed() {
|
||||
print("survey closed")
|
||||
}
|
||||
|
||||
func onError(_ error: Error) {
|
||||
print("survey error:", error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
/// Formbricks SDK delegate protocol. It contains the main methods to interact with the SDK.
|
||||
public protocol FormbricksDelegate: AnyObject {
|
||||
func onSurveyStarted()
|
||||
func onSurveyFinished()
|
||||
func onSurveyClosed()
|
||||
func onError(_ error: Error)
|
||||
}
|
||||
|
||||
/// The main class of the Formbricks SDK. It contains the main methods to interact with the SDK.
|
||||
@objc(Formbricks) public class Formbricks: NSObject {
|
||||
|
||||
@@ -23,7 +15,6 @@ public protocol FormbricksDelegate: AnyObject {
|
||||
static internal var apiQueue: OperationQueue? = OperationQueue()
|
||||
static internal var logger: Logger?
|
||||
static internal var service = FormbricksService()
|
||||
public static weak var delegate: FormbricksDelegate?
|
||||
|
||||
// make this class not instantiatable outside of the SDK
|
||||
internal override init() {
|
||||
@@ -58,7 +49,6 @@ public protocol FormbricksDelegate: AnyObject {
|
||||
|
||||
guard !isInitialized else {
|
||||
let error = FormbricksSDKError(type: .sdkIsAlreadyInitialized)
|
||||
delegate?.onError(error)
|
||||
Formbricks.logger?.error(error.message)
|
||||
return
|
||||
}
|
||||
@@ -101,7 +91,6 @@ public protocol FormbricksDelegate: AnyObject {
|
||||
@objc public static func setUserId(_ userId: String) {
|
||||
guard Formbricks.isInitialized else {
|
||||
let error = FormbricksSDKError(type: .sdkIsNotInitialized)
|
||||
delegate?.onError(error)
|
||||
Formbricks.logger?.error(error.message)
|
||||
return
|
||||
}
|
||||
@@ -126,7 +115,6 @@ public protocol FormbricksDelegate: AnyObject {
|
||||
@objc public static func setAttribute(_ attribute: String, forKey key: String) {
|
||||
guard Formbricks.isInitialized else {
|
||||
let error = FormbricksSDKError(type: .sdkIsNotInitialized)
|
||||
delegate?.onError(error)
|
||||
Formbricks.logger?.error(error.message)
|
||||
return
|
||||
}
|
||||
@@ -146,7 +134,6 @@ public protocol FormbricksDelegate: AnyObject {
|
||||
@objc public static func setAttributes(_ attributes: [String : String]) {
|
||||
guard Formbricks.isInitialized else {
|
||||
let error = FormbricksSDKError(type: .sdkIsNotInitialized)
|
||||
delegate?.onError(error)
|
||||
Formbricks.logger?.error(error.message)
|
||||
return
|
||||
}
|
||||
@@ -166,7 +153,6 @@ public protocol FormbricksDelegate: AnyObject {
|
||||
@objc public static func setLanguage(_ language: String) {
|
||||
guard Formbricks.isInitialized else {
|
||||
let error = FormbricksSDKError(type: .sdkIsNotInitialized)
|
||||
delegate?.onError(error)
|
||||
Formbricks.logger?.error(error.message)
|
||||
return
|
||||
}
|
||||
@@ -191,7 +177,6 @@ public protocol FormbricksDelegate: AnyObject {
|
||||
@objc public static func track(_ action: String) {
|
||||
guard Formbricks.isInitialized else {
|
||||
let error = FormbricksSDKError(type: .sdkIsNotInitialized)
|
||||
delegate?.onError(error)
|
||||
Formbricks.logger?.error(error.message)
|
||||
return
|
||||
}
|
||||
@@ -218,7 +203,6 @@ public protocol FormbricksDelegate: AnyObject {
|
||||
@objc public static func logout() {
|
||||
guard Formbricks.isInitialized else {
|
||||
let error = FormbricksSDKError(type: .sdkIsNotInitialized)
|
||||
delegate?.onError(error)
|
||||
Formbricks.logger?.error(error.message)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -106,7 +106,6 @@ extension SurveyManager {
|
||||
case .failure:
|
||||
self?.hasApiError = true
|
||||
let error = FormbricksSDKError(type: .unableToRefreshEnvironment)
|
||||
Formbricks.delegate?.onError(error)
|
||||
Formbricks.logger?.error(error.message)
|
||||
self?.startErrorTimer()
|
||||
}
|
||||
@@ -186,7 +185,6 @@ extension SurveyManager {
|
||||
return try? JSONDecoder().decode(EnvironmentResponse.self, from: data)
|
||||
} else {
|
||||
let error = FormbricksSDKError(type: .unableToRetrieveEnvironment)
|
||||
Formbricks.delegate?.onError(error)
|
||||
Formbricks.logger?.error(error.message)
|
||||
return nil
|
||||
}
|
||||
@@ -197,7 +195,6 @@ extension SurveyManager {
|
||||
backingEnvironmentResponse = newValue
|
||||
} else {
|
||||
let error = FormbricksSDKError(type: .unableToPersistEnvironment)
|
||||
Formbricks.delegate?.onError(error)
|
||||
Formbricks.logger?.error(error.message)
|
||||
}
|
||||
}
|
||||
@@ -231,7 +228,6 @@ private extension SurveyManager {
|
||||
|
||||
default:
|
||||
let error = FormbricksSDKError(type: .invalidDisplayOption)
|
||||
Formbricks.delegate?.onError(error)
|
||||
Formbricks.logger?.error(error.message)
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -102,7 +102,6 @@ final class UserManager: UserManagerSyncable {
|
||||
self?.surveyManager?.filterSurveys()
|
||||
self?.startSyncTimer()
|
||||
case .failure(let error):
|
||||
Formbricks.delegate?.onError(error)
|
||||
Formbricks.logger?.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,4 +2,5 @@ struct EnvironmentData: Codable {
|
||||
let surveys: [Survey]?
|
||||
let actionClasses: [ActionClass]?
|
||||
let project: Project
|
||||
let recaptchaSiteKey: String?
|
||||
}
|
||||
|
||||
@@ -1,11 +1,198 @@
|
||||
struct Segment: Codable {
|
||||
let id: String?
|
||||
let createdAt: String?
|
||||
let updatedAt: String?
|
||||
let title: String?
|
||||
let description: String?
|
||||
let isPrivate: Bool?
|
||||
let filters: [String]?
|
||||
let environmentId: String?
|
||||
let surveys: [String]?
|
||||
import Foundation
|
||||
|
||||
// MARK: - Connector
|
||||
|
||||
enum SegmentConnector: String, Codable {
|
||||
case and
|
||||
case or
|
||||
}
|
||||
|
||||
// MARK: - Filter Operators
|
||||
|
||||
/// Combined operator set for all filter types
|
||||
enum FilterOperator: String, Codable {
|
||||
// Base / Arithmetic
|
||||
case lessThan
|
||||
case lessEqual
|
||||
case greaterThan
|
||||
case greaterEqual
|
||||
case equals
|
||||
case notEquals
|
||||
// Attribute / String
|
||||
case contains
|
||||
case doesNotContain
|
||||
case startsWith
|
||||
case endsWith
|
||||
// Existence
|
||||
case isSet
|
||||
case isNotSet
|
||||
// Segment membership
|
||||
case userIsIn
|
||||
case userIsNotIn
|
||||
}
|
||||
|
||||
// MARK: - Filter Value
|
||||
|
||||
enum SegmentFilterValue: Codable {
|
||||
case string(String)
|
||||
case number(Double)
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
if let num = try? container.decode(Double.self) {
|
||||
self = .number(num)
|
||||
} else if let str = try? container.decode(String.self) {
|
||||
self = .string(str)
|
||||
} else {
|
||||
throw DecodingError.typeMismatch(
|
||||
SegmentFilterValue.self,
|
||||
DecodingError.Context(
|
||||
codingPath: decoder.codingPath,
|
||||
debugDescription: "Value is neither Double nor String"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.singleValueContainer()
|
||||
switch self {
|
||||
case .number(let num):
|
||||
try container.encode(num)
|
||||
case .string(let str):
|
||||
try container.encode(str)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Root
|
||||
|
||||
enum SegmentFilterRoot: Codable {
|
||||
case attribute(contactAttributeKey: String)
|
||||
case person(personIdentifier: String)
|
||||
case segment(segmentId: String)
|
||||
case device(deviceType: String)
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case type
|
||||
case contactAttributeKey
|
||||
case personIdentifier
|
||||
case segmentId
|
||||
case deviceType
|
||||
}
|
||||
|
||||
private enum RootType: String, Codable {
|
||||
case attribute
|
||||
case person
|
||||
case segment
|
||||
case device
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let type = try container.decode(RootType.self, forKey: .type)
|
||||
switch type {
|
||||
case .attribute:
|
||||
let key = try container.decode(String.self, forKey: .contactAttributeKey)
|
||||
self = .attribute(contactAttributeKey: key)
|
||||
case .person:
|
||||
let id = try container.decode(String.self, forKey: .personIdentifier)
|
||||
self = .person(personIdentifier: id)
|
||||
case .segment:
|
||||
let id = try container.decode(String.self, forKey: .segmentId)
|
||||
self = .segment(segmentId: id)
|
||||
case .device:
|
||||
let type = try container.decode(String.self, forKey: .deviceType)
|
||||
self = .device(deviceType: type)
|
||||
}
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
switch self {
|
||||
case .attribute(let key):
|
||||
try container.encode(RootType.attribute, forKey: .type)
|
||||
try container.encode(key, forKey: .contactAttributeKey)
|
||||
case .person(let id):
|
||||
try container.encode(RootType.person, forKey: .type)
|
||||
try container.encode(id, forKey: .personIdentifier)
|
||||
case .segment(let id):
|
||||
try container.encode(RootType.segment, forKey: .type)
|
||||
try container.encode(id, forKey: .segmentId)
|
||||
case .device(let type):
|
||||
try container.encode(RootType.device, forKey: .type)
|
||||
try container.encode(type, forKey: .deviceType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Qualifier
|
||||
|
||||
struct SegmentFilterQualifier: Codable {
|
||||
let `operator`: FilterOperator
|
||||
}
|
||||
|
||||
// MARK: - Primitive Filter
|
||||
|
||||
struct SegmentPrimitiveFilter: Codable {
|
||||
let id: String
|
||||
let root: SegmentFilterRoot
|
||||
let value: SegmentFilterValue
|
||||
let qualifier: SegmentFilterQualifier
|
||||
|
||||
// Add run-time refinements if needed
|
||||
}
|
||||
|
||||
// MARK: - Recursive Filter Resource
|
||||
|
||||
enum SegmentFilterResource: Codable {
|
||||
case primitive(SegmentPrimitiveFilter)
|
||||
case group([SegmentFilter])
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
// Try primitive first
|
||||
if let prim = try? SegmentPrimitiveFilter(from: decoder) {
|
||||
self = .primitive(prim)
|
||||
} else {
|
||||
let nested = try [SegmentFilter](from: decoder)
|
||||
self = .group(nested)
|
||||
}
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
switch self {
|
||||
case .primitive(let prim):
|
||||
try prim.encode(to: encoder)
|
||||
case .group(let arr):
|
||||
try arr.encode(to: encoder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Base Filter (node)
|
||||
|
||||
struct SegmentFilter: Codable {
|
||||
let id: String
|
||||
let connector: SegmentConnector?
|
||||
let resource: SegmentFilterResource
|
||||
}
|
||||
|
||||
// MARK: - Segment Model
|
||||
|
||||
struct Segment: Codable {
|
||||
let id: String
|
||||
let title: String
|
||||
let description: String?
|
||||
let isPrivate: Bool
|
||||
let filters: [SegmentFilter]
|
||||
let environmentId: String
|
||||
let createdAt: Date
|
||||
let updatedAt: Date
|
||||
let surveys: [String]
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, title, description, filters, surveys
|
||||
case isPrivate = "isPrivate"
|
||||
case environmentId, createdAt, updatedAt
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,6 @@ class APIClient<Request: CodableRequest>: Operation, @unchecked Sendable {
|
||||
private func processResponse(data: Data?, response: URLResponse?, error: Error?) {
|
||||
guard let httpStatus = (response as? HTTPURLResponse)?.status else {
|
||||
let error = FormbricksAPIClientError(type: .invalidResponse)
|
||||
Formbricks.delegate?.onError(error)
|
||||
Formbricks.logger?.error("ERROR \(error.message)")
|
||||
completion?(.failure(error))
|
||||
return
|
||||
@@ -57,7 +56,6 @@ class APIClient<Request: CodableRequest>: Operation, @unchecked Sendable {
|
||||
private func handleSuccessResponse(data: Data?, statusCode: Int, message: inout String) {
|
||||
guard let data = data else {
|
||||
let error = FormbricksAPIClientError(type: .invalidResponse, statusCode: statusCode)
|
||||
Formbricks.delegate?.onError(error)
|
||||
completion?(.failure(error))
|
||||
return
|
||||
}
|
||||
@@ -89,16 +87,13 @@ class APIClient<Request: CodableRequest>: Operation, @unchecked Sendable {
|
||||
|
||||
if let error = error {
|
||||
log.append("\nError: \(error.localizedDescription)")
|
||||
Formbricks.delegate?.onError(error)
|
||||
Formbricks.logger?.error(log)
|
||||
completion?(.failure(error))
|
||||
} else if let data = data, let apiError = try? request.decoder.decode(FormbricksAPIError.self, from: data) {
|
||||
Formbricks.delegate?.onError(apiError)
|
||||
Formbricks.logger?.error("\(log)\n\(apiError.getDetailedErrorMessage())")
|
||||
completion?(.failure(apiError))
|
||||
} else {
|
||||
let error = FormbricksAPIClientError(type: .responseError, statusCode: statusCode)
|
||||
Formbricks.delegate?.onError(error)
|
||||
Formbricks.logger?.error("\(log)\n\(error.message)")
|
||||
completion?(.failure(error))
|
||||
}
|
||||
@@ -119,10 +114,8 @@ class APIClient<Request: CodableRequest>: Operation, @unchecked Sendable {
|
||||
}
|
||||
|
||||
let error = FormbricksAPIClientError(type: .invalidResponse, statusCode: statusCode)
|
||||
Formbricks.delegate?.onError(error)
|
||||
Formbricks.logger?.error(error.message)
|
||||
completion?(.failure(error))
|
||||
|
||||
}
|
||||
|
||||
private func logRequest(_ request: URLRequest) {
|
||||
|
||||
@@ -101,7 +101,6 @@ private extension UpdateQueue {
|
||||
|
||||
guard let userId = effectiveUserId else {
|
||||
let error = FormbricksSDKError(type: .userIdIsNotSetYet)
|
||||
Formbricks.delegate?.onError(error)
|
||||
Formbricks.logger?.error(error.message)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -110,7 +110,6 @@ private class WebViewData {
|
||||
let jsonData = try JSONSerialization.data(withJSONObject: data, options: [])
|
||||
return String(data: jsonData, encoding: .utf8)?.replacingOccurrences(of: "\\\"", with: "'")
|
||||
} catch {
|
||||
Formbricks.delegate?.onError(error)
|
||||
Formbricks.logger?.error(error.message)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -119,12 +119,10 @@ final class JsMessageHandler: NSObject, WKScriptMessageHandler {
|
||||
|
||||
/// Happens when a survey is shown.
|
||||
case .onDisplayCreated:
|
||||
Formbricks.delegate?.onSurveyStarted()
|
||||
Formbricks.surveyManager?.onNewDisplay(surveyId: surveyId)
|
||||
|
||||
/// Happens when the user closes the survey view with the close button.
|
||||
case .onClose:
|
||||
Formbricks.delegate?.onSurveyClosed()
|
||||
Formbricks.surveyManager?.dismissSurveyWebView()
|
||||
|
||||
/// Happens when the survey wants to open an external link in the default browser.
|
||||
@@ -140,7 +138,6 @@ final class JsMessageHandler: NSObject, WKScriptMessageHandler {
|
||||
|
||||
} else {
|
||||
let error = FormbricksSDKError(type: .invalidJavascriptMessage)
|
||||
Formbricks.delegate?.onError(error)
|
||||
Formbricks.logger?.error("\(error.message): \(message.body)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -210,7 +210,6 @@ const refineFilters = (filters: TBaseFilters): boolean => {
|
||||
// The filters can be nested, so we need to use z.lazy to define the type
|
||||
// more on recusrsive types -> https://zod.dev/?id=recursive-types
|
||||
|
||||
// TODO: Figure out why this is not working, and then remove the ts-ignore
|
||||
export const ZSegmentFilters: z.ZodType<TBaseFilters> = z
|
||||
.array(
|
||||
z.object({
|
||||
|
||||
639
pnpm-lock.yaml
generated
639
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user