feat: org owner-admin cant see the teams created by other owner-admin 9982 cal 2120 (#10430)

* WIP

* New files for other teams

* Update code domain model location

* Fixes for finding teams in orgs

* VerticalTabs remove other teams appearance and only display if org admin

* Added conditional to consider orgs admin when updating team profile

* team-member-list wip

* Update memberInviteModal with disableCopyLink prop

* cleaning up

* Undo changes to files

* remove logs

* clean up

* undo changes

* undo change

* Undo changes

* Reset files to main

* typo on import

* Update packages/features/ee/organizations/pages/components/OtherTeamListItem.tsx

Co-authored-by: Omar López <zomars@me.com>

* useSearchParams hook instead of useRouter

* remove unused code and clean up

* Fix type

---------

Co-authored-by: Omar López <zomars@me.com>
This commit is contained in:
alannnc 2023-08-02 12:09:43 -07:00 committed by GitHub
parent 95dee6dd37
commit 871e17a865
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 1602 additions and 188 deletions

View File

@ -0,0 +1,9 @@
import TeamAppearenceView from "@calcom/features/ee/teams/pages/team-appearance-view";
import type { CalPageWrapper } from "@components/PageWrapper";
import PageWrapper from "@components/PageWrapper";
const Page = TeamAppearenceView as CalPageWrapper;
Page.PageWrapper = PageWrapper;
export default Page;

View File

@ -0,0 +1,9 @@
import TeamMembersView from "@calcom/features/ee/organizations/pages/settings/other-team-members-view";
import type { CalPageWrapper } from "@components/PageWrapper";
import PageWrapper from "@components/PageWrapper";
const Page = TeamMembersView as CalPageWrapper;
Page.PageWrapper = PageWrapper;
export default Page;

View File

@ -0,0 +1,9 @@
import OtherTeamProfileView from "@calcom/features/ee/organizations/pages/settings/other-team-profile-view";
import type { CalPageWrapper } from "@components/PageWrapper";
import PageWrapper from "@components/PageWrapper";
const Page = OtherTeamProfileView as CalPageWrapper;
Page.PageWrapper = PageWrapper;
export default Page;

View File

@ -0,0 +1,9 @@
import OtherTeamListView from "@calcom/features/ee/organizations/pages/settings/other-team-listing-view";
import PageWrapper from "@components/PageWrapper";
import type { CalPageWrapper } from "@components/PageWrapper";
const Page = OtherTeamListView as CalPageWrapper;
Page.PageWrapper = PageWrapper;
export default Page;

View File

@ -1972,5 +1972,9 @@
"what_is_this_meeting_about": "What is this meeting about?",
"requires_booker_email_verification": "Requires booker email verification",
"description_requires_booker_email_verification": "To ensure booker's email verification before scheduling events",
"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.",
"no_other_teams_found": "No other teams found",
"no_other_teams_found_description": "There are no other teams in this organization.",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}

View File

