mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-05 11:21:07 -05:00
feat: allow team admins to invite members to their own teams (#6891)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
This commit is contained in:
@@ -60,7 +60,7 @@ export function AddMemberRole({
|
||||
name="role"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<div className="flex flex-col space-y-2">
|
||||
<Label>{t("common.role_organization")}</Label>
|
||||
<Label>{t("environments.settings.teams.organization_role")}</Label>
|
||||
<Select
|
||||
defaultValue={isAccessControlAllowed ? "member" : "owner"}
|
||||
disabled={!isAccessControlAllowed}
|
||||
|
||||
@@ -4,12 +4,12 @@ import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { DatabaseError, UnknownError } from "@formbricks/types/errors";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { getProjectPermissionByUserId, getTeamRoleByTeamIdUserId } from "./roles";
|
||||
import { getProjectPermissionByUserId, getTeamRoleByTeamIdUserId, getTeamsWhereUserIsAdmin } from "./roles";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
projectTeam: { findMany: vi.fn() },
|
||||
teamUser: { findUnique: vi.fn() },
|
||||
teamUser: { findUnique: vi.fn(), findMany: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -19,6 +19,7 @@ vi.mock("@/lib/utils/validate", () => ({ validateInputs: vi.fn() }));
|
||||
const mockUserId = "user-1";
|
||||
const mockProjectId = "project-1";
|
||||
const mockTeamId = "team-1";
|
||||
const mockOrganizationId = "org-1";
|
||||
|
||||
describe("roles lib", () => {
|
||||
beforeEach(() => {
|
||||
@@ -90,7 +91,7 @@ describe("roles lib", () => {
|
||||
});
|
||||
|
||||
test("returns role if teamUser exists", async () => {
|
||||
vi.mocked(prisma.teamUser.findUnique).mockResolvedValueOnce({ role: "member" });
|
||||
vi.mocked(prisma.teamUser.findUnique).mockResolvedValueOnce({ role: "member" } as unknown as any);
|
||||
const result = await getTeamRoleByTeamIdUserId(mockTeamId, mockUserId);
|
||||
expect(result).toBe("member");
|
||||
});
|
||||
@@ -110,4 +111,47 @@ describe("roles lib", () => {
|
||||
await expect(getTeamRoleByTeamIdUserId(mockTeamId, mockUserId)).rejects.toThrow(error);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTeamsWhereUserIsAdmin", () => {
|
||||
test("returns empty array if user is not admin of any team", async () => {
|
||||
vi.mocked(prisma.teamUser.findMany).mockResolvedValueOnce([]);
|
||||
const result = await getTeamsWhereUserIsAdmin(mockUserId, mockOrganizationId);
|
||||
expect(result).toEqual([]);
|
||||
expect(validateInputs).toHaveBeenCalledWith(
|
||||
[mockUserId, expect.anything()],
|
||||
[mockOrganizationId, expect.anything()]
|
||||
);
|
||||
});
|
||||
|
||||
test("returns array of team IDs where user is admin", async () => {
|
||||
vi.mocked(prisma.teamUser.findMany).mockResolvedValueOnce([
|
||||
{ teamId: "team-1" },
|
||||
{ teamId: "team-2" },
|
||||
{ teamId: "team-3" },
|
||||
] as unknown as any);
|
||||
const result = await getTeamsWhereUserIsAdmin(mockUserId, mockOrganizationId);
|
||||
expect(result).toEqual(["team-1", "team-2", "team-3"]);
|
||||
});
|
||||
|
||||
test("returns single team ID when user is admin of one team", async () => {
|
||||
vi.mocked(prisma.teamUser.findMany).mockResolvedValueOnce([{ teamId: "team-1" }] as unknown as any);
|
||||
const result = await getTeamsWhereUserIsAdmin(mockUserId, mockOrganizationId);
|
||||
expect(result).toEqual(["team-1"]);
|
||||
});
|
||||
|
||||
test("throws DatabaseError on PrismaClientKnownRequestError", async () => {
|
||||
const error = new Prisma.PrismaClientKnownRequestError("fail", {
|
||||
code: "P2002",
|
||||
clientVersion: "1.0.0",
|
||||
});
|
||||
vi.mocked(prisma.teamUser.findMany).mockRejectedValueOnce(error);
|
||||
await expect(getTeamsWhereUserIsAdmin(mockUserId, mockOrganizationId)).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
test("throws error on generic error", async () => {
|
||||
const error = new Error("fail");
|
||||
vi.mocked(prisma.teamUser.findMany).mockRejectedValueOnce(error);
|
||||
await expect(getTeamsWhereUserIsAdmin(mockUserId, mockOrganizationId)).rejects.toThrow(error);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -83,3 +83,31 @@ export const getTeamRoleByTeamIdUserId = reactCache(
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const getTeamsWhereUserIsAdmin = reactCache(
|
||||
async (userId: string, organizationId: string): Promise<string[]> => {
|
||||
validateInputs([userId, ZId], [organizationId, ZId]);
|
||||
try {
|
||||
const adminTeams = await prisma.teamUser.findMany({
|
||||
where: {
|
||||
userId,
|
||||
role: "admin",
|
||||
team: {
|
||||
organizationId,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
teamId: true,
|
||||
},
|
||||
});
|
||||
|
||||
return adminTeams.map((at) => at.teamId);
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -43,7 +43,7 @@ export const AccessTable = ({ teams }: AccessTableProps) => {
|
||||
{team.memberCount} {team.memberCount === 1 ? t("common.member") : t("common.members")}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<IdBadge id={team.id} showCopyIconOnHover={true} />
|
||||
<IdBadge id={team.id} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<p className="capitalize">{TeamPermissionMapping[team.permission]}</p>
|
||||
|
||||
@@ -9,10 +9,9 @@ import { TProjectTeam } from "@/modules/ee/teams/project-teams/types/team";
|
||||
interface AccessViewProps {
|
||||
teams: TProjectTeam[];
|
||||
environmentId: string;
|
||||
isOwnerOrManager: boolean;
|
||||
}
|
||||
|
||||
export const AccessView = ({ teams, environmentId, isOwnerOrManager }: AccessViewProps) => {
|
||||
export const AccessView = ({ teams, environmentId }: AccessViewProps) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
@@ -20,7 +19,7 @@ export const AccessView = ({ teams, environmentId, isOwnerOrManager }: AccessVie
|
||||
title={t("common.team_access")}
|
||||
description={t("environments.project.teams.team_settings_description")}>
|
||||
<div className="mb-4 flex justify-end">
|
||||
<ManageTeam environmentId={environmentId} isOwnerOrManager={isOwnerOrManager} />
|
||||
<ManageTeam environmentId={environmentId} />
|
||||
</div>
|
||||
<AccessTable teams={teams} />
|
||||
</SettingsCard>
|
||||
|
||||
@@ -3,14 +3,12 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
|
||||
|
||||
interface ManageTeamProps {
|
||||
environmentId: string;
|
||||
isOwnerOrManager: boolean;
|
||||
}
|
||||
|
||||
export const ManageTeam = ({ environmentId, isOwnerOrManager }: ManageTeamProps) => {
|
||||
export const ManageTeam = ({ environmentId }: ManageTeamProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const router = useRouter();
|
||||
@@ -19,20 +17,9 @@ export const ManageTeam = ({ environmentId, isOwnerOrManager }: ManageTeamProps)
|
||||
router.push(`/environments/${environmentId}/settings/teams`);
|
||||
};
|
||||
|
||||
if (isOwnerOrManager) {
|
||||
return (
|
||||
<Button variant="secondary" size="sm" onClick={handleManageTeams}>
|
||||
{t("environments.project.teams.manage_teams")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipRenderer
|
||||
tooltipContent={t("environments.project.teams.only_organization_owners_and_managers_can_manage_teams")}>
|
||||
<Button variant="secondary" size="sm" disabled>
|
||||
{t("environments.project.teams.manage_teams")}
|
||||
</Button>
|
||||
</TooltipRenderer>
|
||||
<Button variant="secondary" size="sm" onClick={handleManageTeams}>
|
||||
{t("environments.project.teams.manage_teams")}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -10,7 +10,7 @@ export const ProjectTeams = async (props: { params: Promise<{ environmentId: str
|
||||
const t = await getTranslate();
|
||||
const params = await props.params;
|
||||
|
||||
const { project, isOwner, isManager } = await getEnvironmentAuth(params.environmentId);
|
||||
const { project } = await getEnvironmentAuth(params.environmentId);
|
||||
|
||||
const teams = await getTeamsByProjectId(project.id);
|
||||
|
||||
@@ -18,14 +18,12 @@ export const ProjectTeams = async (props: { params: Promise<{ environmentId: str
|
||||
throw new Error(t("common.teams_not_found"));
|
||||
}
|
||||
|
||||
const isOwnerOrManager = isOwner || isManager;
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={t("common.project_configuration")}>
|
||||
<ProjectConfigNavigation environmentId={params.environmentId} activeId="teams" />
|
||||
</PageHeader>
|
||||
<AccessView environmentId={params.environmentId} teams={teams} isOwnerOrManager={isOwnerOrManager} />
|
||||
<AccessView environmentId={params.environmentId} teams={teams} />
|
||||
</PageContentWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
+99
-68
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { PlusIcon, Trash2Icon } from "lucide-react";
|
||||
import { PlusIcon, Trash2Icon, XIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo } from "react";
|
||||
import { FormProvider, SubmitHandler, useForm, useWatch } from "react-hook-form";
|
||||
@@ -80,6 +80,16 @@ export const TeamSettingsModal = ({
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
// Track initial member IDs to distinguish existing members from newly added ones
|
||||
const initialMemberIds = useMemo(() => {
|
||||
return new Set(team.members.map((member) => member.userId));
|
||||
}, [team.members]);
|
||||
|
||||
// Track initial project IDs to distinguish existing projects from newly added ones
|
||||
const initialProjectIds = useMemo(() => {
|
||||
return new Set(team.projects.map((project) => project.projectId));
|
||||
}, [team.projects]);
|
||||
|
||||
const initialMembers = useMemo(() => {
|
||||
const members = team.members.map((member) => ({
|
||||
userId: member.userId,
|
||||
@@ -259,34 +269,44 @@ export const TeamSettingsModal = ({
|
||||
<FormField
|
||||
control={control}
|
||||
name={`members.${index}.userId`}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormItem className="flex-1">
|
||||
<Select
|
||||
onValueChange={(val) => {
|
||||
field.onChange(val);
|
||||
handleMemberSelectionChange(index, val);
|
||||
}}
|
||||
disabled={!isOwnerOrManager && !isTeamAdminMember}
|
||||
value={member.userId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select member" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{memberOpts.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
id={`member-${index}-option`}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{error?.message && (
|
||||
<FormError className="text-left">{error.message}</FormError>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
render={({ field, fieldState: { error } }) => {
|
||||
// Disable user select for existing members (can only remove or change role)
|
||||
const isExistingMember =
|
||||
member.userId && initialMemberIds.has(member.userId);
|
||||
const isSelectDisabled =
|
||||
isExistingMember || (!isOwnerOrManager && !isTeamAdminMember);
|
||||
|
||||
return (
|
||||
<FormItem className="flex-1">
|
||||
<Select
|
||||
onValueChange={(val) => {
|
||||
field.onChange(val);
|
||||
handleMemberSelectionChange(index, val);
|
||||
}}
|
||||
disabled={isSelectDisabled}
|
||||
value={member.userId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={t("environments.settings.teams.select_member")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{memberOpts.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
id={`member-${index}-option`}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{error?.message && (
|
||||
<FormError className="text-left">{error.message}</FormError>
|
||||
)}
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
@@ -328,18 +348,20 @@ export const TeamSettingsModal = ({
|
||||
|
||||
{/* Delete Button for Member */}
|
||||
{watchMembers.length > 1 && (
|
||||
<Button
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="shrink-0"
|
||||
disabled={
|
||||
!isOwnerOrManager &&
|
||||
(!isTeamAdminMember || member.userId === currentUserId)
|
||||
}
|
||||
onClick={() => handleRemoveMember(index)}>
|
||||
<Trash2Icon className="h-4 w-4" />
|
||||
</Button>
|
||||
<TooltipRenderer tooltipContent={t("common.remove_from_team")}>
|
||||
<Button
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="destructive"
|
||||
className="shrink-0"
|
||||
disabled={
|
||||
!isOwnerOrManager &&
|
||||
(!isTeamAdminMember || member.userId === currentUserId)
|
||||
}
|
||||
onClick={() => handleRemoveMember(index)}>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipRenderer>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -360,7 +382,7 @@ export const TeamSettingsModal = ({
|
||||
: t("environments.settings.teams.all_members_added")
|
||||
}>
|
||||
<Button
|
||||
size="default"
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={handleAddMember}
|
||||
@@ -396,31 +418,40 @@ export const TeamSettingsModal = ({
|
||||
<FormField
|
||||
control={control}
|
||||
name={`projects.${index}.projectId`}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormItem className="flex-1">
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={project.projectId}
|
||||
disabled={!isOwnerOrManager}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select project" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{projectOpts.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
id={`project-${index}-option`}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{error?.message && (
|
||||
<FormError className="text-left">{error.message}</FormError>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
render={({ field, fieldState: { error } }) => {
|
||||
// Disable project select for existing projects (can only remove or change permission)
|
||||
const isExistingProject =
|
||||
project.projectId && initialProjectIds.has(project.projectId);
|
||||
const isSelectDisabled = isExistingProject || !isOwnerOrManager;
|
||||
|
||||
return (
|
||||
<FormItem className="flex-1">
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={project.projectId}
|
||||
disabled={isSelectDisabled}>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={t("environments.settings.teams.select_project")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{projectOpts.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
id={`project-${index}-option`}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{error?.message && (
|
||||
<FormError className="text-left">{error.message}</FormError>
|
||||
)}
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
@@ -481,7 +512,7 @@ export const TeamSettingsModal = ({
|
||||
: t("environments.settings.teams.all_projects_added")
|
||||
}>
|
||||
<Button
|
||||
size="default"
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={handleAddProject}
|
||||
|
||||
Reference in New Issue
Block a user