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";
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { symmetricEncrypt, symmetricDecrypt } from "./crypto";
|
||||
|
||||
describe("crypto", () => {
|
||||
const testKey = "12345678901234567890123456789012"; // 32 bytes key
|
||||
const testText = "Hello, World!";
|
||||
|
||||
describe("symmetricEncrypt", () => {
|
||||
it("should encrypt text with a valid key", () => {
|
||||
const encrypted = symmetricEncrypt(testText, testKey);
|
||||
|
||||
// Verify the format is "iv:ciphertext"
|
||||
expect(encrypted).toContain(":");
|
||||
const [iv, ciphertext] = encrypted.split(":");
|
||||
|
||||
// IV should be 32 characters (16 bytes in hex)
|
||||
expect(iv).toHaveLength(32);
|
||||
// Ciphertext should exist
|
||||
expect(ciphertext).toBeDefined();
|
||||
expect(ciphertext.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should produce different ciphertexts for the same input due to random IV", () => {
|
||||
const encrypted1 = symmetricEncrypt(testText, testKey);
|
||||
const encrypted2 = symmetricEncrypt(testText, testKey);
|
||||
|
||||
expect(encrypted1).not.toBe(encrypted2);
|
||||
});
|
||||
|
||||
it("should throw error if key is not 32 bytes", () => {
|
||||
const shortKey = "short";
|
||||
expect(() => symmetricEncrypt(testText, shortKey)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("symmetricDecrypt", () => {
|
||||
it("should correctly decrypt encrypted text", () => {
|
||||
const encrypted = symmetricEncrypt(testText, testKey);
|
||||
const decrypted = symmetricDecrypt(encrypted, testKey);
|
||||
|
||||
expect(decrypted).toBe(testText);
|
||||
});
|
||||
|
||||
it("should throw error for malformed encrypted text", () => {
|
||||
// Test with invalid IV:ciphertext format
|
||||
expect(() => symmetricDecrypt("invalid", testKey)).toThrow();
|
||||
expect(() => symmetricDecrypt("invalid:data", testKey)).toThrow();
|
||||
expect(() => symmetricDecrypt(":", testKey)).toThrow();
|
||||
});
|
||||
|
||||
it("should throw error if wrong key is used", () => {
|
||||
const encrypted = symmetricEncrypt(testText, testKey);
|
||||
const wrongKey = "12345678901234567890123456789013"; // Different 32 bytes key
|
||||
|
||||
expect(() => symmetricDecrypt(encrypted, wrongKey)).toThrow();
|
||||
});
|
||||
|
||||
it("should handle empty string encryption/decryption", () => {
|
||||
const emptyText = "";
|
||||
const encrypted = symmetricEncrypt(emptyText, testKey);
|
||||
const decrypted = symmetricDecrypt(encrypted, testKey);
|
||||
|
||||
expect(decrypted).toBe(emptyText);
|
||||
});
|
||||
|
||||
it("should handle long text encryption/decryption", () => {
|
||||
const longText = "a".repeat(1000);
|
||||
const encrypted = symmetricEncrypt(longText, testKey);
|
||||
const decrypted = symmetricDecrypt(encrypted, testKey);
|
||||
|
||||
expect(decrypted).toBe(longText);
|
||||
});
|
||||
|
||||
it("should handle special characters", () => {
|
||||
const specialChars = "!@#$%^&*()_+-=[]{}|;:'\",.<>?/\\`~";
|
||||
const encrypted = symmetricEncrypt(specialChars, testKey);
|
||||
const decrypted = symmetricDecrypt(encrypted, testKey);
|
||||
|
||||
expect(decrypted).toBe(specialChars);
|
||||
});
|
||||
|
||||
it("should handle unicode characters", () => {
|
||||
const unicodeText = "Hello, 世界! 👋 🌍";
|
||||
const encrypted = symmetricEncrypt(unicodeText, testKey);
|
||||
const decrypted = symmetricDecrypt(encrypted, testKey);
|
||||
|
||||
expect(decrypted).toBe(unicodeText);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
export interface IDeploymentRepository {
|
||||
getLicenseKeyWithId(id: number): Promise<string | null>;
|
||||
getSignatureToken(id: number): Promise<string | null>;
|
||||
}
|
||||
@@ -15,4 +15,12 @@ export class DeploymentRepository implements IDeploymentRepository {
|
||||
});
|
||||
return deployment?.licenseKey || null;
|
||||
}
|
||||
|
||||
async getSignatureToken(id: number): Promise<string | null> {
|
||||
const deployment = await (this.prisma as PrismaClientWithoutExtensions).deployment.findUnique({
|
||||
where: { id },
|
||||
select: { signatureTokenEncrypted: true },
|
||||
});
|
||||
return deployment?.signatureTokenEncrypted || null;
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Deployment" ADD COLUMN "signatureTokenEncrypted" TEXT;
|
||||
@@ -1401,12 +1401,14 @@ model WorkflowsOnTeams {
|
||||
|
||||
model Deployment {
|
||||
/// This is a single row table, so we use a fixed id
|
||||
id Int @id @default(1)
|
||||
logo String?
|
||||
id Int @id @default(1)
|
||||
logo String?
|
||||
/// @zod.custom(imports.DeploymentTheme)
|
||||
theme Json?
|
||||
licenseKey String?
|
||||
agreedLicenseAt DateTime?
|
||||
theme Json?
|
||||
licenseKey String?
|
||||
// We encrypt the signature token in the deployment table with the current calendso encryption key
|
||||
signatureTokenEncrypted String?
|
||||
agreedLicenseAt DateTime?
|
||||
}
|
||||
|
||||
enum TimeUnit {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { authedAdminProcedure } from "../../../procedures/authedProcedure";
|
||||
import { router } from "../../../trpc";
|
||||
import { ZUpdateInputSchema } from "./update.schema";
|
||||
import { ZValidateLicenseInputSchema } from "./validateLicense.schema";
|
||||
|
||||
type DeploymentSetupRouterHandlerCache = {
|
||||
update?: typeof import("./update.handler").updateHandler;
|
||||
validateLicense?: typeof import("./validateLicense.handler").validateLicenseHandler;
|
||||
};
|
||||
|
||||
export const deploymentSetupRouter = router({
|
||||
@@ -15,4 +17,12 @@ export const deploymentSetupRouter = router({
|
||||
input,
|
||||
});
|
||||
}),
|
||||
validateLicense: authedAdminProcedure.input(ZValidateLicenseInputSchema).query(async ({ input, ctx }) => {
|
||||
const { validateLicenseHandler } = await import("./validateLicense.handler");
|
||||
|
||||
return validateLicenseHandler({
|
||||
ctx,
|
||||
input,
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { symmetricEncrypt } from "@calcom/lib/crypto";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
import type { TUpdateInputSchema } from "./update.schema";
|
||||
@@ -8,11 +9,20 @@ type UpdateOptions = {
|
||||
};
|
||||
|
||||
export const updateHandler = async ({ input }: UpdateOptions) => {
|
||||
const data = {
|
||||
const data: any = {
|
||||
agreedLicenseAt: new Date(),
|
||||
licenseKey: input.licenseKey,
|
||||
};
|
||||
|
||||
// Encrypt and store the signature token if provided
|
||||
if (input.signatureToken) {
|
||||
const encryptionKey = process.env.CALENDSO_ENCRYPTION_KEY;
|
||||
if (!encryptionKey) {
|
||||
throw new Error("CALENDSO_ENCRYPTION_KEY is required to encrypt signature token");
|
||||
}
|
||||
data.signatureTokenEncrypted = symmetricEncrypt(input.signatureToken, encryptionKey);
|
||||
}
|
||||
|
||||
await prisma.deployment.upsert({ where: { id: 1 }, create: data, update: data });
|
||||
|
||||
return;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { z } from "zod";
|
||||
|
||||
export const ZUpdateInputSchema = z.object({
|
||||
licenseKey: z.string().optional(),
|
||||
signatureToken: z.string().optional(),
|
||||
});
|
||||
|
||||
export type TUpdateInputSchema = z.infer<typeof ZUpdateInputSchema>;
|
||||
@@ -0,0 +1,38 @@
|
||||
import LicenseKeyService from "@calcom/features/ee/common/server/LicenseKeyService";
|
||||
|
||||
import type { TrpcSessionUser } from "../../../types";
|
||||
import type { TValidateLicenseInputSchema } from "./validateLicense.schema";
|
||||
|
||||
type ValidateLicenseOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
input: TValidateLicenseInputSchema;
|
||||
};
|
||||
|
||||
export const validateLicenseHandler = async ({ input }: ValidateLicenseOptions) => {
|
||||
const { licenseKey } = input;
|
||||
|
||||
// Skip validation for E2E testing
|
||||
if (process.env.NEXT_PUBLIC_IS_E2E === "1") {
|
||||
return {
|
||||
valid: true,
|
||||
message: "License key is valid (E2E mode)",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const isValid = await LicenseKeyService.validateLicenseKey(licenseKey);
|
||||
|
||||
return {
|
||||
valid: isValid,
|
||||
message: isValid ? "License key is valid" : "License key is invalid",
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("License validation failed:", error);
|
||||
return {
|
||||
valid: false,
|
||||
message: "License key validation failed",
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZValidateLicenseInputSchema = z.object({
|
||||
licenseKey: z.string().min(1, "License key is required"),
|
||||
});
|
||||
|
||||
export type TValidateLicenseInputSchema = z.infer<typeof ZValidateLicenseInputSchema>;
|
||||
@@ -1,49 +1,57 @@
|
||||
"use client";
|
||||
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { noop } from "lodash";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
|
||||
import { Button } from "../../button";
|
||||
import { Steps } from "../../form/step";
|
||||
import { useWizardState } from "./useWizardState";
|
||||
|
||||
type DefaultStep = {
|
||||
export type WizardStep = {
|
||||
title: string;
|
||||
containerClassname?: string;
|
||||
contentClassname?: string;
|
||||
description: string;
|
||||
content?: ((setIsPending: Dispatch<SetStateAction<boolean>>) => JSX.Element) | JSX.Element;
|
||||
content?:
|
||||
| ((
|
||||
setIsPending: Dispatch<SetStateAction<boolean>>,
|
||||
nav: { onNext: () => void; onPrev: () => void; step: number; maxSteps: number }
|
||||
) => JSX.Element)
|
||||
| JSX.Element;
|
||||
isEnabled?: boolean;
|
||||
isPending?: boolean;
|
||||
customActions?: boolean;
|
||||
};
|
||||
|
||||
function WizardForm<T extends DefaultStep>(props: {
|
||||
href: string;
|
||||
steps: T[];
|
||||
disableNavigation?: boolean;
|
||||
export interface WizardFormProps {
|
||||
steps: WizardStep[];
|
||||
containerClassname?: string;
|
||||
prevLabel?: string;
|
||||
nextLabel?: string;
|
||||
finishLabel?: string;
|
||||
stepLabel?: React.ComponentProps<typeof Steps>["stepLabel"];
|
||||
}) {
|
||||
const searchParams = useCompatSearchParams();
|
||||
const { href, steps, nextLabel = "Next", finishLabel = "Finish", prevLabel = "Back", stepLabel } = props;
|
||||
const router = useRouter();
|
||||
const stepSchema = z.coerce.number().int().min(1).max(steps.length).default(1);
|
||||
const stepResult = stepSchema.safeParse(searchParams?.get("step"));
|
||||
const step = stepResult.success ? stepResult.data : 1;
|
||||
const currentStep = steps[step - 1];
|
||||
const setStep = (newStep: number) => {
|
||||
router.replace(`${href}?step=${newStep || 1}`);
|
||||
};
|
||||
defaultStep?: number;
|
||||
disableNavigation?: boolean;
|
||||
}
|
||||
|
||||
export function WizardForm({
|
||||
steps,
|
||||
containerClassname,
|
||||
prevLabel = "Back",
|
||||
nextLabel = "Next",
|
||||
finishLabel = "Finish",
|
||||
stepLabel,
|
||||
defaultStep = 1,
|
||||
disableNavigation = false,
|
||||
}: WizardFormProps) {
|
||||
const { currentStep, maxSteps, nextStep, prevStep, isFirstStep, isLastStep } = useWizardState(
|
||||
defaultStep,
|
||||
steps.length
|
||||
);
|
||||
const [currentStepisPending, setCurrentStepisPending] = useState(false);
|
||||
const currentStepData = steps[currentStep - 1];
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentStepisPending(false);
|
||||
@@ -51,39 +59,45 @@ function WizardForm<T extends DefaultStep>(props: {
|
||||
|
||||
return (
|
||||
<div className="mx-auto mt-4 print:w-full" data-testid="wizard-form">
|
||||
<div className={classNames("overflow-hidden md:mb-2 md:w-[700px]", props.containerClassname)}>
|
||||
<div className="px-6 py-5 sm:px-14">
|
||||
<div className={classNames("overflow-hidden md:mb-2 md:w-[700px]", containerClassname)}>
|
||||
<div className="px-6 py-5">
|
||||
<h1 className="font-cal text-emphasis text-2xl" data-testid="step-title">
|
||||
{currentStep.title}
|
||||
{currentStepData.title}
|
||||
</h1>
|
||||
<p className="text-subtle text-sm" data-testid="step-description">
|
||||
{currentStep.description}
|
||||
{currentStepData.description}
|
||||
</p>
|
||||
{!props.disableNavigation && (
|
||||
{!disableNavigation && (
|
||||
<Steps
|
||||
maxSteps={steps.length}
|
||||
currentStep={step}
|
||||
nextStep={noop}
|
||||
maxSteps={maxSteps}
|
||||
currentStep={currentStep}
|
||||
nextStep={nextStep}
|
||||
stepLabel={stepLabel}
|
||||
data-testid="wizard-step-component"
|
||||
disableNavigation={disableNavigation}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={classNames("mb-8 overflow-hidden md:w-[700px]", props.containerClassname)}>
|
||||
<div className={classNames("print:p-none max-w-3xl px-8 py-5 sm:p-6", currentStep.contentClassname)}>
|
||||
{typeof currentStep.content === "function"
|
||||
? currentStep.content(setCurrentStepisPending)
|
||||
: currentStep.content}
|
||||
<div className={classNames("mb-8 overflow-hidden md:w-[700px]", containerClassname)}>
|
||||
<div
|
||||
className={classNames(
|
||||
"bg-default border-subtle max-w-3xl rounded-2xl border px-4 py-3 sm:p-4 ",
|
||||
currentStepData.contentClassname
|
||||
)}>
|
||||
{typeof currentStepData.content === "function"
|
||||
? currentStepData.content(setCurrentStepisPending, {
|
||||
onNext: nextStep,
|
||||
onPrev: prevStep,
|
||||
step: currentStep,
|
||||
maxSteps,
|
||||
})
|
||||
: currentStepData.content}
|
||||
</div>
|
||||
{!props.disableNavigation && (
|
||||
{!disableNavigation && !currentStepData.customActions && (
|
||||
<div className="flex justify-end px-4 py-4 print:hidden sm:px-6">
|
||||
{step > 1 && (
|
||||
<Button
|
||||
color="secondary"
|
||||
onClick={() => {
|
||||
setStep(step - 1);
|
||||
}}>
|
||||
{!isFirstStep && (
|
||||
<Button color="secondary" onClick={prevStep}>
|
||||
{prevLabel}
|
||||
</Button>
|
||||
)}
|
||||
@@ -93,10 +107,10 @@ function WizardForm<T extends DefaultStep>(props: {
|
||||
loading={currentStepisPending}
|
||||
type="submit"
|
||||
color="primary"
|
||||
form={`wizard-step-${step}`}
|
||||
disabled={currentStep.isEnabled === false}
|
||||
form={`wizard-step-${currentStep}`}
|
||||
disabled={currentStepData.isEnabled === false}
|
||||
className="relative ml-2">
|
||||
{step < steps.length ? nextLabel : finishLabel}
|
||||
{isLastStep ? finishLabel : nextLabel}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -104,5 +118,3 @@ function WizardForm<T extends DefaultStep>(props: {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default WizardForm;
|
||||
@@ -1 +1,4 @@
|
||||
export { default as WizardForm } from "./WizardForm";
|
||||
export { WizardForm } from "./WizardForm";
|
||||
export type { WizardFormProps, WizardStep } from "./WizardForm";
|
||||
export { useWizardState } from "./useWizardState";
|
||||
export type { WizardState } from "./useWizardState";
|
||||
@@ -0,0 +1,54 @@
|
||||
import { createParser, useQueryState } from "nuqs";
|
||||
import { useCallback } from "react";
|
||||
|
||||
export interface WizardState {
|
||||
currentStep: number;
|
||||
maxSteps: number;
|
||||
}
|
||||
|
||||
const createStepParser = (defaultStep: number, maxSteps: number) =>
|
||||
createParser({
|
||||
parse: (value: string) => {
|
||||
const parsed = parseInt(value, 10);
|
||||
if (isNaN(parsed)) return defaultStep;
|
||||
// Ensure step is within bounds
|
||||
if (parsed < 1) return 1;
|
||||
if (parsed > maxSteps) return maxSteps;
|
||||
return parsed;
|
||||
},
|
||||
serialize: (value: number) => value.toString(),
|
||||
});
|
||||
|
||||
export function useWizardState(defaultStep = 1, maxSteps: number) {
|
||||
const stepParser = createStepParser(defaultStep, maxSteps);
|
||||
const [step, setStep] = useQueryState("step", stepParser.withDefault(defaultStep));
|
||||
|
||||
const goToStep = useCallback(
|
||||
(newStep: number) => {
|
||||
setStep(Math.min(Math.max(newStep, 1), maxSteps));
|
||||
},
|
||||
[setStep, maxSteps]
|
||||
);
|
||||
|
||||
const nextStep = useCallback(() => {
|
||||
if (step < maxSteps) {
|
||||
setStep((prev) => prev + 1);
|
||||
}
|
||||
}, [step, maxSteps, setStep]);
|
||||
|
||||
const prevStep = useCallback(() => {
|
||||
if (step > 1) {
|
||||
setStep((prev) => prev - 1);
|
||||
}
|
||||
}, [step, setStep]);
|
||||
|
||||
return {
|
||||
currentStep: step,
|
||||
maxSteps,
|
||||
goToStep,
|
||||
nextStep,
|
||||
prevStep,
|
||||
isFirstStep: step === 1,
|
||||
isLastStep: step === maxSteps,
|
||||
};
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
import { vi } from "vitest";
|
||||
|
||||
import WizardForm from "./WizardForm";
|
||||
import { WizardForm } from "./WizardForm";
|
||||
|
||||
vi.mock("@calcom/lib/hooks/useCompatSearchParams", () => ({
|
||||
useCompatSearchParams() {
|
||||
@@ -19,6 +19,13 @@ vi.mock("next/navigation", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("nuqs", () => ({
|
||||
useQueryState: vi.fn(() => [currentStepNavigation, vi.fn()]),
|
||||
createParser: vi.fn(() => ({
|
||||
withDefault: vi.fn(() => ({})),
|
||||
})),
|
||||
}));
|
||||
|
||||
const steps = [
|
||||
{
|
||||
title: "Step 1",
|
||||
|
||||
Reference in new issue
Block a user