@ -0,0 +1,112 @@
import classNames from "classnames";
import TeamPill, { TeamRole } from "@calcom/ee/teams/components/TeamPill";
import { useBookerUrl } from "@calcom/lib/hooks/useBookerUrl";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc/react";
import {
Avatar,
Button,
ButtonGroup,
Dropdown,
DropdownItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Tooltip,
} from "@calcom/ui";
import { ExternalLink, MoreHorizontal } from "@calcom/ui/components/icon";
interface Props {
member: RouterOutputs["viewer"]["organizations"]["listOtherTeamMembers"][number];
}
export default function MemberListItem(props: Props) {
const { t } = useLocale();
const { member } = props;
const { user } = member;
const bookerUrl = useBookerUrl();
const bookerUrlWithoutProtocol = bookerUrl.replace(/^https?:\/\//, "");
const bookingLink = user.username && `${bookerUrlWithoutProtocol}/${user.username}`;
const name = user.name || user.username || user.email;
return (
<li className="divide-subtle divide-y px-5">
<div className="my-4 flex justify-between">
<div className="flex w-full flex-col justify-between sm:flex-row">
<div className="flex">
<Avatar
size="sm"
imageSrc={bookerUrl + "/" + user.username + "/avatar.png"}
alt={name || ""}
className="h-10 w-10 rounded-full"
/>
<div className="ms-3 inline-block">
<div className="mb-1 flex">
<span className="text-default mr-1 text-sm font-bold leading-4">{name}</span>
{!props.member.accepted && <TeamPill color="orange" text={t("pending")} />}
{props.member.role && <TeamRole role={props.member.role} />}
</div>
<div className="text-default flex items-center">
<span className=" block text-sm" data-testid="member-email" data-email={user.email}>
{user.email}
</span>
{user.username != null && (
<>
<span className="text-default mx-2 block"></span>
<a
target="_blank"
href={`${bookerUrl}/${user.username}`}
className="text-default block text-sm">
{bookingLink}
</a>
</>
)}
</div>
</div>
</div>
</div>
{member.accepted && user.username && (
<div className="flex items-center justify-center">
<ButtonGroup combined containerProps={{ className: "border-default hidden md:flex" }}>
<Tooltip content={t("view_public_page")}>
<Button
target="_blank"
href={`${bookerUrl}/${user.username}`}
color="secondary"
className={classNames("rounded-r-md")}
variant="icon"
StartIcon={ExternalLink}
disabled={!member.accepted}
/>
</Tooltip>
</ButtonGroup>
<div className="flex md:hidden">
<Dropdown>
<DropdownMenuTrigger asChild>
<Button type="button" variant="icon" color="minimal" StartIcon={MoreHorizontal} />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem className="outline-none">
<DropdownItem
disabled={!member.accepted}
href={"/" + user.username}
target="_blank"
type="button"
StartIcon={ExternalLink}>
{t("view_public_page")}
</DropdownItem>
</DropdownMenuItem>
</DropdownMenuContent>
</Dropdown>
</div>
</div>
)}
</div>
</li>
);
}

View File

@ -0,0 +1,58 @@
import { useState } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import { showToast } from "@calcom/ui";
import OtherTeamListItem from "./OtherTeamListItem";
interface Props {
teams: RouterOutputs["viewer"]["organizations"]["listOtherTeams"];
pending?: boolean;
}
export default function OtherTeamList(props: Props) {
const utils = trpc.useContext();
const { t } = useLocale();
const [hideDropdown, setHideDropdown] = useState(false);
function selectAction(action: string, teamId: number) {
switch (action) {
case "disband":
deleteTeam(teamId);
break;
}
}
const deleteTeamMutation = trpc.viewer.teams.delete.useMutation({
async onSuccess() {
await utils.viewer.teams.list.invalidate();
await utils.viewer.teams.hasTeamPlan.invalidate();
},
async onError(err) {
showToast(err.message, "error");
},
});
function deleteTeam(teamId: number) {
deleteTeamMutation.mutate({ teamId });
}
return (
<ul className="bg-default divide-subtle border-subtle mb-2 divide-y overflow-hidden rounded-md border">
{props.teams.map((team) => (
<OtherTeamListItem
key={team?.id as number}
team={team}
onActionSelect={(action: string) => selectAction(action, team?.id as number)}
isLoading={deleteTeamMutation.isLoading}
hideDropdown={hideDropdown}
setHideDropdown={setHideDropdown}
/>
))}
</ul>
);
}

View File

@ -0,0 +1,166 @@
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { MembershipRole } from "@calcom/prisma/enums";
import type { RouterOutputs } from "@calcom/trpc/react";
import {
Avatar,
Button,
ButtonGroup,
ConfirmationDialogContent,
Dialog,
DialogTrigger,
Dropdown,
DropdownItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
showToast,
Tooltip,
} from "@calcom/ui";
import { Edit2, ExternalLink, Link as LinkIcon, MoreHorizontal, Trash } from "@calcom/ui/components/icon";
import { useOrgBranding } from "../../../organizations/context/provider";
interface Props {
team: RouterOutputs["viewer"]["organizations"]["listOtherTeams"][number];
key: number;
onActionSelect: (text: string) => void;
isLoading?: boolean;
hideDropdown: boolean;
setHideDropdown: (value: boolean) => void;
}
export default function OtherTeamListItem(props: Props) {
const { t } = useLocale();
const team = props.team;
const orgBranding = useOrgBranding();
const isOwner = props.team.role === MembershipRole.OWNER;
const { hideDropdown, setHideDropdown } = props;
if (!team) return <></>;
const teamInfo = (
<div className="item-center flex px-5 py-5">
<Avatar
size="md"
imageSrc={getPlaceholderAvatar(team?.logo, team?.name as string)}
alt="Team Logo"
className="inline-flex justify-center"
/>
<div className="ms-3 inline-block truncate">
<span className="text-default text-sm font-bold">{team.name}</span>
<span className="text-muted block text-xs">
{team.slug
? orgBranding
? `${orgBranding.fullDomain}${team.slug}`
: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/team/${team.slug}`
: "Unpublished team"}
</span>
</div>
</div>
);
return (
<li>
<div className="hover:bg-muted group flex items-center justify-between">
{teamInfo}
<div className="px-5 py-5">
<div className="flex space-x-2 rtl:space-x-reverse">
<ButtonGroup combined>
{team.slug && (
<Tooltip content={t("copy_link_team")}>
<Button
color="secondary"
onClick={() => {
navigator.clipboard.writeText(
`${
orgBranding
? `${orgBranding.fullDomain}`
: process.env.NEXT_PUBLIC_WEBSITE_URL + "/team/"
}${team.slug}`
);
showToast(t("link_copied"), "success");
}}
variant="icon"
StartIcon={LinkIcon}
/>
</Tooltip>
)}
<Dropdown>
<DropdownMenuTrigger asChild>
<Button
className="radix-state-open:rounded-r-md"
type="button"
color="secondary"
variant="icon"
StartIcon={MoreHorizontal}
/>
</DropdownMenuTrigger>
<DropdownMenuContent hidden={hideDropdown}>
<DropdownMenuItem>
<DropdownItem
type="button"
href={"/settings/teams/other/" + team.id + "/profile"}
StartIcon={Edit2}>
{t("edit_team") as string}
</DropdownItem>
</DropdownMenuItem>
{team.slug && (
<DropdownMenuItem>
<DropdownItem
type="button"
target="_blank"
href={`${
orgBranding
? `${orgBranding.fullDomain}`
: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/team/other/`
}${team.slug}`}
StartIcon={ExternalLink}>
{t("preview_team") as string}
</DropdownItem>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
{isOwner && (
<DropdownMenuItem>
<Dialog open={hideDropdown} onOpenChange={setHideDropdown}>
<DialogTrigger asChild>
<DropdownItem
color="destructive"
type="button"
StartIcon={Trash}
onClick={(e) => {
e.stopPropagation();
}}>
{t("disband_team")}
</DropdownItem>
</DialogTrigger>
<ConfirmationDialogContent
variety="danger"
title={t("disband_team")}
confirmBtnText={t("confirm_disband_team")}
isLoading={props.isLoading}
onConfirm={() => {
props.onActionSelect("disband");
}}>
{t("disband_team_confirmation_message")}
</ConfirmationDialogContent>
</Dialog>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</Dropdown>
</ButtonGroup>
</div>
</div>
</div>
</li>
);
}

View File

@ -0,0 +1,42 @@
import { useMemo, useState } from "react";
import SkeletonLoaderTeamList from "@calcom/ee/teams/components/SkeletonloaderTeamList";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Alert, EmptyScreen } from "@calcom/ui";
import OtherTeamList from "./OtherTeamList";
export function OtherTeamsListing() {
const { t } = useLocale();
const [errorMessage, setErrorMessage] = useState("");
const { data, 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 />;
}
return (
<>
{!!errorMessage && <Alert severity="error" title={errorMessage} />}
{teams.length > 0 ? (
<OtherTeamList teams={teams} />
) : (
<EmptyScreen
headline={t("no_other_teams_found")}
title={t("no_other_teams_found")}
description={t("no_other_teams_found_description")}
/>
)}
</>
);
}

View File

@ -0,0 +1,19 @@
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Meta } from "@calcom/ui";
import { getLayout } from "../../../../settings/layouts/SettingsLayout";
import { OtherTeamsListing } from "./../components/OtherTeamsListing";
const OtherTeamListingView = (): React.ReactElement => {
const { t } = useLocale();
return (
<>
<Meta title={t("org_admin_other_teams")} description={t("org_admin_other_teams_description")} />
<OtherTeamsListing />
</>
);
};
OtherTeamListingView.getLayout = getLayout;
export default OtherTeamListingView;

View File

@ -0,0 +1,214 @@
// import { debounce } from "lodash";
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 { trpc } from "@calcom/trpc/react";
import type { RouterOutputs } from "@calcom/trpc/react";
import { Meta, showToast } from "@calcom/ui";
import { getLayout } from "../../../../settings/layouts/SettingsLayout";
import MakeTeamPrivateSwitch from "../../../teams/components/MakeTeamPrivateSwitch";
import MemberListItem from "../components/MemberListItem";
type Members = RouterOutputs["viewer"]["organizations"]["listOtherTeamMembers"];
type Team = RouterOutputs["viewer"]["organizations"]["getOtherTeam"];
interface MembersListProps {
members: Members | undefined;
team: Team | undefined;
offset: number;
setOffset: (offset: number) => void;
displayLoadMore: boolean;
}
function MembersList(props: MembersListProps) {
const { t } = useLocale();
const { displayLoadMore, members, team } = props;
return (
<div className="flex flex-col gap-y-3">
{members?.length && team ? (
<ul className="divide-subtle border-subtle divide-y rounded-md border ">
{members.map((member) => {
return <MemberListItem key={member.id} member={member} />;
})}
</ul>
) : null}
{displayLoadMore && (
<button
className="text-primary-500 hover:text-primary-600"
onClick={() => props.setOffset(props.offset + 1)}>
{t("load_more")}
</button>
)}
</div>
);
}
const MembersView = () => {
const { t, i18n } = useLocale();
const router = useRouter();
const searchParams = useSearchParams();
const teamId = Number(searchParams.get("id"));
const utils = trpc.useContext();
const [offset, setOffset] = useState<number>(1);
// const [query, setQuery] = useState<string | undefined>("");
// const [queryToFetch, setQueryToFetch] = useState<string | undefined>("");
const [loadMore, setLoadMore] = useState<boolean>(true);
const limit = 100;
const [showMemberInvitationModal, setShowMemberInvitationModal] = useState<boolean>(false);
const [members, setMembers] = useState<Members>([]);
const { data: team, isLoading: isTeamLoading } = trpc.viewer.organizations.getOtherTeam.useQuery(
{ teamId },
{
onError: () => {
router.push("/settings");
},
}
);
const { data: membersFetch, isLoading: isLoadingMembers } =
trpc.viewer.organizations.listOtherTeamMembers.useQuery(
{ teamId, limit, offset: (offset - 1) * limit },
{
onError: () => {
router.push("/settings");
},
}
);
useEffect(() => {
if (membersFetch) {
if (membersFetch.length < limit) {
setLoadMore(false);
} else {
setLoadMore(true);
}
setMembers(members.concat(membersFetch));
}
}, [membersFetch]);
// useEffect(() => {
// if (queryToFetch !== "") {
// setMembers(membersFetch || []);
// setLoadMore(false);
// }
// }, [membersFetch, query]);
const isLoading = isTeamLoading || isLoadingMembers;
const inviteMemberMutation = trpc.viewer.teams.inviteMember.useMutation();
// const debouncedFunction = debounce((query) => {
// setQueryToFetch(query);
// }, 500);
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>
// }
/>
{!isLoading && (
<>
<div>
<>
{/* Currently failing due to re render and loose focus */}
{/* <TextField
type="search"
autoComplete="false"
onChange={(e) => {
setQuery(e.target.value);
debouncedFunction(e.target.value);
}}
value={query}
placeholder={`${t("search")}...`}
/> */}
<MembersList
members={members}
team={team}
setOffset={setOffset}
offset={offset}
displayLoadMore={loadMore}
/>
</>
{team && (
<>
<hr className="border-subtle my-8" />
<MakeTeamPrivateSwitch teamId={team.id} isPrivate={team.isPrivate} disabled={false} />
</>
)}
</div>
{showMemberInvitationModal && team && (
<MemberInvitationModal
isLoading={inviteMemberMutation.isLoading}
isOpen={showMemberInvitationModal}
teamId={team.id}
disableCopyLink={true}
onExit={() => setShowMemberInvitationModal(false)}
onSubmit={(values, resetFields) => {
inviteMemberMutation.mutate(
{
teamId,
language: i18n.language,
role: values.role,
usernameOrEmail: values.emailOrUsername,
sendEmailInvitation: values.sendInviteEmail,
},
{
onSuccess: async (data) => {
await utils.viewer.teams.get.invalidate();
setShowMemberInvitationModal(false);
if (data.sendEmailInvitation) {
if (Array.isArray(data.usernameOrEmail)) {
showToast(
t("email_invite_team_bulk", {
userCount: data.usernameOrEmail.length,
}),
"success"
);
resetFields();
} else {
showToast(
t("email_invite_team", {
email: data.usernameOrEmail,
}),
"success"
);
}
}
},
onError: (error) => {
showToast(error.message, "error");
},
}
);
}}
onSettingsOpen={() => {
setShowMemberInvitationModal(false);
}}
/>
)}
</>
)}
</>
);
};
MembersView.getLayout = getLayout;
export default MembersView;

View File

@ -0,0 +1,359 @@
import { zodResolver } from "@hookform/resolvers/zod";
import type { Prisma } from "@prisma/client";
import { useSession } from "next-auth/react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useState, useLayoutEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { md } from "@calcom/lib/markdownIt";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import objectKeys from "@calcom/lib/objectKeys";
import turndown from "@calcom/lib/turndownService";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import { SkeletonContainer, SkeletonText } from "@calcom/ui";
import {
Avatar,
Button,
ConfirmationDialogContent,
Dialog,
DialogTrigger,
Form,
ImageUploader,
Label,
LinkIconButton,
Meta,
showToast,
TextField,
Editor,
} from "@calcom/ui";
import { ExternalLink, Link as LinkIcon, Trash2 } from "@calcom/ui/components/icon";
import { getLayout } from "../../../../settings/layouts/SettingsLayout";
import { extractDomainFromWebsiteUrl } from "../../../organizations/lib/utils";
const regex = new RegExp("^[a-zA-Z0-9-]*$");
const teamProfileFormSchema = z.object({
name: z.string(),
slug: z
.string()
.regex(regex, {
message: "Url can only have alphanumeric characters(a-z, 0-9) and hyphen(-) symbol.",
})
.min(1, { message: "Url cannot be left empty" }),
logo: z.string(),
bio: z.string(),
});
const OtherTeamProfileView = () => {
const { t } = useLocale();
const router = useRouter();
const utils = trpc.useContext();
const session = useSession();
const [firstRender, setFirstRender] = useState(true);
useLayoutEffect(() => {
document.body.focus();
}, []);
const mutation = trpc.viewer.teams.update.useMutation({
onError: (err) => {
showToast(err.message, "error");
},
async onSuccess() {
await utils.viewer.teams.get.invalidate();
showToast(t("your_team_updated_successfully"), "success");
},
});
const form = useForm({
resolver: zodResolver(teamProfileFormSchema),
});
const searchParams = useSearchParams();
const teamId = Number(searchParams.get("id"));
const { data: team, isLoading } = trpc.viewer.organizations.getOtherTeam.useQuery(
{ teamId: teamId },
{
enabled: !!teamId,
onError: () => {
router.push("/settings");
},
onSuccess: (team: RouterOutputs["viewer"]["organizations"]["getOtherTeam"]) => {
if (team) {
form.setValue("name", team.name || "");
form.setValue("slug", team.slug || "");
form.setValue("logo", team.logo || "");
form.setValue("bio", team.bio || "");
if (team.slug === null && (team?.metadata as Prisma.JsonObject)?.requestedSlug) {
form.setValue("slug", ((team?.metadata as Prisma.JsonObject)?.requestedSlug as string) || "");
}
}
},
}
);
// This page can only be accessed by team admins (owner/admin)
const isAdmin = true;
const permalink = `${WEBAPP_URL}/team/${team?.slug}`;
const isBioEmpty = !team || !team.bio || !team.bio.replace("<p><br></p>", "").length;
const deleteTeamMutation = trpc.viewer.teams.delete.useMutation({
async onSuccess() {
await utils.viewer.teams.list.invalidate();
showToast(t("your_team_disbanded_successfully"), "success");
router.push(`${WEBAPP_URL}/teams`);
},
});
const removeMemberMutation = trpc.viewer.teams.removeMember.useMutation({
async onSuccess() {
await utils.viewer.teams.get.invalidate();
await utils.viewer.teams.list.invalidate();
await utils.viewer.eventTypes.invalidate();
showToast(t("success"), "success");
},
async onError(err) {
showToast(err.message, "error");
},
});
const publishMutation = trpc.viewer.teams.publish.useMutation({
async onSuccess(data: { url?: string }) {
if (data.url) {
router.push(data.url);
}
},
async onError(err) {
showToast(err.message, "error");
},
});
function deleteTeam() {
if (team?.id) deleteTeamMutation.mutate({ teamId: team.id });
}
function leaveTeam() {
if (team?.id && session.data)
removeMemberMutation.mutate({
teamId: team.id,
memberId: session.data.user.id,
});
}
if (!team) return null;
return (
<>
<Meta title={t("profile")} description={t("profile_team_description")} />
{!isLoading ? (
<>
{isAdmin ? (
<Form
form={form}
handleSubmit={(values) => {
if (team) {
const variables = {
logo: values.logo,
name: values.name,
slug: values.slug,
bio: values.bio,
};
objectKeys(variables).forEach((key) => {
if (variables[key as keyof typeof variables] === team?.[key]) delete variables[key];
});
mutation.mutate({ id: team.id, ...variables });
}
}}>
<div className="flex items-center">
<Controller
control={form.control}
name="logo"
render={({ field: { value } }) => (
<>
<Avatar alt="" imageSrc={getPlaceholderAvatar(value, team?.name as string)} size="lg" />
<div className="ms-4">
<ImageUploader
target="avatar"
id="avatar-upload"
buttonMsg={t("update")}
handleAvatarChange={(newLogo) => {
form.setValue("logo", newLogo);
}}
imageSrc={value}
/>
</div>
</>
)}
/>
</div>
<hr className="border-subtle my-8" />
<Controller
control={form.control}
name="name"
render={({ field: { value } }) => (
<div className="mt-8">
<TextField
name="name"
label={t("team_name")}
value={value}
onChange={(e) => {
form.setValue("name", e?.target.value);
}}
/>
</div>
)}
/>
<Controller
control={form.control}
name="slug"
render={({ field: { value } }) => (
<div className="mt-8">
<TextField
name="slug"
label={t("team_url")}
value={value}
addOnLeading={
team?.parent
? `${team.parent.slug}.${extractDomainFromWebsiteUrl}/`
: `${WEBAPP_URL}/team/`
}
onChange={(e) => {
form.clearErrors("slug");
form.setValue("slug", e?.target.value);
}}
/>
</div>
)}
/>
<div className="mt-8">
<Label>{t("about")}</Label>
<Editor
getText={() => md.render(form.getValues("bio") || "")}
setText={(value: string) => form.setValue("bio", turndown(value))}
excludedToolbarItems={["blockType"]}
disableLists
firstRender={firstRender}
setFirstRender={setFirstRender}
/>
</div>
<p className="text-default mt-2 text-sm">{t("team_description")}</p>
<Button color="primary" className="mt-8" type="submit" loading={mutation.isLoading}>
{t("update")}
</Button>
{IS_TEAM_BILLING_ENABLED &&
team.slug === null &&
(team.metadata as Prisma.JsonObject)?.requestedSlug && (
<Button
color="secondary"
className="ml-2 mt-8"
type="button"
onClick={() => {
publishMutation.mutate({ teamId: team.id });
}}>
Publish
</Button>
)}
</Form>
) : (
<div className="flex">
<div className="flex-grow">
<div>
<Label className="text-emphasis">{t("team_name")}</Label>
<p className="text-default text-sm">{team?.name}</p>
</div>
{team && !isBioEmpty && (
<>
<Label className="text-emphasis mt-5">{t("about")}</Label>
<div
className=" text-subtle break-words text-sm [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600"
dangerouslySetInnerHTML={{ __html: md.render(markdownToSafeHTML(team.bio)) }}
/>
</>
)}
</div>
<div className="">
<Link href={permalink} passHref={true} target="_blank">
<LinkIconButton Icon={ExternalLink}>{t("preview")}</LinkIconButton>
</Link>
<LinkIconButton
Icon={LinkIcon}
onClick={() => {
navigator.clipboard.writeText(permalink);
showToast("Copied to clipboard", "success");
}}>
{t("copy_link_team")}
</LinkIconButton>
</div>
</div>
)}
<hr className="border-subtle my-8 border" />
<div className="text-default mb-3 text-base font-semibold">{t("danger_zone")}</div>
<Dialog>
<DialogTrigger asChild>
<Button color="destructive" className="border" StartIcon={Trash2}>
{t("disband_team")}
</Button>
</DialogTrigger>
<ConfirmationDialogContent
variety="danger"
title={t("disband_team")}
confirmBtnText={t("confirm_disband_team")}
onConfirm={deleteTeam}>
{t("disband_team_confirmation_message")}
</ConfirmationDialogContent>
</Dialog>
</>
) : (
<>
<SkeletonContainer as="form">
<div className="flex items-center">
<div className="ms-4">
<SkeletonContainer>
<div className="bg-emphasis h-16 w-16 rounded-full" />
</SkeletonContainer>
</div>
</div>
<hr className="border-subtle my-8" />
<SkeletonContainer>
<div className="mt-8">
<SkeletonText className="h-6 w-48" />
</div>
</SkeletonContainer>
<SkeletonContainer>
<div className="mt-8">
<SkeletonText className="h-6 w-48" />
</div>
</SkeletonContainer>
<div className="mt-8">
<SkeletonContainer>
<div className="bg-emphasis h-24 rounded-md" />
</SkeletonContainer>
<SkeletonText className="mt-4 h-12 w-32" />
</div>
<SkeletonContainer>
<div className="mt-8">
<SkeletonText className="h-9 w-24" />
</div>
</SkeletonContainer>
</SkeletonContainer>
</>
)}
</>
);
};
OtherTeamProfileView.getLayout = getLayout;
export default OtherTeamProfileView;

View File

@ -37,9 +37,10 @@ type MemberInvitationModalProps = {
onSubmit: (values: NewMemberForm, resetFields: () => void) => void;
onSettingsOpen?: () => void;
teamId: number;
members: PendingMember[];
members?: PendingMember[];
token?: string;
isLoading?: boolean;
disableCopyLink?: boolean;
};
type MembershipRoleOption = {
@ -66,6 +67,7 @@ function toggleElementInArray(value: string[] | string | undefined, element: str
export default function MemberInvitationModal(props: MemberInvitationModalProps) {
const { t } = useLocale();
const { disableCopyLink = false } = props;
const trpcContext = trpc.useContext();
const [modalImportMode, setModalInputMode] = useState<ModalMode>(
@ -119,9 +121,10 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps)
const newMemberFormMethods = useForm<NewMemberForm>();
const validateUniqueInvite = (value: string) => {
if (!props?.members?.length) return true;
return !(
props.members.some((member) => member?.username === value) ||
props.members.some((member) => member?.email === value)
props?.members.some((member) => member?.username === value) ||
props?.members.some((member) => member?.email === value)
);
};
@ -351,23 +354,24 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps)
)}
</div>
<DialogFooter showDivider>
<div className="relative right-40">
<Button
type="button"
color="minimal"
variant="icon"
onClick={() =>
props.token
? copyInviteLinkToClipboard(props.token)
: createInviteMutation.mutate({ teamId: props.teamId })
}
className={classNames("gap-2", props.token && "opacity-50")}
data-testid="copy-invite-link-button">
<Link className="text-default h-4 w-4" aria-hidden="true" />
{t("copy_invite_link")}
</Button>
</div>
{!disableCopyLink && (
<div className="relative right-40">
<Button
type="button"
color="minimal"
variant="icon"
onClick={() =>
props.token
? copyInviteLinkToClipboard(props.token)
: createInviteMutation.mutate({ teamId: props.teamId })
}
className={classNames("gap-2", props.token && "opacity-50")}
data-testid="copy-invite-link-button">
<Link className="text-default h-4 w-4" aria-hidden="true" />
{t("copy_invite_link")}
</Button>
</div>
)}
<Button
type="button"
color="minimal"

View File

@ -97,11 +97,11 @@ export default function MemberListItem(props: Props) {
});
const editMode =
(props.team.membership.role === MembershipRole.OWNER &&
(props.team.membership?.role === MembershipRole.OWNER &&
(props.member.role !== MembershipRole.OWNER ||
ownersInTeam() > 1 ||
props.member.id !== currentUserId)) ||
(props.team.membership.role === MembershipRole.ADMIN && props.member.role !== MembershipRole.OWNER);
(props.team.membership?.role === MembershipRole.ADMIN && props.member.role !== MembershipRole.OWNER);
const impersonationMode =
editMode &&
!props.member.disableImpersonation &&
@ -150,7 +150,7 @@ export default function MemberListItem(props: Props) {
</div>
</div>
</div>
{props.team.membership.accepted && (
{props.team.membership?.accepted && (
<div className="flex items-center justify-center">
<ButtonGroup combined containerProps={{ className: "border-default hidden md:flex" }}>
{/* TODO: bring availability back. right now its ugly and broken

View File

@ -4,7 +4,7 @@ import { Meta } from "@calcom/ui";
import { getLayout } from "../../../settings/layouts/SettingsLayout";
import { TeamsListing } from "../components";
const BillingView = () => {
const TeamListingView = () => {
const { t } = useLocale();
return (
<>
@ -14,6 +14,6 @@ const BillingView = () => {
);
};
BillingView.getLayout = getLayout;
TeamListingView.getLayout = getLayout;
export default BillingView;
export default TeamListingView;

View File

@ -197,13 +197,20 @@ const SettingsSidebarContainer = ({
const tabsWithPermissions = useTabs();
const [teamMenuState, setTeamMenuState] =
useState<{ teamId: number | undefined; teamMenuOpen: boolean }[]>();
const [otherTeamMenuState, setOtherTeamMenuState] = useState<
{
teamId: number | undefined;
teamMenuOpen: boolean;
}[]
>();
const { data: teams } = trpc.viewer.teams.list.useQuery();
const session = useSession();
const { data: currentOrg } = trpc.viewer.organizations.listCurrent.useQuery(undefined, {
enabled: !!session.data?.user?.organizationId,
});
const { data: otherTeams } = trpc.viewer.organizations.listOtherTeams.useQuery();
useEffect(() => {
if (teams) {
const teamStates = teams?.map((team) => ({
@ -220,6 +227,35 @@ const SettingsSidebarContainer = ({
}
}, [searchParams?.get("id"), teams]);
// Same as above but for otherTeams
useEffect(() => {
if (otherTeams) {
const otherTeamStates = otherTeams?.map((team) => ({
teamId: team.id,
teamMenuOpen: String(team.id) === searchParams?.get("id"),
}));
setOtherTeamMenuState(otherTeamStates);
setTimeout(() => {
// @TODO: test if this works for 2 dataset testids
const tabMembers = Array.from(document.getElementsByTagName("a")).filter(
(bottom) => bottom.dataset.testid === "vertical-tab-Members"
)[1];
tabMembers?.scrollIntoView({ behavior: "smooth" });
}, 100);
}
}, [searchParams?.get("id"), otherTeams]);
if (currentOrg && currentOrg?.user?.role && ["OWNER", "ADMIN"].includes(currentOrg?.user?.role)) {
const teamsIndex = tabsWithPermissions.findIndex((tab) => tab.name === "teams");
tabsWithPermissions.splice(teamsIndex + 1, 0, {
name: "other_teams",
href: "/settings/organizations/teams/other",
icon: Users,
children: [],
});
}
return (
<nav
style={{ maxHeight: `calc(100vh - ${bannersHeight}px)`, top: `${bannersHeight}px` }}
@ -234,86 +270,79 @@ const SettingsSidebarContainer = ({
<>
<BackButtonInSidebar name={t("back")} />
{tabsWithPermissions.map((tab) => {
return tab.name !== "teams" ? (
<React.Fragment key={tab.href}>
<div className={`${!tab.children?.length ? "!mb-3" : ""}`}>
<div className="[&[aria-current='page']]:bg-emphasis [&[aria-current='page']]:text-emphasis text-default group flex h-9 w-full flex-row items-center rounded-md px-2 text-sm font-medium leading-none">
{tab && tab.icon && (
<tab.icon className="h-[16px] w-[16px] stroke-[2px] ltr:mr-3 rtl:ml-3 md:mt-0" />
)}
{!tab.icon && tab?.avatar && (
<img
className="h-4 w-4 rounded-full ltr:mr-3 rtl:ml-3"
src={tab?.avatar}
alt="User Avatar"
/>
)}
<Skeleton
title={tab.name}
as="p"
className="truncate text-sm font-medium leading-5"
loadingClassName="ms-3">
{t(tab.name)}
</Skeleton>
</div>
</div>
<div className="my-3 space-y-0.5">
{tab.children?.map((child, index) => (
<VerticalTabItem
key={child.href}
name={t(child.name)}
isExternalLink={child.isExternalLink}
href={child.href || "/"}
textClassNames="px-3 text-emphasis font-medium text-sm"
className={`my-0.5 me-5 h-7 ${
tab.children && index === tab.children?.length - 1 && "!mb-3"
}`}
disableChevron
/>
))}
</div>
</React.Fragment>
) : (
<React.Fragment key={tab.href}>
<div className={`${!tab.children?.length ? "mb-3" : ""}`}>
<Link href={tab.href}>
<div className="hover:bg-subtle [&[aria-current='page']]:bg-emphasis [&[aria-current='page']]:text-emphasis group-hover:text-default text-default group flex h-9 w-full flex-row items-center rounded-md px-2 py-[10px] text-sm font-medium leading-none">
{tab && tab.icon && (
<tab.icon className="h-[16px] w-[16px] stroke-[2px] ltr:mr-3 rtl:ml-3 md:mt-0" />
)}
<Skeleton
title={tab.name}
as="p"
className="truncate text-sm font-medium leading-5"
loadingClassName="ms-3">
{t(tab.name)}
</Skeleton>
return (
<>
{!["teams", "other_teams"].includes(tab.name) && (
<React.Fragment key={tab.href}>
<div className={`${!tab.children?.length ? "!mb-3" : ""}`}>
<div className="[&[aria-current='page']]:bg-emphasis [&[aria-current='page']]:text-emphasis text-default group flex h-9 w-full flex-row items-center rounded-md px-2 text-sm font-medium leading-none">
{tab && tab.icon && (
<tab.icon className="h-[16px] w-[16px] stroke-[2px] ltr:mr-3 rtl:ml-3 md:mt-0" />
)}
{!tab.icon && tab?.avatar && (
<img
className="h-4 w-4 rounded-full ltr:mr-3 rtl:ml-3"
src={tab?.avatar}
alt="User Avatar"
/>
)}
<Skeleton
title={tab.name}
as="p"
className="truncate text-sm font-medium leading-5"
loadingClassName="ms-3">
{t(tab.name)}
</Skeleton>
</div>
</div>
</Link>
{teams &&
teamMenuState &&
teams.map((team, index: number) => {
if (!teamMenuState[index]) {
return null;
}
if (teamMenuState.some((teamState) => teamState.teamId === team.id))
return (
<Collapsible
key={team.id}
open={teamMenuState[index].teamMenuOpen}
onOpenChange={() =>
setTeamMenuState([
...teamMenuState,
(teamMenuState[index] = {
...teamMenuState[index],
teamMenuOpen: !teamMenuState[index].teamMenuOpen,
}),
])
}>
<CollapsibleTrigger asChild>
<div
className="hover:bg-subtle [&[aria-current='page']]:bg-emphasis [&[aria-current='page']]:text-emphasis text-default flex h-9 w-full flex-row items-center rounded-md px-3 py-[10px] text-left text-sm font-medium leading-none"
onClick={() =>
<div className="my-3 space-y-0.5">
{tab.children?.map((child, index) => (
<VerticalTabItem
key={child.href}
name={t(child.name)}
isExternalLink={child.isExternalLink}
href={child.href || "/"}
textClassNames="px-3 text-emphasis font-medium text-sm"
className={`my-0.5 me-5 h-7 ${
tab.children && index === tab.children?.length - 1 && "!mb-3"
}`}
disableChevron
/>
))}
</div>
</React.Fragment>
)}
{tab.name === "teams" && (
<React.Fragment key={tab.href}>
<div className={`${!tab.children?.length ? "mb-3" : ""}`}>
<Link href={tab.href}>
<div className="hover:bg-subtle [&[aria-current='page']]:bg-emphasis [&[aria-current='page']]:text-emphasis group-hover:text-default text-default group flex h-9 w-full flex-row items-center rounded-md px-2 py-[10px] text-sm font-medium leading-none">
{tab && tab.icon && (
<tab.icon className="h-[16px] w-[16px] stroke-[2px] ltr:mr-3 rtl:ml-3 md:mt-0" />
)}
<Skeleton
title={tab.name}
as="p"
className="truncate text-sm font-medium leading-5"
loadingClassName="ms-3">
{t(tab.name)}
</Skeleton>
</div>
</Link>
{teams &&
teamMenuState &&
teams.map((team, index: number) => {
if (!teamMenuState[index]) {
return null;
}
if (teamMenuState.some((teamState) => teamState.teamId === team.id))
return (
<Collapsible
className="cursor-pointer"
key={team.id}
open={teamMenuState[index].teamMenuOpen}
onOpenChange={() =>
setTeamMenuState([
...teamMenuState,
(teamMenuState[index] = {
@ -322,96 +351,213 @@ const SettingsSidebarContainer = ({
}),
])
}>
<div className="me-3">
{teamMenuState[index].teamMenuOpen ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
<CollapsibleTrigger asChild>
<div
className="hover:bg-subtle [&[aria-current='page']]:bg-emphasis [&[aria-current='page']]:text-emphasis text-default flex h-9 w-full flex-row items-center rounded-md px-3 py-[10px] text-left text-sm font-medium leading-none"
onClick={() =>
setTeamMenuState([
...teamMenuState,
(teamMenuState[index] = {
...teamMenuState[index],
teamMenuOpen: !teamMenuState[index].teamMenuOpen,
}),
])
}>
<div className="me-3">
{teamMenuState[index].teamMenuOpen ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</div>
<img
src={getPlaceholderAvatar(team.logo, team?.name as string)}
className="h-[16px] w-[16px] self-start rounded-full stroke-[2px] ltr:mr-2 rtl:ml-2 md:mt-0"
alt={team.name || "Team logo"}
/>
<p className="w-1/2 truncate">{team.name}</p>
{!team.accepted && (
<Badge className="ms-3" variant="orange">
Inv.
</Badge>
)}
</div>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-0.5">
{team.accepted && (
<VerticalTabItem
name={t("profile")}
href={`/settings/teams/${team.id}/profile`}
textClassNames="px-3 text-emphasis font-medium text-sm"
disableChevron
/>
)}
</div>
<img
src={getPlaceholderAvatar(team.logo, team?.name as string)}
className="h-[16px] w-[16px] self-start rounded-full stroke-[2px] ltr:mr-2 rtl:ml-2 md:mt-0"
alt={team.name || "Team logo"}
/>
<p className="w-1/2 truncate">{team.name}</p>
{!team.accepted && (
<Badge className="ms-3" variant="orange">
Inv.
</Badge>
)}
</div>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-0.5">
{team.accepted && (
<VerticalTabItem
name={t("profile")}
href={`/settings/teams/${team.id}/profile`}
textClassNames="px-3 text-emphasis font-medium text-sm"
disableChevron
/>
)}
<VerticalTabItem
name={t("members")}
href={`/settings/teams/${team.id}/members`}
textClassNames="px-3 text-emphasis font-medium text-sm"
disableChevron
/>
{(team.role === MembershipRole.OWNER ||
team.role === MembershipRole.ADMIN ||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore this exists wtf?
(team.isOrgAdmin && team.isOrgAdmin)) && (
<>
{/* TODO */}
{/* <VerticalTabItem
name={t("general")}
href={`${WEBAPP_URL}/settings/my-account/appearance`}
textClassNames="px-3 text-emphasis font-medium text-sm"
disableChevron
/> */}
<VerticalTabItem
name={t("appearance")}
href={`/settings/teams/${team.id}/appearance`}
name={t("members")}
href={`/settings/teams/${team.id}/members`}
textClassNames="px-3 text-emphasis font-medium text-sm"
disableChevron
/>
{/* Hide if there is a parent ID */}
{!team.parentId ? (
{(team.role === MembershipRole.OWNER ||
team.role === MembershipRole.ADMIN ||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore this exists wtf?
(team.isOrgAdmin && team.isOrgAdmin)) && (
<>
{/* TODO */}
{/* <VerticalTabItem
name={t("general")}
href={`${WEBAPP_URL}/settings/my-account/appearance`}
textClassNames="px-3 text-emphasis font-medium text-sm"
disableChevron
/> */}
<VerticalTabItem
name={t("billing")}
href={`/settings/teams/${team.id}/billing`}
name={t("appearance")}
href={`/settings/teams/${team.id}/appearance`}
textClassNames="px-3 text-emphasis font-medium text-sm"
disableChevron
/>
{HOSTED_CAL_FEATURES && (
<VerticalTabItem
name={t("saml_config")}
href={`/settings/teams/${team.id}/sso`}
textClassNames="px-3 text-emphasis font-medium text-sm"
disableChevron
/>
)}
{/* Hide if there is a parent ID */}
{!team.parentId ? (
<>
<VerticalTabItem
name={t("billing")}
href={`/settings/teams/${team.id}/billing`}
textClassNames="px-3 text-emphasis font-medium text-sm"
disableChevron
/>
{HOSTED_CAL_FEATURES && (
<VerticalTabItem
name={t("saml_config")}
href={`/settings/teams/${team.id}/sso`}
textClassNames="px-3 text-emphasis font-medium text-sm"
disableChevron
/>
)}
</>
) : null}
</>
) : null}
</>
)}
</CollapsibleContent>
</Collapsible>
);
})}
{(!currentOrg || (currentOrg && currentOrg?.user?.role !== "MEMBER")) && (
<VerticalTabItem
name={t("add_a_team")}
href={`${WEBAPP_URL}/settings/teams/new`}
textClassNames="px-3 items-center mt-2 text-emphasis font-medium text-sm"
icon={Plus}
disableChevron
/>
)}
</div>
</React.Fragment>
)}
</CollapsibleContent>
</Collapsible>
);
})}
{(!currentOrg || (currentOrg && currentOrg?.user?.role !== "MEMBER")) && (
<VerticalTabItem
name={t("add_a_team")}
href={`${WEBAPP_URL}/settings/teams/new`}
textClassNames="px-3 items-center mt-2 text-emphasis font-medium text-sm"
icon={Plus}
disableChevron
/>
)}
</div>
</React.Fragment>
)}
{tab.name === "other_teams" && (
<React.Fragment key={tab.href}>
<div className={`${!tab.children?.length ? "mb-3" : ""}`}>
<Link href={tab.href}>
<div className="hover:bg-subtle [&[aria-current='page']]:bg-emphasis [&[aria-current='page']]:text-emphasis group-hover:text-default text-default group flex h-9 w-full flex-row items-center rounded-md px-2 py-[10px] text-sm font-medium leading-none">
{tab && tab.icon && (
<tab.icon className="h-[16px] w-[16px] stroke-[2px] ltr:mr-3 rtl:ml-3 md:mt-0" />
)}
<Skeleton
title={t("org_admin_other_teams")}
as="p"
className="truncate text-sm font-medium leading-5"
loadingClassName="ms-3">
{t("org_admin_other_teams")}
</Skeleton>
</div>
</Link>
{otherTeams &&
otherTeamMenuState &&
otherTeams.map((otherTeam, index: number) => {
if (!otherTeamMenuState[index]) {
return null;
}
if (otherTeamMenuState.some((teamState) => teamState.teamId === otherTeam.id))
return (
<Collapsible
className="cursor-pointer"
key={otherTeam.id}
open={otherTeamMenuState[index].teamMenuOpen}
onOpenChange={() =>
setOtherTeamMenuState([
...otherTeamMenuState,
(otherTeamMenuState[index] = {
...otherTeamMenuState[index],
teamMenuOpen: !otherTeamMenuState[index].teamMenuOpen,
}),
])
}>
<CollapsibleTrigger asChild>
<div
className="hover:bg-subtle [&[aria-current='page']]:bg-emphasis [&[aria-current='page']]:text-emphasis text-default flex h-9 w-full flex-row items-center rounded-md px-3 py-[10px] text-left text-sm font-medium leading-none"
onClick={() =>
setOtherTeamMenuState([
...otherTeamMenuState,
(otherTeamMenuState[index] = {
...otherTeamMenuState[index],
teamMenuOpen: !otherTeamMenuState[index].teamMenuOpen,
}),
])
}>
<div className="me-3">
{otherTeamMenuState[index].teamMenuOpen ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</div>
<img
src={getPlaceholderAvatar(otherTeam.logo, otherTeam?.name as string)}
className="h-[16px] w-[16px] self-start rounded-full stroke-[2px] ltr:mr-2 rtl:ml-2 md:mt-0"
alt={otherTeam.name || "Team logo"}
/>
<p className="w-1/2 truncate">{otherTeam.name}</p>
{!otherTeam.accepted && (
<Badge className="ms-3" variant="orange">
Inv.
</Badge>
)}
</div>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-0.5">
{otherTeam.accepted && (
<VerticalTabItem
name={t("profile")}
href={`/settings/organizations/teams/other/${otherTeam.id}/profile`}
textClassNames="px-3 text-emphasis font-medium text-sm"
disableChevron
/>
)}
<VerticalTabItem
name={t("members")}
href={`/settings/organizations/teams/other/${otherTeam.id}/members`}
textClassNames="px-3 text-emphasis font-medium text-sm"
disableChevron
/>
<>
{/* TODO: enable appearance edit */}
{/* <VerticalTabItem
name={t("appearance")}
href={`/settings/organizations/teams/other/${otherTeam.id}/appearance`}
textClassNames="px-3 text-emphasis font-medium text-sm"
disableChevron
/> */}
</>
</CollapsibleContent>
</Collapsible>
);
})}
</div>
</React.Fragment>
)}
</>
);
})}
</>

