fix: role escalation in org settings (#4901)

Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
This commit is contained in:
Piyush Gupta
2025-03-21 11:08:51 +05:30
committed by GitHub
parent aa2588dd89
commit aec697f5b9
26 changed files with 1601 additions and 55 deletions

View File

@@ -8,9 +8,11 @@ import { updateMembership } from "@/modules/ee/role-management/lib/membership";
import { ZInviteUpdateInput } from "@/modules/ee/role-management/types/invites";
import { z } from "zod";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getOrganization } from "@formbricks/lib/organization/service";
import { ZId, ZUuid } from "@formbricks/types/common";
import { OperationNotAllowedError, ValidationError } from "@formbricks/types/errors";
import { AuthenticationError } from "@formbricks/types/errors";
import { ZMembershipUpdateInput } from "@formbricks/types/memberships";
export const checkRoleManagementPermission = async (organizationId: string) => {
@@ -34,6 +36,14 @@ const ZUpdateInviteAction = z.object({
export const updateInviteAction = authenticatedActionClient
.schema(ZUpdateInviteAction)
.action(async ({ ctx, parsedInput }) => {
const currentUserMembership = await getMembershipByUserIdOrganizationId(
ctx.user.id,
parsedInput.organizationId
);
if (!currentUserMembership) {
throw new AuthenticationError("User not a member of this organization");
}
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
@@ -51,6 +61,10 @@ export const updateInviteAction = authenticatedActionClient
throw new ValidationError("Billing role is not allowed");
}
if (currentUserMembership.role === "manager" && parsedInput.data.role !== "member") {
throw new OperationNotAllowedError("Managers can only invite members");
}
await checkRoleManagementPermission(parsedInput.organizationId);
return await updateInvite(parsedInput.inviteId, parsedInput.data);
@@ -65,6 +79,14 @@ const ZUpdateMembershipAction = z.object({
export const updateMembershipAction = authenticatedActionClient
.schema(ZUpdateMembershipAction)
.action(async ({ ctx, parsedInput }) => {
const currentUserMembership = await getMembershipByUserIdOrganizationId(
ctx.user.id,
parsedInput.organizationId
);
if (!currentUserMembership) {
throw new AuthenticationError("User not a member of this organization");
}
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
@@ -82,6 +104,10 @@ export const updateMembershipAction = authenticatedActionClient
throw new ValidationError("Billing role is not allowed");
}
if (currentUserMembership.role === "manager" && parsedInput.data.role !== "member") {
throw new OperationNotAllowedError("Managers can only assign users to the member role");
}
await checkRoleManagementPermission(parsedInput.organizationId);
return await updateMembership(parsedInput.userId, parsedInput.organizationId, parsedInput.data);

View File

@@ -10,25 +10,43 @@ import {
SelectValue,
} from "@/modules/ui/components/select";
import { Muted, P } from "@/modules/ui/components/typography";
import { OrganizationRole } from "@prisma/client";
import { useTranslate } from "@tolgee/react";
import type { Control } from "react-hook-form";
import { Controller } from "react-hook-form";
import { useMemo } from "react";
import { type Control, Controller } from "react-hook-form";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { TOrganizationRole } from "@formbricks/types/memberships";
interface AddMemberRoleProps {
control: Control<{ name: string; email: string; role: TOrganizationRole; teamIds: string[] }>;
canDoRoleManagement: boolean;
isFormbricksCloud: boolean;
membershipRole?: TOrganizationRole;
}
export function AddMemberRole({ control, canDoRoleManagement, isFormbricksCloud }: AddMemberRoleProps) {
const roles = isFormbricksCloud
? Object.values(OrganizationRole)
: Object.keys(OrganizationRole).filter((role) => role !== "billing");
export function AddMemberRole({
control,
canDoRoleManagement,
isFormbricksCloud,
membershipRole,
}: AddMemberRoleProps) {
const { isMember, isOwner } = getAccessFlags(membershipRole);
const { t } = useTranslate();
const roles = useMemo(() => {
let rolesArray = ["member"];
if (isOwner) {
rolesArray.push("owner", "manager");
if (isFormbricksCloud) {
rolesArray.push("billing");
}
}
return rolesArray;
}, [isOwner, isFormbricksCloud]);
if (isMember) return null;
const rolesDescription = {
owner: t("environments.settings.teams.owner_role_description"),
manager: t("environments.settings.teams.manager_role_description"),
@@ -44,7 +62,7 @@ export function AddMemberRole({ control, canDoRoleManagement, isFormbricksCloud
<div className="flex flex-col space-y-2">
<Label>{t("common.role_organization")}</Label>
<Select
defaultValue="owner"
defaultValue="member"
disabled={!canDoRoleManagement}
onValueChange={(v) => {
onChange(v as TOrganizationRole);

View File

@@ -0,0 +1,104 @@
import { cleanup, render, screen } from "@testing-library/react";
import { FormProvider, useForm } from "react-hook-form";
import { afterEach, describe, expect, it, vi } from "vitest";
import { AddMemberRole } from "./add-member-role";
// Mock dependencies
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => key,
}),
}));
// Create a wrapper component that provides the form context
const FormWrapper = ({ children, defaultValues, membershipRole, canDoRoleManagement, isFormbricksCloud }) => {
const methods = useForm({ defaultValues });
return (
<FormProvider {...methods}>
<AddMemberRole
control={methods.control}
membershipRole={membershipRole}
canDoRoleManagement={canDoRoleManagement}
isFormbricksCloud={isFormbricksCloud}
/>
{children}
</FormProvider>
);
};
describe("AddMemberRole Component", () => {
afterEach(() => {
cleanup();
});
const defaultValues = {
name: "Test User",
email: "test@example.com",
role: "member",
teamIds: [],
};
describe("Rendering", () => {
it("renders role selector when user is owner", () => {
render(
<FormWrapper
defaultValues={defaultValues}
membershipRole="owner"
canDoRoleManagement={true}
isFormbricksCloud={true}>
<div />
</FormWrapper>
);
const roleLabel = screen.getByText("common.role_organization");
expect(roleLabel).toBeInTheDocument();
});
it("does not render anything when user is member", () => {
render(
<FormWrapper
defaultValues={defaultValues}
membershipRole="member"
canDoRoleManagement={true}
isFormbricksCloud={true}>
<div data-testid="child" />
</FormWrapper>
);
expect(screen.queryByText("common.role_organization")).not.toBeInTheDocument();
expect(screen.getByTestId("child")).toBeInTheDocument();
});
it("disables the role selector when canDoRoleManagement is false", () => {
render(
<FormWrapper
defaultValues={defaultValues}
membershipRole="owner"
canDoRoleManagement={false}
isFormbricksCloud={true}>
<div />
</FormWrapper>
);
const selectTrigger = screen.getByRole("combobox");
expect(selectTrigger).toBeDisabled();
});
});
describe("Default values", () => {
it("displays the default role value", () => {
render(
<FormWrapper
defaultValues={defaultValues}
membershipRole="owner"
canDoRoleManagement={true}
isFormbricksCloud={true}>
<div />
</FormWrapper>
);
const selectTrigger = screen.getByRole("combobox");
expect(selectTrigger).toHaveTextContent("member");
});
});
});

View File

@@ -0,0 +1,93 @@
import { cleanup, render, screen } from "@testing-library/react";
import { useRouter } from "next/navigation";
import { type Mock, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { EditMembershipRole } from "./edit-membership-role";
// Mock dependencies
vi.mock("next/navigation", () => ({
useRouter: vi.fn(),
}));
vi.mock("react-hot-toast", () => ({
default: {
error: vi.fn(),
},
}));
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => key,
}),
}));
vi.mock("../actions", () => ({
updateMembershipAction: vi.fn(),
updateInviteAction: vi.fn(),
}));
describe("EditMembershipRole Component", () => {
const mockRouter = {
refresh: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
(useRouter as Mock).mockReturnValue(mockRouter);
});
afterEach(() => {
cleanup();
});
const defaultProps = {
memberRole: "member" as const,
organizationId: "org-123",
currentUserRole: "owner" as const,
memberId: "member-123",
userId: "user-456",
memberAccepted: true,
inviteId: undefined,
doesOrgHaveMoreThanOneOwner: true,
isFormbricksCloud: true,
};
describe("Rendering", () => {
it("renders a dropdown when user is owner", () => {
render(<EditMembershipRole {...defaultProps} />);
const button = screen.queryByRole("button-role");
expect(button).toBeInTheDocument();
expect(button).toHaveTextContent("Member");
});
it("renders a badge when user is not owner or manager", () => {
render(<EditMembershipRole {...defaultProps} currentUserRole="member" />);
const badge = screen.queryByRole("badge-role");
expect(badge).toBeInTheDocument();
const button = screen.queryByRole("button-role");
expect(button).not.toBeInTheDocument();
});
it("disables the dropdown when editing own role", () => {
render(<EditMembershipRole {...defaultProps} memberId="user-456" userId="user-456" />);
const button = screen.getByRole("button-role");
expect(button).toBeDisabled();
});
it("disables the dropdown when the user is the only owner", () => {
render(<EditMembershipRole {...defaultProps} memberRole="owner" doesOrgHaveMoreThanOneOwner={false} />);
const button = screen.getByRole("button-role");
expect(button).toBeDisabled();
});
it("disables the dropdown when a manager tries to edit an owner", () => {
render(<EditMembershipRole {...defaultProps} currentUserRole="manager" memberRole="owner" />);
const button = screen.getByRole("button-role");
expect(button).toBeDisabled();
});
});
});

View File

@@ -9,7 +9,6 @@ import {
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { OrganizationRole } from "@prisma/client";
import { useTranslate } from "@tolgee/react";
import { ChevronDownIcon } from "lucide-react";
import { useRouter } from "next/navigation";
@@ -79,10 +78,15 @@ export function EditMembershipRole({
};
const getMembershipRoles = () => {
const roles = isFormbricksCloud
? Object.values(OrganizationRole)
: Object.keys(OrganizationRole).filter((role) => role !== "billing");
let roles: string[] = ["member"];
if (isOwner) {
roles.push("owner", "manager");
if (isFormbricksCloud) {
roles.push("billing");
}
}
return roles;
};
@@ -95,7 +99,8 @@ export function EditMembershipRole({
disabled={disableRole}
loading={loading}
size="sm"
variant="secondary">
variant="secondary"
role="button-role">
<span className="ml-1">{capitalizeFirstLetter(memberRole)}</span>
<ChevronDownIcon className="h-4 w-4" />
</Button>
@@ -119,5 +124,5 @@ export function EditMembershipRole({
);
}
return <Badge size="tiny" type="gray" text={capitalizeFirstLetter(memberRole)} />;
return <Badge size="tiny" type="gray" role="badge-role" text={capitalizeFirstLetter(memberRole)} />;
}

View File

@@ -0,0 +1,107 @@
import { TInviteUpdateInput } from "@/modules/ee/role-management/types/invites";
import { Session } from "next-auth";
import { TMembership, TMembershipUpdateInput } from "@formbricks/types/memberships";
import { TOrganization, TOrganizationBillingPlan } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
// Common mock IDs
export const mockOrganizationId = "cblt7dwr7d0hvdifl4iw6d5x";
export const mockUserId = "wl43gybf3pxmqqx3fcmsk8eb";
export const mockInviteId = "dc0b6ea6-bb65-4a22-88e1-847df2e85af4";
export const mockTargetUserId = "vevt9qm7sqmh44e3za6a2vzd";
// Mock user
export const mockUser: TUser = {
id: mockUserId,
name: "Test User",
email: "test@example.com",
emailVerified: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
identityProvider: "email",
twoFactorEnabled: false,
objective: null,
notificationSettings: {
alert: {},
weeklySummary: {},
},
locale: "en-US",
imageUrl: null,
role: null,
};
// Mock session
export const mockSession: Session = {
user: {
id: mockUserId,
},
expires: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
};
// Mock organizations
export const createMockOrganization = (plan: TOrganizationBillingPlan): TOrganization => ({
id: mockOrganizationId,
name: "Test Organization",
createdAt: new Date(),
updatedAt: new Date(),
isAIEnabled: false,
billing: {
stripeCustomerId: null,
plan,
period: "monthly",
periodStart: new Date(),
limits: {
projects: plan === "free" ? 3 : null,
monthly: {
responses: plan === "free" ? 1500 : null,
miu: plan === "free" ? 2000 : null,
},
},
},
});
export const mockOrganizationFree = createMockOrganization("free");
export const mockOrganizationStartup = createMockOrganization("startup");
export const mockOrganizationScale = createMockOrganization("scale");
// Mock membership data
export const createMockMembership = (role: TMembership["role"]): TMembership => ({
userId: mockUserId,
organizationId: mockOrganizationId,
role,
accepted: true,
});
export const mockMembershipMember = createMockMembership("member");
export const mockMembershipManager = createMockMembership("manager");
export const mockMembershipOwner = createMockMembership("owner");
// Mock data payloads
export const mockInviteDataMember: TInviteUpdateInput = { role: "member" };
export const mockInviteDataOwner: TInviteUpdateInput = { role: "owner" };
export const mockInviteDataBilling: TInviteUpdateInput = { role: "billing" };
export const mockMembershipUpdateMember: TMembershipUpdateInput = { role: "member" };
export const mockMembershipUpdateOwner: TMembershipUpdateInput = { role: "owner" };
export const mockMembershipUpdateBilling: TMembershipUpdateInput = { role: "billing" };
// Mock input objects for actions
export const mockUpdateInviteInput = {
inviteId: mockInviteId,
organizationId: mockOrganizationId,
data: mockInviteDataMember,
};
export const mockUpdateMembershipInput = {
userId: mockTargetUserId,
organizationId: mockOrganizationId,
data: mockMembershipUpdateMember,
};
// Mock responses
export const mockUpdatedMembership: TMembership = {
userId: mockTargetUserId,
organizationId: mockOrganizationId,
role: "member",
accepted: true,
};

View File

@@ -0,0 +1,256 @@
import {
mockInviteDataBilling,
mockInviteDataOwner,
mockMembershipManager,
mockMembershipMember,
mockMembershipUpdateBilling,
mockMembershipUpdateOwner,
mockOrganizationFree,
mockOrganizationId,
mockOrganizationScale,
mockOrganizationStartup,
mockSession,
mockUpdateInviteInput,
mockUpdateMembershipInput,
mockUpdatedMembership,
mockUser,
} from "./__mocks__/actions.mock";
import "@/lib/utils/action-client-middleware";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import { getRoleManagementPermission } 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 { getServerSession } from "next-auth";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getOrganization } from "@formbricks/lib/organization/service";
import { getUser } from "@formbricks/lib/user/service";
import { OperationNotAllowedError, ValidationError } from "@formbricks/types/errors";
import { checkRoleManagementPermission } from "../actions";
import { updateInviteAction, updateMembershipAction } from "../actions";
// Mock all external dependencies
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getRoleManagementPermission: vi.fn(),
}));
vi.mock("@/modules/ee/role-management/lib/invite", () => ({
updateInvite: vi.fn(),
}));
vi.mock("@formbricks/lib/user/service", () => ({
getUser: vi.fn(),
}));
vi.mock("@/modules/ee/role-management/lib/membership", () => ({
updateMembership: vi.fn(),
}));
vi.mock("@formbricks/lib/membership/service", () => ({
getMembershipByUserIdOrganizationId: vi.fn(),
}));
vi.mock("@formbricks/lib/organization/service", () => ({
getOrganization: vi.fn(),
}));
vi.mock("@/lib/utils/action-client-middleware", () => ({
checkAuthorizationUpdated: vi.fn(),
}));
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
// Mock constants without importing the actual module
vi.mock("@formbricks/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
IS_MULTI_ORG_ENABLED: true,
ENCRYPTION_KEY: "test-encryption-key",
ENTERPRISE_LICENSE_KEY: "test-enterprise-license-key",
GITHUB_ID: "test-github-id",
GITHUB_SECRET: "test-github-secret",
GOOGLE_CLIENT_ID: "test-google-client-id",
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
AZUREAD_CLIENT_ID: "test-azure-client-id",
AZUREAD_CLIENT_SECRET: "test-azure-client-secret",
AZUREAD_TENANT_ID: "test-azure-tenant-id",
OIDC_CLIENT_ID: "test-oidc-client-id",
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
OIDC_ISSUER: "test-oidc-issuer",
OIDC_DISPLAY_NAME: "test-oidc-display-name",
OIDC_SIGNING_ALGORITHM: "test-oidc-algorithm",
SAML_DATABASE_URL: "test-saml-db-url",
NEXTAUTH_SECRET: "test-nextauth-secret",
WEBAPP_URL: "http://localhost:3000",
}));
vi.mock("@/lib/utils/action-client-middleware", () => ({
checkAuthorizationUpdated: vi.fn(),
}));
vi.mock("@formbricks/lib/errors", () => ({
OperationNotAllowedError: vi.fn(),
ValidationError: vi.fn(),
}));
describe("role-management/actions.ts", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.resetAllMocks();
});
describe("checkRoleManagementPermission", () => {
test("throws error when organization not found", async () => {
vi.mocked(getOrganization).mockResolvedValue(null);
await expect(checkRoleManagementPermission(mockOrganizationId)).rejects.toThrow(
"Organization not found"
);
expect(getOrganization).toHaveBeenCalledWith(mockOrganizationId);
});
test("throws error when role management is not allowed", async () => {
vi.mocked(getOrganization).mockResolvedValue(mockOrganizationFree);
vi.mocked(getRoleManagementPermission).mockResolvedValue(false);
await expect(checkRoleManagementPermission(mockOrganizationId)).rejects.toThrow(
new OperationNotAllowedError("Role management is not allowed for this organization")
);
expect(getRoleManagementPermission).toHaveBeenCalledWith("free");
expect(getOrganization).toHaveBeenCalledWith(mockOrganizationId);
});
test("succeeds when role management is allowed", async () => {
vi.mocked(getOrganization).mockResolvedValue(mockOrganizationStartup);
vi.mocked(getRoleManagementPermission).mockResolvedValue(true);
await expect(checkRoleManagementPermission(mockOrganizationId)).resolves.toBeUndefined();
await expect(getRoleManagementPermission).toHaveBeenCalledWith("startup");
expect(getOrganization).toHaveBeenCalledWith(mockOrganizationId);
});
});
describe("updateInviteAction", () => {
test("throws error when user is not a member of the organization", async () => {
vi.mocked(getServerSession).mockResolvedValue(mockSession);
vi.mocked(getUser).mockResolvedValue(mockUser);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(null);
expect(await updateInviteAction(mockUpdateInviteInput)).toStrictEqual({
serverError: "User not a member of this organization",
});
});
test("throws error when billing role is not allowed in self-hosted", async () => {
const inputWithBillingRole = {
...mockUpdateInviteInput,
data: mockInviteDataBilling,
};
vi.mocked(getServerSession).mockResolvedValue(mockSession);
vi.mocked(getUser).mockResolvedValue(mockUser);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembershipMember);
vi.mocked(checkAuthorizationUpdated).mockResolvedValue(true);
expect(await updateInviteAction(inputWithBillingRole)).toStrictEqual({
serverError: "Something went wrong while executing the operation.",
});
});
test("throws error when manager tries to assign non-member role", async () => {
const inputWithOwnerRole = {
...mockUpdateInviteInput,
data: mockInviteDataOwner,
};
vi.mocked(getServerSession).mockResolvedValue(mockSession);
vi.mocked(getUser).mockResolvedValue(mockUser);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembershipManager);
vi.mocked(checkAuthorizationUpdated).mockResolvedValue(true);
expect(await updateInviteAction(inputWithOwnerRole)).toStrictEqual({
serverError: "Managers can only invite members",
});
});
test("successfully updates invite", async () => {
vi.mocked(getServerSession).mockResolvedValue(mockSession);
vi.mocked(getUser).mockResolvedValue(mockUser);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembershipManager);
vi.mocked(checkAuthorizationUpdated).mockResolvedValue(true);
vi.mocked(getOrganization).mockResolvedValue(mockOrganizationScale);
vi.mocked(getRoleManagementPermission).mockResolvedValue(true);
vi.mocked(updateInvite).mockResolvedValue(true);
const result = await updateInviteAction(mockUpdateInviteInput);
expect(result).toEqual({ data: true });
});
});
describe("updateMembershipAction", () => {
test("throws error when user is not a member of the organization", async () => {
vi.mocked(getServerSession).mockResolvedValue(mockSession);
vi.mocked(getUser).mockResolvedValue(mockUser);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(null);
expect(await updateMembershipAction(mockUpdateMembershipInput)).toStrictEqual({
serverError: "User not a member of this organization",
});
});
test("throws error when billing role is not allowed in self-hosted", async () => {
const inputWithBillingRole = {
...mockUpdateMembershipInput,
data: mockMembershipUpdateBilling,
};
vi.mocked(getServerSession).mockResolvedValue(mockSession);
vi.mocked(getUser).mockResolvedValue(mockUser);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembershipMember);
vi.mocked(checkAuthorizationUpdated).mockResolvedValue(true);
expect(await updateMembershipAction(inputWithBillingRole)).toStrictEqual({
serverError: "Something went wrong while executing the operation.",
});
});
test("throws error when manager tries to assign non-member role", async () => {
const inputWithOwnerRole = {
...mockUpdateMembershipInput,
data: mockMembershipUpdateOwner,
};
vi.mocked(getServerSession).mockResolvedValue(mockSession);
vi.mocked(getUser).mockResolvedValue(mockUser);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembershipManager);
vi.mocked(checkAuthorizationUpdated).mockResolvedValue(true);
expect(await updateMembershipAction(inputWithOwnerRole)).toStrictEqual({
serverError: "Managers can only assign users to the member role",
});
});
test("successfully updates membership", async () => {
vi.mocked(getServerSession).mockResolvedValue(mockSession);
vi.mocked(getUser).mockResolvedValue(mockUser);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembershipManager);
vi.mocked(checkAuthorizationUpdated).mockResolvedValue(true);
vi.mocked(getOrganization).mockResolvedValue(mockOrganizationScale);
vi.mocked(getRoleManagementPermission).mockResolvedValue(true);
vi.mocked(updateMembership).mockResolvedValue(mockUpdatedMembership);
const result = await updateMembershipAction(mockUpdateMembershipInput);
expect(result).toEqual({
data: mockUpdatedMembership,
});
});
});
});

