feat: platform onboarding flow and dashboard (#14721)

* add endpoint to fetch managed user from client id

* update typings

* minor tweaks

* custom hook to fetch managed users from client id

* add translations for platform onboarding

* add isPlatformOrg boolean to figure out which is platform and which is not

* set isPlatform hook based on data obtained from org

* add limitWidth prop to control component width

* add props to shell to make sidebar display different tabs based on if the shell isPlattform or not

* platform related pages

* fix merge conflicts

* fix merge conflicts

* platform oauth client form and card

* remove everything related to platform from organization

* update oauth client card and form

* fixup

* fix imports and remove logs

* fixup

* update redirect url

* split oauth client form into separate update and create forms

* separate forms for create and edit oauth clients

* fixup

* fixup

* dynamic routes for oauth client edit page

* fixup fixup

* fix to not show error when redirect uri is empty

* refactor create handler for org

* cleaup comments

* add custom hook to check user billing

* export managed user type

* refactor platform index page

* refactor edit and create pages

* dashboard component containing oauth client list and managed user

* common oauth client form used for create and edit form

* platform pricing helper

* platform pricing component

* fix typing and data response for billing

* use custom hook to check team billing info

* fix type checks

* upgrade conditional rendering for upgrade to org banner

* add isLoading prop to check button loading state

* pass in button handler

* add custom hook to subscribe to stripe and typings

* update typings

* fix incorrect endpoint

* pass in team id as prop

* fix type check

* update stripe success and cancel redirect url

* add and pass redirect url param to custom hook

* custom hooks for platform

* cleanup

* update imports

* fix merge conflicts

* fixup

* fixup fixup

* fixup

* merge conflicts fixup

* merge conlficts battle :(

* minor fixes

* skip admin checks for a platform client

* fix typo

* append slug with _platform for a platform user

* PR feedback

* dashboard refactor

* bring back org form to its orginal state

* add platform folder to ee

* update typings

* use new create platform form

* fixup

* fix typo and update plans

* simplifying rendering

* remove managed users endpoint since it already exists

* url for endpoint

* rename tabs

* pr feedback

* managed users endpoint

* update endpoint

* remove form

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: exception <erik@erosemberg.com>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
This commit is contained in:
Rajiv Sahal
2024-05-09 13:01:10 -04:00
committed by GitHub
co-authored by Morgan exception Carina Wollendorfer Keith Williams Peer Richelsen
parent 6d8fbfc49a
commit c2a07e221d
31 changed files with 1258 additions and 355 deletions
@@ -62,8 +62,8 @@ export class BillingService {
quantity: 1,
},
],
success_url: `${this.webAppUrl}/settings/platform/oauth-clients`,
cancel_url: `${this.webAppUrl}/settings/platform/oauth-clients`,
success_url: `${this.webAppUrl}/settings/platform/`,
cancel_url: `${this.webAppUrl}/settings/platform/`,
mode: "subscription",
metadata: {
teamId: teamId.toString(),
+1
View File
@@ -1,5 +1,6 @@
export enum PlatformPlan {
STARTER = "STARTER",
ESSENTIALS = "ESSENTIALS",
SCALE = "SCALE",
ENTERPRISE = "ENTERPRISE",
}
@@ -3,6 +3,8 @@ import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard";
import { OrganizationRolesGuard } from "@/modules/auth/guards/organization-roles/organization-roles.guard";
import { GetManagedUsersOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-users.output";
import { ManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/managed-user.output";
import { CreateOAuthClientResponseDto } from "@/modules/oauth-clients/controllers/oauth-clients/responses/CreateOAuthClientResponse.dto";
import { GetOAuthClientResponseDto } from "@/modules/oauth-clients/controllers/oauth-clients/responses/GetOAuthClientResponse.dto";
import { GetOAuthClientsResponseDto } from "@/modules/oauth-clients/controllers/oauth-clients/responses/GetOAuthClientsResponse.dto";
@@ -10,9 +12,11 @@ import { UpdateOAuthClientInput } from "@/modules/oauth-clients/inputs/update-oa
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
import { UserWithProfile } from "@/modules/users/users.repository";
import { UsersRepository } from "@/modules/users/users.repository";
import {
Body,
Controller,
Query,
Get,
Post,
Patch,
@@ -32,9 +36,11 @@ import {
ApiCreatedResponse as DocsCreatedResponse,
} from "@nestjs/swagger";
import { MembershipRole } from "@prisma/client";
import { User } from "@prisma/client";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { CreateOAuthClientInput } from "@calcom/platform-types";
import { Pagination } from "@calcom/platform-types";
const AUTH_DOCUMENTATION = `⚠️ First, this endpoint requires \`Cookie: next-auth.session-token=eyJhbGciOiJ\` header. Log into Cal web app using owner of organization that was created after visiting \`/settings/organizations/new\`, refresh swagger docs, and the cookie will be added to requests automatically to pass the NextAuthGuard.
Second, make sure that the logged in user has organizationId set to pass the OrganizationRolesGuard guard.`;
@@ -51,6 +57,7 @@ export class OAuthClientsController {
constructor(
private readonly oauthClientRepository: OAuthClientRepository,
private readonly userRepository: UsersRepository,
private readonly teamsRepository: OrganizationsRepository
) {}
@@ -109,6 +116,24 @@ export class OAuthClientsController {
return { status: SUCCESS_STATUS, data: client };
}
@Get("/:clientId/managed-users")
@HttpCode(HttpStatus.OK)
@Roles([MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER])
@DocsOperation({ description: AUTH_DOCUMENTATION })
async getOAuthClientManagedUsersById(
@Param("clientId") clientId: string,
@Query() queryParams: Pagination
): Promise<GetManagedUsersOutput> {
const { offset, limit } = queryParams;
const existingManagedUsers = await this.userRepository.findManagedUsersByOAuthClientId(
clientId,
offset ?? 0,
limit ?? 50
);
return { status: SUCCESS_STATUS, data: existingManagedUsers.map((user) => this.getResponseUser(user)) };
}
@Patch("/:clientId")
@HttpCode(HttpStatus.OK)
@Roles([MembershipRole.ADMIN, MembershipRole.OWNER])
@@ -131,4 +156,17 @@ export class OAuthClientsController {
const client = await this.oauthClientRepository.deleteOAuthClient(clientId);
return { status: SUCCESS_STATUS, data: client };
}
private getResponseUser(user: User): ManagedUserOutput {
return {
id: user.id,
email: user.email,
username: user.username,
timeZone: user.timeZone,
weekStart: user.weekStart,
createdDate: user.createdDate,
timeFormat: user.timeFormat,
defaultScheduleId: user.defaultScheduleId,
};
}
}
@@ -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>
);
};
@@ -6,7 +6,7 @@ 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";
import { hasPermission } from "../../../../../../packages/platform/utils/permissions";
type OAuthClientCardProps = {
name: string;
@@ -115,7 +115,7 @@ export const OAuthClientCard = ({
</div>
<div className="border-subtle flex text-sm">
<span className="font-semibold">Permissions: </span>
<div className="flex">{clientPermissions}</div>
{permissions ? <div className="flex">{clientPermissions}</div> : <>&nbsp;Disabled</>}
</div>
<div className="flex gap-1 text-sm">
<span className="font-semibold">Redirect uris: </span>
@@ -145,7 +145,7 @@ export const OAuthClientCard = ({
className="bg-subtle hover:bg-emphasis text-white"
loading={isLoading}
disabled={isLoading}
onClick={() => router.push(`/settings/organizations/platform/oauth-clients/create?clientId=${id}`)}>
onClick={() => router.push(`/settings/platform/oauth-clients/${id}/edit`)}>
Edit
</Button>
<Button
@@ -1,33 +1,18 @@
import { useRouter } from "next/navigation";
import type { FC } from "react";
import React, { useState, useCallback, useEffect } from "react";
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 { showToast } from "@calcom/ui";
import { Meta, Button, TextField, Label, Tooltip } from "@calcom/ui";
import { TextField, Tooltip, Button, Label } from "@calcom/ui";
import { useOAuthClient } from "@lib/hooks/settings/organizations/platform/oauth-clients/useOAuthClients";
import {
useCreateOAuthClient,
useUpdateOAuthClient,
} from "@lib/hooks/settings/organizations/platform/oauth-clients/usePersistOAuthClient";
type OAuthClientFormProps = {
defaultValues?: Partial<FormValues>;
isPending?: boolean;
isFormDisabled?: boolean;
onSubmit: (data: FormValues) => void;
};
import {
hasAppsReadPermission,
hasAppsWritePermission,
hasBookingReadPermission,
hasBookingWritePermission,
hasEventTypeReadPermission,
hasEventTypeWritePermission,
hasProfileReadPermission,
hasProfileWritePermission,
hasScheduleReadPermission,
hasScheduleWritePermission,
} from "../../../../../../../packages/platform/utils/permissions";
type FormValues = {
export type FormValues = {
name: string;
logo?: string;
permissions: number;
@@ -50,45 +35,20 @@ type FormValues = {
areEmailsEnabled?: boolean;
};
export const OAuthClientForm: FC<{ clientId?: string }> = ({ clientId }) => {
export const OAuthClientForm = ({
defaultValues,
isPending,
isFormDisabled,
onSubmit,
}: OAuthClientFormProps) => {
const { t } = useLocale();
const router = useRouter();
const { data, isFetched, isError, refetch } = useOAuthClient(clientId);
const { register, control, handleSubmit, setValue } = useForm<FormValues>({
defaultValues: {
redirectUris: [{ uri: "" }],
},
defaultValues: { redirectUris: [{ uri: "" }], ...defaultValues },
});
const { fields, append, remove } = useFieldArray({
control,
name: "redirectUris",
});
useEffect(() => {
if (isFetched && data && !isError) {
setValue("name", data.name);
data.bookingRedirectUri && setValue("bookingRedirectUri", data.bookingRedirectUri);
data.bookingCancelRedirectUri && setValue("bookingCancelRedirectUri", data.bookingCancelRedirectUri);
data.bookingRescheduleRedirectUri &&
setValue("bookingRescheduleRedirectUri", data.bookingRescheduleRedirectUri);
setValue("areEmailsEnabled", data?.areEmailsEnabled);
data.redirectUris?.forEach?.((uri: string, index: number) => {
index === 0 && setValue(`redirectUris.${index}.uri`, uri);
index !== 0 && append({ uri });
});
if (!data.permissions) return;
if (hasAppsReadPermission(data.permissions)) setValue("appsRead", true);
if (hasAppsWritePermission(data.permissions)) setValue("appsWrite", true);
if (hasBookingReadPermission(data.permissions)) setValue("bookingRead", true);
if (hasBookingWritePermission(data.permissions)) setValue("bookingWrite", true);
if (hasEventTypeReadPermission(data.permissions)) setValue("eventTypeRead", true);
if (hasEventTypeWritePermission(data.permissions)) setValue("eventTypeWrite", true);
if (hasProfileReadPermission(data.permissions)) setValue("profileRead", true);
if (hasProfileWritePermission(data.permissions)) setValue("profileWrite", true);
if (hasScheduleReadPermission(data.permissions)) setValue("scheduleRead", true);
if (hasScheduleWritePermission(data.permissions)) setValue("scheduleWrite", true);
}
}, [isFetched, data]);
const disabledForm = Boolean(clientId && !isFetched && isError);
const [isSelectAllPermissionsChecked, setIsSelectAllPermissionsChecked] = useState(false);
@@ -104,67 +64,6 @@ export const OAuthClientForm: FC<{ clientId?: string }> = ({ clientId }) => {
setIsSelectAllPermissionsChecked((preValue) => !preValue);
}, [isSelectAllPermissionsChecked, setValue]);
const { mutateAsync: save, isPending: isSaving } = useCreateOAuthClient({
onSuccess: () => {
showToast("OAuth client created successfully", "success");
refetch();
router.push("/settings/organizations/platform/oauth-clients");
},
onError: () => {
showToast("Internal server error, please try again later", "error");
},
});
const { mutateAsync: update, isPending: isUpdating } = useUpdateOAuthClient({
onSuccess: () => {
showToast("OAuth client updated successfully", "success");
refetch();
router.push("/settings/organizations/platform/oauth-clients");
},
onError: () => {
showToast("Internal server error, please try again later", "error");
},
clientId,
});
const onSubmit = (data: FormValues) => {
let userPermissions = 0;
const userRedirectUris = data.redirectUris.map((uri) => uri.uri).filter((uri) => !!uri);
Object.keys(PERMISSIONS_GROUPED_MAP).forEach((key) => {
const entity = key as keyof typeof PERMISSIONS_GROUPED_MAP;
const entityKey = PERMISSIONS_GROUPED_MAP[entity].key;
const read = PERMISSIONS_GROUPED_MAP[entity].read;
const write = PERMISSIONS_GROUPED_MAP[entity].write;
if (data[`${entityKey}Read`]) userPermissions |= read;
if (data[`${entityKey}Write`]) userPermissions |= write;
});
if (clientId) {
// don't update permissions if client is already created
update({
name: data.name,
// logo: data.logo,
redirectUris: userRedirectUris,
bookingRedirectUri: data.bookingRedirectUri,
bookingCancelRedirectUri: data.bookingCancelRedirectUri,
bookingRescheduleRedirectUri: data.bookingRescheduleRedirectUri,
areEmailsEnabled: data.areEmailsEnabled,
});
} else {
save({
name: data.name,
permissions: userPermissions,
// logo: data.logo,
redirectUris: userRedirectUris,
bookingRedirectUri: data.bookingRedirectUri,
bookingCancelRedirectUri: data.bookingCancelRedirectUri,
bookingRescheduleRedirectUri: data.bookingRescheduleRedirectUri,
areEmailsEnabled: data.areEmailsEnabled,
});
}
};
const isPending = isSaving || isUpdating;
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;
@@ -180,7 +79,7 @@ export const OAuthClientForm: FC<{ clientId?: string }> = ({ clientId }) => {
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={Boolean(clientId)}
disabled={!!defaultValues}
/>
<label htmlFor={`${permissionKey}Read`} className="cursor-pointer text-sm">
Read
@@ -192,7 +91,7 @@ export const OAuthClientForm: FC<{ clientId?: string }> = ({ clientId }) => {
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={Boolean(clientId)}
disabled={!!defaultValues}
/>
<label htmlFor={`${permissionKey}Write`} className="cursor-pointer text-sm">
Write
@@ -205,16 +104,11 @@ export const OAuthClientForm: FC<{ clientId?: string }> = ({ clientId }) => {
return (
<div>
<Meta
title={t("oauth_form_title") + (clientId ? " - Update" : "")}
description={t("oauth_form_description") + (clientId ? " - Update" : "")}
borderInShellHeader={true}
/>
<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={disabledForm} required={true} label="Client name" {...register("name")} />
<TextField disabled={isFormDisabled} required={true} label="Client name" {...register("name")} />
</div>
<div className="mt-6">
<Label>Redirect uris</Label>
@@ -227,7 +121,7 @@ export const OAuthClientForm: FC<{ clientId?: string }> = ({ clientId }) => {
required={index === 0}
className="w-[100%]"
label=""
disabled={disabledForm}
disabled={isFormDisabled}
{...register(`redirectUris.${index}.uri` as const)}
/>
</div>
@@ -239,10 +133,10 @@ export const OAuthClientForm: FC<{ clientId?: string }> = ({ clientId }) => {
variant="icon"
StartIcon="plus"
className="text-default mx-2 mb-2"
disabled={isFormDisabled}
onClick={() => {
append({ uri: "" });
}}
disabled={disabledForm}
/>
{index > 0 && (
<Button
@@ -252,10 +146,10 @@ export const OAuthClientForm: FC<{ clientId?: string }> = ({ clientId }) => {
variant="icon"
StartIcon="trash"
className="text-default mx-2 mb-2"
disabled={isFormDisabled}
onClick={() => {
remove(index);
}}
disabled={disabledForm}
/>
)}
</div>
@@ -300,7 +194,7 @@ export const OAuthClientForm: FC<{ clientId?: string }> = ({ clientId }) => {
label="Booking redirect uri"
className="w-[100%]"
{...register("bookingRedirectUri")}
disabled={disabledForm}
disabled={isFormDisabled}
/>
</Tooltip>
</div>
@@ -311,7 +205,7 @@ export const OAuthClientForm: FC<{ clientId?: string }> = ({ clientId }) => {
label="Booking cancel redirect uri"
className="w-[100%]"
{...register("bookingCancelRedirectUri")}
disabled={disabledForm}
disabled={isFormDisabled}
/>
</Tooltip>
</div>
@@ -322,7 +216,7 @@ export const OAuthClientForm: FC<{ clientId?: string }> = ({ clientId }) => {
label="Booking reschedule redirect uri"
className="w-[100%]"
{...register("bookingRescheduleRedirectUri")}
disabled={disabledForm}
disabled={isFormDisabled}
/>
</Tooltip>
</div>
@@ -332,27 +226,23 @@ export const OAuthClientForm: FC<{ clientId?: string }> = ({ clientId }) => {
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={disabledForm}
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">
<Tooltip side="right" content="Permissions once set cannot be modified">
<h1 className="text-base font-semibold underline">Permissions</h1>
</Tooltip>
<Button type="button" onClick={selectAllPermissions} disabled={disabledForm || Boolean(clientId)}>
<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} disabled={disabledForm}>
{clientId ? "Update" : "Submit"}
<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 w-auto rounded-md border p-5 lg:w-[350px]">
<div className="pb-5">
<h1 className="pb-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">&bull;</div>
<div>{feature}</div>
</div>
);
})}
</div>
</div>
);
};
@@ -0,0 +1,52 @@
import { useRouter } from "next/navigation";
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("Internal server error, please try again later", "error");
},
teamId,
});
return (
<div className="flex h-auto flex-col items-center justify-center px-5 py-5 md:px-10 md:py-0 lg:h-[100vh]">
<div className="mb-5 text-center text-2xl font-semibold">
<h1>Subscribe to Platform</h1>
</div>
<div className="flex flex-col flex-wrap gap-x-0 gap-y-5 md:flex-row md:gap-x-5 md:gap-y-5 lg:flex-nowrap">
{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>
);
};
@@ -3,6 +3,17 @@ import { useQuery } from "@tanstack/react-query";
import type { ApiSuccessResponse } from "@calcom/platform-types";
import type { PlatformOAuthClient } from "@calcom/prisma/client";
export type ManagedUser = {
id: number;
email: string;
username: string | null;
timeZone: string;
weekStart: string;
createdDate: Date;
timeFormat: number | null;
defaultScheduleId: number | null;
};
export const useOAuthClients = () => {
const query = useQuery<ApiSuccessResponse<PlatformOAuthClient[]>>({
queryKey: ["oauth-clients"],
@@ -52,3 +63,21 @@ export const useOAuthClient = (clientId?: string) => {
refetch,
};
};
export const useGetOAuthClientManagedUsers = (clientId: string) => {
const {
isLoading,
error,
data: response,
refetch,
} = useQuery<ApiSuccessResponse<ManagedUser[]>>({
queryKey: ["oauth-client-managed-users", clientId],
queryFn: () => {
return fetch(`/api/v2/oauth-clients/${clientId}/managed-users`, {
method: "get",
headers: { "Content-type": "application/json" },
}).then((res) => res.json());
},
});
return { isLoading, error, data: response?.data, refetch };
};
@@ -1,7 +1,12 @@
import { useMutation } from "@tanstack/react-query";
import { useMutation, useQuery } from "@tanstack/react-query";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import type { ApiResponse, CreateOAuthClientInput, DeleteOAuthClientInput } from "@calcom/platform-types";
import type {
ApiResponse,
CreateOAuthClientInput,
DeleteOAuthClientInput,
SubscribeTeamInput,
} from "@calcom/platform-types";
import type { OAuthClient } from "@calcom/prisma/client";
interface IPersistOAuthClient {
@@ -113,3 +118,59 @@ export const useDeleteOAuthClient = (
return mutation;
};
export const useCheckTeamBilling = (teamId?: number | null) => {
const QUERY_KEY = "check-team-billing";
const isTeamBilledAlready = useQuery({
queryKey: [QUERY_KEY, teamId],
queryFn: async () => {
const response = await fetch(`/api/v2/billing/${teamId}/check`, {
method: "get",
headers: { "Content-type": "application/json" },
});
const data = await response.json();
return data.data;
},
enabled: !!teamId,
});
return isTeamBilledAlready;
};
export const useSubscribeTeamToStripe = (
{
onSuccess,
onError,
teamId,
}: { teamId?: number | null; onSuccess: (redirectUrl: string) => void; onError: () => void } = {
onSuccess: () => {
return;
},
onError: () => {
return;
},
}
) => {
const mutation = useMutation<ApiResponse<{ action: string; url: string }>, unknown, SubscribeTeamInput>({
mutationFn: (data) => {
return fetch(`/api/v2/billing/${teamId}/subscribe`, {
method: "post",
headers: { "Content-type": "application/json" },
body: JSON.stringify(data),
}).then((res) => res?.json());
},
onSuccess: (data) => {
if (data.status === SUCCESS_STATUS) {
onSuccess?.(data.data?.url);
} else {
onError?.();
}
},
onError: () => {
onError?.();
},
});
return mutation;
};
@@ -1,22 +0,0 @@
import React from "react";
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import PageWrapper from "@components/PageWrapper";
import { OAuthClientForm } from "@components/settings/organizations/platform/oauth-clients/OAuthClientForm";
export const CreateOAuthClient = () => {
const searchParams = useCompatSearchParams();
const clientId = searchParams?.get("clientId") || "";
return (
<div>
<OAuthClientForm clientId={clientId} />
</div>
);
};
CreateOAuthClient.getLayout = getLayout;
CreateOAuthClient.PageWrapper = PageWrapper;
export default CreateOAuthClient;
@@ -1,103 +0,0 @@
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import React from "react";
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
import { EmptyScreen, showToast } from "@calcom/ui";
import { Meta, Button } from "@calcom/ui";
import { Spinner } from "@calcom/ui/components/icon/Spinner";
import { useOAuthClients } from "@lib/hooks/settings/organizations/platform/oauth-clients/useOAuthClients";
import { useDeleteOAuthClient } from "@lib/hooks/settings/organizations/platform/oauth-clients/usePersistOAuthClient";
import PageWrapper from "@components/PageWrapper";
import { OAuthClientCard } from "@components/settings/organizations/platform/oauth-clients/OAuthClientCard";
const queryClient = new QueryClient();
export const OAuthClients = () => {
const { data, isLoading, refetch: refetchClients } = useOAuthClients();
const { mutateAsync, isPending: isDeleting } = useDeleteOAuthClient({
onSuccess: () => {
showToast("OAuth client deleted successfully", "success");
refetchClients();
},
});
const handleDelete = async (id: string) => {
await mutateAsync({ id: id });
};
const NewOAuthClientButton = () => {
const router = useRouter();
return (
<Button
onClick={(e) => {
e.preventDefault();
router.push("/settings/organizations/platform/oauth-clients/create");
}}
color="secondary"
StartIcon="plus">
Add
</Button>
);
};
if (isLoading) {
return <Spinner className="mx-auto mt-12 h-10 w-10" />;
}
return (
<QueryClientProvider client={queryClient}>
<div>
<Meta
title="OAuth Clients"
description="Connect your platform to cal.com with oAuth"
CTA={<NewOAuthClientButton />}
borderInShellHeader={true}
/>
<div>
{Array.isArray(data) && data.length ? (
<>
<div className="border-subtle rounded-b-lg border border-t-0">
{data.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={data.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="rounded-b-lg rounded-t-none border-t-0"
buttonRaw={<NewOAuthClientButton />}
/>
)}
</div>
</div>
</QueryClientProvider>
);
};
OAuthClients.getLayout = getLayout;
OAuthClients.PageWrapper = PageWrapper;
export default OAuthClients;
+100
View File
@@ -0,0 +1,100 @@
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
import { useState, useEffect } from "react";
import Shell from "@calcom/features/shell/Shell";
import { showToast } from "@calcom/ui";
import {
useOAuthClients,
useGetOAuthClientManagedUsers,
} from "@lib/hooks/settings/platform/oauth-clients/useOAuthClients";
import {
useDeleteOAuthClient,
useCheckTeamBilling,
} from "@lib/hooks/settings/platform/oauth-clients/usePersistOAuthClient";
import useMeQuery from "@lib/hooks/useMeQuery";
import PageWrapper from "@components/PageWrapper";
import { ManagedUserList } from "@components/settings/platform/dashboard/managed-user-list";
import { OAuthClientsList } from "@components/settings/platform/dashboard/oauth-clients-list";
import { PlatformPricing } from "@components/settings/platform/pricing/platform-pricing";
const queryClient = new QueryClient();
export default function Platform() {
const [initialClientId, setInitialClientId] = useState("");
const [initialClientName, setInitialClientName] = useState("");
const { data: user, isLoading } = useMeQuery();
const { data, isLoading: isOAuthClientLoading, refetch: refetchClients } = useOAuthClients();
const {
isLoading: isManagedUserLoading,
data: managedUserData,
refetch: refetchManagedUsers,
} = useGetOAuthClientManagedUsers(initialClientId);
const { data: userBillingData } = useCheckTeamBilling(user?.organizationId);
const isPlatformUser = user?.organization.isPlatform;
const isPaidUser = userBillingData?.valid;
const { mutateAsync, isPending: isDeleting } = useDeleteOAuthClient({
onSuccess: () => {
showToast("OAuth client deleted successfully", "success");
refetchClients();
refetchManagedUsers();
},
});
const handleDelete = async (id: string) => {
await mutateAsync({ id: id });
};
useEffect(() => {
setInitialClientId(data[0]?.id);
setInitialClientName(data[0]?.name);
}, [data]);
if (isLoading || isOAuthClientLoading) return <div className="m-5">Loading...</div>;
if (isPlatformUser && !isPaidUser) return <PlatformPricing teamId={user?.organizationId} />;
if (isPlatformUser) {
return (
<QueryClientProvider client={queryClient}>
<div>
<Shell
heading="Platform"
title="Platform"
hideHeadingOnMobile
withoutMain={false}
subtitle="Manage everything related to platform."
isPlatformUser={true}>
<OAuthClientsList oauthClients={data} isDeleting={isDeleting} handleDelete={handleDelete} />
<ManagedUserList
oauthClients={data}
managedUsers={managedUserData}
isManagedUserLoading={isManagedUserLoading}
initialClientName={initialClientName}
initialClientId={initialClientId}
handleChange={(id: string, name: string) => {
setInitialClientId(id);
setInitialClientName(name);
refetchManagedUsers();
}}
/>
</Shell>
</div>
</QueryClientProvider>
);
}
return (
<div>
<Shell hideHeadingOnMobile withoutMain={false} SidebarContainer={<></>}>
You are not subscribed to a Platform plan.
</Shell>
</div>
);
}
Platform.PageWrapper = PageWrapper;
@@ -0,0 +1,45 @@
"use client";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import { CreateANewPlatformForm } from "@calcom/features/ee/platform/components/index";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { WizardLayout, Meta, WizardLayoutAppDir } from "@calcom/ui";
import { getServerSideProps } from "@lib/settings/organizations/new/getServerSideProps";
import PageWrapper from "@components/PageWrapper";
const CreateNewOrganizationPage = () => {
const { t } = useLocale();
return (
<LicenseRequired>
<Meta
title={t("set_up_your_platform_organization")}
description={t("platform_organization_description")}
/>
<CreateANewPlatformForm />
</LicenseRequired>
);
};
const LayoutWrapper = (page: React.ReactElement) => {
return (
<WizardLayout currentStep={1} maxSteps={1}>
{page}
</WizardLayout>
);
};
export const LayoutWrapperAppDir = (page: React.ReactElement) => {
return (
<WizardLayoutAppDir currentStep={1} maxSteps={2}>
{page}
</WizardLayoutAppDir>
);
};
CreateNewOrganizationPage.getLayout = LayoutWrapper;
CreateNewOrganizationPage.PageWrapper = PageWrapper;
export default CreateNewOrganizationPage;
export { getServerSideProps };
@@ -0,0 +1,116 @@
import { useParams } from "next/navigation";
import { useRouter } from "next/navigation";
import Shell from "@calcom/features/shell/Shell";
import { PERMISSIONS_GROUPED_MAP } from "@calcom/platform-constants";
import { showToast } from "@calcom/ui";
import { useOAuthClient } from "@lib/hooks/settings/platform/oauth-clients/useOAuthClients";
import { useUpdateOAuthClient } from "@lib/hooks/settings/platform/oauth-clients/usePersistOAuthClient";
import PageWrapper from "@components/PageWrapper";
import type { FormValues } from "@components/settings/platform/oauth-clients/oauth-client-form";
import { OAuthClientForm as EditOAuthClientForm } from "@components/settings/platform/oauth-clients/oauth-client-form";
import {
hasAppsReadPermission,
hasAppsWritePermission,
hasBookingReadPermission,
hasBookingWritePermission,
hasEventTypeReadPermission,
hasEventTypeWritePermission,
hasProfileReadPermission,
hasProfileWritePermission,
hasScheduleReadPermission,
hasScheduleWritePermission,
} from "../../../../../../../../packages/platform/utils/permissions";
export default function EditOAuthClient() {
const router = useRouter();
const params = useParams<{ clientId: string }>();
const clientId = params?.clientId || "";
const { data, isFetched, isFetching, isError, refetch } = useOAuthClient(clientId);
const { mutateAsync: update, isPending: isUpdating } = useUpdateOAuthClient({
onSuccess: () => {
showToast("OAuth client updated successfully", "success");
refetch();
router.push("/settings/platform/");
},
onError: () => {
showToast("Internal server error, please try again later", "error");
},
clientId,
});
const onSubmit = (data: FormValues) => {
let userPermissions = 0;
const userRedirectUris = data.redirectUris.map((uri) => uri.uri).filter((uri) => !!uri);
Object.keys(PERMISSIONS_GROUPED_MAP).forEach((key) => {
const entity = key as keyof typeof PERMISSIONS_GROUPED_MAP;
const entityKey = PERMISSIONS_GROUPED_MAP[entity].key;
const read = PERMISSIONS_GROUPED_MAP[entity].read;
const write = PERMISSIONS_GROUPED_MAP[entity].write;
if (data[`${entityKey}Read`]) userPermissions |= read;
if (data[`${entityKey}Write`]) userPermissions |= write;
});
update({
name: data.name,
// logo: data.logo,
redirectUris: userRedirectUris,
bookingRedirectUri: data.bookingRedirectUri,
bookingCancelRedirectUri: data.bookingCancelRedirectUri,
bookingRescheduleRedirectUri: data.bookingRescheduleRedirectUri,
areEmailsEnabled: data.areEmailsEnabled,
});
};
return (
<div>
<Shell title="OAuth client updation form" isPlatformUser={true}>
<div className="m-2 md:mx-14 md:mx-5">
<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 client updation form
</h1>
<p className="text-default text-sm ltr:mr-4 rtl:ml-4">
This is the form to edit an existing OAuth client
</p>
</div>
</div>
{(!Boolean(clientId) || (isFetched && !data)) && <p>OAuth Client not found.</p>}
{isFetched && !!data && (
<EditOAuthClientForm
defaultValues={{
name: data?.name ?? "",
areEmailsEnabled: data.areEmailsEnabled ?? false,
redirectUris: data?.redirectUris?.map((uri) => ({ uri })) ?? [{ uri: "" }],
bookingRedirectUri: data?.bookingRedirectUri ?? "",
bookingCancelRedirectUri: data?.bookingCancelRedirectUri ?? "",
bookingRescheduleRedirectUri: data?.bookingRescheduleRedirectUri ?? "",
appsRead: hasAppsReadPermission(data?.permissions),
appsWrite: hasAppsWritePermission(data?.permissions),
bookingRead: hasBookingReadPermission(data?.permissions),
bookingWrite: hasBookingWritePermission(data?.permissions),
eventTypeRead: hasEventTypeReadPermission(data?.permissions),
eventTypeWrite: hasEventTypeWritePermission(data?.permissions),
profileRead: hasProfileReadPermission(data?.permissions),
profileWrite: hasProfileWritePermission(data?.permissions),
scheduleRead: hasScheduleReadPermission(data?.permissions),
scheduleWrite: hasScheduleWritePermission(data?.permissions),
}}
onSubmit={onSubmit}
isPending={isUpdating}
/>
)}
{isFetching && <p>Loading...</p>}
{isError && <p>Something went wrong.</p>}
</div>
</Shell>
</div>
);
}
EditOAuthClient.PageWrapper = PageWrapper;
@@ -0,0 +1,75 @@
import { useRouter } from "next/navigation";
import Shell from "@calcom/features/shell/Shell";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { PERMISSIONS_GROUPED_MAP } from "@calcom/platform-constants/permissions";
import { showToast } from "@calcom/ui";
import { useCreateOAuthClient } from "@lib/hooks/settings/platform/oauth-clients/usePersistOAuthClient";
import PageWrapper from "@components/PageWrapper";
import type { FormValues } from "@components/settings/platform/oauth-clients/oauth-client-form";
import { OAuthClientForm } from "@components/settings/platform/oauth-clients/oauth-client-form";
export default function CreateOAuthClient() {
const searchParams = useCompatSearchParams();
const router = useRouter();
const clientId = searchParams?.get("clientId") || "";
const { mutateAsync: save, isPending: isSaving } = useCreateOAuthClient({
onSuccess: () => {
showToast("OAuth client created successfully", "success");
router.push("/settings/platform/");
},
onError: () => {
showToast("Internal server error, please try again later", "error");
},
});
const onSubmit = (data: FormValues) => {
let userPermissions = 0;
const userRedirectUris = data.redirectUris.map((uri) => uri.uri).filter((uri) => !!uri);
Object.keys(PERMISSIONS_GROUPED_MAP).forEach((key) => {
const entity = key as keyof typeof PERMISSIONS_GROUPED_MAP;
const entityKey = PERMISSIONS_GROUPED_MAP[entity].key;
const read = PERMISSIONS_GROUPED_MAP[entity].read;
const write = PERMISSIONS_GROUPED_MAP[entity].write;
if (data[`${entityKey}Read`]) userPermissions |= read;
if (data[`${entityKey}Write`]) userPermissions |= write;
});
save({
name: data.name,
permissions: userPermissions,
// logo: data.logo,
redirectUris: userRedirectUris,
bookingRedirectUri: data.bookingRedirectUri,
bookingCancelRedirectUri: data.bookingCancelRedirectUri,
bookingRescheduleRedirectUri: data.bookingRescheduleRedirectUri,
areEmailsEnabled: data.areEmailsEnabled,
});
};
return (
<div>
<Shell title={`OAuth client ${!!clientId ? "updation" : "creation"} form`} isPlatformUser={true}>
<div className="m-2 md:mx-14 md:mx-5">
<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 client creation form
</h1>
<p className="text-default text-sm ltr:mr-4 rtl:ml-4">
This is the form to create a new OAuth client
</p>
</div>
</div>
<OAuthClientForm isPending={isSaving} onSubmit={onSubmit} />
</div>
</Shell>
</div>
);
}
CreateOAuthClient.PageWrapper = PageWrapper;
@@ -2038,12 +2038,16 @@
"organization_banner_description": "Create an environments where your teams can create shared apps, workflows and event types with round-robin and collective scheduling.",
"organization_banner_title": "Manage organizations with multiple teams",
"set_up_your_organization": "Set up your organization",
"set_up_your_platform_organization": "Set up your platform",
"organizations_description": "Organizations are shared environments where teams can create shared event types, apps, workflows and more.",
"platform_organization_description": "Cal.com Platform lets you integrate scheduling effortlessly into your app using platform apis and atoms.",
"must_enter_organization_name": "Must enter an organization name",
"must_enter_organization_admin_email": "Must enter your organization email address",
"admin_email": "Your organization email address",
"platform_admin_email": "Your admin email address",
"admin_username": "Administrator's username",
"organization_name": "Organization name",
"platform_name": "Platform name",
"organization_url": "Organization URL",
"organization_verify_header": "Verify your organization email",
"organization_verify_email_body": "Please use the code below to verify your email address to continue setting up your organization.",
@@ -32,7 +32,12 @@ export const CreateANewOrganizationForm = () => {
return <CreateANewOrganizationFormChild session={session} />;
};
const CreateANewOrganizationFormChild = ({ session }: { session: Ensure<SessionContextValue, "data"> }) => {
const CreateANewOrganizationFormChild = ({
session,
}: {
session: Ensure<SessionContextValue, "data">;
isPlatformOrg?: boolean;
}) => {
const { t } = useLocale();
const router = useRouter();
const telemetry = useTelemetry();
@@ -282,13 +287,13 @@ const CreateANewOrganizationFormChild = ({ session }: { session: Ensure<SessionC
);
};
function deriveSlugFromEmail(email: string) {
export function deriveSlugFromEmail(email: string) {
const domain = extractDomainFromEmail(email);
return domain;
}
function deriveOrgNameFromEmail(email: string) {
export function deriveOrgNameFromEmail(email: string) {
const domain = extractDomainFromEmail(email);
return domain.charAt(0).toUpperCase() + domain.slice(1);
@@ -0,0 +1,175 @@
import type { SessionContextValue } from "next-auth/react";
import { useSession, signIn } from "next-auth/react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { deriveOrgNameFromEmail } from "@calcom/ee/organizations/components/CreateANewOrganizationForm";
import { deriveSlugFromEmail } from "@calcom/ee/organizations/components/CreateANewOrganizationForm";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import slugify from "@calcom/lib/slugify";
import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import { UserPermissionRole } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
import type { Ensure } from "@calcom/types/utils";
import { Alert, Form, TextField, Button } from "@calcom/ui";
export const CreateANewPlatformForm = () => {
const session = useSession();
if (!session.data) {
return null;
}
return <CreateANewPlatformFormChild session={session} />;
};
const CreateANewPlatformFormChild = ({ session }: { session: Ensure<SessionContextValue, "data"> }) => {
const { t } = useLocale();
const router = useRouter();
const telemetry = useTelemetry();
const [serverErrorMessage, setServerErrorMessage] = useState<string | null>(null);
const isAdmin = session.data.user.role === UserPermissionRole.ADMIN;
const defaultOrgOwnerEmail = session.data.user.email ?? "";
const newOrganizationFormMethods = useForm<{
name: string;
slug: string;
orgOwnerEmail: string;
isPlatform: boolean;
}>({
defaultValues: {
slug: !isAdmin ? deriveSlugFromEmail(defaultOrgOwnerEmail) : undefined,
orgOwnerEmail: !isAdmin ? defaultOrgOwnerEmail : undefined,
name: !isAdmin ? deriveOrgNameFromEmail(defaultOrgOwnerEmail) : undefined,
isPlatform: true,
},
});
const createOrganizationMutation = trpc.viewer.organizations.create.useMutation({
onSuccess: async (data) => {
telemetry.event(telemetryEventTypes.org_created);
// This is necessary so that server token has the updated upId
await session.update({
upId: data.upId,
});
if (isAdmin && data.userId !== session.data?.user.id) {
// Impersonate the user chosen as the organization owner(if the admin user isn't the owner himself), so that admin can now configure the organisation on his behalf.
// He won't need to have access to the org directly in this way.
signIn("impersonation-auth", {
username: data.email,
callbackUrl: `/settings/platform`,
});
}
router.push("/settings/platform");
},
onError: (err) => {
if (err.message === "organization_url_taken") {
newOrganizationFormMethods.setError("slug", { type: "custom", message: t("url_taken") });
} else if (err.message === "domain_taken_team" || err.message === "domain_taken_project") {
newOrganizationFormMethods.setError("slug", {
type: "custom",
message: t("problem_registering_domain"),
});
} else {
setServerErrorMessage(err.message);
}
},
});
return (
<>
<Form
form={newOrganizationFormMethods}
className="space-y-5"
id="createOrg"
handleSubmit={(v) => {
if (!createOrganizationMutation.isPending) {
setServerErrorMessage(null);
createOrganizationMutation.mutate({
...v,
slug: `${v.name.toLocaleLowerCase()}_platform`,
});
}
}}>
<div>
{serverErrorMessage && (
<div className="mb-4">
<Alert severity="error" message={serverErrorMessage} />
</div>
)}
<Controller
name="orgOwnerEmail"
control={newOrganizationFormMethods.control}
rules={{
required: t("must_enter_organization_admin_email"),
}}
render={({ field: { value } }) => (
<div className="flex">
<TextField
containerClassName="w-full"
placeholder="john@acme.com"
name="orgOwnerEmail"
disabled={!isAdmin}
label={t("platform_admin_email")}
defaultValue={value}
onChange={(e) => {
const email = e?.target.value;
const slug = deriveSlugFromEmail(email);
newOrganizationFormMethods.setValue("orgOwnerEmail", email.trim());
if (newOrganizationFormMethods.getValues("slug") === "") {
newOrganizationFormMethods.setValue("slug", slug);
}
newOrganizationFormMethods.setValue("name", deriveOrgNameFromEmail(email));
}}
autoComplete="off"
/>
</div>
)}
/>
</div>
<div>
<Controller
name="name"
control={newOrganizationFormMethods.control}
defaultValue=""
rules={{
required: t("must_enter_organization_name"),
}}
render={({ field: { value } }) => (
<>
<TextField
className="mt-2"
placeholder="Acme"
name="name"
label={t("platform_name")}
defaultValue={value}
onChange={(e) => {
newOrganizationFormMethods.setValue("name", e?.target.value.trim());
if (newOrganizationFormMethods.formState.touchedFields["slug"] === undefined) {
newOrganizationFormMethods.setValue("slug", slugify(e?.target.value));
}
}}
autoComplete="off"
/>
</>
)}
/>
</div>
<div className="flex space-x-2 rtl:space-x-reverse">
<Button
disabled={
newOrganizationFormMethods.formState.isSubmitting || createOrganizationMutation.isPending
}
color="primary"
EndIcon="arrow-right"
type="submit"
form="createOrg"
className="w-full justify-center">
{t("continue")}
</Button>
</div>
<div />
</Form>
</>
);
};
@@ -0,0 +1 @@
export { CreateANewPlatformForm } from "./CreateANewPlatformForm";
+128 -76
View File
@@ -289,7 +289,7 @@ const Layout = (props: LayoutProps) => {
{props.SidebarContainer ? (
cloneElement(props.SidebarContainer, { bannersHeight })
) : (
<SideBarContainer bannersHeight={bannersHeight} />
<SideBarContainer isPlatformUser={props.isPlatformUser} bannersHeight={bannersHeight} />
)}
<div className="flex w-0 flex-1 flex-col">
<MainContainer {...props} />
@@ -330,6 +330,7 @@ export type LayoutProps = {
afterHeading?: ReactNode;
smallHeading?: boolean;
hideHeadingOnMobile?: boolean;
isPlatformUser?: boolean;
};
const useAppTheme = () => {
@@ -648,27 +649,66 @@ const navigation: NavigationItemType[] = [
},
];
const moreSeparatorIndex = navigation.findIndex((item) => item.name === MORE_SEPARATOR_NAME);
// We create all needed navigation items for the different use cases
const { desktopNavigationItems, mobileNavigationBottomItems, mobileNavigationMoreItems } = navigation.reduce<
Record<string, NavigationItemType[]>
>(
(items, item, index) => {
// We filter out the "more" separator in` desktop navigation
if (item.name !== MORE_SEPARATOR_NAME) items.desktopNavigationItems.push(item);
// Items for mobile bottom navigation
if (index < moreSeparatorIndex + 1 && !item.onlyDesktop) {
items.mobileNavigationBottomItems.push(item);
} // Items for the "more" menu in mobile navigation
else {
items.mobileNavigationMoreItems.push(item);
}
return items;
const platformNavigation: NavigationItemType[] = [
{
name: "Dashboard",
href: "/settings/platform/",
icon: "layout-dashboard",
},
{ desktopNavigationItems: [], mobileNavigationBottomItems: [], mobileNavigationMoreItems: [] }
);
{
name: "Documentation",
href: "https://docs.cal.com/docs/platform",
icon: "bar-chart",
target: "_blank",
},
{
name: "API reference",
href: "https://api.cal.com/v2/docs#/",
icon: "terminal",
target: "_blank",
},
{
name: "Atoms",
href: "https://docs.cal.com/docs/platform#atoms",
icon: "atom",
target: "_blank",
},
{
name: MORE_SEPARATOR_NAME,
href: "https://docs.cal.com/docs/platform/faq",
icon: "ellipsis",
target: "_blank",
},
];
const getDesktopNavigationItems = (isPlatformNavigation = false) => {
const navigationType = !isPlatformNavigation ? navigation : platformNavigation;
const moreSeparatorIndex = navigationType.findIndex((item) => item.name === MORE_SEPARATOR_NAME);
const { desktopNavigationItems, mobileNavigationBottomItems, mobileNavigationMoreItems } = (
!isPlatformNavigation ? navigation : platformNavigation
).reduce<Record<string, NavigationItemType[]>>(
(items, item, index) => {
// We filter out the "more" separator in` desktop navigation
if (item.name !== MORE_SEPARATOR_NAME) items.desktopNavigationItems.push(item);
// Items for mobile bottom navigation
if (index < moreSeparatorIndex + 1 && !item.onlyDesktop) {
items.mobileNavigationBottomItems.push(item);
} // Items for the "more" menu in mobile navigation
else {
items.mobileNavigationMoreItems.push(item);
}
return items;
},
{ desktopNavigationItems: [], mobileNavigationBottomItems: [], mobileNavigationMoreItems: [] }
);
return { desktopNavigationItems, mobileNavigationBottomItems, mobileNavigationMoreItems };
};
const Navigation = ({ isPlatformNavigation = false }: { isPlatformNavigation?: boolean }) => {
const { desktopNavigationItems } = getDesktopNavigationItems(isPlatformNavigation);
const Navigation = () => {
return (
<nav className="mt-2 flex-1 md:px-2 lg:mt-4 lg:px-0">
{desktopNavigationItems.map((item) => (
@@ -712,6 +752,7 @@ const NavigationItem: React.FC<{
data-test-id={item.name}
href={item.href}
aria-label={t(item.name)}
target={item.target}
className={classNames(
"todesktop:py-[7px] text-default group flex items-center rounded-md px-2 py-1.5 text-sm font-medium transition",
item.child ? `[&[aria-current='page']]:!bg-transparent` : `[&[aria-current='page']]:bg-emphasis`,
@@ -752,14 +793,15 @@ const NavigationItem: React.FC<{
);
};
function MobileNavigationContainer() {
function MobileNavigationContainer({ isPlatformNavigation = false }: { isPlatformNavigation?: boolean }) {
const { status } = useSession();
if (status !== "authenticated") return null;
return <MobileNavigation />;
return <MobileNavigation isPlatformNavigation={isPlatformNavigation} />;
}
const MobileNavigation = () => {
const MobileNavigation = ({ isPlatformNavigation = false }: { isPlatformNavigation?: boolean }) => {
const isEmbed = useIsEmbed();
const { mobileNavigationBottomItems } = getDesktopNavigationItems(isPlatformNavigation);
return (
<>
@@ -794,6 +836,7 @@ const MobileNavigationItem: React.FC<{
<Link
key={item.name}
href={item.href}
target={item.target}
className="[&[aria-current='page']]:text-emphasis hover:text-default text-muted relative my-2 min-w-0 flex-1 overflow-hidden rounded-md !bg-transparent p-1 text-center text-xs font-medium focus:z-10 sm:text-sm"
aria-current={current ? "page" : undefined}>
{item.badge && <div className="absolute right-1 top-1">{item.badge}</div>}
@@ -837,24 +880,26 @@ const MobileNavigationMoreItem: React.FC<{
type SideBarContainerProps = {
bannersHeight: number;
isPlatformUser?: boolean;
};
type SideBarProps = {
bannersHeight: number;
user?: UserAuth | null;
isPlatformUser?: boolean;
};
function SideBarContainer({ bannersHeight }: SideBarContainerProps) {
function SideBarContainer({ bannersHeight, isPlatformUser = false }: SideBarContainerProps) {
const { status, data } = useSession();
// Make sure that Sidebar is rendered optimistically so that a refresh of pages when logged in have SideBar from the beginning.
// This improves the experience of refresh on app store pages(when logged in) which are SSG.
// Though when logged out, app store pages would temporarily show SideBar until session status is confirmed.
if (status !== "loading" && status !== "authenticated") return null;
return <SideBar bannersHeight={bannersHeight} user={data?.user} />;
return <SideBar isPlatformUser={isPlatformUser} bannersHeight={bannersHeight} user={data?.user} />;
}
function SideBar({ bannersHeight, user }: SideBarProps) {
function SideBar({ bannersHeight, user, isPlatformUser = false }: SideBarProps) {
const { t, isLocaleReady } = useLocale();
const orgBranding = useOrgBranding();
@@ -948,54 +993,54 @@ function SideBar({ bannersHeight, user }: SideBarProps) {
<KBarTrigger />
</div>
</header>
{/* logo icon for tablet */}
<Link href="/event-types" className="text-center md:inline lg:hidden">
<Logo small icon />
</Link>
<Navigation />
<Navigation isPlatformNavigation={isPlatformUser} />
</div>
<div>
<Tips />
{bottomNavItems.map((item, index) => (
<Tooltip side="right" content={t(item.name)} className="lg:hidden" key={item.name}>
<ButtonOrLink
id={item.name}
href={item.href || undefined}
aria-label={t(item.name)}
target={item.target}
className={classNames(
"text-left",
"[&[aria-current='page']]:bg-emphasis text-default justify-right group flex items-center rounded-md px-2 py-1.5 text-sm font-medium transition",
"[&[aria-current='page']]:text-emphasis mt-0.5 w-full text-sm",
isLocaleReady ? "hover:bg-emphasis hover:text-emphasis" : "",
index === 0 && "mt-3"
)}
onClick={item.onClick}>
{!!item.icon && (
<Icon
name={item.icon}
className={classNames(
"h-4 w-4 flex-shrink-0 [&[aria-current='page']]:text-inherit",
"me-3 md:mx-auto lg:ltr:mr-2 lg:rtl:ml-2"
)}
aria-hidden="true"
/>
)}
{isLocaleReady ? (
<span className="hidden w-full justify-between lg:flex">
<div className="flex">{t(item.name)}</div>
</span>
) : (
<SkeletonText className="h-[20px] w-full" />
)}
</ButtonOrLink>
</Tooltip>
))}
{!IS_VISUAL_REGRESSION_TESTING && <Credits />}
</div>
{!isPlatformUser && (
<div>
<Tips />
{bottomNavItems.map((item, index) => (
<Tooltip side="right" content={t(item.name)} className="lg:hidden" key={item.name}>
<ButtonOrLink
id={item.name}
href={item.href || undefined}
aria-label={t(item.name)}
target={item.target}
className={classNames(
"text-left",
"[&[aria-current='page']]:bg-emphasis text-default justify-right group flex items-center rounded-md px-2 py-1.5 text-sm font-medium transition",
"[&[aria-current='page']]:text-emphasis mt-0.5 w-full text-sm",
isLocaleReady ? "hover:bg-emphasis hover:text-emphasis" : "",
index === 0 && "mt-3"
)}
onClick={item.onClick}>
{!!item.icon && (
<Icon
name={item.icon}
className={classNames(
"h-4 w-4 flex-shrink-0 [&[aria-current='page']]:text-inherit",
"me-3 md:mx-auto lg:ltr:mr-2 lg:rtl:ml-2"
)}
aria-hidden="true"
/>
)}
{isLocaleReady ? (
<span className="hidden w-full justify-between lg:flex">
<div className="flex">{t(item.name)}</div>
</span>
) : (
<SkeletonText className="h-[20px] w-full" />
)}
</ButtonOrLink>
</Tooltip>
))}
{!IS_VISUAL_REGRESSION_TESTING && <Credits />}
</div>
)}
</aside>
</div>
);
@@ -1075,7 +1120,10 @@ export function ShellMain(props: LayoutProps) {
}
function MainContainer({
MobileNavigationContainer: MobileNavigationContainerProp = <MobileNavigationContainer />,
isPlatformUser,
MobileNavigationContainer: MobileNavigationContainerProp = (
<MobileNavigationContainer isPlatformNavigation={isPlatformUser} />
),
TopNavContainer: TopNavContainerProp = <TopNavContainer />,
...props
}: LayoutProps) {
@@ -1128,13 +1176,17 @@ function TopNav() {
);
}
export const MobileNavigationMoreItems = () => (
<ul className="border-subtle mt-2 rounded-md border">
{mobileNavigationMoreItems.map((item) => (
<MobileNavigationMoreItem key={item.name} item={item} />
))}
</ul>
);
export const MobileNavigationMoreItems = () => {
const { mobileNavigationMoreItems } = getDesktopNavigationItems();
return (
<ul className="border-subtle mt-2 rounded-md border">
{mobileNavigationMoreItems.map((item) => (
<MobileNavigationMoreItem key={item.name} item={item} />
))}
</ul>
);
};
function ProfileDropdown() {
const { update, data: sessionData } = useSession();
@@ -131,6 +131,7 @@ export class OrganizationRepository {
orgPricePerSeat: orgData.pricePerSeat,
isPlatform: orgData.isPlatform,
},
isPlatform: orgData.isPlatform,
},
});
}
+6
View File
@@ -0,0 +1,6 @@
import { IsString } from "class-validator";
export class SubscribeTeamInput {
@IsString()
plan!: string;
}
+1
View File
@@ -5,3 +5,4 @@ export * from "./slots";
export * from "./calendars";
export * from "./schedules";
export * from "./bookings";
export * from "./billing";
@@ -63,11 +63,11 @@ export const createHandler = async ({ input, ctx }: CreateOptions) => {
const IS_USER_ADMIN = loggedInUser.role === UserPermissionRole.ADMIN;
if (!ORG_SELF_SERVE_ENABLED && !IS_USER_ADMIN) {
if (!ORG_SELF_SERVE_ENABLED && !IS_USER_ADMIN && !isPlatform) {
throw new TRPCError({ code: "FORBIDDEN", message: "Only admins can create organizations" });
}
if (!IS_USER_ADMIN && loggedInUser.email !== orgOwnerEmail) {
if (!IS_USER_ADMIN && loggedInUser.email !== orgOwnerEmail && !isPlatform) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You can only create organization where you are the owner",
@@ -76,7 +76,7 @@ export const createHandler = async ({ input, ctx }: CreateOptions) => {
const publishedTeams = loggedInUser.teams.filter((team) => !!team.team.slug);
if (!IS_USER_ADMIN && publishedTeams.length < ORG_MINIMUM_PUBLISHED_TEAMS_SELF_SERVE) {
if (!IS_USER_ADMIN && publishedTeams.length < ORG_MINIMUM_PUBLISHED_TEAMS_SELF_SERVE && !isPlatform) {
throw new TRPCError({ code: "FORBIDDEN", message: "You need to have minimum published teams." });
}
@@ -21,6 +21,7 @@ export function EmptyScreen({
className,
iconClassName,
iconWrapperClassName,
limitWidth = true,
}: {
Icon?: IconName;
customIcon?: React.ReactElement;
@@ -34,6 +35,7 @@ export function EmptyScreen({
dashedBorder?: boolean;
iconWrapperClassName?: string;
iconClassName?: string;
limitWidth?: boolean;
} & React.HTMLAttributes<HTMLDivElement>) {
return (
<>
@@ -62,7 +64,7 @@ export function EmptyScreen({
</div>
)}
{!customIcon ? null : <>{customIcon}</>}
<div className="flex max-w-[420px] flex-col items-center">
<div className={`flex ${limitWidth ? "max-w-[420px]" : ""} flex-col items-center`}>
<h2
className={classNames(
"text-semibold font-cal text-emphasis text-center text-xl",