feat: license creation admin PR (#15024)
* WIP: UI created to see if it matches RFC idea * Remove schema as we validate on the backend with old+new key schemas * Add comment about proxy URL request when on older versions * feat: create custom axios instance to use signature middleware * fix: update form to respect new schema * Add cal signature token to trubo env * use backend mutation for creating license keys * add loading state to creation button * update form to have a success state * fix type error for console.warning :') * use api version v1 * restore checkLicense from main * Add env vars to example * nit: rename page export * use US spelling --------- Co-authored-by: Peer Richelsen <peeroke@gmail.com>
This commit is contained in:
co-authored by
Peer Richelsen
parent
a574e5e60f
commit
2a41cd0770
@@ -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 ************************************************************************************************
|
||||
|
||||
@@ -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: {},
|
||||
};
|
||||
};
|
||||
@@ -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 (
|
||||
<LicenseRequired>
|
||||
<Meta title={t("set_up_your_organization")} description={t("organizations_description")} />
|
||||
<CreateANewLicenseKeyForm />
|
||||
</LicenseRequired>
|
||||
);
|
||||
};
|
||||
const LayoutWrapper = (page: React.ReactElement) => {
|
||||
return (
|
||||
<WizardLayout currentStep={1} maxSteps={2}>
|
||||
{page}
|
||||
</WizardLayout>
|
||||
);
|
||||
};
|
||||
|
||||
CreateNewLicenseKeyPage.getLayout = LayoutWrapper;
|
||||
CreateNewLicenseKeyPage.PageWrapper = PageWrapper;
|
||||
|
||||
export default CreateNewLicenseKeyPage;
|
||||
export { getServerSideProps };
|
||||
@@ -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 <CreateANewLicenseKeyFormChild session={session} />;
|
||||
};
|
||||
|
||||
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<SessionContextValue, "data"> }) => {
|
||||
const { t } = useLocale();
|
||||
const [serverErrorMessage, setServerErrorMessage] = useState<string | null>(null);
|
||||
const [stripeCheckoutUrl, setStripeCheckoutUrl] = useState<string | null>(null);
|
||||
const isAdmin = session.data.user.role === UserPermissionRole.ADMIN;
|
||||
const newLicenseKeyFormMethods = useForm<FormValues>({
|
||||
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 ? (
|
||||
<Form
|
||||
form={newLicenseKeyFormMethods}
|
||||
className="space-y-5"
|
||||
id="createOrg"
|
||||
handleSubmit={(values) => {
|
||||
mutation.mutate(values);
|
||||
}}>
|
||||
<div>
|
||||
{serverErrorMessage && (
|
||||
<div className="mb-5">
|
||||
<Alert severity="error" message={serverErrorMessage} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-5">
|
||||
<Controller
|
||||
name="billingPeriod"
|
||||
control={newLicenseKeyFormMethods.control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<>
|
||||
<Label htmlFor="billingPeriod">Billing Period</Label>
|
||||
<ToggleGroup
|
||||
isFullWidth
|
||||
id="billingPeriod"
|
||||
defaultValue={value}
|
||||
onValueChange={(e) => onChange(e)}
|
||||
options={[
|
||||
{
|
||||
value: "MONTHLY",
|
||||
label: "Monthly",
|
||||
},
|
||||
{
|
||||
value: "ANNUALLY",
|
||||
label: "Annually",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Controller
|
||||
name="billingEmail"
|
||||
control={newLicenseKeyFormMethods.control}
|
||||
rules={{
|
||||
required: t("must_enter_billing_email"),
|
||||
}}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<div className="flex">
|
||||
<TextField
|
||||
containerClassName="w-full"
|
||||
placeholder="john@acme.com"
|
||||
name="billingEmail"
|
||||
disabled={!isAdmin}
|
||||
label="Billing Email for Customer"
|
||||
defaultValue={value}
|
||||
onChange={onChange}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Controller
|
||||
name="billingType"
|
||||
control={newLicenseKeyFormMethods.control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<>
|
||||
<Label htmlFor="bookingType">Booking Type</Label>
|
||||
<ToggleGroup
|
||||
isFullWidth
|
||||
id="bookingType"
|
||||
defaultValue={value}
|
||||
onValueChange={(e) => 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",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 [&>*]:flex-1">
|
||||
<Controller
|
||||
name="entityCount"
|
||||
control={newLicenseKeyFormMethods.control}
|
||||
rules={{
|
||||
required: "Must enter a total of billable users",
|
||||
}}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<TextField
|
||||
className="mt-2"
|
||||
name="entityCount"
|
||||
label="Total entities included"
|
||||
placeholder="100"
|
||||
defaultValue={value}
|
||||
onChange={(event) => onChange(+event.target.value)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="entityPrice"
|
||||
control={newLicenseKeyFormMethods.control}
|
||||
rules={{
|
||||
required: "Must enter fixed price per user",
|
||||
}}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<TextField
|
||||
className="mt-2"
|
||||
name="entityPrice"
|
||||
label="Fixed price per entity"
|
||||
addOnSuffix="$"
|
||||
defaultValue={value / 100}
|
||||
onChange={(event) => onChange(+event.target.value * 100)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Controller
|
||||
name="overages"
|
||||
control={newLicenseKeyFormMethods.control}
|
||||
rules={{
|
||||
required: "Must enter overages",
|
||||
}}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<>
|
||||
<TextField
|
||||
className="mt-2"
|
||||
placeholder="Acme"
|
||||
name="overages"
|
||||
addOnSuffix="$"
|
||||
label="Overages"
|
||||
disabled={!isAdmin}
|
||||
defaultValue={value / 100}
|
||||
onChange={(event) => onChange(+event.target.value * 100)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-2 rtl:space-x-reverse">
|
||||
<Button
|
||||
disabled={newLicenseKeyFormMethods.formState.isSubmitting}
|
||||
color="primary"
|
||||
type="submit"
|
||||
form="createOrg"
|
||||
loading={mutation.isPending}
|
||||
className="w-full justify-center">
|
||||
{t("continue")} - {calculateMonthlyPrice()}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
) : (
|
||||
<div className="w-full">
|
||||
<div className="">
|
||||
<TextField className="flex-1" disabled value={stripeCheckoutUrl} />
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex gap-2 [&>*]:flex-1 [&>*]:justify-center">
|
||||
<Button
|
||||
color="secondary"
|
||||
onClick={() => {
|
||||
newLicenseKeyFormMethods.reset();
|
||||
setStripeCheckoutUrl(null);
|
||||
}}>
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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<TrpcSessionUser>;
|
||||
};
|
||||
input: TCreateSelfHostedLicenseSchema;
|
||||
};
|
||||
|
||||
const generateNonce = (): string => {
|
||||
return crypto.randomBytes(16).toString("hex");
|
||||
};
|
||||
|
||||
// Utility function to create a signature
|
||||
const createSignature = (body: Record<string, unknown>, 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<string, unknown>,
|
||||
secretKey: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<Response> => {
|
||||
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;
|
||||
@@ -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<typeof ZCreateSelfHostedLicenseSchema>;
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user