View File

@ -174,4 +174,13 @@ export const isAdminMiddleware = isAuthed.unstable_pipe(({ ctx, next }) => {
return next({ ctx: { ...ctx, user: user } });
});
// Org admins can be admins or owners
export const isOrgAdminMiddleware = isAuthed.unstable_pipe(({ ctx, next }) => {
const { user } = ctx;
if (!user?.organization?.isOrgAdmin) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({ ctx: { ...ctx, user: user } });
});
export default sessionMiddleware;

View File

@ -1,5 +1,5 @@
import perfMiddleware from "../middlewares/perfMiddleware";
import { isAdminMiddleware, isAuthed } from "../middlewares/sessionMiddleware";
import { isAdminMiddleware, isAuthed, isOrgAdminMiddleware } from "../middlewares/sessionMiddleware";
import { procedure } from "../trpc";
import publicProcedure from "./publicProcedure";
@ -27,5 +27,6 @@ const authedProcedure = procedure.use(perfMiddleware).use(isAuthed);
/*export const authedRateLimitedProcedure = ({ intervalInMs, limit }: IRateLimitOptions) =>
authedProcedure.use(isRateLimitedByUserIdMiddleware({ intervalInMs, limit }));*/
export const authedAdminProcedure = publicProcedure.use(isAdminMiddleware);
export const authedOrgAdminProcedure = publicProcedure.use(isOrgAdminMiddleware);
export default authedProcedure;

