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",
|
"version": "0.2.0",
|
||||||
"configurations": [
|
"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",
|
"type": "pwa-chrome",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"name": "Launch Chrome against localhost",
|
"url": "http://localhost:3000"
|
||||||
"url": "http://localhost:8080",
|
},
|
||||||
"webRoot": "${workspaceFolder}"
|
{
|
||||||
|
"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) {
|
export function DialogHeader(props: DialogHeaderProps) {
|
||||||
return (
|
return (
|
||||||
<div className="mb-8">
|
<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}
|
{props.title}
|
||||||
</h3>
|
</h3>
|
||||||
{props.subtitle && <div className="text-sm text-gray-400">{props.subtitle}</div>}
|
{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 && "..."}
|
{eventType.description.length > 100 && "..."}
|
||||||
</h2>
|
</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">
|
<li className="flex whitespace-nowrap">
|
||||||
<ClockIcon className="inline mt-0.5 mr-1.5 h-4 w-4 text-neutral-400" aria-hidden="true" />
|
<ClockIcon className="inline mt-0.5 mr-1.5 h-4 w-4 text-neutral-400" aria-hidden="true" />
|
||||||
{eventType.length}m
|
{eventType.length}m
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import { UserIcon } from "@heroicons/react/outline";
|
import { UserIcon } from "@heroicons/react/outline";
|
||||||
|
import { InformationCircleIcon } from "@heroicons/react/solid";
|
||||||
import { MembershipRole } from "@prisma/client";
|
import { MembershipRole } from "@prisma/client";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import React, { SyntheticEvent } from "react";
|
import React, { SyntheticEvent } from "react";
|
||||||
|
@ -7,7 +8,7 @@ import { useLocale } from "@lib/hooks/useLocale";
|
||||||
import { TeamWithMembers } from "@lib/queries/teams";
|
import { TeamWithMembers } from "@lib/queries/teams";
|
||||||
import { trpc } from "@lib/trpc";
|
import { trpc } from "@lib/trpc";
|
||||||
|
|
||||||
import { EmailInput } from "@components/form/fields";
|
import { TextField } from "@components/form/fields";
|
||||||
import Button from "@components/ui/Button";
|
import Button from "@components/ui/Button";
|
||||||
|
|
||||||
export default function MemberInvitationModal(props: { team: TeamWithMembers | null; onExit: () => void }) {
|
export default function MemberInvitationModal(props: { team: TeamWithMembers | null; onExit: () => void }) {
|
||||||
|
@ -76,27 +77,21 @@ export default function MemberInvitationModal(props: { team: TeamWithMembers | n
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={inviteMember}>
|
<form onSubmit={inviteMember}>
|
||||||
<div>
|
<div className="space-y-4">
|
||||||
<div className="mb-4">
|
<TextField
|
||||||
<label htmlFor="inviteUser" className="block text-sm font-medium text-gray-700">
|
label={t("email_or_username")}
|
||||||
{t("email_or_username")}
|
|
||||||
</label>
|
|
||||||
<EmailInput
|
|
||||||
type="text"
|
|
||||||
name="inviteUser"
|
|
||||||
id="inviteUser"
|
id="inviteUser"
|
||||||
|
name="inviteUser"
|
||||||
placeholder="email@example.com"
|
placeholder="email@example.com"
|
||||||
required
|
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>
|
||||||
<div className="mb-4">
|
<label className="block mb-1 text-sm font-medium tracking-wide text-gray-700" htmlFor="role">
|
||||||
<label className="block mb-2 text-sm font-medium tracking-wide text-gray-700" htmlFor="role">
|
|
||||||
{t("role")}
|
{t("role")}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
id="role"
|
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="MEMBER">{t("member")}</option>
|
||||||
<option value="ADMIN">{t("admin")}</option>
|
<option value="ADMIN">{t("admin")}</option>
|
||||||
</select>
|
</select>
|
||||||
|
@ -108,7 +103,7 @@ export default function MemberInvitationModal(props: { team: TeamWithMembers | n
|
||||||
name="sendInviteEmail"
|
name="sendInviteEmail"
|
||||||
defaultChecked
|
defaultChecked
|
||||||
id="sendInviteEmail"
|
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>
|
||||||
<div className="ltr:ml-2 rtl:mr-2text-sm">
|
<div className="ltr:ml-2 rtl:mr-2text-sm">
|
||||||
|
@ -117,6 +112,16 @@ export default function MemberInvitationModal(props: { team: TeamWithMembers | n
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
{errorMessage && (
|
{errorMessage && (
|
||||||
<p className="text-sm text-red-700">
|
<p className="text-sm text-red-700">
|
||||||
|
|
|
@ -24,7 +24,7 @@ import Dropdown, {
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "../ui/Dropdown";
|
} from "../ui/Dropdown";
|
||||||
import MemberChangeRoleModal from "./MemberChangeRoleModal";
|
import MemberChangeRoleModal from "./MemberChangeRoleModal";
|
||||||
import TeamRole from "./TeamRole";
|
import TeamPill, { TeamRole } from "./TeamPill";
|
||||||
import { MembershipRole } from ".prisma/client";
|
import { MembershipRole } from ".prisma/client";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
@ -80,8 +80,14 @@ export default function MemberListItem(props: Props) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex mt-2 ltr:mr-2 rtl:ml-2 sm:mt-0 sm:justify-center">
|
<div className="flex mt-2 ltr:mr-2 rtl:ml-2 sm:mt-0 sm:justify-center">
|
||||||
{!props.member.accepted && <TeamRole invitePending />}
|
{/* Tooltip doesn't show... WHY????? */}
|
||||||
<TeamRole role={props.member.role} />
|
{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>
|
</div>
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
|
@ -96,7 +102,7 @@ export default function MemberListItem(props: Props) {
|
||||||
disabled={!props.member.accepted}
|
disabled={!props.member.accepted}
|
||||||
onClick={() => (props.member.accepted ? setShowTeamAvailabilityModal(true) : null)}
|
onClick={() => (props.member.accepted ? setShowTeamAvailabilityModal(true) : null)}
|
||||||
color="minimal"
|
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" />
|
<ClockIcon className="w-5 h-5 group-hover:text-gray-800" />
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
@ -167,7 +173,7 @@ export default function MemberListItem(props: Props) {
|
||||||
{showTeamAvailabilityModal && (
|
{showTeamAvailabilityModal && (
|
||||||
<ModalContainer wide noPadding>
|
<ModalContainer wide noPadding>
|
||||||
<TeamAvailabilityModal team={props.team} member={props.member} />
|
<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>
|
<Button onClick={() => setShowTeamAvailabilityModal(false)}>{t("done")}</Button>
|
||||||
{props.team.membership.role !== MembershipRole.MEMBER && (
|
{props.team.membership.role !== MembershipRole.MEMBER && (
|
||||||
<Link href={`/settings/teams/${props.team.id}/availability`}>
|
<Link href={`/settings/teams/${props.team.id}/availability`}>
|
||||||
|
|
|
@ -1,9 +1,11 @@
|
||||||
import { UsersIcon } from "@heroicons/react/outline";
|
import { UsersIcon } from "@heroicons/react/outline";
|
||||||
import { useRef } from "react";
|
import { useRef, useState } from "react";
|
||||||
|
|
||||||
import { useLocale } from "@lib/hooks/useLocale";
|
import { useLocale } from "@lib/hooks/useLocale";
|
||||||
import { trpc } from "@lib/trpc";
|
import { trpc } from "@lib/trpc";
|
||||||
|
|
||||||
|
import { Alert } from "@components/ui/Alert";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
@ -11,7 +13,7 @@ interface Props {
|
||||||
export default function TeamCreate(props: Props) {
|
export default function TeamCreate(props: Props) {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const utils = trpc.useContext();
|
const utils = trpc.useContext();
|
||||||
|
const [errorMessage, setErrorMessage] = useState<null | string>(null);
|
||||||
const nameRef = useRef<HTMLInputElement>() as React.MutableRefObject<HTMLInputElement>;
|
const nameRef = useRef<HTMLInputElement>() as React.MutableRefObject<HTMLInputElement>;
|
||||||
|
|
||||||
const createTeamMutation = trpc.useMutation("viewer.teams.create", {
|
const createTeamMutation = trpc.useMutation("viewer.teams.create", {
|
||||||
|
@ -19,6 +21,9 @@ export default function TeamCreate(props: Props) {
|
||||||
utils.invalidateQueries(["viewer.teams.list"]);
|
utils.invalidateQueries(["viewer.teams.list"]);
|
||||||
props.onClose();
|
props.onClose();
|
||||||
},
|
},
|
||||||
|
onError: (e) => {
|
||||||
|
setErrorMessage(e?.message || t("something_went_wrong"));
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const createTeam = (e: React.FormEvent<HTMLFormElement>) => {
|
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"
|
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>
|
</div>
|
||||||
|
{errorMessage && <Alert severity="error" title={errorMessage} />}
|
||||||
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||||
<button type="submit" className="btn btn-primary">
|
<button type="submit" className="btn btn-primary">
|
||||||
{t("create_team")}
|
{t("create_team")}
|
||||||
|
|
|
@ -20,7 +20,7 @@ import Dropdown, {
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
} from "@components/ui/Dropdown";
|
} from "@components/ui/Dropdown";
|
||||||
|
|
||||||
import TeamRole from "./TeamRole";
|
import { TeamRole } from "./TeamPill";
|
||||||
import { MembershipRole } from ".prisma/client";
|
import { MembershipRole } from ".prisma/client";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
@ -99,8 +99,8 @@ export default function TeamListItem(props: Props) {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{!isInvitee && (
|
{!isInvitee && (
|
||||||
<div className="flex rtl:space-x-reverse space-x-2">
|
<div className="flex space-x-2 rtl:space-x-reverse">
|
||||||
<TeamRole role={team.role as MembershipRole} />
|
<TeamRole role={team.role} />
|
||||||
|
|
||||||
<Tooltip content={t("copy_link_team")}>
|
<Tooltip content={t("copy_link_team")}>
|
||||||
<Button
|
<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 { MembershipRole } from ".prisma/client";
|
||||||
|
|
||||||
// import Switch from "@components/ui/Switch";
|
|
||||||
|
|
||||||
export default function TeamSettingsRightSidebar(props: { team: TeamWithMembers; role: MembershipRole }) {
|
export default function TeamSettingsRightSidebar(props: { team: TeamWithMembers; role: MembershipRole }) {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const utils = trpc.useContext();
|
const utils = trpc.useContext();
|
||||||
|
@ -27,6 +25,7 @@ export default function TeamSettingsRightSidebar(props: { team: TeamWithMembers;
|
||||||
const deleteTeamMutation = trpc.useMutation("viewer.teams.delete", {
|
const deleteTeamMutation = trpc.useMutation("viewer.teams.delete", {
|
||||||
async onSuccess() {
|
async onSuccess() {
|
||||||
await utils.invalidateQueries(["viewer.teams.get"]);
|
await utils.invalidateQueries(["viewer.teams.get"]);
|
||||||
|
router.push(`/settings/teams`);
|
||||||
showToast(t("your_team_updated_successfully"), "success");
|
showToast(t("your_team_updated_successfully"), "success");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -50,19 +49,20 @@ export default function TeamSettingsRightSidebar(props: { team: TeamWithMembers;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-2 space-y-6">
|
<div className="px-2 space-y-6">
|
||||||
|
{(props.role === MembershipRole.OWNER || props.role === MembershipRole.ADMIN) && (
|
||||||
<CreateEventTypeButton
|
<CreateEventTypeButton
|
||||||
isIndividualTeam
|
isIndividualTeam
|
||||||
canAddEvents={true}
|
canAddEvents={true}
|
||||||
options={[
|
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">
|
<div className="space-y-1">
|
||||||
<Link href={permalink} passHref={true}>
|
<Link href={permalink} passHref={true}>
|
||||||
<a target="_blank">
|
<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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
"rounded-sm p-2",
|
"rounded-sm p-3 border border-opacity-20",
|
||||||
props.className,
|
props.className,
|
||||||
severity === "error" && "bg-red-50 text-red-800",
|
severity === "error" && "bg-red-50 text-red-800 border-red-900",
|
||||||
severity === "warning" && "bg-yellow-50 text-yellow-700",
|
severity === "warning" && "bg-yellow-50 text-yellow-700 border-yellow-700",
|
||||||
severity === "success" && "bg-gray-900 text-white"
|
severity === "success" && "bg-gray-900 text-white"
|
||||||
)}>
|
)}>
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
|
|
|
@ -10,8 +10,8 @@ export default function LinkIconButton(props: LinkIconButtonProps) {
|
||||||
return (
|
return (
|
||||||
<div className="-ml-2">
|
<div className="-ml-2">
|
||||||
<button
|
<button
|
||||||
{...props}
|
|
||||||
type="button"
|
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">
|
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.Icon className="w-4 h-4 ltr:mr-2 rtl:ml-2 text-neutral-500" />
|
||||||
{props.children}
|
{props.children}
|
||||||
|
|
|
@ -7,6 +7,7 @@ import { trpc } from "@lib/trpc";
|
||||||
|
|
||||||
import { Dialog, DialogTrigger } from "@components/Dialog";
|
import { Dialog, DialogTrigger } from "@components/Dialog";
|
||||||
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
||||||
|
import { TextArea } from "@components/form/fields";
|
||||||
import { Alert } from "@components/ui/Alert";
|
import { Alert } from "@components/ui/Alert";
|
||||||
import Badge from "@components/ui/Badge";
|
import Badge from "@components/ui/Badge";
|
||||||
import Button from "@components/ui/Button";
|
import Button from "@components/ui/Button";
|
||||||
|
@ -89,10 +90,9 @@ export default function SAMLConfiguration({
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<hr className="mt-8" />
|
|
||||||
|
|
||||||
{isSAMLLoginEnabled ? (
|
{isSAMLLoginEnabled ? (
|
||||||
<>
|
<>
|
||||||
|
<hr className="mt-8" />
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<h2 className="text-lg font-medium leading-6 text-gray-900 font-cal">
|
<h2 className="text-lg font-medium leading-6 text-gray-900 font-cal">
|
||||||
{t("saml_configuration")}
|
{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}>
|
<form className="mt-3 divide-y divide-gray-200 lg:col-span-9" onSubmit={updateSAMLConfigHandler}>
|
||||||
{hasErrors && <Alert severity="error" title={errorMessage} />}
|
{hasErrors && <Alert severity="error" title={errorMessage} />}
|
||||||
|
|
||||||
<textarea
|
<TextArea
|
||||||
data-testid="saml_config"
|
data-testid="saml_config"
|
||||||
ref={samlConfigRef}
|
ref={samlConfigRef}
|
||||||
name="saml_config"
|
name="saml_config"
|
||||||
id="saml_config"
|
id="saml_config"
|
||||||
required={true}
|
required={true}
|
||||||
rows={10}
|
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")}
|
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;
|
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);
|
const query = stringify(stripeConnectParams);
|
||||||
/**
|
/**
|
||||||
* Choose Express or Stantard Stripe accounts
|
* Choose Express or Standard Stripe accounts
|
||||||
* @url https://stripe.com/docs/connect/accounts
|
* @url https://stripe.com/docs/connect/accounts
|
||||||
*/
|
*/
|
||||||
// const url = `https://connect.stripe.com/express/oauth/authorize?${query}`;
|
// const url = `https://connect.stripe.com/express/oauth/authorize?${query}`;
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
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 { getSession } from "@lib/auth";
|
||||||
import prisma from "@lib/prisma";
|
|
||||||
|
|
||||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (req.method === "POST") {
|
if (req.method === "POST") {
|
||||||
|
@ -15,29 +15,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get user
|
const customerId = await getStripeCustomerFromUser(session.user.id);
|
||||||
const user = await prisma.user.findUnique({
|
|
||||||
where: {
|
|
||||||
id: session.user?.id,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
email: true,
|
|
||||||
name: true,
|
|
||||||
metadata: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!user?.email)
|
if (!customerId) {
|
||||||
return res.status(404).json({
|
res.status(500).json({ message: "Missing customer id" });
|
||||||
message: "User email not found",
|
return;
|
||||||
});
|
}
|
||||||
|
|
||||||
const customerId = await getStripeCustomerId(user);
|
|
||||||
|
|
||||||
if (!customerId)
|
|
||||||
return res.status(404).json({
|
|
||||||
message: "Stripe customer id not found",
|
|
||||||
});
|
|
||||||
|
|
||||||
const return_url = `${process.env.BASE_URL}/settings/billing`;
|
const return_url = `${process.env.BASE_URL}/settings/billing`;
|
||||||
const stripeSession = await stripe.billingPortal.sessions.create({
|
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 WEBSITE_URL = process.env.NEXT_PUBLIC_APP_URL || "https://cal.com";
|
||||||
export const IS_PRODUCTION = process.env.NODE_ENV === "production";
|
export const IS_PRODUCTION = process.env.NODE_ENV === "production";
|
||||||
export const TRIAL_LIMIT_DAYS = 14;
|
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 =
|
export const prisma =
|
||||||
globalThis.prisma ||
|
globalThis.prisma ||
|
||||||
new PrismaClient({
|
new PrismaClient({
|
||||||
log: ["query", "error", "warn"],
|
// log: ["query", "error", "warn"],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!IS_PRODUCTION) {
|
if (!IS_PRODUCTION) {
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { Prisma } from "@prisma/client";
|
import { Prisma, UserPlan } from "@prisma/client";
|
||||||
|
|
||||||
import prisma from "@lib/prisma";
|
import prisma from "@lib/prisma";
|
||||||
|
|
||||||
|
@ -15,6 +15,7 @@ export async function getTeamWithMembers(id?: number, slug?: string) {
|
||||||
email: true,
|
email: true,
|
||||||
name: true,
|
name: true,
|
||||||
id: true,
|
id: true,
|
||||||
|
plan: true,
|
||||||
bio: 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);
|
const membership = memberships.find((membership) => obj.user.id === membership.userId);
|
||||||
return {
|
return {
|
||||||
...obj.user,
|
...obj.user,
|
||||||
|
isMissingSeat: obj.user.plan === UserPlan.FREE,
|
||||||
role: membership?.role,
|
role: membership?.role,
|
||||||
accepted: membership?.role === "OWNER" ? true : membership?.accepted,
|
accepted: membership?.role === "OWNER" ? true : membership?.accepted,
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import { DefaultSeo } from "next-seo";
|
import { DefaultSeo } from "next-seo";
|
||||||
|
// import { ReactQueryDevtools } from "react-query/devtools";
|
||||||
import superjson from "superjson";
|
import superjson from "superjson";
|
||||||
|
|
||||||
import AppProviders, { AppProps } from "@lib/app-providers";
|
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 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 { getSession } from "@lib/auth";
|
||||||
import prisma from "@lib/prisma";
|
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 classNames from "classnames";
|
||||||
import { useSession } from "next-auth/react";
|
import { useSession } from "next-auth/react";
|
||||||
import { Trans } from "next-i18next";
|
import { Trans } from "next-i18next";
|
||||||
|
@ -7,6 +7,7 @@ import { useState } from "react";
|
||||||
import { useLocale } from "@lib/hooks/useLocale";
|
import { useLocale } from "@lib/hooks/useLocale";
|
||||||
import { trpc } from "@lib/trpc";
|
import { trpc } from "@lib/trpc";
|
||||||
|
|
||||||
|
import EmptyScreen from "@components/EmptyScreen";
|
||||||
import Loader from "@components/Loader";
|
import Loader from "@components/Loader";
|
||||||
import SettingsShell from "@components/SettingsShell";
|
import SettingsShell from "@components/SettingsShell";
|
||||||
import Shell, { useMeQuery } from "@components/Shell";
|
import Shell, { useMeQuery } from "@components/Shell";
|
||||||
|
@ -24,7 +25,7 @@ export default function Teams() {
|
||||||
|
|
||||||
const me = useMeQuery();
|
const me = useMeQuery();
|
||||||
|
|
||||||
const { data } = trpc.useQuery(["viewer.teams.list"], {
|
const { data, isLoading } = trpc.useQuery(["viewer.teams.list"], {
|
||||||
onError: (e) => {
|
onError: (e) => {
|
||||||
setErrorMessage(e.message);
|
setErrorMessage(e.message);
|
||||||
},
|
},
|
||||||
|
@ -67,12 +68,20 @@ export default function Teams() {
|
||||||
{t("new_team")}
|
{t("new_team")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{invites.length > 0 && (
|
{invites.length > 0 && (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<h1 className="mb-2 text-lg font-medium">{t("open_invitations")}</h1>
|
<h1 className="mb-2 text-lg font-medium">{t("open_invitations")}</h1>
|
||||||
<TeamList teams={invites}></TeamList>
|
<TeamList teams={invites}></TeamList>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{!isLoading && !teams.length && (
|
||||||
|
<EmptyScreen
|
||||||
|
Icon={UserGroupIcon}
|
||||||
|
headline={t("no_teams")}
|
||||||
|
description={t("no_teams_description")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{teams.length > 0 && <TeamList teams={teams}></TeamList>}
|
{teams.length > 0 && <TeamList teams={teams}></TeamList>}
|
||||||
</SettingsShell>
|
</SettingsShell>
|
||||||
</Shell>
|
</Shell>
|
||||||
|
|
|
@ -1,11 +1,13 @@
|
||||||
import { PlusIcon } from "@heroicons/react/solid";
|
import { PlusIcon } from "@heroicons/react/solid";
|
||||||
|
import { MembershipRole } from "@prisma/client";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import SAMLConfiguration from "@ee/components/saml/Configuration";
|
import SAMLConfiguration from "@ee/components/saml/Configuration";
|
||||||
|
|
||||||
import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
|
import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
|
||||||
import { useLocale } from "@lib/hooks/useLocale";
|
import { useLocale } from "@lib/hooks/useLocale";
|
||||||
|
import showToast from "@lib/notification";
|
||||||
import { trpc } from "@lib/trpc";
|
import { trpc } from "@lib/trpc";
|
||||||
|
|
||||||
import Loader from "@components/Loader";
|
import Loader from "@components/Loader";
|
||||||
|
@ -14,6 +16,7 @@ import MemberInvitationModal from "@components/team/MemberInvitationModal";
|
||||||
import MemberList from "@components/team/MemberList";
|
import MemberList from "@components/team/MemberList";
|
||||||
import TeamSettings from "@components/team/TeamSettings";
|
import TeamSettings from "@components/team/TeamSettings";
|
||||||
import TeamSettingsRightSidebar from "@components/team/TeamSettingsRightSidebar";
|
import TeamSettingsRightSidebar from "@components/team/TeamSettingsRightSidebar";
|
||||||
|
import { UpgradeToFlexibleProModal } from "@components/team/UpgradeToFlexibleProModal";
|
||||||
import { Alert } from "@components/ui/Alert";
|
import { Alert } from "@components/ui/Alert";
|
||||||
import Avatar from "@components/ui/Avatar";
|
import Avatar from "@components/ui/Avatar";
|
||||||
import { Button } from "@components/ui/Button";
|
import { Button } from "@components/ui/Button";
|
||||||
|
@ -22,6 +25,15 @@ export function TeamSettingsPage() {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const router = useRouter();
|
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 [showMemberInvitationModal, setShowMemberInvitationModal] = useState(false);
|
||||||
const [errorMessage, setErrorMessage] = useState("");
|
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 (
|
return (
|
||||||
<Shell
|
<Shell
|
||||||
backPath={!errorMessage ? `/settings/teams` : undefined}
|
backPath={!errorMessage ? `/settings/teams` : undefined}
|
||||||
heading={team?.name}
|
heading={team?.name}
|
||||||
subtitle={team && "Manage this team"}
|
subtitle={team && t("manage_this_team")}
|
||||||
HeadingLeftIcon={
|
HeadingLeftIcon={
|
||||||
team && (
|
team && (
|
||||||
<Avatar
|
<Avatar
|
||||||
|
@ -54,12 +67,54 @@ export function TeamSettingsPage() {
|
||||||
<>
|
<>
|
||||||
<div className="block sm:flex md:max-w-5xl">
|
<div className="block sm:flex md:max-w-5xl">
|
||||||
<div className="w-full ltr:mr-2 rtl:ml-2 sm:w-9/12">
|
<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">
|
<div className="px-4 -mx-0 bg-white border rounded-sm border-neutral-200 sm:px-6">
|
||||||
{isAdmin ? (
|
{isAdmin ? (
|
||||||
<TeamSettings team={team} />
|
<TeamSettings team={team} />
|
||||||
) : (
|
) : (
|
||||||
<div className="py-5">
|
<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>
|
<p className="text-sm text-gray-700">{team.bio}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
@ -80,7 +135,7 @@ export function TeamSettingsPage() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<MemberList team={team} members={team.members || []} />
|
<MemberList team={team} members={team.members || []} />
|
||||||
{isAdmin ? <SAMLConfiguration teamsView={true} teamId={team.id} /> : null}
|
{isAdmin && <SAMLConfiguration teamsView={true} teamId={team.id} />}
|
||||||
</div>
|
</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">
|
<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} />
|
<TeamSettingsRightSidebar role={team.membership.role} team={team} />
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import { ArrowRightIcon } from "@heroicons/react/solid";
|
import { ArrowRightIcon } from "@heroicons/react/solid";
|
||||||
|
import { UserPlan } from "@prisma/client";
|
||||||
import { GetServerSidePropsContext } from "next";
|
import { GetServerSidePropsContext } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
@ -115,6 +116,10 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||||
|
|
||||||
if (!team) return { notFound: true };
|
if (!team) return { notFound: true };
|
||||||
|
|
||||||
|
const members = team.members.filter((member) => member.plan !== UserPlan.FREE);
|
||||||
|
|
||||||
|
team.members = members ?? [];
|
||||||
|
|
||||||
team.eventTypes = team.eventTypes.map((type) => ({
|
team.eventTypes = team.eventTypes.map((type) => ({
|
||||||
...type,
|
...type,
|
||||||
users: type.users.map((user) => ({
|
users: type.users.map((user) => ({
|
||||||
|
|
|
@ -80,10 +80,18 @@
|
||||||
"rejected_event_type_with_organizer": "Rejected: {{eventType}} with {{organizer}} on {{date}}",
|
"rejected_event_type_with_organizer": "Rejected: {{eventType}} with {{organizer}} on {{date}}",
|
||||||
"hi": "Hi",
|
"hi": "Hi",
|
||||||
"join_team": "Join team",
|
"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.",
|
"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}}",
|
"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",
|
"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.",
|
"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",
|
"use_link_to_reset_password": "Use the link below to reset your password",
|
||||||
"hey_there": "Hey there,",
|
"hey_there": "Hey there,",
|
||||||
"forgot_your_password_calcom": "Forgot your password? - Cal.com",
|
"forgot_your_password_calcom": "Forgot your password? - Cal.com",
|
||||||
|
@ -431,6 +439,8 @@
|
||||||
"confirm_remove_member": "Yes, remove member",
|
"confirm_remove_member": "Yes, remove member",
|
||||||
"remove_member": "Remove member",
|
"remove_member": "Remove member",
|
||||||
"manage_your_team": "Manage your team",
|
"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",
|
"submit": "Submit",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"update": "Update",
|
"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_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",
|
"create_team_to_get_started": "Create a team to get started",
|
||||||
"teams": "Teams",
|
"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.",
|
"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",
|
"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.",
|
"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 { Prisma } from "@prisma/client";
|
||||||
import { randomBytes } from "crypto";
|
import { randomBytes } from "crypto";
|
||||||
import { z } from "zod";
|
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 { BASE_URL } from "@lib/config/constants";
|
||||||
|
import { HOSTED_CAL_FEATURES } from "@lib/config/constants";
|
||||||
import { sendTeamInviteEmail } from "@lib/emails/email-manager";
|
import { sendTeamInviteEmail } from "@lib/emails/email-manager";
|
||||||
import { TeamInvite } from "@lib/emails/templates/team-invite-email";
|
import { TeamInvite } from "@lib/emails/templates/team-invite-email";
|
||||||
import { getUserAvailability } from "@lib/queries/availability";
|
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." });
|
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);
|
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
|
// Returns teams I a member of
|
||||||
|
@ -132,6 +149,8 @@ export const viewerTeamsRouter = createProtectedRouter()
|
||||||
async resolve({ ctx, input }) {
|
async resolve({ ctx, input }) {
|
||||||
if (!(await isTeamOwner(ctx.user?.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" });
|
if (!(await isTeamOwner(ctx.user?.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||||
|
|
||||||
|
await downgradeTeamMembers(input.teamId);
|
||||||
|
|
||||||
// delete all memberships
|
// delete all memberships
|
||||||
await ctx.prisma.membership.deleteMany({
|
await ctx.prisma.membership.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
|
@ -166,6 +185,8 @@ export const viewerTeamsRouter = createProtectedRouter()
|
||||||
userId_teamId: { userId: input.memberId, teamId: input.teamId },
|
userId_teamId: { userId: input.memberId, teamId: input.teamId },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (HOSTED_CAL_FEATURES) await removeSeat(ctx.user.id, input.teamId, input.memberId);
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.mutation("inviteMember", {
|
.mutation("inviteMember", {
|
||||||
|
@ -195,6 +216,8 @@ export const viewerTeamsRouter = createProtectedRouter()
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let inviteeUserId: number | undefined = invitee?.id;
|
||||||
|
|
||||||
if (!invitee) {
|
if (!invitee) {
|
||||||
// liberal email match
|
// liberal email match
|
||||||
const isEmail = (str: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(str);
|
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
|
// valid email given, create User and add to team
|
||||||
await ctx.prisma.user.create({
|
const user = await ctx.prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
email: input.usernameOrEmail,
|
email: input.usernameOrEmail,
|
||||||
invitedTo: input.teamId,
|
invitedTo: input.teamId,
|
||||||
|
@ -218,6 +241,7 @@ export const viewerTeamsRouter = createProtectedRouter()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
inviteeUserId = user.id;
|
||||||
|
|
||||||
const token: string = randomBytes(32).toString("hex");
|
const token: string = randomBytes(32).toString("hex");
|
||||||
|
|
||||||
|
@ -273,6 +297,11 @@ export const viewerTeamsRouter = createProtectedRouter()
|
||||||
await sendTeamInviteEmail(teamInviteEvent);
|
await sendTeamInviteEmail(teamInviteEvent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
if (HOSTED_CAL_FEATURES) await addSeat(ctx.user.id, team.id, inviteeUserId);
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.mutation("acceptOrLeave", {
|
.mutation("acceptOrLeave", {
|
||||||
|
@ -291,6 +320,17 @@ export const viewerTeamsRouter = createProtectedRouter()
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else {
|
} 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({
|
await ctx.prisma.membership.delete({
|
||||||
where: {
|
where: {
|
||||||
userId_teamId: { userId: ctx.user.id, teamId: input.teamId },
|
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" });
|
throw new TRPCError({ code: "BAD_REQUEST", message: "Member doesn't have a username" });
|
||||||
|
|
||||||
// get availability for this member
|
// get availability for this member
|
||||||
const availability = await getUserAvailability({
|
return await getUserAvailability({
|
||||||
username: member.user.username,
|
username: member.user.username,
|
||||||
timezone: input.timezone,
|
timezone: input.timezone,
|
||||||
dateFrom: input.dateFrom,
|
dateFrom: input.dateFrom,
|
||||||
dateTo: input.dateTo,
|
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