Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ec5153f14 | ||
|
|
90ce0f8d4d | ||
|
|
5e9902921f | ||
|
|
5d3c72271b | ||
|
|
775010cd2d | ||
|
|
87a8b5a8dd | ||
|
|
973b624882 | ||
|
|
0a0c5ef61e | ||
|
|
46f67bba9b | ||
|
|
d9806da049 | ||
|
|
42fc49488d | ||
|
|
e030fe0e07 | ||
|
|
51428087ef | ||
|
|
3942df369d | ||
|
|
1d309a6f09 | ||
|
|
56f31eee7a | ||
|
|
2dae0bfb8c | ||
|
|
917c7b0764 | ||
|
|
fd64b0f468 | ||
|
|
b1909337db | ||
|
|
039bf6d4e3 | ||
|
|
83e15c4ce1 | ||
|
|
7d6ecf7201 | ||
|
|
bc42c27230 | ||
|
|
57abb26c31 | ||
|
|
a136511c8a | ||
|
|
c65dca9486 | ||
|
|
816d31918d | ||
|
|
097a69d7d7 | ||
|
|
c2a07e221d | ||
|
|
6d8fbfc49a | ||
|
|
304ef29618 | ||
|
|
b8890c841c | ||
|
|
2f5768b74f | ||
|
|
c1416173e8 | ||
|
|
726af4970c | ||
|
|
d15b7d8168 | ||
|
|
c5bd45fdfb | ||
|
|
5d7b2a12a7 | ||
|
|
7c90d1ed76 | ||
|
|
5ca10d1e83 | ||
|
|
f0e59c2331 |
@@ -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()
|
||||
|
||||
@@ -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,5 +1,6 @@
|
||||
export enum PlatformPlan {
|
||||
STARTER = "STARTER",
|
||||
ESSENTIALS = "ESSENTIALS",
|
||||
SCALE = "SCALE",
|
||||
ENTERPRISE = "ENTERPRISE",
|
||||
}
|
||||
|
||||
+5
@@ -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();
|
||||
|
||||
|
||||
+38
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
+3
-3
@@ -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> : <> 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
|
||||
+30
-140
@@ -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">•</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>
|
||||
);
|
||||
};
|
||||
+30
@@ -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 };
|
||||
};
|
||||
+63
-2
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "4.0.7",
|
||||
"version": "4.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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}}?",
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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],
|
||||
},
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# @calcom/embed-core
|
||||
|
||||
## 1.5.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Added namespacing support throughout
|
||||
|
||||
## 1.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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,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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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))
|
||||
);
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
<></>
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user