mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-30 02:10:12 -06:00
fix: role escalation in org settings (#4901)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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)} />;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
256
apps/web/modules/ee/role-management/tests/actions.test.ts
Normal file
256
apps/web/modules/ee/role-management/tests/actions.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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: (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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];
|
||||
@@ -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.",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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],
|
||||
|
||||
@@ -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
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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 | ✅ | ✅ | ❌ | ✅\* |
|
||||
|
||||
|
||||
@@ -18,10 +18,8 @@ icon: "eye-slash"
|
||||
|
||||

|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -293,7 +293,6 @@
|
||||
"privacy": "隱私權政策",
|
||||
"privacy_policy": "隱私權政策",
|
||||
"product_manager": "產品經理",
|
||||
"product_not_found": "找不到產品",
|
||||
"profile": "個人資料",
|
||||
"project": "專案",
|
||||
"project_configuration": "專案組態",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user