feat: adds multiLanguageSurveys and accessControl license features (#6331)

This commit is contained in:
Piyush Gupta
2025-08-06 20:05:28 +05:30
committed by GitHub
parent 282b3e070c
commit f239ee9697
39 changed files with 279 additions and 204 deletions

View File

@@ -62,7 +62,7 @@ describe("ProjectSettings component", () => {
industry: "ind",
defaultBrandColor: "#fff",
organizationTeams: [],
canDoRoleManagement: false,
isAccessControlAllowed: false,
userProjectsCount: 0,
} as any;

View File

@@ -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 && (
<FormField
control={form.control}
name="teamIds"

View File

@@ -1,6 +1,6 @@
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
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 "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
@@ -12,7 +12,7 @@ vi.mock("@/lib/constants", () => ({ 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);

View File

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

View File

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

View File

@@ -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) => (
<div data-testid="main-navigation">
MainNavigation
<div data-testid="organization-teams">{JSON.stringify(organizationTeams || [])}</div>
<div data-testid="can-do-role-management">{canDoRoleManagement?.toString() || "false"}</div>
<div data-testid="is-access-control-allowed">{isAccessControlAllowed?.toString() || "false"}</div>
</div>
),
}));
@@ -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 () => {

View File

@@ -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}
/>
<div id="mainContent" className="flex-1 overflow-y-auto bg-slate-50">
<TopControlBar

View File

@@ -56,16 +56,16 @@ vi.mock("@/modules/projects/components/project-switcher", () => ({
ProjectSwitcher: ({
isCollapsed,
organizationTeams,
canDoRoleManagement,
isAccessControlAllowed,
}: {
isCollapsed: boolean;
organizationTeams: TOrganizationTeam[];
canDoRoleManagement: boolean;
isAccessControlAllowed: boolean;
}) => (
<div data-testid="project-switcher" data-collapsed={isCollapsed}>
Project Switcher
<div data-testid="organization-teams-count">{organizationTeams?.length || 0}</div>
<div data-testid="can-do-role-management">{canDoRoleManagement.toString()}</div>
<div data-testid="is-access-control-allowed">{isAccessControlAllowed.toString()}</div>
</div>
),
}));
@@ -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(<MainNavigation {...defaultProps} />);
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(<MainNavigation {...defaultProps} canDoRoleManagement={false} />);
test("handles isAccessControlAllowed false", () => {
render(<MainNavigation {...defaultProps} isAccessControlAllowed={false} />);
expect(screen.getByTestId("can-do-role-management")).toHaveTextContent("false");
expect(screen.getByTestId("is-access-control-allowed")).toHaveTextContent("false");
});
});

View File

@@ -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}
/>
)}

View File

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

View File

