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:
parent
95dee6dd37
commit
871e17a865
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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")}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
}),
|
||||
});
|
||||
|
|
|
@ -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),
|
||||
};
|
||||
};
|
|
@ -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;
|
||||
};
|
|
@ -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,
|
||||
}));
|
||||
};
|
|
@ -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({
|
||||
|
|
Loading…
Reference in New Issue
Block a user