diff --git a/.github/workflows/docs-build.yml b/.github/workflows/docs-build.yml new file mode 100644 index 00000000000000..49aec7a0da75ec --- /dev/null +++ b/.github/workflows/docs-build.yml @@ -0,0 +1,41 @@ +# This is just to test this file +name: Build + +on: + workflow_call: + +jobs: + build: + name: Build Docs + permissions: + contents: read + runs-on: buildjet-2vcpu-ubuntu-2204 + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/dangerous-git-checkout + - name: Cache Docs build + uses: buildjet/cache@v4 + id: cache-docs-build + env: + cache-name: docs-build + key-1: ${{ hashFiles('yarn.lock') }} + key-2: ${{ hashFiles('docs/**.*', '!**/node_modules') }} + key-3: ${{ github.event.pull_request.number || github.ref }} + key-4: ${{ github.sha }} + with: + path: | + **/docs/** + key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.key-1 }}-${{ env.key-2 }}-${{ env.key-3 }}-${{ env.key-4 }} + - name: Run build + working-directory: docs + run: | + export NODE_OPTIONS="--max_old_space_size=8192" + if [ ${{ steps.cache-docs-build.outputs.cache-hit }} == 'true' ]; then + echo "Cache hit for Docs build. Skipping build." + else + npm install -g mintlify + mintlify dev & + sleep 5 # Let it run for 5 seconds + kill $! + fi + shell: bash diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 707b6fb803e25f..41d7e78bb5cbda 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -139,6 +139,13 @@ jobs: uses: ./.github/workflows/atoms-production-build.yml secrets: inherit + build-docs: + name: Production builds + needs: [changes, check-label, deps] + if: ${{ needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }} + uses: ./.github/workflows/docs-build.yml + secrets: inherit + build: name: Production builds needs: [changes, check-label, deps] @@ -225,6 +232,7 @@ jobs: build-api-v1, build-api-v2, build-atoms, + build-docs, e2e, e2e-api-v2, e2e-embed, diff --git a/.github/workflows/production-build-without-database.yml b/.github/workflows/production-build-without-database.yml index 3790f6f3ad57d9..6d787eeefdca12 100644 --- a/.github/workflows/production-build-without-database.yml +++ b/.github/workflows/production-build-without-database.yml @@ -39,7 +39,7 @@ env: jobs: build: - name: Web App + name: Build Web App runs-on: buildjet-4vcpu-ubuntu-2204 timeout-minutes: 30 steps: diff --git a/apps/api/v1/lib/validations/attendee.ts b/apps/api/v1/lib/validations/attendee.ts index b1be1c2024b455..17338f0e8fa463 100644 --- a/apps/api/v1/lib/validations/attendee.ts +++ b/apps/api/v1/lib/validations/attendee.ts @@ -1,5 +1,6 @@ import { z } from "zod"; +import { emailSchema } from "@calcom/lib/emailSchema"; import { _AttendeeModel as Attendee } from "@calcom/prisma/zod"; import { timeZone } from "~/lib/validations/shared/timeZone"; @@ -14,7 +15,7 @@ export const schemaAttendeeBaseBodyParams = Attendee.pick({ const schemaAttendeeCreateParams = z .object({ bookingId: z.number().int(), - email: z.string().email(), + email: emailSchema, name: z.string(), timeZone: timeZone, }) @@ -23,7 +24,7 @@ const schemaAttendeeCreateParams = z const schemaAttendeeEditParams = z .object({ name: z.string().optional(), - email: z.string().email().optional(), + email: emailSchema.optional(), timeZone: timeZone.optional(), }) .strict(); diff --git a/apps/api/v1/lib/validations/shared/queryAttendeeEmail.ts b/apps/api/v1/lib/validations/shared/queryAttendeeEmail.ts index d7919bf53fd2d4..c90abc5b421e88 100644 --- a/apps/api/v1/lib/validations/shared/queryAttendeeEmail.ts +++ b/apps/api/v1/lib/validations/shared/queryAttendeeEmail.ts @@ -1,16 +1,18 @@ import { withValidation } from "next-validations"; import { z } from "zod"; +import { emailSchema } from "@calcom/lib/emailSchema"; + import { baseApiParams } from "./baseApiParams"; // Extracted out as utility function so can be reused // at different endpoints that require this validation. export const schemaQueryAttendeeEmail = baseApiParams.extend({ - attendeeEmail: z.string().email(), + attendeeEmail: emailSchema, }); export const schemaQuerySingleOrMultipleAttendeeEmails = z.object({ - attendeeEmail: z.union([z.string().email(), z.array(z.string().email())]).optional(), + attendeeEmail: z.union([emailSchema, z.array(emailSchema)]).optional(), }); export const withValidQueryAttendeeEmail = withValidation({ diff --git a/apps/api/v1/lib/validations/shared/queryUserEmail.ts b/apps/api/v1/lib/validations/shared/queryUserEmail.ts index 342f36264adb70..49f96f6354ba7b 100644 --- a/apps/api/v1/lib/validations/shared/queryUserEmail.ts +++ b/apps/api/v1/lib/validations/shared/queryUserEmail.ts @@ -1,16 +1,18 @@ import { withValidation } from "next-validations"; import { z } from "zod"; +import { emailSchema } from "@calcom/lib/emailSchema"; + import { baseApiParams } from "./baseApiParams"; // Extracted out as utility function so can be reused // at different endpoints that require this validation. export const schemaQueryUserEmail = baseApiParams.extend({ - email: z.string().email(), + email: emailSchema, }); export const schemaQuerySingleOrMultipleUserEmails = z.object({ - email: z.union([z.string().email(), z.array(z.string().email())]), + email: z.union([emailSchema, z.array(emailSchema)]), }); export const withValidQueryUserEmail = withValidation({ diff --git a/apps/api/v1/lib/validations/user.ts b/apps/api/v1/lib/validations/user.ts index d9846130665f82..2b86addd213e67 100644 --- a/apps/api/v1/lib/validations/user.ts +++ b/apps/api/v1/lib/validations/user.ts @@ -1,5 +1,6 @@ import { z } from "zod"; +import { emailSchema } from "@calcom/lib/emailSchema"; import { checkUsername } from "@calcom/lib/server/checkUsername"; import { _UserModel as User } from "@calcom/prisma/zod"; import { iso8601 } from "@calcom/prisma/zod-utils"; @@ -92,7 +93,7 @@ export const schemaUserBaseBodyParams = User.pick({ // Here we can both require or not (adding optional or nullish) and also rewrite validations for any value // for example making weekStart only accept weekdays as input const schemaUserEditParams = z.object({ - email: z.string().email().toLowerCase(), + email: emailSchema.toLowerCase(), username: usernameSchema, weekStart: z.nativeEnum(weekdays).optional(), brandColor: z.string().min(4).max(9).regex(/^#/).optional(), @@ -115,7 +116,7 @@ const schemaUserEditParams = z.object({ // merging both BaseBodyParams with RequiredParams, and omiting whatever we want at the end. const schemaUserCreateParams = z.object({ - email: z.string().email().toLowerCase(), + email: emailSchema.toLowerCase(), username: usernameSchema, weekStart: z.nativeEnum(weekdays).optional(), brandColor: z.string().min(4).max(9).regex(/^#/).optional(), diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/user-bookings.controller.e2e-spec.ts b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/user-bookings.controller.e2e-spec.ts index cabca2c978d01e..d1bf1082ef7d1f 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/user-bookings.controller.e2e-spec.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/user-bookings.controller.e2e-spec.ts @@ -1018,10 +1018,6 @@ describe("Bookings Endpoints 2024-08-13", () => { }); }); - function responseDataIsBooking(data: any): data is BookingOutput_2024_08_13 { - return !Array.isArray(data) && typeof data === "object" && data && "id" in data; - } - function responseDataIsRecurranceBooking(data: any): data is RecurringBookingOutput_2024_08_13 { return ( !Array.isArray(data) && @@ -1032,10 +1028,303 @@ describe("Bookings Endpoints 2024-08-13", () => { ); } - function responseDataIsRecurringBooking(data: any): data is RecurringBookingOutput_2024_08_13[] { - return Array.isArray(data); + afterAll(async () => { + await oauthClientRepositoryFixture.delete(oAuthClient.id); + await teamRepositoryFixture.delete(organization.id); + await userRepositoryFixture.deleteByEmail(user.email); + await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email); + await app.close(); + }); + }); + + function responseDataIsBooking(data: any): data is BookingOutput_2024_08_13 { + return !Array.isArray(data) && typeof data === "object" && data && "id" in data; + } + + function responseDataIsRecurringBooking(data: any): data is RecurringBookingOutput_2024_08_13[] { + return Array.isArray(data); + } + + describe("Recurring bookings", () => { + let app: INestApplication; + let organization: Team; + + let userRepositoryFixture: UserRepositoryFixture; + let bookingsRepositoryFixture: BookingsRepositoryFixture; + let schedulesService: SchedulesService_2024_04_15; + let eventTypesRepositoryFixture: EventTypesRepositoryFixture; + let oauthClientRepositoryFixture: OAuthClientRepositoryFixture; + let oAuthClient: PlatformOAuthClient; + let teamRepositoryFixture: TeamRepositoryFixture; + + const userEmail = "bookings-controller-e2e@api.com"; + let user: User; + + const maxRecurrenceCount = 3; + let recurringEventTypeId: number; + + beforeAll(async () => { + const moduleRef = await withApiAuth( + userEmail, + Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15], + }) + ) + .overrideGuard(PermissionsGuard) + .useValue({ + canActivate: () => true, + }) + .compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef); + eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef); + oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef); + teamRepositoryFixture = new TeamRepositoryFixture(moduleRef); + schedulesService = moduleRef.get(SchedulesService_2024_04_15); + + organization = await teamRepositoryFixture.create({ name: "organization bookings" }); + oAuthClient = await createOAuthClient(organization.id); + + user = await userRepositoryFixture.create({ + email: userEmail, + }); + + const userSchedule: CreateScheduleInput_2024_04_15 = { + name: "working time", + timeZone: "Europe/Rome", + isDefault: true, + }; + await schedulesService.createUserSchedule(user.id, userSchedule); + + const recurringEvent = await eventTypesRepositoryFixture.create( + // note(Lauris): freq 2 means weekly, interval 1 means every week and count 3 means 3 weeks in a row + { + title: "peer coding recurring", + slug: "peer-coding-recurring", + length: 60, + recurringEvent: { freq: 2, count: maxRecurrenceCount, interval: 1 }, + }, + user.id + ); + recurringEventTypeId = recurringEvent.id; + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + async function createOAuthClient(organizationId: number) { + const data = { + logo: "logo-url", + name: "name", + redirectUris: ["http://localhost:5555"], + permissions: 32, + }; + const secret = "secret"; + + const client = await oauthClientRepositoryFixture.create(organizationId, data, secret); + return client; } + it("should not create recurring booking with recurrenceCount larger than event type recurrence count", async () => { + const recurrenceCount = 1000; + + const body: CreateRecurringBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString(), + eventTypeId: recurringEventTypeId, + attendee: { + name: "Mr Proper Recurring", + email: "mr_proper_recurring@gmail.com", + timeZone: "Europe/Rome", + language: "it", + }, + location: "https://meet.google.com/abc-def-ghi", + recurrenceCount, + }; + + return request(app.getHttpServer()) + .post("/v2/bookings") + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(400); + }); + + it("should create a recurring booking with recurrenceCount smaller than event type recurrence count", async () => { + const recurrenceCount = maxRecurrenceCount - 1; + const body: CreateRecurringBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString(), + eventTypeId: recurringEventTypeId, + attendee: { + name: "Mr Proper Recurring", + email: "mr_proper_recurring@gmail.com", + timeZone: "Europe/Rome", + language: "it", + }, + location: "https://meet.google.com/abc-def-ghi", + recurrenceCount, + }; + + return request(app.getHttpServer()) + .post("/v2/bookings") + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201) + .then(async (response) => { + const responseBody: CreateBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseDataIsRecurringBooking(responseBody.data)).toBe(true); + + if (responseDataIsRecurringBooking(responseBody.data)) { + const data: RecurringBookingOutput_2024_08_13[] = responseBody.data; + expect(data.length).toEqual(maxRecurrenceCount - 1); + + const firstBooking = data[0]; + expect(firstBooking.id).toBeDefined(); + expect(firstBooking.uid).toBeDefined(); + expect(firstBooking.hosts[0].id).toEqual(user.id); + expect(firstBooking.status).toEqual("accepted"); + expect(firstBooking.start).toEqual(new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString()); + expect(firstBooking.end).toEqual(new Date(Date.UTC(2030, 1, 4, 14, 0, 0)).toISOString()); + expect(firstBooking.duration).toEqual(60); + expect(firstBooking.eventTypeId).toEqual(recurringEventTypeId); + expect(firstBooking.attendees[0]).toEqual({ + name: body.attendee.name, + timeZone: body.attendee.timeZone, + language: body.attendee.language, + absent: false, + }); + expect(firstBooking.location).toEqual(body.location); + expect(firstBooking.recurringBookingUid).toBeDefined(); + expect(firstBooking.absentHost).toEqual(false); + + const secondBooking = data[1]; + expect(secondBooking.id).toBeDefined(); + expect(secondBooking.uid).toBeDefined(); + expect(secondBooking.hosts[0].id).toEqual(user.id); + expect(secondBooking.status).toEqual("accepted"); + expect(secondBooking.start).toEqual(new Date(Date.UTC(2030, 1, 11, 13, 0, 0)).toISOString()); + expect(secondBooking.end).toEqual(new Date(Date.UTC(2030, 1, 11, 14, 0, 0)).toISOString()); + expect(secondBooking.duration).toEqual(60); + expect(secondBooking.eventTypeId).toEqual(recurringEventTypeId); + expect(secondBooking.recurringBookingUid).toBeDefined(); + expect(secondBooking.attendees[0]).toEqual({ + name: body.attendee.name, + timeZone: body.attendee.timeZone, + language: body.attendee.language, + absent: false, + }); + expect(secondBooking.location).toEqual(body.location); + expect(secondBooking.absentHost).toEqual(false); + } else { + throw new Error( + "Invalid response data - expected recurring booking but received non array response" + ); + } + }); + }); + + it("should create a recurring booking with recurrenceCount equal to event type recurrence count", async () => { + const recurrenceCount = maxRecurrenceCount; + const body: CreateRecurringBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString(), + eventTypeId: recurringEventTypeId, + attendee: { + name: "Mr Proper Recurring", + email: "mr_proper_recurring@gmail.com", + timeZone: "Europe/Rome", + language: "it", + }, + location: "https://meet.google.com/abc-def-ghi", + recurrenceCount, + }; + + return request(app.getHttpServer()) + .post("/v2/bookings") + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201) + .then(async (response) => { + const responseBody: CreateBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseDataIsRecurringBooking(responseBody.data)).toBe(true); + + if (responseDataIsRecurringBooking(responseBody.data)) { + const data: RecurringBookingOutput_2024_08_13[] = responseBody.data; + expect(data.length).toEqual(maxRecurrenceCount); + + const firstBooking = data[0]; + expect(firstBooking.id).toBeDefined(); + expect(firstBooking.uid).toBeDefined(); + expect(firstBooking.hosts[0].id).toEqual(user.id); + expect(firstBooking.status).toEqual("accepted"); + expect(firstBooking.start).toEqual(new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString()); + expect(firstBooking.end).toEqual(new Date(Date.UTC(2030, 1, 4, 14, 0, 0)).toISOString()); + expect(firstBooking.duration).toEqual(60); + expect(firstBooking.eventTypeId).toEqual(recurringEventTypeId); + expect(firstBooking.attendees[0]).toEqual({ + name: body.attendee.name, + timeZone: body.attendee.timeZone, + language: body.attendee.language, + absent: false, + }); + expect(firstBooking.location).toEqual(body.location); + expect(firstBooking.meetingUrl).toEqual(body.location); + expect(firstBooking.recurringBookingUid).toBeDefined(); + expect(firstBooking.absentHost).toEqual(false); + + const secondBooking = data[1]; + expect(secondBooking.id).toBeDefined(); + expect(secondBooking.uid).toBeDefined(); + expect(secondBooking.hosts[0].id).toEqual(user.id); + expect(secondBooking.status).toEqual("accepted"); + expect(secondBooking.start).toEqual(new Date(Date.UTC(2030, 1, 11, 13, 0, 0)).toISOString()); + expect(secondBooking.end).toEqual(new Date(Date.UTC(2030, 1, 11, 14, 0, 0)).toISOString()); + expect(secondBooking.duration).toEqual(60); + expect(secondBooking.eventTypeId).toEqual(recurringEventTypeId); + expect(secondBooking.recurringBookingUid).toBeDefined(); + expect(secondBooking.attendees[0]).toEqual({ + name: body.attendee.name, + timeZone: body.attendee.timeZone, + language: body.attendee.language, + absent: false, + }); + expect(secondBooking.location).toEqual(body.location); + expect(secondBooking.absentHost).toEqual(false); + + const thirdBooking = data[2]; + expect(thirdBooking.id).toBeDefined(); + expect(thirdBooking.uid).toBeDefined(); + expect(thirdBooking.hosts[0].id).toEqual(user.id); + expect(thirdBooking.status).toEqual("accepted"); + expect(thirdBooking.start).toEqual(new Date(Date.UTC(2030, 1, 18, 13, 0, 0)).toISOString()); + expect(thirdBooking.end).toEqual(new Date(Date.UTC(2030, 1, 18, 14, 0, 0)).toISOString()); + expect(thirdBooking.duration).toEqual(60); + expect(thirdBooking.eventTypeId).toEqual(recurringEventTypeId); + expect(thirdBooking.recurringBookingUid).toBeDefined(); + expect(thirdBooking.attendees[0]).toEqual({ + name: body.attendee.name, + timeZone: body.attendee.timeZone, + language: body.attendee.language, + absent: false, + }); + expect(thirdBooking.location).toEqual(body.location); + expect(thirdBooking.absentHost).toEqual(false); + } else { + throw new Error( + "Invalid response data - expected recurring booking but received non array response" + ); + } + }); + }); + + afterEach(async () => { + await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email); + }); + afterAll(async () => { await oauthClientRepositoryFixture.delete(oAuthClient.id); await teamRepositoryFixture.delete(organization.id); 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 1190a638c3cb97..2504b5fd433df8 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 @@ -5,7 +5,7 @@ import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key"; import { ApiKeyRepository } from "@/modules/api-key/api-key-repository"; import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service"; -import { Injectable, NotFoundException } from "@nestjs/common"; +import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; import { Logger } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { Request } from "express"; @@ -57,9 +57,9 @@ export enum Frequency { const recurringEventSchema = z.object({ dtstart: z.string().optional(), - interval: z.number().int().optional(), - count: z.number().int().optional(), - freq: z.nativeEnum(Frequency).optional(), + interval: z.number().int(), + count: z.number().int(), + freq: z.nativeEnum(Frequency), until: z.string().optional(), }); @@ -175,13 +175,15 @@ export class InputBookingsService_2024_08_13 { const occurrence = recurringEventSchema.parse(eventType.recurringEvent); const repeatsEvery = occurrence.interval; - const repeatsTimes = occurrence.count; - // note(Lauris): timeBetween 0=yearly, 1=monthly and 2=weekly - const timeBetween = occurrence.freq; - if (!repeatsTimes) { - throw new Error("Repeats times is required"); + if (inputBooking.recurrenceCount && inputBooking.recurrenceCount > occurrence.count) { + throw new BadRequestException( + "Provided recurrence count is higher than the event type's recurring event count." + ); } + const repeatsTimes = inputBooking.recurrenceCount || occurrence.count; + // note(Lauris): timeBetween 0=yearly, 1=monthly and 2=weekly + const timeBetween = occurrence.freq; const events = []; const recurringEventId = uuidv4(); diff --git a/apps/web/components/booking/BookingListItem.tsx b/apps/web/components/booking/BookingListItem.tsx index 0d72d910c7a20c..5d6bb9272ce110 100644 --- a/apps/web/components/booking/BookingListItem.tsx +++ b/apps/web/components/booking/BookingListItem.tsx @@ -598,9 +598,7 @@ function BookingListItem(booking: BookingItemProps) { {isUpcoming && !isCancelled ? ( <> - {isPending && (userId === booking.user?.id || booking.isUserTeamAdminOrOwner) && ( - - )} + {isPending && } {isConfirmed && } {isRejected &&
{t("rejected")}
} diff --git a/apps/web/components/dialog/EditLocationDialog.tsx b/apps/web/components/dialog/EditLocationDialog.tsx index 279dc5202a4ee5..6272e4c0a15b64 100644 --- a/apps/web/components/dialog/EditLocationDialog.tsx +++ b/apps/web/components/dialog/EditLocationDialog.tsx @@ -232,7 +232,7 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {

{t("current_location")}:

-

+

{getHumanReadableLocationValue(booking.location, t)}

({ resolver: zodResolver( z.object({ - email: z.string().email(), + email: emailSchema, }) ), }); diff --git a/apps/web/components/settings/platform/dashboard/oauth-client-dropdown/index.tsx b/apps/web/components/settings/platform/dashboard/oauth-client-dropdown/index.tsx index 50311691e61c20..17ec4c366a4d9b 100644 --- a/apps/web/components/settings/platform/dashboard/oauth-client-dropdown/index.tsx +++ b/apps/web/components/settings/platform/dashboard/oauth-client-dropdown/index.tsx @@ -24,7 +24,9 @@ export const OAuthClientsDropdown = ({ {Array.isArray(oauthClients) && oauthClients.length > 0 ? ( - + {oauthClients.map((client) => { diff --git a/apps/web/components/setup/AdminUser.tsx b/apps/web/components/setup/AdminUser.tsx index ee95820606866c..4ee172469890d1 100644 --- a/apps/web/components/setup/AdminUser.tsx +++ b/apps/web/components/setup/AdminUser.tsx @@ -7,6 +7,7 @@ import { z } from "zod"; 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 { EmailField, EmptyScreen, Label, PasswordField, TextField } from "@calcom/ui"; @@ -39,7 +40,7 @@ export const AdminUser = (props: { onSubmit: () => void; onError: () => void; on username: z .string() .refine((val) => val.trim().length >= 1, { message: t("at_least_characters", { count: 1 }) }), - email_address: z.string().email({ message: t("enter_valid_email") }), + email_address: z.string().regex(emailRegex, { message: t("enter_valid_email") }), full_name: z.string().min(3, t("at_least_characters", { count: 3 })), password: z.string().superRefine((data, ctx) => { const isStrict = true; diff --git a/apps/web/lib/signup/getServerSideProps.tsx b/apps/web/lib/signup/getServerSideProps.tsx index 6e4377bf227ee5..d3d280434954d8 100644 --- a/apps/web/lib/signup/getServerSideProps.tsx +++ b/apps/web/lib/signup/getServerSideProps.tsx @@ -6,20 +6,21 @@ import { checkPremiumUsername } from "@calcom/features/ee/common/lib/checkPremiu import { isSAMLLoginEnabled } from "@calcom/features/ee/sso/lib/saml"; import { getFeatureFlag } from "@calcom/features/flags/server/utils"; import { IS_SELF_HOSTED, WEBAPP_URL } from "@calcom/lib/constants"; +import { emailSchema } from "@calcom/lib/emailSchema"; import slugify from "@calcom/lib/slugify"; import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; import { IS_GOOGLE_LOGIN_ENABLED } from "@server/lib/constants"; import { ssrInit } from "@server/lib/ssr"; -const checkValidEmail = (email: string) => z.string().email().safeParse(email).success; +const checkValidEmail = (email: string) => emailSchema.safeParse(email).success; const querySchema = z.object({ username: z .string() .optional() .transform((val) => val || ""), - email: z.string().email().optional(), + email: emailSchema.optional(), }); export const getServerSideProps = async (ctx: GetServerSidePropsContext) => { diff --git a/apps/web/modules/auth/login-view.tsx b/apps/web/modules/auth/login-view.tsx index c8341098b7f56d..478ec4523e4b91 100644 --- a/apps/web/modules/auth/login-view.tsx +++ b/apps/web/modules/auth/login-view.tsx @@ -12,6 +12,7 @@ import { z } from "zod"; import { SAMLLogin } from "@calcom/features/auth/SAMLLogin"; import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode"; import { HOSTED_CAL_FEATURES, WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants"; +import { emailRegex } from "@calcom/lib/emailSchema"; import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl"; import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams"; import { useLastUsed, LastUsed } from "@calcom/lib/hooks/useLastUsed"; @@ -59,7 +60,7 @@ PageProps & WithNonceProps<{}>) { email: z .string() .min(1, `${t("error_required_field")}`) - .email(`${t("enter_valid_email")}`), + .regex(emailRegex, `${t("enter_valid_email")}`), ...(!!totpEmail ? {} : { password: z.string().min(1, `${t("error_required_field")}`) }), }) // Passthrough other fields like totpCode diff --git a/apps/web/modules/settings/my-account/appearance-view.tsx b/apps/web/modules/settings/my-account/appearance-view.tsx index e07f6ef2cc7fd8..d031f2cdabf6e4 100644 --- a/apps/web/modules/settings/my-account/appearance-view.tsx +++ b/apps/web/modules/settings/my-account/appearance-view.tsx @@ -193,14 +193,9 @@ const AppearanceView = ({ { - if (appTheme === "light" || appTheme === "dark") { - mutation.mutate({ - appTheme, - }); - return; - } + if (appTheme === "system") appTheme = null; mutation.mutate({ - appTheme: null, + appTheme, }); }}>
@@ -231,6 +226,7 @@ const AppearanceView = ({
+ } + /> + ); + + if (!isPlatformUser) + return ( +
+ }> + + +
+ ); return (
diff --git a/apps/web/pages/_document.tsx b/apps/web/pages/_document.tsx index 150137f90dced8..a8c7d3c55e4cb4 100644 --- a/apps/web/pages/_document.tsx +++ b/apps/web/pages/_document.tsx @@ -67,7 +67,39 @@ class MyDocument extends Document { nonce={nonce} id="newLocale" dangerouslySetInnerHTML={{ - __html: `window.calNewLocale = "${newLocale}";`, + __html: ` + window.calNewLocale = "${newLocale}"; + (function applyTheme() { + try { + const appTheme = localStorage.getItem('app-theme'); + if (!appTheme) return; + + let bookingTheme, username; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key.startsWith('booking-theme:')) { + bookingTheme = localStorage.getItem(key); + username = key.split("booking-theme:")[1]; + break; + } + } + + const onReady = () => { + const isBookingPage = username && window.location.pathname.slice(1).startsWith(username); + + if (document.body) { + document.body.classList.add(isBookingPage ? bookingTheme : appTheme); + } else { + requestAnimationFrame(onReady); + } + }; + + requestAnimationFrame(onReady); + } catch (e) { + console.error('Error applying theme:', e); + } + })(); + `, }} /> diff --git a/apps/web/pages/api/auth/forgot-password.ts b/apps/web/pages/api/auth/forgot-password.ts index 6cbe28bd201d4f..f749804ae40634 100644 --- a/apps/web/pages/api/auth/forgot-password.ts +++ b/apps/web/pages/api/auth/forgot-password.ts @@ -1,17 +1,13 @@ import type { NextApiRequest, NextApiResponse } from "next"; -import { z } from "zod"; import { passwordResetRequest } from "@calcom/features/auth/lib/passwordResetRequest"; import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError"; +import { emailSchema } from "@calcom/lib/emailSchema"; import { defaultHandler } from "@calcom/lib/server"; import prisma from "@calcom/prisma"; async function handler(req: NextApiRequest, res: NextApiResponse) { - const email = z - .string() - .email() - .transform((val) => val.toLowerCase()) - .safeParse(req.body?.email); + const email = emailSchema.transform((val) => val.toLowerCase()).safeParse(req.body?.email); if (!email.success) { return res.status(400).json({ message: "email is required" }); diff --git a/apps/web/pages/api/auth/setup.ts b/apps/web/pages/api/auth/setup.ts index fb196cc325875a..c092a7505ae349 100644 --- a/apps/web/pages/api/auth/setup.ts +++ b/apps/web/pages/api/auth/setup.ts @@ -3,6 +3,7 @@ import z from "zod"; import { hashPassword } from "@calcom/features/auth/lib/hashPassword"; import { isPasswordValid } from "@calcom/features/auth/lib/isPasswordValid"; +import { emailRegex } from "@calcom/lib/emailSchema"; import { HttpError } from "@calcom/lib/http-error"; import { defaultHandler, defaultResponder } from "@calcom/lib/server"; import slugify from "@calcom/lib/slugify"; @@ -14,7 +15,7 @@ const querySchema = z.object({ .string() .refine((val) => val.trim().length >= 1, { message: "Please enter at least one character" }), full_name: z.string().min(3, "Please enter at least 3 characters"), - email_address: z.string().email({ message: "Please enter a valid email" }), + email_address: z.string().regex(emailRegex, { message: "Please enter a valid email" }), password: z.string().refine((val) => isPasswordValid(val.trim(), false, true), { message: "The password must be a minimum of 15 characters long containing at least one number and have a mixture of uppercase and lowercase letters", diff --git a/apps/web/pages/api/sync/helpscout/index.ts b/apps/web/pages/api/sync/helpscout/index.ts index 7110d1d0406e04..3931f757582af4 100644 --- a/apps/web/pages/api/sync/helpscout/index.ts +++ b/apps/web/pages/api/sync/helpscout/index.ts @@ -3,6 +3,7 @@ import type { NextApiRequest, NextApiResponse } from "next"; import getRawBody from "raw-body"; import z from "zod"; +import { emailSchema } from "@calcom/lib/emailSchema"; import { default as webPrisma } from "@calcom/prisma"; export const config = { @@ -13,7 +14,7 @@ export const config = { const helpscoutRequestBodySchema = z.object({ customer: z.object({ - email: z.string().email(), + email: emailSchema, }), }); diff --git a/apps/web/pages/settings/platform/members/index.tsx b/apps/web/pages/settings/platform/members/index.tsx new file mode 100644 index 00000000000000..dad151765ac91d --- /dev/null +++ b/apps/web/pages/settings/platform/members/index.tsx @@ -0,0 +1,9 @@ +import PlatformMembersView from "@calcom/features/ee/platform/pages/settings/members"; + +import PageWrapper from "@components/PageWrapper"; + +const Page = () => ; + +Page.PageWrapper = PageWrapper; + +export default Page; diff --git a/docs/mint.json b/docs/mint.json index acde445ba920e5..ac852683b6dc40 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -24,7 +24,7 @@ "topbarLinks": [ { "name": "Support", - "url": "mailto:support@cal.com" + "url": "https://go.cal.com/support" } ], "topbarCtaButton": { diff --git a/packages/app-store-cli/src/build.ts b/packages/app-store-cli/src/build.ts index 91c461b548c951..d8f1e3bbaa3cfe 100644 --- a/packages/app-store-cli/src/build.ts +++ b/packages/app-store-cli/src/build.ts @@ -13,10 +13,7 @@ import type { AppMeta } from "@calcom/types/App"; import { APP_STORE_PATH } from "./constants"; import { getAppName } from "./utils/getAppName"; -let isInWatchMode = false; -if (process.argv[2] === "--watch") { - isInWatchMode = true; -} +const isInWatchMode = process.argv[2] === "--watch"; const formatOutput = (source: string) => prettier.format(source, { @@ -24,14 +21,10 @@ const formatOutput = (source: string) => ...prettierConfig, }); -const getVariableName = function (appName: string) { - return appName.replace(/[-.]/g, "_"); -}; +const getVariableName = (appName: string) => appName.replace(/[-.]/g, "_"); -const getAppId = function (app: { name: string }) { - // Handle stripe separately as it's an old app with different dirName than slug/appId - return app.name === "stripepayment" ? "stripe" : app.name; -}; +// INFO: Handle stripe separately as it's an old app with different dirName than slug/appId +const getAppId = (app: { name: string }) => (app.name === "stripepayment" ? "stripe" : app.name); type App = Partial & { name: string; diff --git a/packages/app-store/exchangecalendar/api/_postAdd.ts b/packages/app-store/exchangecalendar/api/_postAdd.ts index dbea3db7698316..0a7709f4e2a44a 100644 --- a/packages/app-store/exchangecalendar/api/_postAdd.ts +++ b/packages/app-store/exchangecalendar/api/_postAdd.ts @@ -3,6 +3,7 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { z } from "zod"; import { symmetricEncrypt } from "@calcom/lib/crypto"; +import { emailSchema } from "@calcom/lib/emailSchema"; import logger from "@calcom/lib/logger"; import { defaultResponder } from "@calcom/lib/server"; import prisma from "@calcom/prisma"; @@ -14,7 +15,7 @@ import { CalendarService } from "../lib"; const formSchema = z .object({ url: z.string().url(), - username: z.string().email(), + username: emailSchema, password: z.string(), authenticationMethod: z.number().default(ExchangeAuthentication.STANDARD), exchangeVersion: z.number().default(ExchangeVersion.Exchange2016), diff --git a/packages/app-store/exchangecalendar/pages/setup/index.tsx b/packages/app-store/exchangecalendar/pages/setup/index.tsx index 6acde1651d50d2..92ba50af9aa719 100644 --- a/packages/app-store/exchangecalendar/pages/setup/index.tsx +++ b/packages/app-store/exchangecalendar/pages/setup/index.tsx @@ -5,6 +5,7 @@ import { Controller, useForm } from "react-hook-form"; import { Toaster } from "react-hot-toast"; import z from "zod"; +import { emailSchema } from "@calcom/lib/emailSchema"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { Alert, Button, EmailField, Form, PasswordField, SelectField, TextField } from "@calcom/ui"; @@ -22,7 +23,7 @@ interface IFormData { const schema = z .object({ url: z.string().url(), - username: z.string().email(), + username: emailSchema, password: z.string(), authenticationMethod: z.number().default(ExchangeAuthentication.STANDARD), exchangeVersion: z.number().default(ExchangeVersion.Exchange2016), diff --git a/packages/app-store/routing-forms/trpc/response.handler.ts b/packages/app-store/routing-forms/trpc/response.handler.ts index 728355a84445dc..f66bc5ce09e5ac 100644 --- a/packages/app-store/routing-forms/trpc/response.handler.ts +++ b/packages/app-store/routing-forms/trpc/response.handler.ts @@ -1,6 +1,7 @@ import { Prisma } from "@prisma/client"; import { z } from "zod"; +import { emailSchema } from "@calcom/lib/emailSchema"; import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; import type { PrismaClient } from "@calcom/prisma"; @@ -75,7 +76,7 @@ export const responseHandler = async ({ ctx, input }: ResponseHandlerOptions) => } let schema; if (field.type === "email") { - schema = z.string().email(); + schema = emailSchema; } else if (field.type === "phone") { schema = z.any(); } else { diff --git a/packages/embeds/embed-core/src/embed.ts b/packages/embeds/embed-core/src/embed.ts index 72785fdb8d3fdc..99210b962ead33 100644 --- a/packages/embeds/embed-core/src/embed.ts +++ b/packages/embeds/embed-core/src/embed.ts @@ -689,6 +689,8 @@ class CalApi { if (__prerender) { this.preloadedModalUid = uid; + } else { + this.modalUid = uid; } if (typeof config.iframeAttrs === "string" || config.iframeAttrs instanceof Array) { diff --git a/packages/features/auth/SAMLLogin.tsx b/packages/features/auth/SAMLLogin.tsx index 75420c5503e32d..ba410c6543c620 100644 --- a/packages/features/auth/SAMLLogin.tsx +++ b/packages/features/auth/SAMLLogin.tsx @@ -4,6 +4,7 @@ import { useFormContext } from "react-hook-form"; import z from "zod"; import { HOSTED_CAL_FEATURES } from "@calcom/lib/constants"; +import { emailRegex } from "@calcom/lib/emailSchema"; import { LastUsed, useLastUsed } from "@calcom/lib/hooks/useLastUsed"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc/react"; @@ -17,7 +18,7 @@ interface Props { } const schema = z.object({ - email: z.string().email({ message: "Please enter a valid email" }), + email: z.string().regex(emailRegex, { message: "Please enter a valid email" }), }); export function SAMLLogin({ diff --git a/packages/features/bookings/lib/handleCancelBooking.ts b/packages/features/bookings/lib/handleCancelBooking.ts index 044187b2629f19..5ce0a4369caa44 100644 --- a/packages/features/bookings/lib/handleCancelBooking.ts +++ b/packages/features/bookings/lib/handleCancelBooking.ts @@ -368,13 +368,16 @@ async function handler(req: CustomRequest) { await Promise.all(promises); const workflows = await getAllWorkflowsFromEventType(bookingToDelete.eventType, bookingToDelete.userId); + const parsedMetadata = bookingMetadataSchema.safeParse(bookingToDelete.metadata || {}); await sendCancelledReminders({ workflows, smsReminderNumber: bookingToDelete.smsReminderNumber, evt: { ...evt, - metadata: { videoCallUrl: bookingMetadataSchema.parse(bookingToDelete.metadata || {})?.videoCallUrl }, + ...(parsedMetadata.success && parsedMetadata.data?.videoCallUrl + ? { metadata: { videoCallUrl: parsedMetadata.data.videoCallUrl } } + : {}), bookerUrl, ...{ eventType: { diff --git a/packages/features/ee/platform/pages/settings/members.tsx b/packages/features/ee/platform/pages/settings/members.tsx new file mode 100644 index 00000000000000..3f90e1c2b2d01a --- /dev/null +++ b/packages/features/ee/platform/pages/settings/members.tsx @@ -0,0 +1,74 @@ +"use client"; + +import Shell from "@calcom/features/shell/Shell"; +import { UserListTable } from "@calcom/features/users/components/UserTable/UserListTable"; +import { MembershipRole } from "@calcom/prisma/enums"; +import { trpc } from "@calcom/trpc/react"; +import { Button } from "@calcom/ui"; +import NoPlatformPlan from "@calcom/web/components/settings/platform/dashboard/NoPlatformPlan"; +import { useGetUserAttributes } from "@calcom/web/components/settings/platform/hooks/useGetUserAttributes"; +import { PlatformPricing } from "@calcom/web/components/settings/platform/pricing/platform-pricing/index"; + +const PlatformMembersView = () => { + const { isUserLoading, isUserBillingDataLoading, isPlatformUser, isPaidUser, userBillingData, userOrgId } = + useGetUserAttributes(); + const { data: currentOrg, isPending } = trpc.viewer.organizations.listCurrent.useQuery(); + + const isOrgAdminOrOwner = + currentOrg && + (currentOrg.user.role === MembershipRole.OWNER || currentOrg.user.role === MembershipRole.ADMIN); + + const canLoggedInUserSeeMembers = + (currentOrg?.isPrivate && isOrgAdminOrOwner) || isOrgAdminOrOwner || !currentOrg?.isPrivate; + + if (isUserLoading || (isUserBillingDataLoading && !userBillingData)) { + return
Loading...
; + } + + if (isPlatformUser && !isPaidUser) + return ( + +

Subscribe to Platform

+
+ } + /> + ); + + if (!isPlatformUser) + return ( +
+ }> + + +
+ ); + + return ( + +

Member management

+