View File

@ -1,12 +1,17 @@
import { ZVerifyCodeInputSchema } from "@calcom/prisma/zod-utils";
import authedProcedure, { authedAdminProcedure } from "../../../procedures/authedProcedure";
import authedProcedure, {
authedAdminProcedure,
authedOrgAdminProcedure,
} from "../../../procedures/authedProcedure";
import { router } from "../../../trpc";
import { ZAdminVerifyInput } from "./adminVerify.schema";
import { ZCreateInputSchema } from "./create.schema";
import { ZCreateTeamsSchema } from "./createTeams.schema";
import { ZGetMembersInput } from "./getMembers.schema";
import { ZGetOtherTeamInputSchema } from "./getOtherTeam.handler";
import { ZListMembersSchema } from "./listMembers.schema";
import { ZListOtherTeamMembersSchema } from "./listOtherTeamMembers.handler";
import { ZSetPasswordSchema } from "./setPassword.schema";
import { ZUpdateInputSchema } from "./update.schema";
@ -24,6 +29,9 @@ type OrganizationsRouterHandlerCache = {
listMembers?: typeof import("./listMembers.handler").listMembersHandler;
getBrand?: typeof import("./getBrand.handler").getBrandHandler;
getMembers?: typeof import("./getMembers.handler").getMembersHandler;
listOtherTeams?: typeof import("./listOtherTeams.handler").listOtherTeamHandler;
getOtherTeam?: typeof import("./getOtherTeam.handler").getOtherTeamHandler;
listOtherTeamMembers?: typeof import("./listOtherTeamMembers.handler").listOtherTeamMembers;
};
const UNSTABLE_HANDLER_CACHE: OrganizationsRouterHandlerCache = {};
@ -227,4 +235,56 @@ export const viewerOrganizationsRouter = router({
ctx,
});
}),
listOtherTeams: authedOrgAdminProcedure.query(async ({ ctx }) => {
if (!UNSTABLE_HANDLER_CACHE.listOtherTeams) {
UNSTABLE_HANDLER_CACHE.listOtherTeams = await import("./listOtherTeams.handler").then(
(mod) => mod.listOtherTeamHandler
);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.listOtherTeams) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.listOtherTeams({
ctx,
});
}),
getOtherTeam: authedOrgAdminProcedure.input(ZGetOtherTeamInputSchema).query(async ({ ctx, input }) => {
if (!UNSTABLE_HANDLER_CACHE.getOtherTeam) {
UNSTABLE_HANDLER_CACHE.getOtherTeam = await import("./getOtherTeam.handler").then(
(mod) => mod.getOtherTeamHandler
);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.getOtherTeam) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.getOtherTeam({
ctx,
input,
});
}),
listOtherTeamMembers: authedOrgAdminProcedure
.input(ZListOtherTeamMembersSchema)
.query(async ({ ctx, input }) => {
if (!UNSTABLE_HANDLER_CACHE.listOtherTeamMembers) {
UNSTABLE_HANDLER_CACHE.listOtherTeamMembers = await import("./listOtherTeamMembers.handler").then(
(mod) => mod.listOtherTeamMembers
);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.listOtherTeamMembers) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.listOtherTeamMembers({
ctx,
input,
});
}),
});

