From 19563aa697c16cd42f762de202e353a5e585e55e Mon Sep 17 00:00:00 2001 From: sean-brydon <55134778+sean-brydon@users.noreply.github.com> Date: Wed, 9 Jul 2025 09:26:01 +0100 Subject: [PATCH] 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 * fix: add missing getDeploymentSignatureToken mock in LicenseKeyService test Co-Authored-By: sean@cal.com * fix: add nuqs library mock for WizardForm test Co-Authored-By: sean@cal.com * fix: add missing nav prop to AdminAppsList component with eslint disable Co-Authored-By: sean@cal.com * 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 * 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> --- apps/api/v1/lib/helpers/verifyApiKey.test.ts | 1 + .../2024-08-13/services/input.service.ts | 15 +- .../controllers/event-types.controller.ts | 4 +- ...ing-forms-responses.controller.e2e-spec.ts | 138 ++++++---- .../create-routing-form-response.input.ts | 8 +- .../create-routing-form-response.output.ts | 9 +- ...zations-routing-forms-responses.service.ts | 40 ++- ...rganizations-teams-routing-forms.module.ts | 9 +- .../router/controllers/router.controller.ts | 1 - apps/web/components/setup/AdminUser.tsx | 19 +- .../web/components/setup/LicenseSelection.tsx | 255 ++++++++++++++++++ apps/web/modules/auth/setup-view.tsx | 125 +++++---- apps/web/public/static/locales/en/common.json | 17 ++ .../server/lib/setup/getServerSideProps.tsx | 6 + packages/features/apps/AdminAppsList.tsx | 14 + .../api-keys/components/ApiKeyDialogForm.tsx | 3 +- .../common/server/LicenseKeyService.test.ts | 12 +- .../ee/common/server/LicenseKeyService.ts | 34 ++- .../features/ee/common/server/checkLicense.ts | 52 ---- .../deployment/lib/getDeploymentKey.test.ts | 101 +++++++ .../ee/deployment/lib/getDeploymentKey.ts | 25 ++ packages/features/ee/index.ts | 1 - packages/lib/crypto.test.ts | 91 +++++++ .../server/repository/deployment.interface.ts | 1 + packages/lib/server/repository/deployment.ts | 8 + .../migration.sql | 2 + packages/prisma/schema.prisma | 12 +- .../viewer/deploymentSetup/_router.tsx | 10 + .../viewer/deploymentSetup/update.handler.ts | 12 +- .../viewer/deploymentSetup/update.schema.ts | 1 + .../validateLicense.handler.ts | 38 +++ .../deploymentSetup/validateLicense.schema.ts | 7 + .../ui/components/form/wizard/WizardForm.tsx | 106 ++++---- packages/ui/components/form/wizard/index.ts | 5 +- .../components/form/wizard/useWizardState.ts | 54 ++++ .../form/wizard/wizardForm.test.tsx | 9 +- 36 files changed, 982 insertions(+), 263 deletions(-) create mode 100644 apps/web/components/setup/LicenseSelection.tsx delete mode 100644 packages/features/ee/common/server/checkLicense.ts create mode 100644 packages/features/ee/deployment/lib/getDeploymentKey.test.ts delete mode 100644 packages/features/ee/index.ts create mode 100644 packages/lib/crypto.test.ts create mode 100644 packages/prisma/migrations/20250627091352_add_signature_token_to_deployment/migration.sql create mode 100644 packages/trpc/server/routers/viewer/deploymentSetup/validateLicense.handler.ts create mode 100644 packages/trpc/server/routers/viewer/deploymentSetup/validateLicense.schema.ts create mode 100644 packages/ui/components/form/wizard/useWizardState.ts diff --git a/apps/api/v1/lib/helpers/verifyApiKey.test.ts b/apps/api/v1/lib/helpers/verifyApiKey.test.ts index bcdb32f695..88db33adec 100644 --- a/apps/api/v1/lib/helpers/verifyApiKey.test.ts +++ b/apps/api/v1/lib/helpers/verifyApiKey.test.ts @@ -23,6 +23,7 @@ afterEach(() => { const mockDeploymentRepository: IDeploymentRepository = { getLicenseKeyWithId: vi.fn().mockResolvedValue("mockLicenseKey"), // Mocked return value + getSignatureToken: vi.fn().mockResolvedValue("mockSignatureToken"), }; // TODO: Fix the skip condition for this test suite diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/services/input.service.ts b/apps/api/v2/src/ee/bookings/2024-08-13/services/input.service.ts index c0625d0e83..ed430045b3 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/services/input.service.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/services/input.service.ts @@ -190,9 +190,20 @@ export class InputBookingsService_2024_08_13 { }; } - private getRoutingFormData(routing: { teamMemberIds?: number[]; responseId?: number; teamMemberEmail?: string; skipContactOwner?: boolean; crmAppSlug?: string; crmOwnerRecordType?: string } | undefined) { + private getRoutingFormData( + routing: + | { + teamMemberIds?: number[]; + responseId?: number; + teamMemberEmail?: string; + skipContactOwner?: boolean; + crmAppSlug?: string; + crmOwnerRecordType?: string; + } + | undefined + ) { if (!routing) return null; - + return { routedTeamMemberIds: routing.teamMemberIds, routingFormResponseId: routing.responseId, diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_04_15/controllers/event-types.controller.ts b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/controllers/event-types.controller.ts index d3155325a5..b1f11a62a6 100644 --- a/apps/api/v2/src/ee/event-types/event-types_2024_04_15/controllers/event-types.controller.ts +++ b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/controllers/event-types.controller.ts @@ -124,7 +124,9 @@ export class EventTypesController_2024_04_15 { let orgSlug = queryParams.org; if (clientId && !orgSlug && username.includes(`-${clientId}`)) { - const org = await this.organizationsRepository.findTeamIdAndSlugFromClientId(clientId).catch(() => null); + const org = await this.organizationsRepository + .findTeamIdAndSlugFromClientId(clientId) + .catch(() => null); if (org) { orgSlug = org.slug; } diff --git a/apps/api/v2/src/modules/organizations/routing-forms/controllers/organizations-routing-forms-responses.controller.e2e-spec.ts b/apps/api/v2/src/modules/organizations/routing-forms/controllers/organizations-routing-forms-responses.controller.e2e-spec.ts index e45bf5a208..aeda019904 100644 --- a/apps/api/v2/src/modules/organizations/routing-forms/controllers/organizations-routing-forms-responses.controller.e2e-spec.ts +++ b/apps/api/v2/src/modules/organizations/routing-forms/controllers/organizations-routing-forms-responses.controller.e2e-spec.ts @@ -43,7 +43,7 @@ describe("OrganizationsRoutingFormsResponsesController", () => { teamId: number | null; userId: number | null; title: string; - } + }; let membershipsRepositoryFixture: MembershipRepositoryFixture; beforeAll(async () => { const moduleRef = await Test.createTestingModule({ @@ -130,24 +130,24 @@ describe("OrganizationsRoutingFormsResponsesController", () => { operator: "equal", value: ["answer1"], valueSrc: ["value"], - valueType: ["text"] - } - } - } + valueType: ["text"], + }, + }, + }, }, action: { type: "eventTypeRedirectUrl", eventTypeId: routingEventType.id, - value: `team/${team.slug}/${routingEventType.slug}` + value: `team/${team.slug}/${routingEventType.slug}`, }, - isFallback: false + isFallback: false, }, { id: "fallback-route", action: { type: "customPageMessage", value: "Fallback Message" }, isFallback: true, - queryValue: { id: "fallback-route", type: "group" } - } + queryValue: { id: "fallback-route", type: "group" }, + }, ], fields: [ { @@ -155,18 +155,18 @@ describe("OrganizationsRoutingFormsResponsesController", () => { type: "text", label: "Question 1", required: true, - identifier: "question1" + identifier: "question1", }, { - id: "question2", + id: "question2", type: "text", label: "Question 2", required: false, - identifier: "question2" - } + identifier: "question2", + }, ], settings: { - emailOwnerOnSubmission: false + emailOwnerOnSubmission: false, }, teamId: team.id, userId: user.id, @@ -245,7 +245,9 @@ describe("OrganizationsRoutingFormsResponsesController", () => { describe(`POST /v2/organizations/:orgId/routing-forms/:routingFormId/responses`, () => { it("should return 403 when organization does not exist", async () => { return request(app.getHttpServer()) - .post(`/v2/organizations/99999/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06`) + .post( + `/v2/organizations/99999/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06` + ) .set({ Authorization: `Bearer cal_test_${apiKeyString}` }) .send({ question1: "answer1", @@ -255,7 +257,9 @@ describe("OrganizationsRoutingFormsResponsesController", () => { it("should return 404 when routing form does not exist", async () => { return request(app.getHttpServer()) - .post(`/v2/organizations/${org.id}/routing-forms/non-existent-id/responses?start=2050-09-05&end=2050-09-06`) + .post( + `/v2/organizations/${org.id}/routing-forms/non-existent-id/responses?start=2050-09-05&end=2050-09-06` + ) .set({ Authorization: `Bearer cal_test_${apiKeyString}` }) .send({ question1: "answer1", @@ -265,7 +269,9 @@ describe("OrganizationsRoutingFormsResponsesController", () => { it("should return 401 when authentication token is missing", async () => { return request(app.getHttpServer()) - .post(`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06`) + .post( + `/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06` + ) .send({ question1: "answer1", }) @@ -274,7 +280,9 @@ describe("OrganizationsRoutingFormsResponsesController", () => { it("should create response and return available slots when routing to event type", async () => { return request(app.getHttpServer()) - .post(`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06`) + .post( + `/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06` + ) .set({ Authorization: `Bearer cal_test_${apiKeyString}` }) .send({ question1: "answer1", // This matches the route condition @@ -296,7 +304,9 @@ describe("OrganizationsRoutingFormsResponsesController", () => { it("should return 400 when required form fields are missing", async () => { return request(app.getHttpServer()) - .post(`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06`) + .post( + `/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06` + ) .set({ Authorization: `Bearer cal_test_${apiKeyString}` }) .send({ question2: "answer2", // Missing required question1 @@ -306,7 +316,9 @@ describe("OrganizationsRoutingFormsResponsesController", () => { it("should create response and return custom message if the routing is to custom page", async () => { return request(app.getHttpServer()) - .post(`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06`) + .post( + `/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06` + ) .set({ Authorization: `Bearer cal_test_${apiKeyString}` }) .send({ question1: "different-answer", // This won't match any route @@ -334,7 +346,7 @@ describe("OrganizationsRoutingFormsResponsesController", () => { }) .expect(400); - // Missing end parameter + // Missing end parameter await request(app.getHttpServer()) .post(`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05`) .set({ Authorization: `Bearer cal_test_${apiKeyString}` }) @@ -347,7 +359,9 @@ describe("OrganizationsRoutingFormsResponsesController", () => { it("should return 400 when date parameters have invalid format", async () => { return request(app.getHttpServer()) - .post(`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=invalid-date&end=2050-09-06`) + .post( + `/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=invalid-date&end=2050-09-06` + ) .set({ Authorization: `Bearer cal_test_${apiKeyString}` }) .send({ question1: "answer1", @@ -357,7 +371,9 @@ describe("OrganizationsRoutingFormsResponsesController", () => { it("should return 400 when end date is before start date", async () => { return request(app.getHttpServer()) - .post(`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-10&end=2050-09-05`) + .post( + `/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-10&end=2050-09-05` + ) .set({ Authorization: `Bearer cal_test_${apiKeyString}` }) .send({ question1: "answer1", @@ -371,10 +387,15 @@ describe("OrganizationsRoutingFormsResponsesController", () => { email: `unauthorized-user-${randomString()}@api.com`, }); - const { keyString: unauthorizedApiKey } = await apiKeysRepositoryFixture.createApiKey(unauthorizedUser.id, null); + const { keyString: unauthorizedApiKey } = await apiKeysRepositoryFixture.createApiKey( + unauthorizedUser.id, + null + ); const response = await request(app.getHttpServer()) - .post(`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06`) + .post( + `/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06` + ) .set({ Authorization: `Bearer cal_test_${unauthorizedApiKey}` }) .send({ question1: "answer1", @@ -390,7 +411,9 @@ describe("OrganizationsRoutingFormsResponsesController", () => { it("should handle queued response creation", async () => { return request(app.getHttpServer()) - .post(`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06&queueResponse=true`) + .post( + `/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06&queueResponse=true` + ) .set({ Authorization: `Bearer cal_test_${apiKeyString}` }) .send({ question1: "answer1", @@ -426,24 +449,24 @@ describe("OrganizationsRoutingFormsResponsesController", () => { operator: "equal", value: ["answer1"], valueSrc: ["value"], - valueType: ["text"] - } - } - } + valueType: ["text"], + }, + }, + }, }, action: { type: "eventTypeRedirectUrl", eventTypeId: 99999, // Invalid event type ID - value: `team/${team.slug}/non-existent-event-type` + value: `team/${team.slug}/non-existent-event-type`, }, - isFallback: false + isFallback: false, }, { id: "fallback-route", action: { type: "customPageMessage", value: "Fallback Message" }, isFallback: true, - queryValue: { id: "fallback-route", type: "group" } - } + queryValue: { id: "fallback-route", type: "group" }, + }, ], fields: [ { @@ -451,11 +474,11 @@ describe("OrganizationsRoutingFormsResponsesController", () => { type: "text", label: "Question 1", required: true, - identifier: "question1" - } + identifier: "question1", + }, ], settings: { - emailOwnerOnSubmission: false + emailOwnerOnSubmission: false, }, teamId: team.id, userId: user.id, @@ -464,7 +487,9 @@ describe("OrganizationsRoutingFormsResponsesController", () => { // Try to create a response for the form with invalid event type const response = await request(app.getHttpServer()) - .post(`/v2/organizations/${org.id}/routing-forms/${routingFormWithInvalidEventType.id}/responses?start=2050-09-05&end=2050-09-06`) + .post( + `/v2/organizations/${org.id}/routing-forms/${routingFormWithInvalidEventType.id}/responses?start=2050-09-05&end=2050-09-06` + ) .set({ Authorization: `Bearer cal_test_${apiKeyString}` }) .send({ question1: "answer1", @@ -474,7 +499,7 @@ describe("OrganizationsRoutingFormsResponsesController", () => { // Clean up the form await prismaWriteService.prisma.app_RoutingForms_Form.delete({ - where: { id: routingFormWithInvalidEventType.id } + where: { id: routingFormWithInvalidEventType.id }, }); }); @@ -482,7 +507,9 @@ describe("OrganizationsRoutingFormsResponsesController", () => { // This test verifies that routing forms can handle team member assignments // and that the routing returns the correct team member information return request(app.getHttpServer()) - .post(`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06`) + .post( + `/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06` + ) .set({ Authorization: `Bearer cal_test_${apiKeyString}` }) .send({ question1: "answer1", // This matches the route condition @@ -524,23 +551,23 @@ describe("OrganizationsRoutingFormsResponsesController", () => { operator: "equal", value: ["external"], valueSrc: ["value"], - valueType: ["text"] - } - } - } + valueType: ["text"], + }, + }, + }, }, action: { type: "externalRedirectUrl", - value: "https://example.com/external-booking" + value: "https://example.com/external-booking", }, - isFallback: false + isFallback: false, }, { id: "fallback-route", action: { type: "customPageMessage", value: "Fallback Message" }, isFallback: true, - queryValue: { id: "fallback-route", type: "group" } - } + queryValue: { id: "fallback-route", type: "group" }, + }, ], fields: [ { @@ -548,11 +575,11 @@ describe("OrganizationsRoutingFormsResponsesController", () => { type: "text", label: "Question 1", required: true, - identifier: "question1" - } + identifier: "question1", + }, ], settings: { - emailOwnerOnSubmission: false + emailOwnerOnSubmission: false, }, teamId: team.id, userId: user.id, @@ -560,7 +587,9 @@ describe("OrganizationsRoutingFormsResponsesController", () => { }); const response = await request(app.getHttpServer()) - .post(`/v2/organizations/${org.id}/routing-forms/${externalRoutingForm.id}/responses?start=2050-09-05&end=2050-09-06`) + .post( + `/v2/organizations/${org.id}/routing-forms/${externalRoutingForm.id}/responses?start=2050-09-05&end=2050-09-06` + ) .set({ Authorization: `Bearer cal_test_${apiKeyString}` }) .send({ question1: "external", // This matches the route condition for external redirect @@ -574,7 +603,7 @@ describe("OrganizationsRoutingFormsResponsesController", () => { expect(data.routingExternalRedirectUrl).toBeDefined(); expect(data.routingExternalRedirectUrl).toContain("https://example.com/external-booking"); expect(data.routingExternalRedirectUrl).toContain("cal.action=externalRedirectUrl"); - + // Verify that it doesn't contain event type routing data expect(data.eventTypeId).toBeUndefined(); expect(data.slots).toBeUndefined(); @@ -583,10 +612,9 @@ describe("OrganizationsRoutingFormsResponsesController", () => { // Clean up the external routing form await prismaWriteService.prisma.app_RoutingForms_Form.delete({ - where: { id: externalRoutingForm.id } + where: { id: externalRoutingForm.id }, }); }); - }); describe(`PATCH /v2/organizations/:orgId/routing-forms/:routingFormId/responses/:responseId`, () => { diff --git a/apps/api/v2/src/modules/organizations/routing-forms/inputs/create-routing-form-response.input.ts b/apps/api/v2/src/modules/organizations/routing-forms/inputs/create-routing-form-response.input.ts index 40847bda57..2eb1c49f2c 100644 --- a/apps/api/v2/src/modules/organizations/routing-forms/inputs/create-routing-form-response.input.ts +++ b/apps/api/v2/src/modules/organizations/routing-forms/inputs/create-routing-form-response.input.ts @@ -6,9 +6,9 @@ import { GetAvailableSlotsInput_2024_09_04 } from "@calcom/platform-types"; export class CreateRoutingFormResponseInput extends GetAvailableSlotsInput_2024_09_04 { @Transform(({ value }: { value: string | boolean }) => { - if (typeof value === 'boolean') return value; - if (typeof value === 'string') { - return value.toLowerCase() === 'true'; + if (typeof value === "boolean") return value; + if (typeof value === "string") { + return value.toLowerCase() === "true"; } return undefined; }) @@ -20,4 +20,4 @@ export class CreateRoutingFormResponseInput extends GetAvailableSlotsInput_2024_ example: true, }) queueResponse?: boolean; -} \ No newline at end of file +} diff --git a/apps/api/v2/src/modules/organizations/routing-forms/outputs/create-routing-form-response.output.ts b/apps/api/v2/src/modules/organizations/routing-forms/outputs/create-routing-form-response.output.ts index dd76a6ecfc..e61d13ba3c 100644 --- a/apps/api/v2/src/modules/organizations/routing-forms/outputs/create-routing-form-response.output.ts +++ b/apps/api/v2/src/modules/organizations/routing-forms/outputs/create-routing-form-response.output.ts @@ -2,7 +2,12 @@ import { ApiProperty, ApiPropertyOptional, getSchemaPath } from "@nestjs/swagger import { Type } from "class-transformer"; import { IsArray, IsBoolean, IsInt, IsNumber, IsOptional, IsString, ValidateNested } from "class-validator"; -import { ApiResponseWithoutData, SlotsOutput_2024_09_04, RangeSlotsOutput_2024_09_04 } from "@calcom/platform-types"; +import { + ApiResponseWithoutData, + SlotsOutput_2024_09_04, + RangeSlotsOutput_2024_09_04, +} from "@calcom/platform-types"; + class Routing { @ApiProperty({ type: String, @@ -133,4 +138,4 @@ export class CreateRoutingFormResponseOutput extends ApiResponseWithoutData { @ApiProperty({ type: CreateRoutingFormResponseOutputData }) @Type(() => CreateRoutingFormResponseOutputData) data!: CreateRoutingFormResponseOutputData; -} \ No newline at end of file +} diff --git a/apps/api/v2/src/modules/organizations/routing-forms/services/organizations-routing-forms-responses.service.ts b/apps/api/v2/src/modules/organizations/routing-forms/services/organizations-routing-forms-responses.service.ts index dbe859e7d3..3da86f0d81 100644 --- a/apps/api/v2/src/modules/organizations/routing-forms/services/organizations-routing-forms-responses.service.ts +++ b/apps/api/v2/src/modules/organizations/routing-forms/services/organizations-routing-forms-responses.service.ts @@ -2,7 +2,12 @@ import { OrganizationsRoutingFormsRepository } from "@/modules/organizations/rou import { OrganizationsTeamsRoutingFormsResponsesOutputService } from "@/modules/organizations/teams/routing-forms/services/organizations-teams-routing-forms-responses-output.service"; import { SlotsService_2024_09_04 } from "@/modules/slots/slots-2024-09-04/services/slots.service"; import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-event-types.repository"; -import { BadRequestException, Injectable, NotFoundException, InternalServerErrorException } from "@nestjs/common"; +import { + BadRequestException, + Injectable, + NotFoundException, + InternalServerErrorException, +} from "@nestjs/common"; import { Request } from "express"; import { getRoutedUrl } from "@calcom/platform-libraries"; @@ -71,10 +76,14 @@ export class OrganizationsRoutingFormsResponsesService { request: Request ): Promise { const { queueResponse, ...slotsQuery } = query; - + this.validateDateRange(slotsQuery.start, slotsQuery.end); - - const { redirectUrl, customMessage } = await this.getRoutingUrl(request, routingFormId, queueResponse ?? false); + + const { redirectUrl, customMessage } = await this.getRoutingUrl( + request, + routingFormId, + queueResponse ?? false + ); // If there is no redirect URL, then we have to show the message as that would be custom page message to be shown as per the route chosen if (!redirectUrl) { @@ -86,7 +95,7 @@ export class OrganizationsRoutingFormsResponsesService { if (!this.isEventTypeRedirectUrl(redirectUrl)) { return { routingExternalRedirectUrl: redirectUrl.toString(), - } + }; } // Extract event type information from the routed URL @@ -123,9 +132,11 @@ export class OrganizationsRoutingFormsResponsesService { slots, }; } - + if (!queuedResponseId) { - throw new InternalServerErrorException("No routing form response ID or queued form response ID could be found."); + throw new InternalServerErrorException( + "No routing form response ID or queued form response ID could be found." + ); } return { @@ -145,7 +156,7 @@ export class OrganizationsRoutingFormsResponsesService { private validateDateRange(start: string, end: string) { const startDate = new Date(start); const endDate = new Date(end); - + if (endDate < startDate) { throw new BadRequestException("End date cannot be before start date."); } @@ -153,10 +164,13 @@ export class OrganizationsRoutingFormsResponsesService { private async getRoutingUrl(request: Request, formId: string, queueResponse: boolean) { const params = Object.fromEntries(new URLSearchParams(request.body)); - const routedUrlData = await getRoutedUrl({ - req: request, - query: { ...params, form: formId, ...(queueResponse && { "cal.queueFormResponse": "true" }) }, - }, true); + const routedUrlData = await getRoutedUrl( + { + req: request, + query: { ...params, form: formId, ...(queueResponse && { "cal.queueFormResponse": "true" }) }, + }, + true + ); if (routedUrlData.notFound) { throw new NotFoundException("Routing form not found."); @@ -186,8 +200,6 @@ export class OrganizationsRoutingFormsResponsesService { } private async extractEventTypeAndCrmParams(routingUrl: URL) { - - // Extract team and event type information const { teamId, eventTypeSlug } = this.extractTeamIdAndEventTypeSlugFromRedirectUrl(routingUrl); const eventType = await this.teamsEventTypesRepository.getEventTypeByTeamIdAndSlug(teamId, eventTypeSlug); diff --git a/apps/api/v2/src/modules/organizations/teams/routing-forms/organizations-teams-routing-forms.module.ts b/apps/api/v2/src/modules/organizations/teams/routing-forms/organizations-teams-routing-forms.module.ts index 396dc07e96..4d46f91ef0 100644 --- a/apps/api/v2/src/modules/organizations/teams/routing-forms/organizations-teams-routing-forms.module.ts +++ b/apps/api/v2/src/modules/organizations/teams/routing-forms/organizations-teams-routing-forms.module.ts @@ -20,7 +20,14 @@ import { OrganizationsTeamsRoutingFormsResponsesService } from "./services/organ import { OrganizationsTeamsRoutingFormsService } from "./services/organizations-teams-routing-forms.service"; @Module({ - imports: [PrismaModule, StripeModule, RedisModule, RoutingFormsModule, SlotsModule_2024_09_04, TeamsEventTypesModule], + imports: [ + PrismaModule, + StripeModule, + RedisModule, + RoutingFormsModule, + SlotsModule_2024_09_04, + TeamsEventTypesModule, + ], providers: [ OrganizationsTeamsRoutingFormsService, OrganizationsTeamsRoutingFormsResponsesService, diff --git a/apps/api/v2/src/modules/router/controllers/router.controller.ts b/apps/api/v2/src/modules/router/controllers/router.controller.ts index ff25c2fe7c..bb0c403f8d 100644 --- a/apps/api/v2/src/modules/router/controllers/router.controller.ts +++ b/apps/api/v2/src/modules/router/controllers/router.controller.ts @@ -25,7 +25,6 @@ export class RouterController { @Param("formId") formId: string, @Body() body?: Record ): Promise & { redirect: boolean })> { - const params = Object.fromEntries(new URLSearchParams(body ?? {})); const routedUrlData = await getRoutedUrl({ req: request, query: { ...params, form: formId } }); if (routedUrlData?.notFound) { diff --git a/apps/web/components/setup/AdminUser.tsx b/apps/web/components/setup/AdminUser.tsx index 299aa855da..d5d3219625 100644 --- a/apps/web/components/setup/AdminUser.tsx +++ b/apps/web/components/setup/AdminUser.tsx @@ -9,6 +9,7 @@ import { isPasswordValid } from "@calcom/features/auth/lib/isPasswordValid"; import { WEBSITE_URL } from "@calcom/lib/constants"; import { emailRegex } from "@calcom/lib/emailSchema"; import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { Button } from "@calcom/ui/components/button"; import { EmptyScreen } from "@calcom/ui/components/empty-screen"; import { EmailField, Label, TextField, PasswordField } from "@calcom/ui/components/form"; @@ -34,7 +35,12 @@ export const AdminUserContainer = (props: React.ComponentProps return ; }; -export const AdminUser = (props: { onSubmit: () => void; onError: () => void; onSuccess: () => void }) => { +export const AdminUser = (props: { + onSubmit: () => void; + onError: () => void; + onSuccess: () => void; + nav: { onNext: () => void; onPrev: () => void }; +}) => { const { t } = useLocale(); const formSchema = z.object({ @@ -118,7 +124,7 @@ export const AdminUser = (props: { onSubmit: () => void; onError: () => void; on + {process.env.NEXT_PUBLIC_WEBSITE_URL}/ ) @@ -187,6 +193,15 @@ export const AdminUser = (props: { onSubmit: () => void; onError: () => void; on )} /> +
+ +
); diff --git a/apps/web/components/setup/LicenseSelection.tsx b/apps/web/components/setup/LicenseSelection.tsx new file mode 100644 index 0000000000..b7714e55a8 --- /dev/null +++ b/apps/web/components/setup/LicenseSelection.tsx @@ -0,0 +1,255 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import * as RadioGroup from "@radix-ui/react-radio-group"; +import classNames from "classnames"; +import Link from "next/link"; +import { useCallback, useState } from "react"; +import { Controller, FormProvider, useForm, useFormState } from "react-hook-form"; +import { z } from "zod"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import type { RouterInputs, RouterOutputs } from "@calcom/trpc/react"; +import { trpc } from "@calcom/trpc/react"; +import { Button } from "@calcom/ui/components/button"; +import { TextField } from "@calcom/ui/components/form"; +import { Icon } from "@calcom/ui/components/icon"; + +type LicenseSelectionFormValues = { + licenseKey: string; + signatureToken?: string; +}; + +type LicenseOption = "FREE" | "EXISTING"; + +const LicenseSelection = ( + props: { + value: LicenseOption; + onChange: (value: LicenseOption) => void; + onSubmit: (value: LicenseSelectionFormValues) => void; + onSuccess?: ( + data: RouterOutputs["viewer"]["deploymentSetup"]["update"], + variables: RouterInputs["viewer"]["deploymentSetup"]["update"] + ) => void; + onPrevStep: () => void; + onNextStep: () => void; + } & Omit +) => { + const { + value: initialValue = "FREE", + onChange, + onSubmit, + onSuccess, + onPrevStep, + onNextStep, + ...rest + } = props; + const [value, setValue] = useState(initialValue); + const { t } = useLocale(); + const mutation = trpc.viewer.deploymentSetup.update.useMutation({ + onSuccess, + }); + + const [licenseKeyInput, setLicenseKeyInput] = useState(""); + const [licenseTouched, setLicenseTouched] = useState(false); + + // Use TRPC query for validation + const { data: licenseValidation, isLoading: checkLicenseLoading } = + trpc.viewer.deploymentSetup.validateLicense.useQuery( + { licenseKey: licenseKeyInput }, + { + enabled: licenseTouched && licenseKeyInput.trim().length > 0, + } + ); + + const schemaLicenseKey = useCallback( + () => + z.object({ + licenseKey: z + .string() + .min(1, t("license_key_required")) + .refine(() => !licenseTouched || (licenseValidation ? licenseValidation.valid : true), { + message: licenseValidation?.message || t("invalid_license_key"), + }), + signatureToken: z.string().optional(), + }), + [licenseValidation, licenseTouched, t] + ); + + const formMethods = useForm({ + defaultValues: { + licenseKey: "", + signatureToken: "", + }, + resolver: zodResolver(schemaLicenseKey()), + }); + + const handleSubmit = formMethods.handleSubmit((values) => { + onSubmit(values); + if (value === "EXISTING" && values.licenseKey) { + mutation.mutate(values); + } + }); + + const { isDirty, errors } = useFormState(formMethods); + + const handleRadioChange = (newValue: LicenseOption) => { + setValue(newValue); + onChange(newValue); + }; + + return ( +
{ + e.preventDefault(); + if (value === "FREE") { + onSubmit({ licenseKey: "", signatureToken: "" }); + } else { + handleSubmit(); + } + }}> + handleRadioChange(value as LicenseOption)}> + +
+

{t("agplv3_license")}

+

{t("free_license_fee")}

+

{t("forever_open_and_free")}

+
    +
  • {t("required_to_keep_your_code_open_source")}
  • +
  • {t("cannot_repackage_and_resell")}
  • +
  • {t("no_enterprise_features")}
  • +
+
+
+ + +
+

{t("enter_license_key")}

+

{t("enter_existing_license")}

+

{t("enter_your_license_key")}

+

+ {t("need_a_license")}{" "} + + {t("purchase_license")} + +

+
+
+
+ + {value === "EXISTING" && ( + +
+
+
+ ( + + ) : licenseValidation?.valid && licenseTouched ? ( + + ) : undefined + } + color={errors.licenseKey ? "warn" : ""} + onBlur={(e) => { + setLicenseTouched(true); + onBlur(); + }} + onChange={(e) => { + setLicenseKeyInput(e.target.value); + onChange(e.target.value); + }} + /> + )} + /> + {errors.licenseKey && ( +

{errors.licenseKey.message}

+ )} +
+ +
+ ( + ) => { + onChange(e.target.value); + formMethods.setValue("signatureToken", e.target.value); + }} + /> + )} + /> +