View File

@@ -160,7 +160,6 @@ export const resendInviteAction = authenticatedActionClient
});
const invite = await getInvite(parsedInput.inviteId);
const updatedInvite = await resendInvite(parsedInput.inviteId);
await sendInviteMemberEmail(
parsedInput.inviteId,
@@ -191,6 +190,14 @@ export const inviteUserAction = authenticatedActionClient
throw new ValidationError("Billing role is not allowed");
}
const currentUserMembership = await getMembershipByUserIdOrganizationId(
ctx.user.id,
parsedInput.organizationId
);
if (!currentUserMembership) {
throw new AuthenticationError("User not a member of this organization");
}
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
@@ -202,6 +209,10 @@ export const inviteUserAction = authenticatedActionClient
],
});
if (currentUserMembership.role === "manager" && parsedInput.role !== "member") {
throw new OperationNotAllowedError("Managers can only invite users as members");
}
if (parsedInput.role !== "owner" || parsedInput.teamIds.length > 0) {
await checkRoleManagementPermission(parsedInput.organizationId);
}

View File

@@ -0,0 +1,242 @@
import { inviteUserAction, leaveOrganizationAction } from "@/modules/organization/settings/teams/actions";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime";
import { useRouter } from "next/navigation";
import toast from "react-hot-toast";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@formbricks/lib/localStorage";
import { TOrganization } from "@formbricks/types/organizations";
import { OrganizationActions } from "./organization-actions";
// Mock the next/navigation module
vi.mock("next/navigation", () => ({
useRouter: vi.fn(),
}));
// Mock the actions
vi.mock("@/modules/organization/settings/teams/actions", () => ({
inviteUserAction: vi.fn(),
leaveOrganizationAction: vi.fn(),
}));
// Mock the InviteMemberModal
vi.mock("@/modules/organization/settings/teams/components/invite-member/invite-member-modal", () => ({
InviteMemberModal: vi.fn(({ open, setOpen, onSubmit }) => {
if (!open) return null;
return (
<div data-testid="invite-member-modal">
<button
data-testid="invite-submit-btn"
onClick={() =>
onSubmit([{ email: "test@example.com", name: "Test User", role: "admin", teamIds: [] }])
}>
Submit
</button>
<button data-testid="invite-close-btn" onClick={() => setOpen(false)}>
Close
</button>
</div>
);
}),
}));
// Mock the CustomDialog
vi.mock("@/modules/ui/components/custom-dialog", () => ({
CustomDialog: vi.fn(({ open, setOpen, onOk }) => {
if (!open) return null;
return (
<div data-testid="leave-org-modal">
<button data-testid="leave-org-confirm-btn" onClick={onOk}>
Confirm
</button>
<button data-testid="leave-org-cancel-btn" onClick={() => setOpen(false)}>
Cancel
</button>
</div>
);
}),
}));
// Mock react-hot-toast
vi.mock("react-hot-toast", () => ({
default: {
success: vi.fn(),
error: vi.fn(),
},
}));
// Mock localStorage
const localStorageMock = (() => {
let store = {};
return {
getItem: vi.fn((key) => store[key] || null),
setItem: vi.fn((key, value) => {
store[key] = value.toString();
}),
removeItem: vi.fn((key) => {
delete store[key];
}),
clear: vi.fn(() => {
store = {};
}),
};
})();
Object.defineProperty(window, "localStorage", { value: localStorageMock });
// Mock tolgee
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key) => key,
}),
}));
describe("OrganizationActions Component", () => {
const mockRouter = {
push: vi.fn(),
refresh: vi.fn(),
};
const defaultProps = {
role: "member" as const,
membershipRole: "member" as const,
isLeaveOrganizationDisabled: false,
organization: { id: "org-123", name: "Test Org" } as TOrganization,
teams: [{ id: "team-1", name: "Team 1" }],
isInviteDisabled: false,
canDoRoleManagement: true,
isFormbricksCloud: false,
environmentId: "env-123",
isMultiOrgEnabled: true,
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(useRouter).mockReturnValue(mockRouter as unknown as AppRouterInstance);
});
afterEach(() => {
cleanup();
});
test("renders without crashing", () => {
render(<OrganizationActions {...defaultProps} />);
expect(screen.getByText("environments.settings.general.leave_organization")).toBeInTheDocument();
});
test("does not show leave organization button when role is owner", () => {
render(<OrganizationActions {...defaultProps} role="owner" />);
expect(screen.queryByText("environments.settings.general.leave_organization")).not.toBeInTheDocument();
});
test("does not show leave organization button when multi-org is disabled", () => {
render(<OrganizationActions {...defaultProps} isMultiOrgEnabled={false} />);
expect(screen.queryByText("environments.settings.general.leave_organization")).not.toBeInTheDocument();
});
test("does not show invite button when isInviteDisabled is true", () => {
render(<OrganizationActions {...defaultProps} isInviteDisabled={true} />);
expect(screen.queryByText("environments.settings.teams.invite_member")).not.toBeInTheDocument();
});
test("does not show invite button when user is not owner or manager", () => {
render(<OrganizationActions {...defaultProps} membershipRole="member" />);
expect(screen.queryByText("environments.settings.teams.invite_member")).not.toBeInTheDocument();
});
test("shows invite button when user is owner", () => {
render(<OrganizationActions {...defaultProps} membershipRole="owner" />);
expect(screen.getByText("environments.settings.teams.invite_member")).toBeInTheDocument();
});
test("shows invite button when user is manager", () => {
render(<OrganizationActions {...defaultProps} membershipRole="manager" />);
expect(screen.getByText("environments.settings.teams.invite_member")).toBeInTheDocument();
});
test("opens invite member modal when clicking the invite button", () => {
render(<OrganizationActions {...defaultProps} membershipRole="owner" />);
fireEvent.click(screen.getByText("environments.settings.teams.invite_member"));
expect(screen.getByTestId("invite-member-modal")).toBeInTheDocument();
});
test("opens leave organization modal when clicking the leave button", () => {
render(<OrganizationActions {...defaultProps} />);
fireEvent.click(screen.getByText("environments.settings.general.leave_organization"));
expect(screen.getByTestId("leave-org-modal")).toBeInTheDocument();
});
test("handles successful member invite", async () => {
vi.mocked(inviteUserAction).mockResolvedValue({ data: "invite-123" });
render(<OrganizationActions {...defaultProps} membershipRole="owner" />);
fireEvent.click(screen.getByText("environments.settings.teams.invite_member"));
fireEvent.click(screen.getByTestId("invite-submit-btn"));
await waitFor(() => {
expect(inviteUserAction).toHaveBeenCalledWith({
organizationId: "org-123",
email: "test@example.com",
name: "Test User",
role: "admin",
teamIds: [],
});
expect(toast.success).toHaveBeenCalledWith("environments.settings.general.member_invited_successfully");
});
});
test("handles failed member invite", async () => {
vi.mocked(inviteUserAction).mockResolvedValue({ serverError: "Failed to invite user" });
render(<OrganizationActions {...defaultProps} membershipRole="owner" />);
fireEvent.click(screen.getByText("environments.settings.teams.invite_member"));
fireEvent.click(screen.getByTestId("invite-submit-btn"));
await waitFor(() => {
expect(inviteUserAction).toHaveBeenCalled();
expect(toast.error).toHaveBeenCalled();
});
});
test("handles leave organization successfully", async () => {
vi.mocked(leaveOrganizationAction).mockResolvedValue({
data: [
{
userId: "123",
role: "admin",
teamId: "team-1",
},
],
});
render(<OrganizationActions {...defaultProps} />);
fireEvent.click(screen.getByText("environments.settings.general.leave_organization"));
fireEvent.click(screen.getByTestId("leave-org-confirm-btn"));
await waitFor(() => {
expect(leaveOrganizationAction).toHaveBeenCalledWith({ organizationId: "org-123" });
expect(toast.success).toHaveBeenCalledWith("environments.settings.general.member_deleted_successfully");
expect(localStorage.removeItem).toHaveBeenCalledWith(FORMBRICKS_ENVIRONMENT_ID_LS);
expect(mockRouter.push).toHaveBeenCalledWith("/");
expect(mockRouter.refresh).toHaveBeenCalled();
});
});
test("handles leave organization error", async () => {
const mockError = new Error("Failed to leave organization");
vi.mocked(leaveOrganizationAction).mockRejectedValue(mockError);
render(<OrganizationActions {...defaultProps} />);
fireEvent.click(screen.getByText("environments.settings.general.leave_organization"));
fireEvent.click(screen.getByTestId("leave-org-confirm-btn"));
await waitFor(() => {
expect(leaveOrganizationAction).toHaveBeenCalledWith({ organizationId: "org-123" });
expect(toast.error).toHaveBeenCalledWith("Error: Failed to leave organization");
});
});
test("cannot leave organization when only one organization is present", () => {
render(<OrganizationActions {...defaultProps} isMultiOrgEnabled={false} />);
expect(screen.queryByText("environments.settings.general.leave_organization")).not.toBeInTheDocument();
});
});

