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:
Johannes
2025-12-11 21:01:48 -08:00
committed by GitHub
parent 666a79044f
commit 2d7b99ba26
36 changed files with 588 additions and 217 deletions
@@ -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}
+47 -3
View File
@@ -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);
});
});
});
+28
View File
@@ -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>
);
};
@@ -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}