@@ -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<TPreviousResult> => {
active: false,
lastChecked: new Date(0),
features: DEFAULT_FEATURES,
version: 1,
};
}
@@ -158,7 +160,6 @@ const getPreviousResult = async (): Promise<TPreviousResult> => {
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 {

View File

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

View File

@@ -35,20 +35,6 @@ export const getWhiteLabelPermission = async (
return getFeaturePermission(billingPlan, "whitelabel");
};
export const getRoleManagementPermission = async (
billingPlan: Organization["billing"]["plan"]
): Promise<boolean> => {
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<boolean> => {
@@ -59,25 +45,16 @@ export const getBiggerUploadFileSizePermission = async (
return false;
};
export const getMultiLanguagePermission = async (
billingPlan: Organization["billing"]["plan"]
): Promise<boolean> => {
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<boolean> => {
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<boolean> => {
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<boolean> => {
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<boolean> => {
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<number> => {

View File

@@ -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<typeof ZEnterpriseLicenseFeatures>;

View File

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

View File

@@ -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({
<div className="flex flex-col space-y-2">
<Label>{t("common.role_organization")}</Label>
<Select
defaultValue={canDoRoleManagement ? "member" : "owner"}
disabled={!canDoRoleManagement}
defaultValue={isAccessControlAllowed ? "member" : "owner"}
disabled={!isAccessControlAllowed}
onValueChange={(v) => {
onChange(v as TOrganizationRole);
}}

View File

@@ -11,14 +11,20 @@ vi.mock("@tolgee/react", () => ({
}));
// Create a wrapper component that provides the form context
const FormWrapper = ({ children, defaultValues, membershipRole, canDoRoleManagement, isFormbricksCloud }) => {
const FormWrapper = ({
children,
defaultValues,
membershipRole,
isAccessControlAllowed,
isFormbricksCloud,
}) => {
const methods = useForm({ defaultValues });
return (
<FormProvider {...methods}>
<AddMemberRole
control={methods.control}
membershipRole={membershipRole}
canDoRoleManagement={canDoRoleManagement}
isAccessControlAllowed={isAccessControlAllowed}
isFormbricksCloud={isFormbricksCloud}
/>
{children}
@@ -44,7 +50,7 @@ describe("AddMemberRole Component", () => {
<FormWrapper
defaultValues={defaultValues}
membershipRole="owner"
canDoRoleManagement={true}
isAccessControlAllowed={true}
isFormbricksCloud={true}>
<div />
</FormWrapper>
@@ -59,7 +65,7 @@ describe("AddMemberRole Component", () => {
<FormWrapper
defaultValues={defaultValues}
membershipRole="member"
canDoRoleManagement={true}
isAccessControlAllowed={true}
isFormbricksCloud={true}>
<div data-testid="child" />
</FormWrapper>
@@ -69,12 +75,12 @@ describe("AddMemberRole Component", () => {
expect(screen.getByTestId("child")).toBeInTheDocument();
});
test("disables the role selector when canDoRoleManagement is false", () => {
test("disables the role selector when isAccessControlAllowed is false", () => {
render(
<FormWrapper
defaultValues={defaultValues}
membershipRole="owner"
canDoRoleManagement={false}
isAccessControlAllowed={false}
isFormbricksCloud={true}>
<div />
</FormWrapper>
@@ -91,7 +97,7 @@ describe("AddMemberRole Component", () => {
<FormWrapper
defaultValues={defaultValues}
membershipRole="owner"
canDoRoleManagement={true}
isAccessControlAllowed={true}
isFormbricksCloud={true}>
<div />
</FormWrapper>

View File

@@ -10,10 +10,10 @@ import { createUser, getUserByEmail, updateUser } from "@/modules/auth/lib/user"
import { getIsValidInviteToken } from "@/modules/auth/signup/lib/invite";
import { TOidcNameFields, TSamlNameFields } from "@/modules/auth/types/auth";
import {
getAccessControlPermission,
getIsMultiOrgEnabled,
getIsSamlSsoEnabled,
getIsSsoEnabled,
getRoleManagementPermission,
} from "@/modules/ee/license-check/lib/utils";
import { getFirstOrganization } from "@/modules/ee/sso/lib/organization";
import { createDefaultTeamMembership, getOrganizationByTeamId } from "@/modules/ee/sso/lib/team";
@@ -305,13 +305,13 @@ export const handleSsoCallback = async ({
return false;
}
const canDoRoleManagement = await getRoleManagementPermission(organization.billing.plan);
if (!canDoRoleManagement && !callbackUrl) {
const isAccessControlAllowed = await getAccessControlPermission(organization.billing.plan);
if (!isAccessControlAllowed && !callbackUrl) {
contextLogger.debug(
{
reason: "insufficient_role_permissions",
organizationId: organization.id,
canDoRoleManagement,
isAccessControlAllowed,
},
"SSO callback rejected: insufficient role management permissions"
);

View File

@@ -5,10 +5,10 @@ import { createBrevoCustomer } from "@/modules/auth/lib/brevo";
import { createUser, getUserByEmail, updateUser } from "@/modules/auth/lib/user";
import type { TSamlNameFields } from "@/modules/auth/types/auth";
import {
getAccessControlPermission,
getIsMultiOrgEnabled,
getIsSamlSsoEnabled,
getIsSsoEnabled,
getRoleManagementPermission,
} from "@/modules/ee/license-check/lib/utils";
import { createDefaultTeamMembership, getOrganizationByTeamId } from "@/modules/ee/sso/lib/team";
import { beforeEach, describe, expect, test, vi } from "vitest";
@@ -43,7 +43,7 @@ vi.mock("@/modules/auth/signup/lib/invite", () => ({
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getIsSamlSsoEnabled: vi.fn(),
getIsSsoEnabled: vi.fn(),
getRoleManagementPermission: vi.fn(),
getAccessControlPermission: vi.fn(),
getIsMultiOrgEnabled: vi.fn(),
}));
@@ -310,7 +310,7 @@ describe("handleSsoCallback", () => {
});
expect(result).toBe(true);
expect(getRoleManagementPermission).not.toHaveBeenCalled();
expect(getAccessControlPermission).not.toHaveBeenCalled();
});
test("should return true when organization exists but role management is not enabled", async () => {
@@ -318,7 +318,7 @@ describe("handleSsoCallback", () => {
vi.mocked(getUserByEmail).mockResolvedValue(null);
vi.mocked(createUser).mockResolvedValue(mockCreatedUser());
vi.mocked(getOrganizationByTeamId).mockResolvedValue(mockOrganization);
vi.mocked(getRoleManagementPermission).mockResolvedValue(false);
vi.mocked(getAccessControlPermission).mockResolvedValue(false);
const result = await handleSsoCallback({
user: mockUser,

View File

@@ -12,7 +12,7 @@ interface TeamsViewProps {
organizationId: string;
membershipRole?: TOrganizationRole;
currentUserId: string;
canDoRoleManagement: boolean;
isAccessControlAllowed: boolean;
environmentId: string;
}
@@ -20,7 +20,7 @@ export const TeamsView = async ({
organizationId,
membershipRole,
currentUserId,
canDoRoleManagement,
isAccessControlAllowed,
environmentId,
}: TeamsViewProps) => {
const t = await getTranslate();
@@ -52,7 +52,7 @@ export const TeamsView = async ({
<SettingsCard
title={t("environments.settings.teams.teams")}
description={t("environments.settings.teams.teams_description")}>
{canDoRoleManagement ? (
{isAccessControlAllowed ? (
<TeamsTable
teams={teams}
membershipRole={membershipRole}

View File

@@ -67,7 +67,7 @@ describe("EditMemberships", () => {
organization: mockOrg,
currentUserId: "user-1",
role: "owner",
canDoRoleManagement: true,
isAccessControlAllowed: true,
isUserManagementDisabledFromUi: false,
});
render(ui);
@@ -81,18 +81,18 @@ describe("EditMemberships", () => {
expect(props.organization.id).toBe("org-1");
expect(props.currentUserId).toBe("user-1");
expect(props.currentUserRole).toBe("owner");
expect(props.canDoRoleManagement).toBe(true);
expect(props.isAccessControlAllowed).toBe(true);
expect(props.isUserManagementDisabledFromUi).toBe(false);
expect(Array.isArray(props.invites)).toBe(true);
expect(Array.isArray(props.members)).toBe(true);
});
test("does not render role/actions columns if canDoRoleManagement or isUserManagementDisabledFromUi is false", async () => {
test("does not render role/actions columns if isAccessControlAllowed or isUserManagementDisabledFromUi is false", async () => {
const ui = await EditMemberships({
organization: mockOrg,
currentUserId: "user-1",
role: "member",
canDoRoleManagement: false,
isAccessControlAllowed: false,
isUserManagementDisabledFromUi: true,
});
render(ui);
@@ -109,7 +109,7 @@ describe("EditMemberships", () => {
organization: mockOrg,
currentUserId: "user-1",
role: undefined as any,
canDoRoleManagement: true,
isAccessControlAllowed: true,
isUserManagementDisabledFromUi: false,
});
render(ui);

View File

@@ -10,7 +10,7 @@ interface EditMembershipsProps {
organization: TOrganization;
currentUserId: string;
role: TOrganizationRole;
canDoRoleManagement: boolean;
isAccessControlAllowed: boolean;
isUserManagementDisabledFromUi: boolean;
}
@@ -18,7 +18,7 @@ export const EditMemberships = async ({
organization,
currentUserId,
role,
canDoRoleManagement,
isAccessControlAllowed,
isUserManagementDisabledFromUi,
}: EditMembershipsProps) => {
const members = await getMembershipByOrganizationId(organization.id);
@@ -32,7 +32,9 @@ export const EditMemberships = async ({
<div className="w-1/2 overflow-hidden">{t("common.full_name")}</div>
<div className="w-1/2 overflow-hidden">{t("common.email")}</div>
{canDoRoleManagement && <div className="min-w-[100px] whitespace-nowrap">{t("common.role")}</div>}
{isAccessControlAllowed && (
<div className="min-w-[100px] whitespace-nowrap">{t("common.role")}</div>
)}
<div className="min-w-[80px] whitespace-nowrap">{t("common.status")}</div>
@@ -48,7 +50,7 @@ export const EditMemberships = async ({
invites={invites ?? []}
members={members ?? []}
currentUserRole={role}
canDoRoleManagement={canDoRoleManagement}
isAccessControlAllowed={isAccessControlAllowed}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
isUserManagementDisabledFromUi={isUserManagementDisabledFromUi}
/>

View File

@@ -77,7 +77,7 @@ describe("MembersInfo", () => {
cleanup();
});
test("renders member info and EditMembershipRole when canDoRoleManagement", () => {
test("renders member info and EditMembershipRole when isAccessControlAllowed", () => {
render(
<MembersInfo
organization={org}
@@ -85,7 +85,7 @@ describe("MembersInfo", () => {
invites={[]}
currentUserRole="owner"
currentUserId="user-1"
canDoRoleManagement={true}
isAccessControlAllowed={true}
isFormbricksCloud={true}
isUserManagementDisabledFromUi={false}
/>
@@ -105,7 +105,7 @@ describe("MembersInfo", () => {
invites={[]}
currentUserRole="owner"
currentUserId="user-1"
canDoRoleManagement={true}
isAccessControlAllowed={true}
isFormbricksCloud={true}
isUserManagementDisabledFromUi={false}
/>
@@ -121,7 +121,7 @@ describe("MembersInfo", () => {
invites={[invite]}
currentUserRole="owner"
currentUserId="user-1"
canDoRoleManagement={true}
isAccessControlAllowed={true}
isFormbricksCloud={true}
isUserManagementDisabledFromUi={false}
/>
@@ -139,7 +139,7 @@ describe("MembersInfo", () => {
invites={[invite]}
currentUserRole="owner"
currentUserId="user-1"
canDoRoleManagement={true}
isAccessControlAllowed={true}
isFormbricksCloud={true}
isUserManagementDisabledFromUi={false}
/>
@@ -147,7 +147,7 @@ describe("MembersInfo", () => {
expect(screen.getByTestId("expired-badge")).toHaveTextContent("Expired");
});
test("does not render EditMembershipRole if canDoRoleManagement is false", () => {
test("does not render EditMembershipRole if isAccessControlAllowed is false", () => {
render(
<MembersInfo
organization={org}
@@ -155,7 +155,7 @@ describe("MembersInfo", () => {
invites={[]}
currentUserRole="owner"
currentUserId="user-1"
canDoRoleManagement={false}
isAccessControlAllowed={false}
isFormbricksCloud={true}
isUserManagementDisabledFromUi={false}
/>
@@ -171,7 +171,7 @@ describe("MembersInfo", () => {
invites={[]}
currentUserRole="owner"
currentUserId="user-1"
canDoRoleManagement={true}
isAccessControlAllowed={true}
isFormbricksCloud={true}
isUserManagementDisabledFromUi={true}
/>
@@ -193,7 +193,7 @@ describe("MembersInfo", () => {
invites={[invite]}
currentUserRole="owner"
currentUserId="user-1"
canDoRoleManagement={true}
isAccessControlAllowed={true}
isFormbricksCloud={true}
isUserManagementDisabledFromUi={false}
/>

View File

@@ -18,7 +18,7 @@ interface MembersInfoProps {
invites: TInvite[];
currentUserRole: TOrganizationRole;
currentUserId: string;
canDoRoleManagement: boolean;
isAccessControlAllowed: boolean;
isFormbricksCloud: boolean;
isUserManagementDisabledFromUi: boolean;
}
@@ -34,7 +34,7 @@ export const MembersInfo = ({
currentUserRole,
members,
currentUserId,
canDoRoleManagement,
isAccessControlAllowed,
isFormbricksCloud,
isUserManagementDisabledFromUi,
}: MembersInfoProps) => {
@@ -105,7 +105,7 @@ export const MembersInfo = ({
<p className="w-full truncate"> {member.email}</p>
</div>
{canDoRoleManagement && allMembers?.length > 0 && (
{isAccessControlAllowed && allMembers?.length > 0 && (
<div className="ph-no-capture min-w-[100px]">
<EditMembershipRole
currentUserRole={currentUserRole}

View File

@@ -123,7 +123,7 @@ describe("OrganizationActions Component", () => {
organization: { id: "org-123", name: "Test Org" } as TOrganization,
teams: [{ id: "team-1", name: "Team 1" }],
isInviteDisabled: false,
canDoRoleManagement: true,
isAccessControlAllowed: true,
isFormbricksCloud: false,
environmentId: "env-123",
isMultiOrgEnabled: true,
@@ -310,7 +310,7 @@ describe("OrganizationActions Component", () => {
expect
.objectContaining({
environmentId: "env-123",
canDoRoleManagement: true,
isAccessControlAllowed: true,
isFormbricksCloud: false,
teams: expect.arrayContaining(defaultProps.teams),
membershipRole: "owner",

View File

@@ -31,7 +31,7 @@ interface OrganizationActionsProps {
organization: TOrganization;
teams: TOrganizationTeam[];
isInviteDisabled: boolean;
canDoRoleManagement: boolean;
isAccessControlAllowed: boolean;
isFormbricksCloud: boolean;
environmentId: string;
isMultiOrgEnabled: boolean;
@@ -45,7 +45,7 @@ export const OrganizationActions = ({
teams,
isLeaveOrganizationDisabled,
isInviteDisabled,
canDoRoleManagement,
isAccessControlAllowed,
isFormbricksCloud,
environmentId,
isMultiOrgEnabled,
@@ -154,7 +154,7 @@ export const OrganizationActions = ({
setOpen={setInviteMemberModalOpen}
onSubmit={handleAddMembers}
membershipRole={membershipRole}
canDoRoleManagement={canDoRoleManagement}
isAccessControlAllowed={isAccessControlAllowed}
isFormbricksCloud={isFormbricksCloud}
environmentId={environmentId}
teams={teams}

View File

@@ -15,14 +15,14 @@ import { TOrganizationRole } from "@formbricks/types/memberships";
interface BulkInviteTabProps {
setOpen: (v: boolean) => void;
onSubmit: (data: { name: string; email: string; role: TOrganizationRole }[]) => void;
canDoRoleManagement: boolean;
isAccessControlAllowed: boolean;
isFormbricksCloud: boolean;
}
export const BulkInviteTab = ({
setOpen,
onSubmit,
canDoRoleManagement,
isAccessControlAllowed,
isFormbricksCloud,
}: BulkInviteTabProps) => {
const { t } = useTranslate();
@@ -48,7 +48,7 @@ export const BulkInviteTab = ({
},
complete: (results: ParseResult<{ name: string; email: string; role: string }>) => {
const members = results.data.map((csv) => {
let orgRole = canDoRoleManagement ? csv.role.trim().toLowerCase() : "owner";
let orgRole = isAccessControlAllowed ? csv.role.trim().toLowerCase() : "owner";
if (!isFormbricksCloud) {
orgRole = orgRole === "billing" ? "owner" : orgRole;
}
@@ -119,7 +119,7 @@ export const BulkInviteTab = ({
</div>
)}
{!canDoRoleManagement && (
{!isAccessControlAllowed && (
<Alert variant="default" className="mt-1.5 flex items-start bg-slate-50">
<AlertDescription className="ml-2">
<p className="text-sm">

View File

@@ -35,7 +35,7 @@ const defaultProps = {
{ id: "team-1", name: "Team 1" },
{ id: "team-2", name: "Team 2" },
],
canDoRoleManagement: true,
isAccessControlAllowed: true,
isFormbricksCloud: true,
environmentId: "env-1",
membershipRole: "owner" as TOrganizationRole,
@@ -85,21 +85,21 @@ describe("IndividualInviteTab", () => {
});
test("shows member role info alert when role is member", async () => {
render(<IndividualInviteTab {...defaultProps} canDoRoleManagement={true} />);
render(<IndividualInviteTab {...defaultProps} isAccessControlAllowed={true} />);
await userEvent.type(screen.getByLabelText("common.full_name"), "Test User");
await userEvent.type(screen.getByLabelText("common.email"), "test@example.com");
// Simulate selecting member role
// Not needed as default is member if canDoRoleManagement is true
// Not needed as default is member if isAccessControlAllowed is true
expect(screen.getByText("environments.settings.teams.member_role_info_message")).toBeInTheDocument();
});
test("shows team select when canDoRoleManagement is true", () => {
render(<IndividualInviteTab {...defaultProps} canDoRoleManagement={true} />);
test("shows team select when isAccessControlAllowed is true", () => {
render(<IndividualInviteTab {...defaultProps} isAccessControlAllowed={true} />);
expect(screen.getByTestId("multi-select")).toBeInTheDocument();
});
test("shows upgrade alert when canDoRoleManagement is false", () => {
render(<IndividualInviteTab {...defaultProps} canDoRoleManagement={false} />);
test("shows upgrade alert when isAccessControlAllowed is false", () => {
render(<IndividualInviteTab {...defaultProps} isAccessControlAllowed={false} />);
expect(screen.getByText("environments.settings.teams.upgrade_plan_notice_message")).toBeInTheDocument();
expect(screen.getByText("common.start_free_trial")).toBeInTheDocument();
});

View File

@@ -23,7 +23,7 @@ interface IndividualInviteTabProps {
setOpen: (v: boolean) => void;
onSubmit: (data: { name: string; email: string; role: TOrganizationRole }[]) => void;
teams: TOrganizationTeam[];
canDoRoleManagement: boolean;
isAccessControlAllowed: boolean;
isFormbricksCloud: boolean;
environmentId: string;
membershipRole?: TOrganizationRole;
@@ -33,7 +33,7 @@ export const IndividualInviteTab = ({
setOpen,
onSubmit,
teams,
canDoRoleManagement,
isAccessControlAllowed,
isFormbricksCloud,
environmentId,
membershipRole,
@@ -52,7 +52,7 @@ export const IndividualInviteTab = ({
const form = useForm<TFormData>({
resolver: zodResolver(ZFormSchema),
defaultValues: {
role: canDoRoleManagement ? "member" : "owner",
role: isAccessControlAllowed ? "member" : "owner",
teamIds: [],
},
});
@@ -106,7 +106,7 @@ export const IndividualInviteTab = ({
<div>
<AddMemberRole
control={control}
canDoRoleManagement={canDoRoleManagement}
isAccessControlAllowed={isAccessControlAllowed}
isFormbricksCloud={isFormbricksCloud}
membershipRole={membershipRole}
/>
@@ -117,7 +117,7 @@ export const IndividualInviteTab = ({
)}
</div>
{canDoRoleManagement && (
{isAccessControlAllowed && (
<FormField
control={control}
name="teamIds"
@@ -143,7 +143,7 @@ export const IndividualInviteTab = ({
/>
)}
{!canDoRoleManagement && (
{!isAccessControlAllowed && (
<Alert>
<AlertDescription className="flex">
{t("environments.settings.teams.upgrade_plan_notice_message")}

View File

@@ -55,7 +55,7 @@ const defaultProps = {
setOpen: vi.fn(),
onSubmit: vi.fn(),
teams: [],
canDoRoleManagement: true,
isAccessControlAllowed: true,
isFormbricksCloud: true,
environmentId: "env-1",
membershipRole: "owner" as TOrganizationRole,

View File

@@ -21,7 +21,7 @@ interface InviteMemberModalProps {
setOpen: (v: boolean) => void;
onSubmit: (data: { name: string; email: string; role: TOrganizationRole }[]) => void;
teams: TOrganizationTeam[];
canDoRoleManagement: boolean;
isAccessControlAllowed: boolean;
isFormbricksCloud: boolean;
environmentId: string;
membershipRole?: TOrganizationRole;
@@ -32,7 +32,7 @@ export const InviteMemberModal = ({
setOpen,
onSubmit,
teams,
canDoRoleManagement,
isAccessControlAllowed,
isFormbricksCloud,
environmentId,
membershipRole,
@@ -47,7 +47,7 @@ export const InviteMemberModal = ({
setOpen={setOpen}
environmentId={environmentId}
onSubmit={onSubmit}
canDoRoleManagement={canDoRoleManagement}
isAccessControlAllowed={isAccessControlAllowed}
isFormbricksCloud={isFormbricksCloud}
teams={teams}
membershipRole={membershipRole}
@@ -57,7 +57,7 @@ export const InviteMemberModal = ({
<BulkInviteTab
setOpen={setOpen}
onSubmit={onSubmit}
canDoRoleManagement={canDoRoleManagement}
isAccessControlAllowed={isAccessControlAllowed}
isFormbricksCloud={isFormbricksCloud}
/>
),

View File

@@ -56,7 +56,7 @@ describe("MembersView", () => {
organization: { id: "org-1", name: "Test Org" },
currentUserId: "user-1",
environmentId: "env-1",
canDoRoleManagement: true,
isAccessControlAllowed: true,
isUserManagementDisabledFromUi: false,
} as any;

View File

@@ -16,7 +16,7 @@ interface MembersViewProps {
organization: TOrganization;
currentUserId: string;
environmentId: string;
canDoRoleManagement: boolean;
isAccessControlAllowed: boolean;
isUserManagementDisabledFromUi: boolean;
}
@@ -35,7 +35,7 @@ export const MembersView = async ({
organization,
currentUserId,
environmentId,
canDoRoleManagement,
isAccessControlAllowed,
isUserManagementDisabledFromUi,
}: MembersViewProps) => {
const t = await getTranslate();
@@ -47,7 +47,7 @@ export const MembersView = async ({
let teams: TOrganizationTeam[] = [];
if (canDoRoleManagement) {
if (isAccessControlAllowed) {
teams = (await getTeamsByOrganizationId(organization.id)) ?? [];
}
@@ -62,7 +62,7 @@ export const MembersView = async ({
role={membershipRole}
isLeaveOrganizationDisabled={isLeaveOrganizationDisabled}
isInviteDisabled={INVITE_DISABLED}
canDoRoleManagement={canDoRoleManagement}
isAccessControlAllowed={isAccessControlAllowed}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
environmentId={environmentId}
isMultiOrgEnabled={isMultiOrgEnabled}
@@ -74,7 +74,7 @@ export const MembersView = async ({
{membershipRole && (
<Suspense fallback={<MembersLoading />}>
<EditMemberships
canDoRoleManagement={canDoRoleManagement}
isAccessControlAllowed={isAccessControlAllowed}
organization={organization}
currentUserId={currentUserId}
role={membershipRole}

View File

@@ -1,4 +1,4 @@
import { getRoleManagementPermission } from "@/modules/ee/license-check/lib/utils";
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
@@ -20,7 +20,7 @@ vi.mock("@/lib/constants", () => ({
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getRoleManagementPermission: vi.fn(),
getAccessControlPermission: vi.fn(),
}));
vi.mock("@/modules/ee/teams/team-list/components/teams-view", () => ({
@@ -68,7 +68,7 @@ describe("TeamsPage", () => {
currentUserMembership: mockMembership,
organization: mockOrg,
} as any);
vi.mocked(getRoleManagementPermission).mockResolvedValue(true);
vi.mocked(getAccessControlPermission).mockResolvedValue(true);
const props = { params: Promise.resolve(mockParams) };
render(await TeamsPage(props));
expect(screen.getByTestId("content-wrapper")).toBeInTheDocument();
@@ -85,9 +85,9 @@ describe("TeamsPage", () => {
currentUserMembership: mockMembership,
organization: mockOrg,
} as any);
vi.mocked(getRoleManagementPermission).mockResolvedValue(false);
vi.mocked(getAccessControlPermission).mockResolvedValue(false);
const props = { params: Promise.resolve(mockParams) };
render(await TeamsPage(props));
expect(getRoleManagementPermission).toHaveBeenCalledWith("free");
expect(getAccessControlPermission).toHaveBeenCalledWith("free");
});
});

View File

@@ -1,7 +1,7 @@
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
import { IS_FORMBRICKS_CLOUD, USER_MANAGEMENT_MINIMUM_ROLE } from "@/lib/constants";
import { getUserManagementAccess } from "@/lib/membership/utils";
import { getRoleManagementPermission } from "@/modules/ee/license-check/lib/utils";
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
import { TeamsView } from "@/modules/ee/teams/team-list/components/teams-view";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { MembersView } from "@/modules/organization/settings/teams/components/members-view";
@@ -15,7 +15,7 @@ export const TeamsPage = async (props) => {
const { session, currentUserMembership, organization } = await getEnvironmentAuth(params.environmentId);
const canDoRoleManagement = await getRoleManagementPermission(organization.billing.plan);
const isAccessControlAllowed = await getAccessControlPermission(organization.billing.plan);
const hasUserManagementAccess = getUserManagementAccess(
currentUserMembership?.role,
USER_MANAGEMENT_MINIMUM_ROLE
@@ -36,14 +36,14 @@ export const TeamsPage = async (props) => {
organization={organization}
currentUserId={session.user.id}
environmentId={params.environmentId}
canDoRoleManagement={canDoRoleManagement}
isAccessControlAllowed={isAccessControlAllowed}
isUserManagementDisabledFromUi={!hasUserManagementAccess}
/>
<TeamsView
organizationId={organization.id}
membershipRole={currentUserMembership?.role}
currentUserId={session.user.id}
canDoRoleManagement={canDoRoleManagement}
isAccessControlAllowed={isAccessControlAllowed}
environmentId={params.environmentId}
/>
</PageContentWrapper>

View File

@@ -169,7 +169,7 @@ describe("CreateProjectModal", () => {
open: true,
setOpen: vi.fn(),
organizationId: "org-123",
canDoRoleManagement: true,
isAccessControlAllowed: true,
};
beforeEach(() => {
@@ -214,7 +214,7 @@ describe("CreateProjectModal", () => {
});
});
test("shows team selection when canDoRoleManagement is true and teams exist", async () => {
test("shows team selection when isAccessControlAllowed is true and teams exist", async () => {
render(<CreateProjectModal {...defaultProps} />);
await waitFor(() => {
@@ -230,8 +230,8 @@ describe("CreateProjectModal", () => {
expect(options[1]).toHaveTextContent("Marketing Team");
});
test("hides team selection when canDoRoleManagement is false", async () => {
render(<CreateProjectModal {...defaultProps} canDoRoleManagement={false} />);
test("hides team selection when isAccessControlAllowed is false", async () => {
render(<CreateProjectModal {...defaultProps} isAccessControlAllowed={false} />);
await waitFor(() => {
expect(screen.queryByTestId("multi-select")).not.toBeInTheDocument();

View File

@@ -44,14 +44,14 @@ interface CreateProjectModalProps {
open: boolean;
setOpen: (open: boolean) => void;
organizationId: string;
canDoRoleManagement: boolean;
isAccessControlAllowed: boolean;
}
export const CreateProjectModal = ({
open,
setOpen,
organizationId,
canDoRoleManagement,
isAccessControlAllowed,
}: CreateProjectModalProps) => {
const { t } = useTranslate();
const router = useRouter();
@@ -144,7 +144,7 @@ export const CreateProjectModal = ({
)}
/>
{canDoRoleManagement && organizationTeams.length > 0 && (
{isAccessControlAllowed && organizationTeams.length > 0 && (
<FormField
control={form.control}
name="teamIds"

View File

@@ -51,7 +51,7 @@ vi.mock("@/modules/projects/components/project-limit-modal", () => ({
}));
vi.mock("@/modules/projects/components/create-project-modal", () => ({
CreateProjectModal: ({ open, setOpen, organizationId, organizationTeams, canDoRoleManagement }: any) =>
CreateProjectModal: ({ open, setOpen, organizationId, organizationTeams, isAccessControlAllowed }: any) =>
open ? (
<div data-testid="create-project-modal">
<button onClick={() => setOpen(false)} data-testid="close-create-modal">
@@ -59,7 +59,7 @@ vi.mock("@/modules/projects/components/create-project-modal", () => ({
</button>
<div data-testid="modal-organization-id">{organizationId}</div>
<div data-testid="modal-organization-teams">{organizationTeams?.length || 0}</div>
<div data-testid="modal-can-do-role-management">{canDoRoleManagement.toString()}</div>
<div data-testid="modal-is-access-control-allowed">{isAccessControlAllowed.toString()}</div>
</div>
) : null,
}));
@@ -104,7 +104,7 @@ describe("ProjectSwitcher", () => {
environmentId: "env1",
isOwnerOrManager: true,
organizationTeams: mockOrganizationTeams,
canDoRoleManagement: true,
isAccessControlAllowed: true,
};
test("renders dropdown and project name", () => {
@@ -149,7 +149,7 @@ describe("ProjectSwitcher", () => {
await userEvent.click(addButton);
expect(screen.getByTestId("create-project-modal")).toBeInTheDocument();
expect(screen.getByTestId("modal-organization-id")).toHaveTextContent("org1");
expect(screen.getByTestId("modal-can-do-role-management")).toHaveTextContent("true");
expect(screen.getByTestId("modal-is-access-control-allowed")).toHaveTextContent("true");
});
test("closes CreateProjectModal when close button is clicked", async () => {
@@ -162,9 +162,9 @@ describe("ProjectSwitcher", () => {
});
test("passes correct props to CreateProjectModal", async () => {
render(<ProjectSwitcher {...defaultProps} projects={[project]} canDoRoleManagement={false} />);
render(<ProjectSwitcher {...defaultProps} projects={[project]} isAccessControlAllowed={false} />);
const addButton = screen.getByText("common.add_project");
await userEvent.click(addButton);
expect(screen.getByTestId("modal-can-do-role-management")).toHaveTextContent("false");
expect(screen.getByTestId("modal-is-access-control-allowed")).toHaveTextContent("false");
});
});

View File

@@ -32,7 +32,7 @@ interface ProjectSwitcherProps {
isLicenseActive: boolean;
environmentId: string;
isOwnerOrManager: boolean;
canDoRoleManagement: boolean;
isAccessControlAllowed: boolean;
}
export const ProjectSwitcher = ({
@@ -46,7 +46,7 @@ export const ProjectSwitcher = ({
isLicenseActive,
environmentId,
isOwnerOrManager,
canDoRoleManagement,
isAccessControlAllowed,
}: ProjectSwitcherProps) => {
const [openLimitModal, setOpenLimitModal] = useState(false);
const [openCreateProjectModal, setOpenCreateProjectModal] = useState(false);
@@ -227,7 +227,7 @@ export const ProjectSwitcher = ({
open={openCreateProjectModal}
setOpen={setOpenCreateProjectModal}
organizationId={organization.id}
canDoRoleManagement={canDoRoleManagement}
isAccessControlAllowed={isAccessControlAllowed}
/>
)}
</>