{t("signature_token_description")}

+
+
+
+
+ )} + +
+ + {value === "EXISTING" ? ( + + ) : ( + + )} +
+
+ ); +}; + +export default LicenseSelection; diff --git a/apps/web/modules/auth/setup-view.tsx b/apps/web/modules/auth/setup-view.tsx index 7943640321..2cf52db3b1 100644 --- a/apps/web/modules/auth/setup-view.tsx +++ b/apps/web/modules/auth/setup-view.tsx @@ -1,104 +1,102 @@ "use client"; -import { usePathname, useRouter } from "next/navigation"; -import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { useMemo, useState } from "react"; import AdminAppsList from "@calcom/features/apps/AdminAppsList"; import { APP_NAME } from "@calcom/lib/constants"; -import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import type { inferSSRProps } from "@calcom/types/inferSSRProps"; import { WizardForm } from "@calcom/ui/components/form"; +import type { WizardStep } from "@calcom/ui/components/form/wizard/WizardForm"; import { AdminUserContainer as AdminUser } from "@components/setup/AdminUser"; -import ChooseLicense from "@components/setup/ChooseLicense"; -import EnterpriseLicense from "@components/setup/EnterpriseLicense"; +import LicenseSelection from "@components/setup/LicenseSelection"; import type { getServerSideProps } from "@server/lib/setup/getServerSideProps"; -function useSetStep() { - const router = useRouter(); - const searchParams = useCompatSearchParams(); - const pathname = usePathname(); - const setStep = (newStep = 1) => { - const _searchParams = new URLSearchParams(searchParams ?? undefined); - _searchParams.set("step", newStep.toString()); - router.replace(`${pathname}?${_searchParams.toString()}`); - }; - return setStep; -} +const SETUP_VIEW_SETPS = { + ADMIN_USER: 1, + LICENSE: 2, + APPS: 3, +} as const; export type PageProps = inferSSRProps; export function Setup(props: PageProps) { + const [hasPickedAGPLv3, setHasPickedAGPLv3] = useState(false); const { t } = useLocale(); const router = useRouter(); - const [value, setValue] = useState(props.isFreeLicense ? "FREE" : "EE"); - const isFreeLicense = value === "FREE"; - const [isEnabledEE, setIsEnabledEE] = useState(!props.isFreeLicense); - const setStep = useSetStep(); + const [licenseOption, setLicenseOption] = useState<"FREE" | "EXISTING">( + props.hasValidLicense ? "EXISTING" : "FREE" + ); - const steps: React.ComponentProps["steps"] = [ + const defaultStep = useMemo(() => { + if (props.userCount > 0) { + if (!props.hasValidLicense && !hasPickedAGPLv3) { + return SETUP_VIEW_SETPS.LICENSE; + } else { + return SETUP_VIEW_SETPS.APPS; + } + } + return SETUP_VIEW_SETPS.ADMIN_USER; + }, [props.userCount, props.hasValidLicense, hasPickedAGPLv3]); + + const steps: WizardStep[] = [ { title: t("administrator_user"), description: t("lets_create_first_administrator_user"), - content: (setIsPending) => ( + customActions: true, + content: (setIsPending, nav) => ( { setIsPending(true); }} onSuccess={() => { - setStep(2); + // If there's already a valid license or user picked AGPLv3, skip to apps step + if (props.hasValidLicense || hasPickedAGPLv3) { + nav.onNext(); + nav.onNext(); // Skip license step + } else { + nav.onNext(); + } }} onError={() => { setIsPending(false); }} userCount={props.userCount} + nav={nav} /> ), }, - { - title: t("choose_a_license"), - description: t("choose_license_description"), - content: (setIsPending) => { - return ( - { - setIsPending(true); - setStep(3); - }} - /> - ); - }, - }, ]; - if (!isFreeLicense) { + // Only show license selection step if there's no valid license already and AGPLv3 wasn't picked + if (!props.hasValidLicense && !hasPickedAGPLv3) { steps.push({ - title: t("step_enterprise_license"), - description: t("step_enterprise_license_description"), - content: (setIsPending) => { - const currentStep = 3; + title: t("choose_a_license"), + description: t("choose_license_description"), + customActions: true, + content: (setIsPending, nav) => { return ( - { + { setIsPending(true); + if (licenseOption === "FREE") { + setHasPickedAGPLv3(true); + nav.onNext(); + } else if (licenseOption === "EXISTING" && values.licenseKey) { + nav.onNext(); + } }} - onSuccess={() => { - setStep(currentStep + 1); - }} - onSuccessValidate={() => { - setIsEnabledEE(true); - }} + onPrevStep={nav.onPrev} + onNextStep={nav.onNext} /> ); }, - isEnabled: isEnabledEE, }); } @@ -106,23 +104,24 @@ export function Setup(props: PageProps) { title: t("enable_apps"), description: t("enable_apps_description", { appName: APP_NAME }), contentClassname: "!pb-0 mb-[-1px]", - content: (setIsPending) => { - const currentStep = isFreeLicense ? 3 : 4; + customActions: true, + content: (setIsPending, nav) => { return ( { setIsPending(true); router.replace("/"); }} + nav={nav} /> ); }, @@ -131,7 +130,7 @@ export function Setup(props: PageProps) { return (
void; + nav?: { onNext: () => void; onPrev: () => void }; } & Omit) => { + const { t } = useLocale(); return (
{ e.preventDefault(); onSubmit(); + nav?.onNext(); }}> +
+ {nav && ( + + )} + +
); }; diff --git a/packages/features/ee/api-keys/components/ApiKeyDialogForm.tsx b/packages/features/ee/api-keys/components/ApiKeyDialogForm.tsx index 1a4add9cb7..abd0cbeac7 100644 --- a/packages/features/ee/api-keys/components/ApiKeyDialogForm.tsx +++ b/packages/features/ee/api-keys/components/ApiKeyDialogForm.tsx @@ -150,7 +150,8 @@ export default function ApiKeyDialogForm({ {t("api_key_modal_subtitle")} {t("api_key_modal_subtitle_platform")} diff --git a/packages/features/ee/common/server/LicenseKeyService.test.ts b/packages/features/ee/common/server/LicenseKeyService.test.ts index ad3e5b0aa4..9390128bb0 100644 --- a/packages/features/ee/common/server/LicenseKeyService.test.ts +++ b/packages/features/ee/common/server/LicenseKeyService.test.ts @@ -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", () => { diff --git a/packages/features/ee/common/server/LicenseKeyService.ts b/packages/features/ee/common/server/LicenseKeyService.ts index afd7ae792c..092d581087 100644 --- a/packages/features/ee/common/server/LicenseKeyService.ts +++ b/packages/features/ee/common/server/LicenseKeyService.ts @@ -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 { 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; + licenseKey: string; options?: RequestInit; }): Promise { 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; - 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 { + /** 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; diff --git a/packages/features/ee/common/server/checkLicense.ts b/packages/features/ee/common/server/checkLicense.ts deleted file mode 100644 index 1de6aea6c7..0000000000 --- a/packages/features/ee/common/server/checkLicense.ts +++ /dev/null @@ -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 { - /** 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; diff --git a/packages/features/ee/deployment/lib/getDeploymentKey.test.ts b/packages/features/ee/deployment/lib/getDeploymentKey.test.ts new file mode 100644 index 0000000000..7a7adaf1c0 --- /dev/null +++ b/packages/features/ee/deployment/lib/getDeploymentKey.test.ts @@ -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 + }); + }); +}); diff --git a/packages/features/ee/deployment/lib/getDeploymentKey.ts b/packages/features/ee/deployment/lib/getDeploymentKey.ts index 7f4ec0ec2e..1fdde9aedd 100644 --- a/packages/features/ee/deployment/lib/getDeploymentKey.ts +++ b/packages/features/ee/deployment/lib/getDeploymentKey.ts @@ -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 { 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 { + 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; +} diff --git a/packages/features/ee/index.ts b/packages/features/ee/index.ts deleted file mode 100644 index a15d2b584a..0000000000 --- a/packages/features/ee/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./common/server/checkLicense"; diff --git a/packages/lib/crypto.test.ts b/packages/lib/crypto.test.ts new file mode 100644 index 0000000000..e323798eaf --- /dev/null +++ b/packages/lib/crypto.test.ts @@ -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); + }); + }); +}); diff --git a/packages/lib/server/repository/deployment.interface.ts b/packages/lib/server/repository/deployment.interface.ts index 72b176038b..9370594c50 100644 --- a/packages/lib/server/repository/deployment.interface.ts +++ b/packages/lib/server/repository/deployment.interface.ts @@ -1,3 +1,4 @@ export interface IDeploymentRepository { getLicenseKeyWithId(id: number): Promise; + getSignatureToken(id: number): Promise; } diff --git a/packages/lib/server/repository/deployment.ts b/packages/lib/server/repository/deployment.ts index 1697246c02..fd19538c78 100644 --- a/packages/lib/server/repository/deployment.ts +++ b/packages/lib/server/repository/deployment.ts @@ -15,4 +15,12 @@ export class DeploymentRepository implements IDeploymentRepository { }); return deployment?.licenseKey || null; } + + async getSignatureToken(id: number): Promise { + const deployment = await (this.prisma as PrismaClientWithoutExtensions).deployment.findUnique({ + where: { id }, + select: { signatureTokenEncrypted: true }, + }); + return deployment?.signatureTokenEncrypted || null; + } } diff --git a/packages/prisma/migrations/20250627091352_add_signature_token_to_deployment/migration.sql b/packages/prisma/migrations/20250627091352_add_signature_token_to_deployment/migration.sql new file mode 100644 index 0000000000..5da6822046 --- /dev/null +++ b/packages/prisma/migrations/20250627091352_add_signature_token_to_deployment/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Deployment" ADD COLUMN "signatureTokenEncrypted" TEXT; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 30431e937f..170e4f301e 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -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 { diff --git a/packages/trpc/server/routers/viewer/deploymentSetup/_router.tsx b/packages/trpc/server/routers/viewer/deploymentSetup/_router.tsx index 9756b9877b..78fa3938fe 100644 --- a/packages/trpc/server/routers/viewer/deploymentSetup/_router.tsx +++ b/packages/trpc/server/routers/viewer/deploymentSetup/_router.tsx @@ -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, + }); + }), }); diff --git a/packages/trpc/server/routers/viewer/deploymentSetup/update.handler.ts b/packages/trpc/server/routers/viewer/deploymentSetup/update.handler.ts index 12b19e13ad..2a3d63f740 100644 --- a/packages/trpc/server/routers/viewer/deploymentSetup/update.handler.ts +++ b/packages/trpc/server/routers/viewer/deploymentSetup/update.handler.ts @@ -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; diff --git a/packages/trpc/server/routers/viewer/deploymentSetup/update.schema.ts b/packages/trpc/server/routers/viewer/deploymentSetup/update.schema.ts index f2f499267d..d72f237321 100644 --- a/packages/trpc/server/routers/viewer/deploymentSetup/update.schema.ts +++ b/packages/trpc/server/routers/viewer/deploymentSetup/update.schema.ts @@ -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; diff --git a/packages/trpc/server/routers/viewer/deploymentSetup/validateLicense.handler.ts b/packages/trpc/server/routers/viewer/deploymentSetup/validateLicense.handler.ts new file mode 100644 index 0000000000..8b6619b60b --- /dev/null +++ b/packages/trpc/server/routers/viewer/deploymentSetup/validateLicense.handler.ts @@ -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; + }; + 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", + }; + } +}; diff --git a/packages/trpc/server/routers/viewer/deploymentSetup/validateLicense.schema.ts b/packages/trpc/server/routers/viewer/deploymentSetup/validateLicense.schema.ts new file mode 100644 index 0000000000..823997cae1 --- /dev/null +++ b/packages/trpc/server/routers/viewer/deploymentSetup/validateLicense.schema.ts @@ -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; diff --git a/packages/ui/components/form/wizard/WizardForm.tsx b/packages/ui/components/form/wizard/WizardForm.tsx index 3c17f5e0bb..10d836823c 100644 --- a/packages/ui/components/form/wizard/WizardForm.tsx +++ b/packages/ui/components/form/wizard/WizardForm.tsx @@ -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>) => JSX.Element) | JSX.Element; + content?: + | (( + setIsPending: Dispatch>, + nav: { onNext: () => void; onPrev: () => void; step: number; maxSteps: number } + ) => JSX.Element) + | JSX.Element; isEnabled?: boolean; isPending?: boolean; + customActions?: boolean; }; -function WizardForm(props: { - href: string; - steps: T[]; - disableNavigation?: boolean; +export interface WizardFormProps { + steps: WizardStep[]; containerClassname?: string; prevLabel?: string; nextLabel?: string; finishLabel?: string; stepLabel?: React.ComponentProps["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(props: { return (
-
-
+
+

- {currentStep.title} + {currentStepData.title}

- {currentStep.description} + {currentStepData.description}

- {!props.disableNavigation && ( + {!disableNavigation && ( )}
-
-
- {typeof currentStep.content === "function" - ? currentStep.content(setCurrentStepisPending) - : currentStep.content} +
+
+ {typeof currentStepData.content === "function" + ? currentStepData.content(setCurrentStepisPending, { + onNext: nextStep, + onPrev: prevStep, + step: currentStep, + maxSteps, + }) + : currentStepData.content}
- {!props.disableNavigation && ( + {!disableNavigation && !currentStepData.customActions && (
- {step > 1 && ( - )} @@ -93,10 +107,10 @@ function WizardForm(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}
)} @@ -104,5 +118,3 @@ function WizardForm(props: {
); } - -export default WizardForm; diff --git a/packages/ui/components/form/wizard/index.ts b/packages/ui/components/form/wizard/index.ts index 78965db8d4..67aca0d2e8 100644 --- a/packages/ui/components/form/wizard/index.ts +++ b/packages/ui/components/form/wizard/index.ts @@ -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"; diff --git a/packages/ui/components/form/wizard/useWizardState.ts b/packages/ui/components/form/wizard/useWizardState.ts new file mode 100644 index 0000000000..f52aeb4afb --- /dev/null +++ b/packages/ui/components/form/wizard/useWizardState.ts @@ -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, + }; +} diff --git a/packages/ui/components/form/wizard/wizardForm.test.tsx b/packages/ui/components/form/wizard/wizardForm.test.tsx index b241064412..7ce114dd96 100644 --- a/packages/ui/components/form/wizard/wizardForm.test.tsx +++ b/packages/ui/components/form/wizard/wizardForm.test.tsx @@ -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",