View File

@ -0,0 +1,54 @@
import { z } from "zod";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import prisma from "@calcom/prisma";
import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../trpc";
export const ZGetOtherTeamInputSchema = z.object({
teamId: z.number(),
});
export type TGetOtherTeamInputSchema = z.infer<typeof ZGetOtherTeamInputSchema>;
type GetOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TGetOtherTeamInputSchema;
};
export const getOtherTeamHandler = async ({ input }: GetOptions) => {
// No need to validate if user is admin of org as we already do that on authedOrgAdminProcedure
const team = await prisma.team.findFirst({
where: {
id: input.teamId,
},
select: {
id: true,
name: true,
slug: true,
logo: true,
bio: true,
metadata: true,
isPrivate: true,
parent: {
select: {
id: true,
slug: true,
},
},
},
});
if (!team) {
throw new TRPCError({ code: "NOT_FOUND", message: "Team not found." });
}
return {
...team,
safeBio: markdownToSafeHTML(team.bio),
};
};

View File

@ -0,0 +1,81 @@
import type { Prisma } from "@prisma/client";
import z from "zod";
import { prisma } from "@calcom/prisma";
import type { TrpcSessionUser } from "../../../trpc";
export const ZListOtherTeamMembersSchema = z.object({
teamId: z.number(),
query: z.string().optional(),
limit: z.number().optional(),
offset: z.number().optional(),
});
export type TListOtherTeamMembersSchema = z.infer<typeof ZListOtherTeamMembersSchema>;
type ListOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TListOtherTeamMembersSchema;
};
export const listOtherTeamMembers = async ({ ctx, input }: ListOptions) => {
const whereConditional: Prisma.MembershipWhereInput = {
teamId: input.teamId,
};
const { limit = 20 } = input;
let { offset = 0 } = input;
if (input.query) {
offset = 0;
whereConditional.user = {
OR: [
{
username: {
contains: input.query,
mode: "insensitive",
},
},
{
name: {
contains: input.query,
mode: "insensitive",
},
},
{
email: {
contains: input.query,
mode: "insensitive",
},
},
],
};
}
const members = await prisma.membership.findMany({
where: whereConditional,
select: {
id: true,
role: true,
accepted: true,
disableImpersonation: true,
user: {
select: {
id: true,
username: true,
name: true,
email: true,
avatar: true,
},
},
},
distinct: ["userId"],
orderBy: { role: "desc" },
take: limit,
skip: offset,
});
return members;
};

