Compare commits

...
42 Commits
Author SHA1 Message Date
Alex van Andel 0ec5153f14 v4.1.0 (#15060)
release.yml / release (push) Canceled after 0s
2024-05-15 14:11:36 -04:00
Anik Dhabal BabuandCarina Wollendorfer 90ce0f8d4d fix: hosts not set when duplicating team event-type (#15012)
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
2024-05-15 18:07:22 +00:00
Amit Sharma 5e9902921f fix: Dynamic (Group) meetings showing one person avatar on the double (#15055) 2024-05-15 18:25:35 +01:00
Morgan 5d3c72271b chore: prop disableEditableHeading in availability settings (#15041) 2024-05-15 17:16:35 +03:00
Alex van Andel 775010cd2d chore: Allow disabling email sending without errors (#15051)
* chore: Allow disabling email sending without errors

* Explicitly return null for some deps down the tree
2024-05-15 15:06:44 +01:00
Udit Takkar 87a8b5a8dd fix: invalid type of enable_transcription_storage (#15049)
* fix: invalid type of enable_transcription_storage

* fix: type err
2024-05-15 16:12:24 +05:30
Morgan 973b624882 fix: disable useGetOAuthClientManagedUsers if clientId not defined (#15040) 2024-05-15 07:19:22 +00:00
Morgan 0a0c5ef61e chore: more fields event-types apiv2 (#15031) 2024-05-14 22:51:21 +01:00
Keith Williams 46f67bba9b chore: v4.0.9 (#15035)
release.yml / release (push) Canceled after 0s
2024-05-14 21:00:57 +00:00
Anik Dhabal Babu d9806da049 fix: better errors for platform (#14987)
## What does this PR do?

<!-- Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. -->

- Fixes #14977 

<!-- Please provide a loom video for visual changes to speed up reviews
 Loom Video: https://www.loom.com/
-->

## Mandatory Tasks (DO NOT REMOVE)

- [ ] I have self-reviewed the code (A decent size PR without self-review might be rejected)
- [ ] I have added a Docs issue [here](https://github.com/calcom/docs/issues/new) if this PR makes changes that would require a [documentation change](https://docs.cal.com)
- [ ] I have added or modified automated tests that prove my fix is effective or that my feature works (PRs might be rejected if logical changes are not properly tested)

## How should this be tested?

<!-- Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration. Write details that help to start the tests -->

- Are there environment variables that should be set?
- What are the minimal test data to have?
- What is expected (happy path) to have (input and output)?
- Any other important info that could help to test that PR

## Checklist

<!-- Remove bullet points below that don't apply to you -->

- I haven't read the [contributing guide](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md)
- My code doesn't follow the style guidelines of this project
- I haven't commented my code, particularly in hard-to-understand areas
- I haven't checked if my changes generate no new warnings
2024-05-14 11:56:44 -07:00
Alex van Andel 42fc49488d fix: Impersonation flicker on timezone change (#14738) 2024-05-14 11:56:25 -07:00
Omar LópezandCarina Wollendorfer e030fe0e07 fix: error when unlinking google account (#15011)
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
2024-05-14 10:10:41 -07:00
Udit Takkar 51428087ef chore: enable transcription for teams plan users (#15025) 2024-05-14 09:28:13 -04:00
Morgan 3942df369d chore: open disableGuests for event-types apiv2 (#15001)
* chore: open disableGuests for eventtypes apiv2

* fixup! chore: open disableGuests for eventtypes apiv2

* fixup! fixup! chore: open disableGuests for eventtypes apiv2

* chore: add disableGuests to create handler
2024-05-14 10:50:12 +00:00
Morgan 1d309a6f09 chore: hide oauthClientId from session email in booker (#15002) 2024-05-13 18:25:34 +00:00
Amit Sharma 56f31eee7a fix: Regression: Can’t set person to forward OOO bookings (#15003) 2024-05-13 10:29:56 -04:00
2dae0bfb8c fix: add retryCount to workflowReminder (#14943)
* add retry count to workflow reminder

* add logic to for retry count

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-05-13 13:25:22 +00:00
Hariom Balhara 917c7b0764 fix: Allow less than 30 seats for orgs (#14995)
* fix: Allow less than 30 seats for orgs

* Add test

* Stub env variables
2024-05-13 11:37:10 +00:00
fd64b0f468 fix: meeting ended and started webhooks trigger (#14864)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-05-10 12:49:16 -07:00
Syed Ali Shahbaz b1909337db fix: SAML fixes for uppercase email & GOOGLE → SAML idp switch (#14971)
* fix uppercase email slip

* fix google → SAML idp change
2024-05-10 15:09:45 +00:00
vikrant845andborawakevikrant 039bf6d4e3 fix: Fixed email embed for team when specific times are chosen embeds the wrong URL (#14975)
* Fixed team embed url

* Added comments

---------

Co-authored-by: borawakevikrant <borawakevikrant@gmail.com>
2024-05-10 14:05:09 +00:00
sean-brydonandUdit Takkar 83e15c4ce1 feat: orgs create dynamic events from org member list (#14912)
* implement basic UI - WIP

* Add copy + open in tab + center correctly

* use paragraph for link

* fix button spacing and domain takes up w-full

* Add fade in to bar instead of framer motion

* Move truncate to block level element instead of p

* chore:fix tablet styles

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-05-10 13:06:11 +00:00
Hariom Balhara 7d6ecf7201 Release new versions for embed (#14958) 2024-05-10 12:49:47 +00:00
Rajiv Sahal bc42c27230 chore: dashboard fixes (#14968)
* add rewrites for platform dashboard

* fix loading checking

* add checks

* dont display banner for platform client

* custom hooks for pllatform

* add comment for better explanation

* get data from custom hook

* update returned user attributes

* get data from custom hook
2024-05-10 15:16:03 +03:00
Peer Richelsen 57abb26c31 fixed giphy (#14973) 2024-05-10 12:02:10 +00:00
Hariom Balhara a136511c8a fix: Show error log (#14960) 2024-05-10 08:47:57 +01:00
Morgan c65dca9486 fix: api v2 hotfix google calendar, exception details, billing check guard (#14967) 2024-05-10 07:36:51 +00:00
Omar López 816d31918d chore: v4.0.8 (#14965)
release.yml / release (push) Canceled after 0s
2024-05-09 13:20:59 -07:00
Ayush MhetreandCarina Wollendorfer 097a69d7d7 fix: save workflow button not visible on dev env (#14910)
* fix: save workflow button not visible on dev env

* refactor: use the CALCOM_ENV constant

* fix: ui fix for header on workspace pages

* refactor: remove unused variable

---------

Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
2024-05-09 17:46:39 +00:00
c2a07e221d feat: platform onboarding flow and dashboard (#14721)
* add endpoint to fetch managed user from client id

* update typings

* minor tweaks

* custom hook to fetch managed users from client id

* add translations for platform onboarding

* add isPlatformOrg boolean to figure out which is platform and which is not

* set isPlatform hook based on data obtained from org

* add limitWidth prop to control component width

* add props to shell to make sidebar display different tabs based on if the shell isPlattform or not

* platform related pages

* fix merge conflicts

* fix merge conflicts

* platform oauth client form and card

* remove everything related to platform from organization

* update oauth client card and form

* fixup

* fix imports and remove logs

* fixup

* update redirect url

* split oauth client form into separate update and create forms

* separate forms for create and edit oauth clients

* fixup

* fixup

* dynamic routes for oauth client edit page

* fixup fixup

* fix to not show error when redirect uri is empty

* refactor create handler for org

* cleaup comments

* add custom hook to check user billing

* export managed user type

* refactor platform index page

* refactor edit and create pages

* dashboard component containing oauth client list and managed user

* common oauth client form used for create and edit form

* platform pricing helper

* platform pricing component

* fix typing and data response for billing

* use custom hook to check team billing info

* fix type checks

* upgrade conditional rendering for upgrade to org banner

* add isLoading prop to check button loading state

* pass in button handler

* add custom hook to subscribe to stripe and typings

* update typings

* fix incorrect endpoint

* pass in team id as prop

* fix type check

* update stripe success and cancel redirect url

* add and pass redirect url param to custom hook

* custom hooks for platform

* cleanup

* update imports

* fix merge conflicts

* fixup

* fixup fixup

* fixup

* merge conflicts fixup

* merge conlficts battle :(

* minor fixes

* skip admin checks for a platform client

* fix typo

* append slug with _platform for a platform user

* PR feedback

* dashboard refactor

* bring back org form to its orginal state

* add platform folder to ee

* update typings

* use new create platform form

* fixup

* fix typo and update plans

* simplifying rendering

* remove managed users endpoint since it already exists

* url for endpoint

* rename tabs

* pr feedback

* managed users endpoint

* update endpoint

* remove form

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: exception <erik@erosemberg.com>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2024-05-09 13:01:10 -04:00
Morgan 6d8fbfc49a fix: gcal save get credentials from token.tokens (#14962) 2024-05-09 14:08:31 +00:00
Carina WollendorferandCarinaWolli 304ef29618 fix: buffer times in handleNewBooking (#14871)
* get buffer busy time from bookings outside of start and end time

* add test for buffers

* fix typo

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2024-05-09 13:39:32 +00:00
Lauris Skraucis b8890c841c fix: managed user timeZone, timeFormat and weekStart during creation (#14955)
* refactor: create, update user inputs use types instead of custom validators

* fix: save weekStart, timeFormat, timeZone when creating user

* fix: save weekStart, timeFormat, timeZone when creating user

* e2e: fix env variables and test weekStart, timeZone and timeFormat during user create
2024-05-09 10:25:14 -03:00
Amit SharmaandKeith Williams 2f5768b74f fix: Only able to create one webhook per event type (#14922)
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-05-09 02:40:13 +00:00
Carina WollendorferandCarinaWolli c1416173e8 attempt to fix flaky e2e test (#14949)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2024-05-08 18:07:30 -04:00
Morgan 726af4970c fix: sync book event form values with bookingData (#14945)
* fix: sync book event form values with bookingData

* fixup! fix: sync book event form values with bookingData
2024-05-08 15:14:40 +00:00
Keith Williams d15b7d8168 chore: Update PR template to include more mandatory tasks (#14941)
* chore: Update PR template to include more mandatory tasks

* Add XXXX to GitHub number

* Update PULL_REQUEST_TEMPLATE.md
2024-05-08 14:37:17 +01:00
Alex van Andel c5bd45fdfb feat: Redirect when event type does not match booking type (#14460)
* feat: Redirect when event type does not match booking type

* Favour early return

* If multiple bookingUid/rescheduleUid given, use first

* rescheduleUid/bookingUid behaviour is required
2024-05-08 10:33:22 -03:00
Morgan 5d7b2a12a7 fix: redis cache key overlap (#14938) 2024-05-08 10:41:34 +00:00
Morgan 7c90d1ed76 feat: handleCreateBooking for platform booker (#14939) 2024-05-08 16:11:01 +05:30
Peer Richelsen 5ca10d1e83 feat: Added admin-api page (#14890)
* added admin-api page

* i18n
2024-05-08 10:15:20 +00:00
sean-brydon f0e59c2331 fix: pass query params to dynamic meetings (#14920) 2024-05-08 09:53:07 +00:00
140 changed files with 3432 additions and 1254 deletions
+6 -24
View File
@@ -2,29 +2,18 @@
<!-- Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. -->
Fixes # (issue)
- Fixes #XXXX (GitHub issue number)
- Fixes CAL-XXXX (Linear issue number - should be visible at the bottom of the GitHub issue description)
<!-- Please provide a loom video for visual changes to speed up reviews
Loom Video: https://www.loom.com/
-->
## Requirement/Documentation
## Mandatory Tasks (DO NOT REMOVE)
<!-- Please provide all documents that are important to understand the reason of that PR. -->
- If there is a requirement document, please, share it here.
- If there is a UI/UX design document, please, share it here.
## Type of change
<!-- Please delete bullets that are not relevant. -->
- Bug fix (non-breaking change which fixes an issue)
- Chore (refactoring code, technical debt, workflow improvements)
- New feature (non-breaking change which adds functionality)
- Breaking change (fix or feature that would cause existing functionality to not work as expected)
- Tests (Unit/Integration/E2E or any other test)
- This change requires a documentation update
- [ ] I have self-reviewed the code (A decent size PR without self-review might be rejected)
- [ ] I have added a Docs issue [here](https://github.com/calcom/docs/issues/new) if this PR makes changes that would require a [documentation change](https://docs.cal.com)
- [ ] I have added or modified automated tests that prove my fix is effective or that my feature works (PRs might be rejected if logical changes are not properly tested)
## How should this be tested?
@@ -35,10 +24,6 @@ Fixes # (issue)
- What is expected (happy path) to have (input and output)?
- Any other important info that could help to test that PR
## Mandatory Tasks
- [ ] Make sure you have self-reviewed the code. A decent size PR without self-review might be rejected.
## Checklist
<!-- Remove bullet points below that don't apply to you -->
@@ -46,7 +31,4 @@ Fixes # (issue)
- I haven't read the [contributing guide](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md)
- My code doesn't follow the style guidelines of this project
- I haven't commented my code, particularly in hard-to-understand areas
- I haven't checked if my PR needs changes to the documentation
- I haven't checked if my changes generate no new warnings
- I haven't added tests that prove my fix is effective or that my feature works
- I haven't checked if new and existing unit tests pass locally with my changes
@@ -107,7 +107,7 @@ export async function patchHandler(req: NextApiRequest) {
if (IS_TEAM_BILLING_ENABLED) {
const checkoutSession = await purchaseTeamOrOrgSubscription({
teamId: _team.id,
seats: _team.members.length,
seatsUsed: _team.members.length,
userId,
pricePerSeat: null,
});
@@ -131,6 +131,11 @@ describe("Event types Endpoints", () => {
description: "A description of the test event type.",
length: 60,
hidden: false,
disableGuests: true,
slotInterval: 15,
afterEventBuffer: 5,
beforeEventBuffer: 10,
minimumBookingNotice: 120,
locations: [
{
type: "Online",
@@ -148,6 +153,12 @@ describe("Event types Endpoints", () => {
const responseBody: ApiSuccessResponse<EventType> = response.body;
expect(responseBody.data).toHaveProperty("id");
expect(responseBody.data.title).toEqual(body.title);
expect(responseBody.data.disableGuests).toEqual(body.disableGuests);
expect(responseBody.data.slotInterval).toEqual(body.slotInterval);
expect(responseBody.data.minimumBookingNotice).toEqual(body.minimumBookingNotice);
expect(responseBody.data.beforeEventBuffer).toEqual(body.beforeEventBuffer);
expect(responseBody.data.afterEventBuffer).toEqual(body.afterEventBuffer);
eventType = responseBody.data;
});
});
@@ -157,14 +168,32 @@ describe("Event types Endpoints", () => {
const body: UpdateEventTypeInput = {
title: newTitle,
disableGuests: false,
slotInterval: 30,
afterEventBuffer: 10,
beforeEventBuffer: 15,
minimumBookingNotice: 240,
};
return request(app.getHttpServer())
.patch(`/api/v2/event-types/${eventType.id}`)
.send(body)
.expect(200)
.then(async () => {
.then(async (response) => {
const responseBody: ApiSuccessResponse<EventType> = response.body;
expect(responseBody.data.title).toEqual(newTitle);
expect(responseBody.data.disableGuests).toEqual(body.disableGuests);
expect(responseBody.data.slotInterval).toEqual(body.slotInterval);
expect(responseBody.data.minimumBookingNotice).toEqual(body.minimumBookingNotice);
expect(responseBody.data.beforeEventBuffer).toEqual(body.beforeEventBuffer);
expect(responseBody.data.afterEventBuffer).toEqual(body.afterEventBuffer);
eventType.title = newTitle;
eventType.disableGuests = responseBody.data.disableGuests ?? false;
eventType.slotInterval = body.slotInterval ?? null;
eventType.minimumBookingNotice = body.minimumBookingNotice ?? 10;
eventType.beforeEventBuffer = body.beforeEventBuffer ?? 10;
eventType.afterEventBuffer = body.afterEventBuffer ?? 10;
});
});
@@ -1,7 +1,16 @@
import { EventTypeLocation } from "@/ee/event-types/inputs/event-type-location.input";
import { ApiProperty as DocsProperty, ApiHideProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsString, IsNumber, IsBoolean, IsOptional, ValidateNested, Min, IsArray } from "class-validator";
import {
IsString,
IsNumber,
IsBoolean,
IsOptional,
ValidateNested,
Min,
IsArray,
IsInt,
} from "class-validator";
export const CREATE_EVENT_LENGTH_EXAMPLE = 60;
export const CREATE_EVENT_SLUG_EXAMPLE = "cooking-class";
@@ -41,6 +50,27 @@ export class CreateEventTypeInput {
@IsArray()
locations?: EventTypeLocation[];
@IsBoolean()
@IsOptional()
disableGuests?: boolean;
@IsInt()
@IsOptional()
slotInterval?: number;
@IsInt()
@Min(0)
@IsOptional()
minimumBookingNotice?: number;
@IsInt()
@IsOptional()
beforeEventBuffer?: number;
@IsInt()
@IsOptional()
afterEventBuffer?: number;
// @ApiHideProperty()
// @IsOptional()
// @IsNumber()
@@ -322,26 +322,26 @@ export class UpdateEventTypeInput {
// @IsOptional()
// recurringEvent?: RecurringEvent;
// @IsBoolean()
// @IsOptional()
// disableGuests?: boolean;
@IsBoolean()
@IsOptional()
disableGuests?: boolean;
// @IsBoolean()
// @IsOptional()
// hideCalendarNotes?: boolean;
// @IsInt()
// @Min(0)
// @IsOptional()
// minimumBookingNotice?: number;
@IsInt()
@Min(0)
@IsOptional()
minimumBookingNotice?: number;
// @IsInt()
// @IsOptional()
// beforeEventBuffer?: number;
@IsInt()
@IsOptional()
beforeEventBuffer?: number;
// @IsInt()
// @IsOptional()
// afterEventBuffer?: number;
@IsInt()
@IsOptional()
afterEventBuffer?: number;
// @IsInt()
// @IsOptional()
@@ -375,9 +375,9 @@ export class UpdateEventTypeInput {
// @IsOptional()
// currency?: string;
// @IsInt()
// @IsOptional()
// slotInterval?: number;
@IsInt()
@IsOptional()
slotInterval?: number;
// @IsString()
// @IsOptional()
+6 -9
View File
@@ -26,16 +26,12 @@ import {
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { ApiTags as DocsTags } from "@nestjs/swagger";
import { Prisma } from "@prisma/client";
import { Request } from "express";
import { google } from "googleapis";
import { z } from "zod";
import {
APPS_READ,
GOOGLE_CALENDAR_ID,
GOOGLE_CALENDAR_TYPE,
SUCCESS_STATUS,
} from "@calcom/platform-constants";
import { APPS_READ, GOOGLE_CALENDAR_TYPE, SUCCESS_STATUS } from "@calcom/platform-constants";
const CALENDAR_SCOPES = [
"https://www.googleapis.com/auth/calendar.readonly",
@@ -106,10 +102,11 @@ export class GcalController {
const oAuth2Client = await this.gcalService.getOAuthClient(this.redirectUri);
const token = await oAuth2Client.getToken(parsedCode);
const key = token.res?.data;
// Google oAuth Credentials are stored in token.tokens
const key = token.tokens;
const credential = await this.credentialRepository.createAppCredential(
GOOGLE_CALENDAR_TYPE,
key,
key as Prisma.InputJsonValue,
ownerId
);
@@ -129,7 +126,7 @@ export class GcalController {
primaryCal.id,
credential.id,
ownerId,
GOOGLE_CALENDAR_ID
GOOGLE_CALENDAR_TYPE
);
}
@@ -28,7 +28,7 @@ export class HttpExceptionFilter implements ExceptionFilter<HttpException> {
status: ERROR_STATUS,
timestamp: new Date().toISOString(),
path: request.url,
error: { code: exception.name, message: exception.message },
error: { code: exception.name, message: exception.message, details: exception.getResponse() },
});
}
}
@@ -46,7 +46,7 @@ export class BillingController {
@Get("/:teamId/check")
@UseGuards(NextAuthGuard, OrganizationRolesGuard)
@Roles(["OWNER", "ADMIN"])
@Roles(["OWNER", "ADMIN", "MEMBER"])
async checkTeamBilling(
@Param("teamId") teamId: number
): Promise<ApiResponse<CheckPlatformBillingResponseDto>> {
@@ -62,8 +62,8 @@ export class BillingService {
quantity: 1,
},
],
success_url: `${this.webAppUrl}/settings/platform/oauth-clients`,
cancel_url: `${this.webAppUrl}/settings/platform/oauth-clients`,
success_url: `${this.webAppUrl}/settings/platform/`,
cancel_url: `${this.webAppUrl}/settings/platform/`,
mode: "subscription",
metadata: {
teamId: teamId.toString(),
+1
View File
@@ -1,5 +1,6 @@
export enum PlatformPlan {
STARTER = "STARTER",
ESSENTIALS = "ESSENTIALS",
SCALE = "SCALE",
ENTERPRISE = "ENTERPRISE",
}
@@ -139,6 +139,8 @@ describe("OAuth Client Users Endpoints", () => {
const requestBody: CreateManagedUserInput = {
email: userEmail,
timeZone: userTimeZone,
weekStart: "Monday",
timeFormat: 24,
};
const response = await request(app.getHttpServer())
@@ -158,6 +160,9 @@ describe("OAuth Client Users Endpoints", () => {
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.user.email).toEqual(getOAuthUserEmail(oAuthClient.id, requestBody.email));
expect(responseBody.data.user.timeZone).toEqual(requestBody.timeZone);
expect(responseBody.data.user.weekStart).toEqual(requestBody.weekStart);
expect(responseBody.data.user.timeFormat).toEqual(requestBody.timeFormat);
expect(responseBody.data.accessToken).toBeDefined();
expect(responseBody.data.refreshToken).toBeDefined();
@@ -3,6 +3,8 @@ import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard";
import { OrganizationRolesGuard } from "@/modules/auth/guards/organization-roles/organization-roles.guard";
import { GetManagedUsersOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-users.output";
import { ManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/managed-user.output";
import { CreateOAuthClientResponseDto } from "@/modules/oauth-clients/controllers/oauth-clients/responses/CreateOAuthClientResponse.dto";
import { GetOAuthClientResponseDto } from "@/modules/oauth-clients/controllers/oauth-clients/responses/GetOAuthClientResponse.dto";
import { GetOAuthClientsResponseDto } from "@/modules/oauth-clients/controllers/oauth-clients/responses/GetOAuthClientsResponse.dto";
@@ -10,9 +12,11 @@ import { UpdateOAuthClientInput } from "@/modules/oauth-clients/inputs/update-oa
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
import { UserWithProfile } from "@/modules/users/users.repository";
import { UsersRepository } from "@/modules/users/users.repository";
import {
Body,
Controller,
Query,
Get,
Post,
Patch,
@@ -32,9 +36,11 @@ import {
ApiCreatedResponse as DocsCreatedResponse,
} from "@nestjs/swagger";
import { MembershipRole } from "@prisma/client";
import { User } from "@prisma/client";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { CreateOAuthClientInput } from "@calcom/platform-types";
import { Pagination } from "@calcom/platform-types";
const AUTH_DOCUMENTATION = `⚠️ First, this endpoint requires \`Cookie: next-auth.session-token=eyJhbGciOiJ\` header. Log into Cal web app using owner of organization that was created after visiting \`/settings/organizations/new\`, refresh swagger docs, and the cookie will be added to requests automatically to pass the NextAuthGuard.
Second, make sure that the logged in user has organizationId set to pass the OrganizationRolesGuard guard.`;
@@ -51,6 +57,7 @@ export class OAuthClientsController {
constructor(
private readonly oauthClientRepository: OAuthClientRepository,
private readonly userRepository: UsersRepository,
private readonly teamsRepository: OrganizationsRepository
) {}
@@ -109,6 +116,24 @@ export class OAuthClientsController {
return { status: SUCCESS_STATUS, data: client };
}
@Get("/:clientId/managed-users")
@HttpCode(HttpStatus.OK)
@Roles([MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER])
@DocsOperation({ description: AUTH_DOCUMENTATION })
async getOAuthClientManagedUsersById(
@Param("clientId") clientId: string,
@Query() queryParams: Pagination
): Promise<GetManagedUsersOutput> {
const { offset, limit } = queryParams;
const existingManagedUsers = await this.userRepository.findManagedUsersByOAuthClientId(
clientId,
offset ?? 0,
limit ?? 50
);
return { status: SUCCESS_STATUS, data: existingManagedUsers.map((user) => this.getResponseUser(user)) };
}
@Patch("/:clientId")
@HttpCode(HttpStatus.OK)
@Roles([MembershipRole.ADMIN, MembershipRole.OWNER])
@@ -131,4 +156,17 @@ export class OAuthClientsController {
const client = await this.oauthClientRepository.deleteOAuthClient(clientId);
return { status: SUCCESS_STATUS, data: client };
}
private getResponseUser(user: User): ManagedUserOutput {
return {
id: user.id,
email: user.email,
username: user.username,
timeZone: user.timeZone,
weekStart: user.weekStart,
createdDate: user.createdDate,
timeFormat: user.timeFormat,
defaultScheduleId: user.defaultScheduleId,
};
}
}
@@ -53,6 +53,9 @@ export class OAuthClientUsersService {
},
},
isPlatformManaged,
timeFormat: body.timeFormat,
weekStart: body.weekStart,
timeZone: body.timeZone,
})
)[0];
await this.userRepository.addToOAuthClient(user.id, oAuthClientId);
@@ -40,7 +40,7 @@ export class OAuthFlowService {
}
async getOwnerId(accessToken: string) {
const cacheKey = this._generateActKey(accessToken);
const cacheKey = this._generateOwnerIdKey(accessToken);
try {
const ownerId = await this.redisService.redis.get(cacheKey);
@@ -165,4 +165,8 @@ export class OAuthFlowService {
private _generateActKey(accessToken: string) {
return `act_${accessToken}`;
}
private _generateOwnerIdKey(accessToken: string) {
return `owner_${accessToken}`;
}
}
@@ -1,8 +1,8 @@
import { IsTimeFormat } from "@/modules/users/inputs/validators/is-time-format";
import { IsWeekStart } from "@/modules/users/inputs/validators/is-week-start";
import { ApiProperty } from "@nestjs/swagger";
import { IsNumber, IsOptional, IsTimeZone, IsString, Validate } from "class-validator";
import { IsOptional, IsTimeZone, IsString } from "class-validator";
export type WeekDay = "Monday" | "Tuesday" | "Wednesday" | "Thursday" | "Friday" | "Saturday" | "Sunday";
export type TimeFormat = 12 | 24;
export class CreateManagedUserInput {
@IsString()
@ApiProperty({ example: "alice@example.com" })
@@ -12,17 +12,17 @@ export class CreateManagedUserInput {
@IsOptional()
name?: string;
@IsNumber()
@IsOptional()
@Validate(IsTimeFormat)
@ApiProperty({ example: 12 })
timeFormat?: number;
@ApiProperty({ example: 12, enum: [12, 24], description: "Must be 12 or 24" })
timeFormat?: TimeFormat;
@IsString()
@IsOptional()
@Validate(IsWeekStart)
@ApiProperty({ example: "Sunday" })
weekStart?: string;
@IsString()
@ApiProperty({
example: "Monday",
enum: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"],
})
weekStart?: WeekDay;
@IsTimeZone()
@IsOptional()
@@ -1,6 +1,6 @@
import { IsTimeFormat } from "@/modules/users/inputs/validators/is-time-format";
import { IsWeekStart } from "@/modules/users/inputs/validators/is-week-start";
import { IsNumber, IsOptional, IsString, IsTimeZone, Validate } from "class-validator";
import { TimeFormat, WeekDay } from "@/modules/users/inputs/create-managed-user.input";
import { ApiProperty } from "@nestjs/swagger";
import { IsNumber, IsOptional, IsString, IsTimeZone } from "class-validator";
export class UpdateManagedUserInput {
@IsString()
@@ -11,19 +11,21 @@ export class UpdateManagedUserInput {
@IsOptional()
name?: string;
@IsNumber()
@IsOptional()
@Validate(IsTimeFormat)
timeFormat?: number;
@ApiProperty({ example: 12, enum: [12, 24], description: "Must be 12 or 24" })
timeFormat?: TimeFormat;
@IsNumber()
@IsOptional()
defaultScheduleId?: number;
@IsString()
@IsOptional()
@Validate(IsWeekStart)
weekStart?: string;
@IsString()
@ApiProperty({
example: "Monday",
enum: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"],
})
weekStart?: WeekDay;
@IsTimeZone()
@IsOptional()
@@ -1,12 +0,0 @@
import { ValidatorConstraint, ValidatorConstraintInterface } from "class-validator";
@ValidatorConstraint({ name: "isTimeFormat", async: false })
export class IsTimeFormat implements ValidatorConstraintInterface {
validate(timeFormat: number) {
return timeFormat === 12 || timeFormat === 24;
}
defaultMessage() {
return "timeFormat must be a number either 12 or 24";
}
}
@@ -1,23 +0,0 @@
import { ValidatorConstraint, ValidatorConstraintInterface } from "class-validator";
@ValidatorConstraint({ name: "isWeekStart", async: false })
export class IsWeekStart implements ValidatorConstraintInterface {
validate(weekStart: string) {
if (!weekStart) return false;
const lowerCaseWeekStart = weekStart.toLowerCase();
return (
lowerCaseWeekStart === "monday" ||
lowerCaseWeekStart === "tuesday" ||
lowerCaseWeekStart === "wednesday" ||
lowerCaseWeekStart === "thursday" ||
lowerCaseWeekStart === "friday" ||
lowerCaseWeekStart === "saturday" ||
lowerCaseWeekStart === "sunday"
);
}
defaultMessage() {
return "weekStart must be a string either Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, or Sunday";
}
}
@@ -157,7 +157,7 @@ export class UsersRepository {
formatInput(userInput: CreateManagedUserInput | UpdateManagedUserInput) {
if (userInput.weekStart) {
userInput.weekStart = capitalize(userInput.weekStart);
userInput.weekStart = userInput.weekStart;
}
if (userInput.timeZone) {
+102 -8
View File
@@ -404,6 +404,38 @@
]
}
},
"/v2/oauth-clients/{clientId}/managed-users": {
"get": {
"operationId": "OAuthClientsController_getOAuthClientManagedUsersById",
"summary": "",
"description": "⚠️ First, this endpoint requires `Cookie: next-auth.session-token=eyJhbGciOiJ` header. Log into Cal web app using owner of organization that was created after visiting `/settings/organizations/new`, refresh swagger docs, and the cookie will be added to requests automatically to pass the NextAuthGuard.\nSecond, make sure that the logged in user has organizationId set to pass the OrganizationRolesGuard guard.",
"parameters": [
{
"name": "clientId",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/GetManagedUsersOutput"
}
}
}
}
},
"tags": [
"OAuth - development only"
]
}
},
"/v2/oauth/{clientId}/authorize": {
"post": {
"operationId": "OAuthFlowController_authorize",
@@ -1651,11 +1683,25 @@
},
"timeFormat": {
"type": "number",
"example": 12
"example": 12,
"enum": [
12,
24
],
"description": "Must be 12 or 24"
},
"weekStart": {
"type": "string",
"example": "Sunday"
"example": "Monday",
"enum": [
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
"Sunday"
]
},
"timeZone": {
"type": "string",
@@ -1731,21 +1777,37 @@
"UpdateManagedUserInput": {
"type": "object",
"properties": {
"timeFormat": {
"type": "number",
"example": 12,
"enum": [
12,
24
],
"description": "Must be 12 or 24"
},
"weekStart": {
"type": "string",
"example": "Monday",
"enum": [
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
"Sunday"
]
},
"email": {
"type": "string"
},
"name": {
"type": "string"
},
"timeFormat": {
"type": "number"
},
"defaultScheduleId": {
"type": "number"
},
"weekStart": {
"type": "string"
},
"timeZone": {
"type": "string"
}
@@ -2037,6 +2099,22 @@
"items": {
"$ref": "#/components/schemas/EventTypeLocation"
}
},
"disableGuests": {
"type": "boolean"
},
"slotInterval": {
"type": "number"
},
"minimumBookingNotice": {
"type": "number",
"minimum": 0
},
"beforeEventBuffer": {
"type": "number"
},
"afterEventBuffer": {
"type": "number"
}
},
"required": [
@@ -2719,6 +2797,22 @@
"items": {
"$ref": "#/components/schemas/EventTypeLocation"
}
},
"disableGuests": {
"type": "boolean"
},
"minimumBookingNotice": {
"type": "number",
"minimum": 0
},
"beforeEventBuffer": {
"type": "number"
},
"afterEventBuffer": {
"type": "number"
},
"slotInterval": {
"type": "number"
}
}
},
+2
View File
@@ -8,6 +8,8 @@ const env: Partial<Omit<Environment, "NODE_ENV">> = {
JWT_SECRET: "XF+Hws3A5g2eyWA5uGYYVJ74X+wrCWJ8oWo6kAfU6O8=",
LOG_LEVEL: "trace",
REDIS_URL: "redis://localhost:9199",
STRIPE_API_KEY: "sk_test_51J4",
STRIPE_WEBHOOK_SECRET: "whsec_51J4",
};
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
+9
View File
@@ -1,5 +1,14 @@
# @calcom/web
## 4.0.8
### Patch Changes
- Updated dependencies
- @calcom/embed-core@1.5.0
- @calcom/embed-react@1.5.0
- @calcom/embed-snippet@1.3.0
## 3.9.9
### Patch Changes
@@ -8,6 +8,7 @@ import { Controller, useFormContext } from "react-hook-form";
import type { SingleValue } from "react-select";
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
import { getDefinedBufferTimes } from "@calcom/features/eventtypes/lib/getDefinedBufferTimes";
import type { FormValues } from "@calcom/features/eventtypes/lib/types";
import { classNames } from "@calcom/lib";
import type { DurationType } from "@calcom/lib/convertToNewDurationType";
@@ -172,7 +173,7 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
label: t("event_buffer_default"),
value: 0,
},
...[5, 10, 15, 20, 30, 45, 60, 90, 120].map((minutes) => ({
...getDefinedBufferTimes().map((minutes) => ({
label: `${minutes} ${t("minutes")}`,
value: minutes,
})),
@@ -122,10 +122,21 @@ export const EventWebhooksTab = ({ eventType }: Pick<EventTypeSetupProps, "event
{webhooks.length ? (
<>
<div className="border-subtle mb-2 rounded-md border p-8">
<div className="text-default text-sm font-semibold">{t("webhooks")}</div>
<p className="text-subtle max-w-[280px] break-words text-sm sm:max-w-[500px]">
{t("add_webhook_description", { appName: APP_NAME })}
</p>
<div className="flex justify-between">
<div>
<div className="text-default text-sm font-semibold">{t("webhooks")}</div>
<p className="text-subtle max-w-[280px] break-words text-sm sm:max-w-[500px]">
{t("add_webhook_description", { appName: APP_NAME })}
</p>
</div>
{isChildrenManagedEventType && !isManagedEventType ? (
<Button StartIcon="lock" color="secondary" disabled>
{t("locked_by_team_admin")}
</Button>
) : (
<NewWebhookButton />
)}
</div>
<div className="border-subtle my-8 rounded-md border">
{webhooks.map((webhook, index) => {
@@ -0,0 +1,33 @@
import type { PlatformOAuthClient } from "@calcom/prisma/client";
import { OAuthClientsDropdown } from "@components/settings/platform/dashboard/oauth-client-dropdown";
type ManagedUserHeaderProps = {
oauthClients: PlatformOAuthClient[];
initialClientName: string;
handleChange: (clientId: string, clientName: string) => void;
};
export const ManagedUserHeader = ({
oauthClients,
initialClientName,
handleChange,
}: ManagedUserHeaderProps) => {
return (
<div className="border-subtle mx-auto block justify-between rounded-t-lg border px-4 py-6 sm:flex sm:px-6">
<div className="flex w-full flex-col">
<h1 className="font-cal text-emphasis mb-1 text-xl font-semibold leading-5 tracking-wide">
Managed Users
</h1>
<p className="text-default text-sm ltr:mr-4 rtl:ml-4">
See all the managed users created by your OAuth client.
</p>
</div>
<OAuthClientsDropdown
oauthClients={oauthClients}
initialClientName={initialClientName}
handleChange={handleChange}
/>
</div>
);
};
@@ -0,0 +1,39 @@
import type { PlatformOAuthClient } from "@calcom/prisma/client";
import type { ManagedUser } from "@lib/hooks/settings/platform/oauth-clients/useOAuthClients";
import { ManagedUserHeader } from "@components/settings/platform/dashboard/managed-user-header";
import { ManagedUserTable } from "@components/settings/platform/dashboard/managed-user-table";
type ManagedUserListProps = {
oauthClients: PlatformOAuthClient[];
managedUsers?: ManagedUser[];
initialClientName: string;
initialClientId: string;
isManagedUserLoading: boolean;
handleChange: (clientId: string, clientName: string) => void;
};
export const ManagedUserList = ({
initialClientName,
initialClientId,
oauthClients,
managedUsers,
isManagedUserLoading,
handleChange,
}: ManagedUserListProps) => {
return (
<div>
<ManagedUserHeader
oauthClients={oauthClients}
initialClientName={initialClientName}
handleChange={handleChange}
/>
<ManagedUserTable
managedUsers={managedUsers}
isManagedUserLoading={isManagedUserLoading}
initialClientId={initialClientId}
/>
</div>
);
};
@@ -0,0 +1,60 @@
import { EmptyScreen } from "@calcom/ui";
import type { ManagedUser } from "@lib/hooks/settings/platform/oauth-clients/useOAuthClients";
type ManagedUserTableProps = {
managedUsers?: ManagedUser[];
isManagedUserLoading: boolean;
initialClientId: string;
};
export const ManagedUserTable = ({
managedUsers,
isManagedUserLoading,
initialClientId,
}: ManagedUserTableProps) => {
const showUsers = !isManagedUserLoading && managedUsers?.length;
return (
<div>
{showUsers ? (
<>
<table className="w-[100%] rounded-lg">
<colgroup className="border-subtle overflow-hidden rounded-b-lg border border-b-0" span={3} />
<tr>
<td className="border-subtle border px-4 py-3 md:text-center">Id</td>
<td className="border-subtle border px-4 py-3 md:text-center">Username</td>
<td className="border-subtle border px-4 py-3 md:text-center">Email</td>
</tr>
{managedUsers.map((user) => {
return (
<tr key={user.id} className="">
<td className="border-subtle overflow-hidden border px-4 py-3 md:text-center">{user.id}</td>
<td className="border-subtle border px-4 py-3 md:text-center">{user.username}</td>
<td className="border-subtle overflow-hidden border px-4 py-3 md:overflow-auto md:text-center">
{user.email}
</td>
</tr>
);
})}
</table>
</>
) : (
<EmptyScreen
limitWidth={false}
headline={
initialClientId == undefined
? "OAuth client is missing. You need to create an OAuth client first in order to create a managed user."
: `OAuth client ${initialClientId} does not have a managed user present.`
}
description={
initialClientId == undefined
? "Refer to the Platform Docs from the sidebar in order to create an OAuth client."
: "Refer to the Platform Docs from the sidebar in order to create a managed user."
}
className="items-center border"
/>
)}
</div>
);
};
@@ -0,0 +1,50 @@
import type { PlatformOAuthClient } from "@calcom/prisma/client";
import {
Button,
Dropdown,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownItem,
} from "@calcom/ui";
type OAuthClientsDropdownProps = {
oauthClients: PlatformOAuthClient[];
initialClientName: string;
handleChange: (clientId: string, clientName: string) => void;
};
export const OAuthClientsDropdown = ({
oauthClients,
initialClientName,
handleChange,
}: OAuthClientsDropdownProps) => {
return (
<div>
{Array.isArray(oauthClients) && oauthClients.length > 0 ? (
<Dropdown modal={false}>
<DropdownMenuTrigger asChild>
<Button color="secondary">{initialClientName}</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{oauthClients.map((client) => {
return (
<div key={client.id}>
{initialClientName !== client.name ? (
<DropdownMenuItem className="outline-none">
<DropdownItem type="button" onClick={() => handleChange(client.id, client.name)}>
{client.name}
</DropdownItem>
</DropdownMenuItem>
) : (
<></>
)}
</div>
);
})}
</DropdownMenuContent>
</Dropdown>
) : null}
</div>
);
};
@@ -0,0 +1,81 @@
import { useRouter } from "next/navigation";
import type { PlatformOAuthClient } from "@calcom/prisma/client";
import { EmptyScreen, Button } from "@calcom/ui";
import { OAuthClientCard } from "@components/settings/platform/oauth-clients/OAuthClientCard";
type OAuthClientsListProps = {
oauthClients: PlatformOAuthClient[];
isDeleting: boolean;
handleDelete: (id: string) => Promise<void>;
};
export const OAuthClientsList = ({ oauthClients, isDeleting, handleDelete }: OAuthClientsListProps) => {
return (
<div className="mb-10">
<div className="border-subtle mx-auto block justify-between rounded-t-lg border px-4 py-6 sm:flex sm:px-6">
<div className="flex w-full flex-col">
<h1 className="font-cal text-emphasis mb-1 text-xl font-semibold leading-5 tracking-wide">
OAuth Clients
</h1>
<p className="text-default text-sm ltr:mr-4 rtl:ml-4">
Connect your platform to cal.com with OAuth
</p>
</div>
<div>
<NewOAuthClientButton redirectLink="/settings/platform/oauth-clients/create" />
</div>
</div>
{Array.isArray(oauthClients) && oauthClients.length ? (
<>
<div className="border-subtle rounded-b-lg border border-t-0">
{oauthClients.map((client, index) => {
return (
<OAuthClientCard
name={client.name}
redirectUris={client.redirectUris}
bookingRedirectUri={client.bookingRedirectUri}
bookingRescheduleRedirectUri={client.bookingRescheduleRedirectUri}
bookingCancelRedirectUri={client.bookingCancelRedirectUri}
permissions={client.permissions}
key={index}
lastItem={oauthClients.length === index + 1}
id={client.id}
secret={client.secret}
isLoading={isDeleting}
onDelete={handleDelete}
areEmailsEnabled={client.areEmailsEnabled}
/>
);
})}
</div>
</>
) : (
<EmptyScreen
headline="Create your first OAuth client"
description="OAuth clients facilitate access to Cal.com on behalf of users"
Icon="plus"
className=""
buttonRaw={<NewOAuthClientButton redirectLink="/settings/platform/oauth-clients/create" />}
/>
)}
</div>
);
};
const NewOAuthClientButton = ({ redirectLink, label }: { redirectLink: string; label?: string }) => {
const router = useRouter();
return (
<Button
onClick={(e) => {
e.preventDefault();
router.push(redirectLink);
}}
color="secondary"
StartIcon="plus">
{!!label ? label : "Add"}
</Button>
);
};
@@ -0,0 +1,14 @@
import { useCheckTeamBilling } from "@lib/hooks/settings/platform/oauth-clients/usePersistOAuthClient";
import useMeQuery from "@lib/hooks/useMeQuery";
export const useGetUserAttributes = () => {
const { data: user, isLoading: isUserLoading } = useMeQuery();
const { data: userBillingData, isFetching: isUserBillingDataLoading } = useCheckTeamBilling(
user?.organizationId
);
const isPlatformUser = user?.organization.isPlatform;
const isPaidUser = userBillingData?.valid;
const userOrgId = user?.organizationId;
return { isUserLoading, isUserBillingDataLoading, isPlatformUser, isPaidUser, userBillingData, userOrgId };
};
@@ -6,7 +6,7 @@ import { PERMISSIONS_GROUPED_MAP } from "@calcom/platform-constants";
import type { Avatar } from "@calcom/prisma/client";
import { Button, Icon, showToast } from "@calcom/ui";
import { hasPermission } from "../../../../../../../packages/platform/utils/permissions";
import { hasPermission } from "../../../../../../packages/platform/utils/permissions";
type OAuthClientCardProps = {
name: string;
@@ -115,7 +115,7 @@ export const OAuthClientCard = ({
</div>
<div className="border-subtle flex text-sm">
<span className="font-semibold">Permissions: </span>
<div className="flex">{clientPermissions}</div>
{permissions ? <div className="flex">{clientPermissions}</div> : <>&nbsp;Disabled</>}
</div>
<div className="flex gap-1 text-sm">
<span className="font-semibold">Redirect uris: </span>
@@ -145,7 +145,7 @@ export const OAuthClientCard = ({
className="bg-subtle hover:bg-emphasis text-white"
loading={isLoading}
disabled={isLoading}
onClick={() => router.push(`/settings/organizations/platform/oauth-clients/create?clientId=${id}`)}>
onClick={() => router.push(`/settings/platform/oauth-clients/${id}/edit`)}>
Edit
</Button>
<Button
@@ -1,33 +1,18 @@
import { useRouter } from "next/navigation";
import type { FC } from "react";
import React, { useState, useCallback, useEffect } from "react";
import { useState, useCallback } from "react";
import { useForm, useFieldArray } from "react-hook-form";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { PERMISSIONS_GROUPED_MAP } from "@calcom/platform-constants/permissions";
import { showToast } from "@calcom/ui";
import { Meta, Button, TextField, Label, Tooltip } from "@calcom/ui";
import { TextField, Tooltip, Button, Label } from "@calcom/ui";
import { useOAuthClient } from "@lib/hooks/settings/organizations/platform/oauth-clients/useOAuthClients";
import {
useCreateOAuthClient,
useUpdateOAuthClient,
} from "@lib/hooks/settings/organizations/platform/oauth-clients/usePersistOAuthClient";
type OAuthClientFormProps = {
defaultValues?: Partial<FormValues>;
isPending?: boolean;
isFormDisabled?: boolean;
onSubmit: (data: FormValues) => void;
};
import {
hasAppsReadPermission,
hasAppsWritePermission,
hasBookingReadPermission,
hasBookingWritePermission,
hasEventTypeReadPermission,
hasEventTypeWritePermission,
hasProfileReadPermission,
hasProfileWritePermission,
hasScheduleReadPermission,
hasScheduleWritePermission,
} from "../../../../../../../packages/platform/utils/permissions";
type FormValues = {
export type FormValues = {
name: string;
logo?: string;
permissions: number;
@@ -50,45 +35,20 @@ type FormValues = {
areEmailsEnabled?: boolean;
};
export const OAuthClientForm: FC<{ clientId?: string }> = ({ clientId }) => {
export const OAuthClientForm = ({
defaultValues,
isPending,
isFormDisabled,
onSubmit,
}: OAuthClientFormProps) => {
const { t } = useLocale();
const router = useRouter();
const { data, isFetched, isError, refetch } = useOAuthClient(clientId);
const { register, control, handleSubmit, setValue } = useForm<FormValues>({
defaultValues: {
redirectUris: [{ uri: "" }],
},
defaultValues: { redirectUris: [{ uri: "" }], ...defaultValues },
});
const { fields, append, remove } = useFieldArray({
control,
name: "redirectUris",
});
useEffect(() => {
if (isFetched && data && !isError) {
setValue("name", data.name);
data.bookingRedirectUri && setValue("bookingRedirectUri", data.bookingRedirectUri);
data.bookingCancelRedirectUri && setValue("bookingCancelRedirectUri", data.bookingCancelRedirectUri);
data.bookingRescheduleRedirectUri &&
setValue("bookingRescheduleRedirectUri", data.bookingRescheduleRedirectUri);
setValue("areEmailsEnabled", data?.areEmailsEnabled);
data.redirectUris?.forEach?.((uri: string, index: number) => {
index === 0 && setValue(`redirectUris.${index}.uri`, uri);
index !== 0 && append({ uri });
});
if (!data.permissions) return;
if (hasAppsReadPermission(data.permissions)) setValue("appsRead", true);
if (hasAppsWritePermission(data.permissions)) setValue("appsWrite", true);
if (hasBookingReadPermission(data.permissions)) setValue("bookingRead", true);
if (hasBookingWritePermission(data.permissions)) setValue("bookingWrite", true);
if (hasEventTypeReadPermission(data.permissions)) setValue("eventTypeRead", true);
if (hasEventTypeWritePermission(data.permissions)) setValue("eventTypeWrite", true);
if (hasProfileReadPermission(data.permissions)) setValue("profileRead", true);
if (hasProfileWritePermission(data.permissions)) setValue("profileWrite", true);
if (hasScheduleReadPermission(data.permissions)) setValue("scheduleRead", true);
if (hasScheduleWritePermission(data.permissions)) setValue("scheduleWrite", true);
}
}, [isFetched, data]);
const disabledForm = Boolean(clientId && !isFetched && isError);
const [isSelectAllPermissionsChecked, setIsSelectAllPermissionsChecked] = useState(false);
@@ -104,67 +64,6 @@ export const OAuthClientForm: FC<{ clientId?: string }> = ({ clientId }) => {
setIsSelectAllPermissionsChecked((preValue) => !preValue);
}, [isSelectAllPermissionsChecked, setValue]);
const { mutateAsync: save, isPending: isSaving } = useCreateOAuthClient({
onSuccess: () => {
showToast("OAuth client created successfully", "success");
refetch();
router.push("/settings/organizations/platform/oauth-clients");
},
onError: () => {
showToast("Internal server error, please try again later", "error");
},
});
const { mutateAsync: update, isPending: isUpdating } = useUpdateOAuthClient({
onSuccess: () => {
showToast("OAuth client updated successfully", "success");
refetch();
router.push("/settings/organizations/platform/oauth-clients");
},
onError: () => {
showToast("Internal server error, please try again later", "error");
},
clientId,
});
const onSubmit = (data: FormValues) => {
let userPermissions = 0;
const userRedirectUris = data.redirectUris.map((uri) => uri.uri).filter((uri) => !!uri);
Object.keys(PERMISSIONS_GROUPED_MAP).forEach((key) => {
const entity = key as keyof typeof PERMISSIONS_GROUPED_MAP;
const entityKey = PERMISSIONS_GROUPED_MAP[entity].key;
const read = PERMISSIONS_GROUPED_MAP[entity].read;
const write = PERMISSIONS_GROUPED_MAP[entity].write;
if (data[`${entityKey}Read`]) userPermissions |= read;
if (data[`${entityKey}Write`]) userPermissions |= write;
});
if (clientId) {
// don't update permissions if client is already created
update({
name: data.name,
// logo: data.logo,
redirectUris: userRedirectUris,
bookingRedirectUri: data.bookingRedirectUri,
bookingCancelRedirectUri: data.bookingCancelRedirectUri,
bookingRescheduleRedirectUri: data.bookingRescheduleRedirectUri,
areEmailsEnabled: data.areEmailsEnabled,
});
} else {
save({
name: data.name,
permissions: userPermissions,
// logo: data.logo,
redirectUris: userRedirectUris,
bookingRedirectUri: data.bookingRedirectUri,
bookingCancelRedirectUri: data.bookingCancelRedirectUri,
bookingRescheduleRedirectUri: data.bookingRescheduleRedirectUri,
areEmailsEnabled: data.areEmailsEnabled,
});
}
};
const isPending = isSaving || isUpdating;
const permissionsCheckboxes = Object.keys(PERMISSIONS_GROUPED_MAP).map((key) => {
const entity = key as keyof typeof PERMISSIONS_GROUPED_MAP;
const permissionKey = PERMISSIONS_GROUPED_MAP[entity].key;
@@ -180,7 +79,7 @@ export const OAuthClientForm: FC<{ clientId?: string }> = ({ clientId }) => {
id={`${permissionKey}Read`}
className="bg-default border-default h-4 w-4 shrink-0 cursor-pointer rounded-[4px] border ring-offset-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed"
type="checkbox"
disabled={Boolean(clientId)}
disabled={!!defaultValues}
/>
<label htmlFor={`${permissionKey}Read`} className="cursor-pointer text-sm">
Read
@@ -192,7 +91,7 @@ export const OAuthClientForm: FC<{ clientId?: string }> = ({ clientId }) => {
id={`${permissionKey}Write`}
className="bg-default border-default h-4 w-4 shrink-0 cursor-pointer rounded-[4px] border ring-offset-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed"
type="checkbox"
disabled={Boolean(clientId)}
disabled={!!defaultValues}
/>
<label htmlFor={`${permissionKey}Write`} className="cursor-pointer text-sm">
Write
@@ -205,16 +104,11 @@ export const OAuthClientForm: FC<{ clientId?: string }> = ({ clientId }) => {
return (
<div>
<Meta
title={t("oauth_form_title") + (clientId ? " - Update" : "")}
description={t("oauth_form_description") + (clientId ? " - Update" : "")}
borderInShellHeader={true}
/>
<form
className="border-subtle rounded-b-lg border border-t-0 px-4 pb-8 pt-2"
onSubmit={handleSubmit(onSubmit)}>
<div className="mt-6">
<TextField disabled={disabledForm} required={true} label="Client name" {...register("name")} />
<TextField disabled={isFormDisabled} required={true} label="Client name" {...register("name")} />
</div>
<div className="mt-6">
<Label>Redirect uris</Label>
@@ -227,7 +121,7 @@ export const OAuthClientForm: FC<{ clientId?: string }> = ({ clientId }) => {
required={index === 0}
className="w-[100%]"
label=""
disabled={disabledForm}
disabled={isFormDisabled}
{...register(`redirectUris.${index}.uri` as const)}
/>
</div>
@@ -239,10 +133,10 @@ export const OAuthClientForm: FC<{ clientId?: string }> = ({ clientId }) => {
variant="icon"
StartIcon="plus"
className="text-default mx-2 mb-2"
disabled={isFormDisabled}
onClick={() => {
append({ uri: "" });
}}
disabled={disabledForm}
/>
{index > 0 && (
<Button
@@ -252,10 +146,10 @@ export const OAuthClientForm: FC<{ clientId?: string }> = ({ clientId }) => {
variant="icon"
StartIcon="trash"
className="text-default mx-2 mb-2"
disabled={isFormDisabled}
onClick={() => {
remove(index);
}}
disabled={disabledForm}
/>
)}
</div>
@@ -300,7 +194,7 @@ export const OAuthClientForm: FC<{ clientId?: string }> = ({ clientId }) => {
label="Booking redirect uri"
className="w-[100%]"
{...register("bookingRedirectUri")}
disabled={disabledForm}
disabled={isFormDisabled}
/>
</Tooltip>
</div>
@@ -311,7 +205,7 @@ export const OAuthClientForm: FC<{ clientId?: string }> = ({ clientId }) => {
label="Booking cancel redirect uri"
className="w-[100%]"
{...register("bookingCancelRedirectUri")}
disabled={disabledForm}
disabled={isFormDisabled}
/>
</Tooltip>
</div>
@@ -322,7 +216,7 @@ export const OAuthClientForm: FC<{ clientId?: string }> = ({ clientId }) => {
label="Booking reschedule redirect uri"
className="w-[100%]"
{...register("bookingRescheduleRedirectUri")}
disabled={disabledForm}
disabled={isFormDisabled}
/>
</Tooltip>
</div>
@@ -332,27 +226,23 @@ export const OAuthClientForm: FC<{ clientId?: string }> = ({ clientId }) => {
id="areEmailsEnabled"
className="bg-default border-default h-4 w-4 shrink-0 cursor-pointer rounded-[4px] border ring-offset-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed"
type="checkbox"
disabled={disabledForm}
disabled={isFormDisabled}
/>
<label htmlFor="areEmailsEnabled" className="cursor-pointer px-2 text-base font-semibold">
Enable emails
</label>
</div>
<div className="mt-6">
<div className="flex justify-between">
<Tooltip side="right" content="Permissions once set cannot be modified">
<h1 className="text-base font-semibold underline">Permissions</h1>
</Tooltip>
<Button type="button" onClick={selectAllPermissions} disabled={disabledForm || Boolean(clientId)}>
<h1 className="text-base font-semibold underline">Permissions</h1>
<Button type="button" onClick={selectAllPermissions} disabled={!!defaultValues || isFormDisabled}>
{!isSelectAllPermissionsChecked ? "Select all" : "Discard all"}
</Button>
</div>
<div>{permissionsCheckboxes}</div>
</div>
<Button className="mt-6" type="submit" loading={isPending} disabled={disabledForm}>
{clientId ? "Update" : "Submit"}
<Button className="mt-6" type="submit" loading={isPending}>
{defaultValues ? "Update" : "Submit"}
</Button>
</form>
</div>
@@ -0,0 +1,57 @@
type IndividualPlatformPlan = {
plan: string;
description: string;
pricing?: number;
includes: string[];
};
// if pricing or plans change in future modify this
export const platformPlans: IndividualPlatformPlan[] = [
{
plan: "Starter",
description:
"Perfect for just getting started with community support and access to hosted platform APIs, Cal.com Atoms (React components) and more.",
pricing: 99,
includes: [
"Up to 100 bookings a month",
"Community Support",
"Cal Atoms (React Library)",
"Platform APIs",
"Admin APIs",
],
},
{
plan: "Essentials",
description:
"Your essential package with sophisticated support, hosted platform APIs, Cal.com Atoms (React components) and more.",
pricing: 299,
includes: [
"Up to 500 bookings a month. $0,60 overage beyond",
"Everything in Starter",
"Cal Atoms (React Library)",
"User Management and Analytics",
"Technical Account Manager and Onboarding Support",
],
},
{
plan: "Scale",
description:
"The best all-in-one plan to scale your company. Everything you need to provide scheduling for the masses, without breaking things.",
pricing: 2499,
includes: [
"Up to 5000 bookings a month. $0.50 overage beyond",
"Everything in Essentials",
"Credential import from other platforms",
"Compliance Check SOC2, HIPAA",
"One-on-one developer calls",
"Help with Credentials Verification (Zoom, Google App Store)",
"Expedited features and integrations",
"SLA (99.999% uptime)",
],
},
{
plan: "Enterprise",
description: "Everything in Scale with generous volume discounts beyond 50,000 bookings a month.",
includes: ["Beyond 50,000 bookings a month", "Everything in Scale", "Up to 50% discount on overages"],
},
];
@@ -0,0 +1,54 @@
import { Button } from "@calcom/ui";
type PlatformBillingCardProps = {
plan: string;
description: string;
pricing?: number;
includes: string[];
isLoading?: boolean;
handleSubscribe?: () => void;
};
export const PlatformBillingCard = ({
plan,
description,
pricing,
includes,
isLoading,
handleSubscribe,
}: PlatformBillingCardProps) => {
return (
<div className="border-subtle w-auto rounded-md border p-5 lg:w-[350px]">
<div className="pb-5">
<h1 className="pb-3 text-xl font-semibold">{plan}</h1>
<p className="pb-5 text-base">{description}</p>
<h1 className="text-3xl font-semibold">
{pricing && (
<>
US${pricing} <span className="text-sm">per month</span>
</>
)}
</h1>
</div>
<div>
<Button
loading={isLoading}
onClick={handleSubscribe}
className="flex w-[100%] items-center justify-center">
{pricing ? "Subscribe" : "Schedule a time"}
</Button>
</div>
<div className="mt-5">
<p>This includes:</p>
{includes.map((feature) => {
return (
<div key={feature} className="my-2 flex">
<div className="pr-2">&bull;</div>
<div>{feature}</div>
</div>
);
})}
</div>
</div>
);
};
@@ -0,0 +1,53 @@
import { useRouter } from "next/navigation";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { showToast } from "@calcom/ui";
import { useSubscribeTeamToStripe } from "@lib/hooks/settings/platform/oauth-clients/usePersistOAuthClient";
import { platformPlans } from "@components/settings/platform/platformUtils";
import { PlatformBillingCard } from "@components/settings/platform/pricing/billing-card";
type PlatformPricingProps = { teamId?: number | null };
export const PlatformPricing = ({ teamId }: PlatformPricingProps) => {
const router = useRouter();
const { mutateAsync, isPending } = useSubscribeTeamToStripe({
onSuccess: (redirectUrl: string) => {
router.push(redirectUrl);
},
onError: () => {
showToast(ErrorCode.UnableToSubscribeToThePlatform, "error");
},
teamId,
});
return (
<div className="flex h-auto flex-col items-center justify-center px-5 py-5 md:px-10 md:py-0 lg:h-[100vh]">
<div className="mb-5 text-center text-2xl font-semibold">
<h1>Subscribe to Platform</h1>
</div>
<div className="flex flex-col flex-wrap gap-x-0 gap-y-5 md:flex-row md:gap-x-5 md:gap-y-5 lg:flex-nowrap">
{platformPlans.map((plan) => {
return (
<div key={plan.plan}>
<PlatformBillingCard
plan={plan.plan}
description={plan.description}
pricing={plan.pricing}
includes={plan.includes}
isLoading={isPending}
handleSubscribe={() => {
!!teamId &&
(plan.plan === "Enterprise"
? router.push("https://i.cal.com/sales/exploration")
: mutateAsync({ plan: plan.plan.toLocaleUpperCase() }));
}}
/>
</div>
);
})}
</div>
</div>
);
};
@@ -3,6 +3,17 @@ import { useQuery } from "@tanstack/react-query";
import type { ApiSuccessResponse } from "@calcom/platform-types";
import type { PlatformOAuthClient } from "@calcom/prisma/client";
export type ManagedUser = {
id: number;
email: string;
username: string | null;
timeZone: string;
weekStart: string;
createdDate: Date;
timeFormat: number | null;
defaultScheduleId: number | null;
};
export const useOAuthClients = () => {
const query = useQuery<ApiSuccessResponse<PlatformOAuthClient[]>>({
queryKey: ["oauth-clients"],
@@ -52,3 +63,22 @@ export const useOAuthClient = (clientId?: string) => {
refetch,
};
};
export const useGetOAuthClientManagedUsers = (clientId: string) => {
const {
isLoading,
error,
data: response,
refetch,
} = useQuery<ApiSuccessResponse<ManagedUser[]>>({
queryKey: ["oauth-client-managed-users", clientId],
queryFn: () => {
return fetch(`/api/v2/oauth-clients/${clientId}/managed-users`, {
method: "get",
headers: { "Content-type": "application/json" },
}).then((res) => res.json());
},
enabled: Boolean(clientId),
});
return { isLoading, error, data: response?.data, refetch };
};
@@ -1,7 +1,12 @@
import { useMutation } from "@tanstack/react-query";
import { useMutation, useQuery } from "@tanstack/react-query";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import type { ApiResponse, CreateOAuthClientInput, DeleteOAuthClientInput } from "@calcom/platform-types";
import type {
ApiResponse,
CreateOAuthClientInput,
DeleteOAuthClientInput,
SubscribeTeamInput,
} from "@calcom/platform-types";
import type { OAuthClient } from "@calcom/prisma/client";
interface IPersistOAuthClient {
@@ -113,3 +118,59 @@ export const useDeleteOAuthClient = (
return mutation;
};
export const useCheckTeamBilling = (teamId?: number | null) => {
const QUERY_KEY = "check-team-billing";
const isTeamBilledAlready = useQuery({
queryKey: [QUERY_KEY, teamId],
queryFn: async () => {
const response = await fetch(`/api/v2/billing/${teamId}/check`, {
method: "get",
headers: { "Content-type": "application/json" },
});
const data = await response.json();
return data.data;
},
enabled: !!teamId,
});
return isTeamBilledAlready;
};
export const useSubscribeTeamToStripe = (
{
onSuccess,
onError,
teamId,
}: { teamId?: number | null; onSuccess: (redirectUrl: string) => void; onError: () => void } = {
onSuccess: () => {
return;
},
onError: () => {
return;
},
}
) => {
const mutation = useMutation<ApiResponse<{ action: string; url: string }>, unknown, SubscribeTeamInput>({
mutationFn: (data) => {
return fetch(`/api/v2/billing/${teamId}/subscribe`, {
method: "post",
headers: { "Content-type": "application/json" },
body: JSON.stringify(data),
}).then((res) => res?.json());
},
onSuccess: (data) => {
if (data.status === SUCCESS_STATUS) {
onSuccess?.(data.data?.url);
} else {
onError?.();
}
},
onError: () => {
onError?.();
},
});
return mutation;
};
@@ -413,6 +413,10 @@ export default function Success(props: PageProps) {
imageSrc={`${bookingInfo.user.avatarUrl}`}
/>
)}
{giphyImage && !needsConfirmation && !isCancelled && (
// eslint-disable-next-line @next/next/no-img-element
<img src={giphyImage} className="w-full rounded-lg" alt="Gif from Giphy" />
)}
<div
className={classNames(
"mx-auto flex h-12 w-12 items-center justify-center rounded-full",
@@ -422,10 +426,6 @@ export default function Success(props: PageProps) {
!giphyImage && !isCancelled && needsConfirmation ? "bg-subtle" : "",
isCancelled ? "bg-error" : ""
)}>
{giphyImage && !needsConfirmation && !isCancelled && (
// eslint-disable-next-line @next/next/no-img-element
<img src={giphyImage} alt="Gif from Giphy" />
)}
{!giphyImage && !needsConfirmation && !isCancelled && (
<Icon name="check" className="h-5 w-5 text-green-600 dark:text-green-400" />
)}
@@ -1,6 +1,8 @@
import type { GetServerSidePropsContext } from "next";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { asStringOrThrow } from "@lib/asStringOrNull";
import type { inferSSRProps } from "@lib/types/inferSSRProps";
@@ -40,6 +42,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
const { eventType } = await ssr.viewer.eventTypes.get.fetch({ id: eventTypeId });
return eventType;
} catch (e: unknown) {
logger.error(safeStringify(e));
// reject, user has no access to this event type.
return null;
}
@@ -104,11 +104,13 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
if (isDynamicGroup) {
const destinationUrl = `/${usernameList.join("+")}/dynamic`;
const originalQueryString = new URLSearchParams(context.query as Record<string, string>).toString();
const destinationWithQuery = `${destinationUrl}?${originalQueryString}`;
log.debug(`Dynamic group detected, redirecting to ${destinationUrl}`);
return {
redirect: {
permanent: false,
destination: destinationUrl,
destination: destinationWithQuery,
},
} as const;
}
@@ -1,13 +1,17 @@
import type { DehydratedState } from "@tanstack/react-query";
import { type GetServerSidePropsContext } from "next";
import type { Session } from "next-auth";
import { z } from "zod";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { getBookingForReschedule, getBookingForSeatedEvent } from "@calcom/features/bookings/lib/get-booking";
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import type { getPublicEvent } from "@calcom/features/eventtypes/lib/getPublicEvent";
import { getUsernameList } from "@calcom/lib/defaultEvents";
import { UserRepository } from "@calcom/lib/server/repository/user";
import slugify from "@calcom/lib/slugify";
import prisma from "@calcom/prisma";
import { RedirectType } from "@calcom/prisma/client";
import { getTemporaryOrgRedirect } from "@lib/getTemporaryOrgRedirect";
@@ -16,6 +20,74 @@ import { type EmbedProps } from "@lib/withEmbedSsr";
export type PageProps = inferSSRProps<typeof getServerSideProps> & EmbedProps;
type Props = {
eventData: Pick<
NonNullable<Awaited<ReturnType<typeof getPublicEvent>>>,
"id" | "length" | "metadata" | "entity"
>;
booking?: GetBookingType;
rescheduleUid: string | null;
bookingUid: string | null;
user: string;
slug: string;
trpcState: DehydratedState;
isBrandingHidden: boolean;
isSEOIndexable: boolean | null;
themeBasis: null | string;
orgBannerUrl: null;
};
async function processReschedule({
props,
rescheduleUid,
session,
}: {
props: Props;
session: Session | null;
rescheduleUid: string | string[] | undefined;
}) {
if (!rescheduleUid) return;
const booking = await getBookingForReschedule(`${rescheduleUid}`, session?.user?.id);
// if no booking found, no eventTypeId (dynamic) or it matches this eventData - return void (success).
if (booking === null || !booking.eventTypeId || booking?.eventTypeId === props.eventData?.id) {
props.booking = booking;
props.rescheduleUid = Array.isArray(rescheduleUid) ? rescheduleUid[0] : rescheduleUid;
return;
}
// handle redirect response
const redirectEventTypeTarget = await prisma.eventType.findUnique({
where: {
id: booking.eventTypeId,
},
select: {
slug: true,
},
});
if (!redirectEventTypeTarget) {
return {
notFound: true,
} as const;
}
return {
redirect: {
permanent: false,
destination: redirectEventTypeTarget.slug,
},
};
}
async function processSeatedEvent({
props,
bookingUid,
}: {
props: Props;
bookingUid: string | string[] | undefined;
}) {
if (!bookingUid) return;
props.booking = await getBookingForSeatedEvent(`${bookingUid}`);
props.bookingUid = Array.isArray(bookingUid) ? bookingUid[0] : bookingUid;
}
async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
const session = await getServerSession(context);
const { user: usernames, type: slug } = paramsSchema.parse(context.params);
@@ -51,13 +123,6 @@ async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
} as const;
}
let booking: GetBookingType | null = null;
if (rescheduleUid) {
booking = await getBookingForReschedule(`${rescheduleUid}`, session?.user?.id);
} else if (bookingUid) {
booking = await getBookingForSeatedEvent(`${bookingUid}`);
}
// We use this to both prefetch the query on the server,
// as well as to check if the event exist, so we c an show a 404 otherwise.
const eventData = await ssr.viewer.public.event.fetch({
@@ -73,27 +138,38 @@ async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
} as const;
}
return {
props: {
eventData: {
entity: eventData.entity,
length: eventData.length,
metadata: {
...eventData.metadata,
multipleDuration: [15, 30, 60],
},
const props: Props = {
eventData: {
id: eventData.id,
entity: eventData.entity,
length: eventData.length,
metadata: {
...eventData.metadata,
multipleDuration: [15, 30, 60],
},
booking,
user: usernames.join("+"),
slug,
trpcState: ssr.dehydrate(),
isBrandingHidden: false,
isSEOIndexable: true,
themeBasis: null,
bookingUid: bookingUid ? `${bookingUid}` : null,
rescheduleUid: rescheduleUid ? `${rescheduleUid}` : null,
orgBannerUrl: null,
},
user: usernames.join("+"),
slug,
trpcState: ssr.dehydrate(),
isBrandingHidden: false,
isSEOIndexable: true,
themeBasis: null,
bookingUid: bookingUid ? `${bookingUid}` : null,
rescheduleUid: null,
orgBannerUrl: null,
};
if (rescheduleUid) {
const processRescheduleResult = await processReschedule({ props, rescheduleUid, session });
if (processRescheduleResult) {
return processRescheduleResult;
}
} else if (bookingUid) {
await processSeatedEvent({ props, bookingUid });
}
return {
props,
};
}
@@ -131,13 +207,6 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
} as const;
}
let booking: GetBookingType | null = null;
if (rescheduleUid) {
booking = await getBookingForReschedule(`${rescheduleUid}`, session?.user?.id);
} else if (bookingUid) {
booking = await getBookingForSeatedEvent(`${bookingUid}`);
}
const org = isValidOrgDomain ? currentOrgDomain : null;
// We use this to both prefetch the query on the server,
// as well as to check if the event exist, so we can show a 404 otherwise.
@@ -154,24 +223,35 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
} as const;
}
return {
props: {
booking,
eventData: {
entity: eventData.entity,
length: eventData.length,
metadata: eventData.metadata,
},
user: username,
slug,
trpcState: ssr.dehydrate(),
isBrandingHidden: user?.hideBranding,
isSEOIndexable: user?.allowSEOIndexing,
themeBasis: username,
bookingUid: bookingUid ? `${bookingUid}` : null,
rescheduleUid: rescheduleUid ? `${rescheduleUid}` : null,
orgBannerUrl: eventData?.owner?.profile?.organization?.bannerUrl ?? null,
const props: Props = {
eventData: {
id: eventData.id,
entity: eventData.entity,
length: eventData.length,
metadata: eventData.metadata,
},
user: username,
slug,
trpcState: ssr.dehydrate(),
isBrandingHidden: user?.hideBranding,
isSEOIndexable: user?.allowSEOIndexing,
themeBasis: username,
bookingUid: bookingUid ? `${bookingUid}` : null,
rescheduleUid: null,
orgBannerUrl: eventData?.owner?.profile?.organization?.bannerUrl ?? null,
};
if (rescheduleUid) {
const processRescheduleResult = await processReschedule({ props, rescheduleUid, session });
if (processRescheduleResult) {
return processRescheduleResult;
}
} else if (bookingUid) {
await processSeatedEvent({ props, bookingUid });
}
return {
props,
};
}
+1 -1
View File
@@ -6,7 +6,7 @@ import { Toaster } from "react-hot-toast";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { showToast } from "@calcom/ui";
export const CalAiTransctibe = () => {
export const CalAiTranscribe = () => {
const daily = useDaily();
const { t } = useLocale();
@@ -60,6 +60,19 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
};
}
const hasTeamPlan = booking.user?.id
? await prisma.membership.findFirst({
where: {
userId: booking.user.id,
team: {
slug: {
not: null,
},
},
},
})
: false;
const profile = booking.user
? (
await UserRepository.enrichUserWithItsProfile({
@@ -114,6 +127,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
}
: bookingObj.user,
},
hasTeamPlan: !!hasTeamPlan,
trpcState: ssr.dehydrate(),
},
};
@@ -14,13 +14,13 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import { Icon } from "@calcom/ui";
import { CalAiTransctibe } from "~/videos/ai/ai-transcribe";
import { CalAiTranscribe } from "~/videos/ai/ai-transcribe";
import { type PageProps } from "./videos-single-view.getServerSideProps";
export default function JoinCall(props: PageProps) {
const { t } = useLocale();
const { meetingUrl, meetingPassword, booking } = props;
const { meetingUrl, meetingPassword, booking, hasTeamPlan } = props;
const [daily, setDaily] = useState<DailyCall | null>(null);
useEffect(() => {
@@ -47,14 +47,16 @@ export default function JoinCall(props: PageProps) {
},
url: meetingUrl,
...(typeof meetingPassword === "string" && { token: meetingPassword }),
customTrayButtons: {
transcription: {
label: "Cal.ai",
tooltip: "Enable transcription powered by AI",
iconPath: `${WEBAPP_URL}/sparkles.svg`,
iconPathDarkMode: `${WEBAPP_URL}/sparkles.svg`,
...(hasTeamPlan && {
customTrayButtons: {
transcription: {
label: "Cal.ai",
tooltip: "Toggle real time transcription powered by AI",
iconPath: `${WEBAPP_URL}/sparkles.svg`,
iconPathDarkMode: `${WEBAPP_URL}/sparkles.svg`,
},
},
},
}),
});
setDaily(callFrame);
@@ -86,7 +88,7 @@ export default function JoinCall(props: PageProps) {
</Head>
<DailyProvider callObject={daily}>
<div className="mx-auto" style={{ zIndex: 2, position: "absolute", bottom: 60, width: "100%" }}>
<CalAiTransctibe />
<CalAiTranscribe />
</div>
<div style={{ zIndex: 2, position: "relative" }}>
{booking?.user?.organization?.calVideoLogo ? (
+5
View File
@@ -537,6 +537,11 @@ const nextConfig = {
destination: "/apps/installed/calendar",
permanent: true,
},
{
source: "/settings/organizations/platform/:path*",
destination: "/settings/platform",
permanent: true,
},
// OAuth callbacks when sent to localhost:3000(w would be expected) should be redirected to corresponding to WEBAPP_URL
...(process.env.NODE_ENV === "development" &&
// Safer to enable the redirect only when the user is opting to test out organizations
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "4.0.7",
"version": "4.1.0",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
+10 -4
View File
@@ -124,6 +124,15 @@ const ProfileView = () => {
}
},
});
const unlinkConnectedAccountMutation = trpc.viewer.unlinkConnectedAccount.useMutation({
onSuccess: async (res) => {
showToast(t(res.message), "success");
utils.viewer.me.invalidate();
},
onError: (e) => {
showToast(t(e.message), "error");
},
});
const addSecondaryEmailMutation = trpc.viewer.addSecondaryEmail.useMutation({
onSuccess: (res) => {
@@ -439,10 +448,7 @@ const ProfileView = () => {
<Button
color="primary"
onClick={() => {
updateProfileMutation.mutate({
...tempFormValues,
unlinkConnectedAccount: true,
});
unlinkConnectedAccountMutation.mutate();
setShowAccountDisconnectWarning(false);
}}>
{t("confirm")}
@@ -0,0 +1,9 @@
import AdminAPIView from "@calcom/features/ee/organizations/pages/settings/admin-api";
import type { CalPageWrapper } from "@components/PageWrapper";
import PageWrapper from "@components/PageWrapper";
const Page = AdminAPIView as CalPageWrapper;
Page.PageWrapper = PageWrapper;
export default Page;
@@ -1,22 +0,0 @@
import React from "react";
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import PageWrapper from "@components/PageWrapper";
import { OAuthClientForm } from "@components/settings/organizations/platform/oauth-clients/OAuthClientForm";
export const CreateOAuthClient = () => {
const searchParams = useCompatSearchParams();
const clientId = searchParams?.get("clientId") || "";
return (
<div>
<OAuthClientForm clientId={clientId} />
</div>
);
};
CreateOAuthClient.getLayout = getLayout;
CreateOAuthClient.PageWrapper = PageWrapper;
export default CreateOAuthClient;
@@ -1,103 +0,0 @@
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import React from "react";
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
import { EmptyScreen, showToast } from "@calcom/ui";
import { Meta, Button } from "@calcom/ui";
import { Spinner } from "@calcom/ui/components/icon/Spinner";
import { useOAuthClients } from "@lib/hooks/settings/organizations/platform/oauth-clients/useOAuthClients";
import { useDeleteOAuthClient } from "@lib/hooks/settings/organizations/platform/oauth-clients/usePersistOAuthClient";
import PageWrapper from "@components/PageWrapper";
import { OAuthClientCard } from "@components/settings/organizations/platform/oauth-clients/OAuthClientCard";
const queryClient = new QueryClient();
export const OAuthClients = () => {
const { data, isLoading, refetch: refetchClients } = useOAuthClients();
const { mutateAsync, isPending: isDeleting } = useDeleteOAuthClient({
onSuccess: () => {
showToast("OAuth client deleted successfully", "success");
refetchClients();
},
});
const handleDelete = async (id: string) => {
await mutateAsync({ id: id });
};
const NewOAuthClientButton = () => {
const router = useRouter();
return (
<Button
onClick={(e) => {
e.preventDefault();
router.push("/settings/organizations/platform/oauth-clients/create");
}}
color="secondary"
StartIcon="plus">
Add
</Button>
);
};
if (isLoading) {
return <Spinner className="mx-auto mt-12 h-10 w-10" />;
}
return (
<QueryClientProvider client={queryClient}>
<div>
<Meta
title="OAuth Clients"
description="Connect your platform to cal.com with oAuth"
CTA={<NewOAuthClientButton />}
borderInShellHeader={true}
/>
<div>
{Array.isArray(data) && data.length ? (
<>
<div className="border-subtle rounded-b-lg border border-t-0">
{data.map((client, index) => {
return (
<OAuthClientCard
name={client.name}
redirectUris={client.redirectUris}
bookingRedirectUri={client.bookingRedirectUri}
bookingRescheduleRedirectUri={client.bookingRescheduleRedirectUri}
bookingCancelRedirectUri={client.bookingCancelRedirectUri}
permissions={client.permissions}
key={index}
lastItem={data.length === index + 1}
id={client.id}
secret={client.secret}
isLoading={isDeleting}
onDelete={handleDelete}
areEmailsEnabled={client.areEmailsEnabled}
/>
);
})}
</div>
</>
) : (
<EmptyScreen
headline="Create your first OAuth client"
description="OAuth clients facilitate access to Cal.com on behalf of users"
Icon="plus"
className="rounded-b-lg rounded-t-none border-t-0"
buttonRaw={<NewOAuthClientButton />}
/>
)}
</div>
</div>
</QueryClientProvider>
);
};
OAuthClients.getLayout = getLayout;
OAuthClients.PageWrapper = PageWrapper;
export default OAuthClients;
+105
View File
@@ -0,0 +1,105 @@
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
import { useState, useEffect } from "react";
import Shell from "@calcom/features/shell/Shell";
import { showToast } from "@calcom/ui";
import {
useOAuthClients,
useGetOAuthClientManagedUsers,
} from "@lib/hooks/settings/platform/oauth-clients/useOAuthClients";
import { useDeleteOAuthClient } from "@lib/hooks/settings/platform/oauth-clients/usePersistOAuthClient";
import PageWrapper from "@components/PageWrapper";
import { ManagedUserList } from "@components/settings/platform/dashboard/managed-user-list";
import { OAuthClientsList } from "@components/settings/platform/dashboard/oauth-clients-list";
import { useGetUserAttributes } from "@components/settings/platform/hooks/useGetUserAttributes";
import { PlatformPricing } from "@components/settings/platform/pricing/platform-pricing";
const queryClient = new QueryClient();
export default function Platform() {
const [initialClientId, setInitialClientId] = useState("");
const [initialClientName, setInitialClientName] = useState("");
const { data, isLoading: isOAuthClientLoading, refetch: refetchClients } = useOAuthClients();
const {
isLoading: isManagedUserLoading,
data: managedUserData,
refetch: refetchManagedUsers,
} = useGetOAuthClientManagedUsers(initialClientId);
const { isUserLoading, isUserBillingDataLoading, isPlatformUser, isPaidUser, userBillingData, userOrgId } =
useGetUserAttributes();
const { mutateAsync, isPending: isDeleting } = useDeleteOAuthClient({
onSuccess: () => {
showToast("OAuth client deleted successfully", "success");
refetchClients();
refetchManagedUsers();
},
});
const handleDelete = async (id: string) => {
await mutateAsync({ id: id });
};
useEffect(() => {
setInitialClientId(data[0]?.id);
setInitialClientName(data[0]?.name);
}, [data]);
if (isUserLoading || isOAuthClientLoading) return <div className="m-5">Loading...</div>;
if (isUserBillingDataLoading && !userBillingData) {
return <div className="m-5">Loading...</div>;
}
if (isPlatformUser && !isPaidUser) return <PlatformPricing teamId={userOrgId} />;
if (isPlatformUser) {
return (
<QueryClientProvider client={queryClient}>
<div>
<Shell
heading="Platform"
title="Platform"
hideHeadingOnMobile
withoutMain={false}
subtitle="Manage everything related to platform."
isPlatformUser={true}>
<OAuthClientsList oauthClients={data} isDeleting={isDeleting} handleDelete={handleDelete} />
<ManagedUserList
oauthClients={data}
managedUsers={managedUserData}
isManagedUserLoading={isManagedUserLoading}
initialClientName={initialClientName}
initialClientId={initialClientId}
handleChange={(id: string, name: string) => {
setInitialClientId(id);
setInitialClientName(name);
refetchManagedUsers();
}}
/>
</Shell>
</div>
</QueryClientProvider>
);
}
return (
<div>
<Shell
// we want to hide org banner and have different sidebar tabs for platform clients
// hence we pass isPlatformUser boolean as prop
isPlatformUser={true}
hideHeadingOnMobile
withoutMain={false}
SidebarContainer={<></>}>
You are not subscribed to a Platform plan.
</Shell>
</div>
);
}
Platform.PageWrapper = PageWrapper;
@@ -0,0 +1,45 @@
"use client";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import { CreateANewPlatformForm } from "@calcom/features/ee/platform/components/index";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { WizardLayout, Meta, WizardLayoutAppDir } from "@calcom/ui";
import { getServerSideProps } from "@lib/settings/organizations/new/getServerSideProps";
import PageWrapper from "@components/PageWrapper";
const CreateNewOrganizationPage = () => {
const { t } = useLocale();
return (
<LicenseRequired>
<Meta
title={t("set_up_your_platform_organization")}
description={t("platform_organization_description")}
/>
<CreateANewPlatformForm />
</LicenseRequired>
);
};
const LayoutWrapper = (page: React.ReactElement) => {
return (
<WizardLayout currentStep={1} maxSteps={1}>
{page}
</WizardLayout>
);
};
export const LayoutWrapperAppDir = (page: React.ReactElement) => {
return (
<WizardLayoutAppDir currentStep={1} maxSteps={2}>
{page}
</WizardLayoutAppDir>
);
};
CreateNewOrganizationPage.getLayout = LayoutWrapper;
CreateNewOrganizationPage.PageWrapper = PageWrapper;
export default CreateNewOrganizationPage;
export { getServerSideProps };
@@ -0,0 +1,133 @@
import { useParams } from "next/navigation";
import { useRouter } from "next/navigation";
import Shell from "@calcom/features/shell/Shell";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { PERMISSIONS_GROUPED_MAP } from "@calcom/platform-constants";
import { showToast } from "@calcom/ui";
import { useOAuthClient } from "@lib/hooks/settings/platform/oauth-clients/useOAuthClients";
import { useUpdateOAuthClient } from "@lib/hooks/settings/platform/oauth-clients/usePersistOAuthClient";
import PageWrapper from "@components/PageWrapper";
import { useGetUserAttributes } from "@components/settings/platform/hooks/useGetUserAttributes";
import type { FormValues } from "@components/settings/platform/oauth-clients/oauth-client-form";
import { OAuthClientForm as EditOAuthClientForm } from "@components/settings/platform/oauth-clients/oauth-client-form";
import {
hasAppsReadPermission,
hasAppsWritePermission,
hasBookingReadPermission,
hasBookingWritePermission,
hasEventTypeReadPermission,
hasEventTypeWritePermission,
hasProfileReadPermission,
hasProfileWritePermission,
hasScheduleReadPermission,
hasScheduleWritePermission,
} from "../../../../../../../../packages/platform/utils/permissions";
export default function EditOAuthClient() {
const router = useRouter();
const params = useParams<{ clientId: string }>();
const clientId = params?.clientId || "";
const { isUserLoading, isPlatformUser, isPaidUser } = useGetUserAttributes();
const { data, isFetched, isFetching, isError, refetch } = useOAuthClient(clientId);
const { mutateAsync: update, isPending: isUpdating } = useUpdateOAuthClient({
onSuccess: () => {
showToast("OAuth client updated successfully", "success");
refetch();
router.push("/settings/platform/");
},
onError: () => {
showToast(ErrorCode.UpdatingOauthClientError, "error");
},
clientId,
});
const onSubmit = (data: FormValues) => {
let userPermissions = 0;
const userRedirectUris = data.redirectUris.map((uri) => uri.uri).filter((uri) => !!uri);
Object.keys(PERMISSIONS_GROUPED_MAP).forEach((key) => {
const entity = key as keyof typeof PERMISSIONS_GROUPED_MAP;
const entityKey = PERMISSIONS_GROUPED_MAP[entity].key;
const read = PERMISSIONS_GROUPED_MAP[entity].read;
const write = PERMISSIONS_GROUPED_MAP[entity].write;
if (data[`${entityKey}Read`]) userPermissions |= read;
if (data[`${entityKey}Write`]) userPermissions |= write;
});
update({
name: data.name,
// logo: data.logo,
redirectUris: userRedirectUris,
bookingRedirectUri: data.bookingRedirectUri,
bookingCancelRedirectUri: data.bookingCancelRedirectUri,
bookingRescheduleRedirectUri: data.bookingRescheduleRedirectUri,
areEmailsEnabled: data.areEmailsEnabled,
});
};
if (isUserLoading) return <div className="m-5">Loading...</div>;
if (isPlatformUser && isPaidUser) {
return (
<div>
<Shell title="OAuth client updation form" isPlatformUser={true}>
<div className="m-2 md:mx-14 md:mx-5">
<div className="border-subtle mx-auto block justify-between rounded-t-lg border px-4 py-6 sm:flex sm:px-6">
<div className="flex w-full flex-col">
<h1 className="font-cal text-emphasis mb-1 text-xl font-semibold leading-5 tracking-wide">
OAuth client updation form
</h1>
<p className="text-default text-sm ltr:mr-4 rtl:ml-4">
This is the form to edit an existing OAuth client
</p>
</div>
</div>
{(!Boolean(clientId) || (isFetched && !data)) && <p>OAuth Client not found.</p>}
{isFetched && !!data && (
<EditOAuthClientForm
defaultValues={{
name: data?.name ?? "",
areEmailsEnabled: data.areEmailsEnabled ?? false,
redirectUris: data?.redirectUris?.map((uri) => ({ uri })) ?? [{ uri: "" }],
bookingRedirectUri: data?.bookingRedirectUri ?? "",
bookingCancelRedirectUri: data?.bookingCancelRedirectUri ?? "",
bookingRescheduleRedirectUri: data?.bookingRescheduleRedirectUri ?? "",
appsRead: hasAppsReadPermission(data?.permissions),
appsWrite: hasAppsWritePermission(data?.permissions),
bookingRead: hasBookingReadPermission(data?.permissions),
bookingWrite: hasBookingWritePermission(data?.permissions),
eventTypeRead: hasEventTypeReadPermission(data?.permissions),
eventTypeWrite: hasEventTypeWritePermission(data?.permissions),
profileRead: hasProfileReadPermission(data?.permissions),
profileWrite: hasProfileWritePermission(data?.permissions),
scheduleRead: hasScheduleReadPermission(data?.permissions),
scheduleWrite: hasScheduleWritePermission(data?.permissions),
}}
onSubmit={onSubmit}
isPending={isUpdating}
/>
)}
{isFetching && <p>Loading...</p>}
{isError && <p>Something went wrong.</p>}
</div>
</Shell>
</div>
);
}
return (
<div>
<Shell isPlatformUser={true} hideHeadingOnMobile withoutMain={false} SidebarContainer={<></>}>
You are not subscribed to a Platform plan.
</Shell>
</div>
);
}
EditOAuthClient.PageWrapper = PageWrapper;
@@ -0,0 +1,91 @@
import { useRouter } from "next/navigation";
import Shell from "@calcom/features/shell/Shell";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { PERMISSIONS_GROUPED_MAP } from "@calcom/platform-constants/permissions";
import { showToast } from "@calcom/ui";
import { useCreateOAuthClient } from "@lib/hooks/settings/platform/oauth-clients/usePersistOAuthClient";
import PageWrapper from "@components/PageWrapper";
import { useGetUserAttributes } from "@components/settings/platform/hooks/useGetUserAttributes";
import type { FormValues } from "@components/settings/platform/oauth-clients/oauth-client-form";
import { OAuthClientForm } from "@components/settings/platform/oauth-clients/oauth-client-form";
export default function CreateOAuthClient() {
const searchParams = useCompatSearchParams();
const router = useRouter();
const clientId = searchParams?.get("clientId") || "";
const { isUserLoading, isPlatformUser, isPaidUser } = useGetUserAttributes();
const { mutateAsync: save, isPending: isSaving } = useCreateOAuthClient({
onSuccess: () => {
showToast("OAuth client created successfully", "success");
router.push("/settings/platform/");
},
onError: () => {
showToast(ErrorCode.CreatingOauthClientError, "error");
},
});
const onSubmit = (data: FormValues) => {
let userPermissions = 0;
const userRedirectUris = data.redirectUris.map((uri) => uri.uri).filter((uri) => !!uri);
Object.keys(PERMISSIONS_GROUPED_MAP).forEach((key) => {
const entity = key as keyof typeof PERMISSIONS_GROUPED_MAP;
const entityKey = PERMISSIONS_GROUPED_MAP[entity].key;
const read = PERMISSIONS_GROUPED_MAP[entity].read;
const write = PERMISSIONS_GROUPED_MAP[entity].write;
if (data[`${entityKey}Read`]) userPermissions |= read;
if (data[`${entityKey}Write`]) userPermissions |= write;
});
save({
name: data.name,
permissions: userPermissions,
// logo: data.logo,
redirectUris: userRedirectUris,
bookingRedirectUri: data.bookingRedirectUri,
bookingCancelRedirectUri: data.bookingCancelRedirectUri,
bookingRescheduleRedirectUri: data.bookingRescheduleRedirectUri,
areEmailsEnabled: data.areEmailsEnabled,
});
};
if (isUserLoading) return <div className="m-5">Loading...</div>;
if (isPlatformUser && isPaidUser) {
return (
<div>
<Shell title={`OAuth client ${!!clientId ? "updation" : "creation"} form`} isPlatformUser={true}>
<div className="m-2 md:mx-14 md:mx-5">
<div className="border-subtle mx-auto block justify-between rounded-t-lg border px-4 py-6 sm:flex sm:px-6">
<div className="flex w-full flex-col">
<h1 className="font-cal text-emphasis mb-1 text-xl font-semibold leading-5 tracking-wide">
OAuth client creation form
</h1>
<p className="text-default text-sm ltr:mr-4 rtl:ml-4">
This is the form to create a new OAuth client
</p>
</div>
</div>
<OAuthClientForm isPending={isSaving} onSubmit={onSubmit} />
</div>
</Shell>
</div>
);
}
return (
<div>
<Shell isPlatformUser={true} hideHeadingOnMobile withoutMain={false} SidebarContainer={<></>}>
You are not subscribed to a Platform plan.
</Shell>
</div>
);
}
CreateOAuthClient.PageWrapper = PageWrapper;
+15
View File
@@ -126,6 +126,21 @@ testBothFutureAndLegacyRoutes.describe("pro user", () => {
});
});
test("it redirects when a rescheduleUid does not match the current event type", async ({
page,
users,
bookings,
}) => {
const [pro] = users.get();
const [eventType] = pro.eventTypes;
const bookingFixture = await bookings.create(pro.id, pro.username, eventType.id);
// open the wrong eventType (rescheduleUid created for /30min event)
await page.goto(`${pro.username}/${pro.eventTypes[1].slug}?rescheduleUid=${bookingFixture.uid}`);
await expect(page).toHaveURL(new RegExp(`${pro.username}/${eventType.slug}`));
});
test("Can cancel the recently created booking and rebook the same timeslot", async ({
page,
users,
@@ -62,6 +62,36 @@ test("dynamic booking", async ({ page, users }) => {
});
});
test("dynamic booking info prefilled by query params", async ({ page, users }) => {
const pro = await users.create();
await pro.apiLogin();
let duration = 15;
const free = await users.create({ username: "free.example" });
await page.goto(`/${pro.username}+${free.username}?duration=${duration}`);
await page.waitForLoadState("networkidle");
const badgeByDurationTestId = (duration: number) => `multiple-choice-${duration}mins`;
let badgeLocator = await page.getByTestId(badgeByDurationTestId(duration));
let activeState = await badgeLocator.getAttribute("data-active");
expect(activeState).toEqual("true");
duration = 30;
await page.goto(`/${pro.username}+${free.username}?duration=${duration}`);
badgeLocator = await page.getByTestId(badgeByDurationTestId(duration));
activeState = await badgeLocator.getAttribute("data-active");
expect(activeState).toEqual("true");
// Check another badge just to ensure its not selected
badgeLocator = await page.getByTestId(badgeByDurationTestId(15));
activeState = await badgeLocator.getAttribute("data-active");
expect(activeState).toEqual("false");
});
test.describe("Organization:", () => {
test.afterEach(({ orgs, users }) => {
orgs.deleteAll();
+2 -1
View File
@@ -3,7 +3,8 @@ import mailhog from "mailhog";
import { IS_MAILHOG_ENABLED } from "@calcom/lib/constants";
const unimplemented = () => {
throw new Error("Mailhog is not enabled");
// throw new Error("Mailhog is not enabled");
return null;
};
const hasUUID = (query: string) => {
+130
View File
@@ -2,6 +2,7 @@ import type { Page } from "@playwright/test";
import { expect } from "@playwright/test";
import { v4 as uuidv4 } from "uuid";
import dayjs from "@calcom/dayjs";
import prisma from "@calcom/prisma";
import { BookingStatus } from "@calcom/prisma/client";
@@ -543,6 +544,131 @@ test.describe("BOOKING_RESCHEDULED", async () => {
});
});
test.describe("MEETING_ENDED, MEETING_STARTED", async () => {
test("should create/remove scheduledWebhookTriggers for existing bookings", async ({
page,
users,
bookings,
}, _testInfo) => {
const user = await users.create();
await user.apiLogin();
const tomorrow = dayjs().add(1, "day");
const [eventType] = user.eventTypes;
bookings.create(user.id, user.name, eventType.id);
bookings.create(user.id, user.name, eventType.id, { startTime: dayjs().add(2, "day").toDate() });
//create a new webhook with meeting ended trigger here
await page.goto("/settings/developer/webhooks");
// --- add webhook
await page.click('[data-testid="new_webhook"]');
await page.fill('[name="subscriberUrl"]', "https://www.example.com");
await Promise.all([
page.click("[type=submit]"),
page.waitForURL((url) => url.pathname.endsWith("/settings/developer/webhooks")),
]);
const scheduledTriggers = await prisma.webhookScheduledTriggers.findMany({
where: {
webhook: {
userId: user.id,
},
},
select: {
payload: true,
webhook: {
select: {
userId: true,
id: true,
subscriberUrl: true,
},
},
startAfter: true,
},
});
const existingUserBookings = await prisma.booking.findMany({
where: {
userId: user.id,
startTime: {
gt: new Date(),
},
},
});
const meetingStartedTriggers = scheduledTriggers.filter((trigger) =>
trigger.payload.includes("MEETING_STARTED")
);
const meetingEndedTriggers = scheduledTriggers.filter((trigger) =>
trigger.payload.includes("MEETING_ENDED")
);
expect(meetingStartedTriggers.length).toBe(existingUserBookings.length);
expect(meetingEndedTriggers.length).toBe(existingUserBookings.length);
expect(meetingStartedTriggers.map((trigger) => trigger.startAfter)).toEqual(
expect.arrayContaining(existingUserBookings.map((booking) => booking.startTime))
);
expect(meetingEndedTriggers.map((trigger) => trigger.startAfter)).toEqual(
expect.arrayContaining(existingUserBookings.map((booking) => booking.endTime))
);
page.reload();
// edit webhook and remove trigger meeting ended trigger
await page.click('[data-testid="webhook-edit-button"]');
await page.getByRole("button", { name: "Remove Meeting Ended" }).click();
await Promise.all([
page.click("[type=submit]"),
page.waitForURL((url) => url.pathname.endsWith("/settings/developer/webhooks")),
]);
const scheduledTriggersAfterRemovingTrigger = await prisma.webhookScheduledTriggers.findMany({
where: {
webhook: {
userId: user.id,
},
},
});
const newMeetingStartedTriggers = scheduledTriggersAfterRemovingTrigger.filter((trigger) =>
trigger.payload.includes("MEETING_STARTED")
);
const newMeetingEndedTriggers = scheduledTriggersAfterRemovingTrigger.filter((trigger) =>
trigger.payload.includes("MEETING_ENDED")
);
expect(newMeetingStartedTriggers.length).toBe(existingUserBookings.length);
expect(newMeetingEndedTriggers.length).toBe(0);
// disable webhook
await page.click('[data-testid="webhook-switch"]');
await page.waitForLoadState("networkidle");
const scheduledTriggersAfterDisabling = await prisma.webhookScheduledTriggers.findMany({
where: {
webhook: {
userId: user.id,
},
},
select: {
payload: true,
webhook: {
select: {
userId: true,
},
},
startAfter: true,
},
});
expect(scheduledTriggersAfterDisabling.length).toBe(0);
});
});
test.describe("FORM_SUBMITTED", async () => {
test("on submitting user form, triggers user webhook", async ({ page, users, routingForms }) => {
const webhookReceiver = createHttpServer();
@@ -578,6 +704,8 @@ test.describe("FORM_SUBMITTED", async () => {
],
});
await page.waitForLoadState("networkidle");
await gotoRoutingLink({ page, formId: form.id });
const fieldName = "name";
await page.fill(`[data-testid="form-field-${fieldName}"]`, "John Doe");
@@ -637,6 +765,8 @@ test.describe("FORM_SUBMITTED", async () => {
],
});
await page.waitForLoadState("networkidle");
await gotoRoutingLink({ page, formId: form.id });
const fieldName = "name";
await page.fill(`[data-testid="form-field-${fieldName}"]`, "John Doe");
@@ -2248,9 +2248,9 @@
"unified_billing_description": "Fügen Sie eine einzige Kreditkarte hinzu, um alle Abos Ihres Teams zu bezahlen",
"advanced_managed_events": "Erweiterte verwaltete Ereignistypen",
"advanced_managed_events_description": "Fügen Sie eine einzige Kreditkarte hinzu, um alle Abonnements Ihres Teams zu bezahlen",
"enterprise_description": "Rüsten Sie auf Enterprise auf, um Ihre Organisation zu erstellen",
"enterprise_description": "Upgraden Sie um Ihre Organisation zu erstellen",
"create_your_org": "Erstellen Sie Ihre Organisation",
"create_your_org_description": "Rüsten Sie auf Enterprise auf und erhalten Sie eine Subdomain, einheitliche Abrechnung, Insights, umfangreiches Whitelabeling und mehr",
"create_your_org_description": "Upgraden und erhalten Sie eine Subdomain, einheitliche Abrechnungen, Insights, umfangreiches Whitelabeling und mehr",
"other_payment_app_enabled": "Sie können nur eine Zahlungs-App pro Ereignistyp aktivieren",
"admin_delete_organization_description": "<ul><li>Teams, die Mitglieder dieser Organisation sind, werden ebenfalls zusammen mit ihren Ereignistypen gelöscht</li><li>Benutzer, die Teil der Organisation waren, werden nicht gelöscht und ihre Ereignistypen bleiben ebenfalls intakt.</li><li>Benutzernamen würden geändert werden, um zu ermöglichen, dass sie außerhalb der Organisation existieren können</li></ul>",
"admin_delete_organization_title": "{{organizationName}} löschen?",
+13 -1
View File
@@ -624,6 +624,7 @@
"number_selected": "{{count}} selected",
"owner": "Owner",
"admin": "Admin",
"admin_api": "Admin API",
"administrator_user": "Administrator user",
"lets_create_first_administrator_user": "Let's create the first administrator user.",
"admin_user_created": "Administrator user setup",
@@ -682,6 +683,7 @@
"user_from_team": "{{user}} from {{team}}",
"preview": "Preview",
"link_copied": "Link copied!",
"copied": "Copied!",
"private_link_copied": "Private link copied!",
"link_shared": "Link shared!",
"title": "Title",
@@ -2037,12 +2039,16 @@
"organization_banner_description": "Create an environments where your teams can create shared apps, workflows and event types with round-robin and collective scheduling.",
"organization_banner_title": "Manage organizations with multiple teams",
"set_up_your_organization": "Set up your organization",
"set_up_your_platform_organization": "Set up your platform",
"organizations_description": "Organizations are shared environments where teams can create shared event types, apps, workflows and more.",
"platform_organization_description": "Cal.com Platform lets you integrate scheduling effortlessly into your app using platform apis and atoms.",
"must_enter_organization_name": "Must enter an organization name",
"must_enter_organization_admin_email": "Must enter your organization email address",
"admin_email": "Your organization email address",
"platform_admin_email": "Your admin email address",
"admin_username": "Administrator's username",
"organization_name": "Organization name",
"platform_name": "Platform name",
"organization_url": "Organization URL",
"organization_verify_header": "Verify your organization email",
"organization_verify_email_body": "Please use the code below to verify your email address to continue setting up your organization.",
@@ -2252,7 +2258,8 @@
"advanced_managed_events_description": "Add a single credit card to pay for all your team's subscriptions",
"enterprise_description": "Upgrade to Enterprise to create your Organization",
"create_your_org": "Create your Organization",
"create_your_org_description": "Upgrade to Enterprise and receive a subdomain, unified billing, Insights, extensive whitelabeling and more",
"create_your_org_description": "Upgrade to Organizations and receive a subdomain, unified billing, Insights, extensive whitelabeling and more",
"create_your_enterprise_description": "Upgrade to Enterprise and get access to Active Directory Sync, SCIM Automatic User provisioning, Cal.ai Voice Agents, Admin APIs and more!",
"other_payment_app_enabled": "You can only enable one payment app per event type",
"admin_delete_organization_description": "<ul><li>Teams that are member of this organization will also be deleted along with their event-types</li><li>Users that were part of the organization will not be deleted and their event-types will also remain intact.</li><li>Usernames would be changed to allow them to exist outside the organization</li></ul>",
"admin_delete_organization_title": "Delete {{organizationName}}?",
@@ -2328,6 +2335,8 @@
"field_identifiers_as_variables": "Use field identifiers as variables for your custom event redirect",
"field_identifiers_as_variables_with_example": "Use field identifiers as variables for your custom event redirect (e.g. {{variable}})",
"account_already_linked": "Account is already linked",
"account_unlinked_success": "Account unlinked successfully",
"account_unlinked_error": "There was an error unlinking the account",
"travel_schedule": "Travel Schedule",
"travel_schedule_description": "Plan your trip ahead to keep your existing schedule in a different timezone and prevent being booked at midnight.",
"schedule_timezone_change": "Schedule timezone change",
@@ -2432,5 +2441,8 @@
"csat_score": "CSAT Score",
"lockedSMS": "Locked SMS",
"signing_up_terms": "By proceeding, you agree to our <1>Terms</1> and <2>Privacy Policy</2>.",
"unable_to_subscribe_to_the_platform": "An error occurred while trying to subscribe to the platform plan, please try again later",
"updating_oauth_client_error": "An error occurred while updating the OAuth client, please try again later",
"creating_oauth_client_error": "An error occurred while creating the OAuth client, please try again later",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -2250,7 +2250,7 @@
"advanced_managed_events_description": "Ajoutez une seule carte de crédit pour payer tous les abonnements de votre équipe",
"enterprise_description": "Passez en Entreprise pour créer votre organisation",
"create_your_org": "Créer votre organisation",
"create_your_org_description": "Passez en Entreprise et recevez un sous-domaine, une facturation unifiée, des statistiques, un marquage blanc extensif et plus encore",
"create_your_org_description": "Passez en Organizations et recevez un sous-domaine, une facturation unifiée, des statistiques, un marquage blanc extensif et plus encore",
"other_payment_app_enabled": "Vous ne pouvez activer qu'une seule application de paiement par type d'événement",
"admin_delete_organization_description": "<ul><li>Les équipes qui sont membres de cette organisation seront également supprimées, ainsi que leurs types d'événements</li><li>Les utilisateurs qui faisaient partie de l'organisation ne seront pas supprimés et leurs types d'événements resteront intacts.</li><li>Les noms d'utilisateur seront modifiés pour leur permettre d'exister en dehors de l'organisation</li></ul>",
"admin_delete_organization_title": "Supprimer {{organizationName}} ?",
@@ -2250,7 +2250,7 @@
"advanced_managed_events_description": "Indica una singola carta di credito per pagare tutti gli abbonamenti del tuo team",
"enterprise_description": "Passa a Enterprise per creare la tua organizzazione",
"create_your_org": "Crea la tua organizzazione",
"create_your_org_description": "Passa a Enterprise per avere sottodominio, fatturazione unificata, approfondimenti, personalizzazione completa e altro ancora",
"create_your_org_description": "Passa a Organizations per avere sottodominio, fatturazione unificata, approfondimenti, personalizzazione completa e altro ancora",
"other_payment_app_enabled": "Puoi attivare solo un'app di pagamento per ciascun tipo di evento",
"admin_delete_organization_description": "<ul><li>Verranno eliminati anche i team che sono membri di questa organizzazione, insieme ai rispettivi tipi di evento</li><li>Gli utenti che facevano parte dell'organizzazione non verranno eliminati e anche i loro tipi di evento rimarranno inalterati.</li><li>I nomi utente saranno modificati in modo che possano esistere al di fuori dell'organizzazione</li></ul>",
"admin_delete_organization_title": "Eliminare {{organizationName}}?",
@@ -2209,7 +2209,7 @@
"advanced_managed_events_description": "Adicione um único cartão de crédito para pagar todas as assinaturas da sua equipe",
"enterprise_description": "Atualize para Enterprise para criar sua organização",
"create_your_org": "Crie sua organização",
"create_your_org_description": "Atualize para Enterprise e receba um subdomínio, faturamento unificado, insights, whitelabeling abrangente e muito mais",
"create_your_org_description": "Atualize para Organizations e receba um subdomínio, faturamento unificado, insights, whitelabeling abrangente e muito mais",
"other_payment_app_enabled": "Você só pode ativar um aplicativo de pagamento por tipo de evento",
"admin_delete_organization_description": "<ul><li>As equipes que são membros desta organização também serão excluídas junto com seus tipos de eventos</li><li> Os usuários que faziam parte da organização não serão excluídos e seus tipos de eventos também permanecerão intactos.</li><li> Os nomes de usuário seriam alterados para que existam fora da organização</li></ul>",
"admin_delete_organization_title": "Excluir {{organizationName}} ?",
@@ -2250,7 +2250,7 @@
"advanced_managed_events_description": "Adicione um único cartão de crédito para pagar todas as subscrições da sua equipa",
"enterprise_description": "Atualize para Enterprise para criar a sua Organização",
"create_your_org": "Crie a sua Organização",
"create_your_org_description": "Atualize para Enterprise e receba um subdomínio, faturação unificada, Insights, personalização abrangente da imagem corporativa e muito mais",
"create_your_org_description": "Atualize para Organizations e receba um subdomínio, faturação unificada, Insights, personalização abrangente da imagem corporativa e muito mais",
"other_payment_app_enabled": "Só pode ativar uma aplicação de pagamento por tipo de evento",
"admin_delete_organization_description": "<ul><li>As equipas que são membros desta organização também serão eliminadas, em conjunto com os respetivos tipos de evento</li><li>Utilizadores que faziam parte da organização não serão eliminados e os seus tipos de evento também permanecerão intactos.</li><li>Os nomes de utilizador serão alterados para permitir que possam existir fora da organização</li></ul>",
"admin_delete_organization_title": "Eliminar {{organizationName}}?",
+9 -13
View File
@@ -288,20 +288,16 @@ export function expectWebhookToHaveBeenCalledWith(
expect(parsedBody.triggerEvent).toBe(data.triggerEvent);
if (parsedBody.payload.metadata?.videoCallUrl) {
parsedBody.payload.metadata.videoCallUrl = parsedBody.payload.metadata.videoCallUrl
? parsedBody.payload.metadata.videoCallUrl
: parsedBody.payload.metadata.videoCallUrl;
}
if (data.payload) {
if (data.payload.metadata !== undefined) {
expect(parsedBody.payload.metadata).toEqual(expect.objectContaining(data.payload.metadata));
if (parsedBody.payload) {
if (data.payload) {
if (data.payload.metadata !== undefined) {
expect(parsedBody.payload.metadata).toEqual(expect.objectContaining(data.payload.metadata));
}
if (data.payload.responses !== undefined)
expect(parsedBody.payload.responses).toEqual(expect.objectContaining(data.payload.responses));
const { responses: _1, metadata: _2, ...remainingPayload } = data.payload;
expect(parsedBody.payload).toEqual(expect.objectContaining(remainingPayload));
}
if (data.payload.responses !== undefined)
expect(parsedBody.payload.responses).toEqual(expect.objectContaining(data.payload.responses));
const { responses: _1, metadata: _2, ...remainingPayload } = data.payload;
expect(parsedBody.payload).toEqual(expect.objectContaining(remainingPayload));
}
}
@@ -30,7 +30,7 @@ const dailyReturnTypeSchema = z.object({
enable_chat: z.boolean(),
enable_knocking: z.boolean(),
enable_prejoin_ui: z.boolean(),
enable_transcription_storage: z.boolean(),
enable_transcription_storage: z.boolean().default(false),
}),
});
@@ -169,17 +169,6 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => {
},
});
// Check if organizer has subscribed to Cal.ai
const isCalAiSubscribed = await prisma.credential.findMany({
where: {
userId: event.organizer.id,
type: "cal-ai_automation",
invalid: false,
paymentStatus: "active",
},
});
return {
privacy: "public",
properties: {
@@ -189,10 +178,12 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => {
enable_chat: true,
exp: exp,
enable_recording: scalePlan === "true" && !!hasTeamPlan === true ? "cloud" : undefined,
enable_transcription_storage: !!isCalAiSubscribed,
permissions: {
canAdmin: ["transcription"],
},
enable_transcription_storage: !!hasTeamPlan,
...(!!hasTeamPlan && {
permissions: {
canAdmin: ["transcription"],
},
}),
},
};
};
+10 -2
View File
@@ -14,6 +14,8 @@ import { stringToDayjs } from "@calcom/prisma/zod-utils";
import type { EventBusyDetails, IntervalLimit } from "@calcom/types/Calendar";
import type { CredentialPayload } from "@calcom/types/Credential";
import { getDefinedBufferTimes } from "../features/eventtypes/lib/getDefinedBufferTimes";
export async function getBusyTimes(params: {
credentials: CredentialPayload[];
userId: number;
@@ -85,10 +87,16 @@ export async function getBusyTimes(params: {
const endTimeDate =
rescheduleUid && duration ? dayjs(endTime).add(duration, "minute").toDate() : new Date(endTime);
// to also get bookings that are outside of start and end time, but the buffer falls within the start and end time
const definedBufferTimes = getDefinedBufferTimes();
const maxBuffer = definedBufferTimes[definedBufferTimes.length - 1];
const startTimeAdjustedWithMaxBuffer = dayjs(startTimeDate).subtract(maxBuffer, "minute").toDate();
const endTimeAdjustedWithMaxBuffer = dayjs(endTimeDate).add(maxBuffer, "minute").toDate();
// startTime is less than endTimeDate and endTime grater than startTimeDate
const sharedQuery = {
startTime: { lte: endTimeDate },
endTime: { gte: startTimeDate },
startTime: { lte: endTimeAdjustedWithMaxBuffer },
endTime: { gte: startTimeAdjustedWithMaxBuffer },
status: {
in: [BookingStatus.ACCEPTED],
},
+6
View File
@@ -1,5 +1,11 @@
# @calcom/embed-core
## 1.5.0
### Minor Changes
- Added namespacing support throughout
## 1.4.0
### Minor Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/embed-core",
"version": "1.4.0",
"version": "1.5.0",
"description": "This is the vanilla JS core script that embeds Cal Link",
"main": "./dist/embed/embed.js",
"types": "./dist/index.d.ts",
+12
View File
@@ -1,5 +1,17 @@
# @calcom/embed-react
## 1.5.0
### Minor Changes
- Added namespacing support throughout
### Patch Changes
- Updated dependencies
- @calcom/embed-core@1.5.0
- @calcom/embed-snippet@1.3.0
## 1.4.0
### Minor Changes
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@calcom/embed-react",
"sideEffects": false,
"version": "1.4.0",
"version": "1.5.0",
"description": "Embed Cal Link as a React Component",
"license": "SEE LICENSE IN LICENSE",
"repository": {
@@ -1,5 +1,16 @@
# @calcom/embed-snippet
## 1.3.0
### Minor Changes
- Added namespacing support throughout
### Patch Changes
- Updated dependencies
- @calcom/embed-core@1.5.0
## 1.2.0
### Minor Changes
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@calcom/embed-snippet",
"sideEffects": false,
"version": "1.2.0",
"version": "1.3.0",
"main": "./dist/snippet.umd.js",
"module": "./dist/snippet.es.js",
"description": "Vanilla JS embed snippet that is responsible to fetch @calcom/embed-core and thus show Cal Link as an embed on a page.",
@@ -342,7 +342,8 @@ if (isSAMLLoginEnabled) {
return null;
}
const { id, firstName, lastName, email } = userInfo;
const { id, firstName, lastName } = userInfo;
const email = userInfo.email.toLowerCase();
let user = !email
? undefined
: await UserRepository.findByEmailAndIncludeProfilesAndPassword({ email });
@@ -844,7 +845,7 @@ export const AUTH_OPTIONS: AuthOptions = {
where: { email: existingUserWithEmail.email },
// also update email to the IdP email
data: {
email: user.email,
email: user.email.toLowerCase(),
identityProvider: idP,
identityProviderId: account.providerAccountId,
},
@@ -857,6 +858,19 @@ export const AUTH_OPTIONS: AuthOptions = {
}
} else if (existingUserWithEmail.identityProvider === IdentityProvider.CAL) {
return "/auth/error?error=use-password-login";
} else if (
existingUserWithEmail.identityProvider === IdentityProvider.GOOGLE &&
idP === IdentityProvider.SAML
) {
await prisma.user.update({
where: { email: existingUserWithEmail.email },
// also update email to the IdP email
data: {
email: user.email.toLowerCase(),
identityProvider: idP,
identityProviderId: account.providerAccountId,
},
});
}
return "/auth/error?error=use-identity-login";
@@ -34,7 +34,10 @@ export function useInitialFormValues({
extraOptions,
prefillFormParams,
}: UseInitialFormValuesProps) {
const [initialValues, setDefaultValues] = useState<Record<string, unknown>>({});
const [initialValues, setDefaultValues] = useState<{
responses?: Partial<z.infer<ReturnType<typeof getBookingResponsesSchema>>>;
bookingId?: number;
}>({});
const bookingData = useBookerStore((state) => state.bookingData);
const formValues = useBookerStore((state) => state.formValues);
useEffect(() => {
@@ -104,6 +107,7 @@ export function useInitialFormValues({
const defaults = {
responses: {} as Partial<z.infer<ReturnType<typeof getBookingResponsesSchema>>>,
bookingId: bookingData?.id,
};
const responses = eventType.bookingFields.reduce((responses, field) => {
@@ -126,6 +130,7 @@ export function useInitialFormValues({
formValues,
isRescheduling,
bookingData,
bookingData?.id,
rescheduleUid,
email,
name,
@@ -134,7 +139,8 @@ export function useInitialFormValues({
]);
// When initialValues is available(after doing async schema parsing) or session is available(so that we can prefill logged-in user email and name), we need to reset the form with the initialValues
const key = `${Object.keys(initialValues).length}_${hasSession ? 1 : 0}`;
// We also need the key to change if the bookingId changes, so that the form is reset and rerendered with the new initialValues
const key = `${Object.keys(initialValues).length}_${hasSession ? 1 : 0}_${initialValues?.bookingId ?? 0}`;
return { initialValues, key };
}
@@ -65,6 +65,8 @@ export const EventDuration = ({ event }: { event: PublicEvent }) => {
.filter((dur) => state !== "booking" || dur === selectedDuration)
.map((duration) => (
<Badge
data-testId={`multiple-choice-${duration}mins`}
data-active={selectedDuration === duration ? "true" : "false"}
variant="gray"
className={classNames(selectedDuration === duration && "bg-brand-default text-brand")}
size="md"
@@ -1,4 +1,5 @@
import { useIsEmbed } from "@calcom/embed-core/embed-iframe";
import { useBookerStore } from "@calcom/features/bookings/Booker/store";
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import { getBookerBaseUrlSync } from "@calcom/lib/getBookerUrl/client";
import { SchedulingType } from "@calcom/prisma/enums";
@@ -18,6 +19,8 @@ export interface EventMembersProps {
}
export const EventMembers = ({ schedulingType, users, profile, entity }: EventMembersProps) => {
const username = useBookerStore((state) => state.username);
const isDynamic = !!(username && username.indexOf("+") > -1);
const isEmbed = useIsEmbed();
const showMembers = !!schedulingType && schedulingType !== SchedulingType.ROUND_ROBIN;
const shownUsers = showMembers ? users : [];
@@ -34,13 +37,17 @@ export const EventMembers = ({ schedulingType, users, profile, entity }: EventMe
size="sm"
className="border-muted"
items={[
{
// We don't want booker to be able to see the list of other users or teams inside the embed
href: isEmbed ? null : getBookerBaseUrlSync(entity.orgSlug),
image: profile.image || "",
alt: profile.name || "",
title: profile.name || "",
},
...(isDynamic
? []
: [
{
// We don't want booker to be able to see the list of other users or teams inside the embed
href: isEmbed ? null : getBookerBaseUrlSync(entity.orgSlug),
image: profile.image || "",
alt: profile.name || "",
title: profile.name || "",
},
]),
...shownUsers.map((user) => ({
href: `${getBookerBaseUrlSync(user.profile?.organization?.slug ?? null)}/${
user.profile?.username
@@ -12,12 +12,13 @@ import { sendCancelledReminders } from "@calcom/features/ee/workflows/lib/remind
import { deleteScheduledSMSReminder } from "@calcom/features/ee/workflows/lib/reminders/smsReminderManager";
import { deleteScheduledWhatsappReminder } from "@calcom/features/ee/workflows/lib/reminders/whatsappReminderManager";
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
import { cancelScheduledJobs } from "@calcom/features/webhooks/lib/scheduleTrigger";
import { deleteWebhookScheduledTriggers } from "@calcom/features/webhooks/lib/scheduleTrigger";
import sendPayload from "@calcom/features/webhooks/lib/sendOrSchedulePayload";
import type { EventTypeInfo } from "@calcom/features/webhooks/lib/sendPayload";
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { getTranslation } from "@calcom/lib/server/i18n";
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
@@ -30,6 +31,8 @@ import type { CalendarEvent } from "@calcom/types/Calendar";
import { getAllCredentials } from "./getAllCredentialsForUsersOnEvent/getAllCredentials";
import cancelAttendeeSeat from "./handleSeats/cancel/cancelAttendeeSeat";
const log = logger.getSubLogger({ prefix: ["handleCancelBooking"] });
async function getBookingToDelete(id: number | undefined, uid: string | undefined) {
return await prisma.booking.findUnique({
where: {
@@ -113,7 +116,6 @@ async function getBookingToDelete(id: number | undefined, uid: string | undefine
destinationCalendar: true,
smsReminderNumber: true,
workflowReminders: true,
scheduledJobs: true,
seatsReferences: true,
responses: true,
iCalUID: true,
@@ -201,7 +203,7 @@ async function handler(req: CustomRequest) {
},
select: {
id: true,
username:true,
username: true,
name: true,
email: true,
timeZone: true,
@@ -276,8 +278,8 @@ async function handler(req: CustomRequest) {
destinationCalendar: bookingToDelete?.destinationCalendar
? [bookingToDelete?.destinationCalendar]
: bookingToDelete?.user.destinationCalendar
? [bookingToDelete?.user.destinationCalendar]
: [],
? [bookingToDelete?.user.destinationCalendar]
: [],
cancellationReason: cancellationReason,
...(teamMembers && {
team: { name: bookingToDelete?.eventType?.team?.name || "Nameless", members: teamMembers, id: teamId! },
@@ -324,9 +326,9 @@ async function handler(req: CustomRequest) {
}
let updatedBookings: {
id: number;
uid: string;
workflowReminders: WorkflowReminder[];
scheduledJobs: string[];
references: {
type: string;
credentialId: number | null;
@@ -362,6 +364,7 @@ async function handler(req: CustomRequest) {
},
},
select: {
id: true,
startTime: true,
endTime: true,
references: {
@@ -374,7 +377,6 @@ async function handler(req: CustomRequest) {
},
workflowReminders: true,
uid: true,
scheduledJobs: true,
},
});
updatedBookings = updatedBookings.concat(allUpdatedBookings);
@@ -398,6 +400,7 @@ async function handler(req: CustomRequest) {
iCalSequence: evt.iCalSequence || 100,
},
select: {
id: true,
startTime: true,
endTime: true,
references: {
@@ -410,7 +413,6 @@ async function handler(req: CustomRequest) {
},
workflowReminders: true,
uid: true,
scheduledJobs: true,
},
});
updatedBookings.push(updatedBooking);
@@ -441,24 +443,27 @@ async function handler(req: CustomRequest) {
},
});
// delete scheduled jobs of cancelled bookings
// FIXME: async calls into ether
updatedBookings.forEach((booking) => {
cancelScheduledJobs(booking);
});
const webhookTriggerPromises = [];
const workflowReminderPromises = [];
//Workflows - cancel all reminders for cancelled bookings
// FIXME: async calls into ether
updatedBookings.forEach((booking) => {
booking.workflowReminders.forEach((reminder) => {
for (const booking of updatedBookings) {
// delete scheduled webhook triggers of cancelled bookings
webhookTriggerPromises.push(deleteWebhookScheduledTriggers({ booking }));
//Workflows - cancel all reminders for cancelled bookings
for (const reminder of booking.workflowReminders) {
if (reminder.method === WorkflowMethods.EMAIL) {
deleteScheduledEmailReminder(reminder.id, reminder.referenceId);
workflowReminderPromises.push(deleteScheduledEmailReminder(reminder.id, reminder.referenceId));
} else if (reminder.method === WorkflowMethods.SMS) {
deleteScheduledSMSReminder(reminder.id, reminder.referenceId);
workflowReminderPromises.push(deleteScheduledSMSReminder(reminder.id, reminder.referenceId));
} else if (reminder.method === WorkflowMethods.WHATSAPP) {
deleteScheduledWhatsappReminder(reminder.id, reminder.referenceId);
workflowReminderPromises.push(deleteScheduledWhatsappReminder(reminder.id, reminder.referenceId));
}
});
}
}
await Promise.all([...webhookTriggerPromises, ...workflowReminderPromises]).catch((error) => {
log.error("An error occurred when deleting workflow reminders and webhook triggers", error);
});
const prismaPromises: Promise<unknown>[] = [bookingReferenceDeletes];
@@ -113,7 +113,6 @@ export async function handleConfirmation(args: {
}
}
let updatedBookings: {
scheduledJobs: string[];
id: number;
description: string | null;
location: string | null;
@@ -201,7 +200,6 @@ export async function handleConfirmation(args: {
smsReminderNumber: true,
customInputs: true,
id: true,
scheduledJobs: true,
},
})
);
@@ -256,7 +254,6 @@ export async function handleConfirmation(args: {
location: true,
customInputs: true,
id: true,
scheduledJobs: true,
},
});
updatedBookings.push(updatedBooking);
@@ -324,17 +321,35 @@ export async function handleConfirmation(args: {
teamId: booking.eventType?.teamId,
});
const scheduleTriggerPromises: Promise<unknown>[] = [];
subscribersMeetingStarted.forEach((subscriber) => {
updatedBookings.forEach((booking) => {
scheduleTrigger(booking, subscriber.subscriberUrl, subscriber, WebhookTriggerEvents.MEETING_STARTED);
scheduleTriggerPromises.push(
scheduleTrigger({
booking,
subscriberUrl: subscriber.subscriberUrl,
subscriber,
triggerEvent: WebhookTriggerEvents.MEETING_STARTED,
})
);
});
});
subscribersMeetingEnded.forEach((subscriber) => {
updatedBookings.forEach((booking) => {
scheduleTrigger(booking, subscriber.subscriberUrl, subscriber, WebhookTriggerEvents.MEETING_ENDED);
scheduleTriggerPromises.push(
scheduleTrigger({
booking,
subscriberUrl: subscriber.subscriberUrl,
subscriber,
triggerEvent: WebhookTriggerEvents.MEETING_ENDED,
})
);
});
});
await Promise.all(scheduleTriggerPromises);
const eventTypeInfo: EventTypeInfo = {
eventTitle: booking.eventType?.title,
eventDescription: booking.eventType?.description,
@@ -53,7 +53,10 @@ import {
import { getFullName } from "@calcom/features/form-builder/utils";
import type { GetSubscriberOptions } from "@calcom/features/webhooks/lib/getWebhooks";
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
import { cancelScheduledJobs, scheduleTrigger } from "@calcom/features/webhooks/lib/scheduleTrigger";
import {
deleteWebhookScheduledTriggers,
scheduleTrigger,
} from "@calcom/features/webhooks/lib/scheduleTrigger";
import {
isPrismaObjOrUndefined,
parseBookingLimit,
@@ -2269,38 +2272,49 @@ async function handler(
// We are here so, booking doesn't require payment and booking is also created in DB already, through createBooking call
if (isConfirmedByDefault) {
try {
const subscribersMeetingEnded = await getWebhooks(subscriberOptionsMeetingEnded);
const subscribersMeetingStarted = await getWebhooks(subscriberOptionsMeetingStarted);
const subscribersMeetingEnded = await getWebhooks(subscriberOptionsMeetingEnded);
const subscribersMeetingStarted = await getWebhooks(subscriberOptionsMeetingStarted);
subscribersMeetingEnded.forEach((subscriber) => {
if (rescheduleUid && originalRescheduledBooking) {
cancelScheduledJobs(originalRescheduledBooking, undefined, true);
}
if (booking && booking.status === BookingStatus.ACCEPTED) {
scheduleTrigger(booking, subscriber.subscriberUrl, subscriber, WebhookTriggerEvents.MEETING_ENDED);
}
let deleteWebhookScheduledTriggerPromise: Promise<unknown> = Promise.resolve();
const scheduleTriggerPromises = [];
if (rescheduleUid && originalRescheduledBooking) {
//delete all scheduled triggers for meeting ended and meeting started of booking
deleteWebhookScheduledTriggerPromise = deleteWebhookScheduledTriggers({
booking: originalRescheduledBooking,
});
}
subscribersMeetingStarted.forEach((subscriber) => {
if (rescheduleUid && originalRescheduledBooking) {
cancelScheduledJobs(originalRescheduledBooking, undefined, true);
}
if (booking && booking.status === BookingStatus.ACCEPTED) {
scheduleTrigger(
if (booking && booking.status === BookingStatus.ACCEPTED) {
for (const subscriber of subscribersMeetingEnded) {
scheduleTriggerPromises.push(
scheduleTrigger({
booking,
subscriber.subscriberUrl,
subscriberUrl: subscriber.subscriberUrl,
subscriber,
WebhookTriggerEvents.MEETING_STARTED
);
}
});
} catch (error) {
triggerEvent: WebhookTriggerEvents.MEETING_ENDED,
})
);
}
for (const subscriber of subscribersMeetingStarted) {
scheduleTriggerPromises.push(
scheduleTrigger({
booking,
subscriberUrl: subscriber.subscriberUrl,
subscriber,
triggerEvent: WebhookTriggerEvents.MEETING_STARTED,
})
);
}
}
await Promise.all([deleteWebhookScheduledTriggerPromise, ...scheduleTriggerPromises]).catch((error) => {
loggerWithEventDetails.error(
"Error while running scheduledJobs for booking",
"Error while scheduling or canceling webhook triggers",
JSON.stringify({ error })
);
}
});
// Send Webhook call if hooked to BOOKING_CREATED & BOOKING_RESCHEDULED
await handleWebhookTrigger({ subscriberOptions, eventTrigger, webhookData });
@@ -462,4 +462,141 @@ describe("handleNewBooking", () => {
},
timeout
);
describe("Buffers", () => {
test(`should throw error when booking is within a before event buffer of an existing booking
`, async ({}) => {
const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default;
const booker = getBooker({
email: "booker@example.com",
name: "Booker",
});
const organizer = getOrganizer({
name: "Organizer",
email: "organizer@example.com",
id: 101,
schedules: [TestData.schedules.IstWorkHours],
});
const { dateString: nextDayDateString } = getDate({ dateIncrement: 1 });
await createBookingScenario(
getScenarioData({
eventTypes: [
{
id: 1,
slotInterval: 15,
length: 15,
beforeEventBuffer: 60,
users: [
{
id: 101,
},
],
},
],
bookings: [
{
eventTypeId: 1,
userId: 101,
status: BookingStatus.ACCEPTED,
startTime: `${nextDayDateString}T05:00:00.000Z`,
endTime: `${nextDayDateString}T05:15:00.000Z`,
},
],
organizer,
})
);
const mockBookingData = getMockRequestDataForBooking({
data: {
start: `${nextDayDateString}T04:30:00.000Z`,
end: `${nextDayDateString}T04:45:00.000Z`,
eventTypeId: 1,
responses: {
email: booker.email,
name: booker.name,
location: { optionValue: "", value: "New York" },
},
},
});
const { req } = createMockNextJsRequest({
method: "POST",
body: mockBookingData,
});
await expect(async () => await handleNewBooking(req)).rejects.toThrowError(
"no_available_users_found_error"
);
});
});
test(`should throw error when booking is within a after event buffer of an existing booking
`, async ({}) => {
const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default;
const booker = getBooker({
email: "booker@example.com",
name: "Booker",
});
const organizer = getOrganizer({
name: "Organizer",
email: "organizer@example.com",
id: 101,
schedules: [TestData.schedules.IstWorkHours],
});
const { dateString: nextDayDateString } = getDate({ dateIncrement: 1 });
await createBookingScenario(
getScenarioData({
eventTypes: [
{
id: 1,
slotInterval: 15,
length: 15,
afterEventBuffer: 60,
users: [
{
id: 101,
},
],
},
],
bookings: [
{
eventTypeId: 1,
userId: 101,
status: BookingStatus.ACCEPTED,
startTime: `${nextDayDateString}T05:00:00.000Z`,
endTime: `${nextDayDateString}T05:15:00.000Z`,
},
],
organizer,
})
);
const mockBookingData = getMockRequestDataForBooking({
data: {
start: `${nextDayDateString}T05:30:00.000Z`,
end: `${nextDayDateString}T05:45:00.000Z`,
eventTypeId: 1,
responses: {
email: booker.email,
name: booker.name,
location: { optionValue: "", value: "New York" },
},
},
});
const { req } = createMockNextJsRequest({
method: "POST",
body: mockBookingData,
});
await expect(async () => await handleNewBooking(req)).rejects.toThrowError(
"no_available_users_found_error"
);
});
});
@@ -60,7 +60,6 @@ const handleSeats = async (newSeatedBookingObject: NewSeatedBookingObject) => {
status: true,
smsReminderNumber: true,
endTime: true,
scheduledJobs: true,
},
});
-1
View File
@@ -53,7 +53,6 @@ export type SeatedBooking = Prisma.BookingGetPayload<{
status: true;
smsReminderNumber: true;
endTime: true;
scheduledJobs: true;
};
}>;
@@ -41,7 +41,9 @@ const LicenseRequired = ({ children, as = "", ...rest }: LicenseRequiredProps) =
title={
<>
{t("enterprise_license_locally")} {t("enterprise_license_sales")}{" "}
<a className="underline" href="https://cal.com/sales">{t("contact_sales")}</a>
<a className="underline" href="https://cal.com/sales">
{t("contact_sales")}
</a>
</>
}
/>
@@ -32,7 +32,12 @@ export const CreateANewOrganizationForm = () => {
return <CreateANewOrganizationFormChild session={session} />;
};
const CreateANewOrganizationFormChild = ({ session }: { session: Ensure<SessionContextValue, "data"> }) => {
const CreateANewOrganizationFormChild = ({
session,
}: {
session: Ensure<SessionContextValue, "data">;
isPlatformOrg?: boolean;
}) => {
const { t } = useLocale();
const router = useRouter();
const telemetry = useTelemetry();
@@ -282,13 +287,13 @@ const CreateANewOrganizationFormChild = ({ session }: { session: Ensure<SessionC
);
};
function deriveSlugFromEmail(email: string) {
export function deriveSlugFromEmail(email: string) {
const domain = extractDomainFromEmail(email);
return domain;
}
function deriveOrgNameFromEmail(email: string) {
export function deriveOrgNameFromEmail(email: string) {
const domain = extractDomainFromEmail(email);
return domain.charAt(0).toUpperCase() + domain.slice(1);
@@ -0,0 +1,64 @@
"use client";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
import { UpgradeTip } from "@calcom/features/tips";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button, Icon, Meta, ButtonGroup } from "@calcom/ui";
const AdminAPIViewWrapper = () => {
const { t } = useLocale();
const features = [
{
icon: <Icon name="terminal" className="h-5 w-5 text-pink-500" />,
title: t("admin_api"),
description: t("leverage_our_api"),
},
{
icon: <Icon name="folder" className="h-5 w-5 text-red-500" />,
title: `SCIM & ${t("directory_sync")}`,
description: t("directory_sync_description"),
},
{
icon: <Icon name="sparkles" className="h-5 w-5 text-blue-500" />,
title: "Cal.ai",
description: t("use_cal_ai_to_make_call_description"),
},
];
return (
<LicenseRequired>
<Meta
title={`${t("admin")} ${t("api_reference")}`}
description={t("leverage_our_api")}
borderInShellHeader={false}
/>
<div className="mt-8">
<UpgradeTip
plan="enterprise"
title={t("enterprise_license")}
description={t("create_your_enterprise_description")}
features={features}
background="/tips/enterprise"
buttons={
<div className="space-y-2 rtl:space-x-reverse sm:space-x-2">
<ButtonGroup>
<Button color="primary" href="https://i.cal.com/sales/enterprise" target="_blank">
{t("contact_sales")}
</Button>
<Button color="minimal" href="https://cal.com/enterprise" target="_blank">
{t("learn_more")}
</Button>
</ButtonGroup>
</div>
}>
<>Create Org</>
</UpgradeTip>
</div>
</LicenseRequired>
);
};
AdminAPIViewWrapper.getLayout = getLayout;
export default AdminAPIViewWrapper;
@@ -0,0 +1,175 @@
import type { SessionContextValue } from "next-auth/react";
import { useSession, signIn } from "next-auth/react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { deriveOrgNameFromEmail } from "@calcom/ee/organizations/components/CreateANewOrganizationForm";
import { deriveSlugFromEmail } from "@calcom/ee/organizations/components/CreateANewOrganizationForm";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import slugify from "@calcom/lib/slugify";
import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import { UserPermissionRole } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
import type { Ensure } from "@calcom/types/utils";
import { Alert, Form, TextField, Button } from "@calcom/ui";
export const CreateANewPlatformForm = () => {
const session = useSession();
if (!session.data) {
return null;
}
return <CreateANewPlatformFormChild session={session} />;
};
const CreateANewPlatformFormChild = ({ session }: { session: Ensure<SessionContextValue, "data"> }) => {
const { t } = useLocale();
const router = useRouter();
const telemetry = useTelemetry();
const [serverErrorMessage, setServerErrorMessage] = useState<string | null>(null);
const isAdmin = session.data.user.role === UserPermissionRole.ADMIN;
const defaultOrgOwnerEmail = session.data.user.email ?? "";
const newOrganizationFormMethods = useForm<{
name: string;
slug: string;
orgOwnerEmail: string;
isPlatform: boolean;
}>({
defaultValues: {
slug: !isAdmin ? deriveSlugFromEmail(defaultOrgOwnerEmail) : undefined,
orgOwnerEmail: !isAdmin ? defaultOrgOwnerEmail : undefined,
name: !isAdmin ? deriveOrgNameFromEmail(defaultOrgOwnerEmail) : undefined,
isPlatform: true,
},
});
const createOrganizationMutation = trpc.viewer.organizations.create.useMutation({
onSuccess: async (data) => {
telemetry.event(telemetryEventTypes.org_created);
// This is necessary so that server token has the updated upId
await session.update({
upId: data.upId,
});
if (isAdmin && data.userId !== session.data?.user.id) {
// Impersonate the user chosen as the organization owner(if the admin user isn't the owner himself), so that admin can now configure the organisation on his behalf.
// He won't need to have access to the org directly in this way.
signIn("impersonation-auth", {
username: data.email,
callbackUrl: `/settings/platform`,
});
}
router.push("/settings/platform");
},
onError: (err) => {
if (err.message === "organization_url_taken") {
newOrganizationFormMethods.setError("slug", { type: "custom", message: t("url_taken") });
} else if (err.message === "domain_taken_team" || err.message === "domain_taken_project") {
newOrganizationFormMethods.setError("slug", {
type: "custom",
message: t("problem_registering_domain"),
});
} else {
setServerErrorMessage(err.message);
}
},
});
return (
<>
<Form
form={newOrganizationFormMethods}
className="space-y-5"
id="createOrg"
handleSubmit={(v) => {
if (!createOrganizationMutation.isPending) {
setServerErrorMessage(null);
createOrganizationMutation.mutate({
...v,
slug: `${v.name.toLocaleLowerCase()}_platform`,
});
}
}}>
<div>
{serverErrorMessage && (
<div className="mb-4">
<Alert severity="error" message={serverErrorMessage} />
</div>
)}
<Controller
name="orgOwnerEmail"
control={newOrganizationFormMethods.control}
rules={{
required: t("must_enter_organization_admin_email"),
}}
render={({ field: { value } }) => (
<div className="flex">
<TextField
containerClassName="w-full"
placeholder="john@acme.com"
name="orgOwnerEmail"
disabled={!isAdmin}
label={t("platform_admin_email")}
defaultValue={value}
onChange={(e) => {
const email = e?.target.value;
const slug = deriveSlugFromEmail(email);
newOrganizationFormMethods.setValue("orgOwnerEmail", email.trim());
if (newOrganizationFormMethods.getValues("slug") === "") {
newOrganizationFormMethods.setValue("slug", slug);
}
newOrganizationFormMethods.setValue("name", deriveOrgNameFromEmail(email));
}}
autoComplete="off"
/>
</div>
)}
/>
</div>
<div>
<Controller
name="name"
control={newOrganizationFormMethods.control}
defaultValue=""
rules={{
required: t("must_enter_organization_name"),
}}
render={({ field: { value } }) => (
<>
<TextField
className="mt-2"
placeholder="Acme"
name="name"
label={t("platform_name")}
defaultValue={value}
onChange={(e) => {
newOrganizationFormMethods.setValue("name", e?.target.value.trim());
if (newOrganizationFormMethods.formState.touchedFields["slug"] === undefined) {
newOrganizationFormMethods.setValue("slug", slugify(e?.target.value));
}
}}
autoComplete="off"
/>
</>
)}
/>
</div>
<div className="flex space-x-2 rtl:space-x-reverse">
<Button
disabled={
newOrganizationFormMethods.formState.isSubmitting || createOrganizationMutation.isPending
}
color="primary"
EndIcon="arrow-right"
type="submit"
form="createOrg"
className="w-full justify-center">
{t("continue")}
</Button>
</div>
<div />
</Form>
</>
);
};
@@ -0,0 +1 @@
export { CreateANewPlatformForm } from "./CreateANewPlatformForm";
@@ -0,0 +1,136 @@
import prismock from "../../../../../tests/libs/__mocks__/prisma";
import { describe, expect, it, vi, beforeAll, afterAll } from "vitest";
import stripe from "@calcom/app-store/stripepayment/lib/server";
import { purchaseTeamOrOrgSubscription } from "./payments";
beforeAll(() => {
vi.stubEnv("STRIPE_ORG_MONTHLY_PRICE_ID", "STRIPE_ORG_MONTHLY_PRICE_ID");
vi.stubEnv("STRIPE_TEAM_MONTHLY_PRICE_ID", "STRIPE_TEAM_MONTHLY_PRICE_ID");
});
afterAll(() => {
vi.unstubAllEnvs();
});
vi.mock("@calcom/app-store/stripepayment/lib/customer", () => {
return {
getStripeCustomerIdFromUserId: function () {
return "CUSTOMER_ID";
},
};
});
vi.mock("@calcom/app-store/stripepayment/lib/server", () => {
return {
default: {
checkout: {
sessions: {
create: vi.fn(),
retrieve: vi.fn(),
},
},
prices: {
retrieve: vi.fn(),
create: vi.fn(),
},
},
};
});
describe("purchaseTeamOrOrgSubscription", () => {
it("should use `seatsToChargeFor` to create price", async () => {
const user = await prismock.user.create({
data: {
name: "test",
email: "test@email.com",
},
});
const checkoutSessionsCreate = mockStripeCheckoutSessionsCreate({
url: "SESSION_URL",
});
mockStripeCheckoutSessionRetrieve({
currency: "USD",
product: {
id: "PRODUCT_ID",
},
});
mockStripeCheckoutPricesRetrieve({
id: "PRICE_ID",
product: {
id: "PRODUCT_ID",
},
});
mockStripePricesCreate({
id: "PRICE_ID",
});
const team = await prismock.team.create({
data: {
name: "test",
},
});
const seatsToChargeFor = 1000;
expect(
await purchaseTeamOrOrgSubscription({
teamId: team.id,
seatsUsed: 10,
seatsToChargeFor,
userId: user.id,
isOrg: true,
pricePerSeat: 100,
})
).toEqual({ url: "SESSION_URL" });
expect(checkoutSessionsCreate).toHaveBeenCalledWith(
expect.objectContaining({
line_items: [
{
price: "PRICE_ID",
quantity: seatsToChargeFor,
},
],
})
);
});
});
function mockStripePricesCreate(data) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
return vi.mocked(stripe.prices.create).mockImplementation(() => new Promise((resolve) => resolve(data)));
}
function mockStripeCheckoutPricesRetrieve(data) {
return vi.mocked(stripe.prices.retrieve).mockImplementation(
async () =>
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
new Promise((resolve) => {
resolve(data);
})
);
}
function mockStripeCheckoutSessionRetrieve(data) {
return vi.mocked(stripe.checkout.sessions.retrieve).mockImplementation(
async () =>
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
new Promise((resolve) => resolve(data))
);
}
function mockStripeCheckoutSessionsCreate(data) {
return vi.mocked(stripe.checkout.sessions.create).mockImplementation(
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
async () => new Promise((resolve) => resolve(data))
);
}
+15 -4
View File
@@ -81,17 +81,28 @@ export const generateTeamCheckoutSession = async ({
*/
export const purchaseTeamOrOrgSubscription = async (input: {
teamId: number;
seats: number;
/**
* The actual number of seats in the team.
* The seats that we would charge for could be more than this depending on the MINIMUM_NUMBER_OF_ORG_SEATS in case of an organization
* For a team it would be the same as this value
*/
seatsUsed: number;
/**
* If provided, this is the exact number we would charge for.
*/
seatsToChargeFor?: number | null;
userId: number;
isOrg?: boolean;
pricePerSeat: number | null;
}) => {
const { teamId, seats, userId, isOrg, pricePerSeat } = input;
const { teamId, seatsToChargeFor, seatsUsed, userId, isOrg, pricePerSeat } = input;
const { url } = await checkIfTeamPaymentRequired({ teamId });
if (url) return { url };
// For orgs, enforce minimum of 30 seats
const quantity = isOrg ? Math.max(seats, MINIMUM_NUMBER_OF_ORG_SEATS) : seats;
// For orgs, enforce minimum of MINIMUM_NUMBER_OF_ORG_SEATS seats if `seatsToChargeFor` not set
const seats = isOrg ? Math.max(seatsUsed, MINIMUM_NUMBER_OF_ORG_SEATS) : seatsUsed;
const quantity = seatsToChargeFor ? seatsToChargeFor : seats;
const customer = await getStripeCustomerIdFromUserId(userId);
const session = await stripe.checkout.sessions.create({
@@ -28,10 +28,19 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
//delete all scheduled sms reminders where scheduled date is past current date
await prisma.workflowReminder.deleteMany({
where: {
method: WorkflowMethods.SMS,
scheduledDate: {
lte: dayjs().toISOString(),
},
OR: [
{
method: WorkflowMethods.SMS,
scheduledDate: {
lte: dayjs().toISOString(),
},
},
{
retryCount: {
gt: 1,
},
},
],
},
});
@@ -44,8 +53,11 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
lte: dayjs().add(7, "day").toISOString(),
},
},
select,
})) as PartialWorkflowReminder[];
select: {
...select,
retryCount: true,
},
})) as (PartialWorkflowReminder & { retryCount: number })[];
if (!unscheduledReminders.length) {
res.json({ ok: true });
@@ -163,9 +175,26 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
referenceId: scheduledSMS.sid,
},
});
} else {
await prisma.workflowReminder.update({
where: {
id: reminder.id,
},
data: {
retryCount: reminder.retryCount + 1,
},
});
}
}
} catch (error) {
await prisma.workflowReminder.update({
where: {
id: reminder.id,
},
data: {
retryCount: reminder.retryCount + 1,
},
});
console.log(`Error scheduling SMS with error ${error}`);
}
}
+44 -43
View File
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
import type { Dispatch, SetStateAction } from "react";
import { useState } from "react";
import Shell from "@calcom/features/shell/Shell";
import Shell, { ShellMain } from "@calcom/features/shell/Shell";
import { classNames } from "@calcom/lib";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@@ -51,50 +51,51 @@ function WorkflowsPage() {
});
return (
<Shell
withoutMain={false}
heading={t("workflows")}
subtitle={t("workflows_to_automate_notifications")}
title="Workflows"
description="Create workflows to automate notifications and reminders."
hideHeadingOnMobile
CTA={
session.data?.hasValidLicense ? (
<CreateButtonWithTeamsList
subtitle={t("new_workflow_subtitle").toUpperCase()}
createFunction={(teamId?: number) => {
createMutation.mutate({ teamId });
}}
isPending={createMutation.isPending}
disableMobileButton={true}
onlyShowWithNoTeams={true}
/>
) : null
}>
<Shell withoutMain>
<LicenseRequired>
<>
{queryRes.data?.totalCount ? (
<div className="flex">
<TeamsFilter />
<div className="ml-auto">
<CreateButtonWithTeamsList
subtitle={t("new_workflow_subtitle").toUpperCase()}
createFunction={(teamId?: number) => createMutation.mutate({ teamId })}
isPending={createMutation.isPending}
disableMobileButton={true}
onlyShowWithTeams={true}
/>
<ShellMain
heading={t("workflows")}
subtitle={t("workflows_to_automate_notifications")}
title="Workflows"
description="Create workflows to automate notifications and reminders."
hideHeadingOnMobile
CTA={
session.data?.hasValidLicense ? (
<CreateButtonWithTeamsList
subtitle={t("new_workflow_subtitle").toUpperCase()}
createFunction={(teamId?: number) => {
createMutation.mutate({ teamId });
}}
isPending={createMutation.isPending}
disableMobileButton={true}
onlyShowWithNoTeams={true}
/>
) : null
}>
<>
{queryRes.data?.totalCount ? (
<div className="flex">
<TeamsFilter />
<div className="ml-auto">
<CreateButtonWithTeamsList
subtitle={t("new_workflow_subtitle").toUpperCase()}
createFunction={(teamId?: number) => createMutation.mutate({ teamId })}
isPending={createMutation.isPending}
disableMobileButton={true}
onlyShowWithTeams={true}
/>
</div>
</div>
</div>
) : null}
<FilterResults
queryRes={queryRes}
emptyScreen={<EmptyScreen isFilteredView={false} />}
noResultsScreen={<EmptyScreen isFilteredView={true} />}
SkeletonLoader={SkeletonLoader}>
<WorkflowList workflows={queryRes.data?.filtered} />
</FilterResults>
</>
) : null}
<FilterResults
queryRes={queryRes}
emptyScreen={<EmptyScreen isFilteredView={false} />}
noResultsScreen={<EmptyScreen isFilteredView={true} />}
SkeletonLoader={SkeletonLoader}>
<WorkflowList workflows={queryRes.data?.filtered} />
</FilterResults>
</>
</ShellMain>
</LicenseRequired>
</Shell>
);
+122 -114
View File
@@ -7,7 +7,7 @@ import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import Shell from "@calcom/features/shell/Shell";
import Shell, { ShellMain } from "@calcom/features/shell/Shell";
import { classNames } from "@calcom/lib";
import { SENDER_ID } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@@ -204,124 +204,132 @@ function WorkflowPage() {
});
return session.data ? (
<Form
form={form}
handleSubmit={async (values) => {
let activeOnEventTypeIds: number[] = [];
let isEmpty = false;
let isVerified = true;
<Shell withoutMain backPath="/workflows">
<LicenseRequired>
<Form
form={form}
handleSubmit={async (values) => {
let activeOnEventTypeIds: number[] = [];
let isEmpty = false;
let isVerified = true;
values.steps.forEach((step) => {
const strippedHtml = step.reminderBody?.replace(/<[^>]+>/g, "") || "";
values.steps.forEach((step) => {
const strippedHtml = step.reminderBody?.replace(/<[^>]+>/g, "") || "";
const isBodyEmpty = !isSMSOrWhatsappAction(step.action) && strippedHtml.length <= 1;
const isBodyEmpty = !isSMSOrWhatsappAction(step.action) && strippedHtml.length <= 1;
if (isBodyEmpty) {
form.setError(`steps.${step.stepNumber - 1}.reminderBody`, {
type: "custom",
message: t("fill_this_field"),
if (isBodyEmpty) {
form.setError(`steps.${step.stepNumber - 1}.reminderBody`, {
type: "custom",
message: t("fill_this_field"),
});
}
if (step.reminderBody) {
step.reminderBody = translateVariablesToEnglish(step.reminderBody, {
locale: i18n.language,
t,
});
}
if (step.emailSubject) {
step.emailSubject = translateVariablesToEnglish(step.emailSubject, {
locale: i18n.language,
t,
});
}
isEmpty = !isEmpty ? isBodyEmpty : isEmpty;
//check if phone number is verified
if (
(step.action === WorkflowActions.SMS_NUMBER ||
step.action === WorkflowActions.WHATSAPP_NUMBER) &&
!verifiedNumbers?.find((verifiedNumber) => verifiedNumber.phoneNumber === step.sendTo)
) {
isVerified = false;
form.setError(`steps.${step.stepNumber - 1}.sendTo`, {
type: "custom",
message: t("not_verified"),
});
}
});
}
if (step.reminderBody) {
step.reminderBody = translateVariablesToEnglish(step.reminderBody, { locale: i18n.language, t });
}
if (step.emailSubject) {
step.emailSubject = translateVariablesToEnglish(step.emailSubject, { locale: i18n.language, t });
}
isEmpty = !isEmpty ? isBodyEmpty : isEmpty;
//check if phone number is verified
if (
(step.action === WorkflowActions.SMS_NUMBER || step.action === WorkflowActions.WHATSAPP_NUMBER) &&
!verifiedNumbers?.find((verifiedNumber) => verifiedNumber.phoneNumber === step.sendTo)
) {
isVerified = false;
form.setError(`steps.${step.stepNumber - 1}.sendTo`, {
type: "custom",
message: t("not_verified"),
});
}
});
if (!isEmpty && isVerified) {
if (values.activeOn) {
activeOnEventTypeIds = values.activeOn.map((option) => {
return parseInt(option.value, 10);
});
}
updateMutation.mutate({
id: workflowId,
name: values.name,
activeOn: activeOnEventTypeIds,
steps: values.steps,
trigger: values.trigger,
time: values.time || null,
timeUnit: values.timeUnit || null,
});
utils.viewer.workflows.getVerifiedNumbers.invalidate();
}
}}>
<Shell
backPath="/workflows"
title={workflow && workflow.name ? workflow.name : "Untitled"}
CTA={
!readOnly && (
<div>
<Button data-testid="save-workflow" type="submit" loading={updateMutation.isPending}>
{t("save")}
</Button>
</div>
)
}
hideHeadingOnMobile
heading={
session.data?.hasValidLicense &&
isAllDataLoaded && (
<div className="flex">
<div className={classNames(workflow && !workflow.name ? "text-muted" : "")}>
{workflow && workflow.name ? workflow.name : "untitled"}
</div>
{workflow && workflow.team && (
<Badge className="ml-4 mt-1" variant="gray">
{workflow.team.name}
</Badge>
)}
{readOnly && (
<Badge className="ml-4 mt-1" variant="gray">
{t("readonly")}
</Badge>
)}
</div>
)
}>
<LicenseRequired>
{!isError ? (
<>
{isAllDataLoaded && user ? (
<>
<WorkflowDetailsPage
form={form}
workflowId={+workflowId}
user={user}
selectedEventTypes={selectedEventTypes}
setSelectedEventTypes={setSelectedEventTypes}
teamId={workflow ? workflow.teamId || undefined : undefined}
isMixedEventType={isMixedEventType}
readOnly={readOnly}
/>
</>
) : (
<SkeletonLoader />
)}
</>
) : (
<Alert severity="error" title="Something went wrong" message={error.message} />
)}
</LicenseRequired>
</Shell>
</Form>
if (!isEmpty && isVerified) {
if (values.activeOn) {
activeOnEventTypeIds = values.activeOn.map((option) => {
return parseInt(option.value, 10);
});
}
updateMutation.mutate({
id: workflowId,
name: values.name,
activeOn: activeOnEventTypeIds,
steps: values.steps,
trigger: values.trigger,
time: values.time || null,
timeUnit: values.timeUnit || null,
});
utils.viewer.workflows.getVerifiedNumbers.invalidate();
}
}}>
<ShellMain
backPath="/workflows"
title={workflow && workflow.name ? workflow.name : "Untitled"}
CTA={
!readOnly && (
<div>
<Button data-testid="save-workflow" type="submit" loading={updateMutation.isPending}>
{t("save")}
</Button>
</div>
)
}
hideHeadingOnMobile
heading={
isAllDataLoaded && (
<div className="flex">
<div className={classNames(workflow && !workflow.name ? "text-muted" : "")}>
{workflow && workflow.name ? workflow.name : "untitled"}
</div>
{workflow && workflow.team && (
<Badge className="ml-4 mt-1" variant="gray">
{workflow.team.name}
</Badge>
)}
{readOnly && (
<Badge className="ml-4 mt-1" variant="gray">
{t("readonly")}
</Badge>
)}
</div>
)
}>
{!isError ? (
<>
{isAllDataLoaded && user ? (
<>
<WorkflowDetailsPage
form={form}
workflowId={+workflowId}
user={user}
selectedEventTypes={selectedEventTypes}
setSelectedEventTypes={setSelectedEventTypes}
teamId={workflow ? workflow.teamId || undefined : undefined}
isMixedEventType={isMixedEventType}
readOnly={readOnly}
/>
</>
) : (
<SkeletonLoader />
)}
</>
) : (
<Alert severity="error" title="Something went wrong" message={error.message} />
)}
</ShellMain>
</Form>
</LicenseRequired>
</Shell>
) : (
<></>
);
+7 -1
View File
@@ -418,7 +418,13 @@ const EmailEmbedPreview = ({
<tr style={{ height: "25px" }}>
{selectedDateAndTime[key]?.length > 0 &&
selectedDateAndTime[key].map((time) => {
const bookingURL = `${WEBSITE_URL}/${username}/${eventType.slug}?duration=${eventType.length}&date=${key}&month=${month}&slot=${time}`;
// If teamId is present on eventType and is not null, it means it is a team event.
// So we add 'team/' to the url.
const bookingURL = `${WEBSITE_URL}/${
eventType.teamId !== null ? "team/" : ""
}${username}/${eventType.slug}?duration=${
eventType.length
}&date=${key}&month=${month}&slot=${time}`;
return (
<td
key={time}
@@ -0,0 +1,3 @@
export const getDefinedBufferTimes = () => {
return [5, 10, 15, 20, 30, 45, 60, 90, 120];
};
@@ -1,21 +1,33 @@
"use client";
import { useSession } from "next-auth/react";
import { useEffect, useState } from "react";
import dayjs from "@calcom/dayjs";
import { IS_VISUAL_REGRESSION_TESTING } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Dialog, DialogClose, DialogContent, DialogFooter, showToast } from "@calcom/ui";
export default function TimezoneChangeDialog() {
const TimezoneChangeDialogContent = ({
onAction,
browserTimezone,
}: {
browserTimezone: string;
onAction: (action?: "update" | "cancel") => void;
}) => {
const { t } = useLocale();
const { data: user, isPending } = trpc.viewer.me.useQuery();
const utils = trpc.useUtils();
const userTz = user?.timeZone;
const currentTz = dayjs.tz.guess() || "Europe/London";
const formattedCurrentTz = currentTz?.replace("_", " ");
const formattedCurrentTz = browserTimezone.replace("_", " ");
// save cookie to not show again
function onCancel(hideFor: [number, dayjs.ManipulateType], toast: boolean) {
onAction("cancel");
document.cookie = `calcom-timezone-dialog=1;max-age=${
dayjs().add(hideFor[0], hideFor[1]).unix() - dayjs().unix()
}`;
toast && showToast(t("we_wont_show_again"), "success");
}
// update user settings
const onSuccessMutation = async () => {
showToast(t("updated_timezone_to", { formattedCurrentTz }), "success");
await utils.viewer.me.invalidate();
@@ -32,55 +44,69 @@ export default function TimezoneChangeDialog() {
});
function updateTimezone() {
setOpen(false);
onAction("update");
mutation.mutate({
timeZone: currentTz,
timeZone: browserTimezone,
});
}
// check for difference in user timezone and current browser timezone
const [open, setOpen] = useState(false);
useEffect(() => {
const tzDifferent =
!isPending && dayjs.tz(undefined, currentTz).utcOffset() !== dayjs.tz(undefined, userTz).utcOffset();
const showDialog = tzDifferent && !document.cookie.includes("calcom-timezone-dialog=1");
setOpen(!IS_VISUAL_REGRESSION_TESTING && showDialog);
}, [currentTz, isPending, userTz]);
// save cookie to not show again
function onCancel(maxAge: number, toast: boolean) {
setOpen(false);
document.cookie = `calcom-timezone-dialog=1;max-age=${maxAge}`;
toast && showToast(t("we_wont_show_again"), "success");
}
const { data } = useSession();
if (data?.user.impersonatedBy) return null;
const ONE_DAY = 60 * 60 * 24; // 1 day in seconds (60 seconds * 60 minutes * 24 hours)
const THREE_MONTHS = ONE_DAY * 90; // 90 days in seconds (90 days * 1 day in seconds)
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent
title={t("update_timezone_question")}
description={t("update_timezone_description", { formattedCurrentTz })}
type="creation"
onInteractOutside={() => onCancel(ONE_DAY, false) /* 1 day expire */}>
{/* todo: save this in db and auto-update when timezone changes (be able to disable??? if yes, /settings)
<DialogContent
title={t("update_timezone_question")}
description={t("update_timezone_description", { formattedCurrentTz })}
type="creation"
onInteractOutside={() => onCancel([1, "day"], false) /* 1 day expire */}>
{/* todo: save this in db and auto-update when timezone changes (be able to disable??? if yes, /settings)
<Checkbox description="Always update timezone" />
*/}
<div className="mb-8" />
<DialogFooter showDivider>
<DialogClose onClick={() => onCancel(THREE_MONTHS, true)} color="secondary">
{t("dont_update")}
</DialogClose>
<DialogClose onClick={() => updateTimezone()} color="primary">
{t("update_timezone")}
</DialogClose>
</DialogFooter>
</DialogContent>
<div className="mb-8" />
<DialogFooter showDivider>
<DialogClose onClick={() => onCancel([3, "months"], true)} color="secondary">
{t("dont_update")}
</DialogClose>
<DialogClose onClick={() => updateTimezone()} color="primary">
{t("update_timezone")}
</DialogClose>
</DialogFooter>
</DialogContent>
);
};
export function useOpenTimezoneDialog() {
const { data: user } = trpc.viewer.me.useQuery();
const [showDialog, setShowDialog] = useState(false);
const browserTimezone = dayjs.tz.guess() || "Europe/London";
const { isLocaleReady } = useLocale();
const { data: userSession, status } = useSession();
useEffect(() => {
if (
!isLocaleReady ||
!user?.timeZone ||
status !== "authenticated" ||
userSession?.user?.impersonatedBy
) {
return;
}
const cookie = document.cookie
.split(";")
.find((cookie) => cookie.trim().startsWith("calcom-timezone-dialog"));
if (
!cookie &&
dayjs.tz(undefined, browserTimezone).utcOffset() !== dayjs.tz(undefined, user.timeZone).utcOffset()
) {
setShowDialog(true);
}
}, [user, isLocaleReady, status, browserTimezone, userSession?.user?.impersonatedBy]);
return { open: showDialog, setOpen: setShowDialog, browserTimezone };
}
export default function TimezoneChangeDialog() {
const { open, setOpen, browserTimezone } = useOpenTimezoneDialog();
return (
<Dialog open={open} onOpenChange={setOpen}>
<TimezoneChangeDialogContent browserTimezone={browserTimezone} onAction={() => setOpen(false)} />
</Dialog>
);
}
@@ -99,6 +99,10 @@ const tabs: VerticalTabItemProps[] = [
name: "directory_sync",
href: "/settings/organizations/dsync",
},
{
name: "admin_api",
href: "/settings/organizations/admin-api",
},
],
},
{
+129 -78
View File
@@ -251,11 +251,10 @@ const Layout = (props: LayoutProps) => {
<Toaster position="bottom-right" />
</div>
{/* todo: only run this if timezone is different */}
<TimezoneChangeDialog />
<div className="flex min-h-screen flex-col">
{banners && (
{banners && !props.isPlatformUser && (
<div className="sticky top-0 z-10 w-full divide-y divide-black">
<UserV2OptInBanner />
{Object.keys(banners).map((key) => {
@@ -289,7 +288,7 @@ const Layout = (props: LayoutProps) => {
{props.SidebarContainer ? (
cloneElement(props.SidebarContainer, { bannersHeight })
) : (
<SideBarContainer bannersHeight={bannersHeight} />
<SideBarContainer isPlatformUser={props.isPlatformUser} bannersHeight={bannersHeight} />
)}
<div className="flex w-0 flex-1 flex-col">
<MainContainer {...props} />
@@ -330,6 +329,7 @@ export type LayoutProps = {
afterHeading?: ReactNode;
smallHeading?: boolean;
hideHeadingOnMobile?: boolean;
isPlatformUser?: boolean;
};
const useAppTheme = () => {
@@ -648,27 +648,66 @@ const navigation: NavigationItemType[] = [
},
];
const moreSeparatorIndex = navigation.findIndex((item) => item.name === MORE_SEPARATOR_NAME);
// We create all needed navigation items for the different use cases
const { desktopNavigationItems, mobileNavigationBottomItems, mobileNavigationMoreItems } = navigation.reduce<
Record<string, NavigationItemType[]>
>(
(items, item, index) => {
// We filter out the "more" separator in` desktop navigation
if (item.name !== MORE_SEPARATOR_NAME) items.desktopNavigationItems.push(item);
// Items for mobile bottom navigation
if (index < moreSeparatorIndex + 1 && !item.onlyDesktop) {
items.mobileNavigationBottomItems.push(item);
} // Items for the "more" menu in mobile navigation
else {
items.mobileNavigationMoreItems.push(item);
}
return items;
const platformNavigation: NavigationItemType[] = [
{
name: "Dashboard",
href: "/settings/platform/",
icon: "layout-dashboard",
},
{ desktopNavigationItems: [], mobileNavigationBottomItems: [], mobileNavigationMoreItems: [] }
);
{
name: "Documentation",
href: "https://docs.cal.com/docs/platform",
icon: "bar-chart",
target: "_blank",
},
{
name: "API reference",
href: "https://api.cal.com/v2/docs#/",
icon: "terminal",
target: "_blank",
},
{
name: "Atoms",
href: "https://docs.cal.com/docs/platform#atoms",
icon: "atom",
target: "_blank",
},
{
name: MORE_SEPARATOR_NAME,
href: "https://docs.cal.com/docs/platform/faq",
icon: "ellipsis",
target: "_blank",
},
];
const getDesktopNavigationItems = (isPlatformNavigation = false) => {
const navigationType = !isPlatformNavigation ? navigation : platformNavigation;
const moreSeparatorIndex = navigationType.findIndex((item) => item.name === MORE_SEPARATOR_NAME);
const { desktopNavigationItems, mobileNavigationBottomItems, mobileNavigationMoreItems } = (
!isPlatformNavigation ? navigation : platformNavigation
).reduce<Record<string, NavigationItemType[]>>(
(items, item, index) => {
// We filter out the "more" separator in` desktop navigation
if (item.name !== MORE_SEPARATOR_NAME) items.desktopNavigationItems.push(item);
// Items for mobile bottom navigation
if (index < moreSeparatorIndex + 1 && !item.onlyDesktop) {
items.mobileNavigationBottomItems.push(item);
} // Items for the "more" menu in mobile navigation
else {
items.mobileNavigationMoreItems.push(item);
}
return items;
},
{ desktopNavigationItems: [], mobileNavigationBottomItems: [], mobileNavigationMoreItems: [] }
);
return { desktopNavigationItems, mobileNavigationBottomItems, mobileNavigationMoreItems };
};
const Navigation = ({ isPlatformNavigation = false }: { isPlatformNavigation?: boolean }) => {
const { desktopNavigationItems } = getDesktopNavigationItems(isPlatformNavigation);
const Navigation = () => {
return (
<nav className="mt-2 flex-1 md:px-2 lg:mt-4 lg:px-0">
{desktopNavigationItems.map((item) => (
@@ -712,6 +751,7 @@ const NavigationItem: React.FC<{
data-test-id={item.name}
href={item.href}
aria-label={t(item.name)}
target={item.target}
className={classNames(
"todesktop:py-[7px] text-default group flex items-center rounded-md px-2 py-1.5 text-sm font-medium transition",
item.child ? `[&[aria-current='page']]:!bg-transparent` : `[&[aria-current='page']]:bg-emphasis`,
@@ -752,14 +792,15 @@ const NavigationItem: React.FC<{
);
};
function MobileNavigationContainer() {
function MobileNavigationContainer({ isPlatformNavigation = false }: { isPlatformNavigation?: boolean }) {
const { status } = useSession();
if (status !== "authenticated") return null;
return <MobileNavigation />;
return <MobileNavigation isPlatformNavigation={isPlatformNavigation} />;
}
const MobileNavigation = () => {
const MobileNavigation = ({ isPlatformNavigation = false }: { isPlatformNavigation?: boolean }) => {
const isEmbed = useIsEmbed();
const { mobileNavigationBottomItems } = getDesktopNavigationItems(isPlatformNavigation);
return (
<>
@@ -794,6 +835,7 @@ const MobileNavigationItem: React.FC<{
<Link
key={item.name}
href={item.href}
target={item.target}
className="[&[aria-current='page']]:text-emphasis hover:text-default text-muted relative my-2 min-w-0 flex-1 overflow-hidden rounded-md !bg-transparent p-1 text-center text-xs font-medium focus:z-10 sm:text-sm"
aria-current={current ? "page" : undefined}>
{item.badge && <div className="absolute right-1 top-1">{item.badge}</div>}
@@ -837,24 +879,26 @@ const MobileNavigationMoreItem: React.FC<{
type SideBarContainerProps = {
bannersHeight: number;
isPlatformUser?: boolean;
};
type SideBarProps = {
bannersHeight: number;
user?: UserAuth | null;
isPlatformUser?: boolean;
};
function SideBarContainer({ bannersHeight }: SideBarContainerProps) {
function SideBarContainer({ bannersHeight, isPlatformUser = false }: SideBarContainerProps) {
const { status, data } = useSession();
// Make sure that Sidebar is rendered optimistically so that a refresh of pages when logged in have SideBar from the beginning.
// This improves the experience of refresh on app store pages(when logged in) which are SSG.
// Though when logged out, app store pages would temporarily show SideBar until session status is confirmed.
if (status !== "loading" && status !== "authenticated") return null;
return <SideBar bannersHeight={bannersHeight} user={data?.user} />;
return <SideBar isPlatformUser={isPlatformUser} bannersHeight={bannersHeight} user={data?.user} />;
}
function SideBar({ bannersHeight, user }: SideBarProps) {
function SideBar({ bannersHeight, user, isPlatformUser = false }: SideBarProps) {
const { t, isLocaleReady } = useLocale();
const orgBranding = useOrgBranding();
@@ -948,54 +992,54 @@ function SideBar({ bannersHeight, user }: SideBarProps) {
<KBarTrigger />
</div>
</header>
{/* logo icon for tablet */}
<Link href="/event-types" className="text-center md:inline lg:hidden">
<Logo small icon />
</Link>
<Navigation />
<Navigation isPlatformNavigation={isPlatformUser} />
</div>
<div>
<Tips />
{bottomNavItems.map((item, index) => (
<Tooltip side="right" content={t(item.name)} className="lg:hidden" key={item.name}>
<ButtonOrLink
id={item.name}
href={item.href || undefined}
aria-label={t(item.name)}
target={item.target}
className={classNames(
"text-left",
"[&[aria-current='page']]:bg-emphasis text-default justify-right group flex items-center rounded-md px-2 py-1.5 text-sm font-medium transition",
"[&[aria-current='page']]:text-emphasis mt-0.5 w-full text-sm",
isLocaleReady ? "hover:bg-emphasis hover:text-emphasis" : "",
index === 0 && "mt-3"
)}
onClick={item.onClick}>
{!!item.icon && (
<Icon
name={item.icon}
className={classNames(
"h-4 w-4 flex-shrink-0 [&[aria-current='page']]:text-inherit",
"me-3 md:mx-auto lg:ltr:mr-2 lg:rtl:ml-2"
)}
aria-hidden="true"
/>
)}
{isLocaleReady ? (
<span className="hidden w-full justify-between lg:flex">
<div className="flex">{t(item.name)}</div>
</span>
) : (
<SkeletonText className="h-[20px] w-full" />
)}
</ButtonOrLink>
</Tooltip>
))}
{!IS_VISUAL_REGRESSION_TESTING && <Credits />}
</div>
{!isPlatformUser && (
<div>
<Tips />
{bottomNavItems.map((item, index) => (
<Tooltip side="right" content={t(item.name)} className="lg:hidden" key={item.name}>
<ButtonOrLink
id={item.name}
href={item.href || undefined}
aria-label={t(item.name)}
target={item.target}
className={classNames(
"text-left",
"[&[aria-current='page']]:bg-emphasis text-default justify-right group flex items-center rounded-md px-2 py-1.5 text-sm font-medium transition",
"[&[aria-current='page']]:text-emphasis mt-0.5 w-full text-sm",
isLocaleReady ? "hover:bg-emphasis hover:text-emphasis" : "",
index === 0 && "mt-3"
)}
onClick={item.onClick}>
{!!item.icon && (
<Icon
name={item.icon}
className={classNames(
"h-4 w-4 flex-shrink-0 [&[aria-current='page']]:text-inherit",
"me-3 md:mx-auto lg:ltr:mr-2 lg:rtl:ml-2"
)}
aria-hidden="true"
/>
)}
{isLocaleReady ? (
<span className="hidden w-full justify-between lg:flex">
<div className="flex">{t(item.name)}</div>
</span>
) : (
<SkeletonText className="h-[20px] w-full" />
)}
</ButtonOrLink>
</Tooltip>
))}
{!IS_VISUAL_REGRESSION_TESTING && <Credits />}
</div>
)}
</aside>
</div>
);
@@ -1075,7 +1119,10 @@ export function ShellMain(props: LayoutProps) {
}
function MainContainer({
MobileNavigationContainer: MobileNavigationContainerProp = <MobileNavigationContainer />,
isPlatformUser,
MobileNavigationContainer: MobileNavigationContainerProp = (
<MobileNavigationContainer isPlatformNavigation={isPlatformUser} />
),
TopNavContainer: TopNavContainerProp = <TopNavContainer />,
...props
}: LayoutProps) {
@@ -1128,13 +1175,17 @@ function TopNav() {
);
}
export const MobileNavigationMoreItems = () => (
<ul className="border-subtle mt-2 rounded-md border">
{mobileNavigationMoreItems.map((item) => (
<MobileNavigationMoreItem key={item.name} item={item} />
))}
</ul>
);
export const MobileNavigationMoreItems = () => {
const { mobileNavigationMoreItems } = getDesktopNavigationItems();
return (
<ul className="border-subtle mt-2 rounded-md border">
{mobileNavigationMoreItems.map((item) => (
<MobileNavigationMoreItem key={item.name} item={item} />
))}
</ul>
);
};
function ProfileDropdown() {
const { update, data: sessionData } = useSession();
@@ -1,10 +1,12 @@
import { keepPreviousData } from "@tanstack/react-query";
import type { ColumnDef } from "@tanstack/react-table";
import type { ColumnDef, Table } from "@tanstack/react-table";
import { m } from "framer-motion";
import { useSession } from "next-auth/react";
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import { useCopy } from "@calcom/lib/hooks/useCopy";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { MembershipRole } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc";
@@ -110,6 +112,7 @@ function reducer(state: State, action: Action): State {
export function UserListTable() {
const { data: session } = useSession();
const { copyToClipboard, isCopied } = useCopy();
const { data: org } = trpc.viewer.organizations.listCurrent.useQuery();
const { data: teams } = trpc.viewer.organizations.getTeams.useQuery();
const tableContainerRef = useRef<HTMLDivElement>(null);
@@ -117,6 +120,7 @@ export function UserListTable() {
const { t } = useLocale();
const orgBranding = useOrgBranding();
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState("");
const [dynamicLinkVisible, setDynamicLinkVisible] = useState(false);
const { data, isPending, fetchNextPage, isFetching } =
trpc.viewer.organizations.listMembers.useInfiniteQuery(
{
@@ -325,6 +329,15 @@ export function UserListTable() {
type: "render",
render: (table) => <TeamListBulkAction table={table} />,
},
{
type: "action",
icon: "handshake",
label: "Group Meeting",
needsXSelected: 2,
onClick: () => {
setDynamicLinkVisible((old) => !old);
},
},
{
type: "render",
render: (table) => (
@@ -335,6 +348,50 @@ export function UserListTable() {
),
},
]}
renderAboveSelection={(table: Table<User>) => {
const numberOfSelectedRows = table.getSelectedRowModel().rows.length;
const isVisible = numberOfSelectedRows >= 2 && dynamicLinkVisible;
const users = table
.getSelectedRowModel()
.flatRows.map((row) => row.original.username)
.filter((u) => u !== null);
const usersNameAsString = users.join("+");
const dynamicLinkOfSelectedUsers = `${domain}/${usersNameAsString}`;
const domainWithoutHttps = dynamicLinkOfSelectedUsers.replace(/https?:\/\//g, "");
return (
<>
{isVisible ? (
<m.div
layout
className="bg-brand-default text-inverted item-center animate-fade-in-bottom hidden w-full gap-1 rounded-lg p-2 text-sm font-medium leading-none md:flex">
<div className="w-[300px] items-center truncate p-2">
<p>{domainWithoutHttps}</p>
</div>
<div className="ml-auto flex items-center">
<Button
StartIcon="copy"
size="sm"
onClick={() => copyToClipboard(dynamicLinkOfSelectedUsers)}>
{!isCopied ? t("copy") : t("copied")}
</Button>
<Button
EndIcon="external-link"
size="sm"
href={dynamicLinkOfSelectedUsers}
target="_blank"
rel="noopener noreferrer">
Open
</Button>
</div>
</m.div>
) : null}
</>
);
}}
tableContainerRef={tableContainerRef}
tableCTA={
adminOrOwner && (
@@ -103,6 +103,7 @@ export default function WebhookListItem(props: {
<div className="ml-2 flex items-center space-x-4">
<Switch
defaultChecked={webhook.active}
data-testid="webhook-switch"
disabled={!canEditWebhook}
onCheckedChange={() =>
toggleWebhook.mutate({
@@ -114,7 +115,11 @@ export default function WebhookListItem(props: {
}
/>
<Button className="hidden lg:flex" color="secondary" onClick={props.onEditWebhook}>
<Button
className="hidden lg:flex"
color="secondary"
onClick={props.onEditWebhook}
data-testid="webhook-edit-button">
{t("edit")}
</Button>

Some files were not shown because too many files have changed in this diff Show More