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:
co-authored by
Morgan
exception
Carina Wollendorfer
Keith Williams
Peer Richelsen
parent
6d8fbfc49a
commit
c2a07e221d
@@ -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,5 +1,6 @@
|
||||
export enum PlatformPlan {
|
||||
STARTER = "STARTER",
|
||||
ESSENTIALS = "ESSENTIALS",
|
||||
SCALE = "SCALE",
|
||||
ENTERPRISE = "ENTERPRISE",
|
||||
}
|
||||
|
||||
+38
@@ -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>
|
||||
);
|
||||
};
|
||||
+3
-3
@@ -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> : <> 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
|
||||
+30
-140
@@ -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">•</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>
|
||||
);
|
||||
};
|
||||
+29
@@ -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 };
|
||||
};
|
||||
+63
-2
@@ -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;
|
||||
@@ -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";
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { IsString } from "class-validator";
|
||||
|
||||
export class SubscribeTeamInput {
|
||||
@IsString()
|
||||
plan!: string;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user