View File

@@ -13,12 +13,13 @@ import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@formbricks/lib/localStorage";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { TOrganizationRole } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations";
interface OrganizationActionsProps {
role: TOrganizationRole;
isOwnerOrManager: boolean;
membershipRole?: TOrganizationRole;
isLeaveOrganizationDisabled: boolean;
organization: TOrganization;
teams: TOrganizationTeam[];
@@ -30,9 +31,9 @@ interface OrganizationActionsProps {
}
export const OrganizationActions = ({
isOwnerOrManager,
role,
organization,
membershipRole,
teams,
isLeaveOrganizationDisabled,
isInviteDisabled,
@@ -47,6 +48,9 @@ export const OrganizationActions = ({
const [isInviteMemberModalOpen, setInviteMemberModalOpen] = useState(false);
const [loading, setLoading] = useState(false);
const { isOwner, isManager } = getAccessFlags(membershipRole);
const isOwnerOrManager = isOwner || isManager;
const handleLeaveOrganization = async () => {
setLoading(true);
try {
@@ -139,6 +143,7 @@ export const OrganizationActions = ({
open={isInviteMemberModalOpen}
setOpen={setInviteMemberModalOpen}
onSubmit={handleAddMembers}
membershipRole={membershipRole}
canDoRoleManagement={canDoRoleManagement}
isFormbricksCloud={isFormbricksCloud}
environmentId={environmentId}

View File

@@ -25,6 +25,7 @@ interface IndividualInviteTabProps {
canDoRoleManagement: boolean;
isFormbricksCloud: boolean;
environmentId: string;
membershipRole?: TOrganizationRole;
}
export const IndividualInviteTab = ({
@@ -34,6 +35,7 @@ export const IndividualInviteTab = ({
canDoRoleManagement,
isFormbricksCloud,
environmentId,
membershipRole,
}: IndividualInviteTabProps) => {
const ZFormSchema = z.object({
name: ZUserName,
@@ -47,7 +49,7 @@ export const IndividualInviteTab = ({
const form = useForm<TFormData>({
resolver: zodResolver(ZFormSchema),
defaultValues: {
role: "owner",
role: "member",
teamIds: [],
},
});
@@ -102,6 +104,7 @@ export const IndividualInviteTab = ({
control={control}
canDoRoleManagement={canDoRoleManagement}
isFormbricksCloud={isFormbricksCloud}
membershipRole={membershipRole}
/>
{watch("role") === "member" && (
<Alert className="mt-2" variant="info">

View File

@@ -20,6 +20,7 @@ interface InviteMemberModalProps {
canDoRoleManagement: boolean;
isFormbricksCloud: boolean;
environmentId: string;
membershipRole?: TOrganizationRole;
}
export const InviteMemberModal = ({
@@ -30,6 +31,7 @@ export const InviteMemberModal = ({
canDoRoleManagement,
isFormbricksCloud,
environmentId,
membershipRole,
}: InviteMemberModalProps) => {
const [type, setType] = useState<"individual" | "bulk">("individual");
@@ -44,6 +46,7 @@ export const InviteMemberModal = ({
canDoRoleManagement={canDoRoleManagement}
isFormbricksCloud={isFormbricksCloud}
teams={teams}
membershipRole={membershipRole}
/>
),
bulk: (

View File

@@ -8,7 +8,6 @@ import { getMembershipsByUserId } from "@/modules/organization/settings/teams/li
import { getTranslate } from "@/tolgee/server";
import { Suspense } from "react";
import { INVITE_DISABLED, IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { TOrganizationRole } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations";
@@ -39,9 +38,6 @@ export const MembersView = async ({
}: MembersViewProps) => {
const t = await getTranslate();
const { isOwner, isManager } = getAccessFlags(membershipRole);
const isOwnerOrManager = isManager || isOwner;
const userMemberships = await getMembershipsByUserId(currentUserId);
const isLeaveOrganizationDisabled = userMemberships.length <= 1;
@@ -63,7 +59,7 @@ export const MembersView = async ({
{membershipRole && (
<OrganizationActions
organization={organization}
isOwnerOrManager={isOwnerOrManager}
membershipRole={membershipRole}
role={membershipRole}
isLeaveOrganizationDisabled={isLeaveOrganizationDisabled}
isInviteDisabled={INVITE_DISABLED}

View File

@@ -0,0 +1,207 @@
import { TInviteUpdateInput } from "@/modules/ee/role-management/types/invites";
import { InviteWithCreator } from "@/modules/organization/settings/teams/types/invites";
import { OrganizationRole, TeamUserRole } from "@prisma/client";
import { Session } from "next-auth";
import { z } from "zod";
import { ZInvite } from "@formbricks/database/zod/invites";
import { TMembership } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
// Mock IDs
export const mockUserId = "pltrxzyc5ej4n7v4yupet0j9";
export const mockOtherUserId = "bfn3tytw0puawppgo6ppooag";
export const mockManagerUserId = "mqn6li7zxcpteaq92dwv56ht";
export const mockInviteId = "dc0b6ea6-bb65-4a22-88e1-847df2e85af4";
export const mockOrganizationId = "pvay6sljkaqfsb199gcacebb";
export const mockTeamId = "uj7bo5b2smv559v5i2d7qi2q";
// Mock names and emails
export const mockUserName = "Test User";
export const mockUserEmail = "test@example.com";
export const mockInviteeName = "Invitee User";
export const mockInviteeEmail = "invitee@example.com";
// Mock user
export const mockUser: TUser = {
id: mockUserId,
name: "Test User",
email: "test@example.com",
emailVerified: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
identityProvider: "email",
twoFactorEnabled: false,
objective: null,
notificationSettings: {
alert: {},
weeklySummary: {},
},
locale: "en-US",
imageUrl: null,
role: null,
};
// Mock session
export const mockSession: Session = {
user: {
id: mockUserId,
},
expires: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
};
// Mock organization
export const createMockOrganization = (plan: TOrganization["billing"]["plan"]): TOrganization => ({
id: mockOrganizationId,
name: "Test Organization",
billing: {
plan,
period: "monthly",
periodStart: new Date(),
stripeCustomerId: "stripe-customer-id",
limits: {
monthly: {
responses: 1000,
miu: 1000,
},
projects: 1,
},
},
createdAt: new Date(),
updatedAt: new Date(),
isAIEnabled: false,
});
export const mockOrganization = createMockOrganization("scale");
export const mockOrganizationFree = createMockOrganization("startup");
// Mock invite data with different roles
export const mockInviteDataMember: TInviteUpdateInput = { role: "member" };
export const mockInviteDataManager: TInviteUpdateInput = { role: "manager" };
export const mockInviteDataOwner: TInviteUpdateInput = { role: "owner" };
export const mockInviteDataBilling: TInviteUpdateInput = { role: "billing" };
// Mock input objects for actions
export const mockDeleteInviteInput = {
inviteId: mockInviteId,
organizationId: mockOrganizationId,
};
export const mockCreateInviteTokenInput = {
inviteId: mockInviteId,
};
export const mockDeleteMembershipInput = {
userId: mockOtherUserId,
organizationId: mockOrganizationId,
};
export const mockResendInviteInput = {
inviteId: mockInviteId,
organizationId: mockOrganizationId,
};
export const mockInviteUserInput = {
organizationId: mockOrganizationId,
email: mockInviteeEmail,
name: mockInviteeName,
role: OrganizationRole.member,
teamIds: [mockTeamId],
};
export const mockLeaveOrganizationInput = {
organizationId: mockOrganizationId,
};
// Mock memberships - correctly typed based on TMembership
export const mockOwnerMembership: TMembership = {
organizationId: mockOrganizationId,
userId: mockUserId,
accepted: true,
role: "owner",
};
export const mockManagerMembership: TMembership = {
organizationId: mockOrganizationId,
userId: mockUserId,
accepted: true,
role: "manager",
};
export const mockMemberMembership: TMembership = {
organizationId: mockOrganizationId,
userId: mockUserId,
accepted: true,
role: "member",
};
// Mock invites - using ZInvite type
export const mockInvite: z.infer<typeof ZInvite> = {
id: mockInviteId,
email: mockInviteeEmail,
name: mockInviteeName,
organizationId: mockOrganizationId,
creatorId: mockUserId,
acceptorId: null,
createdAt: new Date(),
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days from now
role: "member",
teamIds: [mockTeamId],
};
// Mock for invite with creator data (as returned by getInvite)
export const mockInviteWithCreator: InviteWithCreator = {
email: mockInviteeEmail,
creator: {
name: mockUserName,
},
};
export const mockResendInviteResponse = {
email: mockInviteeEmail,
name: mockInviteeName,
};
// Mock deleted team memberships with correct types
export const mockDeletedMembership = {
role: TeamUserRole.admin,
userId: mockOtherUserId,
teamId: mockTeamId,
};
export const mockDeletedMemberMembership = {
role: TeamUserRole.contributor,
userId: mockUserId,
teamId: mockTeamId,
};
// Mock tokens
export const mockInviteToken = "mock-token";
// Mock access flags
export const mockOwnerAccessFlags = {
isOwner: true,
isManager: false,
isBilling: false,
isMember: false,
};
export const mockNonOwnerAccessFlags = {
isOwner: false,
isManager: true,
isBilling: false,
isMember: false,
};
// Mock membership arrays
export const mockMultipleMemberships = [
mockOwnerMembership,
{
organizationId: "other-org-id",
userId: mockUserId,
accepted: true,
role: "member",
} as TMembership,
];
export const mockSingleMembership = [mockOwnerMembership];

View File

@@ -0,0 +1,457 @@
import {
mockDeleteMembershipInput,
mockDeletedMemberMembership,
mockDeletedMembership,
mockInviteId,
mockInviteToken,
mockInviteUserInput,
mockInviteWithCreator,
mockInviteeEmail,
mockInviteeName,
mockLeaveOrganizationInput,
mockManagerMembership,
mockMemberMembership,
mockMultipleMemberships,
mockNonOwnerAccessFlags,
mockOrganizationId,
mockOtherUserId,
mockOwnerAccessFlags,
mockOwnerMembership,
mockResendInviteResponse,
mockSession,
mockSingleMembership,
mockUser,
mockUserId,
mockUserName,
} from "./__mocks__/actions.mock";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import { getOrganizationIdFromInviteId } from "@/lib/utils/helper";
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
import { checkRoleManagementPermission } from "@/modules/ee/role-management/actions";
import { sendInviteMemberEmail } from "@/modules/email";
import { OrganizationRole } from "@prisma/client";
import { getServerSession } from "next-auth";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { createInviteToken } from "@formbricks/lib/jwt";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getUser } from "@formbricks/lib/user/service";
import {
createInviteTokenAction,
deleteInviteAction,
deleteMembershipAction,
inviteUserAction,
leaveOrganizationAction,
resendInviteAction,
} from "../actions";
import { deleteInvite, getInvite, inviteUser, resendInvite } from "../lib/invite";
import { deleteMembership, getMembershipsByUserId, getOrganizationOwnerCount } from "../lib/membership";
vi.mock("@/lib/utils/action-client-middleware", () => ({
checkAuthorizationUpdated: vi.fn(() => Promise.resolve()),
}));
vi.mock("@/lib/utils/helper", () => ({
getOrganizationIdFromInviteId: vi.fn(() => Promise.resolve(mockOrganizationId)),
}));
vi.mock("../lib/invite", () => ({
deleteInvite: vi.fn(),
getInvite: vi.fn(),
inviteUser: vi.fn(),
resendInvite: vi.fn(),
}));
vi.mock("../lib/membership", () => ({
deleteMembership: vi.fn(),
getMembershipsByUserId: vi.fn(),
getOrganizationOwnerCount: vi.fn(),
}));
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
vi.mock("@formbricks/lib/user/service", () => ({
getUser: vi.fn(),
}));
vi.mock("@/modules/email", () => ({
sendInviteMemberEmail: vi.fn(),
}));
vi.mock("@formbricks/lib/jwt", () => ({
createInviteToken: vi.fn(),
}));
vi.mock("@formbricks/lib/membership/service", () => ({
getMembershipByUserIdOrganizationId: vi.fn(),
}));
vi.mock("@formbricks/lib/membership/utils", () => ({
getAccessFlags: vi.fn(),
}));
vi.mock("@/modules/ee/role-management/actions", () => ({
checkRoleManagementPermission: vi.fn(),
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getIsMultiOrgEnabled: vi.fn(),
}));
// Mock constants without importing the actual module
vi.mock("@formbricks/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
IS_MULTI_ORG_ENABLED: true,
ENCRYPTION_KEY: "test-encryption-key",
ENTERPRISE_LICENSE_KEY: "test-enterprise-license-key",
GITHUB_ID: "test-github-id",
GITHUB_SECRET: "test-github-secret",
GOOGLE_CLIENT_ID: "test-google-client-id",
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
INVITE_DISABLED: 0,
AZUREAD_CLIENT_ID: "test-azure-client-id",
AZUREAD_CLIENT_SECRET: "test-azure-client-secret",
AZUREAD_TENANT_ID: "test-azure-tenant-id",
OIDC_CLIENT_ID: "test-oidc-client-id",
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
OIDC_ISSUER: "test-oidc-issuer",
OIDC_DISPLAY_NAME: "test-oidc-display-name",
OIDC_SIGNING_ALGORITHM: "test-oidc-algorithm",
SAML_DATABASE_URL: "test-saml-db-url",
NEXTAUTH_SECRET: "test-nextauth-secret",
WEBAPP_URL: "http://localhost:3000",
}));
describe("Organization Settings Teams Actions", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.resetAllMocks();
});
describe("deleteInviteAction", () => {
test("deletes an invite when authorized", async () => {
vi.mocked(getServerSession).mockResolvedValue(mockSession);
vi.mocked(getUser).mockResolvedValue(mockUser);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(null);
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(true);
vi.mocked(deleteInvite).mockResolvedValueOnce(true);
await expect(
await deleteInviteAction({
inviteId: mockInviteId,
organizationId: mockOrganizationId,
})
).toStrictEqual({
data: true,
});
expect(deleteInvite).toHaveBeenCalledWith(mockInviteId);
});
});
describe("createInviteTokenAction", () => {
test("creates an invite token when authorized", async () => {
vi.mocked(getServerSession).mockResolvedValue(mockSession);
vi.mocked(getUser).mockResolvedValue(mockUser);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(null);
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(true);
vi.mocked(getInvite).mockResolvedValueOnce(mockInviteWithCreator);
vi.mocked(createInviteToken).mockReturnValueOnce(mockInviteToken);
const result = await createInviteTokenAction({
inviteId: mockInviteId,
});
expect(createInviteToken).toHaveBeenCalledWith(mockInviteId, mockInviteWithCreator.email, {
expiresIn: "7d",
});
expect(result).toEqual({ data: { inviteToken: encodeURIComponent(mockInviteToken) } });
});
test("throws an error if invite is not found", async () => {
vi.mocked(getServerSession).mockResolvedValue(mockSession);
vi.mocked(getUser).mockResolvedValue(mockUser);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(null);
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(true);
vi.mocked(getInvite).mockResolvedValueOnce(null);
await expect(
await createInviteTokenAction({
inviteId: mockInviteId,
})
).toStrictEqual({
serverError: "Something went wrong while executing the operation.",
});
});
});
describe("deleteMembershipAction", () => {
test("deletes a membership when authorized", async () => {
vi.mocked(getServerSession).mockResolvedValue(mockSession);
vi.mocked(getUser).mockResolvedValue(mockUser);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockOwnerMembership);
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(true);
vi.mocked(getMembershipByUserIdOrganizationId)
.mockResolvedValueOnce(mockOwnerMembership)
.mockResolvedValueOnce(mockMemberMembership);
vi.mocked(deleteMembership).mockResolvedValueOnce([mockDeletedMembership]);
await expect(
await deleteMembershipAction({
organizationId: mockOrganizationId,
userId: mockOtherUserId,
})
).toStrictEqual({
data: [mockDeletedMembership],
});
expect(deleteMembership).toHaveBeenCalledWith(mockOtherUserId, mockOrganizationId);
});
test("throws an error when trying to delete yourself", async () => {
const input = { ...mockDeleteMembershipInput, userId: mockUserId };
vi.mocked(getServerSession).mockResolvedValue(mockSession);
vi.mocked(getUser).mockResolvedValue(mockUser);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockOwnerMembership);
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(true);
expect(
await deleteMembershipAction({
...input,
})
).toStrictEqual({
serverError: "You cannot delete yourself from the organization",
});
});
test("throws an error when manager tries to delete an owner", async () => {
vi.mocked(getServerSession).mockResolvedValue(mockSession);
vi.mocked(getUser).mockResolvedValue(mockUser);
vi.mocked(getMembershipByUserIdOrganizationId)
.mockResolvedValueOnce(mockManagerMembership)
.mockResolvedValueOnce(mockOwnerMembership);
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(true);
expect(
await deleteMembershipAction({
...mockDeleteMembershipInput,
})
).toStrictEqual({
serverError: "You cannot delete the owner of the organization",
});
});
test("throws an error when deleting the last owner", async () => {
vi.mocked(getServerSession).mockResolvedValue(mockSession);
vi.mocked(getUser).mockResolvedValue(mockUser);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockOwnerMembership);
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(true);
vi.mocked(getMembershipByUserIdOrganizationId)
.mockResolvedValueOnce(mockOwnerMembership)
.mockResolvedValueOnce(mockOwnerMembership);
vi.mocked(getOrganizationOwnerCount).mockResolvedValueOnce(1);
expect(
await deleteMembershipAction({
...mockDeleteMembershipInput,
})
).toStrictEqual({
serverError: "Something went wrong while executing the operation.",
});
});
});
describe("resendInviteAction", () => {
test("resends an invite when authorized", async () => {
vi.mocked(getOrganizationIdFromInviteId).mockResolvedValueOnce(mockOrganizationId);
vi.mocked(getServerSession).mockResolvedValue(mockSession);
vi.mocked(getUser).mockResolvedValue(mockUser);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(null);
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(true);
vi.mocked(getInvite).mockResolvedValueOnce(mockInviteWithCreator);
vi.mocked(resendInvite).mockResolvedValueOnce(mockResendInviteResponse);
await resendInviteAction({
inviteId: mockInviteId,
organizationId: mockOrganizationId,
});
expect(resendInvite).toHaveBeenCalledWith(mockInviteId);
expect(sendInviteMemberEmail).toHaveBeenCalledWith(
mockInviteId,
mockInviteeEmail,
mockUserName,
mockInviteeName,
undefined,
"en-US"
);
});
test("throws an error when invite does not belong to organization", async () => {
vi.mocked(getServerSession).mockResolvedValue(mockSession);
vi.mocked(getUser).mockResolvedValue(mockUser);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(null);
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(true);
vi.mocked(getOrganizationIdFromInviteId).mockResolvedValueOnce("different-org-id");
expect(
await resendInviteAction({
inviteId: mockInviteId,
organizationId: mockOrganizationId,
})
).toStrictEqual({
serverError: "Something went wrong while executing the operation.",
});
});
});
describe("inviteUserAction", () => {
test("invites a user when authorized", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce(mockSession);
vi.mocked(getUser).mockResolvedValueOnce(mockUser);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce(mockOwnerMembership);
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(true);
vi.mocked(inviteUser).mockResolvedValueOnce(mockInviteId);
const result = await inviteUserAction({
...mockInviteUserInput,
});
expect(inviteUser).toHaveBeenCalledWith({
organizationId: mockOrganizationId,
invitee: {
email: mockInviteeEmail,
name: mockInviteeName,
role: "member",
teamIds: [mockInviteUserInput.teamIds[0]],
},
currentUserId: mockUserId,
});
expect(sendInviteMemberEmail).toHaveBeenCalled();
expect(result).toStrictEqual({ data: mockInviteId });
});
test("throws an error when manager tries to invite non-member role", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce(mockSession);
vi.mocked(getUser).mockResolvedValueOnce(mockUser);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce(mockManagerMembership);
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(true);
const input = { ...mockInviteUserInput, role: OrganizationRole.owner };
expect(
await inviteUserAction({
...input,
})
).toStrictEqual({
serverError: "Managers can only invite users as members",
});
});
test("checks role management permission for non-owner roles or team assignments", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce(mockSession);
vi.mocked(getUser).mockResolvedValueOnce(mockUser);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce(mockOwnerMembership);
vi.mocked(inviteUser).mockResolvedValueOnce(mockInviteId);
await expect(
await inviteUserAction({
...mockInviteUserInput,
})
).toStrictEqual({
data: mockInviteId,
});
expect(checkRoleManagementPermission).toHaveBeenCalledWith(mockOrganizationId);
});
});
describe("leaveOrganizationAction", () => {
test("allows a non-owner to leave an organization", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce(mockSession);
vi.mocked(getUser).mockResolvedValueOnce(mockUser);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce(mockMemberMembership);
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(true);
vi.mocked(getAccessFlags).mockReturnValueOnce(mockNonOwnerAccessFlags);
vi.mocked(getIsMultiOrgEnabled).mockResolvedValueOnce(true);
vi.mocked(getMembershipsByUserId).mockResolvedValueOnce(mockMultipleMemberships);
vi.mocked(deleteMembership).mockResolvedValueOnce([mockDeletedMemberMembership]);
expect(
await leaveOrganizationAction({
...mockLeaveOrganizationInput,
})
).toStrictEqual({
data: [mockDeletedMemberMembership],
});
expect(deleteMembership).toHaveBeenCalledWith(mockUserId, mockOrganizationId);
});
test("throws an error when an owner tries to leave", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce(mockSession);
vi.mocked(getUser).mockResolvedValueOnce(mockUser);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce(mockOwnerMembership);
vi.mocked(getAccessFlags).mockReturnValueOnce(mockOwnerAccessFlags);
expect(
await leaveOrganizationAction({
...mockLeaveOrganizationInput,
})
).toStrictEqual({
serverError: "You cannot leave an organization you own",
});
});
test("throws an error when user tries to leave their only organization", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce(mockSession);
vi.mocked(getUser).mockResolvedValueOnce(mockUser);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce(mockMemberMembership);
vi.mocked(getAccessFlags).mockReturnValueOnce(mockNonOwnerAccessFlags);
vi.mocked(getIsMultiOrgEnabled).mockResolvedValueOnce(true);
vi.mocked(getMembershipsByUserId).mockResolvedValueOnce(mockSingleMembership);
expect(
await leaveOrganizationAction({
...mockLeaveOrganizationInput,
})
).toStrictEqual({
serverError: "Something went wrong while executing the operation.",
});
});
});
});

View File

@@ -5,9 +5,10 @@ interface BadgeProps {
type: "warning" | "success" | "error" | "gray";
size: "tiny" | "normal" | "large";
className?: string;
role?: string;
}
export const Badge: React.FC<BadgeProps> = ({ text, type, size, className }) => {
export const Badge: React.FC<BadgeProps> = ({ text, type, size, className, role }) => {
const bgColor = {
warning: "bg-amber-100",
success: "bg-emerald-100",
@@ -39,6 +40,7 @@ export const Badge: React.FC<BadgeProps> = ({ text, type, size, className }) =>
return (
<span
role={role}
className={cn(
"inline-flex cursor-default items-center rounded-full border font-medium",
bgColor[type],

View File

@@ -24,6 +24,8 @@ export default defineConfig({
"modules/auth/lib/**/*.ts",
"modules/signup/lib/**/*.ts",
"modules/ee/whitelabel/email-customization/components/*.tsx",
"modules/ee/role-management/components/*.tsx",
"modules/organization/settings/teams/components/edit-memberships/organization-actions.tsx",
"modules/email/components/email-template.tsx",
"modules/email/emails/survey/follow-up.tsx",
"app/(app)/environments/**/settings/(organization)/general/page.tsx",
@@ -33,6 +35,8 @@ export default defineConfig({
"app/(auth)/layout.tsx",
"app/(app)/layout.tsx",
"app/intercom/*.tsx",
"modules/ee/role-management/*.ts",
"modules/organization/settings/teams/actions.ts",
],
exclude: [
"**/.next/**",
@@ -42,7 +46,6 @@ export default defineConfig({
"**/openapi.ts", // Exclude openapi configuration files
"**/openapi-document.ts", // Exclude openapi document files
"modules/**/types/**", // Exclude types
"**/*.tsx", // Exclude tsx files
],
},
},

View File

@@ -24,14 +24,32 @@ Here are the different access permissions, ranked from highest to lowest access
3. Billing
4. Member
### Role Permissions and Privilege Escalation Prevention
To prevent privilege escalation, the following rules apply:
- **Owners** can:
- Invite users as owners, managers, or members
- Assign roles up to owner
- **Managers** can:
- Invite users only as members
- Assign roles up to member only, not manager or owner
- **Members** cannot:
- Invite users
- Assign roles
### Organisational level
All users and their organization-level roles are listed in **Organization Settings > General**. Users can hold any of the following org-level roles:
- **Owner** have full access to the organization, its data, and settings. Org Owners can perform Team Admin actions without needing to join the team.
- **Manager** have full management access to all teams and projects. They can also manage the organization's membership. Org Managers can perform Team Admin actions without needing to join the team. They cannot change other organization settings.
- **Billing** users can manage payment and compliance details in the organization.
- **Member** can view most data in the organization and act in the projects they are members of. They cannot join projects on their own and need to be assigned.
- **Owner** have full access to the organization, its data, and settings. Org Owners can perform Team Admin actions without needing to join the team.
- **Manager** have full management access to all teams and projects. They can also manage the organization's membership (but can only invite or assign users as members). Org Managers can perform Team Admin actions without needing to join the team. They cannot change other organization settings.
- **Billing** users can manage payment and compliance details in the organization.
- **Member** can view most data in the organization and act in the projects they are members of. They cannot join projects on their own and need to be assigned.
### Permissions at project level
@@ -41,8 +59,8 @@ All users and their organization-level roles are listed in **Organization Settin
### Team-level Roles
- **Team Contributors** can view and act on surveys and responses.
- **Team Admins** have additional permissions to manage their team's membership and projects. These permissions are granted at the team-level, and don't apply to teams where they're not a Team Admin.
- **Team Contributors** can view and act on surveys and responses.
- **Team Admins** have additional permissions to manage their team's membership and projects. These permissions are granted at the team-level, and don't apply to teams where they're not a Team Admin.
For more information on user roles & permissions, see below:
@@ -84,8 +102,8 @@ For more information on user roles & permissions, see below:
| Create tags | ✅ | ✅ | ❌ | ✅\* |
| Update tags | ✅ | ✅ | ❌ | ✅\* |
| Delete tags | ✅ | ✅ | ❌ | ✅\*\* |
| **Contacts** | | | | |
| Delete contact | ✅ | ✅ | ❌ | ✅\* |
| **Contacts** | | | | |
| Delete contact | ✅ | ✅ | ❌ | ✅\* |
| **Integrations** | | | | |
| Manage integrations | ✅ | ✅ | ❌ | ✅\* |

View File

@@ -18,10 +18,8 @@ icon: "eye-slash"
![Add Hidden Fields](/images/xm-and-surveys/surveys/general-features/hidden-fields/input-hidden-fields.webp)
![Filled Hidden Fields](/images/xm-and-surveys/surveys/general-features/hidden-fields/filled-hidden-fields.webp)
## Set Hidden Field in Responses
### Link Surveys
@@ -29,7 +27,7 @@ icon: "eye-slash"
Single Hidden Field:
```
sh https://formbricks.com/clin3dxja02k8l80hpwmx4bjy?screen=pricing
sh https://formbricks.com/clin3dxja02k8l80hpwmx4bjy?screen=pricing
```
Multiple Hidden Fields:
@@ -42,18 +40,16 @@ Multiple Hidden Fields:
For in-product surveys, you can set hidden fields in the response by adding them to the `formbricks.track` call:
<Col>
<CodeGroup title="Example Screen from which the User filled it">
```sh
formbricks.track("my event", {
hiddenFields: {
screen: "landing_page",
job: "Founder"
},
});
```
</CodeGroup>
</Col>
<CodeGroup>
```JS action.js
formbricks.track("my event", {
hiddenFields: {
screen: "landing_page",
job: "Founder"
},
});
```
</CodeGroup>
## View Hidden Fields in Responses

View File

@@ -293,7 +293,6 @@
"privacy": "Datenschutz",
"privacy_policy": "Datenschutzerklärung",
"product_manager": "Produktmanager",
"product_not_found": "Produkt nicht gefunden",
"profile": "Profil",
"project": "Projekt",
"project_configuration": "Projektkonfiguration",

View File

@@ -293,7 +293,6 @@
"privacy": "Privacy Policy",
"privacy_policy": "Privacy Policy",
"product_manager": "Product Manager",
"product_not_found": "Product not found",
"profile": "Profile",
"project": "Project",
"project_configuration": "Project's Configuration",

View File

@@ -293,7 +293,6 @@
"privacy": "Politique de confidentialité",
"privacy_policy": "Politique de confidentialité",
"product_manager": "Chef de produit",
"product_not_found": "Produit non trouvé",
"profile": "Profil",
"project": "Projet",
"project_configuration": "Configuration du projet",

View File

@@ -293,7 +293,6 @@
"privacy": "Política de Privacidade",
"privacy_policy": "Política de Privacidade",
"product_manager": "Gerente de Produto",
"product_not_found": "Produto não encontrado",
"profile": "Perfil",
"project": "Projeto",
"project_configuration": "Configuração do Projeto",

View File

@@ -293,7 +293,6 @@
"privacy": "Política de Privacidade",
"privacy_policy": "Política de Privacidade",
"product_manager": "Gestor de Produto",
"product_not_found": "Produto não encontrado",
"profile": "Perfil",
"project": "Projeto",
"project_configuration": "Configuração do Projeto",

View File

@@ -293,7 +293,6 @@
"privacy": "隱私權政策",
"privacy_policy": "隱私權政策",
"product_manager": "產品經理",
"product_not_found": "找不到產品",
"profile": "個人資料",
"project": "專案",
"project_configuration": "專案組態",

View File

@@ -21,5 +21,5 @@ sonar.scm.exclusions.disabled=false
sonar.sourceEncoding=UTF-8
# Coverage
sonar.coverage.exclusions=**/*.test.*,**/*.spec.*,**/constants.ts,**/route.ts,modules/**/types/**,**/*.stories.*,**/mocks/**,**/openapi.ts,**/openapi-document.ts,playwright/**,**/*.tsx
sonar.coverage.exclusions=**/*.test.*,**/*.spec.*,**/constants.ts,**/route.ts,modules/**/types/**,**/*.stories.*,**/mocks/**,**/openapi.ts,**/openapi-document.ts,playwright/**
sonar.cpd.exclusions=**/*.test.*,**/*.spec.*,**/constants.ts,**/route.ts,modules/**/types/**,**/openapi.ts,**/openapi-document.ts