From c2a07e221d5c182efdef74003fb1fb02ac8961c0 Mon Sep 17 00:00:00 2001 From: Rajiv Sahal Date: Thu, 9 May 2024 22:31:10 +0530 Subject: [PATCH] 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 Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com> Co-authored-by: Keith Williams Co-authored-by: Peer Richelsen --- .../billing/services/billing.service.ts | 4 +- apps/api/v2/src/modules/billing/types.ts | 1 + .../oauth-clients/oauth-clients.controller.ts | 38 ++++ .../dashboard/managed-user-header/index.tsx | 33 +++ .../dashboard/managed-user-list/index.tsx | 39 ++++ .../dashboard/managed-user-table/index.tsx | 60 ++++++ .../dashboard/oauth-client-dropdown/index.tsx | 50 +++++ .../dashboard/oauth-clients-list/index.tsx | 81 +++++++ .../oauth-clients/OAuthClientCard.tsx | 6 +- .../oauth-client-form/index.tsx} | 170 +++------------ .../settings/platform/platformUtils.ts | 57 +++++ .../platform/pricing/billing-card/index.tsx | 54 +++++ .../pricing/platform-pricing/index.tsx | 52 +++++ .../platform/oauth-clients/useOAuthClients.ts | 29 +++ .../oauth-clients/usePersistOAuthClient.ts | 65 +++++- .../platform/oauth-clients/create.tsx | 22 -- .../platform/oauth-clients/index.tsx | 103 --------- apps/web/pages/settings/platform/index.tsx | 100 +++++++++ .../web/pages/settings/platform/new/index.tsx | 45 ++++ .../oauth-clients/[clientId]/edit/index.tsx | 116 ++++++++++ .../platform/oauth-clients/create.tsx | 75 +++++++ apps/web/public/static/locales/en/common.json | 4 + .../components/CreateANewOrganizationForm.tsx | 11 +- .../components/CreateANewPlatformForm.tsx | 175 +++++++++++++++ .../features/ee/platform/components/index.ts | 1 + packages/features/shell/Shell.tsx | 204 +++++++++++------- .../lib/server/repository/organization.ts | 1 + packages/platform/types/billing.ts | 6 + packages/platform/types/index.ts | 1 + .../viewer/organizations/create.handler.ts | 6 +- .../components/empty-screen/EmptyScreen.tsx | 4 +- 31 files changed, 1258 insertions(+), 355 deletions(-) create mode 100644 apps/web/components/settings/platform/dashboard/managed-user-header/index.tsx create mode 100644 apps/web/components/settings/platform/dashboard/managed-user-list/index.tsx create mode 100644 apps/web/components/settings/platform/dashboard/managed-user-table/index.tsx create mode 100644 apps/web/components/settings/platform/dashboard/oauth-client-dropdown/index.tsx create mode 100644 apps/web/components/settings/platform/dashboard/oauth-clients-list/index.tsx rename apps/web/components/settings/{organizations => }/platform/oauth-clients/OAuthClientCard.tsx (95%) rename apps/web/components/settings/{organizations/platform/oauth-clients/OAuthClientForm.tsx => platform/oauth-clients/oauth-client-form/index.tsx} (56%) create mode 100644 apps/web/components/settings/platform/platformUtils.ts create mode 100644 apps/web/components/settings/platform/pricing/billing-card/index.tsx create mode 100644 apps/web/components/settings/platform/pricing/platform-pricing/index.tsx rename apps/web/lib/hooks/settings/{organizations => }/platform/oauth-clients/useOAuthClients.ts (63%) rename apps/web/lib/hooks/settings/{organizations => }/platform/oauth-clients/usePersistOAuthClient.ts (61%) delete mode 100644 apps/web/pages/settings/organizations/platform/oauth-clients/create.tsx delete mode 100644 apps/web/pages/settings/organizations/platform/oauth-clients/index.tsx create mode 100644 apps/web/pages/settings/platform/index.tsx create mode 100644 apps/web/pages/settings/platform/new/index.tsx create mode 100644 apps/web/pages/settings/platform/oauth-clients/[clientId]/edit/index.tsx create mode 100644 apps/web/pages/settings/platform/oauth-clients/create.tsx create mode 100644 packages/features/ee/platform/components/CreateANewPlatformForm.tsx create mode 100644 packages/features/ee/platform/components/index.ts create mode 100644 packages/platform/types/billing.ts diff --git a/apps/api/v2/src/modules/billing/services/billing.service.ts b/apps/api/v2/src/modules/billing/services/billing.service.ts index 203d734c12..445c9208e7 100644 --- a/apps/api/v2/src/modules/billing/services/billing.service.ts +++ b/apps/api/v2/src/modules/billing/services/billing.service.ts @@ -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(), diff --git a/apps/api/v2/src/modules/billing/types.ts b/apps/api/v2/src/modules/billing/types.ts index ff713b99f9..3cce957fa8 100644 --- a/apps/api/v2/src/modules/billing/types.ts +++ b/apps/api/v2/src/modules/billing/types.ts @@ -1,5 +1,6 @@ export enum PlatformPlan { STARTER = "STARTER", ESSENTIALS = "ESSENTIALS", + SCALE = "SCALE", ENTERPRISE = "ENTERPRISE", } diff --git a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-clients/oauth-clients.controller.ts b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-clients/oauth-clients.controller.ts index 303cb6e71c..93fe11003e 100644 --- a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-clients/oauth-clients.controller.ts +++ b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-clients/oauth-clients.controller.ts @@ -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 { + 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, + }; + } } diff --git a/apps/web/components/settings/platform/dashboard/managed-user-header/index.tsx b/apps/web/components/settings/platform/dashboard/managed-user-header/index.tsx new file mode 100644 index 0000000000..40a7ece0c1 --- /dev/null +++ b/apps/web/components/settings/platform/dashboard/managed-user-header/index.tsx @@ -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 ( +
+
+

+ Managed Users +

+

+ See all the managed users created by your OAuth client. +

+
+ +
+ ); +}; diff --git a/apps/web/components/settings/platform/dashboard/managed-user-list/index.tsx b/apps/web/components/settings/platform/dashboard/managed-user-list/index.tsx new file mode 100644 index 0000000000..341e19a073 --- /dev/null +++ b/apps/web/components/settings/platform/dashboard/managed-user-list/index.tsx @@ -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 ( +
+ + +
+ ); +}; diff --git a/apps/web/components/settings/platform/dashboard/managed-user-table/index.tsx b/apps/web/components/settings/platform/dashboard/managed-user-table/index.tsx new file mode 100644 index 0000000000..af32a422e2 --- /dev/null +++ b/apps/web/components/settings/platform/dashboard/managed-user-table/index.tsx @@ -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 ( +
+ {showUsers ? ( + <> + + + + + + + + {managedUsers.map((user) => { + return ( + + + + + + ); + })} +
IdUsernameEmail
{user.id}{user.username} + {user.email} +
+ + ) : ( + + )} +
+ ); +}; diff --git a/apps/web/components/settings/platform/dashboard/oauth-client-dropdown/index.tsx b/apps/web/components/settings/platform/dashboard/oauth-client-dropdown/index.tsx new file mode 100644 index 0000000000..50311691e6 --- /dev/null +++ b/apps/web/components/settings/platform/dashboard/oauth-client-dropdown/index.tsx @@ -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 ( +
+ {Array.isArray(oauthClients) && oauthClients.length > 0 ? ( + + + + + + {oauthClients.map((client) => { + return ( +
+ {initialClientName !== client.name ? ( + + handleChange(client.id, client.name)}> + {client.name} + + + ) : ( + <> + )} +
+ ); + })} +
+
+ ) : null} +
+ ); +}; diff --git a/apps/web/components/settings/platform/dashboard/oauth-clients-list/index.tsx b/apps/web/components/settings/platform/dashboard/oauth-clients-list/index.tsx new file mode 100644 index 0000000000..27c4e0e5bb --- /dev/null +++ b/apps/web/components/settings/platform/dashboard/oauth-clients-list/index.tsx @@ -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; +}; + +export const OAuthClientsList = ({ oauthClients, isDeleting, handleDelete }: OAuthClientsListProps) => { + return ( +
+
+
+

+ OAuth Clients +

+

+ Connect your platform to cal.com with OAuth +

+
+
+ +
+
+ {Array.isArray(oauthClients) && oauthClients.length ? ( + <> +
+ {oauthClients.map((client, index) => { + return ( + + ); + })} +
+ + ) : ( + } + /> + )} +
+ ); +}; + +const NewOAuthClientButton = ({ redirectLink, label }: { redirectLink: string; label?: string }) => { + const router = useRouter(); + + return ( + + ); +}; diff --git a/apps/web/components/settings/organizations/platform/oauth-clients/OAuthClientCard.tsx b/apps/web/components/settings/platform/oauth-clients/OAuthClientCard.tsx similarity index 95% rename from apps/web/components/settings/organizations/platform/oauth-clients/OAuthClientCard.tsx rename to apps/web/components/settings/platform/oauth-clients/OAuthClientCard.tsx index 0d7fa83532..d57f254f55 100644 --- a/apps/web/components/settings/organizations/platform/oauth-clients/OAuthClientCard.tsx +++ b/apps/web/components/settings/platform/oauth-clients/OAuthClientCard.tsx @@ -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 = ({
Permissions: -
{clientPermissions}
+ {permissions ?
{clientPermissions}
: <> Disabled}
Redirect uris: @@ -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
@@ -300,7 +194,7 @@ export const OAuthClientForm: FC<{ clientId?: string }> = ({ clientId }) => { label="Booking redirect uri" className="w-[100%]" {...register("bookingRedirectUri")} - disabled={disabledForm} + disabled={isFormDisabled} /> @@ -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} /> @@ -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} /> @@ -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} /> -
- -

