Compare commits

..

11 Commits

Author SHA1 Message Date
pandeymangg 1fba692626 fix: android sdk segment bug 2025-05-13 19:24:06 +05:30
pandeymangg 3ace91cdd5 fix: android sdk segment bug 2025-05-13 18:39:50 +05:30
pandeymangg 4ba7bf5b3c fix 2025-05-13 16:44:45 +05:30
pandeymangg bd1402a58b fixes android sdk issues: 2025-05-13 15:55:54 +05:30
pandeymangg c2af0c3fb6 fixes ios sdk issues and removes callbacks 2025-05-13 14:48:23 +05:30
Piyush Gupta dde5a55446 fix: CTA and consent question breaking the survey editor (#5745) 2025-05-13 04:18:34 +00:00
Piyush Gupta 13e615a798 fix: duplicate switch cases (#5752)
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-05-12 15:00:55 +00:00
victorvhs017 9c81961b0b chore: remove redundant code (#5751) 2025-05-12 12:23:05 +00:00
Matti Nannt c1a35e2d75 chore: introduce new reliable cache for enterprise license check (#5740)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-05-12 10:41:53 +02:00
Piyush Gupta 13415c75c2 docs: adds auth-behaviour docs (#5743) 2025-05-12 05:28:12 +00:00
Johannes 300557a0e6 docs: update ee feature table (#5744) 2025-05-11 22:10:40 -07:00
66 changed files with 2657 additions and 1387 deletions
-1
View File
@@ -172,7 +172,6 @@ ENTERPRISE_LICENSE_KEY=
# Automatically assign new users to a specific organization and role within that organization # 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) # 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) # (Role Management is an Enterprise feature)
# DEFAULT_ORGANIZATION_ROLE=owner
# AUTH_SSO_DEFAULT_TEAM_ID= # AUTH_SSO_DEFAULT_TEAM_ID=
# AUTH_SKIP_INVITE_FOR_SSO= # AUTH_SKIP_INVITE_FOR_SSO=
+18 -4
View File
@@ -11,6 +11,8 @@ on:
required: false required: false
PLAYWRIGHT_SERVICE_URL: PLAYWRIGHT_SERVICE_URL:
required: false required: false
ENTERPRISE_LICENSE_KEY:
required: true
# Add other secrets if necessary # Add other secrets if necessary
workflow_dispatch: workflow_dispatch:
@@ -48,15 +50,17 @@ jobs:
- name: Harden the runner (Audit all outbound calls) - name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with: with:
egress-policy: audit egress-policy: allow
allowed-endpoints: |
ee.formbricks.com:443
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: ./.github/actions/dangerous-git-checkout - 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 uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # v3.8.2
with: with:
node-version: 20.x node-version: 22.x
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 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/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${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/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 "" >> .env
echo "E2E_TESTING=1" >> .env echo "E2E_TESTING=1" >> .env
shell: bash shell: bash
@@ -89,8 +93,18 @@ jobs:
# pnpm prisma migrate deploy # pnpm prisma migrate deploy
pnpm db:migrate:dev 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 - name: Run App
run: | run: |
echo "Starting app with enterprise license..."
NODE_ENV=test pnpm start --filter=@formbricks/web | tee app.log 2>&1 & NODE_ENV=test pnpm start --filter=@formbricks/web | tee app.log 2>&1 &
sleep 10 # Optional: gives some buffer for the app to start sleep 10 # Optional: gives some buffer for the app to start
for attempt in {1..10}; do for attempt in {1..10}; do
@@ -1,13 +1,21 @@
import { getOrganizationsByUserId } from "@/lib/organization/service"; import { getOrganizationsByUserId } from "@/lib/organization/service";
import { getUser } from "@/lib/user/service"; import { getUser } from "@/lib/user/service";
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationAuth } from "@/modules/organization/lib/utils"; import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { getTranslate } from "@/tolgee/server"; import { getTranslate } from "@/tolgee/server";
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react"; import { cleanup, render, screen } from "@testing-library/react";
import { notFound, redirect } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import { afterEach, describe, expect, test, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import Page from "./page";
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", () => ({ vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false, 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("@/modules/organization/lib/utils");
vi.mock("@/lib/user/service"); vi.mock("@/lib/user/service");
vi.mock("@/lib/organization/service"); vi.mock("@/lib/organization/service");
vi.mock("@/modules/ee/license-check/lib/utils");
vi.mock("@/tolgee/server"); vi.mock("@/tolgee/server");
vi.mock("next/navigation", () => ({ vi.mock("next/navigation", () => ({
redirect: vi.fn(() => "REDIRECT_STUB"), redirect: vi.fn(() => "REDIRECT_STUB"),
notFound: vi.fn(() => "NOT_FOUND_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", () => { describe("Page component", () => {
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
vi.clearAllMocks();
});
beforeEach(() => {
vi.resetModules();
}); });
test("redirects to login if no user session", async () => { test("redirects to login if no user session", async () => {
vi.mocked(getOrganizationAuth).mockResolvedValue({ session: {}, organization: {} } as any); 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" } }); const result = await Page({ params: { organizationId: "org1" } });
expect(redirect).toHaveBeenCalledWith("/auth/login"); expect(redirect).toHaveBeenCalledWith("/auth/login");
expect(result).toBe("REDIRECT_STUB"); expect(result).toBe("REDIRECT_STUB");
}); });
@@ -124,9 +153,17 @@ describe("Page component", () => {
organization: {}, organization: {},
} as any); } as any);
vi.mocked(getUser).mockResolvedValue(null); 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" } }); const result = await Page({ params: { organizationId: "org1" } });
expect(notFound).toHaveBeenCalled(); expect(notFound).toHaveBeenCalled();
expect(result).toBe("NOT_FOUND_STUB"); expect(result).toBe("NOT_FOUND_STUB");
}); });
@@ -138,14 +175,21 @@ describe("Page component", () => {
} as any); } as any);
vi.mocked(getUser).mockResolvedValue({ id: "user1", name: "Test User" } 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(getOrganizationsByUserId).mockResolvedValue([{ id: "org1", name: "Org One" } as any]);
vi.mocked(getEnterpriseLicense).mockResolvedValue({ features: { isMultiOrgEnabled: true } } as any);
vi.mocked(getTranslate).mockResolvedValue((props: any) => vi.mocked(getTranslate).mockResolvedValue((props: any) =>
typeof props === "string" ? props : props.key || "" 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" } }); const element = await Page({ params: { organizationId: "org1" } });
render(element as React.ReactElement); render(element as React.ReactElement);
expect(screen.getByTestId("landing-sidebar")).toBeInTheDocument(); expect(screen.getByTestId("landing-sidebar")).toBeInTheDocument();
expect(screen.getByText("organizations.landing.no_projects_warning_title")).toBeInTheDocument(); expect(screen.getByText("organizations.landing.no_projects_warning_title")).toBeInTheDocument();
expect(screen.getByText("organizations.landing.no_projects_warning_subtitle")).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 { LandingSidebar } from "@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar";
import { getOrganizationsByUserId } from "@/lib/organization/service"; import { getOrganizationsByUserId } from "@/lib/organization/service";
import { getUser } from "@/lib/user/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 { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { Header } from "@/modules/ui/components/header"; import { Header } from "@/modules/ui/components/header";
import { getTranslate } from "@/tolgee/server"; import { getTranslate } from "@/tolgee/server";
@@ -65,7 +65,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
/> />
{projects.length >= 1 && ( {projects.length >= 1 && (
<Button <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" variant="ghost"
asChild> asChild>
<Link href={"/"}> <Link href={"/"}>
@@ -1,4 +1,3 @@
import { EnvironmentLayout } from "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout";
import { getEnvironment, getEnvironments } from "@/lib/environment/service"; import { getEnvironment, getEnvironments } from "@/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service"; import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils"; import { getAccessFlags } from "@/lib/membership/utils";
@@ -10,7 +9,7 @@ import {
} from "@/lib/organization/service"; } from "@/lib/organization/service";
import { getUserProjects } from "@/lib/project/service"; import { getUserProjects } from "@/lib/project/service";
import { getUser } from "@/lib/user/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 { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { cleanup, render, screen } from "@testing-library/react"; import { cleanup, render, screen } from "@testing-library/react";
import type { Session } from "next-auth"; 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 getAccessFlags: vi.fn(() => ({ isMember: true })), // Default to member for simplicity
})); }));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({ vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getEnterpriseLicense: vi.fn(),
getOrganizationProjectsLimit: vi.fn(), getOrganizationProjectsLimit: vi.fn(),
})); }));
vi.mock("@/modules/ee/teams/lib/roles", () => ({ vi.mock("@/modules/ee/teams/lib/roles", () => ({
@@ -176,7 +174,6 @@ describe("EnvironmentLayout", () => {
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership); vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
vi.mocked(getMonthlyActiveOrganizationPeopleCount).mockResolvedValue(100); vi.mocked(getMonthlyActiveOrganizationPeopleCount).mockResolvedValue(100);
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(500); vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(500);
vi.mocked(getEnterpriseLicense).mockResolvedValue(mockLicense);
vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(null as any); vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(null as any);
vi.mocked(getProjectPermissionByUserId).mockResolvedValue(mockProjectPermission); vi.mocked(getProjectPermissionByUserId).mockResolvedValue(mockProjectPermission);
mockIsDevelopment = false; mockIsDevelopment = false;
@@ -189,13 +186,19 @@ describe("EnvironmentLayout", () => {
}); });
test("renders correctly with default props", async () => { test("renders correctly with default props", async () => {
// Ensure the default mockLicense has isPendingDowngrade: false and active: false vi.resetModules();
vi.mocked(getEnterpriseLicense).mockResolvedValue({ await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
...mockLicense, getEnterpriseLicense: vi.fn().mockResolvedValue({
isPendingDowngrade: false, active: false,
active: false, isPendingDowngrade: false,
}); features: { isMultiOrgEnabled: false },
lastChecked: new Date(),
fallbackLevel: "live",
}),
}));
const { EnvironmentLayout } = await import(
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
);
render( render(
await EnvironmentLayout({ await EnvironmentLayout({
environmentId: "env-1", environmentId: "env-1",
@@ -203,20 +206,31 @@ describe("EnvironmentLayout", () => {
children: <div>Child Content</div>, children: <div>Child Content</div>,
}) })
); );
expect(screen.getByTestId("main-navigation")).toBeInTheDocument(); expect(screen.getByTestId("main-navigation")).toBeInTheDocument();
expect(screen.getByTestId("top-control-bar")).toBeInTheDocument(); expect(screen.getByTestId("top-control-bar")).toBeInTheDocument();
expect(screen.getByText("Child Content")).toBeInTheDocument(); expect(screen.getByText("Child Content")).toBeInTheDocument();
expect(screen.queryByTestId("dev-banner")).not.toBeInTheDocument(); expect(screen.queryByTestId("dev-banner")).not.toBeInTheDocument();
expect(screen.queryByTestId("limits-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 () => { test("renders DevEnvironmentBanner in development environment", async () => {
const devEnvironment = { ...mockEnvironment, type: "development" as const }; const devEnvironment = { ...mockEnvironment, type: "development" as const };
vi.mocked(getEnvironment).mockResolvedValue(devEnvironment); vi.mocked(getEnvironment).mockResolvedValue(devEnvironment);
mockIsDevelopment = true; 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( render(
await EnvironmentLayout({ await EnvironmentLayout({
environmentId: "env-1", environmentId: "env-1",
@@ -224,13 +238,24 @@ describe("EnvironmentLayout", () => {
children: <div>Child Content</div>, children: <div>Child Content</div>,
}) })
); );
expect(screen.getByTestId("dev-banner")).toBeInTheDocument(); expect(screen.getByTestId("dev-banner")).toBeInTheDocument();
}); });
test("renders LimitsReachedBanner in Formbricks Cloud", async () => { test("renders LimitsReachedBanner in Formbricks Cloud", async () => {
mockIsFormbricksCloud = true; 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( render(
await EnvironmentLayout({ await EnvironmentLayout({
environmentId: "env-1", environmentId: "env-1",
@@ -238,17 +263,21 @@ describe("EnvironmentLayout", () => {
children: <div>Child Content</div>, children: <div>Child Content</div>,
}) })
); );
expect(screen.getByTestId("limits-banner")).toBeInTheDocument(); expect(screen.getByTestId("limits-banner")).toBeInTheDocument();
expect(vi.mocked(getMonthlyActiveOrganizationPeopleCount)).toHaveBeenCalledWith(mockOrganization.id); expect(vi.mocked(getMonthlyActiveOrganizationPeopleCount)).toHaveBeenCalledWith(mockOrganization.id);
expect(vi.mocked(getMonthlyOrganizationResponseCount)).toHaveBeenCalledWith(mockOrganization.id); expect(vi.mocked(getMonthlyOrganizationResponseCount)).toHaveBeenCalledWith(mockOrganization.id);
}); });
test("renders PendingDowngradeBanner when pending downgrade", async () => { 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 }; 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( render(
await EnvironmentLayout({ await EnvironmentLayout({
environmentId: "env-1", environmentId: "env-1",
@@ -256,12 +285,24 @@ describe("EnvironmentLayout", () => {
children: <div>Child Content</div>, children: <div>Child Content</div>,
}) })
); );
expect(screen.getByTestId("downgrade-banner")).toBeInTheDocument(); expect(screen.getByTestId("downgrade-banner")).toBeInTheDocument();
}); });
test("throws error if user not found", async () => { test("throws error if user not found", async () => {
vi.mocked(getUser).mockResolvedValue(null); 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( await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
"common.user_not_found" "common.user_not_found"
); );
@@ -269,6 +310,19 @@ describe("EnvironmentLayout", () => {
test("throws error if organization not found", async () => { test("throws error if organization not found", async () => {
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null); 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( await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
"common.organization_not_found" "common.organization_not_found"
); );
@@ -276,13 +330,39 @@ describe("EnvironmentLayout", () => {
test("throws error if environment not found", async () => { test("throws error if environment not found", async () => {
vi.mocked(getEnvironment).mockResolvedValue(null); 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( await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
"common.environment_not_found" "common.environment_not_found"
); );
}); });
test("throws error if projects, environments or organizations not found", async () => { 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( await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
"environments.projects_environments_organizations_not_found" "environments.projects_environments_organizations_not_found"
); );
@@ -291,6 +371,19 @@ describe("EnvironmentLayout", () => {
test("throws error if member has no project permission", async () => { test("throws error if member has no project permission", async () => {
vi.mocked(getAccessFlags).mockReturnValue({ isMember: true } as any); vi.mocked(getAccessFlags).mockReturnValue({ isMember: true } as any);
vi.mocked(getProjectPermissionByUserId).mockResolvedValue(null); 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( await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
"common.project_permission_not_found" "common.project_permission_not_found"
); );
@@ -12,7 +12,8 @@ import {
} from "@/lib/organization/service"; } from "@/lib/organization/service";
import { getUserProjects } from "@/lib/project/service"; import { getUserProjects } from "@/lib/project/service";
import { getUser } from "@/lib/user/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 { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { DevEnvironmentBanner } from "@/modules/ui/components/dev-environment-banner"; import { DevEnvironmentBanner } from "@/modules/ui/components/dev-environment-banner";
import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-banner"; import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-banner";
@@ -109,7 +109,7 @@ export const MainNavigation = ({
useEffect(() => { useEffect(() => {
const toggleTextOpacity = () => { const toggleTextOpacity = () => {
setIsTextVisible(isCollapsed ? true : false); setIsTextVisible(isCollapsed);
}; };
const timeoutId = setTimeout(toggleTextOpacity, 150); const timeoutId = setTimeout(toggleTextOpacity, 150);
return () => clearTimeout(timeoutId); return () => clearTimeout(timeoutId);
@@ -170,7 +170,7 @@ export const MainNavigation = ({
name: t("common.actions"), name: t("common.actions"),
href: `/environments/${environment.id}/actions`, href: `/environments/${environment.id}/actions`,
icon: MousePointerClick, icon: MousePointerClick,
isActive: pathname?.includes("/actions") || pathname?.includes("/actions"), isActive: pathname?.includes("/actions"),
}, },
{ {
name: t("common.integrations"), name: t("common.integrations"),
@@ -10,7 +10,6 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TMembership } from "@formbricks/types/memberships"; import { TMembership } from "@formbricks/types/memberships";
import { TOrganization, TOrganizationBilling } from "@formbricks/types/organizations"; import { TOrganization, TOrganizationBilling } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user"; import { TUser } from "@formbricks/types/user";
import EnterpriseSettingsPage from "./page";
vi.mock("@formbricks/database", () => ({ vi.mock("@formbricks/database", () => ({
prisma: { prisma: {
@@ -179,15 +178,23 @@ describe("EnterpriseSettingsPage", () => {
}); });
test("renders correctly for an owner when not on Formbricks Cloud", async () => { 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 } }); const Page = await EnterpriseSettingsPage({ params: { environmentId: mockEnvironmentId } });
render(Page); render(Page);
expect(screen.getByTestId("page-content-wrapper")).toBeInTheDocument(); expect(screen.getByTestId("page-content-wrapper")).toBeInTheDocument();
expect(screen.getByTestId("page-header")).toBeInTheDocument(); expect(screen.getByTestId("page-header")).toBeInTheDocument();
expect(screen.getByText("environments.settings.enterprise.sso")).toBeInTheDocument(); expect(screen.getByText("environments.settings.enterprise.sso")).toBeInTheDocument();
expect(screen.getByText("environments.settings.billing.remove_branding")).toBeInTheDocument(); expect(screen.getByText("environments.settings.billing.remove_branding")).toBeInTheDocument();
expect(redirect).not.toHaveBeenCalled(); expect(redirect).not.toHaveBeenCalled();
}); });
}); });
@@ -1,6 +1,6 @@
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar"; import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants"; 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 { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; 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"> <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 <svg
viewBox="0 0 1024 1024" 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"> aria-hidden="true">
<circle <circle
cx={512} cx={512}
@@ -1,7 +1,14 @@
import { cleanup, render, screen } from "@testing-library/react"; import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest"; 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", () => { describe("QuestionsComboBox", () => {
afterEach(() => { afterEach(() => {
@@ -53,3 +60,67 @@ describe("QuestionsComboBox", () => {
expect(screen.getByText("Q1")).toBeInTheDocument(); // Verify the selected item is now displayed 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, CheckIcon,
ChevronDown, ChevronDown,
ChevronUp, ChevronUp,
ContactIcon,
EyeOff, EyeOff,
GlobeIcon, GlobeIcon,
GridIcon, GridIcon,
HashIcon, HashIcon,
HelpCircleIcon, HomeIcon,
ImageIcon, ImageIcon,
LanguagesIcon, LanguagesIcon,
ListIcon, ListIcon,
@@ -63,59 +64,60 @@ interface QuestionComboBoxProps {
onChangeValue: (option: QuestionOption) => void; onChangeValue: (option: QuestionOption) => void;
} }
const SelectedCommandItem = ({ label, questionType, type }: Partial<QuestionOption>) => { const questionIcons = {
const getIconType = () => { // questions
switch (type) { [TSurveyQuestionTypeEnum.OpenText]: MessageSquareTextIcon,
case OptionsType.QUESTIONS: [TSurveyQuestionTypeEnum.Rating]: StarIcon,
switch (questionType) { [TSurveyQuestionTypeEnum.CTA]: MousePointerClickIcon,
case TSurveyQuestionTypeEnum.OpenText: [TSurveyQuestionTypeEnum.MultipleChoiceMulti]: ListIcon,
return <MessageSquareTextIcon width={18} height={18} className="text-white" />; [TSurveyQuestionTypeEnum.MultipleChoiceSingle]: Rows3Icon,
case TSurveyQuestionTypeEnum.Rating: [TSurveyQuestionTypeEnum.NPS]: NetPromoterScoreIcon,
return <StarIcon width={18} height={18} className="text-white" />; [TSurveyQuestionTypeEnum.Consent]: CheckIcon,
case TSurveyQuestionTypeEnum.CTA: [TSurveyQuestionTypeEnum.PictureSelection]: ImageIcon,
return <MousePointerClickIcon width={18} height={18} className="text-white" />; [TSurveyQuestionTypeEnum.Matrix]: GridIcon,
case TSurveyQuestionTypeEnum.OpenText: [TSurveyQuestionTypeEnum.Ranking]: ListOrderedIcon,
return <HelpCircleIcon width={18} height={18} className="text-white" />; [TSurveyQuestionTypeEnum.Address]: HomeIcon,
case TSurveyQuestionTypeEnum.MultipleChoiceMulti: [TSurveyQuestionTypeEnum.ContactInfo]: ContactIcon,
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" />;
case OptionsType.HIDDEN_FIELDS: // attributes
return <EyeOff width={18} height={18} className="text-white" />; [OptionsType.ATTRIBUTES]: User,
case OptionsType.META:
switch (label) { // hidden fields
case "device": [OptionsType.HIDDEN_FIELDS]: EyeOff,
return <SmartphoneIcon width={18} height={18} className="text-white" />;
case "os": // meta
return <AirplayIcon width={18} height={18} className="text-white" />; device: SmartphoneIcon,
case "browser": os: AirplayIcon,
return <GlobeIcon width={18} height={18} className="text-white" />; browser: GlobeIcon,
case "source": source: GlobeIcon,
return <GlobeIcon width={18} height={18} className="text-white" />; action: MousePointerClickIcon,
case "action":
return <MousePointerClickIcon width={18} height={18} className="text-white" />; // others
} Language: LanguagesIcon,
case OptionsType.OTHERS:
switch (label) { // tags
case "Language": [OptionsType.TAGS]: HashIcon,
return <LanguagesIcon width={18} height={18} className="text-white" />; };
}
case OptionsType.TAGS: const getIcon = (type: string) => {
return <HashIcon width={18} height={18} className="text-white" />; 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} value={inputValue}
onValueChange={setInputValue} onValueChange={setInputValue}
placeholder={t("common.search") + "..."} 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> <div>
+97
View File
@@ -0,0 +1,97 @@
// This cache handler follows the @fortedigital/nextjs-cache-handler example
// Read more at: https://github.com/fortedigital/nextjs-cache-handler
// @neshca/cache-handler dependencies
const { CacheHandler } = require("@neshca/cache-handler");
const createLruHandler = require("@neshca/cache-handler/local-lru").default;
// Next/Redis dependencies
const { createClient } = require("redis");
const { PHASE_PRODUCTION_BUILD } = require("next/constants");
// @fortedigital/nextjs-cache-handler dependencies
const createRedisHandler = require("@fortedigital/nextjs-cache-handler/redis-strings").default;
const { Next15CacheHandler } = require("@fortedigital/nextjs-cache-handler/next-15-cache-handler");
// Usual onCreation from @neshca/cache-handler
CacheHandler.onCreation(() => {
// Important - It's recommended to use global scope to ensure only one Redis connection is made
// This ensures only one instance get created
if (global.cacheHandlerConfig) {
return global.cacheHandlerConfig;
}
// Important - It's recommended to use global scope to ensure only one Redis connection is made
// This ensures new instances are not created in a race condition
if (global.cacheHandlerConfigPromise) {
return global.cacheHandlerConfigPromise;
}
// If REDIS_URL is not set, we will use LRU cache only
if (!process.env.REDIS_URL) {
const lruCache = createLruHandler();
return { handlers: [lruCache] };
}
// Main promise initializing the handler
global.cacheHandlerConfigPromise = (async () => {
/** @type {import("redis").RedisClientType | null} */
let redisClient = null;
// eslint-disable-next-line turbo/no-undeclared-env-vars -- Next.js will inject this variable
if (PHASE_PRODUCTION_BUILD !== process.env.NEXT_PHASE) {
const settings = {
url: process.env.REDIS_URL, // Make sure you configure this variable
pingInterval: 10000,
};
try {
redisClient = createClient(settings);
redisClient.on("error", (e) => {
console.error("Redis error", e);
global.cacheHandlerConfig = null;
global.cacheHandlerConfigPromise = null;
});
} catch (error) {
console.error("Failed to create Redis client:", error);
}
}
if (redisClient) {
try {
console.info("Connecting Redis client...");
await redisClient.connect();
console.info("Redis client connected.");
} catch (error) {
console.error("Failed to connect Redis client:", error);
await redisClient
.disconnect()
.catch(() => console.error("Failed to quit the Redis client after failing to connect."));
}
}
const lruCache = createLruHandler();
if (!redisClient?.isReady) {
console.error("Failed to initialize caching layer.");
global.cacheHandlerConfigPromise = null;
global.cacheHandlerConfig = { handlers: [lruCache] };
return global.cacheHandlerConfig;
}
const redisCacheHandler = createRedisHandler({
client: redisClient,
keyPrefix: "nextjs:",
});
global.cacheHandlerConfigPromise = null;
global.cacheHandlerConfig = {
handlers: [redisCacheHandler],
};
return global.cacheHandlerConfig;
})();
return global.cacheHandlerConfigPromise;
});
module.exports = new Next15CacheHandler();
-79
View File
@@ -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", () => ({ vi.mock("@formbricks/logger", () => ({
logger: { logger: {
error: vi.fn(), error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
fatal: vi.fn(),
}, },
})); }));
+4 -2
View File
@@ -10,6 +10,7 @@ import { Button } from "@/modules/ui/components/button";
import { getTranslate } from "@/tolgee/server"; import { getTranslate } from "@/tolgee/server";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import Link from "next/link"; import Link from "next/link";
import { after } from "next/server";
import { logger } from "@formbricks/logger"; import { logger } from "@formbricks/logger";
import { ContentLayout } from "./components/content-layout"; import { ContentLayout } from "./components/content-layout";
@@ -117,8 +118,9 @@ export const InvitePage = async (props: InvitePageProps) => {
}); });
}; };
// Execute the server action immediately after(async () => {
await createMembershipAction(); await createMembershipAction();
});
return ( return (
<ContentLayout <ContentLayout
+2 -2
View File
@@ -16,7 +16,7 @@ import { Testimonial } from "@/modules/auth/components/testimonial";
import { import {
getIsMultiOrgEnabled, getIsMultiOrgEnabled,
getIsSamlSsoEnabled, getIsSamlSsoEnabled,
getisSsoEnabled, getIsSsoEnabled,
} from "@/modules/ee/license-check/lib/utils"; } from "@/modules/ee/license-check/lib/utils";
import { Metadata } from "next"; import { Metadata } from "next";
import { LoginForm } from "./components/login-form"; import { LoginForm } from "./components/login-form";
@@ -29,7 +29,7 @@ export const metadata: Metadata = {
export const LoginPage = async () => { export const LoginPage = async () => {
const [isMultiOrgEnabled, isSsoEnabled, isSamlSsoEnabled] = await Promise.all([ const [isMultiOrgEnabled, isSsoEnabled, isSamlSsoEnabled] = await Promise.all([
getIsMultiOrgEnabled(), getIsMultiOrgEnabled(),
getisSsoEnabled(), getIsSsoEnabled(),
getIsSamlSsoEnabled(), getIsSamlSsoEnabled(),
]); ]);
+10 -5
View File
@@ -4,7 +4,7 @@ import { getIsValidInviteToken } from "@/modules/auth/signup/lib/invite";
import { import {
getIsMultiOrgEnabled, getIsMultiOrgEnabled,
getIsSamlSsoEnabled, getIsSamlSsoEnabled,
getisSsoEnabled, getIsSsoEnabled,
} from "@/modules/ee/license-check/lib/utils"; } from "@/modules/ee/license-check/lib/utils";
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react"; 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", () => ({ vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getIsMultiOrgEnabled: vi.fn(), getIsMultiOrgEnabled: vi.fn(),
getIsSsoEnabled: vi.fn(),
getIsSamlSsoEnabled: 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", () => ({ vi.mock("@/modules/auth/signup/lib/invite", () => ({
@@ -89,7 +95,6 @@ vi.mock("@/lib/constants", () => ({
AZURE_OAUTH_ENABLED: true, AZURE_OAUTH_ENABLED: true,
OIDC_OAUTH_ENABLED: true, OIDC_OAUTH_ENABLED: true,
DEFAULT_ORGANIZATION_ID: "test-default-organization-id", DEFAULT_ORGANIZATION_ID: "test-default-organization-id",
DEFAULT_ORGANIZATION_ROLE: "test-default-organization-role",
IS_TURNSTILE_CONFIGURED: true, IS_TURNSTILE_CONFIGURED: true,
SAML_TENANT: "test-saml-tenant", SAML_TENANT: "test-saml-tenant",
SAML_PRODUCT: "test-saml-product", SAML_PRODUCT: "test-saml-product",
@@ -114,7 +119,7 @@ describe("SignupPage", () => {
test("renders the signup page with all components when signup is enabled", async () => { test("renders the signup page with all components when signup is enabled", async () => {
// Mock the license check functions to return true // Mock the license check functions to return true
vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(true); vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(true);
vi.mocked(getisSsoEnabled).mockResolvedValue(true); vi.mocked(getIsSsoEnabled).mockResolvedValue(true);
vi.mocked(getIsSamlSsoEnabled).mockResolvedValue(true); vi.mocked(getIsSamlSsoEnabled).mockResolvedValue(true);
vi.mocked(findMatchingLocale).mockResolvedValue("en-US"); vi.mocked(findMatchingLocale).mockResolvedValue("en-US");
vi.mocked(verifyInviteToken).mockReturnValue({ vi.mocked(verifyInviteToken).mockReturnValue({
@@ -173,7 +178,7 @@ describe("SignupPage", () => {
test("renders the page with email from search params", async () => { test("renders the page with email from search params", async () => {
// Mock the license check functions to return true // Mock the license check functions to return true
vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(true); vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(true);
vi.mocked(getisSsoEnabled).mockResolvedValue(true); vi.mocked(getIsSsoEnabled).mockResolvedValue(true);
vi.mocked(getIsSamlSsoEnabled).mockResolvedValue(true); vi.mocked(getIsSamlSsoEnabled).mockResolvedValue(true);
vi.mocked(findMatchingLocale).mockResolvedValue("en-US"); vi.mocked(findMatchingLocale).mockResolvedValue("en-US");
vi.mocked(verifyInviteToken).mockReturnValue({ vi.mocked(verifyInviteToken).mockReturnValue({
+2 -2
View File
@@ -24,7 +24,7 @@ import { getIsValidInviteToken } from "@/modules/auth/signup/lib/invite";
import { import {
getIsMultiOrgEnabled, getIsMultiOrgEnabled,
getIsSamlSsoEnabled, getIsSamlSsoEnabled,
getisSsoEnabled, getIsSsoEnabled,
} from "@/modules/ee/license-check/lib/utils"; } from "@/modules/ee/license-check/lib/utils";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { SignupForm } from "./components/signup-form"; import { SignupForm } from "./components/signup-form";
@@ -34,7 +34,7 @@ export const SignupPage = async ({ searchParams: searchParamsProps }) => {
const inviteToken = searchParams["inviteToken"] ?? null; const inviteToken = searchParams["inviteToken"] ?? null;
const [isMultOrgEnabled, isSsoEnabled, isSamlSsoEnabled] = await Promise.all([ const [isMultOrgEnabled, isSsoEnabled, isSamlSsoEnabled] = await Promise.all([
getIsMultiOrgEnabled(), getIsMultiOrgEnabled(),
getisSsoEnabled(), getIsSsoEnabled(),
getIsSamlSsoEnabled(), getIsSamlSsoEnabled(),
]); ]);
+117
View File
@@ -0,0 +1,117 @@
import KeyvRedis from "@keyv/redis";
import { createCache } from "cache-manager";
import { Keyv } from "keyv";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
// Mock dependencies
vi.mock("keyv");
vi.mock("@keyv/redis");
vi.mock("cache-manager");
vi.mock("@formbricks/logger");
const mockCacheInstance = {
set: vi.fn(),
get: vi.fn(),
del: vi.fn(),
};
const CACHE_TTL_SECONDS = 60 * 60 * 24; // 24 hours
const CACHE_TTL_MS = CACHE_TTL_SECONDS * 1000;
describe("Cache Service", () => {
let originalRedisUrl: string | undefined;
beforeEach(() => {
originalRedisUrl = process.env.REDIS_URL;
vi.resetAllMocks();
vi.resetModules(); // Crucial for re-running module initialization logic
// Setup default mock implementations
vi.mocked(createCache).mockReturnValue(mockCacheInstance as any);
vi.mocked(Keyv).mockClear(); // Clear any previous calls
vi.mocked(KeyvRedis).mockClear(); // Clear any previous calls
vi.mocked(logger.warn).mockClear(); // Clear logger warnings
});
afterEach(() => {
process.env.REDIS_URL = originalRedisUrl;
});
describe("Initialization and getCache", () => {
test("should use Redis store and return it via getCache if REDIS_URL is set", async () => {
process.env.REDIS_URL = "redis://localhost:6379";
const { getCache } = await import("./service"); // Dynamically import
expect(KeyvRedis).toHaveBeenCalledWith("redis://localhost:6379");
expect(Keyv).toHaveBeenCalledWith({
store: expect.any(KeyvRedis),
ttl: CACHE_TTL_MS,
});
expect(createCache).toHaveBeenCalledWith({
stores: [expect.any(Keyv)],
ttl: CACHE_TTL_MS,
});
expect(logger.warn).not.toHaveBeenCalled();
expect(logger.info).toHaveBeenCalledWith("Successfully connected to Redis cache");
expect(getCache()).toBe(mockCacheInstance);
});
test("should fall back to memory store if Redis connection fails", async () => {
process.env.REDIS_URL = "redis://localhost:6379";
const mockError = new Error("Connection refused");
vi.mocked(KeyvRedis).mockImplementation(() => {
throw mockError;
});
const { getCache } = await import("./service"); // Dynamically import
expect(KeyvRedis).toHaveBeenCalledWith("redis://localhost:6379");
expect(logger.error).toHaveBeenCalledWith("Failed to connect to Redis cache:", mockError);
expect(logger.warn).toHaveBeenCalledWith(
"Falling back to in-memory cache due to Redis connection failure"
);
expect(Keyv).toHaveBeenCalledWith({
ttl: CACHE_TTL_MS,
});
expect(createCache).toHaveBeenCalledWith({
stores: [expect.any(Keyv)],
ttl: CACHE_TTL_MS,
});
expect(getCache()).toBe(mockCacheInstance);
});
test("should use memory store, log warning, and return it via getCache if REDIS_URL is not set", async () => {
delete process.env.REDIS_URL;
const { getCache } = await import("./service"); // Dynamically import
expect(KeyvRedis).not.toHaveBeenCalled();
expect(Keyv).toHaveBeenCalledWith({
ttl: CACHE_TTL_MS,
});
expect(createCache).toHaveBeenCalledWith({
stores: [expect.any(Keyv)],
ttl: CACHE_TTL_MS,
});
expect(logger.warn).toHaveBeenCalledWith("REDIS_URL not found, falling back to in-memory cache.");
expect(getCache()).toBe(mockCacheInstance);
});
test("should use memory store, log warning, and return it via getCache if REDIS_URL is an empty string", async () => {
process.env.REDIS_URL = ""; // Test with empty string
const { getCache } = await import("./service"); // Dynamically import
// If REDIS_URL is "", it's falsy, so it should fall back to memory store
expect(KeyvRedis).not.toHaveBeenCalled();
expect(Keyv).toHaveBeenCalledWith({
ttl: CACHE_TTL_MS, // Expect memory store configuration
});
expect(createCache).toHaveBeenCalledWith({
stores: [expect.any(Keyv)],
ttl: CACHE_TTL_MS,
});
expect(logger.warn).toHaveBeenCalledWith("REDIS_URL not found, falling back to in-memory cache.");
expect(getCache()).toBe(mockCacheInstance);
});
});
});
+55
View File
@@ -0,0 +1,55 @@
import "server-only";
import KeyvRedis from "@keyv/redis";
import { type Cache, createCache } from "cache-manager";
import { Keyv } from "keyv";
import { logger } from "@formbricks/logger";
const CACHE_TTL_SECONDS = 60 * 60 * 24; // 24 hours
const CACHE_TTL_MS = CACHE_TTL_SECONDS * 1000;
let cache: Cache;
const initializeMemoryCache = (): void => {
const memoryKeyvStore = new Keyv({
ttl: CACHE_TTL_MS,
});
cache = createCache({
stores: [memoryKeyvStore],
ttl: CACHE_TTL_MS,
});
logger.info("Using in-memory cache");
};
if (process.env.REDIS_URL) {
try {
const redisStore = new KeyvRedis(process.env.REDIS_URL);
// Gracefully fall back if Redis dies later on
redisStore.on("error", (err) => {
logger.error("Redis connection lost switching to in-memory cache", { error: err });
initializeMemoryCache();
});
const redisKeyvStore = new Keyv({
store: redisStore,
ttl: CACHE_TTL_MS,
});
cache = createCache({
stores: [redisKeyvStore],
ttl: CACHE_TTL_MS,
});
logger.info("Successfully connected to Redis cache");
} catch (error) {
logger.error("Failed to connect to Redis cache:", error);
logger.warn("Falling back to in-memory cache due to Redis connection failure");
initializeMemoryCache();
}
} else {
logger.warn("REDIS_URL not found, falling back to in-memory cache.");
initializeMemoryCache();
}
export const getCache = (): Cache => {
return cache;
};
@@ -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);
@@ -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 { Organization } from "@prisma/client";
import fetch from "node-fetch";
import { beforeEach, describe, expect, test, vi } from "vitest"; import { beforeEach, describe, expect, test, vi } from "vitest";
import * as licenseModule from "./license";
import { import {
getBiggerUploadFileSizePermission, getBiggerUploadFileSizePermission,
getIsContactsEnabled, getIsContactsEnabled,
getIsMultiOrgEnabled, getIsMultiOrgEnabled,
getIsSamlSsoEnabled, getIsSamlSsoEnabled,
getIsSpamProtectionEnabled, getIsSpamProtectionEnabled,
getIsSsoEnabled,
getIsTwoFactorAuthEnabled, getIsTwoFactorAuthEnabled,
getLicenseFeatures,
getMultiLanguagePermission, getMultiLanguagePermission,
getOrganizationProjectsLimit, getOrganizationProjectsLimit,
getRemoveBrandingPermission, getRemoveBrandingPermission,
getRoleManagementPermission, getRoleManagementPermission,
getWhiteLabelPermission, getWhiteLabelPermission,
getisSsoEnabled,
} from "./utils"; } from "./utils";
// Mock declarations must be at the top level vi.mock("@/lib/constants");
vi.mock("@/lib/env", () => ({ vi.mock("./license");
env: {
ENTERPRISE_LICENSE_KEY: "test-license-key",
},
}));
vi.mock("@/lib/constants", () => ({ const mockOrganization = {
E2E_TESTING: false, billing: {
ENTERPRISE_LICENSE_KEY: "test-license-key", plan: constants.PROJECT_FEATURE_KEYS.FREE,
IS_FORMBRICKS_CLOUD: false, limits: {
IS_RECAPTCHA_CONFIGURED: true, projects: 3,
PROJECT_FEATURE_KEYS: { monthly: {
removeBranding: "remove-branding", responses: null,
whiteLabel: "white-label", miu: null,
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,
},
}, },
} 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); 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 () => { test("should return false if license active but feature disabled (self-hosted)", async () => {
const result = await getWhiteLabelPermission(mockOrganization.billing.plan); vi.mocked(constants).IS_FORMBRICKS_CLOUD = false;
expect(result).toBe(false); // Default value when no license is active vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
...defaultLicense,
features: { ...defaultFeatures, removeBranding: false },
});
const result = await getRemoveBrandingPermission(mockOrganization.billing.plan);
expect(result).toBe(false);
}); });
test("getRoleManagementPermission", async () => { test("should return true if license active and plan is not FREE (cloud)", async () => {
const result = await getRoleManagementPermission(mockOrganization.billing.plan); vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
expect(result).toBe(false); // Default value when no license is active vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense);
const result = await getRemoveBrandingPermission(constants.PROJECT_FEATURE_KEYS.SCALE);
expect(result).toBe(true);
}); });
test("getBiggerUploadFileSizePermission", async () => { test("should return false if license active and plan is FREE (cloud)", async () => {
const result = await getBiggerUploadFileSizePermission(mockOrganization.billing.plan); vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
expect(result).toBe(false); // Default value when no license is active vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense);
const result = await getRemoveBrandingPermission(constants.PROJECT_FEATURE_KEYS.FREE);
expect(result).toBe(false);
}); });
test("getMultiLanguagePermission", async () => { test("should return false if license is inactive", async () => {
const result = await getMultiLanguagePermission(mockOrganization.billing.plan); vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
expect(result).toBe(false); // Default value when no license is active ...defaultLicense,
}); active: false,
});
test("getIsMultiOrgEnabled", async () => { const result = await getRemoveBrandingPermission(mockOrganization.billing.plan);
const result = await getIsMultiOrgEnabled(); expect(result).toBe(false);
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
}); });
}); });
describe("License Features", () => { describe("getWhiteLabelPermission", () => {
test("getLicenseFeatures returns null when no license is active", async () => { test("should return true if license active and feature enabled (self-hosted)", async () => {
vi.mocked(fetch).mockResolvedValueOnce({ vi.mocked(constants).IS_FORMBRICKS_CLOUD = false;
ok: false, vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
} as any); ...defaultLicense,
features: { ...defaultFeatures, whitelabel: true },
});
const result = await getWhiteLabelPermission(mockOrganization.billing.plan);
expect(result).toBe(true);
});
const result = await getLicenseFeatures(); test("should return true if license active and plan is not FREE (cloud)", async () => {
expect(result).toBeNull(); 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);
}); });
}); });
}); });
+62 -354
View File
@@ -1,386 +1,102 @@
import "server-only"; import "server-only";
import { cache, revalidateTag } from "@/lib/cache"; import { IS_FORMBRICKS_CLOUD, IS_RECAPTCHA_CONFIGURED, PROJECT_FEATURE_KEYS } from "@/lib/constants";
import { import { TEnterpriseLicenseFeatures } from "@/modules/ee/license-check/types/enterprise-license";
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 { Organization } from "@prisma/client"; import { Organization } from "@prisma/client";
import { HttpsProxyAgent } from "https-proxy-agent"; import { getEnterpriseLicense, getLicenseFeatures } from "./license";
import fetch from "node-fetch";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
const hashedKey = ENTERPRISE_LICENSE_KEY ? hashString(ENTERPRISE_LICENSE_KEY) : undefined; // Helper function for feature permissions (e.g., removeBranding, whitelabel)
const PREVIOUS_RESULTS_CACHE_TAG_KEY = `getPreviousResult-${hashedKey}` as const; 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 if (IS_FORMBRICKS_CLOUD) {
// This might seem confusing at first since we only return the default value from this function, return license.active && billingPlan !== PROJECT_FEATURE_KEYS.FREE;
// 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;
} else { } else {
// if result is undefined -> error return license.active && !!license.features?.[featureKey];
// 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,
};
} }
}; };
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 ( export const getRemoveBrandingPermission = async (
billingPlan: Organization["billing"]["plan"] billingPlan: Organization["billing"]["plan"]
): Promise<boolean> => { ): Promise<boolean> => {
if (E2E_TESTING) { return getFeaturePermission(billingPlan, "removeBranding");
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;
}
}; };
export const getWhiteLabelPermission = async ( export const getWhiteLabelPermission = async (
billingPlan: Organization["billing"]["plan"] billingPlan: Organization["billing"]["plan"]
): Promise<boolean> => { ): Promise<boolean> => {
if (E2E_TESTING) { return getFeaturePermission(billingPlan, "whitelabel");
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;
}
}; };
export const getRoleManagementPermission = async ( export const getRoleManagementPermission = async (
billingPlan: Organization["billing"]["plan"] billingPlan: Organization["billing"]["plan"]
): Promise<boolean> => { ): Promise<boolean> => {
if (E2E_TESTING) { const license = await getEnterpriseLicense();
const previousResult = await fetchLicenseForE2ETesting();
return previousResult && previousResult.active !== null ? previousResult.active : false;
}
if (IS_FORMBRICKS_CLOUD) if (IS_FORMBRICKS_CLOUD)
return billingPlan === PROJECT_FEATURE_KEYS.SCALE || billingPlan === PROJECT_FEATURE_KEYS.ENTERPRISE; return (
else if (!IS_FORMBRICKS_CLOUD) return (await getEnterpriseLicense()).active; license.active &&
(billingPlan === PROJECT_FEATURE_KEYS.SCALE || billingPlan === PROJECT_FEATURE_KEYS.ENTERPRISE)
);
else if (!IS_FORMBRICKS_CLOUD) return license.active;
return false; return false;
}; };
export const getBiggerUploadFileSizePermission = async ( export const getBiggerUploadFileSizePermission = async (
billingPlan: Organization["billing"]["plan"] billingPlan: Organization["billing"]["plan"]
): Promise<boolean> => { ): Promise<boolean> => {
if (IS_FORMBRICKS_CLOUD) return billingPlan !== PROJECT_FEATURE_KEYS.FREE; const license = await getEnterpriseLicense();
else if (!IS_FORMBRICKS_CLOUD) return (await getEnterpriseLicense()).active;
if (IS_FORMBRICKS_CLOUD) return billingPlan !== PROJECT_FEATURE_KEYS.FREE && license.active;
else if (!IS_FORMBRICKS_CLOUD) return license.active;
return false; return false;
}; };
export const getMultiLanguagePermission = async ( export const getMultiLanguagePermission = async (
billingPlan: Organization["billing"]["plan"] billingPlan: Organization["billing"]["plan"]
): Promise<boolean> => { ): Promise<boolean> => {
if (E2E_TESTING) { const license = await getEnterpriseLicense();
const previousResult = await fetchLicenseForE2ETesting();
return previousResult && previousResult.active !== null ? previousResult.active : false;
}
if (IS_FORMBRICKS_CLOUD) if (IS_FORMBRICKS_CLOUD)
return billingPlan === PROJECT_FEATURE_KEYS.SCALE || billingPlan === PROJECT_FEATURE_KEYS.ENTERPRISE; return (
else if (!IS_FORMBRICKS_CLOUD) return (await getEnterpriseLicense()).active; license.active &&
(billingPlan === PROJECT_FEATURE_KEYS.SCALE || billingPlan === PROJECT_FEATURE_KEYS.ENTERPRISE)
);
else if (!IS_FORMBRICKS_CLOUD) return license.active;
return false; return false;
}; };
export const getIsMultiOrgEnabled = async (): Promise<boolean> => { // Helper function for simple boolean feature flags
if (E2E_TESTING) { const getSpecificFeatureFlag = async (
const previousResult = await fetchLicenseForE2ETesting(); featureKey: keyof Pick<
return previousResult && previousResult.features ? previousResult.features.isMultiOrgEnabled : false; TEnterpriseLicenseFeatures,
} "isMultiOrgEnabled" | "contacts" | "twoFactorAuth" | "sso"
>
): Promise<boolean> => {
const licenseFeatures = await getLicenseFeatures(); const licenseFeatures = await getLicenseFeatures();
if (!licenseFeatures) return false; 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> => { export const getIsContactsEnabled = async (): Promise<boolean> => {
if (E2E_TESTING) { return getSpecificFeatureFlag("contacts");
const previousResult = await fetchLicenseForE2ETesting();
return previousResult && previousResult.features ? previousResult.features.contacts : false;
}
const licenseFeatures = await getLicenseFeatures();
if (!licenseFeatures) return false;
return licenseFeatures.contacts;
}; };
export const getIsTwoFactorAuthEnabled = async (): Promise<boolean> => { export const getIsTwoFactorAuthEnabled = async (): Promise<boolean> => {
if (E2E_TESTING) { return getSpecificFeatureFlag("twoFactorAuth");
const previousResult = await fetchLicenseForE2ETesting();
return previousResult && previousResult.features ? previousResult.features.twoFactorAuth : false;
}
const licenseFeatures = await getLicenseFeatures();
if (!licenseFeatures) return false;
return licenseFeatures.twoFactorAuth;
}; };
export const getisSsoEnabled = async (): Promise<boolean> => { export const getIsSsoEnabled = async (): Promise<boolean> => {
if (E2E_TESTING) { return getSpecificFeatureFlag("sso");
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 getIsSamlSsoEnabled = async (): Promise<boolean> => { 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) { if (IS_FORMBRICKS_CLOUD) {
return false; return false;
} }
@@ -394,38 +110,30 @@ export const getIsSpamProtectionEnabled = async (
): Promise<boolean> => { ): Promise<boolean> => {
if (!IS_RECAPTCHA_CONFIGURED) return false; if (!IS_RECAPTCHA_CONFIGURED) return false;
if (E2E_TESTING) { const license = await getEnterpriseLicense();
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 licenseFeatures = await getLicenseFeatures(); if (IS_FORMBRICKS_CLOUD) {
if (!licenseFeatures) return false; return (
return licenseFeatures.spamProtection; 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 ( export const getOrganizationProjectsLimit = async (
limits: Organization["billing"]["limits"] limits: Organization["billing"]["limits"]
): Promise<number> => { ): Promise<number> => {
if (E2E_TESTING) { const license = await getEnterpriseLicense();
const previousResult = await fetchLicenseForE2ETesting();
return previousResult && previousResult.features ? (previousResult.features.projects ?? Infinity) : 3;
}
let limit: number; let limit: number;
if (IS_FORMBRICKS_CLOUD && (await getEnterpriseLicense()).active) { if (IS_FORMBRICKS_CLOUD) {
limit = limits.projects ?? Infinity; limit = license.active ? (limits.projects ?? Infinity) : 3;
} else { } else {
const licenseFeatures = await getLicenseFeatures(); limit = license.active && license.features?.projects != null ? license.features.projects : 3;
if (!licenseFeatures) {
limit = 3;
} else {
limit = licenseFeatures.projects ?? Infinity;
}
} }
return limit; return limit;
}; };
@@ -71,16 +71,18 @@ export function LocalizedEditor({
key={`${questionIdx}-${selectedLanguageCode}`} key={`${questionIdx}-${selectedLanguageCode}`}
setFirstRender={setFirstRender} setFirstRender={setFirstRender}
setText={(v: string) => { setText={(v: string) => {
const translatedHtml = { if (localSurvey.questions[questionIdx] || questionIdx === -1) {
...value, const translatedHtml = {
[selectedLanguageCode]: v, ...value,
}; [selectedLanguageCode]: v,
if (questionIdx === -1) { };
// welcome card if (questionIdx === -1) {
updateQuestion({ html: translatedHtml }); // welcome card
return; updateQuestion({ html: translatedHtml });
return;
}
updateQuestion(questionIdx, { html: translatedHtml });
} }
updateQuestion(questionIdx, { html: translatedHtml });
}} }}
/> />
{localSurvey.languages.length > 1 && ( {localSurvey.languages.length > 1 && (
+3 -4
View File
@@ -5,15 +5,14 @@ import { verifyInviteToken } from "@/lib/jwt";
import { createMembership } from "@/lib/membership/service"; import { createMembership } from "@/lib/membership/service";
import { findMatchingLocale } from "@/lib/utils/locale"; import { findMatchingLocale } from "@/lib/utils/locale";
import { createBrevoCustomer } from "@/modules/auth/lib/brevo"; import { createBrevoCustomer } from "@/modules/auth/lib/brevo";
import { getUserByEmail, updateUser } from "@/modules/auth/lib/user"; import { createUser, getUserByEmail, updateUser } from "@/modules/auth/lib/user";
import { createUser } from "@/modules/auth/lib/user";
import { getIsValidInviteToken } from "@/modules/auth/signup/lib/invite"; import { getIsValidInviteToken } from "@/modules/auth/signup/lib/invite";
import { TOidcNameFields, TSamlNameFields } from "@/modules/auth/types/auth"; import { TOidcNameFields, TSamlNameFields } from "@/modules/auth/types/auth";
import { import {
getIsMultiOrgEnabled, getIsMultiOrgEnabled,
getIsSamlSsoEnabled, getIsSamlSsoEnabled,
getIsSsoEnabled,
getRoleManagementPermission, getRoleManagementPermission,
getisSsoEnabled,
} from "@/modules/ee/license-check/lib/utils"; } from "@/modules/ee/license-check/lib/utils";
import { getFirstOrganization } from "@/modules/ee/sso/lib/organization"; import { getFirstOrganization } from "@/modules/ee/sso/lib/organization";
import { createDefaultTeamMembership, getOrganizationByTeamId } from "@/modules/ee/sso/lib/team"; import { createDefaultTeamMembership, getOrganizationByTeamId } from "@/modules/ee/sso/lib/team";
@@ -32,7 +31,7 @@ export const handleSsoCallback = async ({
account: Account; account: Account;
callbackUrl: string; callbackUrl: string;
}) => { }) => {
const isSsoEnabled = await getisSsoEnabled(); const isSsoEnabled = await getIsSsoEnabled();
if (!isSsoEnabled) { if (!isSsoEnabled) {
return false; return false;
} }
@@ -7,8 +7,8 @@ import type { TSamlNameFields } from "@/modules/auth/types/auth";
import { import {
getIsMultiOrgEnabled, getIsMultiOrgEnabled,
getIsSamlSsoEnabled, getIsSamlSsoEnabled,
getIsSsoEnabled,
getRoleManagementPermission, getRoleManagementPermission,
getisSsoEnabled,
} from "@/modules/ee/license-check/lib/utils"; } from "@/modules/ee/license-check/lib/utils";
import { createDefaultTeamMembership, getOrganizationByTeamId } from "@/modules/ee/sso/lib/team"; import { createDefaultTeamMembership, getOrganizationByTeamId } from "@/modules/ee/sso/lib/team";
import { beforeEach, describe, expect, test, vi } from "vitest"; 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", () => ({ vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getIsSamlSsoEnabled: vi.fn(), getIsSamlSsoEnabled: vi.fn(),
getisSsoEnabled: vi.fn(), getIsSsoEnabled: vi.fn(),
getRoleManagementPermission: vi.fn(), getRoleManagementPermission: vi.fn(),
getIsMultiOrgEnabled: vi.fn(), getIsMultiOrgEnabled: vi.fn(),
})); }));
@@ -93,7 +93,6 @@ vi.mock("@/lib/constants", () => ({
SKIP_INVITE_FOR_SSO: 0, SKIP_INVITE_FOR_SSO: 0,
DEFAULT_TEAM_ID: "team-123", DEFAULT_TEAM_ID: "team-123",
DEFAULT_ORGANIZATION_ID: "org-123", DEFAULT_ORGANIZATION_ID: "org-123",
DEFAULT_ORGANIZATION_ROLE: "member",
ENCRYPTION_KEY: "test-encryption-key-32-chars-long", ENCRYPTION_KEY: "test-encryption-key-32-chars-long",
})); }));
@@ -103,7 +102,7 @@ describe("handleSsoCallback", () => {
vi.resetModules(); vi.resetModules();
// Default mock implementations // Default mock implementations
vi.mocked(getisSsoEnabled).mockResolvedValue(true); vi.mocked(getIsSsoEnabled).mockResolvedValue(true);
vi.mocked(getIsSamlSsoEnabled).mockResolvedValue(true); vi.mocked(getIsSamlSsoEnabled).mockResolvedValue(true);
vi.mocked(findMatchingLocale).mockResolvedValue("en-US"); vi.mocked(findMatchingLocale).mockResolvedValue("en-US");
vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(true); vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(true);
@@ -123,7 +122,7 @@ describe("handleSsoCallback", () => {
describe("Early return conditions", () => { describe("Early return conditions", () => {
test("should return false if SSO is not enabled", async () => { test("should return false if SSO is not enabled", async () => {
vi.mocked(getisSsoEnabled).mockResolvedValue(false); vi.mocked(getIsSsoEnabled).mockResolvedValue(false);
const result = await handleSsoCallback({ const result = await handleSsoCallback({
user: mockUser, user: mockUser,
@@ -1,5 +1,5 @@
import { findMatchingLocale } from "@/lib/utils/locale"; 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 { getTranslate } from "@/tolgee/server";
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
@@ -47,7 +47,6 @@ vi.mock("@/lib/constants", () => ({
AZURE_OAUTH_ENABLED: true, AZURE_OAUTH_ENABLED: true,
OIDC_OAUTH_ENABLED: true, OIDC_OAUTH_ENABLED: true,
DEFAULT_ORGANIZATION_ID: "test-default-organization-id", DEFAULT_ORGANIZATION_ID: "test-default-organization-id",
DEFAULT_ORGANIZATION_ROLE: "test-default-organization-role",
IS_TURNSTILE_CONFIGURED: true, IS_TURNSTILE_CONFIGURED: true,
SAML_TENANT: "test-saml-tenant", SAML_TENANT: "test-saml-tenant",
SAML_PRODUCT: "test-saml-product", SAML_PRODUCT: "test-saml-product",
@@ -55,7 +54,7 @@ vi.mock("@/lib/constants", () => ({
})); }));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({ vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getisSsoEnabled: vi.fn(), getIsSsoEnabled: vi.fn(),
getIsSamlSsoEnabled: vi.fn(), getIsSamlSsoEnabled: vi.fn(),
})); }));
@@ -78,7 +77,7 @@ vi.mock("@/modules/auth/signup/components/signup-form", () => ({
describe("SignupPage", () => { describe("SignupPage", () => {
beforeEach(() => { beforeEach(() => {
vi.mocked(getisSsoEnabled).mockResolvedValue(true); vi.mocked(getIsSsoEnabled).mockResolvedValue(true);
vi.mocked(getIsSamlSsoEnabled).mockResolvedValue(false); vi.mocked(getIsSamlSsoEnabled).mockResolvedValue(false);
vi.mocked(findMatchingLocale).mockResolvedValue("en-US"); vi.mocked(findMatchingLocale).mockResolvedValue("en-US");
vi.mocked(getTranslate).mockResolvedValue((key) => key); vi.mocked(getTranslate).mockResolvedValue((key) => key);
@@ -17,7 +17,7 @@ import {
} from "@/lib/constants"; } from "@/lib/constants";
import { findMatchingLocale } from "@/lib/utils/locale"; import { findMatchingLocale } from "@/lib/utils/locale";
import { SignupForm } from "@/modules/auth/signup/components/signup-form"; 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 { getTranslate } from "@/tolgee/server";
import { Metadata } from "next"; import { Metadata } from "next";
@@ -29,7 +29,7 @@ export const metadata: Metadata = {
export const SignupPage = async () => { export const SignupPage = async () => {
const locale = await findMatchingLocale(); 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; const samlSsoEnabled = isSamlSsoEnabled && SAML_OAUTH_ENABLED;
@@ -48,7 +48,6 @@ vi.mock("@/lib/constants", () => ({
AZURE_OAUTH_ENABLED: true, AZURE_OAUTH_ENABLED: true,
OIDC_OAUTH_ENABLED: true, OIDC_OAUTH_ENABLED: true,
DEFAULT_ORGANIZATION_ID: "test-default-organization-id", DEFAULT_ORGANIZATION_ID: "test-default-organization-id",
DEFAULT_ORGANIZATION_ROLE: "test-default-organization-role",
IS_TURNSTILE_CONFIGURED: true, IS_TURNSTILE_CONFIGURED: true,
SAML_TENANT: "test-saml-tenant", SAML_TENANT: "test-saml-tenant",
SAML_PRODUCT: "test-saml-product", SAML_PRODUCT: "test-saml-product",
@@ -50,7 +50,6 @@ vi.mock("@/lib/constants", () => ({
AZURE_OAUTH_ENABLED: true, AZURE_OAUTH_ENABLED: true,
OIDC_OAUTH_ENABLED: true, OIDC_OAUTH_ENABLED: true,
DEFAULT_ORGANIZATION_ID: "test-default-organization-id", DEFAULT_ORGANIZATION_ID: "test-default-organization-id",
DEFAULT_ORGANIZATION_ROLE: "test-default-organization-role",
IS_TURNSTILE_CONFIGURED: true, IS_TURNSTILE_CONFIGURED: true,
SAML_TENANT: "test-saml-tenant", SAML_TENANT: "test-saml-tenant",
SAML_PRODUCT: "test-saml-product", SAML_PRODUCT: "test-saml-product",
+1 -1
View File
@@ -2,4 +2,4 @@
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
// NOTE: This file should not be edited // 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.
+7 -8
View File
@@ -16,23 +16,22 @@ const getHostname = (url) => {
const nextConfig = { const nextConfig = {
assetPrefix: process.env.ASSET_PREFIX_URL || undefined, assetPrefix: process.env.ASSET_PREFIX_URL || undefined,
cacheHandler: require.resolve("./cache-handler.mjs"), cacheHandler: require.resolve("./cache-handler.js"),
//cacheMaxMemorySize: 0, // disable default in-memory caching cacheMaxMemorySize: 0, // disable default in-memory caching
output: "standalone", output: "standalone",
poweredByHeader: false, poweredByHeader: false,
productionBrowserSourceMaps: false, productionBrowserSourceMaps: false,
experimental: { serverExternalPackages: ["@aws-sdk", "@opentelemetry/instrumentation", "pino", "pino-pretty"],
serverComponentsExternalPackages: ["@aws-sdk", "@opentelemetry/instrumentation", "pino", "pino-pretty"], outputFileTracingIncludes: {
outputFileTracingIncludes: { "app/api/packages": ["../../packages/js-core/dist/*", "../../packages/surveys/dist/*"],
"app/api/packages": ["../../packages/js-core/dist/*", "../../packages/surveys/dist/*"], "/api/auth/**/*": ["../../node_modules/jose/**/*"],
"/api/auth/**/*": ["../../node_modules/jose/**/*"],
},
}, },
i18n: { i18n: {
locales: ["en-US", "de-DE", "fr-FR", "pt-BR", "zh-Hant-TW", "pt-PT"], locales: ["en-US", "de-DE", "fr-FR", "pt-BR", "zh-Hant-TW", "pt-PT"],
localeDetection: false, localeDetection: false,
defaultLocale: "en-US", defaultLocale: "en-US",
}, },
experimental: {},
transpilePackages: ["@formbricks/database"], transpilePackages: ["@formbricks/database"],
images: { images: {
remotePatterns: [ remotePatterns: [
+6 -3
View File
@@ -5,8 +5,8 @@
"private": true, "private": true,
"scripts": { "scripts": {
"clean": "rimraf .turbo node_modules .next coverage", "clean": "rimraf .turbo node_modules .next coverage",
"dev": "next dev -p 3000", "dev": "next dev -p 3000 --turbopack",
"go": "next dev -p 3000", "go": "next dev -p 3000 --turbopack",
"build": "next build", "build": "next build",
"build:dev": "next build", "build:dev": "next build",
"start": "next start", "start": "next start",
@@ -36,6 +36,7 @@
"@hookform/resolvers": "5.0.1", "@hookform/resolvers": "5.0.1",
"@intercom/messenger-js-sdk": "0.0.14", "@intercom/messenger-js-sdk": "0.0.14",
"@json2csv/node": "7.0.6", "@json2csv/node": "7.0.6",
"@keyv/redis": "4.4.0",
"@lexical/code": "0.31.0", "@lexical/code": "0.31.0",
"@lexical/link": "0.31.0", "@lexical/link": "0.31.0",
"@lexical/list": "0.31.0", "@lexical/list": "0.31.0",
@@ -83,6 +84,7 @@
"@vercel/og": "0.6.8", "@vercel/og": "0.6.8",
"bcryptjs": "3.0.2", "bcryptjs": "3.0.2",
"boring-avatars": "1.11.2", "boring-avatars": "1.11.2",
"cache-manager": "6.4.3",
"class-variance-authority": "0.7.1", "class-variance-authority": "0.7.1",
"clsx": "2.1.1", "clsx": "2.1.1",
"cmdk": "1.1.1", "cmdk": "1.1.1",
@@ -95,6 +97,7 @@
"https-proxy-agent": "7.0.6", "https-proxy-agent": "7.0.6",
"jiti": "2.4.2", "jiti": "2.4.2",
"jsonwebtoken": "9.0.2", "jsonwebtoken": "9.0.2",
"keyv": "5.3.3",
"lexical": "0.31.0", "lexical": "0.31.0",
"lodash": "4.17.21", "lodash": "4.17.21",
"lru-cache": "11.1.0", "lru-cache": "11.1.0",
@@ -102,7 +105,7 @@
"markdown-it": "14.1.0", "markdown-it": "14.1.0",
"mime-types": "3.0.1", "mime-types": "3.0.1",
"nanoid": "5.1.5", "nanoid": "5.1.5",
"next": "14.2.28", "next": "15.3.1",
"next-auth": "4.24.11", "next-auth": "4.24.11",
"next-safe-action": "7.10.8", "next-safe-action": "7.10.8",
"node-fetch": "3.3.2", "node-fetch": "3.3.2",
+1
View File
@@ -260,6 +260,7 @@
"group": "Auth & SSO", "group": "Auth & SSO",
"icon": "lock", "icon": "lock",
"pages": [ "pages": [
"self-hosting/auth-behavior",
"self-hosting/configuration/auth-sso/open-id-connect", "self-hosting/configuration/auth-sso/open-id-connect",
"self-hosting/configuration/auth-sso/azure-ad-oauth", "self-hosting/configuration/auth-sso/azure-ad-oauth",
"self-hosting/configuration/auth-sso/google-oauth", "self-hosting/configuration/auth-sso/google-oauth",
+4 -1
View File
@@ -88,8 +88,11 @@ The Enterprise Edition allows us to fund the development of Formbricks sustainab
| Teams & access roles | ❌ | ✅ | | Teams & access roles | ❌ | ✅ |
| Contact management & segments | ❌ | ✅ | | Contact management & segments | ❌ | ✅ |
| Multi-language surveys | ❌ | ✅ | | Multi-language surveys | ❌ | ✅ |
| OAuth SSO (AzureAD, Google, OpenID) | ❌ | ✅ | | OIDC SSO (AzureAD, Google, OpenID) | ❌ | ✅ |
| SAML SSO | ❌ | ✅ | | SAML SSO | ❌ | ✅ |
| Spam protection (ReCaptchaV3) | ❌ | ✅ |
| Two-factor authentication | ❌ | ✅ |
| Custom 'Project' count | ❌ | ✅ |
| White-glove onboarding | ❌ | ✅ | | White-glove onboarding | ❌ | ✅ |
| Support SLAs | ❌ | ✅ | | Support SLAs | ❌ | ✅ |
+64
View File
@@ -0,0 +1,64 @@
---
title: "Authentication Behavior"
description: "Learn how authentication and user invitation work in self-hosted Formbricks deployments."
icon: "user"
---
## Overview
In self-hosted Formbricks, user management and authentication can be customized using environment variables. By default, self-hosted instances have user signup disabled, and only organization owners or admins can invite new users. The behavior of the authentication and invitation flow can be further controlled using the following environment variables:
- `AUTH_SKIP_INVITE_FOR_SSO`
- `AUTH_DEFAULT_TEAM_ID`
## License Requirement for Role Management and SSO Behavior
<Note>
To control advanced role management features and environment-based SSO behavior, your self-hosted Formbricks
instance must have a valid enterprise license.
</Note>
## Environment Variables
### `AUTH_SKIP_INVITE_FOR_SSO`
- **Type:** Boolean (0 or 1)
- **Default:** 0 (invite required)
- **Description:**
- When set to `1`, users who sign up via SSO (Single Sign-On) providers (such as Google, Azure AD, SAML, or OIDC) can create an account without requiring an invitation.
- When set to `0` (default), all users—including those signing up via SSO—must be invited by an organization owner or admin before they can create an account.
- **Use case:**
- Set this to `1` if you want to allow anyone with access to your SSO provider to join your Formbricks instance without a manual invite.
- Keep it at `0` for stricter access control, where only invited users can join, regardless of SSO.
### `AUTH_DEFAULT_TEAM_ID`
- **Type:** String (Team ID, a valid cuid)
- **Default:** None (must be set if you want to use default team assignment)
- **Description:**
- When a new user is invited or signs up (if allowed), they will automatically be added to the team with the ID specified in this variable.
- This is useful for onboarding users into a default team, ensuring they have access to relevant projects and resources immediately after joining.
- **Use case:**
- Set this to the ID of your default team to streamline onboarding for new users.
- If not set, users will not be automatically assigned to any team upon signup or invite acceptance.
## Example `.env` Configuration
```env
# Allow SSO users to join without invite
AUTH_SKIP_INVITE_FOR_SSO=1
# Automatically assign new users to this team
AUTH_DEFAULT_TEAM_ID=team-123
```
Refer to the [Environment Variables documentation](./configuration/environment-variables) for a full list and details.
---
For more information on SSO setup, see:
- [Google OAuth](./configuration/auth-sso/google-oauth)
- [Azure AD OAuth](./configuration/auth-sso/azure-ad-oauth)
- [Open ID Connect](./configuration/auth-sso/open-id-connect)
- [SAML SSO](./configuration/auth-sso/saml-sso)
@@ -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 | | | 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_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_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_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_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) | | | OIDC_CLIENT_SECRET | Secret for Custom OpenID Connect Provider | optional (required if OIDC auth is enabled) | |
-3
View File
@@ -66,8 +66,5 @@
"budgetPercentIncreaseRed": 20, "budgetPercentIncreaseRed": 20,
"minimumChangeThreshold": 0, "minimumChangeThreshold": 0,
"showDetails": true "showDetails": true
},
"dependencies": {
"@changesets/cli": "2.29.3"
} }
} }
@@ -1,16 +1,13 @@
package com.formbricks.demo package com.formbricks.demo
import android.os.Bundle import android.os.Bundle
import android.util.Log
import android.widget.Button import android.widget.Button
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import com.formbricks.formbrickssdk.Formbricks import com.formbricks.formbrickssdk.Formbricks
import com.formbricks.formbrickssdk.FormbricksCallback
import com.formbricks.formbrickssdk.helper.FormbricksConfig import com.formbricks.formbrickssdk.helper.FormbricksConfig
import com.formbricks.formbrickssdk.model.enums.SuccessType
import java.util.UUID import java.util.UUID
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
@@ -18,33 +15,6 @@ class MainActivity : AppCompatActivity() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() 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]") val config = FormbricksConfig.Builder("[appUrl]","[environmentId]")
.setLoggingEnabled(true) .setLoggingEnabled(true)
.setFragmentManager(supportFragmentManager) .setFragmentManager(supportFragmentManager)
+1 -2
View File
@@ -34,5 +34,4 @@
-keep class com.formbricks.formbrickssdk.DataBinderMapperImpl { *; } -keep class com.formbricks.formbrickssdk.DataBinderMapperImpl { *; }
-keep class com.formbricks.formbrickssdk.Formbricks { *; } -keep class com.formbricks.formbrickssdk.Formbricks { *; }
-keep class com.formbricks.formbrickssdk.helper.FormbricksConfig { *; } -keep class com.formbricks.formbrickssdk.helper.FormbricksConfig { *; }
-keep class com.formbricks.formbrickssdk.model.error.SDKError { *; } -keep class com.formbricks.formbrickssdk.model.error.SDKError { *; }
-keep interface com.formbricks.formbrickssdk.FormbricksCallback { *; }
@@ -10,22 +10,10 @@ import com.formbricks.formbrickssdk.helper.FormbricksConfig
import com.formbricks.formbrickssdk.logger.Logger import com.formbricks.formbrickssdk.logger.Logger
import com.formbricks.formbrickssdk.manager.SurveyManager import com.formbricks.formbrickssdk.manager.SurveyManager
import com.formbricks.formbrickssdk.manager.UserManager import com.formbricks.formbrickssdk.manager.UserManager
import com.formbricks.formbrickssdk.model.enums.SuccessType
import com.formbricks.formbrickssdk.model.error.SDKError import com.formbricks.formbrickssdk.model.error.SDKError
import com.formbricks.formbrickssdk.webview.FormbricksFragment import com.formbricks.formbrickssdk.webview.FormbricksFragment
import java.lang.RuntimeException import java.lang.RuntimeException
@Keep
interface FormbricksCallback {
fun onSurveyStarted()
fun onSurveyFinished()
fun onSurveyClosed()
fun onPageCommitVisible()
fun onError(error: Exception)
fun onSuccess(successType: SuccessType)
}
@Keep @Keep
object Formbricks { object Formbricks {
internal lateinit var applicationContext: Context internal lateinit var applicationContext: Context
@@ -37,8 +25,6 @@ object Formbricks {
private var fragmentManager: FragmentManager? = null private var fragmentManager: FragmentManager? = null
internal var isInitialized = false internal var isInitialized = false
var callback: FormbricksCallback? = null
/** /**
* Initializes the Formbricks SDK with the given [Context] config [FormbricksConfig]. * 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. * 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) { fun setup(context: Context, config: FormbricksConfig, forceRefresh: Boolean = false) {
if (isInitialized && !forceRefresh) { if (isInitialized && !forceRefresh) {
val error = SDKError.sdkIsAlreadyInitialized val error = SDKError.sdkIsAlreadyInitialized
callback?.onError(error)
Logger.e(error) Logger.e(error)
return return
} }
@@ -97,14 +82,12 @@ object Formbricks {
fun setUserId(userId: String) { fun setUserId(userId: String) {
if (!isInitialized) { if (!isInitialized) {
val error = SDKError.sdkIsNotInitialized val error = SDKError.sdkIsNotInitialized
callback?.onError(error)
Logger.e(error) Logger.e(error)
return return
} }
if(UserManager.userId != null) { if(UserManager.userId != null) {
val error = RuntimeException("A userId is already set ${UserManager.userId} - please call logout first before setting a new one") 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) Logger.e(error)
return return
} }
@@ -124,7 +107,6 @@ object Formbricks {
fun setAttribute(attribute: String, key: String) { fun setAttribute(attribute: String, key: String) {
if (!isInitialized) { if (!isInitialized) {
val error = SDKError.sdkIsNotInitialized val error = SDKError.sdkIsNotInitialized
callback?.onError(error)
Logger.e(error) Logger.e(error)
return return
} }
@@ -143,7 +125,6 @@ object Formbricks {
fun setAttributes(attributes: Map<String, String>) { fun setAttributes(attributes: Map<String, String>) {
if (!isInitialized) { if (!isInitialized) {
val error = SDKError.sdkIsNotInitialized val error = SDKError.sdkIsNotInitialized
callback?.onError(error)
Logger.e(error) Logger.e(error)
return return
} }
@@ -162,7 +143,6 @@ object Formbricks {
fun setLanguage(language: String) { fun setLanguage(language: String) {
if (!isInitialized) { if (!isInitialized) {
val error = SDKError.sdkIsNotInitialized val error = SDKError.sdkIsNotInitialized
callback?.onError(error)
Logger.e(error) Logger.e(error)
return return
} }
@@ -182,14 +162,12 @@ object Formbricks {
fun track(action: String) { fun track(action: String) {
if (!isInitialized) { if (!isInitialized) {
val error = SDKError.sdkIsNotInitialized val error = SDKError.sdkIsNotInitialized
callback?.onError(error)
Logger.e(error) Logger.e(error)
return return
} }
if (!isInternetAvailable()) { if (!isInternetAvailable()) {
val error = SDKError.connectionIsNotAvailable val error = SDKError.connectionIsNotAvailable
callback?.onError(error)
Logger.e(error) Logger.e(error)
return return
} }
@@ -209,12 +187,10 @@ object Formbricks {
fun logout() { fun logout() {
if (!isInitialized) { if (!isInitialized) {
val error = SDKError.sdkIsNotInitialized val error = SDKError.sdkIsNotInitialized
callback?.onError(error)
Logger.e(error) Logger.e(error)
return return
} }
callback?.onSuccess(SuccessType.LOGOUT_SUCCESS)
UserManager.logout() UserManager.logout()
} }
@@ -236,7 +212,6 @@ object Formbricks {
internal fun showSurvey(id: String) { internal fun showSurvey(id: String) {
if (fragmentManager == null) { if (fragmentManager == null) {
val error = SDKError.fragmentManagerIsNotSet val error = SDKError.fragmentManagerIsNotSet
callback?.onError(error)
Logger.e(error) Logger.e(error)
return return
} }
@@ -9,7 +9,6 @@ import com.formbricks.formbrickssdk.logger.Logger
import com.formbricks.formbrickssdk.model.environment.EnvironmentDataHolder import com.formbricks.formbrickssdk.model.environment.EnvironmentDataHolder
import com.formbricks.formbrickssdk.model.environment.Survey import com.formbricks.formbrickssdk.model.environment.Survey
import com.formbricks.formbrickssdk.model.error.SDKError 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.model.user.Display
import com.google.gson.Gson import com.google.gson.Gson
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -60,7 +59,6 @@ object SurveyManager {
try { try {
Gson().fromJson(json, EnvironmentDataHolder::class.java) Gson().fromJson(json, EnvironmentDataHolder::class.java)
} catch (e: Exception) { } catch (e: Exception) {
Formbricks.callback?.onError(e)
Logger.e(RuntimeException("Unable to retrieve environment data from the local storage.")) Logger.e(RuntimeException("Unable to retrieve environment data from the local storage."))
null null
} }
@@ -118,11 +116,9 @@ object SurveyManager {
startRefreshTimer(environmentDataHolder?.expiresAt()) startRefreshTimer(environmentDataHolder?.expiresAt())
filterSurveys() filterSurveys()
hasApiError = false hasApiError = false
Formbricks.callback?.onSuccess(SuccessType.GET_ENVIRONMENT_SUCCESS)
} catch (e: Exception) { } catch (e: Exception) {
hasApiError = true hasApiError = true
val error = SDKError.unableToRefreshEnvironment val error = SDKError.unableToRefreshEnvironment
Formbricks.callback?.onError(error)
Logger.e(error) Logger.e(error)
startErrorTimer() startErrorTimer()
} }
@@ -134,6 +130,7 @@ object SurveyManager {
* Handles the display percentage and the delay of the survey. * Handles the display percentage and the delay of the survey.
*/ */
fun track(action: String) { fun track(action: String) {
print(environmentDataHolder?.data?.data?.actionClasses)
val actionClasses = environmentDataHolder?.data?.data?.actionClasses ?: listOf() val actionClasses = environmentDataHolder?.data?.data?.actionClasses ?: listOf()
val codeActionClasses = actionClasses.filter { it.type == "code" } val codeActionClasses = actionClasses.filter { it.type == "code" }
val actionClass = codeActionClasses.firstOrNull { it.key == action } val actionClass = codeActionClasses.firstOrNull { it.key == action }
@@ -143,7 +140,8 @@ object SurveyManager {
} }
if (firstSurveyWithActionClass == null) { if (firstSurveyWithActionClass == null) {
Formbricks.callback?.onError(SDKError.surveyNotFoundError) val error = SDKError.surveyNotFoundError
Logger.e(error)
return return
} }
@@ -154,7 +152,6 @@ object SurveyManager {
if (languageCode == null) { if (languageCode == null) {
val error = RuntimeException("Survey “${firstSurveyWithActionClass.name}” is not available in language “$currentLanguage”. Skipping.") val error = RuntimeException("Survey “${firstSurveyWithActionClass.name}” is not available in language “$currentLanguage”. Skipping.")
Formbricks.callback?.onError(error)
Logger.e(error) Logger.e(error)
return return
} }
@@ -177,7 +174,8 @@ object SurveyManager {
}, Date(System.currentTimeMillis() + timeout.toLong() * 1000)) }, Date(System.currentTimeMillis() + timeout.toLong() * 1000))
} }
} else { } else {
Formbricks.callback?.onError(SDKError.surveyNotDisplayedError) val error = SDKError.surveyNotDisplayedError
Logger.e(error)
} }
} }
@@ -192,7 +190,6 @@ object SurveyManager {
fun postResponse(surveyId: String?) { fun postResponse(surveyId: String?) {
val id = surveyId.guard { val id = surveyId.guard {
val error = SDKError.missingSurveyId val error = SDKError.missingSurveyId
Formbricks.callback?.onError(error)
Logger.e(error) Logger.e(error)
return return
} }
@@ -206,7 +203,6 @@ object SurveyManager {
fun onNewDisplay(surveyId: String?) { fun onNewDisplay(surveyId: String?) {
val id = surveyId.guard { val id = surveyId.guard {
val error = SDKError.missingSurveyId val error = SDKError.missingSurveyId
Formbricks.callback?.onError(error)
Logger.e(error) Logger.e(error)
return return
} }
@@ -269,7 +265,6 @@ object SurveyManager {
else -> { else -> {
val error = SDKError.invalidDisplayOption val error = SDKError.invalidDisplayOption
Formbricks.callback?.onError(error)
Logger.e(error) Logger.e(error)
false false
} }
@@ -9,7 +9,6 @@ import com.formbricks.formbrickssdk.extensions.guard
import com.formbricks.formbrickssdk.extensions.lastDisplayAt import com.formbricks.formbrickssdk.extensions.lastDisplayAt
import com.formbricks.formbrickssdk.logger.Logger import com.formbricks.formbrickssdk.logger.Logger
import com.formbricks.formbrickssdk.model.error.SDKError 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.model.user.Display
import com.formbricks.formbrickssdk.network.queue.UpdateQueue import com.formbricks.formbrickssdk.network.queue.UpdateQueue
import com.google.gson.Gson import com.google.gson.Gson
@@ -147,10 +146,8 @@ object UserManager {
UpdateQueue.current.reset() UpdateQueue.current.reset()
SurveyManager.filterSurveys() SurveyManager.filterSurveys()
startSyncTimer() startSyncTimer()
Formbricks.callback?.onSuccess(SuccessType.SET_USER_SUCCESS)
} catch (e: Exception) { } catch (e: Exception) {
val error = SDKError.unableToPostResponse val error = SDKError.unableToPostResponse
Formbricks.callback?.onError(error)
Logger.e(error) Logger.e(error)
} }
} }
@@ -164,7 +161,6 @@ object UserManager {
if (!isUserIdDefined) { if (!isUserIdDefined) {
val error = SDKError.noUserIdSetError val error = SDKError.noUserIdSetError
Formbricks.callback?.onError(error)
Logger.e(error) Logger.e(error)
} }
@@ -7,5 +7,6 @@ import kotlinx.serialization.Serializable
data class EnvironmentData( data class EnvironmentData(
@SerializedName("surveys") val surveys: List<Survey>?, @SerializedName("surveys") val surveys: List<Survey>?,
@SerializedName("actionClasses") val actionClasses: List<ActionClass>?, @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 package com.formbricks.formbrickssdk.model.environment
import com.google.gson.annotations.SerializedName import kotlinx.serialization.*
import kotlinx.serialization.Serializable 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 youre 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 @Serializable
data class Segment( data class Segment(
@SerializedName("id") val id: String? = null, val id: String,
@SerializedName("createdAt") val createdAt: String? = null, val title: String,
@SerializedName("updatedAt") val updatedAt: String? = null, val description: String? = null,
@SerializedName("title") val title: String? = null, @SerialName("isPrivate") val isPrivate: Boolean,
@SerializedName("description") val description: String? = null, val filters: List<SegmentFilter>,
@SerializedName("isPrivate") val isPrivate: Boolean? = null, val environmentId: String,
@SerializedName("filters") val filters: List<String>? = null, val createdAt: String,
@SerializedName("environmentId") val environmentId: String? = null, val updatedAt: String,
@SerializedName("surveys") val surveys: List<String>? = null val surveys: List<String>
) )
@@ -30,5 +30,5 @@ data class Survey(
@SerializedName("displayOption") val displayOption: String?, @SerializedName("displayOption") val displayOption: String?,
@SerializedName("segment") val segment: Segment?, @SerializedName("segment") val segment: Segment?,
@SerializedName("styling") val styling: Styling?, @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 package com.formbricks.formbrickssdk.network.queue
import com.formbricks.formbrickssdk.Formbricks
import com.formbricks.formbrickssdk.logger.Logger import com.formbricks.formbrickssdk.logger.Logger
import com.formbricks.formbrickssdk.manager.UserManager import com.formbricks.formbrickssdk.manager.UserManager
import com.formbricks.formbrickssdk.model.error.SDKError import com.formbricks.formbrickssdk.model.error.SDKError
@@ -68,7 +67,6 @@ class UpdateQueue private constructor() {
?: UserManager.userId ?: UserManager.userId
if (effectiveUserId == null) { if (effectiveUserId == null) {
val error = SDKError.noUserIdSetError val error = SDKError.noUserIdSetError
Formbricks.callback?.onError(error)
Logger.e(error) Logger.e(error)
return return
} }
@@ -25,7 +25,6 @@ import android.widget.FrameLayout
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentManager
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import com.formbricks.formbrickssdk.Formbricks
import com.formbricks.formbrickssdk.R import com.formbricks.formbrickssdk.R
import com.formbricks.formbrickssdk.databinding.FragmentFormbricksBinding import com.formbricks.formbrickssdk.databinding.FragmentFormbricksBinding
import com.formbricks.formbrickssdk.logger.Logger import com.formbricks.formbrickssdk.logger.Logger
@@ -37,26 +36,22 @@ import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import com.google.gson.JsonObject import com.google.gson.JsonObject
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.InputStream import java.io.InputStream
import java.util.Timer
class FormbricksFragment : BottomSheetDialogFragment() { class FormbricksFragment : BottomSheetDialogFragment() {
private lateinit var binding: FragmentFormbricksBinding private lateinit var binding: FragmentFormbricksBinding
private lateinit var surveyId: String private lateinit var surveyId: String
private val closeTimer = Timer()
private val viewModel: FormbricksViewModel by viewModels() private val viewModel: FormbricksViewModel by viewModels()
private var webAppInterface = WebAppInterface(object : WebAppInterface.WebAppCallback { private var webAppInterface = WebAppInterface(object : WebAppInterface.WebAppCallback {
override fun onClose() { override fun onClose() {
Handler(Looper.getMainLooper()).post { Handler(Looper.getMainLooper()).post {
Formbricks.callback?.onSurveyClosed()
dismiss() dismiss()
} }
} }
override fun onDisplayCreated() { override fun onDisplayCreated() {
Formbricks.callback?.onSurveyStarted()
SurveyManager.onNewDisplay(surveyId) SurveyManager.onNewDisplay(surveyId)
} }
@@ -74,7 +69,8 @@ class FormbricksFragment : BottomSheetDialogFragment() {
} }
override fun onSurveyLibraryLoadError() { override fun onSurveyLibraryLoadError() {
Formbricks.callback?.onError(SDKError.unableToLoadFormbicksJs) val error = SDKError.unableToLoadFormbicksJs
Logger.e(error)
dismiss() dismiss()
} }
}) })
@@ -155,7 +151,8 @@ class FormbricksFragment : BottomSheetDialogFragment() {
override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean { override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
consoleMessage?.let { cm -> consoleMessage?.let { cm ->
if (cm.messageLevel() == ConsoleMessage.MessageLevel.ERROR) { if (cm.messageLevel() == ConsoleMessage.MessageLevel.ERROR) {
Formbricks.callback?.onError(SDKError.surveyDisplayFetchError) val error = SDKError.surveyDisplayFetchError
Logger.e(error)
dismiss() dismiss()
} }
val log = "[CONSOLE:${cm.messageLevel()}] \"${cm.message()}\", source: ${cm.sourceId()} (${cm.lineNumber()})" 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 }); observer.observe(document.body, { childList: true, subtree: true });
const script = document.createElement("script"); 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.async = true;
script.onload = () => loadSurvey(); script.onload = () => loadSurvey();
script.onerror = (error) => { script.onerror = (error) => {
@@ -1,7 +1,6 @@
package com.formbricks.formbrickssdk.webview package com.formbricks.formbrickssdk.webview
import android.webkit.JavascriptInterface import android.webkit.JavascriptInterface
import com.formbricks.formbrickssdk.Formbricks
import com.formbricks.formbrickssdk.logger.Logger import com.formbricks.formbrickssdk.logger.Logger
import com.formbricks.formbrickssdk.model.javascript.JsMessageData import com.formbricks.formbrickssdk.model.javascript.JsMessageData
import com.formbricks.formbrickssdk.model.javascript.EventType 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() } EventType.ON_SURVEY_LIBRARY_LOAD_ERROR -> { callback?.onSurveyLibraryLoadError() }
} }
} catch (e: Exception) { } catch (e: Exception) {
Formbricks.callback?.onError(e)
Logger.e(RuntimeException(e.message)) Logger.e(RuntimeException(e.message))
} catch (e: JsonParseException) { } catch (e: JsonParseException) {
Logger.e(RuntimeException("Failed to parse JSON message: $data")) Logger.e(RuntimeException("Failed to parse JSON message: $data"))
} catch (e: IllegalArgumentException) { } catch (e: IllegalArgumentException) {
Formbricks.callback?.onError(e)
Logger.e(RuntimeException("Invalid message format: $data")) Logger.e(RuntimeException("Invalid message format: $data"))
} catch (e: Exception) { } catch (e: Exception) {
Formbricks.callback?.onError(e)
Logger.e(RuntimeException("Unexpected error processing message: $data")) Logger.e(RuntimeException("Unexpected error processing message: $data"))
} }
} }
+1 -20
View File
@@ -1,29 +1,10 @@
import UIKit import UIKit
import FormbricksSDK import FormbricksSDK
class AppDelegate: NSObject, UIApplicationDelegate, FormbricksDelegate { class AppDelegate: NSObject, UIApplicationDelegate {
func application(_ application: UIApplication, func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
Formbricks.delegate = self
return true 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 Foundation
import Network 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. /// The main class of the Formbricks SDK. It contains the main methods to interact with the SDK.
@objc(Formbricks) public class Formbricks: NSObject { @objc(Formbricks) public class Formbricks: NSObject {
@@ -23,7 +15,6 @@ public protocol FormbricksDelegate: AnyObject {
static internal var apiQueue: OperationQueue? = OperationQueue() static internal var apiQueue: OperationQueue? = OperationQueue()
static internal var logger: Logger? static internal var logger: Logger?
static internal var service = FormbricksService() static internal var service = FormbricksService()
public static weak var delegate: FormbricksDelegate?
// make this class not instantiatable outside of the SDK // make this class not instantiatable outside of the SDK
internal override init() { internal override init() {
@@ -58,7 +49,6 @@ public protocol FormbricksDelegate: AnyObject {
guard !isInitialized else { guard !isInitialized else {
let error = FormbricksSDKError(type: .sdkIsAlreadyInitialized) let error = FormbricksSDKError(type: .sdkIsAlreadyInitialized)
delegate?.onError(error)
Formbricks.logger?.error(error.message) Formbricks.logger?.error(error.message)
return return
} }
@@ -101,7 +91,6 @@ public protocol FormbricksDelegate: AnyObject {
@objc public static func setUserId(_ userId: String) { @objc public static func setUserId(_ userId: String) {
guard Formbricks.isInitialized else { guard Formbricks.isInitialized else {
let error = FormbricksSDKError(type: .sdkIsNotInitialized) let error = FormbricksSDKError(type: .sdkIsNotInitialized)
delegate?.onError(error)
Formbricks.logger?.error(error.message) Formbricks.logger?.error(error.message)
return return
} }
@@ -126,7 +115,6 @@ public protocol FormbricksDelegate: AnyObject {
@objc public static func setAttribute(_ attribute: String, forKey key: String) { @objc public static func setAttribute(_ attribute: String, forKey key: String) {
guard Formbricks.isInitialized else { guard Formbricks.isInitialized else {
let error = FormbricksSDKError(type: .sdkIsNotInitialized) let error = FormbricksSDKError(type: .sdkIsNotInitialized)
delegate?.onError(error)
Formbricks.logger?.error(error.message) Formbricks.logger?.error(error.message)
return return
} }
@@ -146,7 +134,6 @@ public protocol FormbricksDelegate: AnyObject {
@objc public static func setAttributes(_ attributes: [String : String]) { @objc public static func setAttributes(_ attributes: [String : String]) {
guard Formbricks.isInitialized else { guard Formbricks.isInitialized else {
let error = FormbricksSDKError(type: .sdkIsNotInitialized) let error = FormbricksSDKError(type: .sdkIsNotInitialized)
delegate?.onError(error)
Formbricks.logger?.error(error.message) Formbricks.logger?.error(error.message)
return return
} }
@@ -166,7 +153,6 @@ public protocol FormbricksDelegate: AnyObject {
@objc public static func setLanguage(_ language: String) { @objc public static func setLanguage(_ language: String) {
guard Formbricks.isInitialized else { guard Formbricks.isInitialized else {
let error = FormbricksSDKError(type: .sdkIsNotInitialized) let error = FormbricksSDKError(type: .sdkIsNotInitialized)
delegate?.onError(error)
Formbricks.logger?.error(error.message) Formbricks.logger?.error(error.message)
return return
} }
@@ -191,7 +177,6 @@ public protocol FormbricksDelegate: AnyObject {
@objc public static func track(_ action: String) { @objc public static func track(_ action: String) {
guard Formbricks.isInitialized else { guard Formbricks.isInitialized else {
let error = FormbricksSDKError(type: .sdkIsNotInitialized) let error = FormbricksSDKError(type: .sdkIsNotInitialized)
delegate?.onError(error)
Formbricks.logger?.error(error.message) Formbricks.logger?.error(error.message)
return return
} }
@@ -218,7 +203,6 @@ public protocol FormbricksDelegate: AnyObject {
@objc public static func logout() { @objc public static func logout() {
guard Formbricks.isInitialized else { guard Formbricks.isInitialized else {
let error = FormbricksSDKError(type: .sdkIsNotInitialized) let error = FormbricksSDKError(type: .sdkIsNotInitialized)
delegate?.onError(error)
Formbricks.logger?.error(error.message) Formbricks.logger?.error(error.message)
return return
} }
@@ -106,7 +106,6 @@ extension SurveyManager {
case .failure: case .failure:
self?.hasApiError = true self?.hasApiError = true
let error = FormbricksSDKError(type: .unableToRefreshEnvironment) let error = FormbricksSDKError(type: .unableToRefreshEnvironment)
Formbricks.delegate?.onError(error)
Formbricks.logger?.error(error.message) Formbricks.logger?.error(error.message)
self?.startErrorTimer() self?.startErrorTimer()
} }
@@ -186,7 +185,6 @@ extension SurveyManager {
return try? JSONDecoder().decode(EnvironmentResponse.self, from: data) return try? JSONDecoder().decode(EnvironmentResponse.self, from: data)
} else { } else {
let error = FormbricksSDKError(type: .unableToRetrieveEnvironment) let error = FormbricksSDKError(type: .unableToRetrieveEnvironment)
Formbricks.delegate?.onError(error)
Formbricks.logger?.error(error.message) Formbricks.logger?.error(error.message)
return nil return nil
} }
@@ -197,7 +195,6 @@ extension SurveyManager {
backingEnvironmentResponse = newValue backingEnvironmentResponse = newValue
} else { } else {
let error = FormbricksSDKError(type: .unableToPersistEnvironment) let error = FormbricksSDKError(type: .unableToPersistEnvironment)
Formbricks.delegate?.onError(error)
Formbricks.logger?.error(error.message) Formbricks.logger?.error(error.message)
} }
} }
@@ -231,7 +228,6 @@ private extension SurveyManager {
default: default:
let error = FormbricksSDKError(type: .invalidDisplayOption) let error = FormbricksSDKError(type: .invalidDisplayOption)
Formbricks.delegate?.onError(error)
Formbricks.logger?.error(error.message) Formbricks.logger?.error(error.message)
return false return false
} }
@@ -102,7 +102,6 @@ final class UserManager: UserManagerSyncable {
self?.surveyManager?.filterSurveys() self?.surveyManager?.filterSurveys()
self?.startSyncTimer() self?.startSyncTimer()
case .failure(let error): case .failure(let error):
Formbricks.delegate?.onError(error)
Formbricks.logger?.error(error) Formbricks.logger?.error(error)
} }
} }
@@ -2,4 +2,5 @@ struct EnvironmentData: Codable {
let surveys: [Survey]? let surveys: [Survey]?
let actionClasses: [ActionClass]? let actionClasses: [ActionClass]?
let project: Project let project: Project
let recaptchaSiteKey: String?
} }
@@ -1,11 +1,198 @@
struct Segment: Codable { import Foundation
let id: String?
let createdAt: String? // MARK: - Connector
let updatedAt: String?
let title: String? enum SegmentConnector: String, Codable {
let description: String? case and
let isPrivate: Bool? case or
let filters: [String]? }
let environmentId: String?
let surveys: [String]? // 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?) { private func processResponse(data: Data?, response: URLResponse?, error: Error?) {
guard let httpStatus = (response as? HTTPURLResponse)?.status else { guard let httpStatus = (response as? HTTPURLResponse)?.status else {
let error = FormbricksAPIClientError(type: .invalidResponse) let error = FormbricksAPIClientError(type: .invalidResponse)
Formbricks.delegate?.onError(error)
Formbricks.logger?.error("ERROR \(error.message)") Formbricks.logger?.error("ERROR \(error.message)")
completion?(.failure(error)) completion?(.failure(error))
return return
@@ -57,7 +56,6 @@ class APIClient<Request: CodableRequest>: Operation, @unchecked Sendable {
private func handleSuccessResponse(data: Data?, statusCode: Int, message: inout String) { private func handleSuccessResponse(data: Data?, statusCode: Int, message: inout String) {
guard let data = data else { guard let data = data else {
let error = FormbricksAPIClientError(type: .invalidResponse, statusCode: statusCode) let error = FormbricksAPIClientError(type: .invalidResponse, statusCode: statusCode)
Formbricks.delegate?.onError(error)
completion?(.failure(error)) completion?(.failure(error))
return return
} }
@@ -89,16 +87,13 @@ class APIClient<Request: CodableRequest>: Operation, @unchecked Sendable {
if let error = error { if let error = error {
log.append("\nError: \(error.localizedDescription)") log.append("\nError: \(error.localizedDescription)")
Formbricks.delegate?.onError(error)
Formbricks.logger?.error(log) Formbricks.logger?.error(log)
completion?(.failure(error)) completion?(.failure(error))
} else if let data = data, let apiError = try? request.decoder.decode(FormbricksAPIError.self, from: data) { } 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())") Formbricks.logger?.error("\(log)\n\(apiError.getDetailedErrorMessage())")
completion?(.failure(apiError)) completion?(.failure(apiError))
} else { } else {
let error = FormbricksAPIClientError(type: .responseError, statusCode: statusCode) let error = FormbricksAPIClientError(type: .responseError, statusCode: statusCode)
Formbricks.delegate?.onError(error)
Formbricks.logger?.error("\(log)\n\(error.message)") Formbricks.logger?.error("\(log)\n\(error.message)")
completion?(.failure(error)) completion?(.failure(error))
} }
@@ -119,10 +114,8 @@ class APIClient<Request: CodableRequest>: Operation, @unchecked Sendable {
} }
let error = FormbricksAPIClientError(type: .invalidResponse, statusCode: statusCode) let error = FormbricksAPIClientError(type: .invalidResponse, statusCode: statusCode)
Formbricks.delegate?.onError(error)
Formbricks.logger?.error(error.message) Formbricks.logger?.error(error.message)
completion?(.failure(error)) completion?(.failure(error))
} }
private func logRequest(_ request: URLRequest) { private func logRequest(_ request: URLRequest) {
@@ -101,7 +101,6 @@ private extension UpdateQueue {
guard let userId = effectiveUserId else { guard let userId = effectiveUserId else {
let error = FormbricksSDKError(type: .userIdIsNotSetYet) let error = FormbricksSDKError(type: .userIdIsNotSetYet)
Formbricks.delegate?.onError(error)
Formbricks.logger?.error(error.message) Formbricks.logger?.error(error.message)
return return
} }
@@ -110,7 +110,6 @@ private class WebViewData {
let jsonData = try JSONSerialization.data(withJSONObject: data, options: []) let jsonData = try JSONSerialization.data(withJSONObject: data, options: [])
return String(data: jsonData, encoding: .utf8)?.replacingOccurrences(of: "\\\"", with: "'") return String(data: jsonData, encoding: .utf8)?.replacingOccurrences(of: "\\\"", with: "'")
} catch { } catch {
Formbricks.delegate?.onError(error)
Formbricks.logger?.error(error.message) Formbricks.logger?.error(error.message)
return nil return nil
} }
@@ -119,12 +119,10 @@ final class JsMessageHandler: NSObject, WKScriptMessageHandler {
/// Happens when a survey is shown. /// Happens when a survey is shown.
case .onDisplayCreated: case .onDisplayCreated:
Formbricks.delegate?.onSurveyStarted()
Formbricks.surveyManager?.onNewDisplay(surveyId: surveyId) Formbricks.surveyManager?.onNewDisplay(surveyId: surveyId)
/// Happens when the user closes the survey view with the close button. /// Happens when the user closes the survey view with the close button.
case .onClose: case .onClose:
Formbricks.delegate?.onSurveyClosed()
Formbricks.surveyManager?.dismissSurveyWebView() Formbricks.surveyManager?.dismissSurveyWebView()
/// Happens when the survey wants to open an external link in the default browser. /// Happens when the survey wants to open an external link in the default browser.
@@ -140,7 +138,6 @@ final class JsMessageHandler: NSObject, WKScriptMessageHandler {
} else { } else {
let error = FormbricksSDKError(type: .invalidJavascriptMessage) let error = FormbricksSDKError(type: .invalidJavascriptMessage)
Formbricks.delegate?.onError(error)
Formbricks.logger?.error("\(error.message): \(message.body)") Formbricks.logger?.error("\(error.message): \(message.body)")
} }
} }
+10 -5
View File
@@ -1,5 +1,5 @@
import Pino, { type Logger, type LoggerOptions, stdSerializers } from "pino"; 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 IS_PRODUCTION = !process.env.NODE_ENV || process.env.NODE_ENV === "production";
const getLogLevel = (): TLogLevel => { const getLogLevel = (): TLogLevel => {
@@ -58,12 +58,17 @@ const productionConfig: LoggerOptions = {
const logger: Logger = IS_PRODUCTION ? Pino(productionConfig) : Pino(developmentConfig); const logger: Logger = IS_PRODUCTION ? Pino(productionConfig) : Pino(developmentConfig);
LOG_LEVELS.forEach((level) => { // Ensure all log levels are properly bound
logger[level] = logger[level].bind(logger); 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 = { const extendedLogger = {
...logger, ...boundLogger,
withContext: (context: Record<string, unknown>) => logger.child(context), withContext: (context: Record<string, unknown>) => logger.child(context),
request: (req: Request) => request: (req: Request) =>
logger.child({ logger.child({
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, test } from "vitest";
import { TI18nString } from "@formbricks/types/surveys/types";
import { getLocalizedValue } from "./i18n";
describe("i18n", () => {
describe("getLocalizedValue", () => {
test("should return empty string for undefined value", () => {
expect(getLocalizedValue(undefined, "en")).toBe("");
});
test("should return empty string for non-i18n string", () => {
expect(getLocalizedValue("not an i18n string" as any, "en")).toBe("");
});
test("should return default value when language not found", () => {
const i18nString: TI18nString = {
default: "Default text",
en: "English text",
};
expect(getLocalizedValue(i18nString, "fr")).toBe("Default text");
});
test("should return localized value when language found", () => {
const i18nString: TI18nString = {
default: "Default text",
en: "English text",
fr: "French text",
};
expect(getLocalizedValue(i18nString, "fr")).toBe("French text");
});
});
});
-1
View File
@@ -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 // 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 // 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 export const ZSegmentFilters: z.ZodType<TBaseFilters> = z
.array( .array(
z.object({ z.object({
+116 -523
View File
File diff suppressed because it is too large Load Diff