From 6d67808627e5b8958a2e881de6c1095398529b3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Omar=20L=C3=B3pez?= Date: Thu, 10 Nov 2022 13:23:56 -0700 Subject: [PATCH] Team billing (#5453) * WIP teams billing page * WIP * Create settings page * Remove unused imports * Create stripe customer on team creation * Add Stripe ids to team record * Add Stripe price ids for team to .env * Create & delete Stripe customers * Add string * Merge branch 'main' into v2/teams-billing * Create checkout session when creating team * Create webhook to update team with Stripe ids * Add Stripe migration files * Move deleting team from Stripe under ee * Some cleanup * Merge branch 'v2/teams-billing' of https://github.com/calcom/cal.com into v2/teams-billing * Small clean up * Link to team's portal page * Fix types * Fix type errors * Fix type errors * Fix type error * Delete old files & type fixes * Address feedback * Fix type errors * Removes team creation modal * WIP * Removed billing frequency from team creation * Add Stripe check for delete team customer * Merge branch 'v2/teams-billing' of https://github.com/calcom/cal.com into v2/teams-billing * Add high level form to create new team * WIP * Add new team to form * Validate for invited members * Add translations * WIP * Add validation for team name * Add validation to team slug * Clean up * Fix type error * Fix type errors * WIP * Abstract invite members function * Add subscription status column * Hide pending teams from settings * Send email on paid subscription * WIP * Sync packages * Add team subscription cols to schema * WIP * Matches locks vite version to <3 * Removed subscriptionStatus * WIP * Fix warning * Query optimizations * WIP * Cleanup * Wip * WIP * Runtime error fixes * Cancellation fixes * Delete team fixes * Cleanup * Type fixes * Allows to check memebership in getTeamWithMembers * Adds team creation tests * Cleanup * Cleanup * Restored change * Updated copy * Moved component * Cleanup * Fix team members view * Cleanup * Adds failsafe for skipping publishing on update * Cleanup * Feedback * More feedback * Cleanup * Cleanup * Feedback * Feedback * Feedback * Adds edge-case for slug conflicts * Feedback * e2e fixes Co-authored-by: Joe Au-Yeung Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> Co-authored-by: Peer Richelsen --- .env.example | 7 +- .../team/DisableTeamImpersonation.tsx | 62 ----- .../components/team/MemberChangeRoleModal.tsx | 115 -------- .../components/team/MemberInvitationModal.tsx | 159 ----------- apps/web/components/team/MemberList.tsx | 22 -- apps/web/components/team/MemberListItem.tsx | 260 ------------------ apps/web/components/team/TeamCreateModal.tsx | 73 ----- apps/web/components/team/TeamPill.tsx | 37 --- .../team/UpgradeToFlexibleProModal.tsx | 91 ------ .../web/pages/getting-started/[[...step]].tsx | 4 +- apps/web/pages/settings/billing/index.tsx | 7 +- .../web/pages/settings/teams/[id]/billing.tsx | 1 + .../settings/teams/[id]/onboard-members.tsx | 26 ++ apps/web/pages/settings/teams/index.ts | 1 + .../pages/settings/teams/new/[[...step]].tsx | 113 -------- apps/web/pages/settings/teams/new/index.tsx | 20 ++ apps/web/pages/teams/index.tsx | 53 +--- apps/web/playwright/teams.e2e.ts | 64 +++++ apps/web/public/static/locales/en/common.json | 16 ++ .../app-store/stripepayment/api/portal.ts | 36 ++- .../stripepayment/lib/team-billing.ts | 40 +-- packages/features/ee/payments/api/webhook.ts | 8 +- packages/features/ee/teams/api/upgrade.ts | 75 ++++- .../ee/teams/components/AddNewTeamMembers.tsx | 180 ++++++++++++ .../teams/components/CreateANewTeamForm.tsx | 129 +++++++++ .../components/MemberInvitationModal.tsx | 202 +++++++------- .../components}/SkeletonloaderTeamList.tsx | 0 .../ee/teams/components}/TeamList.tsx | 0 .../ee/teams/components}/TeamListItem.tsx | 87 ++++-- .../ee/teams/components/TeamsListing.tsx | 52 ++++ .../components/UpgradeToFlexibleProModal.tsx | 68 ----- .../features/ee/teams/components/index.ts | 2 + .../teams/components/v2/AddNewTeamMembers.tsx | 127 --------- .../ee/teams/components/v2/CreateNewTeam.tsx | 113 -------- packages/features/ee/teams/lib/payments.ts | 52 ++++ packages/features/ee/teams/lib/types.ts | 18 ++ .../ee/teams/pages/team-billing-view.tsx | 35 +++ .../ee/teams/pages/team-listing-view.tsx | 19 ++ .../ee/teams/pages/team-members-view.tsx | 70 ++--- .../ee/teams/pages/team-profile-view.tsx | 10 +- packages/lib/constants.ts | 7 + packages/lib/server/queries/teams/index.ts | 31 ++- .../migration.sql | 4 + packages/prisma/schema.prisma | 5 +- packages/prisma/seed.ts | 1 + packages/prisma/zod-utils.ts | 10 + packages/trpc/server/routers/viewer/teams.tsx | 217 +++++++++------ packages/ui/v2/core/Dialog.tsx | 4 +- .../ui/v2/core}/StepCard.tsx | 0 .../ui/v2/core}/Steps.tsx | 0 .../ui/v2/core/layouts/SettingsLayout.tsx | 23 +- packages/ui/v2/core/layouts/WizardLayout.tsx | 50 ++++ turbo.json | 1 + 53 files changed, 1178 insertions(+), 1629 deletions(-) delete mode 100644 apps/web/components/team/DisableTeamImpersonation.tsx delete mode 100644 apps/web/components/team/MemberChangeRoleModal.tsx delete mode 100644 apps/web/components/team/MemberInvitationModal.tsx delete mode 100644 apps/web/components/team/MemberList.tsx delete mode 100644 apps/web/components/team/MemberListItem.tsx delete mode 100644 apps/web/components/team/TeamCreateModal.tsx delete mode 100644 apps/web/components/team/TeamPill.tsx delete mode 100644 apps/web/components/team/UpgradeToFlexibleProModal.tsx create mode 100644 apps/web/pages/settings/teams/[id]/billing.tsx create mode 100644 apps/web/pages/settings/teams/[id]/onboard-members.tsx create mode 100644 apps/web/pages/settings/teams/index.ts delete mode 100644 apps/web/pages/settings/teams/new/[[...step]].tsx create mode 100644 apps/web/pages/settings/teams/new/index.tsx create mode 100644 apps/web/playwright/teams.e2e.ts create mode 100644 packages/features/ee/teams/components/AddNewTeamMembers.tsx create mode 100644 packages/features/ee/teams/components/CreateANewTeamForm.tsx rename {apps/web/components/team => packages/features/ee/teams/components}/SkeletonloaderTeamList.tsx (100%) rename {apps/web/components/team => packages/features/ee/teams/components}/TeamList.tsx (100%) rename {apps/web/components/team => packages/features/ee/teams/components}/TeamListItem.tsx (80%) create mode 100644 packages/features/ee/teams/components/TeamsListing.tsx delete mode 100644 packages/features/ee/teams/components/UpgradeToFlexibleProModal.tsx create mode 100644 packages/features/ee/teams/components/index.ts delete mode 100644 packages/features/ee/teams/components/v2/AddNewTeamMembers.tsx delete mode 100644 packages/features/ee/teams/components/v2/CreateNewTeam.tsx create mode 100644 packages/features/ee/teams/lib/payments.ts create mode 100644 packages/features/ee/teams/lib/types.ts create mode 100644 packages/features/ee/teams/pages/team-billing-view.tsx create mode 100644 packages/features/ee/teams/pages/team-listing-view.tsx create mode 100644 packages/prisma/migrations/20221107201132_add_team_subscription_cols/migration.sql rename {apps/web/components/getting-started/components => packages/ui/v2/core}/StepCard.tsx (100%) rename {apps/web/components/getting-started/components => packages/ui/v2/core}/Steps.tsx (100%) create mode 100644 packages/ui/v2/core/layouts/WizardLayout.tsx diff --git a/.env.example b/.env.example index 5b34bcf263..308d5a5b42 100644 --- a/.env.example +++ b/.env.example @@ -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_ diff --git a/apps/web/components/team/DisableTeamImpersonation.tsx b/apps/web/components/team/DisableTeamImpersonation.tsx deleted file mode 100644 index 0208c47845..0000000000 --- a/apps/web/components/team/DisableTeamImpersonation.tsx +++ /dev/null @@ -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 ( - <> -

{t("settings")}

-
-
-
-
-

- {t("user_impersonation_heading")} -

- - {!query.data?.disableImpersonation ? t("enabled") : t("disabled")} - -
-

{t("team_impersonation_description")}

-
-
- -
-
-
- - ); -}; - -export default DisableTeamImpersonation; diff --git a/apps/web/components/team/MemberChangeRoleModal.tsx b/apps/web/components/team/MemberChangeRoleModal.tsx deleted file mode 100644 index fe7dda2776..0000000000 --- a/apps/web/components/team/MemberChangeRoleModal.tsx +++ /dev/null @@ -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( - 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 ( - - <> -
-
- -
-
-
-
- - {/* - needs dialog to confirm change of ownership */} - -
-
-
- -
-
- -
-
-
-
- - {errorMessage && ( -

- Error: - {errorMessage} -

- )} - - - - -
- - - ); -} diff --git a/apps/web/components/team/MemberList.tsx b/apps/web/components/team/MemberList.tsx deleted file mode 100644 index 24cf37ebe7..0000000000 --- a/apps/web/components/team/MemberList.tsx +++ /dev/null @@ -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 ( -
-
    - {props.members?.map((member) => ( - - ))} -
-
- ); -} diff --git a/apps/web/components/team/MemberListItem.tsx b/apps/web/components/team/MemberListItem.tsx deleted file mode 100644 index c729803637..0000000000 --- a/apps/web/components/team/MemberListItem.tsx +++ /dev/null @@ -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 ( -
  • -
    -
    -
    - -
    - {name} - - {props.member.email} - -
    -
    -
    - {/* Tooltip doesn't show... WHY????? */} - {props.member.isMissingSeat && ( - - - - )} - {!props.member.accepted && } - {props.member.role && } -
    -
    -
    - - - - - - - - - - - {((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)) && ( - <> - - - - - {/* 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" && ( - <> - - - - - - )} - - - - - - - {t("remove_member_confirmation_message")} - - - - - )} - - -
    -
    - {showChangeMemberRoleModal && ( - setShowChangeMemberRoleModal(false)} - /> - )} - {showImpersonateModal && props.member.username && ( - setShowImpersonateModal(false)}> - <> -
    -
    - -
    -
    -
    { - e.preventDefault(); - await signIn("impersonation-auth", { - username: props.member.username, - teamId: props.team.id, - }); - }}> -

    - {t("impersonate_user_tip")} -

    -
    - - -
    -
    - -
    - )} - {showTeamAvailabilityModal && ( - setShowTeamAvailabilityModal(false)}> - -
    - - {props.team.membership.role !== MembershipRole.MEMBER && ( - - - - )} -
    -
    - )} -
  • - ); -} diff --git a/apps/web/components/team/TeamCreateModal.tsx b/apps/web/components/team/TeamCreateModal.tsx deleted file mode 100644 index a1dc2e9b3a..0000000000 --- a/apps/web/components/team/TeamCreateModal.tsx +++ /dev/null @@ -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); - const nameRef = useRef() as React.MutableRefObject; - - 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 ( - <> - - -
    -
    - -
    -
    - -
    -

    {t("create_new_team_description")}

    -
    -
    -
    -
    -
    - - -
    - {errorMessage && } - -
    -
    - - ); -} diff --git a/apps/web/components/team/TeamPill.tsx b/apps/web/components/team/TeamPill.tsx deleted file mode 100644 index b7abb99077..0000000000 --- a/apps/web/components/team/TeamPill.tsx +++ /dev/null @@ -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 ( -
    - {props.text} -
    - ); -} - -export function TeamRole(props: { role: MembershipRole }) { - const { t } = useLocale(); - const keys: Record = { - [MembershipRole.OWNER]: undefined, - [MembershipRole.ADMIN]: "red", - [MembershipRole.MEMBER]: "blue", - }; - return ; -} diff --git a/apps/web/components/team/UpgradeToFlexibleProModal.tsx b/apps/web/components/team/UpgradeToFlexibleProModal.tsx deleted file mode 100644 index 10450f7c1c..0000000000 --- a/apps/web/components/team/UpgradeToFlexibleProModal.tsx +++ /dev/null @@ -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(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 ( - { - setErrorMessage(null); - }}> - - Upgrade Now - - - - -

    {t("changed_team_billing_info")}

    - {data && ( -

    - {t("team_upgrade_seats_details", { - memberCount: data.totalMembers, - unpaidCount: data.missingSeats, - seatPrice: 12, - totalCost: (data.totalMembers - data.freeSeats) * 12 + 12, - })} -

    - )} - - {errorMessage && ( - - )} - - - - - - - -
    -
    - ); -} diff --git a/apps/web/pages/getting-started/[[...step]].tsx b/apps/web/pages/getting-started/[[...step]].tsx index 6e6540d111..e088354175 100644 --- a/apps/web/pages/getting-started/[[...step]].tsx +++ b/apps/web/pages/getting-started/[[...step]].tsx @@ -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"; diff --git a/apps/web/pages/settings/billing/index.tsx b/apps/web/pages/settings/billing/index.tsx index 11a1072fbb..cb638a0f04 100644 --- a/apps/web/pages/settings/billing/index.tsx +++ b/apps/web/pages/settings/billing/index.tsx @@ -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")}> }> - <> - {!!errorMessage && } - {showCreateTeamModal && ( - setShowCreateTeamModal(false)} /> - )} - {invites.length > 0 && ( -
    -

    {t("open_invitations")}

    - -
    - )} - {isLoading && } - {!teams.length && !isLoading && ( - setShowCreateTeamModal(true)}> - {t("create_team")} - - } - buttonOnClick={() => setShowCreateTeamModal(true)} - /> - )} - {teams.length > 0 && } - + ); } diff --git a/apps/web/playwright/teams.e2e.ts b/apps/web/playwright/teams.e2e.ts new file mode 100644 index 0000000000..f649608fb2 --- /dev/null +++ b/apps/web/playwright/teams.e2e.ts @@ -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(); + }); + }); +}); diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index c2839309eb..22706a4b0f 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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) 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", diff --git a/packages/app-store/stripepayment/api/portal.ts b/packages/app-store/stripepayment/api/portal.ts index f04d3b4be8..1aaedd3d85 100644 --- a/packages/app-store/stripepayment/api/portal.ts +++ b/packages/app-store/stripepayment/api/portal.ts @@ -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); } diff --git a/packages/app-store/stripepayment/lib/team-billing.ts b/packages/app-store/stripepayment/lib/team-billing.ts index e9868fead6..3cd49e0405 100644 --- a/packages/app-store/stripepayment/lib/team-billing.ts +++ b/packages/app-store/stripepayment/lib/team-billing.ts @@ -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 }; } diff --git a/packages/features/ee/payments/api/webhook.ts b/packages/features/ee/payments/api/webhook.ts index 775ea0cade..7dc18d2225 100644 --- a/packages/features/ee/payments/api/webhook.ts +++ b/packages/features/ee/payments/api/webhook.ts @@ -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, diff --git a/packages/features/ee/teams/api/upgrade.ts b/packages/features/ee/teams/api/upgrade.ts index 09b968fedb..1b9f23d128 100644 --- a/packages/features/ee/teams/api/upgrade.ts +++ b/packages/features/ee/teams/api/upgrade.ts @@ -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) }), +}); diff --git a/packages/features/ee/teams/components/AddNewTeamMembers.tsx b/packages/features/ee/teams/components/AddNewTeamMembers.tsx new file mode 100644 index 0000000000..70f49b2446 --- /dev/null +++ b/packages/features/ee/teams/components/AddNewTeamMembers.tsx @@ -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 ; + + return ; +}; + +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 ( + <> +
    +
      + {defaultValues.members.map((member, index) => ( + + ))} +
    + +
    + setMemberInviteModal(false)} + onSubmit={(values) => { + inviteMemberMutation.mutate({ + teamId, + language: i18n.language, + role: values.role.value, + usernameOrEmail: values.emailOrUsername, + sendEmailInvitation: values.sendInviteEmail, + }); + }} + members={defaultValues.members} + /> +
    + + + ); +}; + +export default AddNewTeamMembers; + +const AddNewTeamMemberSkeleton = () => { + return ( + +
    +
    +

    + +

    +
    + +
    +
    +
    +
    + ); +}; + +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 ( +
  • +
    + +
    +
    +

    {member.name || member.email || t("team_member")}

    + {/* Assume that the first member of the team is the creator */} + {index === 0 && {t("you")}} + {!member.accepted && {t("pending")}} + {member.role === "MEMBER" && {t("member")}} + {member.role === "ADMIN" && {t("admin")}} +
    + {member.username ? ( +

    {`${WEBAPP_URL}/${member.username}`}

    + ) : ( +

    {t("not_on_cal")}

    + )} +
    +
    + {member.role !== "OWNER" && ( +
  • + ); +}; diff --git a/packages/features/ee/teams/components/CreateANewTeamForm.tsx b/packages/features/ee/teams/components/CreateANewTeamForm.tsx new file mode 100644 index 0000000000..f786264ca2 --- /dev/null +++ b/packages/features/ee/teams/components/CreateANewTeamForm.tsx @@ -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(); + + 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 ( + <> +
    createTeamMutation.mutate(v)}> +
    + ( + <> + { + newTeamFormMethods.setValue("name", e?.target.value); + if (newTeamFormMethods.formState.touchedFields["slug"] === undefined) { + newTeamFormMethods.setValue("slug", slugify(e?.target.value)); + } + }} + autoComplete="off" + /> + + )} + /> +
    + +
    + await validateTeamSlug() }} + render={({ field: { value } }) => ( + { + newTeamFormMethods.setValue("slug", slugify(e?.target.value), { + shouldTouch: true, + }); + }} + /> + )} + /> +
    + +
    + ( +
    + +
    + { + newTeamFormMethods.setValue("logo", newAvatar); + }} + imageSrc={value} + /> +
    +
    + )} + /> +
    + +
    + + +
    + {createTeamMutation.isError && ( +

    {createTeamMutation.error.message}

    + )} +
    + + ); +}; diff --git a/packages/features/ee/teams/components/MemberInvitationModal.tsx b/packages/features/ee/teams/components/MemberInvitationModal.tsx index 8a01e6b3eb..76c2017009 100644 --- a/packages/features/ee/teams/components/MemberInvitationModal.tsx +++ b/packages/features/ee/teams/components/MemberInvitationModal.tsx @@ -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(); - 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 ( - + { + props.onExit(); + newMemberFormMethods.reset(); + }}> - Note: This will cost an extra seat ($12/m) on - your subscription if this invitee does not have a TEAM account. - + IS_TEAM_BILLING_ENABLED ? ( + + + Note: This will cost an extra seat ($15/m){" "} + on your subscription. + + + ) : ( + "" + ) }> -
    + props.onSubmit(values)}>
    - validateUniqueInvite(value) || t("member_already_invited"), + }} + render={({ field: { onChange }, fieldState: { error } }) => ( + <> + + {error && {error.message}} + + )} + /> + ( +
    + + -
    -
    -
    - -
    -
    - -
    -
    - {errorMessage && ( -

    - Error: - {errorMessage} -

    - )} -
    ); diff --git a/apps/web/components/team/SkeletonloaderTeamList.tsx b/packages/features/ee/teams/components/SkeletonloaderTeamList.tsx similarity index 100% rename from apps/web/components/team/SkeletonloaderTeamList.tsx rename to packages/features/ee/teams/components/SkeletonloaderTeamList.tsx diff --git a/apps/web/components/team/TeamList.tsx b/packages/features/ee/teams/components/TeamList.tsx similarity index 100% rename from apps/web/components/team/TeamList.tsx rename to packages/features/ee/teams/components/TeamList.tsx diff --git a/apps/web/components/team/TeamListItem.tsx b/packages/features/ee/teams/components/TeamListItem.tsx similarity index 80% rename from apps/web/components/team/TeamListItem.tsx rename to packages/features/ee/teams/components/TeamListItem.tsx index 214daf0763..a29bf8fc08 100644 --- a/apps/web/components/team/TeamListItem.tsx +++ b/packages/features/ee/teams/components/TeamListItem.tsx @@ -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) {
    {team.name} - {process.env.NEXT_PUBLIC_WEBSITE_URL}/team/{team.slug} + {team.slug ? `${process.env.NEXT_PUBLIC_WEBSITE_URL}/team/${team.slug}` : "Unpublished team"}
    @@ -95,7 +96,7 @@ export default function TeamListItem(props: Props) { teamInfo )}
    - {isInvitee && ( + {isInvitee ? ( <>
    - )} - {!isInvitee && ( + ) : (
    - - + } + /> + )} + {teams.length > 0 && } + + ); +} diff --git a/packages/features/ee/teams/components/UpgradeToFlexibleProModal.tsx b/packages/features/ee/teams/components/UpgradeToFlexibleProModal.tsx deleted file mode 100644 index f289e94166..0000000000 --- a/packages/features/ee/teams/components/UpgradeToFlexibleProModal.tsx +++ /dev/null @@ -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(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 ( - - - Upgrade Now - - upgrade()}> -

    {t("changed_team_billing_info")}test

    - {data && ( -

    - {t("team_upgrade_seats_details", { - memberCount: data.totalMembers, - unpaidCount: data.missingSeats, - seatPrice: 12, - totalCost: (data.totalMembers - data.freeSeats) * 12 + 12, - })} -

    - )} - {errorMessage && ( - - )} -
    -
    - ); -} diff --git a/packages/features/ee/teams/components/index.ts b/packages/features/ee/teams/components/index.ts new file mode 100644 index 0000000000..cf5f5dcf68 --- /dev/null +++ b/packages/features/ee/teams/components/index.ts @@ -0,0 +1,2 @@ +export { CreateANewTeamForm } from "./CreateANewTeamForm"; +export { TeamsListing } from "./TeamsListing"; diff --git a/packages/features/ee/teams/components/v2/AddNewTeamMembers.tsx b/packages/features/ee/teams/components/v2/AddNewTeamMembers.tsx deleted file mode 100644 index ae4387137e..0000000000 --- a/packages/features/ee/teams/components/v2/AddNewTeamMembers.tsx +++ /dev/null @@ -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 ( - -
    -
    -

    - -

    -
    - -
    -
    -
    -
    - ); -}; - -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 ; - - return ( - }> - <> - <> -
      - {team?.members.map((member, index) => ( -
    • -
      - -
      -
      -

      {member?.name || t("team_member")}

      - {/* Assume that the first member of the team is the creator */} - {index === 0 && {t("you")}} - {!member.accepted && {t("pending")}} - {member.role === "MEMBER" && {t("member")}} - {member.role === "ADMIN" && {t("admin")}} -
      - {member.username ? ( -

      {`${WEBAPP_URL}/${member?.username}`}

      - ) : ( -

      {t("not_on_cal")}

      - )} -
      -
      - {member.role !== "OWNER" && ( -
    • - ))} -
    - - - - - {team && ( - setMemberInviteModal(false)} - team={team} - currentMember={team?.membership.role} - /> - )} - -
    - - - -
    - ); -}; - -export default AddNewTeamMembers; diff --git a/packages/features/ee/teams/components/v2/CreateNewTeam.tsx b/packages/features/ee/teams/components/v2/CreateNewTeam.tsx deleted file mode 100644 index c3f2c781d2..0000000000 --- a/packages/features/ee/teams/components/v2/CreateNewTeam.tsx +++ /dev/null @@ -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 ( -
    { - createTeamMutation.mutate({ - name: values.name, - slug: values.slug || null, - logo: values.logo || null, - }); - }}> -
    - ( - { - formMethods.setValue("name", e?.target.value); - if (formMethods.formState.touchedFields["slug"] === undefined) { - formMethods.setValue("slug", slugify(e?.target.value)); - } - }} - autoComplete="off" - /> - )} - /> -
    - -
    - ( - { - formMethods.setValue("slug", slugify(e?.target.value), { shouldTouch: true }); - }} - /> - )} - /> -
    -
    - ( -
    - -
    - { - formMethods.setValue("avatar", newAvatar); - }} - imageSrc={value} - /> -
    -
    - )} - /> -
    -
    - - -
    - - {createTeamMutation.isError &&

    {createTeamMutation.error.message}

    } -
    - ); -}; - -export default CreateANewTeamForm; diff --git a/packages/features/ee/teams/lib/payments.ts b/packages/features/ee/teams/lib/payments.ts new file mode 100644 index 0000000000..0d915a35ba --- /dev/null +++ b/packages/features/ee/teams/lib/payments.ts @@ -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); + } +}; diff --git a/packages/features/ee/teams/lib/types.ts b/packages/features/ee/teams/lib/types.ts new file mode 100644 index 0000000000..0f87289108 --- /dev/null +++ b/packages/features/ee/teams/lib/types.ts @@ -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; +} diff --git a/packages/features/ee/teams/pages/team-billing-view.tsx b/packages/features/ee/teams/pages/team-billing-view.tsx new file mode 100644 index 0000000000..49d0f0daf9 --- /dev/null +++ b/packages/features/ee/teams/pages/team-billing-view.tsx @@ -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 ( + <> + +
    +
    +

    {t("billing_manage_details_title")}

    +

    {t("billing_manage_details_description")}

    +
    +
    + +
    +
    + + ); +}; + +BillingView.getLayout = getLayout; + +export default BillingView; diff --git a/packages/features/ee/teams/pages/team-listing-view.tsx b/packages/features/ee/teams/pages/team-listing-view.tsx new file mode 100644 index 0000000000..69ce551f96 --- /dev/null +++ b/packages/features/ee/teams/pages/team-listing-view.tsx @@ -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 ( + <> + + + + ); +}; + +BillingView.getLayout = getLayout; + +export default BillingView; diff --git a/packages/features/ee/teams/pages/team-members-view.tsx b/packages/features/ee/teams/pages/team-members-view.tsx index 4d49cb0c42..fa5bac292a 100644 --- a/packages/features/ee/teams/pages/team-members-view.tsx +++ b/packages/features/ee/teams/pages/team-members-view.tsx @@ -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 ? ( - - {t("hidden_team_owner_message")} - - } - className="mb-4 " - /> - ) : ( - <> - {team.membership.isMissingSeat && ( - - )} - {team.membership.role === MembershipRole.OWNER && team.requiresUpgrade && ( - - {t("upgrade_to_flexible_pro_message")}
    - - - } - className="mb-4" - /> - )} - - )} )} {isAdmin && ( @@ -131,9 +103,17 @@ const MembersView = () => { {showMemberInvitationModal && team && ( setShowMemberInvitationModal(false)} + onSubmit={(values) => { + inviteMemberMutation.mutate({ + teamId, + language: i18n.language, + role: values.role.value, + usernameOrEmail: values.emailOrUsername, + sendEmailInvitation: values.sendInviteEmail, + }); + }} /> )} diff --git a/packages/features/ee/teams/pages/team-profile-view.tsx b/packages/features/ee/teams/pages/team-profile-view.tsx index 9592cc6d44..b412bcc167 100644 --- a/packages/features/ee/teams/pages/team-profile-view.tsx +++ b/packages/features/ee/teams/pages/team-profile-view.tsx @@ -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`); }, }); diff --git a/packages/lib/constants.ts b/packages/lib/constants.ts index dee3306f43..911a6be2e1 100644 --- a/packages/lib/constants.ts +++ b/packages/lib/constants.ts @@ -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; diff --git a/packages/lib/server/queries/teams/index.ts b/packages/lib/server/queries/teams/index.ts index 12f8dcf41c..1206dad55b 100644 --- a/packages/lib/server/queries/teams/index.ts +++ b/packages/lib/server/queries/teams/index.ts @@ -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>; -export async function getTeamWithMembers(id?: number, slug?: string) { +export async function getTeamWithMembers(id?: number, slug?: string, userId?: number) { const userSelect = Prisma.validator()({ 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`, }; }); diff --git a/packages/prisma/migrations/20221107201132_add_team_subscription_cols/migration.sql b/packages/prisma/migrations/20221107201132_add_team_subscription_cols/migration.sql new file mode 100644 index 0000000000..ada911fd8c --- /dev/null +++ b/packages/prisma/migrations/20221107201132_add_team_subscription_cols/migration.sql @@ -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; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 8c96334d96..9dda13a96b 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -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 { diff --git a/packages/prisma/seed.ts b/packages/prisma/seed.ts index 59ec94a1f7..3db8dda2c2 100644 --- a/packages/prisma/seed.ts +++ b/packages/prisma/seed.ts @@ -574,6 +574,7 @@ async function main() { ], }, }, + createdAt: new Date(), }, [ { diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts index a041f5b051..c5bf4f005a 100644 --- a/packages/prisma/zod-utils.ts +++ b/packages/prisma/zod-utils.ts @@ -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 diff --git a/packages/trpc/server/routers/viewer/teams.tsx b/packages/trpc/server/routers/viewer/teams.tsx index bf0104c675..ce343452f9 100644 --- a/packages/trpc/server/routers/viewer/teams.tsx +++ b/packages/trpc/server/routers/viewer/teams.tsx @@ -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>; + + 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` }; + }, }); diff --git a/packages/ui/v2/core/Dialog.tsx b/packages/ui/v2/core/Dialog.tsx index 5015cdf121..37ba6329fa 100644 --- a/packages/ui/v2/core/Dialog.tsx +++ b/packages/ui/v2/core/Dialog.tsx @@ -80,7 +80,7 @@ type DialogContentProps = React.ComponentProps( - ({ 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
    )} - {!props.useOwnActionButtons && ( + {!useOwnActionButtons && (
    diff --git a/apps/web/components/getting-started/components/StepCard.tsx b/packages/ui/v2/core/StepCard.tsx similarity index 100% rename from apps/web/components/getting-started/components/StepCard.tsx rename to packages/ui/v2/core/StepCard.tsx diff --git a/apps/web/components/getting-started/components/Steps.tsx b/packages/ui/v2/core/Steps.tsx similarity index 100% rename from apps/web/components/getting-started/components/Steps.tsx rename to packages/ui/v2/core/Steps.tsx diff --git a/packages/ui/v2/core/layouts/SettingsLayout.tsx b/packages/ui/v2/core/layouts/SettingsLayout.tsx index a0fe60a024..0a35c1bed6 100644 --- a/packages/ui/v2/core/layouts/SettingsLayout.tsx +++ b/packages/ui/v2/core/layouts/SettingsLayout.tsx @@ -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 = "" }) => { ) : (
    -
    - {tab && tab.icon && ( - - )} -

    {t(tab.name)}

    -
    + + +
    + {tab && tab.icon && ( + + )} +

    {t(tab.name)}

    +
    +
    + {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 /> + {HOSTED_CAL_FEATURES && ( { + setMeta({ + title: window.document.title, + subtitle: window.document.querySelector('meta[name="description"]')?.getAttribute("content") || "", + }); + }, [router.asPath]); + + return ( +
    +
    + +
    +
    +
    +
    +
    +
    +

    {title} 

    +

    {subtitle} 

    +
    + +
    + {children} +
    +
    +
    +
    + ); +} + +export const getLayout = (page: React.ReactElement) => {page}; diff --git a/turbo.json b/turbo.json index 87631ad877..67b087399d 100644 --- a/turbo.json +++ b/turbo.json @@ -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"