feat: Self hosted onboarding (#22102)
* intro work * update wixard form to have content callback to remove preset navigation * more fixes to deployment * fix calling static service * fix save license key text * ensure default steps work as expected * fix conditional for rendering step * skip step * add on next step for free license * refactor wizard form to use nuqs * fix styles * merge base param with step config * fix next stepo text * use deployment Signature token * decrypt signature token * fix: resolve type errors and test failures from wizard form refactor - Fix signatureToken field name to signatureTokenEncrypted in deployment repository - Add missing getSignatureToken method to verifyApiKey test mock - Fix WizardForm import from default to named export in test file - Add missing nextStep prop to Steps component in WizardForm Resolves TypeScript type check errors and unit test failures without changing functionality. Co-Authored-By: sean@cal.com <Sean@brydon.io> * fix: add missing getDeploymentSignatureToken mock in LicenseKeyService test Co-Authored-By: sean@cal.com <Sean@brydon.io> * fix: add nuqs library mock for WizardForm test Co-Authored-By: sean@cal.com <Sean@brydon.io> * fix: add missing nav prop to AdminAppsList component with eslint disable Co-Authored-By: sean@cal.com <Sean@brydon.io> * Apply suggestions from code review Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> * Update apps/web/modules/auth/setup-view.tsx Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> * fix license schema changes * revret schema generation * fix eslint errors * remove required nav type + add use client * fix types * Update packages/ui/components/form/wizard/useWizardState.ts Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> * fix controller issue * add checks for deployment key being null - add more tests * fix tests * add deployment key tests * fix: resolve crypto mock to handle empty encryption keys gracefully - Updated symmetricDecrypt mock to return null instead of throwing 'Invalid key' error when encryption key is empty - All getDeploymentKey tests now pass including the previously failing 'should return null when decryption fails due to missing encryption key' test - Fixes mocking issues in PR 22102 self-hosted onboarding wizard form refactor Co-Authored-By: sean@cal.com <Sean@brydon.io> * fix label * add i18n to error * use enum for steps * add as const * fix test env issues --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
36 files changed
+982
-263
No files matched your search
@@ -139,6 +139,7 @@ const AdminAppsList = ({
|
||||
useQueryParam = false,
|
||||
classNames,
|
||||
onSubmit = noop,
|
||||
nav,
|
||||
...rest
|
||||
}: {
|
||||
baseURL: string;
|
||||
@@ -151,7 +152,9 @@ const AdminAppsList = ({
|
||||
className?: string;
|
||||
useQueryParam?: boolean;
|
||||
onSubmit?: () => void;
|
||||
nav?: { onNext: () => void; onPrev: () => void };
|
||||
} & Omit<JSX.IntrinsicElements["form"], "onSubmit">) => {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<form
|
||||
{...rest}
|
||||
@@ -161,6 +164,7 @@ const AdminAppsList = ({
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
onSubmit();
|
||||
nav?.onNext();
|
||||
}}>
|
||||
<AppCategoryNavigation
|
||||
baseURL={baseURL}
|
||||
@@ -172,6 +176,16 @@ const AdminAppsList = ({
|
||||
}}>
|
||||
<AdminAppsListContainer />
|
||||
</AppCategoryNavigation>
|
||||
<div className="flex justify-end gap-2 px-4 py-4">
|
||||
{nav && (
|
||||
<Button type="button" color="secondary" onClick={nav.onPrev}>
|
||||
{t("prev_step")}
|
||||
</Button>
|
||||
)}
|
||||
<Button type="submit" color="primary">
|
||||
{t("finish")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -150,7 +150,8 @@ export default function ApiKeyDialogForm({
|
||||
{t("api_key_modal_subtitle")}
|
||||
</div>
|
||||
<Link
|
||||
target="_blank" rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://cal.com/platform"
|
||||
className="border-subtle relative flex w-full items-start rounded-[10px] border p-4 text-sm">
|
||||
{t("api_key_modal_subtitle_platform")}
|
||||
|
||||
@@ -3,7 +3,8 @@ import "../../../../../tests/libs/__mocks__/prisma";
|
||||
import * as cache from "memory-cache";
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
|
||||
|
||||
import { getDeploymentKey } from "../../deployment/lib/getDeploymentKey";
|
||||
import { getDeploymentKey, getDeploymentSignatureToken } from "../../deployment/lib/getDeploymentKey";
|
||||
import { NoopLicenseKeyService } from "./LicenseKeyService";
|
||||
import { createSignature, generateNonce } from "./private-api-utils";
|
||||
|
||||
const baseUrl = "http://test-api.com";
|
||||
@@ -39,6 +40,7 @@ vi.mock("memory-cache", () => ({
|
||||
|
||||
vi.mock("../../deployment/lib/getDeploymentKey", () => ({
|
||||
getDeploymentKey: vi.fn(),
|
||||
getDeploymentSignatureToken: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./private-api-utils", () => ({
|
||||
@@ -53,6 +55,7 @@ const BASE_HEADERS = {
|
||||
describe("LicenseKeyService", () => {
|
||||
beforeEach(async () => {
|
||||
vi.mocked(getDeploymentKey).mockResolvedValue(licenseKey);
|
||||
vi.mocked(getDeploymentSignatureToken).mockResolvedValue("mockSignatureToken");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -66,6 +69,13 @@ describe("LicenseKeyService", () => {
|
||||
const service = await LicenseKeyService.create();
|
||||
expect(service).toBeInstanceOf(LicenseKeyService);
|
||||
});
|
||||
|
||||
it("should create a NoopLicenseKeyService when no license key is provided", async () => {
|
||||
vi.mocked(getDeploymentKey).mockResolvedValue("");
|
||||
const LicenseKeyService = await getLicenseKeyService();
|
||||
const service = await LicenseKeyService.create();
|
||||
expect(service).toBeInstanceOf(NoopLicenseKeyService);
|
||||
});
|
||||
});
|
||||
|
||||
describe("incrementUsage", () => {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import * as cache from "memory-cache";
|
||||
|
||||
import { getDeploymentKey } from "@calcom/features/ee/deployment/lib/getDeploymentKey";
|
||||
import {
|
||||
getDeploymentKey,
|
||||
getDeploymentSignatureToken,
|
||||
} from "@calcom/features/ee/deployment/lib/getDeploymentKey";
|
||||
import { CALCOM_PRIVATE_API_ROUTE } from "@calcom/lib/constants";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import type { IDeploymentRepository } from "@calcom/lib/server/repository/deployment.interface";
|
||||
@@ -20,28 +23,33 @@ export interface ILicenseKeyService {
|
||||
class LicenseKeyService implements ILicenseKeyService {
|
||||
private readonly baseUrl = CALCOM_PRIVATE_API_ROUTE;
|
||||
private readonly licenseKey: string;
|
||||
private readonly signatureToken: string | null;
|
||||
public readonly CACHING_TIME = 86_400_000; // 24 hours in milliseconds
|
||||
|
||||
// Private constructor to prevent direct instantiation
|
||||
private constructor(licenseKey: string) {
|
||||
private constructor(licenseKey: string, signatureToken: string | null) {
|
||||
this.baseUrl = CALCOM_PRIVATE_API_ROUTE;
|
||||
this.licenseKey = licenseKey;
|
||||
this.signatureToken = signatureToken;
|
||||
}
|
||||
|
||||
// Static async factory method
|
||||
public static async create(deploymentRepo: IDeploymentRepository): Promise<ILicenseKeyService> {
|
||||
const licenseKey = await getDeploymentKey(deploymentRepo);
|
||||
const signatureToken = await getDeploymentSignatureToken(deploymentRepo);
|
||||
const useNoop = !licenseKey || process.env.NEXT_PUBLIC_IS_E2E === "1";
|
||||
return !useNoop ? new LicenseKeyService(licenseKey) : new NoopLicenseKeyService();
|
||||
return !useNoop ? new LicenseKeyService(licenseKey, signatureToken) : new NoopLicenseKeyService();
|
||||
}
|
||||
|
||||
private async fetcher({
|
||||
url,
|
||||
body,
|
||||
licenseKey,
|
||||
options = {},
|
||||
}: {
|
||||
url: string;
|
||||
body?: Record<string, unknown>;
|
||||
licenseKey: string;
|
||||
options?: RequestInit;
|
||||
}): Promise<Response> {
|
||||
const nonce = generateNonce();
|
||||
@@ -50,14 +58,13 @@ class LicenseKeyService implements ILicenseKeyService {
|
||||
...options.headers,
|
||||
"Content-Type": "application/json",
|
||||
nonce: nonce,
|
||||
"x-cal-license-key": this.licenseKey,
|
||||
"x-cal-license-key": licenseKey,
|
||||
} as Record<string, string>;
|
||||
|
||||
const signatureToken = process.env.CAL_SIGNATURE_TOKEN;
|
||||
if (!signatureToken) {
|
||||
if (!this.signatureToken) {
|
||||
logger.warn("CAL_SIGNATURE_TOKEN needs to be set to increment usage.");
|
||||
} else {
|
||||
const signature = createSignature(body || {}, nonce, signatureToken);
|
||||
const signature = createSignature(body || {}, nonce, this.signatureToken);
|
||||
headers["signature"] = signature;
|
||||
}
|
||||
|
||||
@@ -70,10 +77,21 @@ class LicenseKeyService implements ILicenseKeyService {
|
||||
});
|
||||
}
|
||||
|
||||
// Static method to validate a license key directly
|
||||
public static async validateLicenseKey(licenseKey: string): Promise<boolean> {
|
||||
/** We skip for E2E testing */
|
||||
if (process.env.NEXT_PUBLIC_IS_E2E === "1") return true;
|
||||
|
||||
// Create a temporary instance to use instance methods
|
||||
const service = new LicenseKeyService(licenseKey, "");
|
||||
return service.checkLicense();
|
||||
}
|
||||
|
||||
async incrementUsage(usageEvent?: UsageEvent) {
|
||||
try {
|
||||
const response = await this.fetcher({
|
||||
url: `${this.baseUrl}/v1/license/usage/increment?event=${usageEvent ?? UsageEvent.BOOKING}`,
|
||||
licenseKey: this.licenseKey,
|
||||
options: {
|
||||
method: "POST",
|
||||
mode: "cors",
|
||||
@@ -94,7 +112,7 @@ class LicenseKeyService implements ILicenseKeyService {
|
||||
const cachedResponse = cache.get(url);
|
||||
if (cachedResponse) return cachedResponse;
|
||||
try {
|
||||
const response = await this.fetcher({ url, options: { mode: "cors" } });
|
||||
const response = await this.fetcher({ url, licenseKey: this.licenseKey, options: { mode: "cors" } });
|
||||
const data = await response.json();
|
||||
cache.put(url, data.status, this.CACHING_TIME);
|
||||
return data.status;
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import cache from "memory-cache";
|
||||
import { z } from "zod";
|
||||
|
||||
import { CONSOLE_URL } from "@calcom/lib/constants";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
const CACHING_TIME = 86400000; // 24 hours in milliseconds
|
||||
|
||||
const schemaLicenseKey = z
|
||||
.string()
|
||||
// .uuid() exists but I'd to fix the situation where the CALCOM_LICENSE_KEY is wrapped in quotes
|
||||
.regex(/^\"?[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}\"?$/, {
|
||||
message: "License key must follow UUID format: 8-4-4-4-12",
|
||||
})
|
||||
.transform((v) => {
|
||||
// Remove the double quotes from the license key, as they 404 the fetch.
|
||||
return v != null && v.length >= 2 && v.charAt(0) == '"' && v.charAt(v.length - 1) == '"'
|
||||
? v.substring(1, v.length - 1)
|
||||
: v;
|
||||
});
|
||||
|
||||
async function checkLicense(
|
||||
/** The prisma client to use (necessary for public API to handle custom prisma instances) */
|
||||
prisma: PrismaClient
|
||||
): Promise<boolean> {
|
||||
/** We skip for E2E testing */
|
||||
if (!!process.env.NEXT_PUBLIC_IS_E2E) return true;
|
||||
/** We check first on env */
|
||||
let licenseKey = process.env.CALCOM_LICENSE_KEY;
|
||||
if (!licenseKey) {
|
||||
/** We try to check on DB only if env is undefined */
|
||||
const deployment = await prisma.deployment.findUnique({ where: { id: 1 } });
|
||||
licenseKey = deployment?.licenseKey ?? undefined;
|
||||
}
|
||||
if (!licenseKey) return false;
|
||||
const url = `${CONSOLE_URL}/api/license?key=${schemaLicenseKey.parse(licenseKey)}`;
|
||||
const cachedResponse = cache.get(url);
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
} else {
|
||||
try {
|
||||
const response = await fetch(url, { mode: "cors" });
|
||||
const data = await response.json();
|
||||
cache.put(url, data.valid, CACHING_TIME);
|
||||
return data.valid;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default checkLicense;
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import { getDeploymentKey, getDeploymentSignatureToken } from "./getDeploymentKey";
|
||||
|
||||
// Mock the crypto module
|
||||
vi.mock("@calcom/lib/crypto", () => ({
|
||||
symmetricDecrypt: vi.fn((text: string, key: string) => {
|
||||
if (!key) return null;
|
||||
return text.replace("encrypted:", "");
|
||||
}),
|
||||
symmetricEncrypt: vi.fn((text: string, key: string) => `encrypted:${text}`),
|
||||
}));
|
||||
|
||||
describe("getDeploymentKey", () => {
|
||||
const mockDeploymentRepo = {
|
||||
getLicenseKeyWithId: vi.fn(),
|
||||
getSignatureToken: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
// Set required environment variables
|
||||
vi.stubEnv("NODE_ENV", "test");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe("getDeploymentKey", () => {
|
||||
it("should return license key from environment variable if set", async () => {
|
||||
vi.stubEnv("CALCOM_LICENSE_KEY", "test-license-key");
|
||||
const result = await getDeploymentKey(mockDeploymentRepo);
|
||||
expect(result).toBe("test-license-key");
|
||||
expect(mockDeploymentRepo.getLicenseKeyWithId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should return license key from repository if environment variable not set", async () => {
|
||||
mockDeploymentRepo.getLicenseKeyWithId.mockResolvedValue("db-license-key");
|
||||
const result = await getDeploymentKey(mockDeploymentRepo);
|
||||
expect(result).toBe("db-license-key");
|
||||
expect(mockDeploymentRepo.getLicenseKeyWithId).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it("should return empty string if no license key found", async () => {
|
||||
mockDeploymentRepo.getLicenseKeyWithId.mockResolvedValue(null);
|
||||
const result = await getDeploymentKey(mockDeploymentRepo);
|
||||
expect(result).toBe("");
|
||||
expect(mockDeploymentRepo.getLicenseKeyWithId).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDeploymentSignatureToken", () => {
|
||||
const ENCRYPTION_KEY = "test-encryption-key";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv("CALENDSO_ENCRYPTION_KEY", ENCRYPTION_KEY);
|
||||
});
|
||||
|
||||
it("should return signature token from environment variable if set", async () => {
|
||||
vi.stubEnv("CAL_SIGNATURE_TOKEN", "test-signature-token");
|
||||
const result = await getDeploymentSignatureToken(mockDeploymentRepo);
|
||||
expect(result).toBe("test-signature-token");
|
||||
expect(mockDeploymentRepo.getSignatureToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should decrypt and return signature token from repository if environment variable not set", async () => {
|
||||
const encryptedToken = "encrypted:test-token";
|
||||
mockDeploymentRepo.getSignatureToken.mockResolvedValue(encryptedToken);
|
||||
|
||||
const result = await getDeploymentSignatureToken(mockDeploymentRepo);
|
||||
expect(result).toBe("test-token");
|
||||
expect(mockDeploymentRepo.getSignatureToken).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it("should return null if no signature token found", async () => {
|
||||
mockDeploymentRepo.getSignatureToken.mockResolvedValue(null);
|
||||
const result = await getDeploymentSignatureToken(mockDeploymentRepo);
|
||||
expect(result).toBeNull();
|
||||
expect(mockDeploymentRepo.getSignatureToken).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it("should return null when decryption fails due to missing encryption key", async () => {
|
||||
vi.stubEnv("CALENDSO_ENCRYPTION_KEY", "");
|
||||
const encryptedToken = "encrypted:test-token";
|
||||
mockDeploymentRepo.getSignatureToken.mockResolvedValue(encryptedToken);
|
||||
|
||||
const result = await getDeploymentSignatureToken(mockDeploymentRepo);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should attempt to decrypt any token format", async () => {
|
||||
const token = "invalid-token";
|
||||
mockDeploymentRepo.getSignatureToken.mockResolvedValue(token);
|
||||
|
||||
const result = await getDeploymentSignatureToken(mockDeploymentRepo);
|
||||
expect(result).toBe("invalid-token"); // The mock decrypt just returns the input if key exists
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,9 @@
|
||||
import { symmetricDecrypt } from "@calcom/lib/crypto";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import type { IDeploymentRepository } from "@calcom/lib/server/repository/deployment.interface";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["getDeploymentKey"] });
|
||||
|
||||
export async function getDeploymentKey(deploymentRepo: IDeploymentRepository): Promise<string> {
|
||||
if (process.env.CALCOM_LICENSE_KEY) {
|
||||
return process.env.CALCOM_LICENSE_KEY;
|
||||
@@ -7,3 +11,24 @@ export async function getDeploymentKey(deploymentRepo: IDeploymentRepository): P
|
||||
const licenseKey = await deploymentRepo.getLicenseKeyWithId(1);
|
||||
return licenseKey || "";
|
||||
}
|
||||
|
||||
export async function getDeploymentSignatureToken(
|
||||
deploymentRepo: IDeploymentRepository
|
||||
): Promise<string | null> {
|
||||
if (process.env.CAL_SIGNATURE_TOKEN) {
|
||||
return process.env.CAL_SIGNATURE_TOKEN;
|
||||
}
|
||||
const signatureTokenEncrypted = await deploymentRepo.getSignatureToken(1);
|
||||
|
||||
if (!signatureTokenEncrypted) {
|
||||
log.error("Signature token not found in database or set in environment variable");
|
||||
return null;
|
||||
}
|
||||
|
||||
const decryptedSignatureToken = symmetricDecrypt(
|
||||
signatureTokenEncrypted,
|
||||
process.env.CALENDSO_ENCRYPTION_KEY || ""
|
||||
);
|
||||
|
||||
return decryptedSignatureToken || null;
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from "./common/server/checkLicense";
|
||||
Reference in new issue
Block a user