View File

@ -0,0 +1,43 @@
import { prisma } from "@calcom/prisma";
import type { TrpcSessionUser } from "../../../trpc";
type ListOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
};
export const listOtherTeamHandler = async ({ ctx }: ListOptions) => {
const teamsInOrgIamNotPartOf = await prisma.membership.findMany({
where: {
userId: {
not: ctx.user.id,
},
team: {
parent: {
is: {
id: ctx.user?.organization?.id,
},
},
members: {
none: {
userId: ctx.user.id,
},
},
},
},
include: {
team: true,
},
orderBy: { role: "desc" },
distinct: ["teamId"],
});
return teamsInOrgIamNotPartOf.map(({ team, ...membership }) => ({
role: membership.role,
accepted: membership.accepted,
isOrgAdmin: true,
...team,
}));
};

View File

@ -19,7 +19,13 @@ type UpdateOptions = {
};
export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
if (!(await isTeamAdmin(ctx.user?.id, input.id))) throw new TRPCError({ code: "UNAUTHORIZED" });
const isOrgAdmin = ctx.user?.organization?.isOrgAdmin;
if (!isOrgAdmin) {
if (!(await isTeamAdmin(ctx.user?.id, input.id))) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
}
if (input.slug) {
const userConflict = await prisma.team.findMany({