Team Billing (#1552)
* added base logic for team billing - moved Stripe customer related logic to customer.ts - implemented unstable logic for team owner upgrading, downgrading and adding/removing seats * logic improvements * - improved Alert style - hide free team members on public team page - upgraded textarea to ui component TextArea in SAML setup - added Alert on team settings for hidden members - hide CreateEventTypeButton if not admin - fixed missing locale strings in team settings * remove random import * - show hidden status on team list - refactor team pill * - improved logic (mostly functional) - added Alerts for members & owners - added local strings - created upgrade modal - added info notice on invite member modal - fixed router redirect after leaving team * - improved logic in team-billing - error display on upgrade modal - added better launch.json for VSCode debugger - fixed bug with missing inviteeUserId * code cleanup * nit pick fixes i should sleep now * fixed leave team bug - quantity would not decrease upon leave or removal * added stripe billing callback handler * - better launch.json - teams empty component * - fixed error not removing after successful pro upgrade - fixed silent fail on team create name conflict - fixed input border radius on member invite modal * updated local strings * improved logic for edge cases, such as: - team owned by member sponsored by another team can smoothly upgrade to pro if kicked from sponsored team - logic to calculate if owner is specifically missing pro subscription (ownerIsMissingSeat) - corrected calculation of members missing seats, shouldn't care for proPaidForByTeamId as that only matters for removing member and preserving pro if they pay for it themselves - added react query devtools - added missing locale string * - allow type override for LinkIconButton - consolidate filter logic for getMembersMissingSeats * - only activate team billing for hosted cal - fix prod price keys * fix requiresUpgrade when not hosted by cal * added HOSTED_CAL_FEATURES * fixed failing build - fixed broken import path - added support for premium price plan. (will consider premium as a valid seat) - remove rouge console log * fix customer id type error Co-authored-by: Peer Richelsen <peeroke@gmail.com> Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
This commit is contained in:
parent
c201bfab2d
commit
5567721431
31 changed files with 772 additions and 189 deletions
36
.vscode/launch.json
vendored
36
.vscode/launch.json
vendored
|
@ -1,15 +1,39 @@
|
|||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Next.js: Server",
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"command": "npm run dev",
|
||||
"skipFiles": ["<node_internals>/**"],
|
||||
"outFiles": [
|
||||
"${workspaceFolder}/**/*.js",
|
||||
"!**/node_modules/**"
|
||||
],
|
||||
"sourceMaps": true,
|
||||
"resolveSourceMapLocations": [
|
||||
"${workspaceFolder}/**",
|
||||
"!**/node_modules/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Next.js: Client",
|
||||
"type": "pwa-chrome",
|
||||
"request": "launch",
|
||||
"name": "Launch Chrome against localhost",
|
||||
"url": "http://localhost:8080",
|
||||
"webRoot": "${workspaceFolder}"
|
||||
"url": "http://localhost:3000"
|
||||
},
|
||||
{
|
||||
"name": "Next.js: Full Stack",
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"command": "npm run dev",
|
||||
"console": "integratedTerminal",
|
||||
"serverReadyAction": {
|
||||
"pattern": "started server on .+, url: (https?://.+)",
|
||||
"uriFormat": "%s",
|
||||
"action": "debugWithChrome"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
|
@ -32,7 +32,7 @@ type DialogHeaderProps = {
|
|||
export function DialogHeader(props: DialogHeaderProps) {
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<h3 className="text-lg font-bold leading-6 text-gray-900 font-cal" id="modal-title">
|
||||
<h3 className="text-xl text-gray-900 leading-16 font-cal" id="modal-title">
|
||||
{props.title}
|
||||
</h3>
|
||||
{props.subtitle && <div className="text-sm text-gray-400">{props.subtitle}</div>}
|
||||
|
|
|
@ -37,7 +37,7 @@ export const EventTypeDescription = ({ eventType, className }: EventTypeDescript
|
|||
{eventType.description.length > 100 && "..."}
|
||||
</h2>
|
||||
)}
|
||||
<ul className="flex mt-2 rtl:space-x-reverse space-x-4 ">
|
||||
<ul className="flex mt-2 space-x-4 rtl:space-x-reverse ">
|
||||
<li className="flex whitespace-nowrap">
|
||||
<ClockIcon className="inline mt-0.5 mr-1.5 h-4 w-4 text-neutral-400" aria-hidden="true" />
|
||||
{eventType.length}m
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { UserIcon } from "@heroicons/react/outline";
|
||||
import { InformationCircleIcon } from "@heroicons/react/solid";
|
||||
import { MembershipRole } from "@prisma/client";
|
||||
import { useState } from "react";
|
||||
import React, { SyntheticEvent } from "react";
|
||||
|
@ -7,7 +8,7 @@ import { useLocale } from "@lib/hooks/useLocale";
|
|||
import { TeamWithMembers } from "@lib/queries/teams";
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
import { EmailInput } from "@components/form/fields";
|
||||
import { TextField } from "@components/form/fields";
|
||||
import Button from "@components/ui/Button";
|
||||
|
||||
export default function MemberInvitationModal(props: { team: TeamWithMembers | null; onExit: () => void }) {
|
||||
|
@ -76,27 +77,21 @@ export default function MemberInvitationModal(props: { team: TeamWithMembers | n
|
|||
</div>
|
||||
</div>
|
||||
<form onSubmit={inviteMember}>
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<label htmlFor="inviteUser" className="block text-sm font-medium text-gray-700">
|
||||
{t("email_or_username")}
|
||||
</label>
|
||||
<EmailInput
|
||||
type="text"
|
||||
name="inviteUser"
|
||||
<div className="space-y-4">
|
||||
<TextField
|
||||
label={t("email_or_username")}
|
||||
id="inviteUser"
|
||||
name="inviteUser"
|
||||
placeholder="email@example.com"
|
||||
required
|
||||
className="block w-full px-3 py-2 mt-1 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-black focus:border-brand sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block mb-2 text-sm font-medium tracking-wide text-gray-700" htmlFor="role">
|
||||
<div>
|
||||
<label className="block mb-1 text-sm font-medium tracking-wide text-gray-700" htmlFor="role">
|
||||
{t("role")}
|
||||
</label>
|
||||
<select
|
||||
id="role"
|
||||
className="block w-full mt-1 border-gray-300 rounded-md shadow-sm focus:ring-black focus:border-brand sm:text-sm">
|
||||
className="block w-full mt-1 border-gray-300 rounded-sm shadow-sm focus:ring-black focus:border-brand sm:text-sm">
|
||||
<option value="MEMBER">{t("member")}</option>
|
||||
<option value="ADMIN">{t("admin")}</option>
|
||||
</select>
|
||||
|
@ -108,7 +103,7 @@ export default function MemberInvitationModal(props: { team: TeamWithMembers | n
|
|||
name="sendInviteEmail"
|
||||
defaultChecked
|
||||
id="sendInviteEmail"
|
||||
className="text-black border-gray-300 rounded-md shadow-sm focus:ring-black focus:border-brand sm:text-sm"
|
||||
className="text-black border-gray-300 rounded-sm shadow-sm focus:ring-black focus:border-brand sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="ltr:ml-2 rtl:mr-2text-sm">
|
||||
|
@ -117,6 +112,16 @@ export default function MemberInvitationModal(props: { team: TeamWithMembers | n
|
|||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row px-3 py-2 rounded-sm bg-gray-50">
|
||||
<InformationCircleIcon className="flex-shrink-0 w-5 h-5 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">
|
||||
|
|
|
@ -24,7 +24,7 @@ import Dropdown, {
|
|||
DropdownMenuTrigger,
|
||||
} from "../ui/Dropdown";
|
||||
import MemberChangeRoleModal from "./MemberChangeRoleModal";
|
||||
import TeamRole from "./TeamRole";
|
||||
import TeamPill, { TeamRole } from "./TeamPill";
|
||||
import { MembershipRole } from ".prisma/client";
|
||||
|
||||
interface Props {
|
||||
|
@ -80,8 +80,14 @@ export default function MemberListItem(props: Props) {
|
|||
</div>
|
||||
</div>
|
||||
<div className="flex mt-2 ltr:mr-2 rtl:ml-2 sm:mt-0 sm:justify-center">
|
||||
{!props.member.accepted && <TeamRole invitePending />}
|
||||
<TeamRole role={props.member.role} />
|
||||
{/* Tooltip doesn't show... WHY????? */}
|
||||
{props.member.isMissingSeat && (
|
||||
<Tooltip 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">
|
||||
|
@ -96,7 +102,7 @@ export default function MemberListItem(props: Props) {
|
|||
disabled={!props.member.accepted}
|
||||
onClick={() => (props.member.accepted ? setShowTeamAvailabilityModal(true) : null)}
|
||||
color="minimal"
|
||||
className="hidden w-10 h-10 p-0 border border-transparent group text-neutral-400 hover:border-gray-200 hover:bg-white sm:block">
|
||||
className="items-center justify-center hidden w-10 h-10 px-0 py-0 border border-transparent group text-neutral-400 hover:border-gray-200 hover:bg-white sm:flex">
|
||||
<ClockIcon className="w-5 h-5 group-hover:text-gray-800" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
@ -167,7 +173,7 @@ export default function MemberListItem(props: Props) {
|
|||
{showTeamAvailabilityModal && (
|
||||
<ModalContainer wide noPadding>
|
||||
<TeamAvailabilityModal team={props.team} member={props.member} />
|
||||
<div className="p-5 rtl:space-x-reverse space-x-2 border-t">
|
||||
<div className="p-5 space-x-2 border-t 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`}>
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
import { UsersIcon } from "@heroicons/react/outline";
|
||||
import { useRef } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
import { Alert } from "@components/ui/Alert";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
@ -11,7 +13,7 @@ interface Props {
|
|||
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", {
|
||||
|
@ -19,6 +21,9 @@ export default function TeamCreate(props: Props) {
|
|||
utils.invalidateQueries(["viewer.teams.list"]);
|
||||
props.onClose();
|
||||
},
|
||||
onError: (e) => {
|
||||
setErrorMessage(e?.message || t("something_went_wrong"));
|
||||
},
|
||||
});
|
||||
|
||||
const createTeam = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
|
@ -70,6 +75,7 @@ export default function TeamCreate(props: Props) {
|
|||
className="block w-full px-3 py-2 mt-1 border border-gray-300 rounded-sm shadow-sm focus:outline-none focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
{errorMessage && <Alert severity="error" title={errorMessage} />}
|
||||
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||
<button type="submit" className="btn btn-primary">
|
||||
{t("create_team")}
|
||||
|
|
|
@ -20,7 +20,7 @@ import Dropdown, {
|
|||
DropdownMenuSeparator,
|
||||
} from "@components/ui/Dropdown";
|
||||
|
||||
import TeamRole from "./TeamRole";
|
||||
import { TeamRole } from "./TeamPill";
|
||||
import { MembershipRole } from ".prisma/client";
|
||||
|
||||
interface Props {
|
||||
|
@ -99,8 +99,8 @@ export default function TeamListItem(props: Props) {
|
|||
</>
|
||||
)}
|
||||
{!isInvitee && (
|
||||
<div className="flex rtl:space-x-reverse space-x-2">
|
||||
<TeamRole role={team.role as MembershipRole} />
|
||||
<div className="flex space-x-2 rtl:space-x-reverse">
|
||||
<TeamRole role={team.role} />
|
||||
|
||||
<Tooltip content={t("copy_link_team")}>
|
||||
<Button
|
||||
|
|
36
components/team/TeamPill.tsx
Normal file
36
components/team/TeamPill.tsx
Normal file
|
@ -0,0 +1,36 @@
|
|||
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;
|
||||
}
|
||||
|
||||
export default function TeamPill(props: Props) {
|
||||
return (
|
||||
<div
|
||||
className={classNames("self-center px-3 py-1 ltr:mr-2 rtl:ml-2 text-xs capitalize border rounded-md", {
|
||||
"bg-gray-50 border-gray-200 text-gray-700": !props.color,
|
||||
"bg-blue-50 border-blue-200 text-blue-700": props.color === "blue",
|
||||
"bg-red-50 border-red-200 text-red-700": props.color === "red",
|
||||
"bg-yellow-50 border-yellow-200 text-yellow-700": props.color === "yellow",
|
||||
"bg-green-50 border-green-200 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]} />;
|
||||
}
|
|
@ -1,40 +0,0 @@
|
|||
import { MembershipRole } from "@prisma/client";
|
||||
import classNames from "classnames";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
|
||||
interface Props {
|
||||
role?: MembershipRole;
|
||||
invitePending?: boolean;
|
||||
}
|
||||
|
||||
export default function TeamRole(props: Props) {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<span
|
||||
className={classNames(
|
||||
"self-center px-3 py-1 ltr:mr-2 rtl:ml-2 text-xs capitalize border rounded-md",
|
||||
{
|
||||
"bg-blue-50 border-blue-200 text-blue-700": props.role === "MEMBER",
|
||||
"bg-gray-50 border-gray-200 text-gray-700": props.role === "OWNER",
|
||||
"bg-red-50 border-red-200 text-red-700": props.role === "ADMIN",
|
||||
"bg-yellow-50 border-yellow-200 text-yellow-700": props.invitePending,
|
||||
}
|
||||
)}>
|
||||
{(() => {
|
||||
if (props.invitePending) return t("invitee");
|
||||
switch (props.role) {
|
||||
case "OWNER":
|
||||
return t("owner");
|
||||
case "ADMIN":
|
||||
return t("admin");
|
||||
case "MEMBER":
|
||||
return t("member");
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
})()}
|
||||
</span>
|
||||
);
|
||||
}
|
|
@ -15,8 +15,6 @@ import LinkIconButton from "@components/ui/LinkIconButton";
|
|||
|
||||
import { MembershipRole } from ".prisma/client";
|
||||
|
||||
// import Switch from "@components/ui/Switch";
|
||||
|
||||
export default function TeamSettingsRightSidebar(props: { team: TeamWithMembers; role: MembershipRole }) {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
|
@ -27,6 +25,7 @@ export default function TeamSettingsRightSidebar(props: { team: TeamWithMembers;
|
|||
const deleteTeamMutation = trpc.useMutation("viewer.teams.delete", {
|
||||
async onSuccess() {
|
||||
await utils.invalidateQueries(["viewer.teams.get"]);
|
||||
router.push(`/settings/teams`);
|
||||
showToast(t("your_team_updated_successfully"), "success");
|
||||
},
|
||||
});
|
||||
|
@ -50,19 +49,20 @@ export default function TeamSettingsRightSidebar(props: { team: TeamWithMembers;
|
|||
|
||||
return (
|
||||
<div className="px-2 space-y-6">
|
||||
{(props.role === MembershipRole.OWNER || props.role === MembershipRole.ADMIN) && (
|
||||
<CreateEventTypeButton
|
||||
isIndividualTeam
|
||||
canAddEvents={true}
|
||||
options={[
|
||||
{ teamId: props.team?.id, name: props.team?.name, slug: props.team?.slug, image: props.team?.logo },
|
||||
{
|
||||
teamId: props.team?.id,
|
||||
name: props.team?.name,
|
||||
slug: props.team?.slug,
|
||||
image: props.team?.logo,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{/* <Switch
|
||||
name="isHidden"
|
||||
defaultChecked={hidden}
|
||||
onCheckedChange={setHidden}
|
||||
label={"Hide team from view"}
|
||||
/> */}
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<Link href={permalink} passHref={true}>
|
||||
<a target="_blank">
|
||||
|
|
90
components/team/UpgradeToFlexibleProModal.tsx
Normal file
90
components/team/UpgradeToFlexibleProModal.tsx
Normal file
|
@ -0,0 +1,90 @@
|
|||
import { useState } from "react";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import showToast from "@lib/notification";
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogClose,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
} from "@components/Dialog";
|
||||
import { Alert } from "@components/ui/Alert";
|
||||
import Button from "@components/ui/Button";
|
||||
|
||||
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);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
onOpenChange={() => {
|
||||
setErrorMessage(null);
|
||||
}}>
|
||||
<DialogTrigger asChild>
|
||||
<a className="underline cursor-pointer">{"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>
|
||||
);
|
||||
}
|
|
@ -15,10 +15,10 @@ export function Alert(props: AlertProps) {
|
|||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
"rounded-sm p-2",
|
||||
"rounded-sm p-3 border border-opacity-20",
|
||||
props.className,
|
||||
severity === "error" && "bg-red-50 text-red-800",
|
||||
severity === "warning" && "bg-yellow-50 text-yellow-700",
|
||||
severity === "error" && "bg-red-50 text-red-800 border-red-900",
|
||||
severity === "warning" && "bg-yellow-50 text-yellow-700 border-yellow-700",
|
||||
severity === "success" && "bg-gray-900 text-white"
|
||||
)}>
|
||||
<div className="flex">
|
||||
|
|
|
@ -10,8 +10,8 @@ export default function LinkIconButton(props: LinkIconButtonProps) {
|
|||
return (
|
||||
<div className="-ml-2">
|
||||
<button
|
||||
{...props}
|
||||
type="button"
|
||||
{...props}
|
||||
className="flex items-center px-2 py-1 text-sm font-medium text-gray-700 rounded-sm text-md hover:text-gray-900 hover:bg-gray-200">
|
||||
<props.Icon className="w-4 h-4 ltr:mr-2 rtl:ml-2 text-neutral-500" />
|
||||
{props.children}
|
||||
|
|
|
@ -7,6 +7,7 @@ import { trpc } from "@lib/trpc";
|
|||
|
||||
import { Dialog, DialogTrigger } from "@components/Dialog";
|
||||
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
||||
import { TextArea } from "@components/form/fields";
|
||||
import { Alert } from "@components/ui/Alert";
|
||||
import Badge from "@components/ui/Badge";
|
||||
import Button from "@components/ui/Button";
|
||||
|
@ -89,10 +90,9 @@ export default function SAMLConfiguration({
|
|||
const { t } = useLocale();
|
||||
return (
|
||||
<>
|
||||
<hr className="mt-8" />
|
||||
|
||||
{isSAMLLoginEnabled ? (
|
||||
<>
|
||||
<hr className="mt-8" />
|
||||
<div className="mt-6">
|
||||
<h2 className="text-lg font-medium leading-6 text-gray-900 font-cal">
|
||||
{t("saml_configuration")}
|
||||
|
@ -141,14 +141,13 @@ export default function SAMLConfiguration({
|
|||
<form className="mt-3 divide-y divide-gray-200 lg:col-span-9" onSubmit={updateSAMLConfigHandler}>
|
||||
{hasErrors && <Alert severity="error" title={errorMessage} />}
|
||||
|
||||
<textarea
|
||||
<TextArea
|
||||
data-testid="saml_config"
|
||||
ref={samlConfigRef}
|
||||
name="saml_config"
|
||||
id="saml_config"
|
||||
required={true}
|
||||
rows={10}
|
||||
className="block w-full border-gray-300 rounded-md shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-black sm:text-sm"
|
||||
placeholder={t("saml_configuration_placeholder")}
|
||||
/>
|
||||
|
||||
|
|
67
ee/lib/stripe/customer.ts
Normal file
67
ee/lib/stripe/customer.ts
Normal file
|
@ -0,0 +1,67 @@
|
|||
import { Prisma } from "@prisma/client";
|
||||
|
||||
import stripe from "@ee/lib/stripe/server";
|
||||
|
||||
import { HttpError as HttpCode } from "@lib/core/http/error";
|
||||
import { prisma } from "@lib/prisma";
|
||||
|
||||
export async function getStripeCustomerFromUser(userId: number) {
|
||||
// Get user
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
select: {
|
||||
email: true,
|
||||
name: true,
|
||||
metadata: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user?.email) throw new HttpCode({ statusCode: 404, message: "User email not found" });
|
||||
|
||||
const customerId = await getStripeCustomerId(user);
|
||||
|
||||
return customerId;
|
||||
}
|
||||
|
||||
const userType = Prisma.validator<Prisma.UserArgs>()({
|
||||
select: {
|
||||
email: true,
|
||||
metadata: true,
|
||||
},
|
||||
});
|
||||
|
||||
type UserType = Prisma.UserGetPayload<typeof userType>;
|
||||
export async function getStripeCustomerId(user: UserType): Promise<string | null> {
|
||||
let customerId: string | null = null;
|
||||
|
||||
if (user?.metadata && typeof user.metadata === "object" && "stripeCustomerId" in user.metadata) {
|
||||
customerId = (user?.metadata as Prisma.JsonObject).stripeCustomerId as string;
|
||||
} else {
|
||||
/* We fallback to finding the customer by email (which is not optimal) */
|
||||
const customersResponse = await stripe.customers.list({
|
||||
email: user.email,
|
||||
limit: 1,
|
||||
});
|
||||
if (customersResponse.data[0]?.id) {
|
||||
customerId = customersResponse.data[0].id;
|
||||
}
|
||||
}
|
||||
|
||||
return customerId;
|
||||
}
|
||||
|
||||
export async function deleteStripeCustomer(user: UserType): Promise<string | null> {
|
||||
const customerId = await getStripeCustomerId(user);
|
||||
|
||||
if (!customerId) {
|
||||
console.warn("No stripe customer found for user:" + user.email);
|
||||
return null;
|
||||
}
|
||||
|
||||
//delete stripe customer
|
||||
const deletedCustomer = await stripe.customers.del(customerId);
|
||||
|
||||
return deletedCustomer.id;
|
||||
}
|
|
@ -168,45 +168,4 @@ async function handleRefundError(opts: { event: CalendarEvent; reason: string; p
|
|||
});
|
||||
}
|
||||
|
||||
const userType = Prisma.validator<Prisma.UserArgs>()({
|
||||
select: {
|
||||
email: true,
|
||||
metadata: true,
|
||||
},
|
||||
});
|
||||
|
||||
type UserType = Prisma.UserGetPayload<typeof userType>;
|
||||
export async function getStripeCustomerId(user: UserType): Promise<string | null> {
|
||||
let customerId: string | null = null;
|
||||
|
||||
if (user?.metadata && typeof user.metadata === "object" && "stripeCustomerId" in user.metadata) {
|
||||
customerId = (user?.metadata as Prisma.JsonObject).stripeCustomerId as string;
|
||||
} else {
|
||||
/* We fallback to finding the customer by email (which is not optimal) */
|
||||
const customersReponse = await stripe.customers.list({
|
||||
email: user.email,
|
||||
limit: 1,
|
||||
});
|
||||
if (customersReponse.data[0]?.id) {
|
||||
customerId = customersReponse.data[0].id;
|
||||
}
|
||||
}
|
||||
|
||||
return customerId;
|
||||
}
|
||||
|
||||
export async function deleteStripeCustomer(user: UserType): Promise<string | null> {
|
||||
const customerId = await getStripeCustomerId(user);
|
||||
|
||||
if (!customerId) {
|
||||
console.warn("No stripe customer found for user:" + user.email);
|
||||
return null;
|
||||
}
|
||||
|
||||
//delete stripe customer
|
||||
const deletedCustomer = await stripe.customers.del(customerId);
|
||||
|
||||
return deletedCustomer.id;
|
||||
}
|
||||
|
||||
export default stripe;
|
||||
|
|
275
ee/lib/stripe/team-billing.ts
Normal file
275
ee/lib/stripe/team-billing.ts
Normal file
|
@ -0,0 +1,275 @@
|
|||
import { MembershipRole, Prisma, UserPlan } from "@prisma/client";
|
||||
import Stripe from "stripe";
|
||||
|
||||
import { getStripeCustomerFromUser } from "@ee/lib/stripe/customer";
|
||||
|
||||
import { HOSTED_CAL_FEATURES } from "@lib/config/constants";
|
||||
import { HttpError } from "@lib/core/http/error";
|
||||
import prisma from "@lib/prisma";
|
||||
|
||||
import stripe from "./server";
|
||||
|
||||
// get team owner's Pro Plan subscription from Cal userId
|
||||
export async function getProPlanSubscription(userId: number) {
|
||||
const stripeCustomerId = await getStripeCustomerFromUser(userId);
|
||||
if (!stripeCustomerId) return null;
|
||||
|
||||
const customer = await stripe.customers.retrieve(stripeCustomerId, {
|
||||
expand: ["subscriptions.data.plan"],
|
||||
});
|
||||
if (customer.deleted) throw new HttpError({ statusCode: 404, message: "Stripe customer not found" });
|
||||
// get the first subscription item which is the Pro Plan TODO: change to find()
|
||||
return customer.subscriptions?.data[0];
|
||||
}
|
||||
|
||||
async function getMembersMissingSeats(teamId: number) {
|
||||
const members = await prisma.membership.findMany({
|
||||
where: { teamId },
|
||||
select: { role: true, accepted: true, user: { select: { id: true, plan: true, metadata: true } } },
|
||||
});
|
||||
// any member that is not Pro is missing a seat excluding the owner
|
||||
const membersMissingSeats = members.filter(
|
||||
(m) => m.role !== MembershipRole.OWNER || m.user.plan !== UserPlan.PRO
|
||||
);
|
||||
// as owner's billing is handled by a different Price, we count this separately
|
||||
const ownerIsMissingSeat = !!members.find(
|
||||
(m) => m.role === MembershipRole.OWNER && m.user.plan === UserPlan.FREE
|
||||
);
|
||||
return {
|
||||
members,
|
||||
membersMissingSeats,
|
||||
ownerIsMissingSeat,
|
||||
};
|
||||
}
|
||||
|
||||
// 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
|
||||
return await stripe.subscriptions.update(subscription.id, {
|
||||
items: [
|
||||
perSeatProPlan ? { id: perSeatProPlan.id, quantity } : { plan: getPerSeatProPlanPrice(), quantity },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// called by the team owner when they are ready to upgrade their team to Per Seat Pro
|
||||
// if user has no subscription, this will be called again after successful stripe checkout callback, with subscription now present
|
||||
export async function upgradeTeam(userId: number, teamId: number) {
|
||||
const ownerUser = await prisma.membership.findFirst({
|
||||
where: { userId, teamId },
|
||||
select: { role: true, user: true },
|
||||
});
|
||||
|
||||
if (ownerUser?.role !== MembershipRole.OWNER)
|
||||
throw new HttpError({ statusCode: 400, message: "User is not an owner" });
|
||||
|
||||
const subscription = await getProPlanSubscription(userId);
|
||||
const { membersMissingSeats, ownerIsMissingSeat } = await getMembersMissingSeats(teamId);
|
||||
|
||||
if (!subscription) {
|
||||
const customer = await getStripeCustomerFromUser(userId);
|
||||
if (!customer) throw new HttpError({ statusCode: 400, message: "User has no Stripe customer" });
|
||||
// create a checkout session with the quantity of missing seats
|
||||
const session = await createCheckoutSession(
|
||||
customer,
|
||||
membersMissingSeats.length,
|
||||
teamId,
|
||||
ownerIsMissingSeat
|
||||
);
|
||||
// return checkout session url for redirect
|
||||
return { url: session.url };
|
||||
}
|
||||
|
||||
// if the owner has a subscription but does not have an individual Pro account
|
||||
if (ownerIsMissingSeat) {
|
||||
const ownerHasProPlan = !!subscription.items.data.find(
|
||||
(item) => item.plan.id === getProPlanPrice() || item.plan.id === getPremiumPlanPrice()
|
||||
);
|
||||
if (!ownerHasProPlan)
|
||||
await stripe.subscriptions.update(subscription.id, {
|
||||
items: [
|
||||
{
|
||||
price: getProPlanPrice(),
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { plan: UserPlan.PRO },
|
||||
});
|
||||
}
|
||||
|
||||
// update the subscription with Stripe
|
||||
await updatePerSeatQuantity(subscription, membersMissingSeats.length);
|
||||
|
||||
// loop through all members and update their account to Pro
|
||||
for (const member of membersMissingSeats) {
|
||||
await prisma.user.update({
|
||||
where: { id: member.user.id },
|
||||
data: {
|
||||
plan: UserPlan.PRO,
|
||||
// declare which team is sponsoring their Pro membership
|
||||
metadata: { proPaidForByTeamId: teamId, ...((member.user.metadata as Prisma.JsonObject) ?? {}) },
|
||||
},
|
||||
});
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// shared logic for add/removing members, called on member invite and member removal/leave
|
||||
async function addOrRemoveSeat(remove: boolean, userId: number, teamId: number, memberUserId?: number) {
|
||||
console.log(remove ? "removing member" : "adding member", { userId, teamId, memberUserId });
|
||||
|
||||
const subscription = await getProPlanSubscription(userId);
|
||||
if (!subscription) return;
|
||||
|
||||
// get the per seat plan from the subscription
|
||||
const perSeatProPlanPrice = subscription?.items.data.find(
|
||||
(item) => item.plan.id === getPerSeatProPlanPrice()
|
||||
);
|
||||
|
||||
// find the member's local user account
|
||||
const memberUser = await prisma.user.findUnique({
|
||||
where: { id: memberUserId },
|
||||
select: { id: true, plan: true, metadata: true },
|
||||
});
|
||||
// in the rare event there is no account, return
|
||||
if (!memberUser) return;
|
||||
|
||||
// check if this user is paying for their own Pro account, if so return.
|
||||
const memberSubscription = await getProPlanSubscription(memberUser.id);
|
||||
const proPlanPrice = memberSubscription?.items.data.find((item) => item.plan.id === getProPlanPrice());
|
||||
if (proPlanPrice) return;
|
||||
|
||||
// takes care of either adding per seat pricing, or updating the existing one's quantity
|
||||
await updatePerSeatQuantity(
|
||||
subscription,
|
||||
remove ? (perSeatProPlanPrice?.quantity ?? 1) - 1 : (perSeatProPlanPrice?.quantity ?? 0) + 1
|
||||
);
|
||||
|
||||
// add or remove proPaidForByTeamId from metadata
|
||||
const metadata: Record<string, unknown> = {
|
||||
proPaidForByTeamId: teamId,
|
||||
...((memberUser.metadata as Prisma.JsonObject) ?? {}),
|
||||
};
|
||||
// entirely remove property if removing member from team and proPaidForByTeamId is this team
|
||||
if (remove && metadata.proPaidForByTeamId === teamId) delete metadata.proPaidForByTeamId;
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: memberUserId },
|
||||
data: { plan: remove ? UserPlan.FREE : UserPlan.PRO, metadata: metadata as Prisma.JsonObject },
|
||||
});
|
||||
}
|
||||
|
||||
// aliased helpers for more verbose usage
|
||||
export async function addSeat(userId: number, teamId: number, memberUserId?: number) {
|
||||
return await addOrRemoveSeat(false, userId, teamId, memberUserId);
|
||||
}
|
||||
|
||||
export async function removeSeat(userId: number, teamId: number, memberUserId?: number) {
|
||||
return await addOrRemoveSeat(true, userId, teamId, memberUserId);
|
||||
}
|
||||
|
||||
// if a team has failed to pay for the pro plan, downgrade all team members to free
|
||||
export async function downgradeTeamMembers(teamId: number) {
|
||||
const members = await prisma.membership.findMany({
|
||||
where: { teamId, user: { plan: UserPlan.PRO } },
|
||||
select: { role: true, accepted: true, user: { select: { id: true, plan: true, metadata: true } } },
|
||||
});
|
||||
|
||||
for (const member of members) {
|
||||
// skip if user had their own Pro subscription
|
||||
const subscription = await getProPlanSubscription(member.user.id);
|
||||
if (subscription?.items.data.length) continue;
|
||||
|
||||
// skip if Pro is paid for by another team
|
||||
const metadata = (member.user.metadata as Prisma.JsonObject) ?? {};
|
||||
if (metadata.proPaidForByTeamId !== teamId) continue;
|
||||
|
||||
// downgrade only if their pro plan was paid for by this team
|
||||
delete metadata.proPaidForByTeamId;
|
||||
await prisma.user.update({
|
||||
where: { id: member.user.id },
|
||||
data: { plan: UserPlan.FREE, metadata },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function createCheckoutSession(
|
||||
customerId: string,
|
||||
quantity: number,
|
||||
teamId: number,
|
||||
includeBaseProPlan?: boolean
|
||||
) {
|
||||
// if the user is missing the base plan, we should include it agnostic of the seat quantity
|
||||
const line_items: Stripe.Checkout.SessionCreateParams["line_items"] =
|
||||
quantity === 0
|
||||
? []
|
||||
: [
|
||||
{
|
||||
price: getPerSeatProPlanPrice(),
|
||||
quantity: quantity ?? 1,
|
||||
},
|
||||
];
|
||||
if (includeBaseProPlan) line_items.push({ price: getProPlanPrice(), quantity: 1 });
|
||||
|
||||
const params: Stripe.Checkout.SessionCreateParams = {
|
||||
mode: "subscription",
|
||||
payment_method_types: ["card"],
|
||||
customer: customerId,
|
||||
line_items,
|
||||
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/api/teams/${teamId}/upgrade?session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/`,
|
||||
allow_promotion_codes: true,
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: these should be moved to env vars
|
||||
export function getPerSeatProPlanPrice(): string {
|
||||
return process.env.NODE_ENV === "production"
|
||||
? "price_1KHkoeH8UDiwIftkkUbiggsM"
|
||||
: "price_1KLD4GH8UDiwIftkWQfsh1Vh";
|
||||
}
|
||||
export function getProPlanPrice(): string {
|
||||
return process.env.NODE_ENV === "production"
|
||||
? "price_1KHkoeH8UDiwIftkkUbiggsM"
|
||||
: "price_1JZ0J3H8UDiwIftk0YIHYKr8";
|
||||
}
|
||||
export function getPremiumPlanPrice(): string {
|
||||
return process.env.NODE_ENV === "production"
|
||||
? "price_1Jv3CMH8UDiwIftkFgyXbcHN"
|
||||
: "price_1Jv3CMH8UDiwIftkFgyXbcHN";
|
||||
}
|
|
@ -39,7 +39,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
};
|
||||
const query = stringify(stripeConnectParams);
|
||||
/**
|
||||
* Choose Express or Stantard Stripe accounts
|
||||
* Choose Express or Standard Stripe accounts
|
||||
* @url https://stripe.com/docs/connect/accounts
|
||||
*/
|
||||
// const url = `https://connect.stripe.com/express/oauth/authorize?${query}`;
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import stripe, { getStripeCustomerId } from "@ee/lib/stripe/server";
|
||||
import { getStripeCustomerFromUser } from "@ee/lib/stripe/customer";
|
||||
import stripe from "@ee/lib/stripe/server";
|
||||
|
||||
import { getSession } from "@lib/auth";
|
||||
import prisma from "@lib/prisma";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method === "POST") {
|
||||
|
@ -15,29 +15,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
return;
|
||||
}
|
||||
|
||||
// Get user
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: session.user?.id,
|
||||
},
|
||||
select: {
|
||||
email: true,
|
||||
name: true,
|
||||
metadata: true,
|
||||
},
|
||||
});
|
||||
const customerId = await getStripeCustomerFromUser(session.user.id);
|
||||
|
||||
if (!user?.email)
|
||||
return res.status(404).json({
|
||||
message: "User email not found",
|
||||
});
|
||||
|
||||
const customerId = await getStripeCustomerId(user);
|
||||
|
||||
if (!customerId)
|
||||
return res.status(404).json({
|
||||
message: "Stripe customer id not found",
|
||||
});
|
||||
if (!customerId) {
|
||||
res.status(500).json({ message: "Missing customer id" });
|
||||
return;
|
||||
}
|
||||
|
||||
const return_url = `${process.env.BASE_URL}/settings/billing`;
|
||||
const stripeSession = await stripe.billingPortal.sessions.create({
|
||||
|
|
21
ee/pages/api/teams/[team]/upgrade.ts
Normal file
21
ee/pages/api/teams/[team]/upgrade.ts
Normal file
|
@ -0,0 +1,21 @@
|
|||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { upgradeTeam } from "@ee/lib/stripe/team-billing";
|
||||
|
||||
import { getSession } from "@lib/auth";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method === "GET") {
|
||||
const session = await getSession({ req });
|
||||
|
||||
if (!session) {
|
||||
res.status(401).json({ message: "You must be logged in to do this" });
|
||||
return;
|
||||
}
|
||||
|
||||
await upgradeTeam(session.user.id, Number(req.query.team));
|
||||
|
||||
// redirect to team screen
|
||||
res.redirect(302, `${process.env.NEXT_PUBLIC_APP_URL}/settings/teams/${req.query.team}?upgraded=true`);
|
||||
}
|
||||
}
|
|
@ -2,3 +2,4 @@ export const BASE_URL = process.env.BASE_URL || `https://${process.env.VERCEL_UR
|
|||
export const WEBSITE_URL = process.env.NEXT_PUBLIC_APP_URL || "https://cal.com";
|
||||
export const IS_PRODUCTION = process.env.NODE_ENV === "production";
|
||||
export const TRIAL_LIMIT_DAYS = 14;
|
||||
export const HOSTED_CAL_FEATURES = process.env.HOSTED_CAL_FEATURES || BASE_URL === "https://app.cal.com";
|
||||
|
|
|
@ -10,7 +10,7 @@ declare global {
|
|||
export const prisma =
|
||||
globalThis.prisma ||
|
||||
new PrismaClient({
|
||||
log: ["query", "error", "warn"],
|
||||
// log: ["query", "error", "warn"],
|
||||
});
|
||||
|
||||
if (!IS_PRODUCTION) {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { Prisma } from "@prisma/client";
|
||||
import { Prisma, UserPlan } from "@prisma/client";
|
||||
|
||||
import prisma from "@lib/prisma";
|
||||
|
||||
|
@ -15,6 +15,7 @@ export async function getTeamWithMembers(id?: number, slug?: string) {
|
|||
email: true,
|
||||
name: true,
|
||||
id: true,
|
||||
plan: true,
|
||||
bio: true,
|
||||
});
|
||||
|
||||
|
@ -69,6 +70,7 @@ export async function getTeamWithMembers(id?: number, slug?: string) {
|
|||
const membership = memberships.find((membership) => obj.user.id === membership.userId);
|
||||
return {
|
||||
...obj.user,
|
||||
isMissingSeat: obj.user.plan === UserPlan.FREE,
|
||||
role: membership?.role,
|
||||
accepted: membership?.role === "OWNER" ? true : membership?.accepted,
|
||||
};
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { DefaultSeo } from "next-seo";
|
||||
// import { ReactQueryDevtools } from "react-query/devtools";
|
||||
import superjson from "superjson";
|
||||
|
||||
import AppProviders, { AppProps } from "@lib/app-providers";
|
||||
|
|
1
pages/api/teams/[team]/upgrade.ts
Normal file
1
pages/api/teams/[team]/upgrade.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { default } from "@ee/pages/api/teams/[team]/upgrade";
|
|
@ -1,6 +1,6 @@
|
|||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { deleteStripeCustomer } from "@ee/lib/stripe/server";
|
||||
import { deleteStripeCustomer } from "@ee/lib/stripe/customer";
|
||||
|
||||
import { getSession } from "@lib/auth";
|
||||
import prisma from "@lib/prisma";
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { PlusIcon } from "@heroicons/react/solid";
|
||||
import { PlusIcon, UserGroupIcon } from "@heroicons/react/solid";
|
||||
import classNames from "classnames";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { Trans } from "next-i18next";
|
||||
|
@ -7,6 +7,7 @@ import { useState } from "react";
|
|||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
import EmptyScreen from "@components/EmptyScreen";
|
||||
import Loader from "@components/Loader";
|
||||
import SettingsShell from "@components/SettingsShell";
|
||||
import Shell, { useMeQuery } from "@components/Shell";
|
||||
|
@ -24,7 +25,7 @@ export default function Teams() {
|
|||
|
||||
const me = useMeQuery();
|
||||
|
||||
const { data } = trpc.useQuery(["viewer.teams.list"], {
|
||||
const { data, isLoading } = trpc.useQuery(["viewer.teams.list"], {
|
||||
onError: (e) => {
|
||||
setErrorMessage(e.message);
|
||||
},
|
||||
|
@ -67,12 +68,20 @@ export default function Teams() {
|
|||
{t("new_team")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{invites.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<h1 className="mb-2 text-lg font-medium">{t("open_invitations")}</h1>
|
||||
<TeamList teams={invites}></TeamList>
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && !teams.length && (
|
||||
<EmptyScreen
|
||||
Icon={UserGroupIcon}
|
||||
headline={t("no_teams")}
|
||||
description={t("no_teams_description")}
|
||||
/>
|
||||
)}
|
||||
{teams.length > 0 && <TeamList teams={teams}></TeamList>}
|
||||
</SettingsShell>
|
||||
</Shell>
|
||||
|
|
|
@ -1,11 +1,13 @@
|
|||
import { PlusIcon } from "@heroicons/react/solid";
|
||||
import { MembershipRole } from "@prisma/client";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import SAMLConfiguration from "@ee/components/saml/Configuration";
|
||||
|
||||
import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import showToast from "@lib/notification";
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
import Loader from "@components/Loader";
|
||||
|
@ -14,6 +16,7 @@ import MemberInvitationModal from "@components/team/MemberInvitationModal";
|
|||
import MemberList from "@components/team/MemberList";
|
||||
import TeamSettings from "@components/team/TeamSettings";
|
||||
import TeamSettingsRightSidebar from "@components/team/TeamSettingsRightSidebar";
|
||||
import { UpgradeToFlexibleProModal } from "@components/team/UpgradeToFlexibleProModal";
|
||||
import { Alert } from "@components/ui/Alert";
|
||||
import Avatar from "@components/ui/Avatar";
|
||||
import { Button } from "@components/ui/Button";
|
||||
|
@ -22,6 +25,15 @@ export function TeamSettingsPage() {
|
|||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
|
||||
const upgraded = router.query.upgraded as string;
|
||||
|
||||
useEffect(() => {
|
||||
if (upgraded) {
|
||||
showToast(t("team_upgraded_successfully"), "success");
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const [showMemberInvitationModal, setShowMemberInvitationModal] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
|
||||
|
@ -31,13 +43,14 @@ export function TeamSettingsPage() {
|
|||
},
|
||||
});
|
||||
|
||||
const isAdmin = team && (team.membership.role === "OWNER" || team.membership.role === "ADMIN");
|
||||
const isAdmin =
|
||||
team && (team.membership.role === MembershipRole.OWNER || team.membership.role === MembershipRole.ADMIN);
|
||||
|
||||
return (
|
||||
<Shell
|
||||
backPath={!errorMessage ? `/settings/teams` : undefined}
|
||||
heading={team?.name}
|
||||
subtitle={team && "Manage this team"}
|
||||
subtitle={team && t("manage_this_team")}
|
||||
HeadingLeftIcon={
|
||||
team && (
|
||||
<Avatar
|
||||
|
@ -54,12 +67,54 @@ export function TeamSettingsPage() {
|
|||
<>
|
||||
<div className="block sm:flex md:max-w-5xl">
|
||||
<div className="w-full ltr:mr-2 rtl:ml-2 sm:w-9/12">
|
||||
{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} />
|
||||
{/* <a href={"https://cal.com/upgrade"} className="underline">
|
||||
{"https://cal.com/upgrade"}
|
||||
</a> */}
|
||||
</>
|
||||
}
|
||||
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"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="px-4 -mx-0 bg-white border rounded-sm border-neutral-200 sm:px-6">
|
||||
{isAdmin ? (
|
||||
<TeamSettings team={team} />
|
||||
) : (
|
||||
<div className="py-5">
|
||||
<span className="mb-1 font-bold">Team Info</span>
|
||||
<span className="mb-1 font-bold">{t("team_info")}</span>
|
||||
<p className="text-sm text-gray-700">{team.bio}</p>
|
||||
</div>
|
||||
)}
|
||||
|
@ -80,7 +135,7 @@ export function TeamSettingsPage() {
|
|||
)}
|
||||
</div>
|
||||
<MemberList team={team} members={team.members || []} />
|
||||
{isAdmin ? <SAMLConfiguration teamsView={true} teamId={team.id} /> : null}
|
||||
{isAdmin && <SAMLConfiguration teamsView={true} teamId={team.id} />}
|
||||
</div>
|
||||
<div className="w-full px-2 mt-8 ltr:ml-2 rtl:mr-2 md:w-3/12 sm:mt-0 min-w-32">
|
||||
<TeamSettingsRightSidebar role={team.membership.role} team={team} />
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { ArrowRightIcon } from "@heroicons/react/solid";
|
||||
import { UserPlan } from "@prisma/client";
|
||||
import { GetServerSidePropsContext } from "next";
|
||||
import Link from "next/link";
|
||||
import React from "react";
|
||||
|
@ -115,6 +116,10 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||
|
||||
if (!team) return { notFound: true };
|
||||
|
||||
const members = team.members.filter((member) => member.plan !== UserPlan.FREE);
|
||||
|
||||
team.members = members ?? [];
|
||||
|
||||
team.eventTypes = team.eventTypes.map((type) => ({
|
||||
...type,
|
||||
users: type.users.map((user) => ({
|
||||
|
|
|
@ -80,10 +80,18 @@
|
|||
"rejected_event_type_with_organizer": "Rejected: {{eventType}} with {{organizer}} on {{date}}",
|
||||
"hi": "Hi",
|
||||
"join_team": "Join team",
|
||||
"manage_this_team": "Manage this team",
|
||||
"team_info": "Team Info",
|
||||
"request_another_invitation_email": "If you prefer not to use {{toEmail}} as your Cal.com email or already have a Cal.com account, please request another invitation to that email.",
|
||||
"you_have_been_invited": "You have been invited to join the team {{teamName}}",
|
||||
"user_invited_you": "{{user}} invited you to join the team {{team}} on Cal.com",
|
||||
"hidden_team_member_title": "You are hidden in this team",
|
||||
"hidden_team_member_message": "Your seat is not paid for, either upgrade to Pro or let the team owner know they can pay for your seat.",
|
||||
"hidden_team_owner_message": "You need a pro account to use teams, you are hidden until you upgrade.",
|
||||
"link_expires": "p.s. It expires in {{expiresIn}} hours.",
|
||||
"upgrade_to_per_seat": "Upgrade to Per-Seat",
|
||||
"team_upgrade_seats_details": "Of the {{memberCount}} members in your team, {{unpaidCount}} seat(s) are unpaid. At ${{seatPrice}}/m per seat the estimated total cost of your membership is ${{totalCost}}/m.",
|
||||
"team_upgraded_successfully": "Your team was upgraded successfully!",
|
||||
"use_link_to_reset_password": "Use the link below to reset your password",
|
||||
"hey_there": "Hey there,",
|
||||
"forgot_your_password_calcom": "Forgot your password? - Cal.com",
|
||||
|
@ -431,6 +439,8 @@
|
|||
"confirm_remove_member": "Yes, remove member",
|
||||
"remove_member": "Remove member",
|
||||
"manage_your_team": "Manage your team",
|
||||
"no_teams": "You don't have any teams yet.",
|
||||
"no_teams_description": "Teams allow others to book events shared between your coworkers.",
|
||||
"submit": "Submit",
|
||||
"delete": "Delete",
|
||||
"update": "Update",
|
||||
|
@ -496,6 +506,10 @@
|
|||
"create_first_team_and_invite_others": "Create your first team and invite other users to work together with you.",
|
||||
"create_team_to_get_started": "Create a team to get started",
|
||||
"teams": "Teams",
|
||||
"team_billing": "Team Billing",
|
||||
"upgrade_to_flexible_pro_title": "We've changed billing for teams",
|
||||
"upgrade_to_flexible_pro_message": "There are members in your team without a seat. Upgrade your pro plan to cover missing seats.",
|
||||
"changed_team_billing_info": "As of January 2020 we charge on a per-seat basis for team members. Members of your team who had Pro for free are now on a 14 day trial. Once their trial expires these members will be hidden from your team unless you upgrade now.",
|
||||
"create_manage_teams_collaborative": "Create and manage teams to use collaborative features.",
|
||||
"only_available_on_pro_plan": "This feature is only available in Pro plan",
|
||||
"remove_cal_branding_description": "In order to remove the Cal branding from your booking pages, you need to upgrade to a Pro account.",
|
||||
|
|
|
@ -1,9 +1,19 @@
|
|||
import { MembershipRole } from "@prisma/client";
|
||||
import { MembershipRole, UserPlan } from "@prisma/client";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { randomBytes } from "crypto";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
addSeat,
|
||||
removeSeat,
|
||||
getTeamSeatStats,
|
||||
downgradeTeamMembers,
|
||||
upgradeTeam,
|
||||
ensureSubscriptionQuantityCorrectness,
|
||||
} from "@ee/lib/stripe/team-billing";
|
||||
|
||||
import { BASE_URL } from "@lib/config/constants";
|
||||
import { HOSTED_CAL_FEATURES } from "@lib/config/constants";
|
||||
import { sendTeamInviteEmail } from "@lib/emails/email-manager";
|
||||
import { TeamInvite } from "@lib/emails/templates/team-invite-email";
|
||||
import { getUserAvailability } from "@lib/queries/availability";
|
||||
|
@ -26,7 +36,14 @@ export const viewerTeamsRouter = createProtectedRouter()
|
|||
throw new TRPCError({ code: "UNAUTHORIZED", message: "You are not a member of this team." });
|
||||
}
|
||||
const membership = team?.members.find((membership) => membership.id === ctx.user.id);
|
||||
return { ...team, membership: { role: membership?.role as MembershipRole } };
|
||||
return {
|
||||
...team,
|
||||
membership: {
|
||||
role: membership?.role as MembershipRole,
|
||||
isMissingSeat: membership?.plan === UserPlan.FREE,
|
||||
},
|
||||
requiresUpgrade: HOSTED_CAL_FEATURES ? !!team.members.find((m) => m.plan !== UserPlan.PRO) : false,
|
||||
};
|
||||
},
|
||||
})
|
||||
// Returns teams I a member of
|
||||
|
@ -132,6 +149,8 @@ export const viewerTeamsRouter = createProtectedRouter()
|
|||
async resolve({ ctx, input }) {
|
||||
if (!(await isTeamOwner(ctx.user?.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
|
||||
await downgradeTeamMembers(input.teamId);
|
||||
|
||||
// delete all memberships
|
||||
await ctx.prisma.membership.deleteMany({
|
||||
where: {
|
||||
|
@ -166,6 +185,8 @@ export const viewerTeamsRouter = createProtectedRouter()
|
|||
userId_teamId: { userId: input.memberId, teamId: input.teamId },
|
||||
},
|
||||
});
|
||||
|
||||
if (HOSTED_CAL_FEATURES) await removeSeat(ctx.user.id, input.teamId, input.memberId);
|
||||
},
|
||||
})
|
||||
.mutation("inviteMember", {
|
||||
|
@ -195,6 +216,8 @@ export const viewerTeamsRouter = createProtectedRouter()
|
|||
},
|
||||
});
|
||||
|
||||
let inviteeUserId: number | undefined = invitee?.id;
|
||||
|
||||
if (!invitee) {
|
||||
// liberal email match
|
||||
const isEmail = (str: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(str);
|
||||
|
@ -206,7 +229,7 @@ export const viewerTeamsRouter = createProtectedRouter()
|
|||
});
|
||||
|
||||
// valid email given, create User and add to team
|
||||
await ctx.prisma.user.create({
|
||||
const user = await ctx.prisma.user.create({
|
||||
data: {
|
||||
email: input.usernameOrEmail,
|
||||
invitedTo: input.teamId,
|
||||
|
@ -218,6 +241,7 @@ export const viewerTeamsRouter = createProtectedRouter()
|
|||
},
|
||||
},
|
||||
});
|
||||
inviteeUserId = user.id;
|
||||
|
||||
const token: string = randomBytes(32).toString("hex");
|
||||
|
||||
|
@ -273,6 +297,11 @@ export const viewerTeamsRouter = createProtectedRouter()
|
|||
await sendTeamInviteEmail(teamInviteEvent);
|
||||
}
|
||||
}
|
||||
try {
|
||||
if (HOSTED_CAL_FEATURES) await addSeat(ctx.user.id, team.id, inviteeUserId);
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
},
|
||||
})
|
||||
.mutation("acceptOrLeave", {
|
||||
|
@ -291,6 +320,17 @@ export const viewerTeamsRouter = createProtectedRouter()
|
|||
},
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
//get team owner so we can alter their subscription seat count
|
||||
const teamOwner = await ctx.prisma.membership.findFirst({
|
||||
where: { teamId: input.teamId, role: MembershipRole.OWNER },
|
||||
});
|
||||
|
||||
// TODO: disable if not hosted by Cal
|
||||
if (teamOwner) await removeSeat(teamOwner.userId, input.teamId, ctx.user.id);
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
await ctx.prisma.membership.delete({
|
||||
where: {
|
||||
userId_teamId: { userId: ctx.user.id, teamId: input.teamId },
|
||||
|
@ -372,13 +412,37 @@ export const viewerTeamsRouter = createProtectedRouter()
|
|||
throw new TRPCError({ code: "BAD_REQUEST", message: "Member doesn't have a username" });
|
||||
|
||||
// get availability for this member
|
||||
const availability = await getUserAvailability({
|
||||
return await getUserAvailability({
|
||||
username: member.user.username,
|
||||
timezone: input.timezone,
|
||||
dateFrom: input.dateFrom,
|
||||
dateTo: input.dateTo,
|
||||
});
|
||||
|
||||
return availability;
|
||||
},
|
||||
})
|
||||
.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);
|
||||
},
|
||||
});
|
||||
|
|
Loading…
Reference in a new issue