Merge branch 'main' into minimum-booking-notice-will-allow-hours-and-days

This commit is contained in:
Om Ray 2022-11-10 16:53:44 -05:00 committed by GitHub
commit a8bda978d3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
53 changed files with 1178 additions and 1629 deletions

View File

@ -96,11 +96,10 @@ NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE=
NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE=
NEXT_PUBLIC_IS_PREMIUM_NEW_PLAN=0
NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE=
NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE=
STRIPE_TEAM_MONTHLY_PRICE_ID=
STRIPE_WEBHOOK_SECRET=
STRIPE_PRO_PLAN_PRODUCT_ID=
STRIPE_PREMIUM_PLAN_PRODUCT_ID=
STRIPE_FREE_PLAN_PRODUCT_ID=
STRIPE_PRIVATE_KEY=
STRIPE_CLIENT_ID=
# Use for internal Public API Keys and optional
API_KEY_PREFIX=cal_

View File

@ -1,62 +0,0 @@
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import Button from "@calcom/ui/Button";
import { Badge } from "@calcom/ui/components/badge";
import showToast from "@calcom/ui/v2/core/notifications";
/** @deprecated Use `packages/features/ee/teams/components/DisableTeamImpersonation.tsx` */
const DisableTeamImpersonation = ({ teamId, memberId }: { teamId: number; memberId: number }) => {
const { t } = useLocale();
const utils = trpc.useContext();
const query = trpc.useQuery(["viewer.teams.getMembershipbyUser", { teamId, memberId }]);
const mutation = trpc.useMutation("viewer.teams.updateMembership", {
onSuccess: async () => {
showToast(t("your_user_profile_updated_successfully"), "success");
await utils.invalidateQueries(["viewer.teams.getMembershipbyUser"]);
},
async onSettled() {
await utils.invalidateQueries(["viewer.public.i18n"]);
},
});
if (query.isLoading) return <></>;
return (
<>
<h3 className="font-cal mt-7 pb-4 text-xl leading-6 text-gray-900">{t("settings")}</h3>
<div className="-mx-0 rounded-sm border border-neutral-200 bg-white px-4 pb-4 sm:px-6">
<div className="flex flex-col justify-between pt-4 sm:flex-row">
<div>
<div className="flex flex-row items-center">
<h2 className="font-cal font-bold leading-6 text-gray-900">
{t("user_impersonation_heading")}
</h2>
<Badge
className="ml-2 text-xs"
variant={!query.data?.disableImpersonation ? "success" : "gray"}>
{!query.data?.disableImpersonation ? t("enabled") : t("disabled")}
</Badge>
</div>
<p className="text-sm text-gray-700">{t("team_impersonation_description")}</p>
</div>
<div className="mt-5 sm:mt-0 sm:self-center">
<Button
type="submit"
color="secondary"
onClick={() =>
!query.data?.disableImpersonation
? mutation.mutate({ teamId, memberId, disableImpersonation: true })
: mutation.mutate({ teamId, memberId, disableImpersonation: false })
}>
{!query.data?.disableImpersonation ? t("disable") : t("enable")}
</Button>
</div>
</div>
</div>
</>
);
};
export default DisableTeamImpersonation;

View File

@ -1,115 +0,0 @@
import { MembershipRole } from "@prisma/client";
import { SyntheticEvent, useMemo, useState } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import Button from "@calcom/ui/Button";
import ModalContainer from "@components/ui/ModalContainer";
import Select from "@components/ui/form/Select";
type MembershipRoleOption = {
label: string;
value: MembershipRole;
};
/** @deprecated Use `packages/features/ee/teams/components/MemberChangeRoleModal.tsx` */
export default function MemberChangeRoleModal(props: {
isOpen: boolean;
currentMember: MembershipRole;
memberId: number;
teamId: number;
initialRole: MembershipRole;
onExit: () => void;
}) {
const { t } = useLocale();
const options = useMemo(() => {
return [
{
label: t("member"),
value: MembershipRole.MEMBER,
},
{
label: t("admin"),
value: MembershipRole.ADMIN,
},
{
label: t("owner"),
value: MembershipRole.OWNER,
},
].filter(({ value }) => value !== MembershipRole.OWNER || props.currentMember === MembershipRole.OWNER);
}, [t, props.currentMember]);
const [role, setRole] = useState<MembershipRoleOption>(
options.find((option) => option.value === props.initialRole) || {
label: t("member"),
value: MembershipRole.MEMBER,
}
);
const [errorMessage, setErrorMessage] = useState("");
const utils = trpc.useContext();
const changeRoleMutation = trpc.useMutation("viewer.teams.changeMemberRole", {
async onSuccess() {
await utils.invalidateQueries(["viewer.teams.get"]);
props.onExit();
},
async onError(err) {
setErrorMessage(err.message);
},
});
function changeRole(e: SyntheticEvent) {
e.preventDefault();
changeRoleMutation.mutate({
teamId: props.teamId,
memberId: props.memberId,
role: role.value,
});
}
return (
<ModalContainer isOpen={props.isOpen} onExit={props.onExit}>
<>
<div className="mb-4 sm:flex sm:items-start">
<div className="text-center sm:text-left">
<h3 className="text-lg font-medium leading-6 text-gray-900" id="modal-title">
{t("change_member_role")}
</h3>
</div>
</div>
<form onSubmit={changeRole}>
<div className="mb-4">
<label className="mb-2 block text-sm font-medium tracking-wide text-gray-700" htmlFor="role">
{t("role")}
</label>
{/*<option value="OWNER">{t("owner")}</option> - needs dialog to confirm change of ownership */}
<Select
isSearchable={false}
options={options}
value={role}
onChange={(option) => option && setRole(option)}
id="role"
className="mt-1 block w-full rounded-md border-gray-300 text-sm"
/>
</div>
{errorMessage && (
<p className="text-sm text-red-700">
<span className="font-bold">Error: </span>
{errorMessage}
</p>
)}
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<Button type="submit" color="primary" className="ltr:ml-2 rtl:mr-2">
{t("save")}
</Button>
<Button type="button" color="secondary" onClick={props.onExit}>
{t("cancel")}
</Button>
</div>
</form>
</>
</ModalContainer>
);
}

View File

