From f239ee9697dc524a46d397befe523ccd70e7a7e7 Mon Sep 17 00:00:00 2001 From: Piyush Gupta <56182734+gupta-piyush19@users.noreply.github.com> Date: Wed, 6 Aug 2025 20:05:28 +0530 Subject: [PATCH] feat: adds `multiLanguageSurveys` and `accessControl` license features (#6331) --- .../components/ProjectSettings.test.tsx | 2 +- .../settings/components/ProjectSettings.tsx | 6 +- .../projects/new/settings/page.test.tsx | 10 +-- .../projects/new/settings/page.tsx | 6 +- .../environments/[environmentId]/actions.ts | 6 +- .../components/EnvironmentLayout.test.tsx | 22 ++--- .../components/EnvironmentLayout.tsx | 8 +- .../components/MainNavigation.test.tsx | 18 ++-- .../components/MainNavigation.tsx | 6 +- .../ee/license-check/lib/license.test.ts | 11 ++- .../modules/ee/license-check/lib/license.ts | 10 +-- .../ee/license-check/lib/utils.test.ts | 90 +++++++++++++++---- .../web/modules/ee/license-check/lib/utils.ts | 70 ++++++++------- .../license-check/types/enterprise-license.ts | 2 + .../web/modules/ee/role-management/actions.ts | 6 +- .../components/add-member-role.tsx | 8 +- .../components/add-member.test.tsx | 20 +++-- apps/web/modules/ee/sso/lib/sso-handlers.ts | 8 +- .../ee/sso/lib/tests/sso-handlers.test.ts | 8 +- .../teams/team-list/components/teams-view.tsx | 6 +- .../edit-memberships.test.tsx | 10 +-- .../edit-memberships/edit-memberships.tsx | 10 ++- .../edit-memberships/members-info.test.tsx | 18 ++-- .../edit-memberships/members-info.tsx | 6 +- .../organization-actions.test.tsx | 4 +- .../edit-memberships/organization-actions.tsx | 6 +- .../invite-member/bulk-invite-tab.tsx | 8 +- .../individual-invite-tab.test.tsx | 14 +-- .../invite-member/individual-invite-tab.tsx | 12 +-- .../invite-member-modal.test.tsx | 2 +- .../invite-member/invite-member-modal.tsx | 8 +- .../teams/components/members-view.test.tsx | 2 +- .../teams/components/members-view.tsx | 10 +-- .../organization/settings/teams/page.test.tsx | 10 +-- .../organization/settings/teams/page.tsx | 8 +- .../create-project-modal/index.test.tsx | 8 +- .../components/create-project-modal/index.tsx | 6 +- .../project-switcher/index.test.tsx | 12 +-- .../components/project-switcher/index.tsx | 6 +- 39 files changed, 279 insertions(+), 204 deletions(-) diff --git a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings.test.tsx b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings.test.tsx index f7ccbd37ae..56e929042d 100644 --- a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings.test.tsx +++ b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings.test.tsx @@ -62,7 +62,7 @@ describe("ProjectSettings component", () => { industry: "ind", defaultBrandColor: "#fff", organizationTeams: [], - canDoRoleManagement: false, + isAccessControlAllowed: false, userProjectsCount: 0, } as any; diff --git a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings.tsx b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings.tsx index ea64a64791..ac8b023dc6 100644 --- a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings.tsx +++ b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings.tsx @@ -42,7 +42,7 @@ interface ProjectSettingsProps { industry: TProjectConfigIndustry; defaultBrandColor: string; organizationTeams: TOrganizationTeam[]; - canDoRoleManagement: boolean; + isAccessControlAllowed: boolean; userProjectsCount: number; } @@ -53,7 +53,7 @@ export const ProjectSettings = ({ industry, defaultBrandColor, organizationTeams, - canDoRoleManagement = false, + isAccessControlAllowed = false, userProjectsCount, }: ProjectSettingsProps) => { const [createTeamModalOpen, setCreateTeamModalOpen] = useState(false); @@ -174,7 +174,7 @@ export const ProjectSettings = ({ )} /> - {canDoRoleManagement && userProjectsCount > 0 && ( + {isAccessControlAllowed && userProjectsCount > 0 && ( ({ DEFAULT_BRAND_COLOR: "#fff" })); // Mocks before component import vi.mock("@/app/(app)/(onboarding)/lib/onboarding", () => ({ getTeamsByOrganizationId: vi.fn() })); vi.mock("@/lib/project/service", () => ({ getUserProjects: vi.fn() })); -vi.mock("@/modules/ee/license-check/lib/utils", () => ({ getRoleManagementPermission: vi.fn() })); +vi.mock("@/modules/ee/license-check/lib/utils", () => ({ getAccessControlPermission: vi.fn() })); vi.mock("@/modules/organization/lib/utils", () => ({ getOrganizationAuth: vi.fn() })); vi.mock("@/tolgee/server", () => ({ getTranslate: () => Promise.resolve((key: string) => key) })); vi.mock("next/navigation", () => ({ redirect: vi.fn() })); @@ -61,7 +61,7 @@ describe("ProjectSettingsPage", () => { } as any); vi.mocked(getUserProjects).mockResolvedValueOnce([] as any); vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce(null as any); - vi.mocked(getRoleManagementPermission).mockResolvedValueOnce(false as any); + vi.mocked(getAccessControlPermission).mockResolvedValueOnce(false as any); await expect(Page({ params, searchParams })).rejects.toThrow("common.organization_teams_not_found"); }); @@ -73,7 +73,7 @@ describe("ProjectSettingsPage", () => { } as any); vi.mocked(getUserProjects).mockResolvedValueOnce([{ id: "p1" }] as any); vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce([{ id: "t1", name: "Team1" }] as any); - vi.mocked(getRoleManagementPermission).mockResolvedValueOnce(true as any); + vi.mocked(getAccessControlPermission).mockResolvedValueOnce(true as any); const element = await Page({ params, searchParams }); render(element as React.ReactElement); @@ -96,7 +96,7 @@ describe("ProjectSettingsPage", () => { } as any); vi.mocked(getUserProjects).mockResolvedValueOnce([] as any); vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce([{ id: "t1", name: "Team1" }] as any); - vi.mocked(getRoleManagementPermission).mockResolvedValueOnce(true as any); + vi.mocked(getAccessControlPermission).mockResolvedValueOnce(true as any); const element = await Page({ params, searchParams }); render(element as React.ReactElement); diff --git a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/page.tsx b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/page.tsx index 38ea2450cc..cdd755bd94 100644 --- a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/page.tsx +++ b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/page.tsx @@ -2,7 +2,7 @@ import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboardin import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings"; import { DEFAULT_BRAND_COLOR } from "@/lib/constants"; import { getUserProjects } from "@/lib/project/service"; -import { getRoleManagementPermission } from "@/modules/ee/license-check/lib/utils"; +import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils"; import { getOrganizationAuth } from "@/modules/organization/lib/utils"; import { Button } from "@/modules/ui/components/button"; import { Header } from "@/modules/ui/components/header"; @@ -41,7 +41,7 @@ const Page = async (props: ProjectSettingsPageProps) => { const organizationTeams = await getTeamsByOrganizationId(params.organizationId); - const canDoRoleManagement = await getRoleManagementPermission(organization.billing.plan); + const isAccessControlAllowed = await getAccessControlPermission(organization.billing.plan); if (!organizationTeams) { throw new Error(t("common.organization_teams_not_found")); @@ -60,7 +60,7 @@ const Page = async (props: ProjectSettingsPageProps) => { industry={industry} defaultBrandColor={DEFAULT_BRAND_COLOR} organizationTeams={organizationTeams} - canDoRoleManagement={canDoRoleManagement} + isAccessControlAllowed={isAccessControlAllowed} userProjectsCount={projects.length} /> {projects.length >= 1 && ( diff --git a/apps/web/app/(app)/environments/[environmentId]/actions.ts b/apps/web/app/(app)/environments/[environmentId]/actions.ts index a533636e7f..b5ac06ec76 100644 --- a/apps/web/app/(app)/environments/[environmentId]/actions.ts +++ b/apps/web/app/(app)/environments/[environmentId]/actions.ts @@ -8,8 +8,8 @@ import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-clie import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { + getAccessControlPermission, getOrganizationProjectsLimit, - getRoleManagementPermission, } from "@/modules/ee/license-check/lib/utils"; import { createProject } from "@/modules/projects/settings/lib/project"; import { z } from "zod"; @@ -58,9 +58,9 @@ export const createProjectAction = authenticatedActionClient.schema(ZCreateProje } if (parsedInput.data.teamIds && parsedInput.data.teamIds.length > 0) { - const canDoRoleManagement = await getRoleManagementPermission(organization.billing.plan); + const isAccessControlAllowed = await getAccessControlPermission(organization.billing.plan); - if (!canDoRoleManagement) { + if (!isAccessControlAllowed) { throw new OperationNotAllowedError("You do not have permission to manage roles"); } } diff --git a/apps/web/app/(app)/environments/[environmentId]/components/EnvironmentLayout.test.tsx b/apps/web/app/(app)/environments/[environmentId]/components/EnvironmentLayout.test.tsx index 42c1c0495c..a0d6e9a850 100644 --- a/apps/web/app/(app)/environments/[environmentId]/components/EnvironmentLayout.test.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/components/EnvironmentLayout.test.tsx @@ -10,8 +10,8 @@ import { import { getUserProjects } from "@/lib/project/service"; import { getUser } from "@/lib/user/service"; import { + getAccessControlPermission, getOrganizationProjectsLimit, - getRoleManagementPermission, } from "@/modules/ee/license-check/lib/utils"; import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles"; import { getTeamsByOrganizationId } from "@/modules/ee/teams/team-list/lib/team"; @@ -53,7 +53,7 @@ vi.mock("@/lib/membership/utils", () => ({ })); vi.mock("@/modules/ee/license-check/lib/utils", () => ({ getOrganizationProjectsLimit: vi.fn(), - getRoleManagementPermission: vi.fn(), + getAccessControlPermission: vi.fn(), })); vi.mock("@/modules/ee/teams/lib/roles", () => ({ getProjectPermissionByUserId: vi.fn(), @@ -79,11 +79,11 @@ vi.mock("@/lib/constants", () => ({ // Mock components vi.mock("@/app/(app)/environments/[environmentId]/components/MainNavigation", () => ({ - MainNavigation: ({ organizationTeams, canDoRoleManagement }: any) => ( + MainNavigation: ({ organizationTeams, isAccessControlAllowed }: any) => (
MainNavigation
{JSON.stringify(organizationTeams || [])}
-
{canDoRoleManagement?.toString() || "false"}
+
{isAccessControlAllowed?.toString() || "false"}
), })); @@ -202,7 +202,7 @@ describe("EnvironmentLayout", () => { vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(null as any); vi.mocked(getProjectPermissionByUserId).mockResolvedValue(mockProjectPermission); vi.mocked(getTeamsByOrganizationId).mockResolvedValue(mockOrganizationTeams); - vi.mocked(getRoleManagementPermission).mockResolvedValue(true); + vi.mocked(getAccessControlPermission).mockResolvedValue(true); mockIsDevelopment = false; mockIsFormbricksCloud = false; }); @@ -315,7 +315,7 @@ describe("EnvironmentLayout", () => { expect(screen.getByTestId("downgrade-banner")).toBeInTheDocument(); }); - test("passes canDoRoleManagement props to MainNavigation", async () => { + test("passes isAccessControlAllowed props to MainNavigation", async () => { vi.resetModules(); await vi.doMock("@/modules/ee/license-check/lib/license", () => ({ getEnterpriseLicense: vi.fn().mockResolvedValue({ @@ -337,8 +337,8 @@ describe("EnvironmentLayout", () => { }) ); - expect(screen.getByTestId("can-do-role-management")).toHaveTextContent("true"); - expect(vi.mocked(getRoleManagementPermission)).toHaveBeenCalledWith(mockOrganization.billing.plan); + expect(screen.getByTestId("is-access-control-allowed")).toHaveTextContent("true"); + expect(vi.mocked(getAccessControlPermission)).toHaveBeenCalledWith(mockOrganization.billing.plan); }); test("handles empty organizationTeams array", async () => { @@ -393,8 +393,8 @@ describe("EnvironmentLayout", () => { expect(screen.getByTestId("organization-teams")).toHaveTextContent("[]"); }); - test("handles canDoRoleManagement false", async () => { - vi.mocked(getRoleManagementPermission).mockResolvedValue(false); + test("handles isAccessControlAllowed false", async () => { + vi.mocked(getAccessControlPermission).mockResolvedValue(false); vi.resetModules(); await vi.doMock("@/modules/ee/license-check/lib/license", () => ({ getEnterpriseLicense: vi.fn().mockResolvedValue({ @@ -416,7 +416,7 @@ describe("EnvironmentLayout", () => { }) ); - expect(screen.getByTestId("can-do-role-management")).toHaveTextContent("false"); + expect(screen.getByTestId("is-access-control-allowed")).toHaveTextContent("false"); }); test("throws error if user not found", async () => { diff --git a/apps/web/app/(app)/environments/[environmentId]/components/EnvironmentLayout.tsx b/apps/web/app/(app)/environments/[environmentId]/components/EnvironmentLayout.tsx index 90f71d8113..ef05273a80 100644 --- a/apps/web/app/(app)/environments/[environmentId]/components/EnvironmentLayout.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/components/EnvironmentLayout.tsx @@ -14,8 +14,8 @@ import { getUserProjects } from "@/lib/project/service"; import { getUser } from "@/lib/user/service"; import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license"; import { + getAccessControlPermission, getOrganizationProjectsLimit, - getRoleManagementPermission, } from "@/modules/ee/license-check/lib/utils"; import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles"; import { DevEnvironmentBanner } from "@/modules/ui/components/dev-environment-banner"; @@ -51,10 +51,10 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En throw new Error(t("common.environment_not_found")); } - const [projects, environments, canDoRoleManagement] = await Promise.all([ + const [projects, environments, isAccessControlAllowed] = await Promise.all([ getUserProjects(user.id, organization.id), getEnvironments(environment.projectId), - getRoleManagementPermission(organization.billing.plan), + getAccessControlPermission(organization.billing.plan), ]); if (!projects || !environments || !organizations) { @@ -121,7 +121,7 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En membershipRole={membershipRole} isMultiOrgEnabled={isMultiOrgEnabled} isLicenseActive={active} - canDoRoleManagement={canDoRoleManagement} + isAccessControlAllowed={isAccessControlAllowed} />
({ ProjectSwitcher: ({ isCollapsed, organizationTeams, - canDoRoleManagement, + isAccessControlAllowed, }: { isCollapsed: boolean; organizationTeams: TOrganizationTeam[]; - canDoRoleManagement: boolean; + isAccessControlAllowed: boolean; }) => (
Project Switcher
{organizationTeams?.length || 0}
-
{canDoRoleManagement.toString()}
+
{isAccessControlAllowed.toString()}
), })); @@ -157,7 +157,7 @@ const defaultProps = { membershipRole: "owner" as const, organizationProjectsLimit: 5, isLicenseActive: true, - canDoRoleManagement: true, + isAccessControlAllowed: true, }; describe("MainNavigation", () => { @@ -347,11 +347,11 @@ describe("MainNavigation", () => { expect(screen.queryByText("common.license")).not.toBeInTheDocument(); }); - test("passes canDoRoleManagement props to ProjectSwitcher", () => { + test("passes isAccessControlAllowed props to ProjectSwitcher", () => { render(); expect(screen.getByTestId("organization-teams-count")).toHaveTextContent("0"); - expect(screen.getByTestId("can-do-role-management")).toHaveTextContent("true"); + expect(screen.getByTestId("is-access-control-allowed")).toHaveTextContent("true"); }); test("handles no organizationTeams", () => { @@ -360,9 +360,9 @@ describe("MainNavigation", () => { expect(screen.getByTestId("organization-teams-count")).toHaveTextContent("0"); }); - test("handles canDoRoleManagement false", () => { - render(); + test("handles isAccessControlAllowed false", () => { + render(); - expect(screen.getByTestId("can-do-role-management")).toHaveTextContent("false"); + expect(screen.getByTestId("is-access-control-allowed")).toHaveTextContent("false"); }); }); diff --git a/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.tsx b/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.tsx index 003ecf3940..460035878d 100644 --- a/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.tsx @@ -66,7 +66,7 @@ interface NavigationProps { membershipRole?: TOrganizationRole; organizationProjectsLimit: number; isLicenseActive: boolean; - canDoRoleManagement: boolean; + isAccessControlAllowed: boolean; } export const MainNavigation = ({ @@ -81,7 +81,7 @@ export const MainNavigation = ({ organizationProjectsLimit, isLicenseActive, isDevelopment, - canDoRoleManagement, + isAccessControlAllowed, }: NavigationProps) => { const router = useRouter(); const pathname = usePathname(); @@ -325,7 +325,7 @@ export const MainNavigation = ({ isTextVisible={isTextVisible} organization={organization} organizationProjectsLimit={organizationProjectsLimit} - canDoRoleManagement={canDoRoleManagement} + isAccessControlAllowed={isAccessControlAllowed} /> )} diff --git a/apps/web/modules/ee/license-check/lib/license.test.ts b/apps/web/modules/ee/license-check/lib/license.test.ts index 0542d095ec..a61cb6d0fe 100644 --- a/apps/web/modules/ee/license-check/lib/license.test.ts +++ b/apps/web/modules/ee/license-check/lib/license.test.ts @@ -102,6 +102,8 @@ describe("License Core Logic", () => { spamProtection: true, ai: false, auditLogs: true, + multiLanguageSurveys: true, + accessControl: true, }; const mockFetchedLicenseDetails: TEnterpriseLicenseDetails = { status: "active", @@ -157,7 +159,6 @@ describe("License Core Logic", () => { active: true, features: mockFetchedLicenseDetails.features, lastChecked: expect.any(Date), - version: 1, }, expect.any(Number) ); @@ -231,9 +232,10 @@ describe("License Core Logic", () => { saml: false, spamProtection: false, auditLogs: false, + multiLanguageSurveys: false, + accessControl: false, }, lastChecked: expect.any(Date), - version: 1, }, expect.any(Number) ); @@ -251,6 +253,8 @@ describe("License Core Logic", () => { saml: false, spamProtection: false, auditLogs: false, + multiLanguageSurveys: false, + accessControl: false, }, lastChecked: expect.any(Date), isPendingDowngrade: false, @@ -278,6 +282,8 @@ describe("License Core Logic", () => { saml: false, spamProtection: false, auditLogs: false, + multiLanguageSurveys: false, + accessControl: false, }; expect(mockCache.set).toHaveBeenCalledWith( expect.stringContaining("fb:license:"), @@ -285,7 +291,6 @@ describe("License Core Logic", () => { active: false, features: expectedFeatures, lastChecked: expect.any(Date), - version: 1, }, expect.any(Number) ); diff --git a/apps/web/modules/ee/license-check/lib/license.ts b/apps/web/modules/ee/license-check/lib/license.ts index 71f27751bd..a46f96b64c 100644 --- a/apps/web/modules/ee/license-check/lib/license.ts +++ b/apps/web/modules/ee/license-check/lib/license.ts @@ -36,7 +36,6 @@ type TPreviousResult = { active: boolean; lastChecked: Date; features: TEnterpriseLicenseFeatures | null; - version: number; // For cache versioning }; // Validation schemas @@ -52,6 +51,8 @@ const LicenseFeaturesSchema = z.object({ saml: z.boolean(), spamProtection: z.boolean(), auditLogs: z.boolean(), + multiLanguageSurveys: z.boolean(), + accessControl: z.boolean(), }); const LicenseDetailsSchema = z.object({ @@ -112,6 +113,8 @@ const DEFAULT_FEATURES: TEnterpriseLicenseFeatures = { saml: false, spamProtection: false, auditLogs: false, + multiLanguageSurveys: false, + accessControl: false, }; // Helper functions @@ -137,7 +140,6 @@ const getPreviousResult = async (): Promise => { active: false, lastChecked: new Date(0), features: DEFAULT_FEATURES, - version: 1, }; } @@ -158,7 +160,6 @@ const getPreviousResult = async (): Promise => { active: false, lastChecked: new Date(0), features: DEFAULT_FEATURES, - version: 1, }; }; @@ -197,7 +198,6 @@ const trackApiError = (error: LicenseApiError) => { 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; }; @@ -224,7 +224,6 @@ const handleInitialFailure = async (currentTime: Date) => { active: false, features: DEFAULT_FEATURES, lastChecked: currentTime, - version: 1, }; await setPreviousResult(initialFailResult); return { @@ -370,7 +369,6 @@ export const getEnterpriseLicense = reactCache( active: liveLicenseDetails.status === "active", features: liveLicenseDetails.features, lastChecked: currentTime, - version: 1, }; await setPreviousResult(currentLicenseState); return { diff --git a/apps/web/modules/ee/license-check/lib/utils.test.ts b/apps/web/modules/ee/license-check/lib/utils.test.ts index a278af08a2..2328ff0e39 100644 --- a/apps/web/modules/ee/license-check/lib/utils.test.ts +++ b/apps/web/modules/ee/license-check/lib/utils.test.ts @@ -4,6 +4,7 @@ import { Organization } from "@prisma/client"; import { beforeEach, describe, expect, test, vi } from "vitest"; import * as licenseModule from "./license"; import { + getAccessControlPermission, getBiggerUploadFileSizePermission, getIsContactsEnabled, getIsMultiOrgEnabled, @@ -14,7 +15,6 @@ import { getMultiLanguagePermission, getOrganizationProjectsLimit, getRemoveBrandingPermission, - getRoleManagementPermission, getWhiteLabelPermission, } from "./utils"; @@ -46,6 +46,8 @@ const defaultFeatures: TEnterpriseLicenseFeatures = { spamProtection: false, ai: false, auditLogs: false, + multiLanguageSurveys: false, + accessControl: false, }; const defaultLicense = { @@ -141,41 +143,59 @@ describe("License Utils", () => { }); }); - describe("getRoleManagementPermission", () => { - test("should return true if license active (self-hosted)", async () => { + describe("getAccessControlPermission", () => { + test("should return true if license active and accessControl feature enabled (self-hosted)", async () => { vi.mocked(constants).IS_FORMBRICKS_CLOUD = false; - vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense); - const result = await getRoleManagementPermission(mockOrganization.billing.plan); + vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({ + ...defaultLicense, + features: { ...defaultFeatures, accessControl: true }, + }); + const result = await getAccessControlPermission(mockOrganization.billing.plan); expect(result).toBe(true); }); - test("should return true if license active and plan is SCALE (cloud)", async () => { + test("should return true if license active, accessControl enabled 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); + vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({ + ...defaultLicense, + features: { ...defaultFeatures, accessControl: true }, + }); + const result = await getAccessControlPermission(constants.PROJECT_FEATURE_KEYS.SCALE); expect(result).toBe(true); }); - test("should return true if license active and plan is ENTERPRISE (cloud)", async () => { + test("should return true if license active, accessControl enabled 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); + vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({ + ...defaultLicense, + features: { ...defaultFeatures, accessControl: true }, + }); + const result = await getAccessControlPermission(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 () => { + test("should return false if license active, accessControl enabled but 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); + vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({ + ...defaultLicense, + features: { ...defaultFeatures, accessControl: true }, + }); + const result = await getAccessControlPermission(constants.PROJECT_FEATURE_KEYS.STARTUP); expect(result).toBe(false); }); + test("should return true if license active but accessControl feature disabled because of fallback", async () => { + vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense); + const result = await getAccessControlPermission(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 getRoleManagementPermission(mockOrganization.billing.plan); + const result = await getAccessControlPermission(mockOrganization.billing.plan); expect(result).toBe(false); }); }); @@ -213,20 +233,52 @@ describe("License Utils", () => { }); describe("getMultiLanguagePermission", () => { - test("should return true if license active (self-hosted)", async () => { + test("should return true if license active and multiLanguageSurveys feature enabled (self-hosted)", async () => { vi.mocked(constants).IS_FORMBRICKS_CLOUD = false; - vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense); + vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({ + ...defaultLicense, + features: { ...defaultFeatures, multiLanguageSurveys: true }, + }); const result = await getMultiLanguagePermission(mockOrganization.billing.plan); expect(result).toBe(true); }); - test("should return true if license active and plan is SCALE (cloud)", async () => { + test("should return true if license active, multiLanguageSurveys enabled and plan is SCALE (cloud)", async () => { vi.mocked(constants).IS_FORMBRICKS_CLOUD = true; - vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense); + vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({ + ...defaultLicense, + features: { ...defaultFeatures, multiLanguageSurveys: true }, + }); const result = await getMultiLanguagePermission(constants.PROJECT_FEATURE_KEYS.SCALE); expect(result).toBe(true); }); + test("should return true if license active, multiLanguageSurveys enabled and plan is ENTERPRISE (cloud)", async () => { + vi.mocked(constants).IS_FORMBRICKS_CLOUD = true; + vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({ + ...defaultLicense, + features: { ...defaultFeatures, multiLanguageSurveys: true }, + }); + const result = await getMultiLanguagePermission(constants.PROJECT_FEATURE_KEYS.ENTERPRISE); + expect(result).toBe(true); + }); + + test("should return false if license active, multiLanguageSurveys 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, multiLanguageSurveys: true }, + }); + const result = await getMultiLanguagePermission(constants.PROJECT_FEATURE_KEYS.STARTUP); + expect(result).toBe(false); + }); + + test("should return true if license active but multiLanguageSurveys feature disabled because of fallback", async () => { + vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense); + const result = await getMultiLanguagePermission(mockOrganization.billing.plan); + expect(result).toBe(true); + }); + test("should return false if license is inactive", async () => { vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({ ...defaultLicense, diff --git a/apps/web/modules/ee/license-check/lib/utils.ts b/apps/web/modules/ee/license-check/lib/utils.ts index 7321b2900f..bc29c9ad2a 100644 --- a/apps/web/modules/ee/license-check/lib/utils.ts +++ b/apps/web/modules/ee/license-check/lib/utils.ts @@ -35,20 +35,6 @@ export const getWhiteLabelPermission = async ( return getFeaturePermission(billingPlan, "whitelabel"); }; -export const getRoleManagementPermission = async ( - billingPlan: Organization["billing"]["plan"] -): Promise => { - const license = await getEnterpriseLicense(); - - if (IS_FORMBRICKS_CLOUD) - return ( - license.active && - (billingPlan === PROJECT_FEATURE_KEYS.SCALE || billingPlan === PROJECT_FEATURE_KEYS.ENTERPRISE) - ); - else if (!IS_FORMBRICKS_CLOUD) return license.active; - return false; -}; - export const getBiggerUploadFileSizePermission = async ( billingPlan: Organization["billing"]["plan"] ): Promise => { @@ -59,25 +45,16 @@ export const getBiggerUploadFileSizePermission = async ( return false; }; -export const getMultiLanguagePermission = async ( - billingPlan: Organization["billing"]["plan"] -): Promise => { - const license = await getEnterpriseLicense(); - - if (IS_FORMBRICKS_CLOUD) - return ( - license.active && - (billingPlan === PROJECT_FEATURE_KEYS.SCALE || billingPlan === PROJECT_FEATURE_KEYS.ENTERPRISE) - ); - else if (!IS_FORMBRICKS_CLOUD) return license.active; - return false; -}; - -// Helper function for simple boolean feature flags const getSpecificFeatureFlag = async ( featureKey: keyof Pick< TEnterpriseLicenseFeatures, - "isMultiOrgEnabled" | "contacts" | "twoFactorAuth" | "sso" | "auditLogs" + | "isMultiOrgEnabled" + | "contacts" + | "twoFactorAuth" + | "sso" + | "auditLogs" + | "multiLanguageSurveys" + | "accessControl" > ): Promise => { const licenseFeatures = await getLicenseFeatures(); @@ -133,6 +110,39 @@ export const getIsSpamProtectionEnabled = async ( return license.active && !!license.features?.spamProtection; }; +const featureFlagFallback = async (billingPlan: Organization["billing"]["plan"]): Promise => { + const license = await getEnterpriseLicense(); + if (IS_FORMBRICKS_CLOUD) + return ( + license.active && + (billingPlan === PROJECT_FEATURE_KEYS.SCALE || billingPlan === PROJECT_FEATURE_KEYS.ENTERPRISE) + ); + else if (!IS_FORMBRICKS_CLOUD) return license.active; + return false; +}; + +export const getMultiLanguagePermission = async ( + billingPlan: Organization["billing"]["plan"] +): Promise => { + const isEnabled = await getSpecificFeatureFlag("multiLanguageSurveys"); + // If the feature is enabled in the license, return true + if (isEnabled) return true; + + // If the feature is not enabled in the license, check the fallback(Backwards compatibility) + return featureFlagFallback(billingPlan); +}; + +export const getAccessControlPermission = async ( + billingPlan: Organization["billing"]["plan"] +): Promise => { + const isEnabled = await getSpecificFeatureFlag("accessControl"); + // If the feature is enabled in the license, return true + if (isEnabled) return true; + + // If the feature is not enabled in the license, check the fallback(Backwards compatibility) + return featureFlagFallback(billingPlan); +}; + export const getOrganizationProjectsLimit = async ( limits: Organization["billing"]["limits"] ): Promise => { diff --git a/apps/web/modules/ee/license-check/types/enterprise-license.ts b/apps/web/modules/ee/license-check/types/enterprise-license.ts index a8bf2e787c..50519c8459 100644 --- a/apps/web/modules/ee/license-check/types/enterprise-license.ts +++ b/apps/web/modules/ee/license-check/types/enterprise-license.ts @@ -16,6 +16,8 @@ const ZEnterpriseLicenseFeatures = z.object({ spamProtection: z.boolean(), ai: z.boolean(), auditLogs: z.boolean(), + multiLanguageSurveys: z.boolean(), + accessControl: z.boolean(), }); export type TEnterpriseLicenseFeatures = z.infer; diff --git a/apps/web/modules/ee/role-management/actions.ts b/apps/web/modules/ee/role-management/actions.ts index fc43978aa3..7fb48df0d5 100644 --- a/apps/web/modules/ee/role-management/actions.ts +++ b/apps/web/modules/ee/role-management/actions.ts @@ -8,7 +8,7 @@ import { authenticatedActionClient } from "@/lib/utils/action-client"; import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; -import { getRoleManagementPermission } from "@/modules/ee/license-check/lib/utils"; +import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils"; import { updateInvite } from "@/modules/ee/role-management/lib/invite"; import { updateMembership } from "@/modules/ee/role-management/lib/membership"; import { ZInviteUpdateInput } from "@/modules/ee/role-management/types/invites"; @@ -24,8 +24,8 @@ export const checkRoleManagementPermission = async (organizationId: string) => { throw new Error("Organization not found"); } - const isRoleManagementAllowed = await getRoleManagementPermission(organization.billing.plan); - if (!isRoleManagementAllowed) { + const isAccessControlAllowed = await getAccessControlPermission(organization.billing.plan); + if (!isAccessControlAllowed) { throw new OperationNotAllowedError("Role management is not allowed for this organization"); } }; diff --git a/apps/web/modules/ee/role-management/components/add-member-role.tsx b/apps/web/modules/ee/role-management/components/add-member-role.tsx index c9038f6259..ec5ec48f2e 100644 --- a/apps/web/modules/ee/role-management/components/add-member-role.tsx +++ b/apps/web/modules/ee/role-management/components/add-member-role.tsx @@ -18,14 +18,14 @@ import { TOrganizationRole } from "@formbricks/types/memberships"; interface AddMemberRoleProps { control: Control<{ name: string; email: string; role: TOrganizationRole; teamIds: string[] }>; - canDoRoleManagement: boolean; + isAccessControlAllowed: boolean; isFormbricksCloud: boolean; membershipRole?: TOrganizationRole; } export function AddMemberRole({ control, - canDoRoleManagement, + isAccessControlAllowed, isFormbricksCloud, membershipRole, }: AddMemberRoleProps) { @@ -62,8 +62,8 @@ export function AddMemberRole({