Permissions

-
-
{permissionsCheckboxes}
- - diff --git a/apps/web/components/settings/platform/platformUtils.ts b/apps/web/components/settings/platform/platformUtils.ts new file mode 100644 index 0000000000..279ff36972 --- /dev/null +++ b/apps/web/components/settings/platform/platformUtils.ts @@ -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"], + }, +]; diff --git a/apps/web/components/settings/platform/pricing/billing-card/index.tsx b/apps/web/components/settings/platform/pricing/billing-card/index.tsx new file mode 100644 index 0000000000..6c4260df2e --- /dev/null +++ b/apps/web/components/settings/platform/pricing/billing-card/index.tsx @@ -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 ( +
+
+

{plan}

+

{description}

+

+ {pricing && ( + <> + US${pricing} per month + + )} +

+
+
+ +
+
+

This includes:

+ {includes.map((feature) => { + return ( +
+
+
{feature}
+
+ ); + })} +
+
+ ); +}; diff --git a/apps/web/components/settings/platform/pricing/platform-pricing/index.tsx b/apps/web/components/settings/platform/pricing/platform-pricing/index.tsx new file mode 100644 index 0000000000..11514a3762 --- /dev/null +++ b/apps/web/components/settings/platform/pricing/platform-pricing/index.tsx @@ -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 ( +
+
+

Subscribe to Platform

+
+
+ {platformPlans.map((plan) => { + return ( +
+ { + !!teamId && + (plan.plan === "Enterprise" + ? router.push("https://i.cal.com/sales/exploration") + : mutateAsync({ plan: plan.plan.toLocaleUpperCase() })); + }} + /> +
+ ); + })} +
+
+ ); +}; diff --git a/apps/web/lib/hooks/settings/organizations/platform/oauth-clients/useOAuthClients.ts b/apps/web/lib/hooks/settings/platform/oauth-clients/useOAuthClients.ts similarity index 63% rename from apps/web/lib/hooks/settings/organizations/platform/oauth-clients/useOAuthClients.ts rename to apps/web/lib/hooks/settings/platform/oauth-clients/useOAuthClients.ts index bef11e9342..3184396972 100644 --- a/apps/web/lib/hooks/settings/organizations/platform/oauth-clients/useOAuthClients.ts +++ b/apps/web/lib/hooks/settings/platform/oauth-clients/useOAuthClients.ts @@ -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>({ 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>({ + 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 }; +}; diff --git a/apps/web/lib/hooks/settings/organizations/platform/oauth-clients/usePersistOAuthClient.ts b/apps/web/lib/hooks/settings/platform/oauth-clients/usePersistOAuthClient.ts similarity index 61% rename from apps/web/lib/hooks/settings/organizations/platform/oauth-clients/usePersistOAuthClient.ts rename to apps/web/lib/hooks/settings/platform/oauth-clients/usePersistOAuthClient.ts index eadc889a77..5e3d3afa52 100644 --- a/apps/web/lib/hooks/settings/organizations/platform/oauth-clients/usePersistOAuthClient.ts +++ b/apps/web/lib/hooks/settings/platform/oauth-clients/usePersistOAuthClient.ts @@ -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, 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; +}; diff --git a/apps/web/pages/settings/organizations/platform/oauth-clients/create.tsx b/apps/web/pages/settings/organizations/platform/oauth-clients/create.tsx deleted file mode 100644 index 3d26f40440..0000000000 --- a/apps/web/pages/settings/organizations/platform/oauth-clients/create.tsx +++ /dev/null @@ -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 ( -
- -
- ); -}; - -CreateOAuthClient.getLayout = getLayout; -CreateOAuthClient.PageWrapper = PageWrapper; - -export default CreateOAuthClient; diff --git a/apps/web/pages/settings/organizations/platform/oauth-clients/index.tsx b/apps/web/pages/settings/organizations/platform/oauth-clients/index.tsx deleted file mode 100644 index 8daef7c847..0000000000 --- a/apps/web/pages/settings/organizations/platform/oauth-clients/index.tsx +++ /dev/null @@ -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 ( - - ); - }; - - if (isLoading) { - return ; - } - - return ( - -
- } - borderInShellHeader={true} - /> -
- {Array.isArray(data) && data.length ? ( - <> -
- {data.map((client, index) => { - return ( - - ); - })} -
- - ) : ( - } - /> - )} -
-
-
- ); -}; - -OAuthClients.getLayout = getLayout; -OAuthClients.PageWrapper = PageWrapper; - -export default OAuthClients; diff --git a/apps/web/pages/settings/platform/index.tsx b/apps/web/pages/settings/platform/index.tsx new file mode 100644 index 0000000000..bcab2b2822 --- /dev/null +++ b/apps/web/pages/settings/platform/index.tsx @@ -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
Loading...
; + + if (isPlatformUser && !isPaidUser) return ; + + if (isPlatformUser) { + return ( + +
+ + + { + setInitialClientId(id); + setInitialClientName(name); + refetchManagedUsers(); + }} + /> + +
+
+ ); + } + + return ( +
+ }> + You are not subscribed to a Platform plan. + +
+ ); +} + +Platform.PageWrapper = PageWrapper; diff --git a/apps/web/pages/settings/platform/new/index.tsx b/apps/web/pages/settings/platform/new/index.tsx new file mode 100644 index 0000000000..2c25a4e4d9 --- /dev/null +++ b/apps/web/pages/settings/platform/new/index.tsx @@ -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 ( + + + + + ); +}; +const LayoutWrapper = (page: React.ReactElement) => { + return ( + + {page} + + ); +}; + +export const LayoutWrapperAppDir = (page: React.ReactElement) => { + return ( + + {page} + + ); +}; + +CreateNewOrganizationPage.getLayout = LayoutWrapper; +CreateNewOrganizationPage.PageWrapper = PageWrapper; + +export default CreateNewOrganizationPage; + +export { getServerSideProps }; diff --git a/apps/web/pages/settings/platform/oauth-clients/[clientId]/edit/index.tsx b/apps/web/pages/settings/platform/oauth-clients/[clientId]/edit/index.tsx new file mode 100644 index 0000000000..2cbb3c28a9 --- /dev/null +++ b/apps/web/pages/settings/platform/oauth-clients/[clientId]/edit/index.tsx @@ -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 ( +
+ +
+
+
+

+ OAuth client updation form +

+

+ This is the form to edit an existing OAuth client +

+
+
+ {(!Boolean(clientId) || (isFetched && !data)) &&

OAuth Client not found.

} + {isFetched && !!data && ( + ({ 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 &&

Loading...

} + {isError &&

Something went wrong.

} +
+
+
+ ); +} + +EditOAuthClient.PageWrapper = PageWrapper; diff --git a/apps/web/pages/settings/platform/oauth-clients/create.tsx b/apps/web/pages/settings/platform/oauth-clients/create.tsx new file mode 100644 index 0000000000..ec764fdb97 --- /dev/null +++ b/apps/web/pages/settings/platform/oauth-clients/create.tsx @@ -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 ( +
+ +
+
+
+

+ OAuth client creation form +

+

+ This is the form to create a new OAuth client +

+
+
+ +
+
+
+ ); +} + +CreateOAuthClient.PageWrapper = PageWrapper; diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 4a54bc9262..ae45e293d2 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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.", diff --git a/packages/features/ee/organizations/components/CreateANewOrganizationForm.tsx b/packages/features/ee/organizations/components/CreateANewOrganizationForm.tsx index 8c1bce1aca..89796aa5fb 100644 --- a/packages/features/ee/organizations/components/CreateANewOrganizationForm.tsx +++ b/packages/features/ee/organizations/components/CreateANewOrganizationForm.tsx @@ -32,7 +32,12 @@ export const CreateANewOrganizationForm = () => { return ; }; -const CreateANewOrganizationFormChild = ({ session }: { session: Ensure }) => { +const CreateANewOrganizationFormChild = ({ + session, +}: { + session: Ensure; + isPlatformOrg?: boolean; +}) => { const { t } = useLocale(); const router = useRouter(); const telemetry = useTelemetry(); @@ -282,13 +287,13 @@ const CreateANewOrganizationFormChild = ({ session }: { session: Ensure { + const session = useSession(); + if (!session.data) { + return null; + } + return ; +}; + +const CreateANewPlatformFormChild = ({ session }: { session: Ensure }) => { + const { t } = useLocale(); + const router = useRouter(); + const telemetry = useTelemetry(); + const [serverErrorMessage, setServerErrorMessage] = useState(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 ( + <> +
{ + if (!createOrganizationMutation.isPending) { + setServerErrorMessage(null); + createOrganizationMutation.mutate({ + ...v, + slug: `${v.name.toLocaleLowerCase()}_platform`, + }); + } + }}> +
+ {serverErrorMessage && ( +
+ +
+ )} + ( +
+ { + 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" + /> +
+ )} + /> +
+ +
+ ( + <> + { + newOrganizationFormMethods.setValue("name", e?.target.value.trim()); + if (newOrganizationFormMethods.formState.touchedFields["slug"] === undefined) { + newOrganizationFormMethods.setValue("slug", slugify(e?.target.value)); + } + }} + autoComplete="off" + /> + + )} + /> +
+ +
+ +
+
+ + + ); +}; diff --git a/packages/features/ee/platform/components/index.ts b/packages/features/ee/platform/components/index.ts new file mode 100644 index 0000000000..c6d6557779 --- /dev/null +++ b/packages/features/ee/platform/components/index.ts @@ -0,0 +1 @@ +export { CreateANewPlatformForm } from "./CreateANewPlatformForm"; diff --git a/packages/features/shell/Shell.tsx b/packages/features/shell/Shell.tsx index c0f88deae7..1cb0800329 100644 --- a/packages/features/shell/Shell.tsx +++ b/packages/features/shell/Shell.tsx @@ -289,7 +289,7 @@ const Layout = (props: LayoutProps) => { {props.SidebarContainer ? ( cloneElement(props.SidebarContainer, { bannersHeight }) ) : ( - + )}
@@ -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 ->( - (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>( + (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 (
- {/* logo icon for tablet */} - - +
-
- - {bottomNavItems.map((item, index) => ( - - - {!!item.icon && ( - - - ))} - {!IS_VISUAL_REGRESSION_TESTING && } -
+ {!isPlatformUser && ( +
+ + {bottomNavItems.map((item, index) => ( + + + {!!item.icon && ( + + + ))} + {!IS_VISUAL_REGRESSION_TESTING && } +
+ )} ); @@ -1075,7 +1120,10 @@ export function ShellMain(props: LayoutProps) { } function MainContainer({ - MobileNavigationContainer: MobileNavigationContainerProp = , + isPlatformUser, + MobileNavigationContainer: MobileNavigationContainerProp = ( + + ), TopNavContainer: TopNavContainerProp = , ...props }: LayoutProps) { @@ -1128,13 +1176,17 @@ function TopNav() { ); } -export const MobileNavigationMoreItems = () => ( -
    - {mobileNavigationMoreItems.map((item) => ( - - ))} -
-); +export const MobileNavigationMoreItems = () => { + const { mobileNavigationMoreItems } = getDesktopNavigationItems(); + + return ( +
    + {mobileNavigationMoreItems.map((item) => ( + + ))} +
+ ); +}; function ProfileDropdown() { const { update, data: sessionData } = useSession(); diff --git a/packages/lib/server/repository/organization.ts b/packages/lib/server/repository/organization.ts index 1c5be89ec2..df8b5f52a4 100644 --- a/packages/lib/server/repository/organization.ts +++ b/packages/lib/server/repository/organization.ts @@ -131,6 +131,7 @@ export class OrganizationRepository { orgPricePerSeat: orgData.pricePerSeat, isPlatform: orgData.isPlatform, }, + isPlatform: orgData.isPlatform, }, }); } diff --git a/packages/platform/types/billing.ts b/packages/platform/types/billing.ts new file mode 100644 index 0000000000..3195034e9c --- /dev/null +++ b/packages/platform/types/billing.ts @@ -0,0 +1,6 @@ +import { IsString } from "class-validator"; + +export class SubscribeTeamInput { + @IsString() + plan!: string; +} diff --git a/packages/platform/types/index.ts b/packages/platform/types/index.ts index e117e6ff8e..634d5f211d 100644 --- a/packages/platform/types/index.ts +++ b/packages/platform/types/index.ts @@ -5,3 +5,4 @@ export * from "./slots"; export * from "./calendars"; export * from "./schedules"; export * from "./bookings"; +export * from "./billing"; diff --git a/packages/trpc/server/routers/viewer/organizations/create.handler.ts b/packages/trpc/server/routers/viewer/organizations/create.handler.ts index 44f221817e..d2767b9e56 100644 --- a/packages/trpc/server/routers/viewer/organizations/create.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/create.handler.ts @@ -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." }); } diff --git a/packages/ui/components/empty-screen/EmptyScreen.tsx b/packages/ui/components/empty-screen/EmptyScreen.tsx index 9f29648d3a..75f26869b3 100644 --- a/packages/ui/components/empty-screen/EmptyScreen.tsx +++ b/packages/ui/components/empty-screen/EmptyScreen.tsx @@ -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) { return ( <> @@ -62,7 +64,7 @@ export function EmptyScreen({ )} {!customIcon ? null : <>{customIcon}} -
+