diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index d172f4df25..ebc27343d8 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" diff --git a/packages/features/ee/organizations/pages/components/OtherTeamsListing.tsx b/packages/features/ee/organizations/pages/components/OtherTeamsListing.tsx index 153ec79155..824b2be8ec 100644 --- a/packages/features/ee/organizations/pages/components/OtherTeamsListing.tsx +++ b/packages/features/ee/organizations/pages/components/OtherTeamsListing.tsx @@ -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 ; } @@ -28,7 +26,7 @@ export function OtherTeamsListing() { <> {!!errorMessage && } - {teams.length > 0 ? ( + {teams && teams.length > 0 ? ( ) : ( { const router = useRouter(); const searchParams = useSearchParams(); const teamId = Number(searchParams.get("id")); - + const session = useSession(); const utils = trpc.useContext(); const [offset, setOffset] = useState(1); // const [query, setQuery] = useState(""); @@ -60,6 +63,9 @@ const MembersView = () => { const limit = 100; const [showMemberInvitationModal, setShowMemberInvitationModal] = useState(false); const [members, setMembers] = useState([]); + 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 ( <> setShowMemberInvitationModal(true)} - // data-testid="new-member-button"> - // {t("add")} - // - // } + CTA={ + isOrgAdminOrOwner ? ( + setShowMemberInvitationModal(true)} + data-testid="new-member-button"> + {t("add")} + + ) : ( + <>> + ) + } /> {!isLoading && ( <> @@ -157,6 +169,7 @@ const MembersView = () => { setShowMemberInvitationModal(false)} diff --git a/packages/features/ee/teams/components/AddNewTeamMembers.tsx b/packages/features/ee/teams/components/AddNewTeamMembers.tsx index 5222b1be74..f58076bca2 100644 --- a/packages/features/ee/teams/components/AddNewTeamMembers.tsx +++ b/packages/features/ee/teams/components/AddNewTeamMembers.tsx @@ -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 ( <> - - {defaultValues.members.map((member, index) => ( - - ))} - + {defaultValues.members.length > 0 && ( + + {defaultValues.members.map((member, index) => ( + + ))} + + )} 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")} @@ -99,6 +108,7 @@ export const AddNewTeamMembersForm = ({ setMemberInviteModal(false)} @@ -162,14 +172,18 @@ export const AddNewTeamMembersForm = ({ )} { - publishTeamMutation.mutate({ teamId }); + if (orgBranding) { + router.push("/settings/teams"); + } else { + publishTeamMutation.mutate({ teamId }); + } }}> - {t("team_publish")} + {t(orgBranding ? "finish" : "team_publish")} > ); @@ -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 ( {member.name || member.email || t("team_member")} {/* Assume that the first member of the team is the creator */} - {index === 0 && {t("you")}} + {member.id === session.data?.user.id && {t("you")}} {!member.accepted && {t("pending")}} {member.role === "MEMBER" && {t("member")}} {member.role === "ADMIN" && {t("admin")}} @@ -241,7 +263,7 @@ const PendingMemberItem = (props: { member: TeamMember; index: number; teamId: n )} - {member.role !== "OWNER" && ( + {(member.role !== "OWNER" || isOrgAdminOrOwner) && ( { - removeMemberMutation.mutate({ teamId, memberId: member.id }); + removeMemberMutation.mutate({ + teamId: teamId, + memberId: member.id, + }); }} /> )} diff --git a/packages/features/ee/teams/pages/team-members-view.tsx b/packages/features/ee/teams/pages/team-members-view.tsx index 4255a798d3..12390b7443 100644 --- a/packages/features/ee/teams/pages/team-members-view.tsx +++ b/packages/features/ee/teams/pages/team-members-view.tsx @@ -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 ( <> { > )} - {((team?.isPrivate && isAdmin) || !team?.isPrivate) && ( + {((team?.isPrivate && isAdmin) || !team?.isPrivate || isOrgAdminOrOwner) && ( <> @@ -167,7 +174,7 @@ const MembersView = () => { /> )} - {team && isAdmin && ( + {team && (isAdmin || isOrgAdminOrOwner) && ( <> diff --git a/packages/features/settings/layouts/SettingsLayout.tsx b/packages/features/settings/layouts/SettingsLayout.tsx index 325b26d73b..7a29857059 100644 --- a/packages/features/settings/layouts/SettingsLayout.tsx +++ b/packages/features/settings/layouts/SettingsLayout.tsx @@ -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)} @@ -517,7 +520,7 @@ const SettingsSidebarContainer = ({ alt={otherTeam.name || "Team logo"} /> {otherTeam.name} - {!otherTeam.accepted && ( + {!otherTeam.accepted && otherTeam.userId === session.data?.user.id && ( Inv. @@ -525,7 +528,8 @@ const SettingsSidebarContainer = ({ - {otherTeam.accepted && ( + {((otherTeam.accepted && otherTeam.userId === session.data?.user.id) || + isOrgAdminOrOwner) && ( { return teamsInOrgIamNotPartOf.map(({ team, ...membership }) => ({ role: membership.role, accepted: membership.accepted, + userId: membership.userId, isOrgAdmin: true, ...team, })); diff --git a/packages/trpc/server/routers/viewer/teams/create.handler.ts b/packages/trpc/server/routers/viewer/teams/create.handler.ts index c6d3f61fba..2b861c7d88 100644 --- a/packages/trpc/server/routers/viewer/teams/create.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/create.handler.ts @@ -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 }), }, }); diff --git a/packages/trpc/server/routers/viewer/teams/get.handler.ts b/packages/trpc/server/routers/viewer/teams/get.handler.ts index 623e75ee38..2f2cdd1352 100644 --- a/packages/trpc/server/routers/viewer/teams/get.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/get.handler.ts @@ -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." }); diff --git a/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.ts b/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.ts index cfaba09ba2..79536c7547 100644 --- a/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.ts @@ -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"); diff --git a/packages/trpc/server/routers/viewer/teams/removeMember.handler.ts b/packages/trpc/server/routers/viewer/teams/removeMember.handler.ts index f6fb0b6a88..2a486d7177 100644 --- a/packages/trpc/server/routers/viewer/teams/removeMember.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/removeMember.handler.ts @@ -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.",
{member.name || member.email || t("team_member")}
{otherTeam.name}