diff --git a/.env.example b/.env.example index fe36f11116..003c195a22 100644 --- a/.env.example +++ b/.env.example @@ -19,6 +19,11 @@ # instructions. This environment variable is deprecated although still supported for backward compatibility. # @see https://console.cal.com CALCOM_LICENSE_KEY= +# Signature token for the Cal.com License API (used for self-hosted integrations) +# We will give you a token when we provide you with a license key this ensure you and only you can communicate with the Cal.com License API for your license key +CAL_SIGNATURE_TOKEN= +# The route to the Cal.com License API +CALCOM_PRIVATE_API_ROUTE= # *********************************************************************************************************** # - DATABASE ************************************************************************************************ diff --git a/apps/web/lib/settings/license-keys/new/getServerSideProps.tsx b/apps/web/lib/settings/license-keys/new/getServerSideProps.tsx new file mode 100644 index 0000000000..600c2ece01 --- /dev/null +++ b/apps/web/lib/settings/license-keys/new/getServerSideProps.tsx @@ -0,0 +1,22 @@ +import type { GetServerSidePropsContext } from "next"; + +import { getServerSession } from "@calcom/feature-auth/lib/getServerSession"; +import { AUTH_OPTIONS } from "@calcom/feature-auth/lib/next-auth-options"; + +export const getServerSideProps = async (context: GetServerSidePropsContext) => { + const session = await getServerSession({ + req: context.req, + res: context.res, + authOptions: AUTH_OPTIONS, + }); + // Disable this check if we ever make this self serve. + if (session?.user.role !== "ADMIN") { + return { + notFound: true, + } as const; + } + + return { + props: {}, + }; +}; diff --git a/apps/web/pages/settings/license-key/new/index.tsx b/apps/web/pages/settings/license-key/new/index.tsx new file mode 100644 index 0000000000..f217036ee2 --- /dev/null +++ b/apps/web/pages/settings/license-key/new/index.tsx @@ -0,0 +1,33 @@ +"use client"; + +import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired"; +import { CreateANewLicenseKeyForm } from "@calcom/features/ee/deployment/licensekey/CreateLicenseKeyForm"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { WizardLayout, Meta } from "@calcom/ui"; + +import { getServerSideProps } from "@lib/settings/license-keys/new/getServerSideProps"; + +import PageWrapper from "@components/PageWrapper"; + +const CreateNewLicenseKeyPage = () => { + const { t } = useLocale(); + return ( + + + + + ); +}; +const LayoutWrapper = (page: React.ReactElement) => { + return ( + + {page} + + ); +}; + +CreateNewLicenseKeyPage.getLayout = LayoutWrapper; +CreateNewLicenseKeyPage.PageWrapper = PageWrapper; + +export default CreateNewLicenseKeyPage; +export { getServerSideProps }; diff --git a/packages/features/ee/deployment/licensekey/CreateLicenseKeyForm.tsx b/packages/features/ee/deployment/licensekey/CreateLicenseKeyForm.tsx new file mode 100644 index 0000000000..18cd5e0330 --- /dev/null +++ b/packages/features/ee/deployment/licensekey/CreateLicenseKeyForm.tsx @@ -0,0 +1,270 @@ +import type { SessionContextValue } from "next-auth/react"; +import { useSession } from "next-auth/react"; +import { useState } from "react"; +import { Controller, useForm } from "react-hook-form"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc"; +import type { Ensure } from "@calcom/types/utils"; +import { showToast } from "@calcom/ui"; +import { Alert, Button, Form, Label, TextField, ToggleGroup } from "@calcom/ui"; + +import { UserPermissionRole } from "../../../../prisma/enums"; + +export const CreateANewLicenseKeyForm = () => { + const session = useSession(); + if (session.data?.user.role !== "ADMIN") { + return null; + } + // @ts-expect-error session can't be null due to the early return + return ; +}; + +enum BillingType { + PER_BOOKING = "PER_BOOKING", + PER_USER = "PER_USER", +} + +enum BillingPeriod { + MONTHLY = "MONTHLY", + ANNUALLY = "ANNUALLY", +} + +interface FormValues { + billingType: BillingType; + entityCount: number; + entityPrice: number; + billingPeriod: BillingPeriod; + overages: number; + billingEmail: string; +} + +const CreateANewLicenseKeyFormChild = ({ session }: { session: Ensure }) => { + const { t } = useLocale(); + const [serverErrorMessage, setServerErrorMessage] = useState(null); + const [stripeCheckoutUrl, setStripeCheckoutUrl] = useState(null); + const isAdmin = session.data.user.role === UserPermissionRole.ADMIN; + const newLicenseKeyFormMethods = useForm({ + defaultValues: { + billingType: BillingType.PER_BOOKING, + billingPeriod: BillingPeriod.MONTHLY, + entityCount: 500, + overages: 99, // $0.99 + entityPrice: 50, // $0.5 + billingEmail: undefined, + }, + }); + + const mutation = trpc.viewer.admin.createSelfHostedLicense.useMutation({ + onSuccess: async (values) => { + showToast(`Success: We have created a stripe payment URL for this billing email`, "success"); + setStripeCheckoutUrl(values.stripeCheckoutUrl); + }, + onError: async (err) => { + setServerErrorMessage(err.message); + }, + }); + + const watchedBillingPeriod = newLicenseKeyFormMethods.watch("billingPeriod"); + const watchedEntityCount = newLicenseKeyFormMethods.watch("entityCount"); + const watchedEntityPrice = newLicenseKeyFormMethods.watch("entityPrice"); + + function calculateMonthlyPrice() { + const occurrence = watchedBillingPeriod === "MONTHLY" ? 1 : 12; + + const sum = watchedEntityCount * watchedEntityPrice * occurrence; + return `$ ${sum / 100} / ${occurrence} months`; + } + + return ( + <> + {!stripeCheckoutUrl ? ( + { + mutation.mutate(values); + }}> + + {serverErrorMessage && ( + + + + )} + + + ( + <> + Billing Period + onChange(e)} + options={[ + { + value: "MONTHLY", + label: "Monthly", + }, + { + value: "ANNUALLY", + label: "Annually", + }, + ]} + /> + > + )} + /> + + + ( + + + + )} + /> + + + ( + <> + Booking Type + onChange(e)} + options={[ + { + value: "PER_BOOKING", + label: "Per Booking", + tooltip: "Configure pricing on a per booking basis", + }, + { + value: "PER_USER", + label: "Per User", + tooltip: "Configure pricing on a per user basis", + }, + ]} + /> + > + )} + /> + + + + ( + onChange(+event.target.value)} + /> + )} + /> + ( + onChange(+event.target.value * 100)} + /> + )} + /> + + + + ( + <> + onChange(+event.target.value * 100)} + autoComplete="off" + /> + > + )} + /> + + + + + {t("continue")} - {calculateMonthlyPrice()} + + + + ) : ( + + + + + + + { + newLicenseKeyFormMethods.reset(); + setStripeCheckoutUrl(null); + }}> + Back + + + + )} + > + ); +}; diff --git a/packages/lib/telemetry.ts b/packages/lib/telemetry.ts index 757ab05161..8816bfd489 100644 --- a/packages/lib/telemetry.ts +++ b/packages/lib/telemetry.ts @@ -22,6 +22,7 @@ export const telemetryEventTypes = { team_created: "team_created", slugReplacementAction: "slug_replacement_action", org_created: "org_created", + license_key_created: "license_key_created", }; export function collectPageParameters( diff --git a/packages/trpc/server/routers/viewer/admin/_router.ts b/packages/trpc/server/routers/viewer/admin/_router.ts index c81693b56f..a99df03afb 100644 --- a/packages/trpc/server/routers/viewer/admin/_router.ts +++ b/packages/trpc/server/routers/viewer/admin/_router.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { authedAdminProcedure } from "../../../procedures/authedProcedure"; import { router, importHandler } from "../../../trpc"; +import { ZCreateSelfHostedLicenseSchema } from "./createSelfHostedLicenseKey.schema"; import { ZListMembersSchema } from "./listPaginated.schema"; import { ZAdminLockUserAccountSchema } from "./lockUserAccount.schema"; import { ZAdminRemoveTwoFactor } from "./removeTwoFactor.schema"; @@ -62,4 +63,13 @@ export const adminRouter = router({ ); return handler(opts); }), + createSelfHostedLicense: authedAdminProcedure + .input(ZCreateSelfHostedLicenseSchema) + .mutation(async (opts) => { + const handler = await importHandler( + namespaced("createSelfHostedLicense"), + () => import("./createSelfHostedLicenseKey.handler") + ); + return handler(opts); + }), }); diff --git a/packages/trpc/server/routers/viewer/admin/createSelfHostedLicenseKey.handler.ts b/packages/trpc/server/routers/viewer/admin/createSelfHostedLicenseKey.handler.ts new file mode 100644 index 0000000000..f69d7d388c --- /dev/null +++ b/packages/trpc/server/routers/viewer/admin/createSelfHostedLicenseKey.handler.ts @@ -0,0 +1,77 @@ +import * as crypto from "crypto"; +import { z } from "zod"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { TCreateSelfHostedLicenseSchema } from "./createSelfHostedLicenseKey.schema"; + +type GetOptions = { + ctx: { + user: NonNullable; + }; + input: TCreateSelfHostedLicenseSchema; +}; + +const generateNonce = (): string => { + return crypto.randomBytes(16).toString("hex"); +}; + +// Utility function to create a signature +const createSignature = (body: Record, nonce: string, secretKey: string): string => { + return crypto + .createHmac("sha256", secretKey) + .update(JSON.stringify(body) + nonce) + .digest("hex"); +}; + +// Fetch wrapper function +const fetchWithSignature = async ( + url: string, + body: Record, + secretKey: string, + options: RequestInit = {} +): Promise => { + const nonce = generateNonce(); + const signature = createSignature(body, nonce, secretKey); + + const headers = { + ...options.headers, + "Content-Type": "application/json", + nonce: nonce, + signature: signature, + }; + + return await fetch(url, { + ...options, + method: "POST", + headers: headers, + body: JSON.stringify(body), + }); +}; + +const createSelfHostedInstance = async ({ input, ctx }: GetOptions) => { + const privateApiUrl = process.env.CALCOM_PRIVATE_API_ROUTE; + const signatureToken = process.env.CAL_SIGNATURE_TOKEN; + + if (!privateApiUrl || !signatureToken) { + throw new Error("Private Api route does not exist in .env"); + } + + // Ensure admin + if (ctx.user.role !== "ADMIN") { + console.warn(`${ctx.user.username} just tried to create a license key without permission`); + throw new Error("You do not have permission to do this."); + } + + const request = await fetchWithSignature(`${privateApiUrl}/v1/license`, input, signatureToken, { + method: "POST", + }); + + const data = await request.json(); + const schema = z.object({ + stripeCheckoutUrl: z.string(), + }); + + return schema.parse(data); +}; + +export default createSelfHostedInstance; diff --git a/packages/trpc/server/routers/viewer/admin/createSelfHostedLicenseKey.schema.ts b/packages/trpc/server/routers/viewer/admin/createSelfHostedLicenseKey.schema.ts new file mode 100644 index 0000000000..64189e28f5 --- /dev/null +++ b/packages/trpc/server/routers/viewer/admin/createSelfHostedLicenseKey.schema.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; + +const BillingType = z.enum(["PER_BOOKING", "PER_USER"]); +const BillingPeriod = z.enum(["MONTHLY", "ANNUALLY"]); + +export const ZCreateSelfHostedLicenseSchema = z.object({ + billingType: BillingType, + entityCount: z.number().int().nonnegative(), + entityPrice: z.number().nonnegative(), + billingPeriod: BillingPeriod, + overages: z.number().nonnegative(), + billingEmail: z.string().email(), +}); + +export type TCreateSelfHostedLicenseSchema = z.infer; diff --git a/turbo.json b/turbo.json index 4354d7bcc2..4ab67328d7 100644 --- a/turbo.json +++ b/turbo.json @@ -236,6 +236,8 @@ "BLACKLISTED_GUEST_EMAILS", "AUTH_BEARER_TOKEN_VERCEL", "BUILD_ID", + "CAL_SIGNATURE_TOKEN", + "CALCOM_PRIVATE_API_ROUTE", "CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY", "CALCOM_CREDENTIAL_SYNC_ENDPOINT", "CALCOM_CREDENTIAL_SYNC_SECRET",