@ -1,159 +0,0 @@
import { MembershipRole } from "@prisma/client";
import React, { useState, SyntheticEvent, useMemo } from "react";
import { TeamWithMembers } from "@calcom/lib/server/queries/teams";
import { trpc } from "@calcom/trpc/react";
import Button from "@calcom/ui/Button";
import { Dialog, DialogContent, DialogFooter } from "@calcom/ui/Dialog";
import { Icon } from "@calcom/ui/Icon";
import { TextField } from "@calcom/ui/form/fields";
import { useLocale } from "@lib/hooks/useLocale";
import Select from "@components/ui/form/Select";
type MemberInvitationModalProps = {
isOpen: boolean;
team: TeamWithMembers | null;
currentMember: MembershipRole;
onExit: () => void;
};
type MembershipRoleOption = {
value: MembershipRole;
label?: string;
};
const _options: MembershipRoleOption[] = [{ value: "MEMBER" }, { value: "ADMIN" }, { value: "OWNER" }];
/** @deprecated Use `packages/features/ee/teams/components/MemberInvitationModal.tsx` */
export default function MemberInvitationModal(props: MemberInvitationModalProps) {
const [errorMessage, setErrorMessage] = useState("");
const { t, i18n } = useLocale();
const utils = trpc.useContext();
const options = useMemo(() => {
_options.forEach((option, i) => {
_options[i].label = t(option.value.toLowerCase());
});
return _options;
}, [t]);
const inviteMemberMutation = trpc.useMutation("viewer.teams.inviteMember", {
async onSuccess() {
await utils.invalidateQueries(["viewer.teams.get"]);
props.onExit();
},
async onError(err) {
setErrorMessage(err.message);
},
});
function inviteMember(e: SyntheticEvent) {
e.preventDefault();
if (!props.team) return;
const target = e.target as typeof e.target & {
elements: {
role: { value: MembershipRole };
inviteUser: { value: string };
sendInviteEmail: { checked: boolean };
};
};
inviteMemberMutation.mutate({
teamId: props.team.id,
language: i18n.language,
role: target.elements["role"].value,
usernameOrEmail: target.elements["inviteUser"].value,
sendEmailInvitation: target.elements["sendInviteEmail"].checked,
});
}
return (
<Dialog open={props.isOpen} onOpenChange={props.onExit}>
<DialogContent>
<div className="mb-4 sm:flex sm:items-start">
<div className="bg-brand text-brandcontrast dark:bg-darkmodebrand dark:text-darkmodebrandcontrast mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-opacity-5 sm:mx-0 sm:h-10 sm:w-10">
<Icon.FiUser className="text-brandcontrast h-6 w-6" />
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 className="text-lg font-medium leading-6 text-gray-900" id="modal-title">
{t("invite_new_member")}
</h3>
<div>
<p className="text-sm text-gray-400">{t("invite_new_team_member")}</p>
</div>
</div>
</div>
<form onSubmit={inviteMember}>
<div className="space-y-4">
<TextField
label={t("email_or_username")}
id="inviteUser"
name="inviteUser"
placeholder="email@example.com"
required
/>
<div>
<label className="mb-1 block text-sm font-medium tracking-wide text-gray-700" htmlFor="role">
{t("role")}
</label>
<Select
defaultValue={options[0]}
options={props.currentMember !== MembershipRole.OWNER ? options.slice(0, 2) : options}
id="role"
name="role"
className="mt-1 block w-full rounded-sm border-gray-300 text-sm"
/>
</div>
<div className="relative flex items-start">
<div className="flex h-5 items-center">
<input
type="checkbox"
name="sendInviteEmail"
defaultChecked
id="sendInviteEmail"
className="rounded-sm border-gray-300 text-sm text-black"
/>
</div>
<div className="text-sm ltr:ml-2 rtl:mr-2">
<label htmlFor="sendInviteEmail" className="font-medium text-gray-700">
{t("send_invite_email")}
</label>
</div>
</div>
<div className="flex flex-row rounded-sm bg-gray-50 px-3 py-2">
<Icon.FiInfo className="h-5 w-5 flex-shrink-0 fill-gray-400" aria-hidden="true" />
<span className="ml-2 text-sm leading-tight text-gray-500">
Note: This will cost an extra seat ($12/m) on your subscription if this invitee does not have
a pro account.{" "}
<a href="#" className="underline">
Learn More
</a>
</span>
</div>
</div>
{errorMessage && (
<p className="text-sm text-red-700">
<span className="font-bold">Error: </span>
{errorMessage}
</p>
)}
<DialogFooter>
<Button type="button" color="secondary" onClick={props.onExit}>
{t("cancel")}
</Button>
<Button
type="submit"
color="primary"
className="ltr:ml-2 rtl:mr-2"
data-testid="invite-new-member-button">
{t("invite")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@ -1,22 +0,0 @@
import { inferQueryOutput } from "@calcom/trpc/react";
import MemberListItem from "./MemberListItem";
interface Props {
team: inferQueryOutput<"viewer.teams.get">;
members: inferQueryOutput<"viewer.teams.get">["members"];
}
export default function MemberList(props: Props) {
if (!props.members.length) return <></>;
return (
<div>
<ul className="-mx-4 mb-2 divide-y divide-gray-200 rounded border bg-white px-4 sm:mx-0 sm:px-4">
{props.members?.map((member) => (
<MemberListItem key={member.id} member={member} team={props.team} />
))}
</ul>
</div>
);
}

View File

@ -1,260 +0,0 @@
import { MembershipRole } from "@prisma/client";
import { signIn } from "next-auth/react";
import Link from "next/link";
import { useState } from "react";
import TeamAvailabilityModal from "@calcom/features/ee/teams/components/TeamAvailabilityModal";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { inferQueryOutput, trpc } from "@calcom/trpc/react";
import Button from "@calcom/ui/Button";
import ConfirmationDialogContent from "@calcom/ui/ConfirmationDialogContent";
import { Dialog, DialogTrigger } from "@calcom/ui/Dialog";
import Dropdown, {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@calcom/ui/Dropdown";
import { Icon } from "@calcom/ui/Icon";
import { Tooltip } from "@calcom/ui/Tooltip";
import showToast from "@calcom/ui/v2/core/notifications";
import useCurrentUserId from "@lib/hooks/useCurrentUserId";
import Avatar from "@components/ui/Avatar";
import ModalContainer from "@components/ui/ModalContainer";
import MemberChangeRoleModal from "./MemberChangeRoleModal";
import TeamPill, { TeamRole } from "./TeamPill";
interface Props {
team: inferQueryOutput<"viewer.teams.get">;
member: inferQueryOutput<"viewer.teams.get">["members"][number];
}
/** @deprecated Use `packages/features/ee/teams/components/MemberListItem.tsx` */
export default function MemberListItem(props: Props) {
const { t } = useLocale();
const utils = trpc.useContext();
const [showChangeMemberRoleModal, setShowChangeMemberRoleModal] = useState(false);
const [showTeamAvailabilityModal, setShowTeamAvailabilityModal] = useState(false);
const [showImpersonateModal, setShowImpersonateModal] = useState(false);
const removeMemberMutation = trpc.useMutation("viewer.teams.removeMember", {
async onSuccess() {
await utils.invalidateQueries(["viewer.teams.get"]);
showToast(t("success"), "success");
},
async onError(err) {
showToast(err.message, "error");
},
});
const ownersInTeam = () => {
const { members } = props.team;
const owners = members.filter((member) => member["role"] === MembershipRole.OWNER && member["accepted"]);
return owners.length;
};
const currentUserId = useCurrentUserId();
const name =
props.member.name ||
(() => {
const emailName = props.member.email.split("@")[0];
return emailName.charAt(0).toUpperCase() + emailName.slice(1);
})();
const removeMember = () =>
removeMemberMutation.mutate({ teamId: props.team?.id, memberId: props.member.id });
return (
<li className="divide-y">
<div className="my-4 flex justify-between">
<div className="flex w-full flex-col justify-between sm:flex-row">
<div className="flex">
<Avatar
imageSrc={WEBAPP_URL + "/" + props.member.username + "/avatar.png"}
alt={name || ""}
className="h-9 w-9 rounded-full"
/>
<div className="ml-3 inline-block">
<span className="text-sm font-bold text-neutral-700">{name}</span>
<span
className="-mt-1 block text-xs text-gray-400"
data-testid="member-email"
data-email={props.member.email}>
{props.member.email}
</span>
</div>
</div>
<div className="mt-2 flex ltr:mr-2 rtl:ml-2 sm:mt-0 sm:justify-center">
{/* Tooltip doesn't show... WHY????? */}
{props.member.isMissingSeat && (
<Tooltip side="top" content={t("hidden_team_member_message")}>
<TeamPill color="red" text={t("hidden")} />
</Tooltip>
)}
{!props.member.accepted && <TeamPill color="yellow" text={t("invitee")} />}
{props.member.role && <TeamRole role={props.member.role} />}
</div>
</div>
<div className="flex space-x-2">
<Tooltip side="top" content={t("team_view_user_availability")}>
<Button
// Disabled buttons don't trigger Tooltips
title={
props.member.accepted
? t("team_view_user_availability")
: t("team_view_user_availability_disabled")
}
disabled={!props.member.accepted}
onClick={() => (props.member.accepted ? setShowTeamAvailabilityModal(true) : null)}
color="minimal"
size="icon">
<Icon.FiClock className="h-5 w-5 group-hover:text-gray-800" />
</Button>
</Tooltip>
<Dropdown>
<DropdownMenuTrigger asChild>
<Button type="button" color="minimal" size="icon" StartIcon={Icon.FiMoreHorizontal} />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>
<Link href={"/" + props.member.username}>
<a target="_blank">
<Button color="minimal" StartIcon={Icon.FiExternalLink} className="w-full font-normal">
{t("view_public_page")}
</Button>
</a>
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator className="h-px bg-gray-200" />
{((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)) && (
<>
<DropdownMenuItem>
<Button
onClick={() => setShowChangeMemberRoleModal(true)}
color="minimal"
StartIcon={Icon.FiEdit2}
className="w-full flex-shrink-0 font-normal">
{t("edit_role")}
</Button>
</DropdownMenuItem>
<DropdownMenuSeparator className="h-px bg-gray-200" />
{/* Only show impersonate box if - The user has impersonation enabled,
They have accepted the team invite, and it is enabled for this instance */}
{!props.member.disableImpersonation &&
props.member.accepted &&
process.env.NEXT_PUBLIC_TEAM_IMPERSONATION === "true" && (
<>
<DropdownMenuItem>
<Button
onClick={() => setShowImpersonateModal(true)}
color="minimal"
StartIcon={Icon.FiLock}
className="w-full flex-shrink-0 font-normal">
{t("impersonate")}
</Button>
</DropdownMenuItem>
<DropdownMenuSeparator className="h-px bg-gray-200" />
</>
)}
<DropdownMenuItem>
<Dialog>
<DialogTrigger asChild>
<Button
onClick={(e) => {
e.stopPropagation();
}}
color="warn"
StartIcon={Icon.FiUserMinus}
className="w-full font-normal">
{t("remove_member")}
</Button>
</DialogTrigger>
<ConfirmationDialogContent
variety="danger"
title={t("remove_member")}
confirmBtnText={t("confirm_remove_member")}
onConfirm={removeMember}>
{t("remove_member_confirmation_message")}
</ConfirmationDialogContent>
</Dialog>
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</Dropdown>
</div>
</div>
{showChangeMemberRoleModal && (
<MemberChangeRoleModal
isOpen={showChangeMemberRoleModal}
currentMember={props.team.membership.role}
teamId={props.team?.id}
memberId={props.member.id}
initialRole={props.member.role as MembershipRole}
onExit={() => setShowChangeMemberRoleModal(false)}
/>
)}
{showImpersonateModal && props.member.username && (
<ModalContainer isOpen={showImpersonateModal} onExit={() => setShowImpersonateModal(false)}>
<>
<div className="mb-4 sm:flex sm:items-start">
<div className="text-center sm:text-left">
<h3 className="text-lg font-medium leading-6 text-gray-900" id="modal-title">
{t("impersonate")}
</h3>
</div>
</div>
<form
onSubmit={async (e) => {
e.preventDefault();
await signIn("impersonation-auth", {
username: props.member.username,
teamId: props.team.id,
});
}}>
<p className="mt-2 text-sm text-gray-500" id="email-description">
{t("impersonate_user_tip")}
</p>
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<Button type="submit" color="primary" className="ltr:ml-2 rtl:mr-2">
{t("impersonate")}
</Button>
<Button type="button" color="secondary" onClick={() => setShowImpersonateModal(false)}>
{t("cancel")}
</Button>
</div>
</form>
</>
</ModalContainer>
)}
{showTeamAvailabilityModal && (
<ModalContainer
wide
noPadding
isOpen={showTeamAvailabilityModal}
onExit={() => setShowTeamAvailabilityModal(false)}>
<TeamAvailabilityModal team={props.team} member={props.member} />
<div className="space-x-2 border-t py-5 rtl:space-x-reverse">
<Button onClick={() => setShowTeamAvailabilityModal(false)}>{t("done")}</Button>
{props.team.membership.role !== MembershipRole.MEMBER && (
<Link href={`/settings/teams/${props.team.id}/availability`} passHref>
<Button color="secondary">{t("Open Team Availability")}</Button>
</Link>
)}
</div>
</ModalContainer>
)}
</li>
);
}

View File

@ -1,73 +0,0 @@
import { useRef, useState } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Button } from "@calcom/ui";
import { Icon } from "@calcom/ui/Icon";
import { Alert } from "@calcom/ui/v2/core/Alert";
import { Dialog, DialogContent, DialogFooter } from "@calcom/ui/v2/core/Dialog";
interface Props {
isOpen: boolean;
onClose: () => void;
}
export default function TeamCreate(props: Props) {
const { t } = useLocale();
const utils = trpc.useContext();
const [errorMessage, setErrorMessage] = useState<null | string>(null);
const nameRef = useRef<HTMLInputElement>() as React.MutableRefObject<HTMLInputElement>;
const createTeamMutation = trpc.useMutation("viewer.teams.create", {
onSuccess: () => {
utils.invalidateQueries(["viewer.teams.list"]);
props.onClose();
},
onError: (e) => {
setErrorMessage(e?.message || t("something_went_wrong"));
},
});
const createTeam = () => {
createTeamMutation.mutate({ name: nameRef?.current?.value });
};
return (
<>
<Dialog open={props.isOpen} onOpenChange={props.onClose}>
<DialogContent type="creation" actionText={t("create_new_team")} actionOnClick={createTeam}>
<div className="mb-4 sm:flex sm:items-start">
<div className="bg-brand text-brandcontrast dark:bg-darkmodebrand dark:text-darkmodebrandcontrast mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-opacity-5 sm:mx-0 sm:h-10 sm:w-10">
<Icon.FiUsers className="text-brandcontrast h-6 w-6" />
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 className="text-lg font-medium leading-6 text-gray-900" id="modal-title">
{t("create_new_team")}
</h3>
<div>
<p className="text-sm text-gray-400">{t("create_new_team_description")}</p>
</div>
</div>
</div>
<form>
<div className="mb-4">
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
{t("name")}
</label>
<input
ref={nameRef}
type="text"
name="name"
id="name"
placeholder="Acme Inc."
required
className="mt-1 block w-full rounded-sm border border-gray-300 px-3 py-2 text-sm"
/>
</div>
{errorMessage && <Alert severity="error" title={errorMessage} />}
</form>
</DialogContent>
</Dialog>
</>
);
}

View File

@ -1,37 +0,0 @@
import { MembershipRole } from "@prisma/client";
import classNames from "classnames";
import { useLocale } from "@lib/hooks/useLocale";
type PillColor = "blue" | "green" | "red" | "yellow";
interface Props {
text: string;
color?: PillColor;
}
/** @deprecated Use `packages/features/ee/teams/components/TeamPill.tsx` */
export default function TeamPill(props: Props) {
return (
<div
className={classNames("self-center rounded-md border px-3 py-1 text-xs capitalize ltr:mr-2 rtl:ml-2", {
"border-gray-200 bg-gray-50 text-gray-700": !props.color,
"border-blue-200 bg-blue-50 text-blue-700": props.color === "blue",
"border-red-200 bg-red-50 text-red-700": props.color === "red",
"border-yellow-200 bg-yellow-50 text-yellow-700": props.color === "yellow",
"border-green-200 bg-green-50 text-green-600": props.color === "green",
})}>
{props.text}
</div>
);
}
export function TeamRole(props: { role: MembershipRole }) {
const { t } = useLocale();
const keys: Record<MembershipRole, PillColor | undefined> = {
[MembershipRole.OWNER]: undefined,
[MembershipRole.ADMIN]: "red",
[MembershipRole.MEMBER]: "blue",
};
return <TeamPill text={t(props.role.toLowerCase())} color={keys[props.role]} />;
}

View File

@ -1,91 +0,0 @@
import { useState } from "react";
import { trpc } from "@calcom/trpc/react";
import { Alert } from "@calcom/ui/Alert";
import Button from "@calcom/ui/Button";
import {
Dialog,
DialogTrigger,
DialogContent,
DialogClose,
DialogFooter,
DialogHeader,
} from "@calcom/ui/Dialog";
import showToast from "@calcom/ui/v2/core/notifications";
import { useLocale } from "@lib/hooks/useLocale";
interface Props {
teamId: number;
}
/** @deprecated Use `packages/features/ee/teams/components/UpgradeToFlexibleProModal.tsx` */
export function UpgradeToFlexibleProModal(props: Props) {
const { t } = useLocale();
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const utils = trpc.useContext();
const { data } = trpc.useQuery(["viewer.teams.getTeamSeats", { teamId: props.teamId }], {
onError: (err) => {
setErrorMessage(err.message);
},
});
const mutation = trpc.useMutation(["viewer.teams.upgradeTeam"], {
onSuccess: (data) => {
// if the user does not already have a Stripe subscription, this wi
if (data?.url) {
window.location.href = data.url;
}
if (data?.success) {
utils.invalidateQueries(["viewer.teams.get"]);
showToast(t("team_upgraded_successfully"), "success");
}
},
onError: (err) => {
setErrorMessage(err.message);
},
});
return (
<Dialog
onOpenChange={() => {
setErrorMessage(null);
}}>
<DialogTrigger asChild>
<a className="cursor-pointer underline">Upgrade Now</a>
</DialogTrigger>
<DialogContent>
<DialogHeader title={t("Purchase missing seats")} />
<p className="-mt-4 text-sm text-gray-600">{t("changed_team_billing_info")}</p>
{data && (
<p className="mt-2 text-sm italic text-gray-700">
{t("team_upgrade_seats_details", {
memberCount: data.totalMembers,
unpaidCount: data.missingSeats,
seatPrice: 12,
totalCost: (data.totalMembers - data.freeSeats) * 12 + 12,
})}
</p>
)}
{errorMessage && (
<Alert severity="error" title={errorMessage} message={t("further_billing_help")} className="my-4" />
)}
<DialogFooter>
<DialogClose>
<Button color="secondary">{t("close")}</Button>
</DialogClose>
<Button
disabled={mutation.isLoading}
onClick={() => {
setErrorMessage(null);
mutation.mutate({ teamId: props.teamId });
}}>
{t("upgrade_to_per_seat")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -8,11 +8,11 @@ import { getSession } from "@calcom/lib/auth";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { User } from "@calcom/prisma/client";
import { Button } from "@calcom/ui/components/button";
import { StepCard } from "@calcom/ui/v2/core/StepCard";
import { Steps } from "@calcom/ui/v2/core/Steps";
import prisma from "@lib/prisma";
import { StepCard } from "@components/getting-started/components/StepCard";
import { Steps } from "@components/getting-started/components/Steps";
import { ConnectedCalendars } from "@components/getting-started/steps-views/ConnectCalendars";
import { SetupAvailability } from "@components/getting-started/steps-views/SetupAvailability";
import UserProfile from "@components/getting-started/steps-views/UserProfile";

View File

@ -1,7 +1,9 @@
import { useRouter } from "next/router";
import { useState } from "react";
import { HelpScout, useChat } from "react-live-chat-loader";
import { classNames } from "@calcom/lib";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Icon } from "@calcom/ui";
@ -37,6 +39,9 @@ const BillingView = () => {
const isPro = user?.plan === "PRO";
const [, loadChat] = useChat();
const [showChat, setShowChat] = useState(false);
const router = useRouter();
const returnTo = router.asPath;
const billingHref = `/api/integrations/stripepayment/portal?returnTo=${WEBAPP_URL}${returnTo}`;
const onContactSupportClick = () => {
setShowChat(true);
@ -63,7 +68,7 @@ const BillingView = () => {
description={t("billing_manage_details_description")}>
<Button
color={isPro ? "primary" : "secondary"}
href="/api/integrations/stripepayment/portal"
href={billingHref}
target="_blank"
EndIcon={Icon.FiExternalLink}>
{t("billing_portal")}

View File

@ -0,0 +1 @@
export { default } from "@calcom/features/ee/teams/pages/team-billing-view";

View File

@ -0,0 +1,26 @@
import Head from "next/head";
import AddNewTeamMembers from "@calcom/features/ee/teams/components/AddNewTeamMembers";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import WizardLayout from "@calcom/ui/v2/core/layouts/WizardLayout";
const OnboardTeamMembersPage = () => {
const { t } = useLocale();
return (
<>
<Head>
<title>{t("add_team_members")}</title>
<meta name="description" content={t("add_team_members_description")} />
</Head>
<AddNewTeamMembers />
</>
);
};
OnboardTeamMembersPage.getLayout = (page: React.ReactElement) => (
<WizardLayout currentStep={1} maxSteps={2}>
{page}
</WizardLayout>
);
export default OnboardTeamMembersPage;

View File

@ -0,0 +1 @@
export { default } from "@calcom/features/ee/teams/pages/team-listing-view";

View File

@ -1,113 +0,0 @@
import Head from "next/head";
import { useRouter } from "next/router";
import { useState } from "react";
import { z } from "zod";
// import TeamGeneralSettings from "@calcom/features/teams/createNewTeam/TeamGeneralSettings";
import AddNewTeamMembers from "@calcom/features/ee/teams/components/v2/AddNewTeamMembers";
import CreateNewTeam from "@calcom/features/ee/teams/components/v2/CreateNewTeam";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { StepCard } from "@components/getting-started/components/StepCard";
import { Steps } from "@components/getting-started/components/Steps";
const INITIAL_STEP = "create-a-new-team";
// TODO: Add teams general settings "general-settings"
const steps = ["create-a-new-team", "add-team-members"] as const;
const stepTransform = (step: typeof steps[number]) => {
const stepIndex = steps.indexOf(step);
if (stepIndex > -1) {
return steps[stepIndex];
}
return INITIAL_STEP;
};
const stepRouteSchema = z.object({
step: z.array(z.enum(steps)).default([INITIAL_STEP]),
});
const CreateNewTeamPage = () => {
const router = useRouter();
const { t } = useLocale();
const [teamId, setTeamId] = useState<number>();
const result = stepRouteSchema.safeParse(router.query);
const currentStep = result.success ? result.data.step[0] : INITIAL_STEP;
const headers = [
{
title: `${t("create_new_team")}`,
subtitle: [`${t("create_new_team_description")}`],
},
// {
// title: `${t("general_settings")}`,
// subtitle: [`${t("general_settings_description")}`],
// },
{
title: `${t("add_team_members")}`,
subtitle: [`${t("add_team_members_description")}`],
},
];
const goToIndex = (index: number) => {
const newStep = steps[index];
router.push(
{
pathname: `/settings/teams/new/${stepTransform(newStep)}`,
},
undefined
);
};
const currentStepIndex = steps.indexOf(currentStep);
return (
<div
className="dark:bg-brand dark:text-brand-contrast min-h-screen text-black"
data-testid="onboarding"
key={router.asPath}>
<Head>
<title>Create a new Team</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<div className="mx-auto px-4 py-24">
<div className="relative">
<div className="sm:mx-auto sm:w-full sm:max-w-[600px]">
<div className="mx-auto sm:max-w-[520px]">
<header>
<p className="font-cal mb-3 text-[28px] font-medium leading-7">
{headers[currentStepIndex]?.title || "Undefined title"}
</p>
<p className="font-sans text-sm font-normal text-gray-500">
{headers[currentStepIndex]?.subtitle}
</p>
</header>
<Steps maxSteps={steps.length} currentStep={currentStepIndex} navigateToStep={goToIndex} />
</div>
<StepCard>
{currentStep === "create-a-new-team" && (
<CreateNewTeam
nextStep={() => {
goToIndex(1);
}}
setTeamId={(teamId: number) => setTeamId(teamId)}
/>
)}
{/* {currentStep === "general-settings" && (
<TeamGeneralSettings teamId={teamId} nextStep={() => goToIndex(2)} />
)} */}
{currentStep === "add-team-members" && teamId && <AddNewTeamMembers teamId={teamId} />}
</StepCard>
</div>
</div>
</div>
</div>
);
};
export default CreateNewTeamPage;

View File

@ -0,0 +1,20 @@
import Head from "next/head";
import { CreateANewTeamForm } from "@calcom/features/ee/teams/components";
import { getLayout } from "@calcom/ui/v2/core/layouts/WizardLayout";
const CreateNewTeamPage = () => {
return (
<>
<Head>
<title>Create a new Team</title>
<meta name="description" content="Create a new team to ease your organisational booking" />
</Head>
<CreateANewTeamForm />
</>
);
};
CreateNewTeamPage.getLayout = getLayout;
export default CreateNewTeamPage;

View File

@ -1,68 +1,23 @@
import { useState } from "react";
import { TeamsListing } from "@calcom/features/ee/teams/components";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Icon } from "@calcom/ui/Icon";
import { Button } from "@calcom/ui/components/button";
import { Shell } from "@calcom/ui/v2";
import { Alert } from "@calcom/ui/v2/core/Alert";
import EmptyScreen from "@calcom/ui/v2/core/EmptyScreen";
import SkeletonLoaderTeamList from "@components/team/SkeletonloaderTeamList";
import TeamCreateModal from "@components/team/TeamCreateModal";
import TeamList from "@components/team/TeamList";
function Teams() {
const { t } = useLocale();
const [showCreateTeamModal, setShowCreateTeamModal] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const { data, isLoading } = trpc.useQuery(["viewer.teams.list"], {
onError: (e) => {
setErrorMessage(e.message);
},
});
const teams = data?.filter((m) => m.accepted) || [];
const invites = data?.filter((m) => !m.accepted) || [];
return (
<Shell
heading={t("teams")}
subtitle={t("create_manage_teams_collaborative")}
CTA={
<Button type="button" onClick={() => setShowCreateTeamModal(true)}>
<Button type="button" href={`${WEBAPP_URL}/settings/teams/new`}>
<Icon.FiPlus className="inline-block h-3.5 w-3.5 text-white group-hover:text-black ltr:mr-2 rtl:ml-2" />
{t("new")}
</Button>
}>
<>
{!!errorMessage && <Alert severity="error" title={errorMessage} />}
{showCreateTeamModal && (
<TeamCreateModal isOpen={showCreateTeamModal} onClose={() => setShowCreateTeamModal(false)} />
)}
{invites.length > 0 && (
<div className="mb-4">
<h1 className="mb-2 text-lg font-medium">{t("open_invitations")}</h1>
<TeamList teams={invites} />
</div>
)}
{isLoading && <SkeletonLoaderTeamList />}
{!teams.length && !isLoading && (
<EmptyScreen
Icon={Icon.FiUsers}
headline={t("no_teams")}
description={t("no_teams_description")}
buttonRaw={
<Button color="secondary" onClick={() => setShowCreateTeamModal(true)}>
{t("create_team")}
</Button>
}
buttonOnClick={() => setShowCreateTeamModal(true)}
/>
)}
{teams.length > 0 && <TeamList teams={teams} />}
</>
<TeamsListing />
</Shell>
);
}

View File

@ -0,0 +1,64 @@
import { expect } from "@playwright/test";
import { test } from "./lib/fixtures";
test.describe.configure({ mode: "parallel" });
test.describe("Teams", () => {
test.afterEach(async ({ users }) => {
await users.deleteAll();
});
test("Can create teams via Wizard", async ({ page, users, prisma }) => {
const user = await users.create();
const inviteeEmail = `${user.username}+invitee@example.com`;
await user.login();
await page.goto("/teams");
// Expect teams to be empty
await expect(page.locator('[data-testid="empty-screen"]')).toBeVisible();
await test.step("Can create team", async () => {
// Click text=Create Team
await page.locator("text=Create Team").click();
await page.waitForURL("/settings/teams/new");
// Fill input[name="name"]
await page.locator('input[name="name"]').fill(`${user.username}'s Team`);
// Click text=Continue
await page.locator("text=Continue").click();
await page.waitForURL(/\/settings\/teams\/(\d+)\/onboard-members$/i);
await page.waitForSelector('[data-testid="pending-member-list"]');
expect(await page.locator('[data-testid="pending-member-item"]').count()).toBe(1);
});
await test.step("Can add members", async () => {
// Click [data-testid="new-member-button"]
await page.locator('[data-testid="new-member-button"]').click();
// Fill [placeholder="email\@example\.com"]
await page.locator('[placeholder="email\\@example\\.com"]').fill(inviteeEmail);
// Click [data-testid="invite-new-member-button"]
await page.locator('[data-testid="invite-new-member-button"]').click();
await expect(page.locator(`li:has-text("${inviteeEmail}PendingMemberNot on Cal.com")`)).toBeVisible();
expect(await page.locator('[data-testid="pending-member-item"]').count()).toBe(2);
});
await test.step("Can remove members", async () => {
const removeMemberButton = page.locator('[data-testid="remove-member-button"]');
await removeMemberButton.click();
await removeMemberButton.waitFor({ state: "hidden" });
expect(await page.locator('[data-testid="pending-member-item"]').count()).toBe(1);
});
await test.step("Can publish team", async () => {
await page.locator("text=Publish team").click();
await page.waitForURL(/\/settings\/teams\/(\d+)\/profile$/i);
});
await test.step("Can disband team", async () => {
await page.locator("text=Delete Team").click();
await page.locator("text=Yes, disband team").click();
await page.waitForURL("/teams");
await expect(page.locator('[data-testid="empty-screen"]')).toBeVisible();
});
});
});

View File

@ -502,6 +502,7 @@
"add_team_members_description": "Invite others to join your team",
"add_team_member": "Add team member",
"invite_new_member": "Invite a new team member",
"invite_new_member_description": "Note: This will <1>cost an extra seat ($15/m)</1> on your subscription.",
"invite_new_team_member": "Invite someone to your team.",
"change_member_role": "Change team member role",
"disable_cal_branding": "Disable Cal branding",
@ -1339,6 +1340,21 @@
"limit_future_bookings_description": "Limit how far in the future this event can be booked",
"no_event_types": "No event types setup",
"no_event_types_description": "{{name}} has not setup any event types for you to book.",
"billing_frequency": "Billing Frequency",
"monthly": "Monthly",
"yearly": "Yearly",
"checkout": "Checkout",
"your_team_disbanded_successfully": "Your team has been disbanded successfully",
"error_creating_team": "Error creating team",
"you": "You",
"send_email": "Send email",
"member_already_invited": "Member has already been invited",
"enter_email_or_username": "Enter an email or username",
"team_name_taken": "This name is already taken",
"must_enter_team_name": "Must enter a team name",
"team_url_required": "Must enter a team URL",
"team_url_taken": "This URL is already taken",
"team_publish": "Publish team",
"number_sms_notifications": "Phone number (SMS\u00a0notifications)",
"attendee_email_workflow": "Attendee email",
"attendee_email_info": "The person booking's email",

View File

@ -1,23 +1,35 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
import { getStripeCustomerIdFromUserId } from "../lib/customer";
import stripe from "../lib/server";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "POST" || req.method === "GET") {
const customerId = await getStripeCustomerIdFromUserId(req.session!.user.id);
if (req.method !== "POST" && req.method !== "GET")
return res.status(405).json({ message: "Method not allowed" });
const { referer } = req.headers;
if (!customerId) {
res.status(500).json({ message: "Missing customer id" });
return;
}
if (!referer) return res.status(400).json({ message: "Missing referrer" });
const return_url = `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing`;
const stripeSession = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url,
});
if (!req.session?.user?.id) return res.status(401).json({ message: "Not authenticated" });
res.redirect(302, stripeSession.url);
// If accessing a user's portal
const customerId = await getStripeCustomerIdFromUserId(req.session.user.id);
if (!customerId) return res.status(400).json({ message: "CustomerId not found in stripe" });
let return_url = `${WEBAPP_URL}/settings/billing`;
if (typeof req.query.returnTo === "string") {
const safeRedirectUrl = getSafeRedirectUrl(req.query.returnTo);
if (safeRedirectUrl) return_url = safeRedirectUrl;
}
const stripeSession = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url,
});
res.redirect(302, stripeSession.url);
}

View File

@ -1,7 +1,7 @@
import { MembershipRole, Prisma, UserPlan } from "@prisma/client";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime";
import Stripe from "stripe";
import { HOSTED_CAL_FEATURES } from "@calcom/lib/constants";
import { HttpError } from "@calcom/lib/http-error";
import prisma from "@calcom/prisma";
@ -42,21 +42,6 @@ async function getMembersMissingSeats(teamId: number) {
};
}
// a helper for the upgrade dialog
export async function getTeamSeatStats(teamId: number) {
const { membersMissingSeats, members, ownerIsMissingSeat } = await getMembersMissingSeats(teamId);
return {
totalMembers: members.length,
// members we need not pay for
freeSeats: members.length - membersMissingSeats.length,
// members we need to pay for (if not hosted cal, team billing is disabled)
missingSeats: HOSTED_CAL_FEATURES ? membersMissingSeats.length : 0,
// members who have been hidden from view
hiddenMembers: members.filter((m) => m.user.plan === UserPlan.FREE).length,
ownerIsMissingSeat: HOSTED_CAL_FEATURES ? ownerIsMissingSeat : false,
};
}
async function updatePerSeatQuantity(subscription: Stripe.Subscription, quantity: number) {
const perSeatProPlan = subscription.items.data.find((item) => item.plan.id === getPerSeatProPlanPrice());
// if their subscription does not contain Per Seat Pro, add it—otherwise, update the existing one
@ -249,16 +234,17 @@ async function createCheckoutSession(
return await stripe.checkout.sessions.create(params);
}
// verifies that the subscription's quantity is correct for the number of members the team has
// this is a function is a dev util, but could be utilized as a sync technique in the future
export async function ensureSubscriptionQuantityCorrectness(userId: number, teamId: number) {
const subscription = await getProPlanSubscription(userId);
const stripeQuantity =
subscription?.items.data.find((item) => item.plan.id === getPerSeatProPlanPrice())?.quantity ?? 0;
const { membersMissingSeats } = await getMembersMissingSeats(teamId);
// correct the quantity if missing seats is out of sync with subscription quantity
if (subscription && membersMissingSeats.length !== stripeQuantity) {
await updatePerSeatQuantity(subscription, membersMissingSeats.length);
export function getRequestedSlugError(error: unknown, requestedSlug: string) {
let message = `Unknown error`;
let statusCode = 500;
// This covers the edge case if an unpublished team takes too long to publish
// and another team gets the requestedSlug first.
// https://www.prisma.io/docs/reference/api-reference/error-reference#p2002
if (error instanceof PrismaClientKnownRequestError && error.code === "P2002") {
statusCode = 400;
message = `It seems like the requestedSlug: '${requestedSlug}' is already taken. Please contact support at help@cal.com so we can resolve this issue.`;
} else if (error instanceof Error) {
message = error.message;
}
return { message, statusCode };
}

View File

@ -7,15 +7,13 @@ import stripe from "@calcom/app-store/stripepayment/lib/server";
import EventManager from "@calcom/core/EventManager";
import { sendScheduledEmails } from "@calcom/emails";
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
import { IS_PRODUCTION } from "@calcom/lib/constants";
import { getErrorFromUnknown } from "@calcom/lib/errors";
import { HttpError as HttpCode } from "@calcom/lib/http-error";
import { getTranslation } from "@calcom/lib/server/i18n";
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
import type { CalendarEvent } from "@calcom/types/Calendar";
import { IS_PRODUCTION } from "@lib/config/constants";
import { HttpError as HttpCode } from "@lib/core/http/error";
import { getTranslation } from "@server/lib/i18n";
export const config = {
api: {
bodyParser: false,

View File

@ -1,20 +1,71 @@
import type { NextApiRequest, NextApiResponse } from "next";
import Stripe from "stripe";
import { z } from "zod";
import { upgradeTeam } from "@calcom/app-store/stripepayment/lib/team-billing";
import { getSession } from "@calcom/lib/auth";
import { getRequestedSlugError } from "@calcom/app-store/stripepayment/lib/team-billing";
import stripe from "@calcom/features/ee/payments/server/stripe";
import { ensureSession } from "@calcom/lib/auth";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { HttpError } from "@calcom/lib/http-error";
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
import { closeComUpdateTeam } from "@calcom/lib/sync/SyncServiceManager";
import prisma from "@calcom/prisma";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "GET") {
const session = await getSession({ req });
const querySchema = z.object({
team: z.string().transform((val) => parseInt(val)),
session_id: z.string().min(1),
});
if (!session) {
res.status(401).json({ message: "You must be logged in to do this" });
return;
async function handler(req: NextApiRequest, res: NextApiResponse) {
await ensureSession({ req });
const { team: id, session_id } = querySchema.parse(req.query);
const checkoutSession = await stripe.checkout.sessions.retrieve(session_id, {
expand: ["subscription"],
});
if (!checkoutSession) throw new HttpError({ statusCode: 404, message: "Checkout session not found" });
const subscription = checkoutSession.subscription as Stripe.Subscription;
if (checkoutSession.payment_status !== "paid")
throw new HttpError({ statusCode: 402, message: "Payment required" });
/* Check if a team was already upgraded with this payment intent */
let team = await prisma.team.findFirst({
where: { metadata: { path: ["paymentId"], equals: checkoutSession.id } },
});
if (!team) {
const prevTeam = await prisma.team.findFirstOrThrow({ where: { id } });
const metadata = teamMetadataSchema.parse(prevTeam.metadata);
if (!metadata?.requestedSlug) throw new HttpError({ statusCode: 400, message: "Missing requestedSlug" });
try {
team = await prisma.team.update({
where: { id },
data: {
slug: metadata.requestedSlug,
metadata: {
paymentId: checkoutSession.id,
subscriptionId: subscription.id || null,
subscriptionItemId: subscription.items.data[0].id || null,
},
},
});
} catch (error) {
const { message, statusCode } = getRequestedSlugError(error, metadata.requestedSlug);
return res.status(statusCode).json({ message });
}
await upgradeTeam(session.user.id, Number(req.query.team));
// redirect to team screen
res.redirect(302, `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/teams/${req.query.team}?upgraded=true`);
// Sync Services: Close.com
closeComUpdateTeam(prevTeam, team);
}
// redirect to team screen
res.redirect(302, `${WEBAPP_URL}/settings/teams/${team.id}/profile?upgraded=true`);
}
export default defaultHandler({
GET: Promise.resolve({ default: defaultResponder(handler) }),
});

View File

@ -0,0 +1,180 @@
import { useSession } from "next-auth/react";
import { useRouter } from "next/router";
import { useState } from "react";
import { z } from "zod";
import MemberInvitationModal from "@calcom/features/ee/teams/components/MemberInvitationModal";
import { classNames } from "@calcom/lib";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { inferQueryOutput, trpc } from "@calcom/trpc/react";
import { Icon } from "@calcom/ui";
import { Avatar, Badge, Button } from "@calcom/ui/components";
import { showToast } from "@calcom/ui/v2/core";
import { SkeletonContainer, SkeletonText } from "@calcom/ui/v2/core/skeleton";
const querySchema = z.object({
id: z.string().transform((val) => parseInt(val)),
});
type TeamMember = inferQueryOutput<"viewer.teams.get">["members"][number];
type FormValues = {
members: TeamMember[];
};
const AddNewTeamMembers = () => {
const session = useSession();
const router = useRouter();
const { id: teamId } = router.isReady ? querySchema.parse(router.query) : { id: -1 };
const teamQuery = trpc.useQuery(["viewer.teams.get", { teamId }], { enabled: router.isReady });
if (session.status === "loading" || !teamQuery.data) return <AddNewTeamMemberSkeleton />;
return <AddNewTeamMembersForm defaultValues={{ members: teamQuery.data.members }} teamId={teamId} />;
};
const AddNewTeamMembersForm = ({ defaultValues, teamId }: { defaultValues: FormValues; teamId: number }) => {
const { t, i18n } = useLocale();
const [memberInviteModal, setMemberInviteModal] = useState(false);
const utils = trpc.useContext();
const router = useRouter();
const inviteMemberMutation = trpc.useMutation("viewer.teams.inviteMember", {
async onSuccess() {
await utils.invalidateQueries(["viewer.teams.get"]);
setMemberInviteModal(false);
},
onError: (error) => {
showToast(error.message, "error");
},
});
const publishTeamMutation = trpc.useMutation("viewer.teams.publish", {
onSuccess(data) {
router.push(data.url);
},
onError: (error) => {
showToast(error.message, "error");
},
});
return (
<>
<div>
<ul className="rounded-md border" data-testid="pending-member-list">
{defaultValues.members.map((member, index) => (
<PendingMemberItem key={member.email} member={member} index={index} teamId={teamId} />
))}
</ul>
<Button
color="secondary"
data-testid="new-member-button"
StartIcon={Icon.FiPlus}
onClick={() => setMemberInviteModal(true)}
className="mt-6 w-full justify-center">
{t("add_team_member")}
</Button>
</div>
<MemberInvitationModal
isOpen={memberInviteModal}
onExit={() => setMemberInviteModal(false)}
onSubmit={(values) => {
inviteMemberMutation.mutate({
teamId,
language: i18n.language,
role: values.role.value,
usernameOrEmail: values.emailOrUsername,
sendEmailInvitation: values.sendInviteEmail,
});
}}
members={defaultValues.members}
/>
<hr className="my-6 border-neutral-200" />
<Button
EndIcon={Icon.FiArrowRight}
className="mt-6 w-full justify-center"
disabled={publishTeamMutation.isLoading}
onClick={() => {
publishTeamMutation.mutate({ teamId });
}}>
{t("team_publish")}
</Button>
</>
);
};
export default AddNewTeamMembers;
const AddNewTeamMemberSkeleton = () => {
return (
<SkeletonContainer className="rounded-md border">
<div className="flex w-full justify-between p-4">
<div>
<p className="text-sm font-medium text-gray-900">
<SkeletonText className="h-4 w-56" />
</p>
<div className="mt-2.5 w-max">
<SkeletonText className="h-5 w-28" />
</div>
</div>
</div>
</SkeletonContainer>
);
};
const PendingMemberItem = (props: { member: TeamMember; index: number; teamId: number }) => {
const { member, index, teamId } = props;
const { t } = useLocale();
const utils = trpc.useContext();
const removeMemberMutation = trpc.useMutation("viewer.teams.removeMember", {
async onSuccess() {
await utils.invalidateQueries(["viewer.teams.get"]);
showToast("Member removed", "success");
},
async onError(err) {
showToast(err.message, "error");
},
});
return (
<li
key={member.email}
className={classNames("flex items-center justify-between p-6 text-sm", index !== 0 && "border-t")}
data-testid="pending-member-item">
<div className="flex space-x-2">
<Avatar
gravatarFallbackMd5="teamMember"
size="mdLg"
imageSrc={WEBAPP_URL + "/" + member.username + "/avatar.png"}
alt="owner-avatar"
/>
<div>
<div className="flex space-x-1">
<p>{member.name || member.email || t("team_member")}</p>
{/* Assume that the first member of the team is the creator */}
{index === 0 && <Badge variant="green">{t("you")}</Badge>}
{!member.accepted && <Badge variant="orange">{t("pending")}</Badge>}
{member.role === "MEMBER" && <Badge variant="gray">{t("member")}</Badge>}
{member.role === "ADMIN" && <Badge variant="default">{t("admin")}</Badge>}
</div>
{member.username ? (
<p className="text-gray-600">{`${WEBAPP_URL}/${member.username}`}</p>
) : (
<p className="text-gray-600">{t("not_on_cal")}</p>
)}
</div>
</div>
{member.role !== "OWNER" && (
<Button
data-testid="remove-member-button"
StartIcon={Icon.FiTrash2}
size="icon"
color="secondary"
className="h-[36px] w-[36px]"
onClick={() => {
removeMemberMutation.mutate({ teamId, memberId: member.id });
}}
/>
)}
</li>
);
};

View File

@ -0,0 +1,129 @@
import { useRouter } from "next/router";
import { Controller, useForm } from "react-hook-form";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import slugify from "@calcom/lib/slugify";
import { trpc } from "@calcom/trpc/react";
import { Icon } from "@calcom/ui";
import { Avatar, Button } from "@calcom/ui/components";
import { Form, TextField } from "@calcom/ui/components/form";
import ImageUploader from "@calcom/ui/v2/core/ImageUploader";
import { NewTeamFormValues } from "../lib/types";
export const CreateANewTeamForm = () => {
const { t } = useLocale();
const router = useRouter();
const newTeamFormMethods = useForm<NewTeamFormValues>();
const createTeamMutation = trpc.useMutation(["viewer.teams.create"], {
onSuccess: (data) => {
router.push(`/settings/teams/${data.id}/onboard-members`);
},
});
const validateTeamSlugQuery = trpc.useQuery(
["viewer.teams.validateTeamSlug", { slug: newTeamFormMethods.watch("slug") }],
{
enabled: false,
refetchOnWindowFocus: false,
}
);
const validateTeamSlug = async () => {
await validateTeamSlugQuery.refetch();
if (validateTeamSlugQuery.isFetched) return validateTeamSlugQuery.data || t("team_url_taken");
};
return (
<>
<Form form={newTeamFormMethods} handleSubmit={(v) => createTeamMutation.mutate(v)}>
<div className="mb-8">
<Controller
name="name"
control={newTeamFormMethods.control}
defaultValue=""
rules={{
required: t("must_enter_team_name"),
}}
render={({ field: { value } }) => (
<>
<TextField
className="mt-2"
name="name"
label={t("team_name")}
value={value}
onChange={(e) => {
newTeamFormMethods.setValue("name", e?.target.value);
if (newTeamFormMethods.formState.touchedFields["slug"] === undefined) {
newTeamFormMethods.setValue("slug", slugify(e?.target.value));
}
}}
autoComplete="off"
/>
</>
)}
/>
</div>
<div className="mb-8">
<Controller
name="slug"
control={newTeamFormMethods.control}
rules={{ required: t("team_url_required"), validate: async () => await validateTeamSlug() }}
render={({ field: { value } }) => (
<TextField
className="mt-2"
name="slug"
label={t("team_url")}
addOnLeading={`${WEBAPP_URL}/team/`}
value={value}
onChange={(e) => {
newTeamFormMethods.setValue("slug", slugify(e?.target.value), {
shouldTouch: true,
});
}}
/>
)}
/>
</div>
<div className="mb-8">
<Controller
control={newTeamFormMethods.control}
name="logo"
render={({ field: { value } }) => (
<div className="flex items-center">
<Avatar alt="" imageSrc={value || null} gravatarFallbackMd5="newTeam" size="lg" />
<div className="ml-4">
<ImageUploader
target="avatar"
id="avatar-upload"
buttonMsg={t("update")}
handleAvatarChange={(newAvatar: string) => {
newTeamFormMethods.setValue("logo", newAvatar);
}}
imageSrc={value}
/>
</div>
</div>
)}
/>
</div>
<div className="flex space-x-2">
<Button color="secondary" href="/settings" className="w-full justify-center">
{t("cancel")}
</Button>
<Button color="primary" type="submit" EndIcon={Icon.FiArrowRight} className="w-full justify-center">
{t("continue")}
</Button>
</div>
{createTeamMutation.isError && (
<p className="mt-4 text-red-700">{createTeamMutation.error.message}</p>
)}
</Form>
</>
);
};

View File

@ -1,127 +1,145 @@
import { MembershipRole } from "@prisma/client";
import React, { useState, SyntheticEvent, useMemo } from "react";
import { Trans } from "next-i18next";
import { useMemo } from "react";
import { Controller, useForm } from "react-hook-form";
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { TeamWithMembers } from "@calcom/lib/server/queries/teams";
import { trpc } from "@calcom/trpc/react";
import { Button, TextField } from "@calcom/ui/components";
import CheckboxField from "@calcom/ui/components/form/checkbox/Checkbox";
import { Form } from "@calcom/ui/form/fields";
import { Dialog, DialogContent, DialogFooter, Select } from "@calcom/ui/v2";
import { PendingMember } from "../lib/types";
type MemberInvitationModalProps = {
isOpen: boolean;
team: TeamWithMembers | null;
currentMember: MembershipRole;
onExit: () => void;
onSubmit: (values: NewMemberForm) => void;
members: PendingMember[];
};
type MembershipRoleOption = {
value: MembershipRole;
label?: string;
label: string;
};
const _options: MembershipRoleOption[] = [{ value: "MEMBER" }, { value: "ADMIN" }, { value: "OWNER" }];
export interface NewMemberForm {
emailOrUsername: string;
role: MembershipRoleOption;
sendInviteEmail: boolean;
}
export default function MemberInvitationModal(props: MemberInvitationModalProps) {
const [errorMessage, setErrorMessage] = useState("");
const { t, i18n } = useLocale();
const utils = trpc.useContext();
const { t } = useLocale();
const options = useMemo(() => {
_options.forEach((option, i) => {
_options[i].label = t(option.value.toLowerCase());
});
return _options;
const options: MembershipRoleOption[] = useMemo(() => {
return [
{ value: "MEMBER", label: t("member") },
{ value: "ADMIN", label: t("admin") },
{ value: "OWNER", label: t("owner") },
];
}, [t]);
const inviteMemberMutation = trpc.useMutation("viewer.teams.inviteMember", {
async onSuccess() {
await utils.invalidateQueries(["viewer.teams.get"]);
props.onExit();
},
async onError(err) {
setErrorMessage(err.message);
},
});
const newMemberFormMethods = useForm<NewMemberForm>();
function inviteMember(e: SyntheticEvent) {
e.preventDefault();
if (!props.team) return;
const target = e.target as typeof e.target & {
elements: {
role: { value: MembershipRole };
inviteUser: { value: string };
sendInviteEmail: { checked: boolean };
};
};
inviteMemberMutation.mutate({
teamId: props.team.id,
language: i18n.language,
role: target.elements["role"].value,
usernameOrEmail: target.elements["inviteUser"].value,
sendEmailInvitation: target.elements["sendInviteEmail"].checked,
});
}
const validateUniqueInvite = (value: string) => {
return !(
props.members.some((member) => member?.username === value) ||
props.members.some((member) => member?.email === value)
);
};
return (
<Dialog open={props.isOpen} onOpenChange={props.onExit}>
<Dialog
open={props.isOpen}
onOpenChange={() => {
props.onExit();
newMemberFormMethods.reset();
}}>
<DialogContent
type="creation"
useOwnActionButtons
title={t("invite_new_member")}
description={
<span className=" text-sm leading-tight text-gray-500">
Note: This will <span className="font-medium text-gray-900">cost an extra seat ($12/m)</span> on
your subscription if this invitee does not have a TEAM account.
</span>
IS_TEAM_BILLING_ENABLED ? (
<span className=" text-sm leading-tight text-gray-500">
<Trans i18nKey="invite_new_member_description">
Note: This will <span className="font-medium text-gray-900">cost an extra seat ($15/m)</span>{" "}
on your subscription.
</Trans>
</span>
) : (
""
)
}>
<form onSubmit={inviteMember}>
<Form form={newMemberFormMethods} handleSubmit={(values) => props.onSubmit(values)}>
<div className="space-y-4">
<TextField
label={t("email_or_username")}
id="inviteUser"
name="inviteUser"
placeholder="email@example.com"
required
<Controller
name="emailOrUsername"
control={newMemberFormMethods.control}
rules={{
required: t("enter_email_or_username"),
validate: (value) => validateUniqueInvite(value) || t("member_already_invited"),
}}
render={({ field: { onChange }, fieldState: { error } }) => (
<>
<TextField
label={t("email_or_username")}
id="inviteUser"
name="inviteUser"
placeholder="email@example.com"
required
onChange={onChange}
/>
{error && <span className="text-sm text-red-800">{error.message}</span>}
</>
)}
/>
<Controller
name="role"
control={newMemberFormMethods.control}
defaultValue={options[0]}
render={({ field: { onChange } }) => (
<div>
<label
className="mb-1 block text-sm font-medium tracking-wide text-gray-700"
htmlFor="role">
{t("role")}
</label>
<Select
defaultValue={options[0]}
options={options.filter((option) => option.value !== "OWNER")}
id="role"
name="role"
className="mt-1 block w-full rounded-sm border-gray-300 text-sm"
onChange={onChange}
/>
</div>
)}
/>
<Controller
name="sendInviteEmail"
control={newMemberFormMethods.control}
defaultValue={false}
render={() => (
<div className="relative flex items-start">
<CheckboxField
description={t("send_invite_email")}
onChange={(e) => newMemberFormMethods.setValue("sendInviteEmail", e.target.checked)}
/>
</div>
)}
/>
<div>
<label className="mb-1 block text-sm font-medium tracking-wide text-gray-700" htmlFor="role">
{t("role")}
</label>
<Select
defaultValue={options[0]}
options={props.currentMember !== MembershipRole.OWNER ? options.slice(0, 2) : options}
id="role"
name="role"
className="mt-1 block w-full rounded-sm border-gray-300 text-sm"
/>
</div>
<div className="relative flex items-start">
<div className="flex h-5 items-center">
<input
type="checkbox"
name="sendInviteEmail"
defaultChecked
id="sendInviteEmail"
className="rounded-sm border-gray-300 text-sm text-black"
/>
</div>
<div className="text-sm ltr:ml-2 rtl:mr-2">
<label htmlFor="sendInviteEmail" className="font-medium text-gray-700">
{t("send_invite_email")}
</label>
</div>
</div>
</div>
{errorMessage && (
<p className="text-sm text-red-700">
<span className="font-bold">Error: </span>
{errorMessage}
</p>
)}
<DialogFooter>
<Button type="button" color="secondary" onClick={props.onExit}>
<Button
type="button"
color="secondary"
onClick={() => {
props.onExit();
newMemberFormMethods.reset();
}}>
{t("cancel")}
</Button>
<Button
@ -132,7 +150,7 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps)
{t("invite")}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);

View File

@ -1,5 +1,6 @@
import { MembershipRole } from "@prisma/client";
import Link from "next/link";
import { useRouter } from "next/router";
import classNames from "@calcom/lib/classNames";
import { getPlaceholderAvatar } from "@calcom/lib/getPlaceholderAvatar";
@ -11,11 +12,11 @@ import { ButtonGroup } from "@calcom/ui/components/buttonGroup";
import ConfirmationDialogContent from "@calcom/ui/v2/core/ConfirmationDialogContent";
import { Dialog, DialogTrigger } from "@calcom/ui/v2/core/Dialog";
import Dropdown, {
DropdownItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownItem,
} from "@calcom/ui/v2/core/Dropdown";
import { Tooltip } from "@calcom/ui/v2/core/Tooltip";
import showToast from "@calcom/ui/v2/core/notifications";
@ -72,7 +73,7 @@ export default function TeamListItem(props: Props) {
<div className="ml-3 inline-block">
<span className="text-sm font-bold text-neutral-700">{team.name}</span>
<span className="block text-xs text-gray-400">
{process.env.NEXT_PUBLIC_WEBSITE_URL}/team/{team.slug}
{team.slug ? `${process.env.NEXT_PUBLIC_WEBSITE_URL}/team/${team.slug}` : "Unpublished team"}
</span>
</div>
</div>
@ -95,7 +96,7 @@ export default function TeamListItem(props: Props) {
teamInfo
)}
<div className="px-5 py-5">
{isInvitee && (
{isInvitee ? (
<>
<div className="hidden sm:block">
<Button type="button" color="secondary" onClick={declineInvite}>
@ -133,25 +134,26 @@ export default function TeamListItem(props: Props) {
</Dropdown>
</div>
</>
)}
{!isInvitee && (
) : (
<div className="flex space-x-2 rtl:space-x-reverse">
<TeamRole role={team.role} />
<ButtonGroup combined>
<Tooltip content={t("copy_link_team")}>
<Button
color="secondary"
onClick={() => {
navigator.clipboard.writeText(
process.env.NEXT_PUBLIC_WEBSITE_URL + "/team/" + team.slug
);
showToast(t("link_copied"), "success");
}}
size="icon"
StartIcon={Icon.FiLink}
combined
/>
</Tooltip>
{team.slug && (
<Tooltip content={t("copy_link_team")}>
<Button
color="secondary"
onClick={() => {
navigator.clipboard.writeText(
process.env.NEXT_PUBLIC_WEBSITE_URL + "/team/" + team.slug
);
showToast(t("link_copied"), "success");
}}
size="icon"
StartIcon={Icon.FiLink}
combined
/>
</Tooltip>
)}
<Dropdown>
<DropdownMenuTrigger asChild className="radix-state-open:rounded-r-md">
<Button type="button" color="secondary" size="icon" StartIcon={Icon.FiMoreHorizontal} />
@ -167,15 +169,18 @@ export default function TeamListItem(props: Props) {
</DropdownItem>
</DropdownMenuItem>
)}
<DropdownMenuItem>
<DropdownItem
type="button"
target="_blank"
href={`${process.env.NEXT_PUBLIC_WEBSITE_URL}/team/${team.slug}`}
StartIcon={Icon.FiExternalLink}>
{t("preview_team") as string}
</DropdownItem>
</DropdownMenuItem>
{!team.slug && <TeamPublishButton teamId={team.id} />}
{team.slug && (
<DropdownMenuItem>
<DropdownItem
type="button"
target="_blank"
href={`${process.env.NEXT_PUBLIC_WEBSITE_URL}/team/${team.slug}`}
StartIcon={Icon.FiExternalLink}>
{t("preview_team") as string}
</DropdownItem>
</DropdownMenuItem>
)}
<DropdownMenuSeparator className="h-px bg-gray-200" />
{isOwner && (
<DropdownMenuItem>
@ -241,3 +246,29 @@ export default function TeamListItem(props: Props) {
</li>
);
}
const TeamPublishButton = ({ teamId }: { teamId: number }) => {
const { t } = useLocale();
const router = useRouter();
const publishTeamMutation = trpc.useMutation("viewer.teams.publish", {
onSuccess(data) {
router.push(data.url);
},
onError: (error) => {
showToast(error.message, "error");
},
});
return (
<DropdownMenuItem>
<DropdownItem
type="button"
onClick={() => {
publishTeamMutation.mutate({ teamId });
}}
StartIcon={Icon.FiGlobe}>
{t("team_publish")}
</DropdownItem>
</DropdownMenuItem>
);
};

View File

@ -0,0 +1,52 @@
import { useState } from "react";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Icon } from "@calcom/ui/Icon";
import { Button } from "@calcom/ui/components/button";
import { Alert } from "@calcom/ui/v2/core/Alert";
import EmptyScreen from "@calcom/ui/v2/core/EmptyScreen";
import SkeletonLoaderTeamList from "./SkeletonloaderTeamList";
import TeamList from "./TeamList";
export function TeamsListing() {
const { t } = useLocale();
const [errorMessage, setErrorMessage] = useState("");
const { data, isLoading } = trpc.useQuery(["viewer.teams.list"], {
onError: (e) => {
setErrorMessage(e.message);
},
});
const teams = data?.filter((m) => m.accepted) || [];
const invites = data?.filter((m) => !m.accepted) || [];
return (
<>
{!!errorMessage && <Alert severity="error" title={errorMessage} />}
{invites.length > 0 && (
<div className="mb-4">
<h1 className="mb-2 text-lg font-medium">{t("open_invitations")}</h1>
<TeamList teams={invites} />
</div>
)}
{isLoading && <SkeletonLoaderTeamList />}
{!teams.length && !isLoading && (
<EmptyScreen
Icon={Icon.FiUsers}
headline={t("no_teams")}
description={t("no_teams_description")}
buttonRaw={
<Button color="secondary" href={`${WEBAPP_URL}/settings/teams/new`}>
{t("create_team")}
</Button>
}
/>
)}
{teams.length > 0 && <TeamList teams={teams} />}
</>
);
}

View File

@ -1,68 +0,0 @@
import { useState } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Alert, Dialog, DialogContent, DialogTrigger, showToast } from "@calcom/ui/v2/core";
interface Props {
teamId: number;
}
export function UpgradeToFlexibleProModal(props: Props) {
const { t } = useLocale();
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const utils = trpc.useContext();
const { data } = trpc.useQuery(["viewer.teams.getTeamSeats", { teamId: props.teamId }], {
onError: (err) => {
setErrorMessage(err.message);
},
});
const mutation = trpc.useMutation(["viewer.teams.upgradeTeam"], {
onSuccess: (data) => {
// if the user does not already have a Stripe subscription, this wi
if (data?.url) {
window.location.href = data.url;
}
if (data?.success) {
utils.invalidateQueries(["viewer.teams.get"]);
showToast(t("team_upgraded_successfully"), "success");
}
},
onError: (err) => {
setErrorMessage(err.message);
},
});
function upgrade() {
setErrorMessage(null);
mutation.mutate({ teamId: props.teamId });
}
return (
<Dialog>
<DialogTrigger asChild>
<a className="cursor-pointer underline">Upgrade Now</a>
</DialogTrigger>
<DialogContent
type="creation"
title={t("purchase_missing_seats")}
actionText={t("upgrade_to_per_seat")}
actionOnClick={() => upgrade()}>
<p className="mt-6 text-sm text-gray-600">{t("changed_team_billing_info")}test</p>
{data && (
<p className="mt-2 text-sm italic text-gray-700">
{t("team_upgrade_seats_details", {
memberCount: data.totalMembers,
unpaidCount: data.missingSeats,
seatPrice: 12,
totalCost: (data.totalMembers - data.freeSeats) * 12 + 12,
})}
</p>
)}
{errorMessage && (
<Alert severity="error" title={errorMessage} message={t("further_billing_help")} className="my-4" />
)}
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,2 @@
export { CreateANewTeamForm } from "./CreateANewTeamForm";
export { TeamsListing } from "./TeamsListing";

View File

@ -1,127 +0,0 @@
import { Suspense, useState } from "react";
import MemberInvitationModal from "@calcom/features/ee/teams/components/MemberInvitationModal";
import { classNames } from "@calcom/lib";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Icon } from "@calcom/ui";
import { Avatar } from "@calcom/ui/components/avatar";
import { Badge } from "@calcom/ui/components/badge";
import { Button } from "@calcom/ui/components/button";
import { SkeletonContainer, SkeletonText } from "@calcom/ui/v2/core/skeleton";
const AddNewTeamMemberSkeleton = () => {
return (
<SkeletonContainer className="rounded-md border">
<div className="flex w-full justify-between p-4">
<div>
<p className="text-sm font-medium text-gray-900">
<SkeletonText className="h-4 w-56" />
</p>
<div className="mt-2.5 w-max">
<SkeletonText className="h-5 w-28" />
</div>
</div>
</div>
</SkeletonContainer>
);
};
const AddNewTeamMembers = (props: { teamId: number }) => {
const { t } = useLocale();
const utils = trpc.useContext();
const { data: team, isLoading } = trpc.useQuery(["viewer.teams.get", { teamId: props.teamId }]);
const removeMemberMutation = trpc.useMutation("viewer.teams.removeMember", {
onSuccess() {
utils.invalidateQueries(["viewer.teams.get", { teamId: props.teamId }]);
utils.invalidateQueries(["viewer.teams.list"]);
},
});
const [memberInviteModal, setMemberInviteModal] = useState(false);
if (isLoading) return <AddNewTeamMemberSkeleton />;
return (
<Suspense fallback={<AddNewTeamMemberSkeleton />}>
<>
<>
<ul className="rounded-md border">
{team?.members.map((member, index) => (
<li
key={member.id}
className={classNames(
"flex items-center justify-between p-6 text-sm",
index !== 0 && "border-t"
)}>
<div className="flex space-x-2">
<Avatar
gravatarFallbackMd5="teamMember"
size="mdLg"
imageSrc={WEBAPP_URL + "/" + member.username + "/avatar.png"}
alt="owner-avatar"
/>
<div>
<div className="flex space-x-1">
<p>{member?.name || t("team_member")}</p>
{/* Assume that the first member of the team is the creator */}
{index === 0 && <Badge variant="green">{t("you")}</Badge>}
{!member.accepted && <Badge variant="orange">{t("pending")}</Badge>}
{member.role === "MEMBER" && <Badge variant="gray">{t("member")}</Badge>}
{member.role === "ADMIN" && <Badge variant="default">{t("admin")}</Badge>}
</div>
{member.username ? (
<p className="text-gray-600">{`${WEBAPP_URL}/${member?.username}`}</p>
) : (
<p className="text-gray-600">{t("not_on_cal")}</p>
)}
</div>
</div>
{member.role !== "OWNER" && (
<Button
StartIcon={Icon.FiTrash2}
size="icon"
color="secondary"
className="h-[36px] w-[36px]"
onClick={() => removeMemberMutation.mutate({ teamId: props.teamId, memberId: member.id })}
/>
)}
</li>
))}
</ul>
<Button
color="secondary"
data-testid="new-member-button"
StartIcon={Icon.FiPlus}
onClick={() => setMemberInviteModal(true)}
className="mt-6 w-full justify-center">
{t("add_team_member")}
</Button>
</>
{team && (
<MemberInvitationModal
isOpen={memberInviteModal}
onExit={() => setMemberInviteModal(false)}
team={team}
currentMember={team?.membership.role}
/>
)}
<hr className="my-6 border-neutral-200" />
<Button
EndIcon={Icon.FiArrowRight}
className="mt-6 w-full justify-center"
href={`${WEBAPP_URL}/settings/teams/${props.teamId}/profile`}>
{t("finish")}
</Button>
</>
</Suspense>
);
};
export default AddNewTeamMembers;

View File

@ -1,113 +0,0 @@
import { useForm, Controller } from "react-hook-form";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import slugify from "@calcom/lib/slugify";
import { trpc } from "@calcom/trpc/react";
import { Icon } from "@calcom/ui";
import { Button, Avatar } from "@calcom/ui/components";
import { Form, TextField } from "@calcom/ui/components/form";
import ImageUploader from "@calcom/ui/v2/core/ImageUploader";
const CreateANewTeamForm = (props: { nextStep: () => void; setTeamId: (teamId: number) => void }) => {
const { t } = useLocale();
const utils = trpc.useContext();
const createTeamMutation = trpc.useMutation("viewer.teams.create", {
onSuccess(data) {
utils.invalidateQueries(["viewer.teams.list"]);
props.setTeamId(data.id);
props.nextStep();
},
});
const formMethods = useForm();
return (
<Form
form={formMethods}
handleSubmit={(values) => {
createTeamMutation.mutate({
name: values.name,
slug: values.slug || null,
logo: values.logo || null,
});
}}>
<div className="mb-8">
<Controller
name="name"
control={formMethods.control}
rules={{ required: { value: true, message: t("team_name_required") } }}
render={({ field: { value } }) => (
<TextField
className="mt-2"
name="name"
label={t("team_name")}
value={value}
onChange={(e) => {
formMethods.setValue("name", e?.target.value);
if (formMethods.formState.touchedFields["slug"] === undefined) {
formMethods.setValue("slug", slugify(e?.target.value));
}
}}
autoComplete="off"
/>
)}
/>
</div>
<div className="mb-8">
<Controller
name="slug"
control={formMethods.control}
render={({ field: { value } }) => (
<TextField
className="mt-2"
name="slug"
label={t("team_url")}
addOnLeading={`${WEBAPP_URL}/team/`}
value={value}
onChange={(e) => {
formMethods.setValue("slug", slugify(e?.target.value), { shouldTouch: true });
}}
/>
)}
/>
</div>
<div className="mb-8">
<Controller
control={formMethods.control}
name="avatar"
render={({ field: { value } }) => (
<div className="flex items-center">
<Avatar alt="" imageSrc={value || null} gravatarFallbackMd5="newTeam" size="lg" />
<div className="ml-4">
<ImageUploader
target="avatar"
id="avatar-upload"
buttonMsg={t("update")}
handleAvatarChange={(newAvatar: string) => {
formMethods.setValue("avatar", newAvatar);
}}
imageSrc={value}
/>
</div>
</div>
)}
/>
</div>
<div className="flex space-x-2">
<Button color="secondary" href="/settings" className="w-full justify-center">
{t("cancel")}
</Button>
<Button color="primary" type="submit" EndIcon={Icon.FiArrowRight} className="w-full justify-center">
{t("continue")}
</Button>
</div>
{createTeamMutation.isError && <p className="mt-4 text-red-700">{createTeamMutation.error.message}</p>}
</Form>
);
};
export default CreateANewTeamForm;

View File

@ -0,0 +1,52 @@
import { getStripeCustomerIdFromUserId } from "@calcom/app-store/stripepayment/lib/customer";
import stripe from "@calcom/app-store/stripepayment/lib/server";
import { WEBAPP_URL } from "@calcom/lib/constants";
import prisma from "@calcom/prisma";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
export const purchaseTeamSubscription = async (input: { teamId: number; seats: number; userId: number }) => {
const { teamId, seats, userId } = input;
const customer = await getStripeCustomerIdFromUserId(userId);
return await stripe.checkout.sessions.create({
customer,
mode: "subscription",
success_url: `${WEBAPP_URL}/api/teams/${teamId}/upgrade?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${WEBAPP_URL}/settings/profile`,
locale: "en",
line_items: [
{
/** We only need to set the base price and we can upsell it directly on Stripe's checkout */
price: process.env.STRIPE_TEAM_MONTHLY_PRICE_ID,
quantity: seats,
},
],
metadata: {
teamId,
},
payment_method_types: ["card"],
subscription_data: {
metadata: {
teamId,
},
},
});
};
export const cancelTeamSubscriptionFromStripe = async (teamId: number) => {
try {
const team = await prisma.team.findUniqueOrThrow({
where: { id: teamId },
select: { metadata: true },
});
const metadata = teamMetadataSchema.parse(team.metadata);
if (!metadata?.subscriptionId)
throw Error(
`Couldn't cancelTeamSubscriptionFromStripe, Team id: ${teamId} didn't have a subscriptionId`
);
return await stripe.subscriptions.cancel(metadata.subscriptionId);
} catch (error) {
let message = "Unknown error on cancelTeamSubscriptionFromStripe";
if (error instanceof Error) message = error.message;
console.error(message);
}
};

View File

@ -0,0 +1,18 @@
import { MembershipRole } from "@prisma/client";
export interface NewTeamFormValues {
name: string;
slug: string;
temporarySlug: string;
logo: string;
}
export interface PendingMember {
name: string | null;
email: string;
id?: number;
username: string | null;
role: MembershipRole;
avatar: string | null;
sendInviteEmail?: boolean;
}

View File

@ -0,0 +1,35 @@
import { useRouter } from "next/router";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button, Icon } from "@calcom/ui";
import Meta from "@calcom/ui/v2/core/Meta";
import { getLayout } from "@calcom/ui/v2/core/layouts/SettingsLayout";
const BillingView = () => {
const { t } = useLocale();
const router = useRouter();
const returnTo = router.asPath;
const billingHref = `/api/integrations/stripepayment/portal?returnTo=${WEBAPP_URL}${returnTo}`;
return (
<>
<Meta title="Team Billing" description="Manage billing for your team" />
<div className="flex flex-col text-sm sm:flex-row">
<div>
<h2 className="font-medium">{t("billing_manage_details_title")}</h2>
<p>{t("billing_manage_details_description")}</p>
</div>
<div className="flex-shrink-0 pt-3 sm:ml-auto sm:pt-0 sm:pl-3">
<Button color="primary" href={billingHref} target="_blank" EndIcon={Icon.FiExternalLink}>
{t("billing_portal")}
</Button>
</div>
</div>
</>
);
};
BillingView.getLayout = getLayout;
export default BillingView;

View File

@ -0,0 +1,19 @@
import { useLocale } from "@calcom/lib/hooks/useLocale";
import Meta from "@calcom/ui/v2/core/Meta";
import { getLayout } from "@calcom/ui/v2/core/layouts/SettingsLayout";
import { TeamsListing } from "../components";
const BillingView = () => {
const { t } = useLocale();
return (
<>
<Meta title={t("teams")} description={t("create_manage_teams_collaborative")} />
<TeamsListing />
</>
);
};
BillingView.getLayout = getLayout;
export default BillingView;

View File

@ -7,7 +7,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Icon } from "@calcom/ui/Icon";
import { Button } from "@calcom/ui/components";
import { Alert } from "@calcom/ui/v2/core";
import { showToast } from "@calcom/ui/v2/core";
import Meta from "@calcom/ui/v2/core/Meta";
import { getLayout } from "@calcom/ui/v2/core/layouts/SettingsLayout";
@ -15,20 +15,30 @@ import DisableTeamImpersonation from "../components/DisableTeamImpersonation";
import MemberInvitationModal from "../components/MemberInvitationModal";
import MemberListItem from "../components/MemberListItem";
import TeamInviteList from "../components/TeamInviteList";
import { UpgradeToFlexibleProModal } from "../components/UpgradeToFlexibleProModal";
const MembersView = () => {
const { t } = useLocale();
const { t, i18n } = useLocale();
const router = useRouter();
const session = useSession();
const utils = trpc.useContext();
const [showMemberInvitationModal, setShowMemberInvitationModal] = useState(false);
const teamId = Number(router.query.id);
const { data: team, isLoading } = trpc.useQuery(["viewer.teams.get", { teamId: Number(router.query.id) }], {
const { data: team, isLoading } = trpc.useQuery(["viewer.teams.get", { teamId }], {
onError: () => {
router.push("/settings");
},
});
const [showMemberInvitationModal, setShowMemberInvitationModal] = useState(false);
const inviteMemberMutation = trpc.useMutation("viewer.teams.inviteMember", {
async onSuccess() {
await utils.invalidateQueries(["viewer.teams.get"]);
setShowMemberInvitationModal(false);
},
onError: (error) => {
showToast(error.message, "error");
},
});
const isInviteOpen = !team?.membership.accepted;
@ -57,44 +67,6 @@ const MembersView = () => {
]}
/>
)}
{team.membership.role === MembershipRole.OWNER &&
team.membership.isMissingSeat &&
team.requiresUpgrade ? (
<Alert
severity="warning"
title={t("hidden_team_member_title")}
message={
<>
{t("hidden_team_owner_message")} <UpgradeToFlexibleProModal teamId={team.id} />
</>
}
className="mb-4 "
/>
) : (
<>
{team.membership.isMissingSeat && (
<Alert
severity="warning"
title={t("hidden_team_member_title")}
message={t("hidden_team_member_message")}
className="mb-4 "
/>
)}
{team.membership.role === MembershipRole.OWNER && team.requiresUpgrade && (
<Alert
severity="warning"
title={t("upgrade_to_flexible_pro_title")}
message={
<span>
{t("upgrade_to_flexible_pro_message")} <br />
<UpgradeToFlexibleProModal teamId={team.id} />
</span>
}
className="mb-4"
/>
)}
</>
)}
</>
)}
{isAdmin && (
@ -131,9 +103,17 @@ const MembersView = () => {
{showMemberInvitationModal && team && (
<MemberInvitationModal
isOpen={showMemberInvitationModal}
team={team}
currentMember={team.membership.role}
members={team.members}
onExit={() => setShowMemberInvitationModal(false)}
onSubmit={(values) => {
inviteMemberMutation.mutate({
teamId,
language: i18n.language,
role: values.role.value,
usernameOrEmail: values.emailOrUsername,
sendEmailInvitation: values.sendInviteEmail,
});
}}
/>
)}
</>

View File

@ -10,12 +10,14 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import objectKeys from "@calcom/lib/objectKeys";
import { trpc } from "@calcom/trpc/react";
import { Icon } from "@calcom/ui";
import { Avatar, Button, Label, TextArea, Form, TextField } from "@calcom/ui/components";
import { Dialog, DialogTrigger, LinkIconButton, showToast } from "@calcom/ui/v2/core";
import { Avatar, Button, Form, Label, TextArea, TextField } from "@calcom/ui/components";
import ConfirmationDialogContent from "@calcom/ui/v2/core/ConfirmationDialogContent";
import { Dialog, DialogTrigger } from "@calcom/ui/v2/core/Dialog";
import ImageUploader from "@calcom/ui/v2/core/ImageUploader";
import LinkIconButton from "@calcom/ui/v2/core/LinkIconButton";
import Meta from "@calcom/ui/v2/core/Meta";
import { getLayout } from "@calcom/ui/v2/core/layouts/SettingsLayout";
import showToast from "@calcom/ui/v2/core/notifications";
interface TeamProfileValues {
name: string;
@ -65,8 +67,8 @@ const ProfileView = () => {
async onSuccess() {
await utils.invalidateQueries(["viewer.teams.get"]);
await utils.invalidateQueries(["viewer.teams.list"]);
router.push(`/settings`);
showToast(t("your_team_updated_successfully"), "success");
showToast(t("your_team_disbanded_successfully"), "success");
router.push(`${WEBAPP_URL}/teams`);
},
});

View File

@ -36,3 +36,10 @@ export const DEVELOPER_DOCS = "https://developer.cal.com";
export const SEO_IMG_DEFAULT = `${WEBSITE_URL}/og-image.png`;
export const SEO_IMG_OGIMG = `${CAL_URL}/api/social/og/image`;
export const SEO_IMG_OGIMG_VIDEO = `${WEBSITE_URL}/video-og-image.png`;
export const IS_STRIPE_ENABLED = !!(
process.env.STRIPE_CLIENT_ID &&
process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY &&
process.env.STRIPE_PRIVATE_KEY
);
/** Self hosted shouldn't checkout when creating teams */
export const IS_TEAM_BILLING_ENABLED = IS_STRIPE_ENABLED && !IS_SELF_HOSTED;

View File

@ -3,8 +3,10 @@ import { Prisma, UserPlan } from "@prisma/client";
import prisma, { baseEventTypeSelect } from "@calcom/prisma";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { WEBAPP_URL } from "../../../constants";
export type TeamWithMembers = Awaited<ReturnType<typeof getTeamWithMembers>>;
export async function getTeamWithMembers(id?: number, slug?: string) {
export async function getTeamWithMembers(id?: number, slug?: string, userId?: number) {
const userSelect = Prisma.validator<Prisma.UserSelect>()({
username: true,
email: true,
@ -22,6 +24,9 @@ export async function getTeamWithMembers(id?: number, slug?: string) {
hideBranding: true,
members: {
select: {
accepted: true,
role: true,
disableImpersonation: true,
user: {
select: userSelect,
},
@ -41,26 +46,26 @@ export async function getTeamWithMembers(id?: number, slug?: string) {
},
});
const team = await prisma.team.findUnique({
where: id ? { id } : { slug },
const where: Prisma.TeamFindFirstArgs["where"] = {};
if (userId) where.members = { some: { userId } };
if (id) where.id = id;
if (slug) where.slug = slug;
const team = await prisma.team.findFirst({
where,
select: teamSelect,
});
if (!team) return null;
const memberships = await prisma.membership.findMany({
where: {
teamId: team.id,
},
});
const members = team.members.map((obj) => {
const membership = memberships.find((membership) => obj.user.id === membership.userId);
return {
...obj.user,
isMissingSeat: obj.user.plan === UserPlan.FREE,
role: membership?.role,
accepted: membership?.accepted,
disableImpersonation: membership?.disableImpersonation,
role: obj.role,
accepted: obj.accepted,
disableImpersonation: obj.disableImpersonation,
avatar: `${WEBAPP_URL}/${obj.user.username}/avatar.png`,
};
});

View File

@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "Team" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "metadata" JSONB,
ALTER COLUMN "slug" DROP NOT NULL;

View File

@ -202,12 +202,15 @@ model Team {
/// @zod.min(1)
name String
/// @zod.min(1)
slug String @unique
slug String? @unique
logo String?
bio String?
hideBranding Boolean @default(false)
members Membership[]
eventTypes EventType[]
createdAt DateTime @default(now())
/// @zod.custom(imports.teamMetadataSchema)
metadata Json?
}
enum MembershipRole {

View File

@ -574,6 +574,7 @@ async function main() {
],
},
},
createdAt: new Date(),
},
[
{

View File

@ -199,6 +199,16 @@ export const userMetadata = z
})
.nullable();
export const teamMetadataSchema = z
.object({
requestedSlug: z.string(),
paymentId: z.string(),
subscriptionId: z.string().nullable(),
subscriptionItemId: z.string().nullable(),
})
.partial()
.nullable();
/**
* Ensures that it is a valid HTTP URL
* It automatically avoids

View File

@ -2,19 +2,16 @@ import { MembershipRole, Prisma, UserPlan } from "@prisma/client";
import { randomBytes } from "crypto";
import { z } from "zod";
import {
addSeat,
downgradeTeamMembers,
ensureSubscriptionQuantityCorrectness,
getTeamSeatStats,
removeSeat,
upgradeTeam,
} from "@calcom/app-store/stripepayment/lib/team-billing";
import { addSeat, getRequestedSlugError, removeSeat } from "@calcom/app-store/stripepayment/lib/team-billing";
import { getUserAvailability } from "@calcom/core/getUserAvailability";
import { sendTeamInviteEmail } from "@calcom/emails";
import { HOSTED_CAL_FEATURES, WEBAPP_URL } from "@calcom/lib/constants";
import {
cancelTeamSubscriptionFromStripe,
purchaseTeamSubscription,
} from "@calcom/features/ee/teams/lib/payments";
import { HOSTED_CAL_FEATURES, IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants";
import { getTranslation } from "@calcom/lib/server/i18n";
import { getTeamWithMembers, isTeamAdmin, isTeamOwner, isTeamMember } from "@calcom/lib/server/queries/teams";
import { getTeamWithMembers, isTeamAdmin, isTeamMember, isTeamOwner } from "@calcom/lib/server/queries/teams";
import slugify from "@calcom/lib/slugify";
import {
closeComDeleteTeam,
@ -23,6 +20,7 @@ import {
closeComUpsertTeamUser,
} from "@calcom/lib/sync/SyncServiceManager";
import { availabilityUserSelect } from "@calcom/prisma";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { TRPCError } from "@trpc/server";
@ -35,9 +33,9 @@ export const viewerTeamsRouter = createProtectedRouter()
teamId: z.number(),
}),
async resolve({ ctx, input }) {
const team = await getTeamWithMembers(input.teamId);
if (!team?.members.find((m) => m.id === ctx.user.id)) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "You are not a member of this team." });
const team = await getTeamWithMembers(input.teamId, undefined, ctx.user.id);
if (!team) {
throw new TRPCError({ code: "NOT_FOUND", message: "Team not found." });
}
const membership = team?.members.find((membership) => membership.id === ctx.user.id);
@ -59,59 +57,52 @@ export const viewerTeamsRouter = createProtectedRouter()
where: {
userId: ctx.user.id,
},
include: {
team: true,
},
orderBy: { role: "desc" },
});
const teams = await ctx.prisma.team.findMany({
where: {
id: {
in: memberships.map((membership) => membership.teamId),
},
},
});
return memberships.map((membership) => ({
return memberships.map(({ team, ...membership }) => ({
role: membership.role,
accepted: membership.accepted,
...teams.find((team) => team.id === membership.teamId),
...team,
}));
},
})
.mutation("create", {
input: z.object({
name: z.string(),
slug: z.string().optional().nullable(),
logo: z.string().optional().nullable(),
slug: z.string().transform((val) => slugify(val.trim())),
logo: z
.string()
.optional()
.nullable()
.transform((v) => v || null),
}),
async resolve({ ctx, input }) {
if (ctx.user.plan === "FREE") {
throw new TRPCError({ code: "UNAUTHORIZED", message: "You need a team plan." });
}
const { slug, name, logo } = input;
const slug = input.slug || slugify(input.name);
const nameCollisions = await ctx.prisma.team.count({
where: {
OR: [{ name: input.name }, { slug: slug }],
},
const nameCollisions = await ctx.prisma.team.findFirst({
where: { OR: [{ name }, { slug }] },
});
if (nameCollisions > 0)
throw new TRPCError({ code: "BAD_REQUEST", message: "Team name already taken." });
if (nameCollisions) throw new TRPCError({ code: "BAD_REQUEST", message: "Team name already taken." });
const createTeam = await ctx.prisma.team.create({
data: {
name: input.name,
slug: slug,
logo: input.logo || null,
},
});
await ctx.prisma.membership.create({
data: {
teamId: createTeam.id,
userId: ctx.user.id,
role: MembershipRole.OWNER,
accepted: true,
name,
logo,
members: {
create: {
userId: ctx.user.id,
role: MembershipRole.OWNER,
accepted: true,
},
},
metadata: {
requestedSlug: slug,
},
},
});
@ -149,17 +140,31 @@ export const viewerTeamsRouter = createProtectedRouter()
},
});
if (!prevTeam) throw new TRPCError({ code: "NOT_FOUND", message: "Team not found." });
const data: Prisma.TeamUpdateArgs["data"] = {
name: input.name,
logo: input.logo,
bio: input.bio,
hideBranding: input.hideBranding,
};
if (
input.slug &&
IS_TEAM_BILLING_ENABLED &&
/** If the team doesn't have a slug we can assume that it hasn't been published yet. */ !prevTeam.slug
) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You cannot change the slug until you publish your team",
});
} else {
data.slug = input.slug;
}
const updatedTeam = await ctx.prisma.team.update({
where: {
id: input.id,
},
data: {
name: input.name,
slug: input.slug,
logo: input.logo,
bio: input.bio,
hideBranding: input.hideBranding,
},
where: { id: input.id },
data,
});
// Sync Services: Close.com
@ -173,9 +178,7 @@ export const viewerTeamsRouter = createProtectedRouter()
async resolve({ ctx, input }) {
if (!(await isTeamOwner(ctx.user?.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" });
if (process.env.STRIPE_PRIVATE_KEY) {
await downgradeTeamMembers(input.teamId);
}
if (IS_TEAM_BILLING_ENABLED) await cancelTeamSubscriptionFromStripe(input.teamId);
// delete all memberships
await ctx.prisma.membership.deleteMany({
@ -487,32 +490,6 @@ export const viewerTeamsRouter = createProtectedRouter()
);
},
})
.mutation("upgradeTeam", {
input: z.object({
teamId: z.number(),
}),
async resolve({ ctx, input }) {
if (!HOSTED_CAL_FEATURES)
throw new TRPCError({ code: "FORBIDDEN", message: "Team billing is not enabled" });
return await upgradeTeam(ctx.user.id, input.teamId);
},
})
.query("getTeamSeats", {
input: z.object({
teamId: z.number(),
}),
async resolve({ input }) {
return await getTeamSeatStats(input.teamId);
},
})
.mutation("ensureSubscriptionQuantityCorrectness", {
input: z.object({
teamId: z.number(),
}),
async resolve({ ctx, input }) {
return await ensureSubscriptionQuantityCorrectness(ctx.user.id, input.teamId);
},
})
.query("getMembershipbyUser", {
input: z.object({
teamId: z.number(),
@ -562,4 +539,72 @@ export const viewerTeamsRouter = createProtectedRouter()
},
});
},
})
.query("validateTeamSlug", {
input: z.object({
slug: z.string(),
}),
async resolve({ ctx, input }) {
const team = await ctx.prisma.team.findFirst({
where: {
slug: input.slug,
},
});
return !team;
},
})
.mutation("publish", {
input: z.object({
teamId: z.number(),
}),
async resolve({ ctx, input }) {
if (!(await isTeamAdmin(ctx.user.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" });
const { teamId: id } = input;
const prevTeam = await ctx.prisma.team.findFirst({ where: { id }, include: { members: true } });
if (!prevTeam) throw new TRPCError({ code: "NOT_FOUND", message: "Team not found." });
const metadata = teamMetadataSchema.safeParse(prevTeam.metadata);
if (!metadata.success || !metadata.data?.requestedSlug)
throw new TRPCError({ code: "BAD_REQUEST", message: "Can't publish team without `requestedSlug`" });
// if payment needed, responed with checkout url
if (IS_TEAM_BILLING_ENABLED) {
const checkoutSession = await purchaseTeamSubscription({
teamId: prevTeam.id,
seats: prevTeam.members.length,
userId: ctx.user.id,
});
if (!checkoutSession.url)
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed retrieving a checkout session URL.",
});
return { url: checkoutSession.url };
}
const { requestedSlug, ...newMetadata } = metadata.data;
let updatedTeam: Awaited<ReturnType<typeof ctx.prisma.team.update>>;
try {
updatedTeam = await ctx.prisma.team.update({
where: { id },
data: {
slug: requestedSlug,
metadata: { ...newMetadata },
},
});
} catch (error) {
const { message } = getRequestedSlugError(error, requestedSlug);
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message });
}
// Sync Services: Close.com
closeComUpdateTeam(prevTeam, updatedTeam);
return { url: `${WEBAPP_URL}/settings/teams/${updatedTeam.id}/profile` };
},
});

View File

@ -80,7 +80,7 @@ type DialogContentProps = React.ComponentProps<typeof DialogPrimitive["Content"]
};
export const DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps>(
({ children, title, Icon, actionProps, ...props }, forwardedRef) => {
({ children, title, Icon, actionProps, useOwnActionButtons, ...props }, forwardedRef) => {
const { t } = useLocale();
return (
@ -122,7 +122,7 @@ export const DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps
</div>
</div>
)}
{!props.useOwnActionButtons && (
{!useOwnActionButtons && (
<DialogFooter>
<div className="mt-2 flex space-x-2">
<DialogClose asChild>

View File

@ -1,6 +1,7 @@
import { MembershipRole, UserPermissionRole } from "@prisma/client";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@radix-ui/react-collapsible";
import { useSession } from "next-auth/react";
import Link from "next/link";
import { useRouter } from "next/router";
import React, { ComponentProps, useEffect, useState } from "react";
@ -158,12 +159,16 @@ const SettingsSidebarContainer = ({ className = "" }) => {
) : (
<React.Fragment key={tab.href}>
<div className={`${!tab.children?.length ? "mb-3" : ""}`}>
<div className="group flex h-9 w-64 flex-row items-center rounded-md px-3 py-[10px] text-sm font-medium leading-none text-gray-600 hover:bg-gray-100 group-hover:text-gray-700 [&[aria-current='page']]:bg-gray-200 [&[aria-current='page']]:text-gray-900">
{tab && tab.icon && (
<tab.icon className="mr-[12px] h-[16px] w-[16px] stroke-[2px] md:mt-0" />
)}
<p className="text-sm font-medium leading-5">{t(tab.name)}</p>
</div>
<Link href={tab.href}>
<a>
<div className="group flex h-9 w-64 flex-row items-center rounded-md px-3 py-[10px] text-sm font-medium leading-none text-gray-600 hover:bg-gray-100 group-hover:text-gray-700 [&[aria-current='page']]:bg-gray-200 [&[aria-current='page']]:text-gray-900">
{tab && tab.icon && (
<tab.icon className="mr-[12px] h-[16px] w-[16px] stroke-[2px] md:mt-0" />
)}
<p className="text-sm font-medium leading-5">{t(tab.name)}</p>
</div>
</a>
</Link>
{teams &&
teamMenuState &&
teams.map((team, index: number) => {
@ -243,6 +248,12 @@ const SettingsSidebarContainer = ({ className = "" }) => {
textClassNames="px-3 text-gray-900 font-medium text-sm"
disableChevron
/>
<VerticalTabItem
name={t("billing")}
href={`/settings/teams/${team.id}/billing`}
textClassNames="px-3 text-gray-900 font-medium text-sm"
disableChevron
/>
{HOSTED_CAL_FEATURES && (
<VerticalTabItem
name={t("saml_config")}

View File

@ -0,0 +1,50 @@
import { noop } from "lodash";
import { useRouter } from "next/router";
import React, { useEffect, useState } from "react";
import { Toaster } from "react-hot-toast";
import { StepCard } from "@calcom/ui/v2/core/StepCard";
import { Steps } from "@calcom/ui/v2/core/Steps";
export default function WizardLayout({
children,
maxSteps = 2,
currentStep = 0,
}: {
children: React.ReactNode;
} & { maxSteps?: number; currentStep?: number }) {
const [meta, setMeta] = useState({ title: "", subtitle: " " });
const router = useRouter();
const { title, subtitle } = meta;
useEffect(() => {
setMeta({
title: window.document.title,
subtitle: window.document.querySelector('meta[name="description"]')?.getAttribute("content") || "",
});
}, [router.asPath]);
return (
<div className="dark:bg-brand dark:text-brand-contrast min-h-screen text-black" data-testid="onboarding">
<div>
<Toaster position="bottom-right" />
</div>
<div className="mx-auto px-4 py-24">
<div className="relative">
<div className="sm:mx-auto sm:w-full sm:max-w-[600px]">
<div className="mx-auto sm:max-w-[520px]">
<header>
<p className="font-cal mb-3 text-[28px] font-medium leading-7">{title}&nbsp;</p>
<p className="font-sans text-sm font-normal text-gray-500">{subtitle}&nbsp;</p>
</header>
<Steps maxSteps={maxSteps} currentStep={currentStep} navigateToStep={noop} />
</div>
<StepCard>{children}</StepCard>
</div>
</div>
</div>
</div>
);
}
export const getLayout = (page: React.ReactElement) => <WizardLayout>{page}</WizardLayout>;

View File

@ -39,6 +39,7 @@
"$STRIPE_PRO_PLAN_PRODUCT_ID",
"$STRIPE_PREMIUM_PLAN_PRODUCT_ID",
"$STRIPE_FREE_PLAN_PRODUCT_ID",
"$STRIPE_TEAM_MONTHLY_PRICE_ID",
"$NEXT_PUBLIC_STRIPE_PUBLIC_KEY",
"$NEXT_PUBLIC_WEBAPP_URL",
"$NEXT_PUBLIC_WEBSITE_URL"