fix: org admin has team member privileges [CAL-2338] (#10809)

Co-authored-by: Omar López <zomars@me.com>
This commit is contained in:
Leo Giovanetti
2023-08-18 14:10:17 -07:00
committed by GitHub
co-authored by Omar López
parent 8995dcc82a
commit fe8b13885c
11 changed files with 127 additions and 63 deletions
@@ -562,6 +562,7 @@
"leave": "Leave",
"profile": "Profile",
"my_team_url": "My team URL",
"my_teams": "My teams",
"team_name": "Team Name",
"your_team_name": "Your team name",
"team_updated_successfully": "Team updated successfully",
@@ -1998,7 +1999,7 @@
"kyc_verification": "KYC Verification",
"organizations": "Organizations",
"org_admin_other_teams": "Other teams",
"org_admin_other_teams_description": "Here you can see teams inside your organization but that you are not part of. You can add yourself to them if needed.",
"org_admin_other_teams_description": "Here you can see teams inside your organization that you are not part of. You can add yourself to them if needed.",
"no_other_teams_found": "No other teams found",
"no_other_teams_found_description": "There are no other teams in this organization.",
"attendee_first_name_variable": "Attendee first name",
@@ -2007,6 +2008,7 @@
"attendee_last_name_info": "The person booking's last name",
"me": "Me",
"verify_team_tooltip": "Verify your team to enable sending messages to attendees",
"member_removed": "Member removed",
"my_availability": "My Availability",
"team_availability": "Team Availability",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
@@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
import { useState } from "react";
import SkeletonLoaderTeamList from "@calcom/ee/teams/components/SkeletonloaderTeamList";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@@ -12,14 +12,12 @@ export function OtherTeamsListing() {
const [errorMessage, setErrorMessage] = useState("");
const { data, isLoading } = trpc.viewer.organizations.listOtherTeams.useQuery(undefined, {
const { data: teams, isLoading } = trpc.viewer.organizations.listOtherTeams.useQuery(undefined, {
onError: (e) => {
setErrorMessage(e.message);
},
});
const teams = useMemo(() => data?.filter((m) => m.accepted) || [], [data]);
if (isLoading) {
return <SkeletonLoaderTeamList />;
}
@@ -28,7 +26,7 @@ export function OtherTeamsListing() {
<>
{!!errorMessage && <Alert severity="error" title={errorMessage} />}
{teams.length > 0 ? (
{teams && teams.length > 0 ? (
<OtherTeamList teams={teams} />
) : (
<EmptyScreen
@@ -1,12 +1,15 @@
// import { debounce } from "lodash";
import { useSession } from "next-auth/react";
import { useSearchParams, useRouter } from "next/navigation";
import { useState, useEffect } from "react";
import MemberInvitationModal from "@calcom/ee/teams/components/MemberInvitationModal";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { MembershipRole } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
import type { RouterOutputs } from "@calcom/trpc/react";
import { Meta, showToast } from "@calcom/ui";
import { Meta, showToast, Button } from "@calcom/ui";
import { Plus } from "@calcom/ui/components/icon";
import { getLayout } from "../../../../settings/layouts/SettingsLayout";
import MakeTeamPrivateSwitch from "../../../teams/components/MakeTeamPrivateSwitch";
@@ -51,7 +54,7 @@ const MembersView = () => {
const router = useRouter();
const searchParams = useSearchParams();
const teamId = Number(searchParams.get("id"));
const session = useSession();
const utils = trpc.useContext();
const [offset, setOffset] = useState<number>(1);
// const [query, setQuery] = useState<string | undefined>("");
@@ -60,6 +63,9 @@ const MembersView = () => {
const limit = 100;
const [showMemberInvitationModal, setShowMemberInvitationModal] = useState<boolean>(false);
const [members, setMembers] = useState<Members>([]);
const { data: currentOrg } = trpc.viewer.organizations.listCurrent.useQuery(undefined, {
enabled: !!session.data?.user?.organizationId,
});
const { data: team, isLoading: isTeamLoading } = trpc.viewer.organizations.getOtherTeam.useQuery(
{ teamId },
{
@@ -68,6 +74,16 @@ const MembersView = () => {
},
}
);
const { data: orgMembersNotInThisTeam, isLoading: isOrgListLoading } =
trpc.viewer.organizations.getMembers.useQuery(
{
teamIdToExclude: teamId,
distinctUser: true,
},
{
enabled: searchParams !== null,
}
);
const { data: membersFetch, isLoading: isLoadingMembers } =
trpc.viewer.organizations.listOtherTeamMembers.useQuery(
{ teamId, limit, offset: (offset - 1) * limit },
@@ -89,38 +105,34 @@ const MembersView = () => {
}
}, [membersFetch]);
// useEffect(() => {
// if (queryToFetch !== "") {
// setMembers(membersFetch || []);
// setLoadMore(false);
// }
// }, [membersFetch, query]);
const isLoading = isTeamLoading || isLoadingMembers;
const isLoading = isTeamLoading || isLoadingMembers || isOrgListLoading;
const inviteMemberMutation = trpc.viewer.teams.inviteMember.useMutation();
// const debouncedFunction = debounce((query) => {
// setQueryToFetch(query);
// }, 500);
const isOrgAdminOrOwner =
currentOrg &&
(currentOrg.user.role === MembershipRole.OWNER || currentOrg.user.role === MembershipRole.ADMIN);
return (
<>
<Meta
title={t("team_members")}
description={t("members_team_description")}
// @TODO: Add this back in when we have the ability to invite members
// CTA={
// <Button
// type="button"
// color="primary"
// StartIcon={Plus}
// className="ml-auto"
// onClick={() => setShowMemberInvitationModal(true)}
// data-testid="new-member-button">
// {t("add")}
// </Button>
// }
CTA={
isOrgAdminOrOwner ? (
<Button
type="button"
color="primary"
StartIcon={Plus}
className="ml-auto"
onClick={() => setShowMemberInvitationModal(true)}
data-testid="new-member-button">
{t("add")}
</Button>
) : (
<></>
)
}
/>
{!isLoading && (
<>
@@ -157,6 +169,7 @@ const MembersView = () => {
<MemberInvitationModal
isLoading={inviteMemberMutation.isLoading}
isOpen={showMemberInvitationModal}
orgMembers={orgMembersNotInThisTeam}
teamId={team.id}
disableCopyLink={true}
onExit={() => setShowMemberInvitationModal(false)}
@@ -1,14 +1,15 @@
import { useSession } from "next-auth/react";
import { useRouter, useSearchParams } from "next/navigation";
import { useState } from "react";
import { z } from "zod";
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
import InviteLinkSettingsModal from "@calcom/features/ee/teams/components/InviteLinkSettingsModal";
import MemberInvitationModal from "@calcom/features/ee/teams/components/MemberInvitationModal";
import { classNames } from "@calcom/lib";
import { APP_NAME, WEBAPP_URL } from "@calcom/lib/constants";
import { useBookerUrl } from "@calcom/lib/hooks/useBookerUrl";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { MembershipRole } from "@calcom/prisma/enums";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import {
@@ -22,10 +23,6 @@ import {
} from "@calcom/ui";
import { ArrowRight, Plus, Trash2 } from "@calcom/ui/components/icon";
const querySchema = z.object({
id: z.string().transform((val) => parseInt(val)),
});
type TeamMember = RouterOutputs["viewer"]["teams"]["get"]["members"][number];
type FormValues = {
@@ -57,12 +54,22 @@ export const AddNewTeamMembersForm = ({
const router = useRouter();
const utils = trpc.useContext();
const orgBranding = useOrgBranding();
const showDialog = searchParams?.get("inviteModal") === "true";
const [memberInviteModal, setMemberInviteModal] = useState(showDialog);
const [inviteLinkSettingsModal, setInviteLinkSettingsModal] = useState(false);
const { data: team, isLoading } = trpc.viewer.teams.get.useQuery({ teamId }, { enabled: !!teamId });
const { data: orgMembersNotInThisTeam } = trpc.viewer.organizations.getMembers.useQuery(
{
teamIdToExclude: teamId,
distinctUser: true,
},
{
enabled: orgBranding !== null,
}
);
const inviteMemberMutation = trpc.viewer.teams.inviteMember.useMutation();
@@ -78,17 +85,19 @@ export const AddNewTeamMembersForm = ({
return (
<>
<div>
<ul className="border-subtle rounded-md border" data-testid="pending-member-list">
{defaultValues.members.map((member, index) => (
<PendingMemberItem key={member.email} member={member} index={index} teamId={teamId} />
))}
</ul>
{defaultValues.members.length > 0 && (
<ul className="border-subtle rounded-md border" data-testid="pending-member-list">
{defaultValues.members.map((member, index) => (
<PendingMemberItem key={member.email} member={member} index={index} teamId={teamId} />
))}
</ul>
)}
<Button
color="secondary"
data-testid="new-member-button"
StartIcon={Plus}
onClick={() => setMemberInviteModal(true)}
className="mt-6 w-full justify-center">
className={classNames("w-full justify-center", defaultValues.members.length > 0 && "mt-6")}>
{t("add_team_member")}
</Button>
</div>
@@ -99,6 +108,7 @@ export const AddNewTeamMembersForm = ({
<MemberInvitationModal
isLoading={inviteMemberMutation.isLoading}
isOpen={memberInviteModal}
orgMembers={orgMembersNotInThisTeam}
teamId={teamId}
token={team?.inviteToken?.token}
onExit={() => setMemberInviteModal(false)}
@@ -162,14 +172,18 @@ export const AddNewTeamMembersForm = ({
)}
<hr className="border-subtle my-6" />
<Button
EndIcon={ArrowRight}
EndIcon={!orgBranding ? ArrowRight : undefined}
color="primary"
className="mt-6 w-full justify-center"
className="w-full justify-center"
disabled={publishTeamMutation.isLoading}
onClick={() => {
publishTeamMutation.mutate({ teamId });
if (orgBranding) {
router.push("/settings/teams");
} else {
publishTeamMutation.mutate({ teamId });
}
}}>
{t("team_publish")}
{t(orgBranding ? "finish" : "team_publish")}
</Button>
</>
);
@@ -198,18 +212,26 @@ const PendingMemberItem = (props: { member: TeamMember; index: number; teamId: n
const { member, index, teamId } = props;
const { t } = useLocale();
const utils = trpc.useContext();
const session = useSession();
const bookerUrl = useBookerUrl();
const { data: currentOrg } = trpc.viewer.organizations.listCurrent.useQuery(undefined, {
enabled: !!session.data?.user?.organizationId,
});
const removeMemberMutation = trpc.viewer.teams.removeMember.useMutation({
async onSuccess() {
await utils.viewer.teams.get.invalidate();
await utils.viewer.eventTypes.invalidate();
showToast("Member removed", "success");
showToast(t("member_removed"), "success");
},
async onError(err) {
showToast(err.message, "error");
},
});
const isOrgAdminOrOwner =
currentOrg &&
(currentOrg.user.role === MembershipRole.OWNER || currentOrg.user.role === MembershipRole.ADMIN);
return (
<li
key={member.email}
@@ -229,7 +251,7 @@ const PendingMemberItem = (props: { member: TeamMember; index: number; teamId: n
<div className="flex space-x-1">
<p>{member.name || member.email || t("team_member")}</p>
{/* Assume that the first member of the team is the creator */}
{index === 0 && <Badge variant="green">{t("you")}</Badge>}
{member.id === session.data?.user.id && <Badge variant="green">{t("you")}</Badge>}
{!member.accepted && <Badge variant="orange">{t("pending")}</Badge>}
{member.role === "MEMBER" && <Badge variant="gray">{t("member")}</Badge>}
{member.role === "ADMIN" && <Badge variant="default">{t("admin")}</Badge>}
@@ -241,7 +263,7 @@ const PendingMemberItem = (props: { member: TeamMember; index: number; teamId: n
)}
</div>
</div>
{member.role !== "OWNER" && (
{(member.role !== "OWNER" || isOrgAdminOrOwner) && (
<Button
data-testid="remove-member-button"
StartIcon={Trash2}
@@ -249,7 +271,10 @@ const PendingMemberItem = (props: { member: TeamMember; index: number; teamId: n
color="secondary"
className="h-[36px] w-[36px]"
onClick={() => {
removeMemberMutation.mutate({ teamId, memberId: member.id });
removeMemberMutation.mutate({
teamId: teamId,
memberId: member.id,
});
}}
/>
)}
@@ -79,6 +79,9 @@ const MembersView = () => {
const showDialog = searchParams?.get("inviteModal") === "true";
const [showMemberInvitationModal, setShowMemberInvitationModal] = useState(showDialog);
const [showInviteLinkSettingsModal, setInviteLinkSettingsModal] = useState(false);
const { data: currentOrg } = trpc.viewer.organizations.listCurrent.useQuery(undefined, {
enabled: !!session.data?.user?.organizationId,
});
const { data: orgMembersNotInThisTeam, isLoading: isOrgListLoading } =
trpc.viewer.organizations.getMembers.useQuery(
@@ -109,13 +112,17 @@ const MembersView = () => {
const isAdmin =
team && (team.membership.role === MembershipRole.OWNER || team.membership.role === MembershipRole.ADMIN);
const isOrgAdminOrOwner =
currentOrg &&
(currentOrg.user.role === MembershipRole.OWNER || currentOrg.user.role === MembershipRole.ADMIN);
return (
<>
<Meta
title={t("team_members")}
description={t("members_team_description")}
CTA={
isAdmin ? (
isAdmin || isOrgAdminOrOwner ? (
<Button
type="button"
color="primary"
@@ -152,7 +159,7 @@ const MembersView = () => {
</>
)}
{((team?.isPrivate && isAdmin) || !team?.isPrivate) && (
{((team?.isPrivate && isAdmin) || !team?.isPrivate || isOrgAdminOrOwner) && (
<>
<MembersList team={team} />
<hr className="border-subtle my-8" />
@@ -167,7 +174,7 @@ const MembersView = () => {
/>
)}
{team && isAdmin && (
{team && (isAdmin || isOrgAdminOrOwner) && (
<>
<hr className="border-subtle my-8" />
<MakeTeamPrivateSwitch teamId={team.id} isPrivate={team.isPrivate} disabled={isInviteOpen} />
@@ -244,7 +244,10 @@ const SettingsSidebarContainer = ({
}
}, [searchParams?.get("id"), otherTeams]);
if (currentOrg && currentOrg?.user?.role && ["OWNER", "ADMIN"].includes(currentOrg?.user?.role)) {
const isOrgAdminOrOwner =
currentOrg && currentOrg?.user?.role && ["OWNER", "ADMIN"].includes(currentOrg?.user?.role);
if (isOrgAdminOrOwner) {
const teamsIndex = tabsWithPermissions.findIndex((tab) => tab.name === "teams");
tabsWithPermissions.splice(teamsIndex + 1, 0, {
@@ -325,7 +328,7 @@ const SettingsSidebarContainer = ({
as="p"
className="truncate text-sm font-medium leading-5"
loadingClassName="ms-3">
{t(tab.name)}
{t(isOrgAdminOrOwner ? "my_teams" : tab.name)}
</Skeleton>
</div>
</Link>
@@ -517,7 +520,7 @@ const SettingsSidebarContainer = ({
alt={otherTeam.name || "Team logo"}
/>
<p className="w-1/2 truncate">{otherTeam.name}</p>
{!otherTeam.accepted && (
{!otherTeam.accepted && otherTeam.userId === session.data?.user.id && (
<Badge className="ms-3" variant="orange">
Inv.
</Badge>
@@ -525,7 +528,8 @@ const SettingsSidebarContainer = ({
</div>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-0.5">
{otherTeam.accepted && (
{((otherTeam.accepted && otherTeam.userId === session.data?.user.id) ||
isOrgAdminOrOwner) && (
<VerticalTabItem
name={t("profile")}
href={`/settings/organizations/teams/other/${otherTeam.id}/profile`}
@@ -37,6 +37,7 @@ export const listOtherTeamHandler = async ({ ctx }: ListOptions) => {
return teamsInOrgIamNotPartOf.map(({ team, ...membership }) => ({
role: membership.role,
accepted: membership.accepted,
userId: membership.userId,
isOrgAdmin: true,
...team,
}));
@@ -66,6 +66,7 @@ export const createHandler = async ({ ctx, input }: CreateOptions) => {
const createTeam = await prisma.team.create({
data: {
...(isOrgChildTeam ? { slug } : {}),
name,
logo,
members: {
@@ -75,9 +76,11 @@ export const createHandler = async ({ ctx, input }: CreateOptions) => {
accepted: true,
},
},
metadata: {
requestedSlug: slug,
},
metadata: !isOrgChildTeam
? {
requestedSlug: slug,
}
: undefined,
...(isOrgChildTeam && { parentId: user.organizationId }),
},
});
@@ -15,7 +15,10 @@ type GetOptions = {
};
export const getHandler = async ({ ctx, input }: GetOptions) => {
const team = await getTeamWithMembers({ id: input.teamId, userId: ctx.user.id });
const team = await getTeamWithMembers({
id: input.teamId,
userId: ctx.user.organization?.isOrgAdmin ? undefined : ctx.user.id,
});
if (!team) {
throw new TRPCError({ code: "NOT_FOUND", message: "Team not found." });
@@ -35,7 +35,12 @@ export const inviteMemberHandler = async ({ ctx, input }: InviteMemberOptions) =
const team = await getTeamOrThrow(input.teamId, input.isOrg);
const { autoAcceptEmailDomain, orgVerified } = getIsOrgVerified(input.isOrg, team);
await checkPermissions({ userId: ctx.user.id, teamId: input.teamId, isOrg: input.isOrg });
await checkPermissions({
userId: ctx.user.id,
teamId:
ctx.user.organization.id && ctx.user.organization.isOrgAdmin ? ctx.user.organization.id : input.teamId,
isOrg: input.isOrg,
});
const translation = await getTranslation(input.language ?? "en", "common");
@@ -20,12 +20,15 @@ type RemoveMemberOptions = {
export const removeMemberHandler = async ({ ctx, input }: RemoveMemberOptions) => {
const isAdmin = await isTeamAdmin(ctx.user.id, input.teamId);
const isOrgAdmin = ctx.user.organizationId
? await isTeamAdmin(ctx.user.id, ctx.user.organizationId)
: false;
if (!isAdmin && ctx.user.id !== input.memberId) throw new TRPCError({ code: "UNAUTHORIZED" });
// Only a team owner can remove another team owner.
if ((await isTeamOwner(input.memberId, input.teamId)) && !(await isTeamOwner(ctx.user.id, input.teamId)))
throw new TRPCError({ code: "UNAUTHORIZED" });
if (ctx.user.id === input.memberId && isAdmin)
if (ctx.user.id === input.memberId && isAdmin && !isOrgAdmin)
throw new TRPCError({
code: "FORBIDDEN",
message: "You can not remove yourself from a team you own.",