first commit
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
import { Card, Icon } from "@calcom/ui";
|
||||
|
||||
import { helpCards } from "@lib/settings/platform/utils";
|
||||
|
||||
export const HelpCards = () => {
|
||||
return (
|
||||
<>
|
||||
<div className="grid-col-1 mb-4 grid gap-2 md:grid-cols-3">
|
||||
{helpCards.map((card) => {
|
||||
return (
|
||||
<div key={card.title}>
|
||||
<Card
|
||||
icon={<Icon name={card.icon} className="h-5 w-5 text-green-700" />}
|
||||
variant={card.variant}
|
||||
title={card.title}
|
||||
description={card.description}
|
||||
actionButton={{
|
||||
href: `${card.actionButton.href}`,
|
||||
child: `${card.actionButton.child}`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import { EmptyScreen, Button } from "@calcom/ui";
|
||||
|
||||
export default function NoPlatformPlan() {
|
||||
return (
|
||||
<EmptyScreen
|
||||
Icon="credit-card"
|
||||
headline="Subscription needed"
|
||||
description="You are not subscribed to a Platform plan."
|
||||
buttonRaw={
|
||||
<div className="flex gap-2">
|
||||
<Button href="https://cal.com/platform/pricing">Go to Pricing</Button>
|
||||
<Button color="secondary" href="https://cal.com/pricing">
|
||||
Contact Sales
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { PlatformOAuthClient } from "@calcom/prisma/client";
|
||||
|
||||
import { OAuthClientsDropdown } from "@components/settings/platform/dashboard/oauth-client-dropdown";
|
||||
|
||||
type ManagedUserHeaderProps = {
|
||||
oauthClients: PlatformOAuthClient[];
|
||||
initialClientName: string;
|
||||
handleChange: (clientId: string, clientName: string) => void;
|
||||
};
|
||||
|
||||
export const ManagedUserHeader = ({
|
||||
oauthClients,
|
||||
initialClientName,
|
||||
handleChange,
|
||||
}: ManagedUserHeaderProps) => {
|
||||
return (
|
||||
<div className="border-subtle mx-auto block justify-between rounded-t-lg border px-4 py-6 sm:flex sm:px-6">
|
||||
<div className="flex w-full flex-col">
|
||||
<h1 className="font-cal text-emphasis mb-1 text-xl font-semibold leading-5 tracking-wide">
|
||||
Managed Users
|
||||
</h1>
|
||||
<p className="text-default text-sm ltr:mr-4 rtl:ml-4">
|
||||
See all the managed users created by your OAuth client.
|
||||
</p>
|
||||
</div>
|
||||
<OAuthClientsDropdown
|
||||
oauthClients={oauthClients}
|
||||
initialClientName={initialClientName}
|
||||
handleChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { PlatformOAuthClient } from "@calcom/prisma/client";
|
||||
|
||||
import type { ManagedUser } from "@lib/hooks/settings/platform/oauth-clients/useOAuthClients";
|
||||
|
||||
import { ManagedUserHeader } from "@components/settings/platform/dashboard/managed-user-header";
|
||||
import { ManagedUserTable } from "@components/settings/platform/dashboard/managed-user-table";
|
||||
|
||||
type ManagedUserListProps = {
|
||||
oauthClients: PlatformOAuthClient[];
|
||||
managedUsers?: ManagedUser[];
|
||||
initialClientName: string;
|
||||
initialClientId: string;
|
||||
isManagedUserLoading: boolean;
|
||||
handleChange: (clientId: string, clientName: string) => void;
|
||||
};
|
||||
|
||||
export const ManagedUserList = ({
|
||||
initialClientName,
|
||||
initialClientId,
|
||||
oauthClients,
|
||||
managedUsers,
|
||||
isManagedUserLoading,
|
||||
handleChange,
|
||||
}: ManagedUserListProps) => {
|
||||
return (
|
||||
<div>
|
||||
<ManagedUserHeader
|
||||
oauthClients={oauthClients}
|
||||
initialClientName={initialClientName}
|
||||
handleChange={handleChange}
|
||||
/>
|
||||
<ManagedUserTable
|
||||
managedUsers={managedUsers}
|
||||
isManagedUserLoading={isManagedUserLoading}
|
||||
initialClientId={initialClientId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import { EmptyScreen } from "@calcom/ui";
|
||||
|
||||
import type { ManagedUser } from "@lib/hooks/settings/platform/oauth-clients/useOAuthClients";
|
||||
|
||||
type ManagedUserTableProps = {
|
||||
managedUsers?: ManagedUser[];
|
||||
isManagedUserLoading: boolean;
|
||||
initialClientId: string;
|
||||
};
|
||||
|
||||
export const ManagedUserTable = ({
|
||||
managedUsers,
|
||||
isManagedUserLoading,
|
||||
initialClientId,
|
||||
}: ManagedUserTableProps) => {
|
||||
const showUsers = !isManagedUserLoading && managedUsers?.length;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{showUsers ? (
|
||||
<>
|
||||
<table className="w-[100%] rounded-lg">
|
||||
<colgroup className="border-subtle overflow-hidden rounded-b-lg border border-b-0" span={3} />
|
||||
<tr>
|
||||
<td className="border-subtle border px-4 py-3 md:text-center">Id</td>
|
||||
<td className="border-subtle border px-4 py-3 md:text-center">Username</td>
|
||||
<td className="border-subtle border px-4 py-3 md:text-center">Email</td>
|
||||
</tr>
|
||||
{managedUsers.map((user) => {
|
||||
return (
|
||||
<tr key={user.id} className="">
|
||||
<td className="border-subtle overflow-hidden border px-4 py-3 md:text-center">{user.id}</td>
|
||||
<td className="border-subtle border px-4 py-3 md:text-center">{user.username}</td>
|
||||
<td className="border-subtle overflow-hidden border px-4 py-3 md:overflow-auto md:text-center">
|
||||
{user.email}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</table>
|
||||
</>
|
||||
) : (
|
||||
<EmptyScreen
|
||||
limitWidth={false}
|
||||
headline={
|
||||
initialClientId == undefined
|
||||
? "OAuth client is missing. You need to create an OAuth client first in order to create a managed user."
|
||||
: `OAuth client ${initialClientId} does not have a managed user present.`
|
||||
}
|
||||
description={
|
||||
initialClientId == undefined
|
||||
? "Refer to the Platform Docs from the sidebar in order to create an OAuth client."
|
||||
: "Refer to the Platform Docs from the sidebar in order to create a managed user."
|
||||
}
|
||||
className="items-center border"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { PlatformOAuthClient } from "@calcom/prisma/client";
|
||||
import {
|
||||
Button,
|
||||
Dropdown,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownItem,
|
||||
} from "@calcom/ui";
|
||||
|
||||
type OAuthClientsDropdownProps = {
|
||||
oauthClients: PlatformOAuthClient[];
|
||||
initialClientName: string;
|
||||
handleChange: (clientId: string, clientName: string) => void;
|
||||
};
|
||||
|
||||
export const OAuthClientsDropdown = ({
|
||||
oauthClients,
|
||||
initialClientName,
|
||||
handleChange,
|
||||
}: OAuthClientsDropdownProps) => {
|
||||
return (
|
||||
<div>
|
||||
{Array.isArray(oauthClients) && oauthClients.length > 0 ? (
|
||||
<Dropdown modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button color="secondary">{initialClientName}</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{oauthClients.map((client) => {
|
||||
return (
|
||||
<div key={client.id}>
|
||||
{initialClientName !== client.name ? (
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<DropdownItem type="button" onClick={() => handleChange(client.id, client.name)}>
|
||||
{client.name}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import type { PlatformOAuthClient } from "@calcom/prisma/client";
|
||||
import { EmptyScreen, Button } from "@calcom/ui";
|
||||
|
||||
import { OAuthClientCard } from "@components/settings/platform/oauth-clients/OAuthClientCard";
|
||||
|
||||
type OAuthClientsListProps = {
|
||||
oauthClients: PlatformOAuthClient[];
|
||||
isDeleting: boolean;
|
||||
handleDelete: (id: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export const OAuthClientsList = ({ oauthClients, isDeleting, handleDelete }: OAuthClientsListProps) => {
|
||||
return (
|
||||
<div className="mb-10">
|
||||
<div className="border-subtle mx-auto block justify-between rounded-t-lg border px-4 py-6 sm:flex sm:px-6">
|
||||
<div className="flex w-full flex-col">
|
||||
<h1 className="font-cal text-emphasis mb-1 text-xl font-semibold leading-5 tracking-wide">
|
||||
OAuth Clients
|
||||
</h1>
|
||||
<p className="text-default text-sm ltr:mr-4 rtl:ml-4">
|
||||
Connect your platform to cal.com with OAuth
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<NewOAuthClientButton redirectLink="/settings/platform/oauth-clients/create" />
|
||||
</div>
|
||||
</div>
|
||||
{Array.isArray(oauthClients) && oauthClients.length ? (
|
||||
<>
|
||||
<div className="border-subtle rounded-b-lg border border-t-0">
|
||||
{oauthClients.map((client, index) => {
|
||||
return (
|
||||
<OAuthClientCard
|
||||
name={client.name}
|
||||
redirectUris={client.redirectUris}
|
||||
bookingRedirectUri={client.bookingRedirectUri}
|
||||
bookingRescheduleRedirectUri={client.bookingRescheduleRedirectUri}
|
||||
bookingCancelRedirectUri={client.bookingCancelRedirectUri}
|
||||
permissions={client.permissions}
|
||||
key={index}
|
||||
lastItem={oauthClients.length === index + 1}
|
||||
id={client.id}
|
||||
secret={client.secret}
|
||||
isLoading={isDeleting}
|
||||
onDelete={handleDelete}
|
||||
areEmailsEnabled={client.areEmailsEnabled}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<EmptyScreen
|
||||
headline="Create your first OAuth client"
|
||||
description="OAuth clients facilitate access to Cal.com on behalf of users"
|
||||
Icon="plus"
|
||||
className=""
|
||||
buttonRaw={<NewOAuthClientButton redirectLink="/settings/platform/oauth-clients/create" />}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const NewOAuthClientButton = ({ redirectLink, label }: { redirectLink: string; label?: string }) => {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
router.push(redirectLink);
|
||||
}}
|
||||
color="secondary"
|
||||
StartIcon="plus">
|
||||
{!!label ? label : "Add"}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
|
||||
import { useCheckTeamBilling } from "@calcom/web/lib/hooks/settings/platform/oauth-clients/usePersistOAuthClient";
|
||||
|
||||
export const useGetUserAttributes = () => {
|
||||
const { data: user, isLoading: isUserLoading } = useMeQuery();
|
||||
const { data: userBillingData, isFetching: isUserBillingDataLoading } = useCheckTeamBilling(
|
||||
user?.organizationId,
|
||||
user?.organization.isPlatform
|
||||
);
|
||||
const isPlatformUser = user?.organization.isPlatform;
|
||||
const isPaidUser = userBillingData?.valid;
|
||||
const userOrgId = user?.organizationId;
|
||||
|
||||
return { isUserLoading, isUserBillingDataLoading, isPlatformUser, isPaidUser, userBillingData, userOrgId };
|
||||
};
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import React from "react";
|
||||
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { PERMISSIONS_GROUPED_MAP } from "@calcom/platform-constants";
|
||||
import type { Avatar } from "@calcom/prisma/client";
|
||||
import { Button, Icon, showToast } from "@calcom/ui";
|
||||
|
||||
import { hasPermission } from "../../../../../../packages/platform/utils/permissions";
|
||||
|
||||
type OAuthClientCardProps = {
|
||||
name: string;
|
||||
logo?: Avatar;
|
||||
redirectUris: string[];
|
||||
bookingRedirectUri: string | null;
|
||||
bookingCancelRedirectUri: string | null;
|
||||
bookingRescheduleRedirectUri: string | null;
|
||||
areEmailsEnabled: boolean;
|
||||
permissions: number;
|
||||
lastItem: boolean;
|
||||
id: string;
|
||||
secret: string;
|
||||
onDelete: (id: string) => Promise<void>;
|
||||
isLoading: boolean;
|
||||
};
|
||||
|
||||
export const OAuthClientCard = ({
|
||||
name,
|
||||
logo,
|
||||
redirectUris,
|
||||
bookingRedirectUri,
|
||||
bookingCancelRedirectUri,
|
||||
bookingRescheduleRedirectUri,
|
||||
permissions,
|
||||
id,
|
||||
secret,
|
||||
lastItem,
|
||||
onDelete,
|
||||
isLoading,
|
||||
areEmailsEnabled,
|
||||
}: OAuthClientCardProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const clientPermissions = Object.values(PERMISSIONS_GROUPED_MAP).map((value, index) => {
|
||||
let permissionsMessage = "";
|
||||
const hasReadPermission = hasPermission(permissions, value.read);
|
||||
const hasWritePermission = hasPermission(permissions, value.write);
|
||||
|
||||
if (hasReadPermission || hasWritePermission) {
|
||||
permissionsMessage = hasReadPermission ? "read" : "write";
|
||||
}
|
||||
|
||||
if (hasReadPermission && hasWritePermission) {
|
||||
permissionsMessage = "read/write";
|
||||
}
|
||||
|
||||
return (
|
||||
!!permissionsMessage && (
|
||||
<div key={value.read} className="relative text-sm">
|
||||
{permissionsMessage} {`${value.label}s`.toLocaleLowerCase()}
|
||||
{Object.values(PERMISSIONS_GROUPED_MAP).length === index + 1 ? " " : ", "}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
"flex w-full justify-between px-4 py-4 sm:px-6",
|
||||
lastItem ? "" : "border-subtle border-b"
|
||||
)}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex gap-1">
|
||||
<p className="font-semibold">
|
||||
Client name: <span className="font-normal">{name}</span>
|
||||
</p>
|
||||
</div>
|
||||
{!!logo && (
|
||||
<div>
|
||||
<>{logo}</>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<div className="font-semibold">Client Id:</div>
|
||||
<div>{id}</div>
|
||||
<Icon
|
||||
name="clipboard"
|
||||
type="button"
|
||||
className="h-4 w-4 cursor-pointer"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(id);
|
||||
showToast("Client id copied to clipboard.", "success");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="font-semibold">Client Secret:</div>
|
||||
<div className="flex items-center justify-center rounded-md">
|
||||
{[...new Array(20)].map((_, index) => (
|
||||
<Icon name="asterisk" key={`${index}asterisk`} className="h-2 w-2" />
|
||||
))}
|
||||
<Icon
|
||||
name="clipboard"
|
||||
type="button"
|
||||
className="ml-2 h-4 w-4 cursor-pointer"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(secret);
|
||||
showToast("Client secret copied to clipboard.", "success");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-subtle flex text-sm">
|
||||
<span className="font-semibold">Permissions: </span>
|
||||
{permissions ? <div className="flex">{clientPermissions}</div> : <> Disabled</>}
|
||||
</div>
|
||||
<div className="flex gap-1 text-sm">
|
||||
<span className="font-semibold">Redirect uris: </span>
|
||||
{redirectUris.map((item, index) => (redirectUris.length === index + 1 ? `${item}` : `${item}, `))}
|
||||
</div>
|
||||
{bookingRedirectUri && (
|
||||
<div className="flex gap-1 text-sm">
|
||||
<span className="font-semibold">Booking redirect uri: </span> {bookingRedirectUri}
|
||||
</div>
|
||||
)}
|
||||
{bookingRescheduleRedirectUri && (
|
||||
<div className="flex gap-1 text-sm">
|
||||
<span className="font-semibold">Booking reschedule uri: </span> {bookingRescheduleRedirectUri}
|
||||
</div>
|
||||
)}
|
||||
{bookingCancelRedirectUri && (
|
||||
<div className="flex gap-1 text-sm">
|
||||
<span className="font-semibold">Booking cancel uri: </span> {bookingCancelRedirectUri}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-1 text-sm">
|
||||
<span className="text-sm font-semibold">Emails enabled:</span> {areEmailsEnabled ? "Yes" : "No"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-4">
|
||||
<Button
|
||||
className="bg-subtle hover:bg-emphasis text-white"
|
||||
loading={isLoading}
|
||||
disabled={isLoading}
|
||||
onClick={() => router.push(`/settings/platform/oauth-clients/${id}/edit`)}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-red-500 text-white hover:bg-red-600"
|
||||
loading={isLoading}
|
||||
disabled={isLoading}
|
||||
onClick={() => onDelete(id)}>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,250 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useForm, useFieldArray } from "react-hook-form";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { PERMISSIONS_GROUPED_MAP } from "@calcom/platform-constants/permissions";
|
||||
import { TextField, Tooltip, Button, Label } from "@calcom/ui";
|
||||
|
||||
type OAuthClientFormProps = {
|
||||
defaultValues?: Partial<FormValues>;
|
||||
isPending?: boolean;
|
||||
isFormDisabled?: boolean;
|
||||
onSubmit: (data: FormValues) => void;
|
||||
};
|
||||
|
||||
export type FormValues = {
|
||||
name: string;
|
||||
logo?: string;
|
||||
permissions: number;
|
||||
eventTypeRead: boolean;
|
||||
eventTypeWrite: boolean;
|
||||
bookingRead: boolean;
|
||||
bookingWrite: boolean;
|
||||
scheduleRead: boolean;
|
||||
scheduleWrite: boolean;
|
||||
appsRead: boolean;
|
||||
appsWrite: boolean;
|
||||
profileRead: boolean;
|
||||
profileWrite: boolean;
|
||||
redirectUris: {
|
||||
uri: string;
|
||||
}[];
|
||||
bookingRedirectUri?: string;
|
||||
bookingCancelRedirectUri?: string;
|
||||
bookingRescheduleRedirectUri?: string;
|
||||
areEmailsEnabled?: boolean;
|
||||
};
|
||||
|
||||
export const OAuthClientForm = ({
|
||||
defaultValues,
|
||||
isPending,
|
||||
isFormDisabled,
|
||||
onSubmit,
|
||||
}: OAuthClientFormProps) => {
|
||||
const { t } = useLocale();
|
||||
const { register, control, handleSubmit, setValue } = useForm<FormValues>({
|
||||
defaultValues: { redirectUris: [{ uri: "" }], ...defaultValues },
|
||||
});
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control,
|
||||
name: "redirectUris",
|
||||
});
|
||||
|
||||
const [isSelectAllPermissionsChecked, setIsSelectAllPermissionsChecked] = useState(false);
|
||||
|
||||
const selectAllPermissions = useCallback(() => {
|
||||
Object.keys(PERMISSIONS_GROUPED_MAP).forEach((key) => {
|
||||
const entity = key as keyof typeof PERMISSIONS_GROUPED_MAP;
|
||||
const permissionKey = PERMISSIONS_GROUPED_MAP[entity].key;
|
||||
|
||||
setValue(`${permissionKey}Read`, !isSelectAllPermissionsChecked);
|
||||
setValue(`${permissionKey}Write`, !isSelectAllPermissionsChecked);
|
||||
});
|
||||
|
||||
setIsSelectAllPermissionsChecked((preValue) => !preValue);
|
||||
}, [isSelectAllPermissionsChecked, setValue]);
|
||||
|
||||
const permissionsCheckboxes = Object.keys(PERMISSIONS_GROUPED_MAP).map((key) => {
|
||||
const entity = key as keyof typeof PERMISSIONS_GROUPED_MAP;
|
||||
const permissionKey = PERMISSIONS_GROUPED_MAP[entity].key;
|
||||
const permissionLabel = PERMISSIONS_GROUPED_MAP[entity].label;
|
||||
|
||||
return (
|
||||
<div className="my-3" key={key}>
|
||||
<p className="text-sm font-semibold">{permissionLabel}</p>
|
||||
<div className="mt-1 flex gap-x-5">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<input
|
||||
{...register(`${permissionKey}Read`)}
|
||||
id={`${permissionKey}Read`}
|
||||
className="bg-default border-default h-4 w-4 shrink-0 cursor-pointer rounded-[4px] border ring-offset-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed"
|
||||
type="checkbox"
|
||||
disabled={!!defaultValues}
|
||||
/>
|
||||
<label htmlFor={`${permissionKey}Read`} className="cursor-pointer text-sm">
|
||||
Read
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<input
|
||||
{...register(`${permissionKey}Write`)}
|
||||
id={`${permissionKey}Write`}
|
||||
className="bg-default border-default h-4 w-4 shrink-0 cursor-pointer rounded-[4px] border ring-offset-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed"
|
||||
type="checkbox"
|
||||
disabled={!!defaultValues}
|
||||
/>
|
||||
<label htmlFor={`${permissionKey}Write`} className="cursor-pointer text-sm">
|
||||
Write
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<form
|
||||
className="border-subtle rounded-b-lg border border-t-0 px-4 pb-8 pt-2"
|
||||
onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="mt-6">
|
||||
<TextField disabled={isFormDisabled} required={true} label="Client name" {...register("name")} />
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<Label>Redirect uris</Label>
|
||||
{fields.map((field, index) => {
|
||||
return (
|
||||
<div className="flex items-end" key={field.id}>
|
||||
<div className="w-[80vw]">
|
||||
<TextField
|
||||
type="url"
|
||||
required={index === 0}
|
||||
className="w-[100%]"
|
||||
label=""
|
||||
disabled={isFormDisabled}
|
||||
{...register(`redirectUris.${index}.uri` as const)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<Button
|
||||
tooltip="Add url"
|
||||
type="button"
|
||||
color="minimal"
|
||||
variant="icon"
|
||||
StartIcon="plus"
|
||||
className="text-default mx-2 mb-2"
|
||||
disabled={isFormDisabled}
|
||||
onClick={() => {
|
||||
append({ uri: "" });
|
||||
}}
|
||||
/>
|
||||
{index > 0 && (
|
||||
<Button
|
||||
tooltip="Remove url"
|
||||
type="button"
|
||||
color="destructive"
|
||||
variant="icon"
|
||||
StartIcon="trash"
|
||||
className="text-default mx-2 mb-2"
|
||||
disabled={isFormDisabled}
|
||||
onClick={() => {
|
||||
remove(index);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/** <div className="mt-6">
|
||||
<Controller
|
||||
control={control}
|
||||
name="logo"
|
||||
render={({ field: { value } }) => (
|
||||
<>
|
||||
<Label>Client logo</Label>
|
||||
<div className="flex items-center">
|
||||
<Avatar
|
||||
alt=""
|
||||
imageSrc={value}
|
||||
fallback={<Icon name="plus" className="text-subtle h-4 w-4" />}
|
||||
size="sm"
|
||||
/>
|
||||
<div className="ms-4">
|
||||
<ImageUploader
|
||||
target="avatar"
|
||||
id="vatar-upload"
|
||||
buttonMsg="Upload"
|
||||
imageSrc={value}
|
||||
handleAvatarChange={(newAvatar: string) => {
|
||||
setValue("logo", newAvatar);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div> */}
|
||||
<div className="mt-6">
|
||||
<Tooltip content="URL of your booking page">
|
||||
<TextField
|
||||
type="url"
|
||||
label="Booking redirect uri"
|
||||
className="w-[100%]"
|
||||
{...register("bookingRedirectUri")}
|
||||
disabled={isFormDisabled}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<Tooltip content="URL of the page where your users can cancel their booking">
|
||||
<TextField
|
||||
type="url"
|
||||
label="Booking cancel redirect uri"
|
||||
className="w-[100%]"
|
||||
{...register("bookingCancelRedirectUri")}
|
||||
disabled={isFormDisabled}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<Tooltip content="URL of the page where your users can reschedule their booking">
|
||||
<TextField
|
||||
type="url"
|
||||
label="Booking reschedule redirect uri"
|
||||
className="w-[100%]"
|
||||
{...register("bookingRescheduleRedirectUri")}
|
||||
disabled={isFormDisabled}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<input
|
||||
{...register("areEmailsEnabled")}
|
||||
id="areEmailsEnabled"
|
||||
className="bg-default border-default h-4 w-4 shrink-0 cursor-pointer rounded-[4px] border ring-offset-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed"
|
||||
type="checkbox"
|
||||
disabled={isFormDisabled}
|
||||
/>
|
||||
<label htmlFor="areEmailsEnabled" className="cursor-pointer px-2 text-base font-semibold">
|
||||
Enable emails
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<div className="flex justify-between">
|
||||
<h1 className="text-base font-semibold underline">Permissions</h1>
|
||||
<Button type="button" onClick={selectAllPermissions} disabled={!!defaultValues || isFormDisabled}>
|
||||
{!isSelectAllPermissionsChecked ? "Select all" : "Discard all"}
|
||||
</Button>
|
||||
</div>
|
||||
<div>{permissionsCheckboxes}</div>
|
||||
</div>
|
||||
<Button className="mt-6" type="submit" loading={isPending}>
|
||||
{defaultValues ? "Update" : "Submit"}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
type IndividualPlatformPlan = {
|
||||
plan: string;
|
||||
description: string;
|
||||
pricing?: number;
|
||||
includes: string[];
|
||||
};
|
||||
|
||||
// if pricing or plans change in future modify this
|
||||
export const platformPlans: IndividualPlatformPlan[] = [
|
||||
{
|
||||
plan: "Starter",
|
||||
description:
|
||||
"Perfect for just getting started with community support and access to hosted platform APIs, Cal.com Atoms (React components) and more.",
|
||||
pricing: 99,
|
||||
includes: [
|
||||
"Up to 100 bookings a month",
|
||||
"Community Support",
|
||||
"Cal Atoms (React Library)",
|
||||
"Platform APIs",
|
||||
"Admin APIs",
|
||||
],
|
||||
},
|
||||
{
|
||||
plan: "Essentials",
|
||||
description:
|
||||
"Your essential package with sophisticated support, hosted platform APIs, Cal.com Atoms (React components) and more.",
|
||||
pricing: 299,
|
||||
includes: [
|
||||
"Up to 500 bookings a month. $0,60 overage beyond",
|
||||
"Everything in Starter",
|
||||
"Cal Atoms (React Library)",
|
||||
"User Management and Analytics",
|
||||
"Technical Account Manager and Onboarding Support",
|
||||
],
|
||||
},
|
||||
{
|
||||
plan: "Scale",
|
||||
description:
|
||||
"The best all-in-one plan to scale your company. Everything you need to provide scheduling for the masses, without breaking things.",
|
||||
pricing: 2499,
|
||||
includes: [
|
||||
"Up to 5000 bookings a month. $0.50 overage beyond",
|
||||
"Everything in Essentials",
|
||||
"Credential import from other platforms",
|
||||
"Compliance Check SOC2, HIPAA",
|
||||
"One-on-one developer calls",
|
||||
"Help with Credentials Verification (Zoom, Google App Store)",
|
||||
"Expedited features and integrations",
|
||||
"SLA (99.999% uptime)",
|
||||
],
|
||||
},
|
||||
{
|
||||
plan: "Enterprise",
|
||||
description: "Everything in Scale with generous volume discounts beyond 50,000 bookings a month.",
|
||||
includes: ["Beyond 50,000 bookings a month", "Everything in Scale", "Up to 50% discount on overages"],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Button } from "@calcom/ui";
|
||||
|
||||
type PlatformBillingCardProps = {
|
||||
plan: string;
|
||||
description: string;
|
||||
pricing?: number;
|
||||
includes: string[];
|
||||
isLoading?: boolean;
|
||||
handleSubscribe?: () => void;
|
||||
};
|
||||
|
||||
export const PlatformBillingCard = ({
|
||||
plan,
|
||||
description,
|
||||
pricing,
|
||||
includes,
|
||||
isLoading,
|
||||
handleSubscribe,
|
||||
}: PlatformBillingCardProps) => {
|
||||
return (
|
||||
<div className="border-subtle mx-4 w-auto rounded-md border p-5 ">
|
||||
<div className="pb-5">
|
||||
<h1 className="pb-3 pt-3 text-xl font-semibold">{plan}</h1>
|
||||
<p className="pb-5 text-base">{description}</p>
|
||||
<h1 className="text-3xl font-semibold">
|
||||
{pricing && (
|
||||
<>
|
||||
US${pricing} <span className="text-sm">per month</span>
|
||||
</>
|
||||
)}
|
||||
</h1>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
loading={isLoading}
|
||||
onClick={handleSubscribe}
|
||||
className="flex w-[100%] items-center justify-center">
|
||||
{pricing ? "Subscribe" : "Schedule a time"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-5">
|
||||
<p>This includes:</p>
|
||||
{includes.map((feature) => {
|
||||
return (
|
||||
<div key={feature} className="my-2 flex">
|
||||
<div className="pr-2">•</div>
|
||||
<div>{feature}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||
import { showToast } from "@calcom/ui";
|
||||
|
||||
import { useSubscribeTeamToStripe } from "@lib/hooks/settings/platform/oauth-clients/usePersistOAuthClient";
|
||||
|
||||
import { platformPlans } from "@components/settings/platform/platformUtils";
|
||||
import { PlatformBillingCard } from "@components/settings/platform/pricing/billing-card";
|
||||
|
||||
type PlatformPricingProps = { teamId?: number | null };
|
||||
|
||||
export const PlatformPricing = ({ teamId }: PlatformPricingProps) => {
|
||||
const router = useRouter();
|
||||
const { mutateAsync, isPending } = useSubscribeTeamToStripe({
|
||||
onSuccess: (redirectUrl: string) => {
|
||||
router.push(redirectUrl);
|
||||
},
|
||||
onError: () => {
|
||||
showToast(ErrorCode.UnableToSubscribeToThePlatform, "error");
|
||||
},
|
||||
teamId,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex h-auto flex-col items-center justify-center px-5 py-10 md:px-10 lg:h-[100%]">
|
||||
<div className="mb-5 text-center text-2xl font-semibold">
|
||||
<h1>Subscribe to Platform</h1>
|
||||
</div>
|
||||
<div className="container mx-auto p-4">
|
||||
<div className="grid grid-cols-1 gap-5 md:grid-cols-2 lg:grid-cols-4">
|
||||
{platformPlans.map((plan) => {
|
||||
return (
|
||||
<div key={plan.plan}>
|
||||
<PlatformBillingCard
|
||||
plan={plan.plan}
|
||||
description={plan.description}
|
||||
pricing={plan.pricing}
|
||||
includes={plan.includes}
|
||||
isLoading={isPending}
|
||||
handleSubscribe={() => {
|
||||
!!teamId &&
|
||||
(plan.plan === "Enterprise"
|
||||
? router.push("https://i.cal.com/sales/exploration")
|
||||
: mutateAsync({ plan: plan.plan.toLocaleUpperCase() }));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user