Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5ca11b27b | ||
|
|
25534f721a | ||
|
|
fad5497363 | ||
|
|
563b31b7d0 | ||
|
|
8c9eb18463 | ||
|
|
0965fad72a | ||
|
|
e76bb37f78 | ||
|
|
b0a85a5637 | ||
|
|
82f374d84c | ||
|
|
9ac037c524 | ||
|
|
f5750f3933 | ||
|
|
8f2cb0522d | ||
|
|
4cd8dbbab4 | ||
|
|
1fb778ae78 | ||
|
|
bc9d76e7da | ||
|
|
af51ce1890 | ||
|
|
75c79f76c8 | ||
|
|
b5b4134b75 | ||
|
|
01ca2ca3de | ||
|
|
f94e80558a | ||
|
|
a5c15e6517 | ||
|
|
aaa39cccf8 | ||
|
|
d24a93962f | ||
|
|
1d9d52ecf8 | ||
|
|
f1a832921a | ||
|
|
af1633db28 | ||
|
|
7353b6a604 | ||
|
|
dab0b4a4d8 |
@@ -424,6 +424,8 @@ DIRECTORY_IDS_TO_LOG=
|
||||
# Read more about it in the README.md
|
||||
NEXT_PUBLIC_SINGLE_ORG_SLUG=
|
||||
|
||||
AKISMET_API_KEY=
|
||||
|
||||
## Env variables related to avoiding booking failures
|
||||
# Request for checking reservation would be attempted to send every these seconds if the request is stale at that time
|
||||
NEXT_PUBLIC_QUERY_RESERVATION_INTERVAL_SECONDS=
|
||||
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
undecided:
|
||||
- "@calcom/prisma"
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
undecided:
|
||||
- calcom-monorepo
|
||||
- "@calcom/prisma"
|
||||
@@ -79,9 +79,10 @@ async function handler(req: NextApiRequest) {
|
||||
allRemainingBookings: req.query.allRemainingBookings === "true",
|
||||
});
|
||||
|
||||
// Normalizing for universal handler
|
||||
req.body = { id, allRemainingBookings, cancellationReason, cancelledBy };
|
||||
return await handleCancelBooking(req);
|
||||
return await handleCancelBooking({
|
||||
bookingData: { id, allRemainingBookings, cancellationReason, cancelledBy },
|
||||
userId: req.userId,
|
||||
});
|
||||
}
|
||||
|
||||
export default defaultResponder(handler);
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
"@axiomhq/winston": "^1.2.0",
|
||||
"@calcom/platform-constants": "*",
|
||||
"@calcom/platform-enums": "*",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.160",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.166",
|
||||
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
|
||||
"@calcom/platform-types": "*",
|
||||
"@calcom/platform-utils": "*",
|
||||
|
||||
@@ -214,9 +214,16 @@ export class BookingsController_2024_04_15 {
|
||||
if (bookingUid) {
|
||||
try {
|
||||
req.body.uid = bookingUid;
|
||||
const res = await handleCancelBooking(
|
||||
await this.createNextApiBookingRequest(req, oAuthClientId, undefined, isEmbed)
|
||||
);
|
||||
const bookingRequest = await this.createNextApiBookingRequest(req, oAuthClientId, undefined, isEmbed);
|
||||
const res = await handleCancelBooking({
|
||||
bookingData: bookingRequest.body,
|
||||
userId: bookingRequest.userId,
|
||||
arePlatformEmailsEnabled: bookingRequest.arePlatformEmailsEnabled,
|
||||
platformClientId: bookingRequest.platformClientId,
|
||||
platformCancelUrl: bookingRequest.platformCancelUrl,
|
||||
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
|
||||
platformBookingUrl: bookingRequest.platformBookingUrl,
|
||||
});
|
||||
if (!res.onlyRemovedAttendee) {
|
||||
void (await this.billingService.cancelUsageByBookingUid(res.bookingUid));
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
|
||||
@Injectable()
|
||||
export class BookingsRepository_2024_08_13 {
|
||||
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||
|
||||
@@ -122,7 +122,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
@@ -150,11 +149,17 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
|
||||
expect(response.body.status).toEqual(SUCCESS_STATUS);
|
||||
expect(response.body.data).toBeDefined();
|
||||
|
||||
const googleCalendarLink = response.body.data.find((item: { id: string }) => item.id === 'googleCalendar').link;
|
||||
const microsoftOfficeLink = response.body.data.find((item: { id: string }) => item.id === 'microsoftOffice').link;
|
||||
const microsoftOutlookLink = response.body.data.find((item: { id: string }) => item.id === 'microsoftOutlook').link;
|
||||
const icsLink = response.body.data.find((item: { id: string }) => item.id === 'ics').link;
|
||||
|
||||
const googleCalendarLink = response.body.data.find(
|
||||
(item: { id: string }) => item.id === "googleCalendar"
|
||||
).link;
|
||||
const microsoftOfficeLink = response.body.data.find(
|
||||
(item: { id: string }) => item.id === "microsoftOffice"
|
||||
).link;
|
||||
const microsoftOutlookLink = response.body.data.find(
|
||||
(item: { id: string }) => item.id === "microsoftOutlook"
|
||||
).link;
|
||||
const icsLink = response.body.data.find((item: { id: string }) => item.id === "ics").link;
|
||||
|
||||
expect(googleCalendarLink).toMatch(/^https:\/\/calendar\.google\.com\//);
|
||||
expect(microsoftOfficeLink).toMatch(/^https:\/\/outlook\.office\.com\//);
|
||||
|
||||
+70
-4
@@ -301,8 +301,8 @@ describe("Managed user bookings 2024-08-13", () => {
|
||||
attendee: {
|
||||
name: thirdManagedUser.user.name!,
|
||||
email: thirdManagedUserEmail,
|
||||
timeZone: secondManagedUser.user.timeZone,
|
||||
language: secondManagedUser.user.locale,
|
||||
timeZone: thirdManagedUser.user.timeZone,
|
||||
language: thirdManagedUser.user.locale,
|
||||
},
|
||||
guests: [secondManagedUserEmail],
|
||||
location: "https://meet.google.com/abc-def-ghi",
|
||||
@@ -418,8 +418,8 @@ describe("Managed user bookings 2024-08-13", () => {
|
||||
attendee: {
|
||||
name: thirdManagedUser.user.name!,
|
||||
email: thirdManagedUser.user.email,
|
||||
timeZone: secondManagedUser.user.timeZone,
|
||||
language: secondManagedUser.user.locale,
|
||||
timeZone: thirdManagedUser.user.timeZone,
|
||||
language: thirdManagedUser.user.locale,
|
||||
},
|
||||
guests: [secondManagedUser.user.email],
|
||||
location: "https://meet.google.com/abc-def-ghi",
|
||||
@@ -477,6 +477,72 @@ describe("Managed user bookings 2024-08-13", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetch bookings by attendeeEmail", () => {
|
||||
it("should return bookings for the original email when original email is attendee only", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/v2/bookings?attendeeEmail=${thirdManagedUserEmail}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set("Authorization", `Bearer ${firstManagedUser.accessToken}`)
|
||||
.expect(200);
|
||||
|
||||
const thirdManagedUserAttendeeBookingsResponseBody: GetBookingsOutput_2024_08_13 = response.body;
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
const thirdManagedUserAttendeeBookings: BookingOutput_2024_08_13[] =
|
||||
thirdManagedUserAttendeeBookingsResponseBody.data;
|
||||
expect(thirdManagedUserBookingsCount).toEqual(2);
|
||||
expect(thirdManagedUserAttendeeBookings.length).toEqual(thirdManagedUserBookingsCount);
|
||||
});
|
||||
|
||||
it("should return bookings for the oAuth email when original oAuth email is attendee only", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/v2/bookings?attendeeEmail=${thirdManagedUser.user.email}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set("Authorization", `Bearer ${firstManagedUser.accessToken}`)
|
||||
.expect(200);
|
||||
|
||||
const thirdManagedUserAttendeeBookingsResponseBody: GetBookingsOutput_2024_08_13 = response.body;
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
const thirdManagedUserAttendeeBookings: BookingOutput_2024_08_13[] =
|
||||
thirdManagedUserAttendeeBookingsResponseBody.data;
|
||||
expect(thirdManagedUserBookingsCount).toEqual(2);
|
||||
expect(thirdManagedUserAttendeeBookings.length).toEqual(thirdManagedUserBookingsCount);
|
||||
});
|
||||
|
||||
it("should return bookings for the original email when original email is attendee or guest", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/v2/bookings?attendeeEmail=${secondManagedUserEmail}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set("Authorization", `Bearer ${firstManagedUser.accessToken}`)
|
||||
.expect(200);
|
||||
|
||||
const secondManagedUserAttendeeBookingsResponseBody: GetBookingsOutput_2024_08_13 = response.body;
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
const secondManagedUserAttendeeBookings: BookingOutput_2024_08_13[] =
|
||||
secondManagedUserAttendeeBookingsResponseBody.data;
|
||||
expect(secondManagedUserBookingsCount).toEqual(4);
|
||||
expect(secondManagedUserAttendeeBookings.length).toEqual(secondManagedUserBookingsCount);
|
||||
});
|
||||
|
||||
it("should return bookings for the oAuth email when original oAuth email is attendee or guest", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/v2/bookings?attendeeEmail=${secondManagedUser.user.email}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set("Authorization", `Bearer ${firstManagedUser.accessToken}`)
|
||||
.expect(200);
|
||||
|
||||
const secondManagedUserAttendeeBookingsResponseBody: GetBookingsOutput_2024_08_13 = response.body;
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
const secondManagedUserAttendeeBookings: BookingOutput_2024_08_13[] =
|
||||
secondManagedUserAttendeeBookingsResponseBody.data;
|
||||
expect(secondManagedUserBookingsCount).toEqual(4);
|
||||
expect(secondManagedUserAttendeeBookings.length).toEqual(secondManagedUserBookingsCount);
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await userRepositoryFixture.delete(firstManagedUser.user.id);
|
||||
await userRepositoryFixture.delete(secondManagedUser.user.id);
|
||||
|
||||
@@ -6,6 +6,8 @@ import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.
|
||||
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
|
||||
import { BillingService } from "@/modules/billing/services/billing.service";
|
||||
import { BookingSeatRepository } from "@/modules/booking-seat/booking-seat.repository";
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { UsersService } from "@/modules/users/services/users.service";
|
||||
import { UsersRepository, UserWithProfile } from "@/modules/users/users.repository";
|
||||
@@ -72,7 +74,8 @@ export class BookingsService_2024_08_13 {
|
||||
private readonly billingService: BillingService,
|
||||
private readonly usersService: UsersService,
|
||||
private readonly usersRepository: UsersRepository,
|
||||
private readonly platformBookingsService: PlatformBookingsService
|
||||
private readonly platformBookingsService: PlatformBookingsService,
|
||||
private readonly oAuthClientRepository: OAuthClientRepository
|
||||
) {}
|
||||
|
||||
async createBooking(request: Request, body: CreateBookingInput) {
|
||||
@@ -226,6 +229,10 @@ export class BookingsService_2024_08_13 {
|
||||
}
|
||||
|
||||
async getBookings(queryParams: GetBookingsInput_2024_08_13, user: { email: string; id: number }) {
|
||||
if (queryParams.attendeeEmail) {
|
||||
queryParams.attendeeEmail = await this.getAttendeeEmail(queryParams.attendeeEmail, user);
|
||||
}
|
||||
|
||||
const fetchedBookings: { bookings: { id: number }[] } = await getAllUserBookings({
|
||||
bookingListingByStatus: queryParams.status || [],
|
||||
skip: queryParams.skip ?? 0,
|
||||
@@ -281,6 +288,35 @@ export class BookingsService_2024_08_13 {
|
||||
return formattedBookings;
|
||||
}
|
||||
|
||||
async getAttendeeEmail(queryParamsAttendeeEmail: string, user: { id: number }) {
|
||||
// note(Lauris): this is to handle attendees that are managed users - in attendee table their email is one of managed users e.g
|
||||
// urdasdqinm+clxyyy21o0003sbk7yw5z6tzg@example.com but if attendeeEmail is passed as urdasdqinm@example.com then we check if user whose
|
||||
// access token is used is a managed user and if attendee with passed email has managed user email composed of passed email without oAuth client id +
|
||||
// authenticated user oAuth client id.
|
||||
const oAuthClient = await this.oAuthClientRepository.getByUserId(user.id);
|
||||
if (!oAuthClient) {
|
||||
return queryParamsAttendeeEmail;
|
||||
}
|
||||
// note(Lauris): query param already contains oAuth client id in the attendeeEmail
|
||||
if (queryParamsAttendeeEmail.includes(oAuthClient.id)) {
|
||||
return queryParamsAttendeeEmail;
|
||||
}
|
||||
|
||||
const managedAttendeeEmail = OAuthClientUsersService.getOAuthUserEmail(
|
||||
oAuthClient.id,
|
||||
queryParamsAttendeeEmail
|
||||
);
|
||||
const [attendee, managedAttendee] = await Promise.all([
|
||||
this.usersRepository.findByEmail(queryParamsAttendeeEmail),
|
||||
this.usersRepository.findByEmail(managedAttendeeEmail),
|
||||
]);
|
||||
if (!attendee && managedAttendee) {
|
||||
return managedAttendeeEmail;
|
||||
}
|
||||
|
||||
return queryParamsAttendeeEmail;
|
||||
}
|
||||
|
||||
async rescheduleBooking(request: Request, bookingUid: string, body: RescheduleBookingInput) {
|
||||
try {
|
||||
const bookingRequest = await this.inputService.createRescheduleBookingRequest(
|
||||
@@ -344,7 +380,16 @@ export class BookingsService_2024_08_13 {
|
||||
}
|
||||
|
||||
const bookingRequest = await this.inputService.createCancelBookingRequest(request, bookingUid, body);
|
||||
const res = await handleCancelBooking(bookingRequest);
|
||||
const res = await handleCancelBooking({
|
||||
bookingData: bookingRequest.body,
|
||||
userId: bookingRequest.userId,
|
||||
arePlatformEmailsEnabled: bookingRequest.arePlatformEmailsEnabled,
|
||||
platformClientId: bookingRequest.platformClientId,
|
||||
platformCancelUrl: bookingRequest.platformCancelUrl,
|
||||
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
|
||||
platformBookingUrl: bookingRequest.platformBookingUrl,
|
||||
});
|
||||
|
||||
if (!res.onlyRemovedAttendee) {
|
||||
await this.billingService.cancelUsageByBookingUid(res.bookingUid);
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ export class AppleCalendarService implements CredentialSyncCalendarApp {
|
||||
}
|
||||
|
||||
async checkIfCalendarConnected(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
|
||||
const appleCalendarCredentials = await this.credentialRepository.getByTypeAndUserId(
|
||||
const appleCalendarCredentials = await this.credentialRepository.findCredentialByTypeAndUserId(
|
||||
APPLE_CALENDAR_TYPE,
|
||||
userId
|
||||
);
|
||||
@@ -94,7 +94,7 @@ export class AppleCalendarService implements CredentialSyncCalendarApp {
|
||||
}
|
||||
|
||||
if (!!hasCalendarWithGivenCredentials && !hasMatchingUsernameAndPassword) {
|
||||
await this.credentialRepository.upsertAppCredential(
|
||||
await this.credentialRepository.upsertUserAppCredential(
|
||||
APPLE_CALENDAR_TYPE,
|
||||
symmetricEncrypt(JSON.stringify({ username, password }), process.env.CALENDSO_ENCRYPTION_KEY || ""),
|
||||
userId,
|
||||
@@ -126,7 +126,7 @@ export class AppleCalendarService implements CredentialSyncCalendarApp {
|
||||
user: { email: userEmail },
|
||||
});
|
||||
await dav?.listCalendars();
|
||||
await this.credentialRepository.upsertAppCredential(APPLE_CALENDAR_TYPE, data.key, userId);
|
||||
await this.credentialRepository.upsertUserAppCredential(APPLE_CALENDAR_TYPE, data.key, userId);
|
||||
} catch (reason) {
|
||||
throw new BadRequestException(`Could not add this apple calendar account: ${reason}`);
|
||||
}
|
||||
|
||||
@@ -172,7 +172,7 @@ export class CalendarsService {
|
||||
calendarType: keyof typeof APPS_TYPE_ID_MAPPING,
|
||||
credentialId?: number | null
|
||||
) {
|
||||
const credential = await this.credentialsRepository.upsertAppCredential(
|
||||
const credential = await this.credentialsRepository.upsertUserAppCredential(
|
||||
calendarType,
|
||||
key,
|
||||
userId,
|
||||
|
||||
@@ -84,7 +84,10 @@ export class GoogleCalendarService implements OAuthCalendarApp {
|
||||
}
|
||||
|
||||
async checkIfCalendarConnected(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
|
||||
const gcalCredentials = await this.credentialRepository.getByTypeAndUserId("google_calendar", userId);
|
||||
const gcalCredentials = await this.credentialRepository.findCredentialByTypeAndUserId(
|
||||
"google_calendar",
|
||||
userId
|
||||
);
|
||||
|
||||
if (!gcalCredentials) {
|
||||
throw new BadRequestException("Credentials for google_calendar not found.");
|
||||
|
||||
@@ -52,7 +52,7 @@ export class IcsFeedService implements ICSFeedCalendarApp {
|
||||
);
|
||||
}
|
||||
|
||||
const credential = await this.credentialRepository.upsertAppCredential(
|
||||
const credential = await this.credentialRepository.upsertUserAppCredential(
|
||||
ICS_CALENDAR_TYPE,
|
||||
data.key,
|
||||
userId
|
||||
@@ -75,7 +75,10 @@ export class IcsFeedService implements ICSFeedCalendarApp {
|
||||
}
|
||||
|
||||
async check(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
|
||||
const icsFeedCredentials = await this.credentialRepository.getByTypeAndUserId(ICS_CALENDAR_TYPE, userId);
|
||||
const icsFeedCredentials = await this.credentialRepository.findCredentialByTypeAndUserId(
|
||||
ICS_CALENDAR_TYPE,
|
||||
userId
|
||||
);
|
||||
|
||||
if (!icsFeedCredentials) {
|
||||
throw new BadRequestException("Credentials for Ics Feed calendar not found.");
|
||||
|
||||
@@ -71,7 +71,7 @@ export class OutlookService implements OAuthCalendarApp {
|
||||
}
|
||||
|
||||
async checkIfCalendarConnected(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
|
||||
const office365CalendarCredentials = await this.credentialRepository.getByTypeAndUserId(
|
||||
const office365CalendarCredentials = await this.credentialRepository.findCredentialByTypeAndUserId(
|
||||
"office365_calendar",
|
||||
userId
|
||||
);
|
||||
|
||||
+38
@@ -377,6 +377,27 @@ describe("Event types Endpoints", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("should not allow creating an event type with integration not installed on user", async () => {
|
||||
const body: CreateEventTypeInput_2024_06_14 = {
|
||||
title: "Coding class",
|
||||
slug: "coding-class",
|
||||
description: "Let's learn how to code like a pro.",
|
||||
lengthInMinutes: 60,
|
||||
locations: [
|
||||
{
|
||||
type: "integration",
|
||||
integration: "zoom",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/api/v2/event-types")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.send(body)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("should create an event type", async () => {
|
||||
const nameBookingField: NameDefaultFieldInput_2024_06_14 = {
|
||||
type: "name",
|
||||
@@ -1090,6 +1111,23 @@ describe("Event types Endpoints", () => {
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it("should not allow to update event type with integration not installed on user", async () => {
|
||||
const body: UpdateEventTypeInput_2024_06_14 = {
|
||||
locations: [
|
||||
{
|
||||
type: "integration",
|
||||
integration: "office365-video",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/api/v2/event-types/${eventType.id}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.send(body)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it(`/GET/:id`, async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v2/event-types/${eventType.id}`)
|
||||
|
||||
+2
-2
@@ -75,7 +75,7 @@ export class EventTypesController_2024_06_14 {
|
||||
@GetUser() user: UserWithProfile
|
||||
): Promise<CreateEventTypeOutput_2024_06_14> {
|
||||
const transformedBody = await this.inputEventTypesService.transformAndValidateCreateEventTypeInput(
|
||||
user.id,
|
||||
user,
|
||||
body
|
||||
);
|
||||
|
||||
@@ -150,7 +150,7 @@ export class EventTypesController_2024_06_14 {
|
||||
): Promise<UpdateEventTypeOutput_2024_06_14> {
|
||||
const transformedBody = await this.inputEventTypesService.transformAndValidateUpdateEventTypeInput(
|
||||
body,
|
||||
user.id,
|
||||
user,
|
||||
eventTypeId
|
||||
);
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { InputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_
|
||||
import { OutputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/output-event-types.service";
|
||||
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
|
||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
import { ConferencingModule } from "@/modules/conferencing/conferencing.module";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
@@ -18,7 +19,7 @@ import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, MembershipsModule, TokensModule, SelectedCalendarsModule],
|
||||
imports: [PrismaModule, MembershipsModule, TokensModule, SelectedCalendarsModule, ConferencingModule],
|
||||
providers: [
|
||||
EventTypesRepository_2024_06_14,
|
||||
EventTypesService_2024_06_14,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
|
||||
import { InputEventTransformed_2024_06_14 } from "@calcom/platform-types";
|
||||
|
||||
@Injectable()
|
||||
|
||||
+28
-7
@@ -1,6 +1,7 @@
|
||||
import { ConnectedCalendarsData } from "@/ee/calendars/outputs/connected-calendars.output";
|
||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
|
||||
import { ConferencingService } from "@/modules/conferencing/services/conferencing.service";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { Injectable, BadRequestException } from "@nestjs/common";
|
||||
|
||||
@@ -49,13 +50,15 @@ interface ValidationContext {
|
||||
export class InputEventTypesService_2024_06_14 {
|
||||
constructor(
|
||||
private readonly eventTypesRepository: EventTypesRepository_2024_06_14,
|
||||
private readonly calendarsService: CalendarsService
|
||||
private readonly calendarsService: CalendarsService,
|
||||
private readonly conferencingService: ConferencingService
|
||||
) {}
|
||||
|
||||
async transformAndValidateCreateEventTypeInput(
|
||||
userId: UserWithProfile["id"],
|
||||
user: UserWithProfile,
|
||||
inputEventType: CreateEventTypeInput_2024_06_14
|
||||
) {
|
||||
await this.validateInputLocations(user, inputEventType.locations);
|
||||
const transformedBody = this.transformInputCreateEventType(inputEventType);
|
||||
|
||||
await this.validateEventTypeInputs({
|
||||
@@ -66,19 +69,21 @@ export class InputEventTypesService_2024_06_14 {
|
||||
});
|
||||
|
||||
transformedBody.destinationCalendar &&
|
||||
(await this.validateInputDestinationCalendar(userId, transformedBody.destinationCalendar));
|
||||
(await this.validateInputDestinationCalendar(user.id, transformedBody.destinationCalendar));
|
||||
|
||||
transformedBody.useEventTypeDestinationCalendarEmail &&
|
||||
(await this.validateInputUseDestinationCalendarEmail(userId));
|
||||
(await this.validateInputUseDestinationCalendarEmail(user.id));
|
||||
|
||||
return transformedBody;
|
||||
}
|
||||
|
||||
async transformAndValidateUpdateEventTypeInput(
|
||||
inputEventType: UpdateEventTypeInput_2024_06_14,
|
||||
userId: UserWithProfile["id"],
|
||||
user: UserWithProfile,
|
||||
eventTypeId: number
|
||||
) {
|
||||
await this.validateInputLocations(user, inputEventType.locations);
|
||||
|
||||
const transformedBody = await this.transformInputUpdateEventType(inputEventType, eventTypeId);
|
||||
|
||||
await this.validateEventTypeInputs({
|
||||
@@ -90,10 +95,10 @@ export class InputEventTypesService_2024_06_14 {
|
||||
});
|
||||
|
||||
transformedBody.destinationCalendar &&
|
||||
(await this.validateInputDestinationCalendar(userId, transformedBody.destinationCalendar));
|
||||
(await this.validateInputDestinationCalendar(user.id, transformedBody.destinationCalendar));
|
||||
|
||||
transformedBody.useEventTypeDestinationCalendarEmail &&
|
||||
(await this.validateInputUseDestinationCalendarEmail(userId));
|
||||
(await this.validateInputUseDestinationCalendarEmail(user.id));
|
||||
|
||||
return transformedBody;
|
||||
}
|
||||
@@ -448,4 +453,20 @@ export class InputEventTypesService_2024_06_14 {
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
async validateInputLocations(
|
||||
user: UserWithProfile,
|
||||
inputLocations: CreateEventTypeInput_2024_06_14["locations"] | undefined
|
||||
) {
|
||||
await Promise.all(
|
||||
inputLocations?.map(async (location) => {
|
||||
if (location.type === "integration") {
|
||||
// cal-video is global, so we can skip this check
|
||||
if (location.integration !== "cal-video") {
|
||||
await this.conferencingService.checkAppIsValidAndConnected(user, location.integration);
|
||||
}
|
||||
}
|
||||
}) ?? []
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,7 +94,10 @@ export class GcalController {
|
||||
@Permissions([APPS_READ])
|
||||
@ApiOperation({ summary: "Check a calendar connection status" })
|
||||
async check(@GetUser("id") userId: number): Promise<GcalCheckOutput> {
|
||||
const gcalCredentials = await this.credentialRepository.getByTypeAndUserId("google_calendar", userId);
|
||||
const gcalCredentials = await this.credentialRepository.findCredentialByTypeAndUserId(
|
||||
"google_calendar",
|
||||
userId
|
||||
);
|
||||
|
||||
if (!gcalCredentials) {
|
||||
throw new BadRequestException("Credentials for google_calendar not found.");
|
||||
|
||||
@@ -36,7 +36,8 @@ export class CalendarServiceExceptionFilter implements ExceptionFilter {
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
|
||||
const requestId = request.headers["X-Request-Id"];
|
||||
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
|
||||
response.setHeader("X-Request-Id", requestId.toString());
|
||||
|
||||
this.logger.error(`Calendar Service Exception Filter: ${exception?.message}`, {
|
||||
exception,
|
||||
|
||||
@@ -14,8 +14,8 @@ export class HttpExceptionFilter implements ExceptionFilter<HttpException> {
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
const statusCode = exception.getStatus();
|
||||
const requestId = request.headers["X-Request-Id"];
|
||||
|
||||
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
|
||||
response.setHeader("X-Request-Id", requestId.toString());
|
||||
this.logger.error(`Http Exception Filter: ${exception?.message}`, {
|
||||
exception,
|
||||
body: request.body,
|
||||
|
||||
@@ -40,7 +40,8 @@ export class PrismaExceptionFilter implements ExceptionFilter {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
const requestId = request.headers["X-Request-Id"];
|
||||
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
|
||||
response.setHeader("X-Request-Id", requestId.toString());
|
||||
|
||||
this.logger.error(`PrismaError: ${error.message}`, {
|
||||
error,
|
||||
|
||||
@@ -87,7 +87,8 @@ export class TRPCExceptionFilter implements ExceptionFilter {
|
||||
break;
|
||||
}
|
||||
|
||||
const requestId = request.headers["X-Request-Id"];
|
||||
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
|
||||
response.setHeader("X-Request-Id", requestId.toString());
|
||||
|
||||
this.logger.error(`TRPC Exception Filter: ${exception?.message}`, {
|
||||
exception,
|
||||
|
||||
@@ -15,7 +15,8 @@ export class ZodExceptionFilter implements ExceptionFilter {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
const requestId = request.headers["X-Request-Id"];
|
||||
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
|
||||
response.setHeader("X-Request-Id", requestId.toString());
|
||||
|
||||
this.logger.error(`ZodError: ${error.message}`, {
|
||||
error,
|
||||
|
||||
@@ -31,6 +31,15 @@ export class AppsRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async deleteTeamAppCredentials(credentialIdsToDelete: number[], teamId: number) {
|
||||
return this.dbWrite.prisma.credential.deleteMany({
|
||||
where: {
|
||||
id: { in: credentialIdsToDelete },
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async createTeamAppCredential(type: string, key: Prisma.InputJsonValue, teamId: number, appId: string) {
|
||||
return this.dbWrite.prisma.credential.create({
|
||||
data: {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { CredentialsRepository } from "@/modules/credentials/credentials.reposit
|
||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { RedisService } from "@/modules/redis/redis.service";
|
||||
import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module";
|
||||
import { UsersService } from "@/modules/users/services/users.service";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
@@ -24,6 +25,7 @@ import { Module } from "@nestjs/common";
|
||||
AtomsRepository,
|
||||
UsersService,
|
||||
SchedulesRepository_2024_06_11,
|
||||
RedisService,
|
||||
],
|
||||
exports: [EventTypesAtomService],
|
||||
controllers: [AtomsController],
|
||||
|
||||
@@ -5,8 +5,15 @@ import {
|
||||
} from "@/modules/atoms/inputs/event-types-app.input";
|
||||
import { ConferencingAtomsService } from "@/modules/atoms/services/conferencing-atom.service";
|
||||
import { EventTypesAtomService } from "@/modules/atoms/services/event-types-atom.service";
|
||||
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
||||
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
|
||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import {
|
||||
Controller,
|
||||
@@ -60,14 +67,27 @@ export class AtomsController {
|
||||
};
|
||||
}
|
||||
|
||||
@Get("/organizations/:orgId/teams/:teamId/event-types")
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@Version(VERSION_NEUTRAL)
|
||||
async listTeamEventTypes(@Param("teamId", ParseIntPipe) teamId: number): Promise<ApiResponse<unknown>> {
|
||||
const eventTypes = await this.eventTypesService.getTeamEventTypes(teamId);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: eventTypes,
|
||||
};
|
||||
}
|
||||
|
||||
@Get("/event-types")
|
||||
@Version(VERSION_NEUTRAL)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
async getAtomEventTypes(@GetUser("id") userId: number): Promise<ApiResponse<unknown>> {
|
||||
const eventType = await this.eventTypesService.getUserEventTypes(userId);
|
||||
async listUserEventTypes(@GetUser("id") userId: number): Promise<ApiResponse<unknown>> {
|
||||
const eventTypes = await this.eventTypesService.getUserEventTypes(userId);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: eventType,
|
||||
data: eventTypes,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -116,6 +136,22 @@ export class AtomsController {
|
||||
};
|
||||
}
|
||||
|
||||
@Patch("/organizations/:orgId/teams/:teamId/event-types/bulk-update-to-default-location")
|
||||
@Version(VERSION_NEUTRAL)
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
async bulkUpdateAtomTeamEventTypes(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Body() body: BulkUpdateEventTypeToDefaultLocationDto,
|
||||
@Param("teamId", ParseIntPipe) teamId: number
|
||||
): Promise<{ status: typeof SUCCESS_STATUS | typeof ERROR_STATUS }> {
|
||||
await this.eventTypesService.bulkUpdateTeamEventTypesDefaultLocation(body.eventTypeIds, teamId);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
};
|
||||
}
|
||||
|
||||
@Patch("event-types/:eventTypeId")
|
||||
@Version(VERSION_NEUTRAL)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@@ -135,9 +171,11 @@ export class AtomsController {
|
||||
};
|
||||
}
|
||||
|
||||
@Patch("/organizations/:organizationId/teams/:teamId/event-types/:eventTypeId")
|
||||
@Patch("/organizations/:orgId/teams/:teamId/event-types/:eventTypeId")
|
||||
@Version(VERSION_NEUTRAL)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
async updateAtomTeamEventType(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
|
||||
@@ -155,13 +193,33 @@ export class AtomsController {
|
||||
data: eventType,
|
||||
};
|
||||
}
|
||||
@Get("/organizations/:orgId/teams/:teamId/conferencing")
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@Version(VERSION_NEUTRAL)
|
||||
async listTeamInstalledConferencingApps(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Param("teamId", ParseIntPipe) teamId: number
|
||||
): Promise<ApiResponse<ConnectedApps>> {
|
||||
const conferencingApps = await this.conferencingService.getTeamConferencingApps(user, teamId);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: conferencingApps,
|
||||
};
|
||||
}
|
||||
|
||||
@Get("/conferencing")
|
||||
@Version(VERSION_NEUTRAL)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
async listInstalledConferencingApps(@GetUser() user: UserWithProfile): Promise<ApiResponse<ConnectedApps>> {
|
||||
const conferencingApps = await this.conferencingService.getConferencingApps(user);
|
||||
|
||||
return { status: SUCCESS_STATUS, data: conferencingApps };
|
||||
async listUserInstalledConferencingApps(
|
||||
@GetUser() user: UserWithProfile
|
||||
): Promise<ApiResponse<ConnectedApps>> {
|
||||
const conferencingApps = await this.conferencingService.getUserConferencingApps(user);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: conferencingApps,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ export class ConferencingAtomsService {
|
||||
|
||||
constructor(private readonly dbWrite: PrismaWriteService) {}
|
||||
|
||||
async getConferencingApps(user: UserWithProfile): Promise<ConnectedApps> {
|
||||
async getUserConferencingApps(user: UserWithProfile): Promise<ConnectedApps> {
|
||||
return getConnectedApps({
|
||||
user,
|
||||
input: {
|
||||
@@ -22,4 +22,17 @@ export class ConferencingAtomsService {
|
||||
prisma: this.dbWrite.prisma as unknown as PrismaClient,
|
||||
});
|
||||
}
|
||||
|
||||
async getTeamConferencingApps(user: UserWithProfile, teamId: number): Promise<ConnectedApps> {
|
||||
return getConnectedApps({
|
||||
user,
|
||||
input: {
|
||||
variant: "conferencing",
|
||||
onlyInstalled: true,
|
||||
teamId,
|
||||
includeTeamInstalledApps: true,
|
||||
},
|
||||
prisma: this.dbWrite.prisma as unknown as PrismaClient,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,9 +22,11 @@ import type {
|
||||
LocationOption,
|
||||
} from "@calcom/platform-libraries/app-store";
|
||||
import {
|
||||
getBulkEventTypes,
|
||||
getEventTypeById,
|
||||
bulkUpdateEventsToDefaultLocation,
|
||||
bulkUpdateTeamEventsToDefaultLocation,
|
||||
getBulkUserEventTypes,
|
||||
getBulkTeamEventTypes,
|
||||
} from "@calcom/platform-libraries/event-types";
|
||||
import {
|
||||
updateEventType,
|
||||
@@ -83,7 +85,11 @@ export class EventTypesAtomService {
|
||||
}
|
||||
|
||||
async getUserEventTypes(userId: number) {
|
||||
return getBulkEventTypes(userId);
|
||||
return getBulkUserEventTypes(userId);
|
||||
}
|
||||
|
||||
async getTeamEventTypes(teamId: number) {
|
||||
return getBulkTeamEventTypes(teamId);
|
||||
}
|
||||
|
||||
async updateTeamEventType(
|
||||
@@ -345,4 +351,12 @@ export class EventTypesAtomService {
|
||||
prisma: this.dbWrite.prisma as unknown as PrismaClient,
|
||||
});
|
||||
}
|
||||
|
||||
async bulkUpdateTeamEventTypesDefaultLocation(eventTypeIds: number[], teamId: number) {
|
||||
return bulkUpdateTeamEventsToDefaultLocation({
|
||||
eventTypeIds,
|
||||
prisma: this.dbWrite.prisma as unknown as PrismaClient,
|
||||
teamId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import { ApiKeysModule } from "@/modules/api-keys/api-keys.module";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
||||
import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard";
|
||||
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
|
||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
|
||||
import { ApiAuthStrategy } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
||||
import { NextAuthStrategy } from "@/modules/auth/strategies/next-auth/next-auth.strategy";
|
||||
import { DeploymentsModule } from "@/modules/deployments/deployments.module";
|
||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
|
||||
import { ProfilesModule } from "@/modules/profiles/profiles.module";
|
||||
import { RedisModule } from "@/modules/redis/redis.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { Module } from "@nestjs/common";
|
||||
import { forwardRef, Module } from "@nestjs/common";
|
||||
import { PassportModule } from "@nestjs/passport";
|
||||
|
||||
@Module({
|
||||
@@ -23,8 +29,28 @@ import { PassportModule } from "@nestjs/passport";
|
||||
TokensModule,
|
||||
DeploymentsModule,
|
||||
ProfilesModule,
|
||||
forwardRef(() => OrganizationsModule),
|
||||
],
|
||||
providers: [
|
||||
NextAuthGuard,
|
||||
NextAuthStrategy,
|
||||
ApiAuthGuard,
|
||||
ApiAuthStrategy,
|
||||
OAuthFlowService,
|
||||
IsTeamInOrg,
|
||||
RolesGuard,
|
||||
IsOrgGuard,
|
||||
IsAdminAPIEnabledGuard,
|
||||
PlatformPlanGuard,
|
||||
],
|
||||
exports: [
|
||||
NextAuthGuard,
|
||||
ApiAuthGuard,
|
||||
IsTeamInOrg,
|
||||
RolesGuard,
|
||||
IsOrgGuard,
|
||||
IsAdminAPIEnabledGuard,
|
||||
PlatformPlanGuard,
|
||||
],
|
||||
providers: [NextAuthGuard, NextAuthStrategy, ApiAuthGuard, ApiAuthStrategy, OAuthFlowService],
|
||||
exports: [NextAuthGuard, ApiAuthGuard],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@@ -21,7 +21,21 @@ export class PlatformPlanGuard implements CanActivate {
|
||||
const orgId = request.params.orgId as string;
|
||||
const user = request.user as ApiAuthGuardUser;
|
||||
const minimumPlan = this.reflector.get(PlatformPlan, context.getHandler()) as PlatformPlanType;
|
||||
const { canAccess } = await this.checkPlatformPlanAccess({ teamId, orgId, user, minimumPlan });
|
||||
return canAccess;
|
||||
}
|
||||
|
||||
async checkPlatformPlanAccess({
|
||||
teamId,
|
||||
orgId,
|
||||
user,
|
||||
minimumPlan,
|
||||
}: {
|
||||
teamId?: string;
|
||||
orgId?: string;
|
||||
user: ApiAuthGuardUser;
|
||||
minimumPlan: PlatformPlanType;
|
||||
}): Promise<{ canAccess: boolean }> {
|
||||
const REDIS_CACHE_KEY = `apiv2:user:${user?.id ?? "none"}:org:${orgId ?? "none"}:team:${
|
||||
teamId ?? "none"
|
||||
}:guard:platformbilling:${minimumPlan}`;
|
||||
@@ -29,7 +43,7 @@ export class PlatformPlanGuard implements CanActivate {
|
||||
const cachedAccess = JSON.parse((await this.redisService.redis.get(REDIS_CACHE_KEY)) ?? "false");
|
||||
|
||||
if (cachedAccess) {
|
||||
return cachedAccess;
|
||||
return { canAccess: cachedAccess };
|
||||
}
|
||||
|
||||
let canAccess = false;
|
||||
@@ -56,7 +70,7 @@ export class PlatformPlanGuard implements CanActivate {
|
||||
|
||||
await this.redisService.redis.set(REDIS_CACHE_KEY, String(canAccess), "EX", 300);
|
||||
if (canAccess) {
|
||||
return canAccess;
|
||||
return { canAccess };
|
||||
}
|
||||
throw new ForbiddenException(
|
||||
`Platform plan - you do not have required plan for this operation. Minimum plan is ${minimumPlan}.`
|
||||
|
||||
@@ -18,22 +18,34 @@ export class IsAdminAPIEnabledGuard implements CanActivate {
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
let canAccess = false;
|
||||
const request = context.switchToHttp().getRequest<Request & { organization: Team }>();
|
||||
const request = context.switchToHttp().getRequest<Request & { organization?: Team }>();
|
||||
const organizationId: string = request.params.orgId;
|
||||
|
||||
if (!organizationId) {
|
||||
throw new ForbiddenException("No organization id found in request params.");
|
||||
}
|
||||
|
||||
const { canAccess, organization } = await this.checkAdminAPIEnabled(organizationId);
|
||||
if (organization) {
|
||||
request.organization = organization;
|
||||
}
|
||||
return canAccess;
|
||||
}
|
||||
|
||||
async checkAdminAPIEnabled(
|
||||
organizationId: string
|
||||
): Promise<{ canAccess: boolean; organization?: Team | null }> {
|
||||
let canAccess = false;
|
||||
const REDIS_CACHE_KEY = `apiv2:org:${organizationId}:guard:isAdminAccess`;
|
||||
const cachedData = await this.redisService.redis.get(REDIS_CACHE_KEY);
|
||||
|
||||
if (cachedData) {
|
||||
const { org: cachedOrg, canAccess: cachedCanAccess } = JSON.parse(cachedData) as CachedData;
|
||||
if (cachedOrg?.id === Number(organizationId) && cachedCanAccess !== undefined) {
|
||||
request.organization = cachedOrg;
|
||||
return cachedCanAccess;
|
||||
return {
|
||||
canAccess: cachedCanAccess,
|
||||
organization: cachedOrg,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +69,6 @@ export class IsAdminAPIEnabledGuard implements CanActivate {
|
||||
"EX",
|
||||
300
|
||||
));
|
||||
return canAccess;
|
||||
return { canAccess, organization: org };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ export class IsOrgGuard implements CanActivate {
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
let canAccess = false;
|
||||
const request = context.switchToHttp().getRequest<Request & { organization: Team }>();
|
||||
const organizationId: string = request.params.orgId;
|
||||
|
||||
@@ -26,33 +25,45 @@ export class IsOrgGuard implements CanActivate {
|
||||
throw new ForbiddenException("No organization id found in request params.");
|
||||
}
|
||||
|
||||
const { canAccess, org } = await this.checkOrgAccess(organizationId);
|
||||
|
||||
if (canAccess && org) {
|
||||
request.organization = org;
|
||||
}
|
||||
|
||||
return canAccess;
|
||||
}
|
||||
|
||||
async checkOrgAccess(organizationId: string): Promise<{ canAccess: boolean; org?: Team | null }> {
|
||||
const REDIS_CACHE_KEY = `apiv2:org:${organizationId}:guard:isOrg`;
|
||||
let canAccess = false;
|
||||
const cachedData = await this.redisService.redis.get(REDIS_CACHE_KEY);
|
||||
|
||||
if (cachedData) {
|
||||
const { org: cachedOrg, canAccess: cachedCanAccess } = JSON.parse(cachedData) as CachedData;
|
||||
if (cachedOrg?.id === Number(organizationId) && cachedCanAccess !== undefined) {
|
||||
request.organization = cachedOrg;
|
||||
return cachedCanAccess;
|
||||
return {
|
||||
org: cachedOrg,
|
||||
canAccess: cachedCanAccess,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const org = await this.organizationsRepository.findById(Number(organizationId));
|
||||
|
||||
if (org?.isOrganization) {
|
||||
request.organization = org;
|
||||
canAccess = true;
|
||||
}
|
||||
|
||||
if (org) {
|
||||
await this.redisService.redis.set(
|
||||
REDIS_CACHE_KEY,
|
||||
JSON.stringify({ org: org, canAccess } satisfies CachedData),
|
||||
JSON.stringify({ org, canAccess } satisfies CachedData),
|
||||
"EX",
|
||||
300
|
||||
);
|
||||
}
|
||||
|
||||
return canAccess;
|
||||
return { canAccess, org };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,13 +24,22 @@ export class RolesGuard implements CanActivate {
|
||||
const orgId = request.params.orgId as string;
|
||||
const user = request.user as ApiAuthGuardUser;
|
||||
const allowedRole = this.reflector.get(Roles, context.getHandler());
|
||||
const { canAccess } = await this.checkUserRoleAccess(user, orgId, teamId, allowedRole);
|
||||
return canAccess;
|
||||
}
|
||||
async checkUserRoleAccess(
|
||||
user: ApiAuthGuardUser,
|
||||
orgId: string,
|
||||
teamId: string,
|
||||
allowedRole: string
|
||||
): Promise<{ canAccess: boolean }> {
|
||||
const REDIS_CACHE_KEY = `apiv2:user:${user.id ?? "none"}:org:${orgId ?? "none"}:team:${
|
||||
teamId ?? "none"
|
||||
}:guard:roles:${allowedRole}`;
|
||||
const cachedAccess = JSON.parse((await this.redisService.redis.get(REDIS_CACHE_KEY)) ?? "false");
|
||||
|
||||
if (cachedAccess) {
|
||||
return cachedAccess;
|
||||
return { canAccess: cachedAccess };
|
||||
}
|
||||
|
||||
let canAccess = false;
|
||||
@@ -130,7 +139,7 @@ export class RolesGuard implements CanActivate {
|
||||
}
|
||||
}
|
||||
await this.redisService.redis.set(REDIS_CACHE_KEY, String(canAccess), "EX", 300);
|
||||
return canAccess;
|
||||
return { canAccess };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ export class IsTeamInOrg implements CanActivate {
|
||||
constructor(private organizationsTeamsRepository: OrganizationsTeamsRepository) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<Request & { team: Team }>();
|
||||
const request = context.switchToHttp().getRequest<Request & { team?: Team }>();
|
||||
const teamId: string = request.params.teamId;
|
||||
const orgId: string = request.params.orgId;
|
||||
|
||||
@@ -27,17 +27,25 @@ export class IsTeamInOrg implements CanActivate {
|
||||
throw new ForbiddenException("No team id found in request params.");
|
||||
}
|
||||
|
||||
const team = await this.organizationsTeamsRepository.findOrgTeam(Number(orgId), Number(teamId));
|
||||
const { canAccess, team } = await this.checkIfTeamIsInOrg(orgId, teamId);
|
||||
|
||||
if (team && !team.isOrganization && team.parentId === Number(orgId)) {
|
||||
if (canAccess && team) {
|
||||
request.team = team;
|
||||
return true;
|
||||
}
|
||||
return canAccess;
|
||||
}
|
||||
|
||||
async checkIfTeamIsInOrg(orgId: string, teamId: string): Promise<{ canAccess: boolean; team?: Team }> {
|
||||
const team = await this.organizationsTeamsRepository.findOrgTeam(Number(orgId), Number(teamId));
|
||||
|
||||
if (!team) {
|
||||
throw new NotFoundException(`Team (${teamId}) not found.`);
|
||||
}
|
||||
|
||||
return false;
|
||||
if (!team.isOrganization && team.parentId === Number(orgId)) {
|
||||
return { canAccess: true, team };
|
||||
}
|
||||
|
||||
return { canAccess: false };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,14 +6,15 @@ import { GoogleMeetService } from "@/modules/conferencing/services/google-meet.s
|
||||
import { Office365VideoService } from "@/modules/conferencing/services/office365-video.service";
|
||||
import { ZoomVideoService } from "@/modules/conferencing/services/zoom-video.service";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { Module } from "@nestjs/common";
|
||||
import { forwardRef, Module } from "@nestjs/common";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, ConfigModule],
|
||||
imports: [PrismaModule, ConfigModule, forwardRef(() => OrganizationsModule)],
|
||||
providers: [
|
||||
ConferencingService,
|
||||
ConferencingRepository,
|
||||
@@ -25,7 +26,13 @@ import { ConfigModule } from "@nestjs/config";
|
||||
Office365VideoService,
|
||||
AppsRepository,
|
||||
],
|
||||
exports: [],
|
||||
exports: [
|
||||
ZoomVideoService,
|
||||
Office365VideoService,
|
||||
GoogleMeetService,
|
||||
ConferencingService,
|
||||
ConferencingRepository,
|
||||
],
|
||||
controllers: [ConferencingController],
|
||||
})
|
||||
export class ConferencingModule {}
|
||||
|
||||
@@ -15,18 +15,16 @@ import {
|
||||
import { GetDefaultConferencingAppOutputResponseDto } from "@/modules/conferencing/outputs/get-default-conferencing-app.output";
|
||||
import { SetDefaultConferencingAppOutputResponseDto } from "@/modules/conferencing/outputs/set-default-conferencing-app.output";
|
||||
import { ConferencingService } from "@/modules/conferencing/services/conferencing.service";
|
||||
import { GoogleMeetService } from "@/modules/conferencing/services/google-meet.service";
|
||||
import { Office365VideoService } from "@/modules/conferencing/services/office365-video.service";
|
||||
import { ZoomVideoService } from "@/modules/conferencing/services/zoom-video.service";
|
||||
import { OrganizationsConferencingService } from "@/modules/organizations/conferencing/services/organizations-conferencing.service";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { Logger } from "@nestjs/common";
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Query,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
UseGuards,
|
||||
Post,
|
||||
Param,
|
||||
@@ -46,7 +44,8 @@ import { GOOGLE_MEET, ZOOM, SUCCESS_STATUS, OFFICE_365_VIDEO, CAL_VIDEO } from "
|
||||
|
||||
export type OAuthCallbackState = {
|
||||
accessToken: string;
|
||||
teamId?: number;
|
||||
teamId?: string;
|
||||
orgId?: string;
|
||||
fromApp?: boolean;
|
||||
returnTo?: string;
|
||||
onErrorReturnTo?: string;
|
||||
@@ -63,9 +62,7 @@ export class ConferencingController {
|
||||
constructor(
|
||||
private readonly tokensRepository: TokensRepository,
|
||||
private readonly conferencingService: ConferencingService,
|
||||
private readonly googleMeetService: GoogleMeetService,
|
||||
private readonly zoomVideoService: ZoomVideoService,
|
||||
private readonly office365VideoService: Office365VideoService
|
||||
private readonly organizationsConferencingService: OrganizationsConferencingService
|
||||
) {}
|
||||
|
||||
@Post("/:app/connect")
|
||||
@@ -80,21 +77,11 @@ export class ConferencingController {
|
||||
required: true,
|
||||
})
|
||||
async connect(
|
||||
@GetUser("id") userId: number,
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Param("app") app: string
|
||||
): Promise<ConferencingAppOutputResponseDto> {
|
||||
switch (app) {
|
||||
case GOOGLE_MEET:
|
||||
const credential = await this.googleMeetService.connectGoogleMeetApp(userId);
|
||||
|
||||
return { status: SUCCESS_STATUS, data: plainToInstance(ConferencingAppsOutputDto, credential) };
|
||||
|
||||
default:
|
||||
throw new BadRequestException(
|
||||
"Invalid conferencing app, available apps are: ",
|
||||
[GOOGLE_MEET].join(", ")
|
||||
);
|
||||
}
|
||||
const credential = await this.conferencingService.connectUserNonOauthApp(app, user.id);
|
||||
return { status: SUCCESS_STATUS, data: plainToInstance(ConferencingAppsOutputDto, credential) };
|
||||
}
|
||||
|
||||
@Get("/:app/oauth/auth-url")
|
||||
@@ -115,7 +102,6 @@ export class ConferencingController {
|
||||
@Query("returnTo") returnTo?: string,
|
||||
@Query("onErrorReturnTo") onErrorReturnTo?: string
|
||||
): Promise<GetConferencingAppsOauthUrlResponseDto> {
|
||||
let credential;
|
||||
const origin = req.headers.origin;
|
||||
const accessToken = authorization.replace("Bearer ", "");
|
||||
|
||||
@@ -126,27 +112,12 @@ export class ConferencingController {
|
||||
accessToken,
|
||||
};
|
||||
|
||||
switch (app) {
|
||||
case ZOOM:
|
||||
credential = await this.zoomVideoService.generateZoomAuthUrl(JSON.stringify(state));
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToInstance(ConferencingAppsOauthUrlOutputDto, credential),
|
||||
};
|
||||
const credential = await this.conferencingService.generateOAuthUrl(app, state);
|
||||
|
||||
case OFFICE_365_VIDEO:
|
||||
credential = await this.office365VideoService.generateOffice365AuthUrl(JSON.stringify(state));
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToInstance(ConferencingAppsOauthUrlOutputDto, credential),
|
||||
};
|
||||
|
||||
default:
|
||||
throw new BadRequestException(
|
||||
"Invalid conferencing app, available apps are: ",
|
||||
[ZOOM, OFFICE_365_VIDEO].join(", ")
|
||||
);
|
||||
}
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToInstance(ConferencingAppsOauthUrlOutputDto, credential),
|
||||
};
|
||||
}
|
||||
|
||||
@Get("/:app/oauth/callback")
|
||||
@@ -176,23 +147,18 @@ export class ConferencingController {
|
||||
if (error) {
|
||||
throw new BadRequestException(error_description);
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
throw new UnauthorizedException("Invalid Access token.");
|
||||
}
|
||||
|
||||
switch (app) {
|
||||
case ZOOM:
|
||||
return await this.zoomVideoService.connectZoomApp(decodedCallbackState, code, userId);
|
||||
|
||||
case OFFICE_365_VIDEO:
|
||||
return await this.office365VideoService.connectOffice365App(decodedCallbackState, code, userId);
|
||||
|
||||
default:
|
||||
throw new BadRequestException(
|
||||
"Invalid conferencing app, available apps are: ",
|
||||
[ZOOM, OFFICE_365_VIDEO].join(", ")
|
||||
);
|
||||
if (decodedCallbackState.orgId) {
|
||||
return this.organizationsConferencingService.connectTeamOauthApps({
|
||||
app,
|
||||
code,
|
||||
userId,
|
||||
decodedCallbackState,
|
||||
});
|
||||
} else {
|
||||
return this.conferencingService.connectOauthApps(app, code, userId, decodedCallbackState);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException || error instanceof Error) {
|
||||
@@ -210,15 +176,13 @@ export class ConferencingController {
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({ summary: "List your conferencing applications" })
|
||||
async listInstalledConferencingApps(
|
||||
@GetUser("id") userId: number
|
||||
@GetUser() user: UserWithProfile
|
||||
): Promise<ConferencingAppsOutputResponseDto> {
|
||||
const conferencingApps = await this.conferencingService.getConferencingApps(userId);
|
||||
|
||||
const data = conferencingApps.map((conferencingApps) =>
|
||||
plainToInstance(ConferencingAppsOutputDto, conferencingApps)
|
||||
);
|
||||
|
||||
return { status: SUCCESS_STATUS, data };
|
||||
const conferencingApps = await this.conferencingService.getConferencingApps(user.id);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: conferencingApps.map((app) => plainToInstance(ConferencingAppsOutputDto, app)),
|
||||
};
|
||||
}
|
||||
|
||||
@Post("/:app/default")
|
||||
@@ -245,8 +209,8 @@ export class ConferencingController {
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({ summary: "Get your default conferencing application" })
|
||||
async getDefault(@GetUser("id") userId: number): Promise<GetDefaultConferencingAppOutputResponseDto> {
|
||||
const defaultconferencingApp = await this.conferencingService.getUserDefaultConferencingApp(userId);
|
||||
async getDefault(@GetUser() user: UserWithProfile): Promise<GetDefaultConferencingAppOutputResponseDto> {
|
||||
const defaultconferencingApp = await this.conferencingService.getUserDefaultConferencingApp(user.id);
|
||||
return { status: SUCCESS_STATUS, data: defaultconferencingApp };
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,15 @@ export class ConferencingRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async findTeamConferencingApps(teamId: number) {
|
||||
return this.dbRead.prisma.credential.findMany({
|
||||
where: {
|
||||
teamId,
|
||||
type: { endsWith: "_video" },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findGoogleMeet(userId: number) {
|
||||
return this.dbRead.prisma.credential.findFirst({
|
||||
where: { userId, type: GOOGLE_MEET_TYPE },
|
||||
@@ -28,4 +37,10 @@ export class ConferencingRepository {
|
||||
where: { userId, appId: app },
|
||||
});
|
||||
}
|
||||
|
||||
async findTeamConferencingApp(teamId: number, app: string) {
|
||||
return this.dbRead.prisma.credential.findFirst({
|
||||
where: { teamId, appId: app },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
import { OAuthCallbackState } from "@/modules/conferencing/controllers/conferencing.controller";
|
||||
import { ConferencingRepository } from "@/modules/conferencing/repositories/conferencing.repository";
|
||||
import { GoogleMeetService } from "@/modules/conferencing/services/google-meet.service";
|
||||
import { Office365VideoService } from "@/modules/conferencing/services/office365-video.service";
|
||||
import { ZoomVideoService } from "@/modules/conferencing/services/zoom-video.service";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { BadRequestException, InternalServerErrorException, Logger } from "@nestjs/common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { CONFERENCING_APPS, CAL_VIDEO } from "@calcom/platform-constants";
|
||||
import {
|
||||
CONFERENCING_APPS,
|
||||
CAL_VIDEO,
|
||||
GOOGLE_MEET,
|
||||
ZOOM,
|
||||
OFFICE_365_VIDEO,
|
||||
} from "@calcom/platform-constants";
|
||||
import { userMetadata, getUsersCredentials } from "@calcom/platform-libraries";
|
||||
import { getApps, handleDeleteCredential } from "@calcom/platform-libraries/app-store";
|
||||
|
||||
@@ -14,13 +24,53 @@ export class ConferencingService {
|
||||
|
||||
constructor(
|
||||
private readonly conferencingRepository: ConferencingRepository,
|
||||
private readonly usersRepository: UsersRepository
|
||||
private readonly usersRepository: UsersRepository,
|
||||
private readonly googleMeetService: GoogleMeetService,
|
||||
private readonly zoomVideoService: ZoomVideoService,
|
||||
private readonly office365VideoService: Office365VideoService
|
||||
) {}
|
||||
|
||||
async getConferencingApps(userId: number) {
|
||||
return this.conferencingRepository.findConferencingApps(userId);
|
||||
}
|
||||
|
||||
async connectUserNonOauthApp(app: string, userId: number) {
|
||||
switch (app) {
|
||||
case GOOGLE_MEET:
|
||||
const credential = await this.googleMeetService.connectGoogleMeetToUser(userId);
|
||||
return credential;
|
||||
default:
|
||||
throw new BadRequestException("Invalid conferencing app. Available apps: GOOGLE_MEET.");
|
||||
}
|
||||
}
|
||||
|
||||
async connectOauthApps(
|
||||
app: string,
|
||||
code: string,
|
||||
userId: number,
|
||||
decodedCallbackState: OAuthCallbackState,
|
||||
teamId?: number
|
||||
) {
|
||||
switch (app) {
|
||||
case ZOOM:
|
||||
return await this.zoomVideoService.connectZoomApp(decodedCallbackState, code, userId, teamId);
|
||||
|
||||
case OFFICE_365_VIDEO:
|
||||
return await this.office365VideoService.connectOffice365App(
|
||||
decodedCallbackState,
|
||||
code,
|
||||
userId,
|
||||
teamId
|
||||
);
|
||||
|
||||
default:
|
||||
throw new BadRequestException(
|
||||
"Invalid conferencing app, available apps are: ",
|
||||
[ZOOM, OFFICE_365_VIDEO].join(", ")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async getUserDefaultConferencingApp(userId: number) {
|
||||
const user = await this.usersRepository.findById(userId);
|
||||
return userMetadata.parse(user?.metadata)?.defaultConferencingApp;
|
||||
@@ -63,4 +113,20 @@ export class ConferencingService {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async generateOAuthUrl(app: string, state: OAuthCallbackState) {
|
||||
switch (app) {
|
||||
case ZOOM:
|
||||
return await this.zoomVideoService.generateZoomAuthUrl(JSON.stringify(state));
|
||||
|
||||
case OFFICE_365_VIDEO:
|
||||
return await this.office365VideoService.generateOffice365AuthUrl(JSON.stringify(state));
|
||||
|
||||
default:
|
||||
throw new BadRequestException(
|
||||
"Invalid conferencing app, available apps are: ",
|
||||
[ZOOM, OFFICE_365_VIDEO].join(", ")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { ConferencingRepository } from "@/modules/conferencing/repositories/conferencing.repository";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { BadRequestException, InternalServerErrorException, Logger } from "@nestjs/common";
|
||||
import { BadRequestException, Logger } from "@nestjs/common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { GOOGLE_CALENDAR_TYPE, GOOGLE_MEET_TYPE, GOOGLE_MEET } from "@calcom/platform-constants";
|
||||
import { GOOGLE_CALENDAR_TYPE, GOOGLE_MEET_TYPE } from "@calcom/platform-constants";
|
||||
|
||||
@Injectable()
|
||||
export class GoogleMeetService {
|
||||
@@ -12,35 +11,51 @@ export class GoogleMeetService {
|
||||
|
||||
constructor(
|
||||
private readonly conferencingRepository: ConferencingRepository,
|
||||
private readonly credentialsRepository: CredentialsRepository,
|
||||
private readonly usersRepository: UsersRepository
|
||||
private readonly credentialsRepository: CredentialsRepository
|
||||
) {}
|
||||
|
||||
async connectGoogleMeetApp(userId: number) {
|
||||
const googleCalendar = await this.credentialsRepository.getByTypeAndUserId(GOOGLE_CALENDAR_TYPE, userId);
|
||||
async connectGoogleMeetToUser(userId: number) {
|
||||
await this.validateGoogleCalendarConnection(userId, "user");
|
||||
|
||||
const googleMeetExists = await this.conferencingRepository.findGoogleMeet(userId);
|
||||
if (googleMeetExists) {
|
||||
throw new BadRequestException("Google Meet is already connected for this user.");
|
||||
}
|
||||
|
||||
return this.credentialsRepository.upsertUserAppCredential(GOOGLE_MEET_TYPE, {}, userId);
|
||||
}
|
||||
|
||||
async connectGoogleMeetToTeam(teamId: number) {
|
||||
await this.validateGoogleCalendarConnection(teamId, "team");
|
||||
|
||||
const googleMeetExists = await this.credentialsRepository.findCredentialByTypeAndTeamId(
|
||||
GOOGLE_MEET_TYPE,
|
||||
teamId
|
||||
);
|
||||
if (googleMeetExists) {
|
||||
throw new BadRequestException("Google Meet is already connected for this team.");
|
||||
}
|
||||
|
||||
return this.credentialsRepository.upsertTeamAppCredential(GOOGLE_MEET_TYPE, {}, teamId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that Google Calendar is connected and valid for either a user or a team.
|
||||
*/
|
||||
private async validateGoogleCalendarConnection(id: number, entity: "user" | "team") {
|
||||
const googleCalendar =
|
||||
entity === "user"
|
||||
? await this.credentialsRepository.findCredentialByTypeAndUserId(GOOGLE_CALENDAR_TYPE, id)
|
||||
: await this.credentialsRepository.findCredentialByTypeAndTeamId(GOOGLE_CALENDAR_TYPE, id);
|
||||
|
||||
if (!googleCalendar) {
|
||||
throw new BadRequestException("Google Meet app requires a Google Calendar connection");
|
||||
throw new BadRequestException("Google Meet requires a Google Calendar connection.");
|
||||
}
|
||||
|
||||
if (googleCalendar.invalid) {
|
||||
throw new BadRequestException(
|
||||
"Google Meet app requires a valid Google Calendar connection, please reconnect Google Calendar."
|
||||
"Google Meet requires a valid Google Calendar connection. Please reconnect Google Calendar."
|
||||
);
|
||||
}
|
||||
|
||||
const googleMeet = await this.conferencingRepository.findGoogleMeet(userId);
|
||||
|
||||
if (googleMeet) {
|
||||
throw new BadRequestException("Google Meet is already connected.");
|
||||
}
|
||||
|
||||
const googleMeetCredential = await this.credentialsRepository.upsertAppCredential(
|
||||
GOOGLE_MEET_TYPE,
|
||||
{},
|
||||
userId
|
||||
);
|
||||
|
||||
return googleMeetCredential;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
import { OAuthCallbackState } from "@/modules/conferencing/controllers/conferencing.controller";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { BadRequestException, Logger, NotFoundException } from "@nestjs/common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
@@ -21,7 +22,11 @@ export class Office365VideoService {
|
||||
private redirectUri = `${this.config.get("api.url")}/conferencing/${OFFICE_365_VIDEO}/oauth/callback`;
|
||||
private scopes = ["OnlineMeetings.ReadWrite", "offline_access"];
|
||||
|
||||
constructor(private readonly config: ConfigService, private readonly appsRepository: AppsRepository) {}
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly appsRepository: AppsRepository,
|
||||
private readonly credentialsRepository: CredentialsRepository
|
||||
) {}
|
||||
|
||||
async getOffice365AppKeys() {
|
||||
const app = await this.appsRepository.getAppBySlug(OFFICE_365_VIDEO);
|
||||
@@ -56,7 +61,7 @@ export class Office365VideoService {
|
||||
return { url };
|
||||
}
|
||||
|
||||
async connectOffice365App(state: OAuthCallbackState, code: string, userId: number) {
|
||||
async connectOffice365App(state: OAuthCallbackState, code: string, userId: number, teamId?: number) {
|
||||
const { client_id, client_secret } = await this.getOffice365AppKeys();
|
||||
|
||||
const toUrlEncoded = (payload: Record<string, string>) =>
|
||||
@@ -98,23 +103,30 @@ export class Office365VideoService {
|
||||
responseBody.expiry_date = Math.round(+new Date() / 1000 + responseBody.expires_in); // set expiry date in seconds
|
||||
delete responseBody.expires_in;
|
||||
|
||||
const existingCredentialOffice365Video = await this.appsRepository.findAppCredential({
|
||||
type: OFFICE_365_VIDEO_TYPE,
|
||||
userId,
|
||||
appId: OFFICE_365_VIDEO,
|
||||
});
|
||||
const existingCredentialOffice365Video = teamId
|
||||
? await this.credentialsRepository.findAllCredentialsByTypeAndTeamId(OFFICE_365_VIDEO_TYPE, teamId)
|
||||
: await this.credentialsRepository.findAllCredentialsByTypeAndUserId(OFFICE_365_VIDEO_TYPE, userId);
|
||||
|
||||
const credentialIdsToDelete = existingCredentialOffice365Video.map((item) => item.id);
|
||||
if (credentialIdsToDelete.length > 0) {
|
||||
await this.appsRepository.deleteAppCredentials(credentialIdsToDelete, userId);
|
||||
teamId
|
||||
? await this.appsRepository.deleteTeamAppCredentials(credentialIdsToDelete, teamId)
|
||||
: await this.appsRepository.deleteAppCredentials(credentialIdsToDelete, userId);
|
||||
}
|
||||
|
||||
await this.appsRepository.createAppCredential(
|
||||
OFFICE_365_VIDEO_TYPE,
|
||||
responseBody as unknown as Prisma.InputJsonObject,
|
||||
userId,
|
||||
OFFICE_365_VIDEO
|
||||
);
|
||||
teamId
|
||||
? await this.appsRepository.createTeamAppCredential(
|
||||
OFFICE_365_VIDEO_TYPE,
|
||||
responseBody as unknown as Prisma.InputJsonObject,
|
||||
teamId,
|
||||
OFFICE_365_VIDEO
|
||||
)
|
||||
: await this.appsRepository.createAppCredential(
|
||||
OFFICE_365_VIDEO_TYPE,
|
||||
responseBody as unknown as Prisma.InputJsonObject,
|
||||
userId,
|
||||
OFFICE_365_VIDEO
|
||||
);
|
||||
|
||||
return { url: state.returnTo ?? "" };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
import { OAuthCallbackState } from "@/modules/conferencing/controllers/conferencing.controller";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { BadRequestException, Logger, NotFoundException, UnauthorizedException } from "@nestjs/common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
@@ -20,7 +21,11 @@ export class ZoomVideoService {
|
||||
private logger = new Logger("ZoomVideoService");
|
||||
private redirectUri = `${this.config.get("api.url")}/conferencing/${ZOOM}/oauth/callback`;
|
||||
|
||||
constructor(private readonly config: ConfigService, private readonly appsRepository: AppsRepository) {}
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly appsRepository: AppsRepository,
|
||||
private readonly credentialsRepository: CredentialsRepository
|
||||
) {}
|
||||
|
||||
async getZoomAppKeys() {
|
||||
const app = await this.appsRepository.getAppBySlug(ZOOM);
|
||||
@@ -53,7 +58,7 @@ export class ZoomVideoService {
|
||||
return { url };
|
||||
}
|
||||
|
||||
async connectZoomApp(state: OAuthCallbackState, code: string, userId: number) {
|
||||
async connectZoomApp(state: OAuthCallbackState, code: string, userId: number, teamId?: number) {
|
||||
const { client_id, client_secret } = await this.getZoomAppKeys();
|
||||
const redirectUri = encodeURI(this.redirectUri);
|
||||
const authHeader = `Basic ${Buffer.from(`${client_id}:${client_secret}`).toString("base64")}`;
|
||||
@@ -90,23 +95,31 @@ export class ZoomVideoService {
|
||||
if (!userId) {
|
||||
throw new UnauthorizedException("Invalid Access token.");
|
||||
}
|
||||
const existingCredentialZoomVideo = await this.appsRepository.findAppCredential({
|
||||
type: ZOOM_TYPE,
|
||||
userId,
|
||||
appId: ZOOM,
|
||||
});
|
||||
|
||||
const existingCredentialZoomVideo = teamId
|
||||
? await this.credentialsRepository.findAllCredentialsByTypeAndTeamId(ZOOM_TYPE, teamId)
|
||||
: await this.credentialsRepository.findAllCredentialsByTypeAndUserId(ZOOM_TYPE, userId);
|
||||
|
||||
const credentialIdsToDelete = existingCredentialZoomVideo.map((item) => item.id);
|
||||
if (credentialIdsToDelete.length > 0) {
|
||||
await this.appsRepository.deleteAppCredentials(credentialIdsToDelete, userId);
|
||||
teamId
|
||||
? await this.appsRepository.deleteTeamAppCredentials(credentialIdsToDelete, teamId)
|
||||
: await this.appsRepository.deleteAppCredentials(credentialIdsToDelete, userId);
|
||||
}
|
||||
|
||||
await this.appsRepository.createAppCredential(
|
||||
ZOOM_TYPE,
|
||||
responseBody as unknown as Prisma.InputJsonObject,
|
||||
userId,
|
||||
ZOOM
|
||||
);
|
||||
teamId
|
||||
? await this.appsRepository.createTeamAppCredential(
|
||||
ZOOM_TYPE,
|
||||
responseBody as unknown as Prisma.InputJsonObject,
|
||||
teamId,
|
||||
ZOOM
|
||||
)
|
||||
: await this.appsRepository.createAppCredential(
|
||||
ZOOM_TYPE,
|
||||
responseBody as unknown as Prisma.InputJsonObject,
|
||||
userId,
|
||||
ZOOM
|
||||
);
|
||||
|
||||
return { url: state.returnTo ?? "" };
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { credentialForCalendarServiceSelect } from "@calcom/platform-libraries";
|
||||
export class CredentialsRepository {
|
||||
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||
|
||||
async upsertAppCredential(
|
||||
async upsertUserAppCredential(
|
||||
type: keyof typeof APPS_TYPE_ID_MAPPING,
|
||||
key: Prisma.InputJsonValue,
|
||||
userId: number,
|
||||
@@ -33,14 +33,45 @@ export class CredentialsRepository {
|
||||
});
|
||||
}
|
||||
|
||||
getByTypeAndUserId(type: string, userId: number) {
|
||||
async upsertTeamAppCredential(
|
||||
type: keyof typeof APPS_TYPE_ID_MAPPING,
|
||||
key: Prisma.InputJsonValue,
|
||||
teamId: number,
|
||||
credentialId?: number | null
|
||||
) {
|
||||
return this.dbWrite.prisma.credential.upsert({
|
||||
create: {
|
||||
type,
|
||||
key,
|
||||
teamId,
|
||||
appId: APPS_TYPE_ID_MAPPING[type],
|
||||
},
|
||||
update: {
|
||||
key,
|
||||
invalid: false,
|
||||
},
|
||||
where: {
|
||||
id: credentialId ?? 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
findCredentialByTypeAndUserId(type: string, userId: number) {
|
||||
return this.dbWrite.prisma.credential.findFirst({ where: { type, userId } });
|
||||
}
|
||||
|
||||
getByTypeAndTeamId(type: string, teamId: number) {
|
||||
findAllCredentialsByTypeAndUserId(type: string, userId: number) {
|
||||
return this.dbWrite.prisma.credential.findMany({ where: { type, userId } });
|
||||
}
|
||||
|
||||
findCredentialByTypeAndTeamId(type: string, teamId: number) {
|
||||
return this.dbWrite.prisma.credential.findFirst({ where: { type, teamId } });
|
||||
}
|
||||
|
||||
findAllCredentialsByTypeAndTeamId(type: string, teamId: number) {
|
||||
return this.dbWrite.prisma.credential.findMany({ where: { type, teamId } });
|
||||
}
|
||||
|
||||
getAllUserCredentialsByTypeAndId(type: string, userId: number) {
|
||||
return this.dbRead.prisma.credential.findMany({ where: { type, userId } });
|
||||
}
|
||||
|
||||
@@ -26,6 +26,10 @@ export class JwtService {
|
||||
// divided by 1000 because iat (issued at time) is in seconds (not milliseconds) as informed by JWT specification
|
||||
return Math.floor(Date.now() / 1000);
|
||||
}
|
||||
|
||||
decode(token: string): Payload {
|
||||
return this.nestJwtService.decode(token) as Payload;
|
||||
}
|
||||
}
|
||||
|
||||
type Payload = Record<string | number | symbol, any>;
|
||||
|
||||
+104
-6
@@ -7,11 +7,13 @@ import { Locales } from "@/lib/enums/locales";
|
||||
import { CreateManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/create-managed-user.output";
|
||||
import { GetManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-user.output";
|
||||
import { GetManagedUsersOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-users.output";
|
||||
import { KeysResponseDto } from "@/modules/oauth-clients/controllers/oauth-flow/responses/KeysResponse.dto";
|
||||
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
|
||||
import { CreateManagedUserInput } from "@/modules/users/inputs/create-managed-user.input";
|
||||
import { UpdateManagedUserInput } from "@/modules/users/inputs/update-managed-user.input";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import { PlatformOAuthClient, Team, User, EventType } from "@prisma/client";
|
||||
@@ -232,8 +234,28 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
const [emailUser, emailDomain] = responseBody.data.user.email.split("@");
|
||||
const [domainName, TLD] = emailDomain.split(".");
|
||||
expect(responseBody.data.user.username).toEqual(slugify(`${emailUser}-${domainName}-${TLD}`));
|
||||
expect(responseBody.data.accessToken).toBeDefined();
|
||||
expect(responseBody.data.refreshToken).toBeDefined();
|
||||
|
||||
const { accessToken, refreshToken, accessTokenExpiresAt, refreshTokenExpiresAt } = response.body.data;
|
||||
expect(accessToken).toBeDefined();
|
||||
expect(refreshToken).toBeDefined();
|
||||
expect(accessTokenExpiresAt).toBeDefined();
|
||||
expect(refreshTokenExpiresAt).toBeDefined();
|
||||
|
||||
const jwtService = app.get(JwtService);
|
||||
const decodedAccessToken = jwtService.decode(accessToken);
|
||||
const decodedRefreshToken = jwtService.decode(refreshToken);
|
||||
|
||||
expect(decodedAccessToken.clientId).toBe(oAuthClient.id);
|
||||
expect(decodedAccessToken.ownerId).toBeDefined();
|
||||
expect(decodedAccessToken.type).toBe("access_token");
|
||||
expect(decodedAccessToken.expiresAt).toBe(new Date(accessTokenExpiresAt).valueOf());
|
||||
expect(decodedAccessToken.iat).toBeGreaterThan(0);
|
||||
|
||||
expect(decodedRefreshToken.clientId).toBe(oAuthClient.id);
|
||||
expect(decodedRefreshToken.ownerId).toBeDefined();
|
||||
expect(decodedRefreshToken.type).toBe("refresh_token");
|
||||
expect(decodedRefreshToken.expiresAt).toBe(new Date(refreshTokenExpiresAt).valueOf());
|
||||
expect(decodedRefreshToken.iat).toBeGreaterThan(0);
|
||||
|
||||
await userConnectedToOAuth(oAuthClient.id, responseBody.data.user.email, 1);
|
||||
await userHasDefaultEventTypes(responseBody.data.user.id);
|
||||
@@ -273,8 +295,28 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
expect(responseBody.data.user.timeFormat).toEqual(requestBody.timeFormat);
|
||||
expect(responseBody.data.user.locale).toEqual(requestBody.locale);
|
||||
expect(responseBody.data.user.avatarUrl).toEqual(requestBody.avatarUrl);
|
||||
expect(responseBody.data.accessToken).toBeDefined();
|
||||
expect(responseBody.data.refreshToken).toBeDefined();
|
||||
|
||||
const { accessToken, refreshToken, accessTokenExpiresAt, refreshTokenExpiresAt } = response.body.data;
|
||||
expect(accessToken).toBeDefined();
|
||||
expect(refreshToken).toBeDefined();
|
||||
expect(accessTokenExpiresAt).toBeDefined();
|
||||
expect(refreshTokenExpiresAt).toBeDefined();
|
||||
|
||||
const jwtService = app.get(JwtService);
|
||||
const decodedAccessToken = jwtService.decode(accessToken);
|
||||
const decodedRefreshToken = jwtService.decode(refreshToken);
|
||||
|
||||
expect(decodedAccessToken.clientId).toBe(oAuthClient.id);
|
||||
expect(decodedAccessToken.ownerId).toBeDefined();
|
||||
expect(decodedAccessToken.type).toBe("access_token");
|
||||
expect(decodedAccessToken.expiresAt).toBe(new Date(accessTokenExpiresAt).valueOf());
|
||||
expect(decodedAccessToken.iat).toBeGreaterThan(0);
|
||||
|
||||
expect(decodedRefreshToken.clientId).toBe(oAuthClient.id);
|
||||
expect(decodedRefreshToken.ownerId).toBeDefined();
|
||||
expect(decodedRefreshToken.type).toBe("refresh_token");
|
||||
expect(decodedRefreshToken.expiresAt).toBe(new Date(refreshTokenExpiresAt).valueOf());
|
||||
expect(decodedRefreshToken.iat).toBeGreaterThan(0);
|
||||
|
||||
await userConnectedToOAuth(oAuthClient.id, responseBody.data.user.email, 2);
|
||||
await userHasDefaultEventTypes(responseBody.data.user.id);
|
||||
@@ -314,8 +356,28 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
expect(responseBody.data.user.timeFormat).toEqual(requestBody.timeFormat);
|
||||
expect(responseBody.data.user.locale).toEqual(requestBody.locale);
|
||||
expect(responseBody.data.user.avatarUrl).toEqual(requestBody.avatarUrl);
|
||||
expect(responseBody.data.accessToken).toBeDefined();
|
||||
expect(responseBody.data.refreshToken).toBeDefined();
|
||||
|
||||
const { accessToken, refreshToken, accessTokenExpiresAt, refreshTokenExpiresAt } = response.body.data;
|
||||
expect(accessToken).toBeDefined();
|
||||
expect(refreshToken).toBeDefined();
|
||||
expect(accessTokenExpiresAt).toBeDefined();
|
||||
expect(refreshTokenExpiresAt).toBeDefined();
|
||||
|
||||
const jwtService = app.get(JwtService);
|
||||
const decodedAccessToken = jwtService.decode(accessToken);
|
||||
const decodedRefreshToken = jwtService.decode(refreshToken);
|
||||
|
||||
expect(decodedAccessToken.clientId).toBe(oAuthClientEventTypesDisabled.id);
|
||||
expect(decodedAccessToken.ownerId).toBeDefined();
|
||||
expect(decodedAccessToken.type).toBe("access_token");
|
||||
expect(decodedAccessToken.expiresAt).toBe(new Date(accessTokenExpiresAt).valueOf());
|
||||
expect(decodedAccessToken.iat).toBeGreaterThan(0);
|
||||
|
||||
expect(decodedRefreshToken.clientId).toBe(oAuthClientEventTypesDisabled.id);
|
||||
expect(decodedRefreshToken.ownerId).toBeDefined();
|
||||
expect(decodedRefreshToken.type).toBe("refresh_token");
|
||||
expect(decodedRefreshToken.expiresAt).toBe(new Date(refreshTokenExpiresAt).valueOf());
|
||||
expect(decodedRefreshToken.iat).toBeGreaterThan(0);
|
||||
|
||||
await userConnectedToOAuth(oAuthClientEventTypesDisabled.id, responseBody.data.user.email, 1);
|
||||
await userDoesNotHaveDefaultEventTypes(responseBody.data.user.id);
|
||||
@@ -556,6 +618,42 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
expect(responseBody.data.locale).toEqual(Locales.PT_BR);
|
||||
});
|
||||
|
||||
it("should force refresh tokens", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/api/v2/oauth-clients/${oAuthClient.id}/users/${postResponseData.user.id}/force-refresh`)
|
||||
.set("x-cal-secret-key", oAuthClient.secret)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: KeysResponseDto = response.body;
|
||||
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data.accessToken).toBeDefined();
|
||||
expect(responseBody.data.accessTokenExpiresAt).toBeDefined();
|
||||
|
||||
const { accessToken, refreshToken, accessTokenExpiresAt, refreshTokenExpiresAt } = response.body.data;
|
||||
expect(accessToken).toBeDefined();
|
||||
expect(refreshToken).toBeDefined();
|
||||
expect(accessTokenExpiresAt).toBeDefined();
|
||||
expect(refreshTokenExpiresAt).toBeDefined();
|
||||
|
||||
const jwtService = app.get(JwtService);
|
||||
const decodedAccessToken = jwtService.decode(accessToken);
|
||||
const decodedRefreshToken = jwtService.decode(refreshToken);
|
||||
|
||||
expect(decodedAccessToken.clientId).toBe(oAuthClient.id);
|
||||
expect(decodedAccessToken.ownerId).toBe(postResponseData.user.id);
|
||||
expect(decodedAccessToken.type).toBe("access_token");
|
||||
expect(decodedAccessToken.expiresAt).toBe(new Date(accessTokenExpiresAt).valueOf());
|
||||
expect(decodedAccessToken.iat).toBeGreaterThan(0);
|
||||
|
||||
expect(decodedRefreshToken.clientId).toBe(oAuthClient.id);
|
||||
expect(decodedRefreshToken.ownerId).toBe(postResponseData.user.id);
|
||||
expect(decodedRefreshToken.type).toBe("refresh_token");
|
||||
expect(decodedRefreshToken.expiresAt).toBe(new Date(refreshTokenExpiresAt).valueOf());
|
||||
expect(decodedRefreshToken.iat).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it(`/DELETE/:id`, () => {
|
||||
return request(app.getHttpServer())
|
||||
.delete(`/api/v2/oauth-clients/${oAuthClient.id}/users/${postResponseData.user.id}`)
|
||||
|
||||
+8
-15
@@ -8,6 +8,7 @@ import { CreateManagedUserOutput } from "@/modules/oauth-clients/controllers/oau
|
||||
import { GetManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-user.output";
|
||||
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 { TOKENS_DOCS } from "@/modules/oauth-clients/controllers/oauth-flow/oauth-flow.controller";
|
||||
import { KeysResponseDto } from "@/modules/oauth-clients/controllers/oauth-flow/responses/KeysResponse.dto";
|
||||
import { OAuthClientGuard } from "@/modules/oauth-clients/guards/oauth-client-guard";
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
@@ -94,9 +95,7 @@ export class OAuthClientUsersController {
|
||||
status: SUCCESS_STATUS,
|
||||
data: {
|
||||
user: this.getResponseUser(user),
|
||||
accessToken: tokens.accessToken,
|
||||
accessTokenExpiresAt: tokens.accessTokenExpiresAt.valueOf(),
|
||||
refreshToken: tokens.refreshToken,
|
||||
...tokens,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -160,8 +159,7 @@ export class OAuthClientUsersController {
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: "Force refresh tokens",
|
||||
description: `If you have lost managed user access or refresh token, then you can get new ones by using OAuth credentials.
|
||||
Each access token is valid for 60 minutes and each refresh token for 1 year. Make sure to store them later in your database, for example, by updating the User model to have \`calAccessToken\` and \`calRefreshToken\` columns.`,
|
||||
description: `If you have lost managed user access or refresh token, then you can get new ones by using OAuth credentials. ${TOKENS_DOCS}`,
|
||||
})
|
||||
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER])
|
||||
async forceRefresh(
|
||||
@@ -170,21 +168,16 @@ export class OAuthClientUsersController {
|
||||
): Promise<KeysResponseDto> {
|
||||
this.logger.log(`Forcing new access tokens for managed user with ID ${userId}`);
|
||||
|
||||
const { id } = await this.validateManagedUserOwnership(oAuthClientId, userId);
|
||||
await this.validateManagedUserOwnership(oAuthClientId, userId);
|
||||
|
||||
const { accessToken, refreshToken, accessTokenExpiresAt } = await this.tokensRepository.createOAuthTokens(
|
||||
oAuthClientId,
|
||||
id,
|
||||
true
|
||||
const { accessToken, refreshToken } = await this.tokensRepository.refreshOAuthTokens(
|
||||
userId,
|
||||
oAuthClientId
|
||||
);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
accessTokenExpiresAt: accessTokenExpiresAt.valueOf(),
|
||||
},
|
||||
data: this.oAuthClientUsersService.getResponseOAuthTokens(accessToken, refreshToken),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+3
-11
@@ -1,26 +1,18 @@
|
||||
import { ManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/managed-user.output";
|
||||
import { KeysDto } from "@/modules/oauth-clients/controllers/oauth-flow/responses/KeysResponse.dto";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsEnum, IsNumber, IsString, ValidateNested } from "class-validator";
|
||||
import { IsEnum, ValidateNested } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class CreateManagedUserData {
|
||||
export class CreateManagedUserData extends KeysDto {
|
||||
@ApiProperty({
|
||||
type: ManagedUserOutput,
|
||||
})
|
||||
@ValidateNested()
|
||||
@Type(() => ManagedUserOutput)
|
||||
user!: ManagedUserOutput;
|
||||
|
||||
@IsString()
|
||||
accessToken!: string;
|
||||
|
||||
@IsString()
|
||||
refreshToken!: string;
|
||||
|
||||
@IsNumber()
|
||||
accessTokenExpiresAt!: number;
|
||||
}
|
||||
|
||||
export class CreateManagedUserOutput {
|
||||
|
||||
+52
-13
@@ -4,6 +4,7 @@ import { HttpExceptionFilter } from "@/filters/http-exception.filter";
|
||||
import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter";
|
||||
import { ZodExceptionFilter } from "@/filters/zod-exception.filter";
|
||||
import { AuthModule } from "@/modules/auth/auth.module";
|
||||
import { JwtService } from "@/modules/jwt/jwt.service";
|
||||
import { OAuthAuthorizeInput } from "@/modules/oauth-clients/inputs/authorize.input";
|
||||
import { ExchangeAuthorizationCodeInput } from "@/modules/oauth-clients/inputs/exchange-code.input";
|
||||
import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module";
|
||||
@@ -17,7 +18,6 @@ import * as request from "supertest";
|
||||
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
||||
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
|
||||
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { randomString } from "test/utils/randomString";
|
||||
import { withNextAuth } from "test/utils/withNextAuth";
|
||||
@@ -68,7 +68,7 @@ describe("OAuthFlow Endpoints", () => {
|
||||
let oAuthClient: PlatformOAuthClient;
|
||||
|
||||
let authorizationCode: string | null;
|
||||
let refreshToken: string;
|
||||
let responseRefreshToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const userEmail = `oauth-flow-user-${randomString()}@api.com`;
|
||||
@@ -153,29 +153,68 @@ describe("OAuthFlow Endpoints", () => {
|
||||
.send(body)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body?.data?.accessToken).toBeDefined();
|
||||
expect(response.body?.data?.refreshToken).toBeDefined();
|
||||
const { accessToken, refreshToken, accessTokenExpiresAt, refreshTokenExpiresAt } = response.body.data;
|
||||
expect(accessToken).toBeDefined();
|
||||
expect(refreshToken).toBeDefined();
|
||||
expect(accessTokenExpiresAt).toBeDefined();
|
||||
expect(refreshTokenExpiresAt).toBeDefined();
|
||||
|
||||
refreshToken = response.body.data.refreshToken;
|
||||
const jwtService = app.get(JwtService);
|
||||
const decodedAccessToken = jwtService.decode(accessToken);
|
||||
const decodedRefreshToken = jwtService.decode(refreshToken);
|
||||
|
||||
expect(decodedAccessToken.clientId).toBe(oAuthClient.id);
|
||||
expect(decodedAccessToken.ownerId).toBe(user.id);
|
||||
expect(decodedAccessToken.type).toBe("access_token");
|
||||
expect(decodedAccessToken.expiresAt).toBe(new Date(accessTokenExpiresAt).valueOf());
|
||||
expect(decodedAccessToken.iat).toBeGreaterThan(0);
|
||||
|
||||
expect(decodedRefreshToken.clientId).toBe(oAuthClient.id);
|
||||
expect(decodedRefreshToken.ownerId).toBe(user.id);
|
||||
expect(decodedRefreshToken.type).toBe("refresh_token");
|
||||
expect(decodedRefreshToken.expiresAt).toBe(new Date(refreshTokenExpiresAt).valueOf());
|
||||
expect(decodedRefreshToken.iat).toBeGreaterThan(0);
|
||||
|
||||
responseRefreshToken = refreshToken;
|
||||
});
|
||||
});
|
||||
|
||||
describe("Refresh Token Endpoint", () => {
|
||||
it("POST /oauth/:clientId/refresh", () => {
|
||||
it("POST /oauth/:clientId/refresh", async () => {
|
||||
const secretKey = oAuthClient.secret;
|
||||
const body = {
|
||||
refreshToken,
|
||||
refreshToken: responseRefreshToken,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/oauth/${oAuthClient.id}/refresh`)
|
||||
.set("x-cal-secret-key", secretKey)
|
||||
.send(body)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
expect(response.body?.data?.accessToken).toBeDefined();
|
||||
expect(response.body?.data?.refreshToken).toBeDefined();
|
||||
});
|
||||
.expect(200);
|
||||
|
||||
const { accessToken, refreshToken, accessTokenExpiresAt, refreshTokenExpiresAt } = response.body.data;
|
||||
expect(accessToken).toBeDefined();
|
||||
expect(refreshToken).toBeDefined();
|
||||
expect(accessTokenExpiresAt).toBeDefined();
|
||||
expect(refreshTokenExpiresAt).toBeDefined();
|
||||
|
||||
const jwtService = app.get(JwtService);
|
||||
const decodedAccessToken = jwtService.decode(accessToken);
|
||||
const decodedRefreshToken = jwtService.decode(refreshToken);
|
||||
|
||||
expect(decodedAccessToken.clientId).toBe(oAuthClient.id);
|
||||
expect(decodedAccessToken.ownerId).toBe(user.id);
|
||||
expect(decodedAccessToken.type).toBe("access_token");
|
||||
expect(decodedAccessToken.expiresAt).toBe(new Date(accessTokenExpiresAt).valueOf());
|
||||
expect(decodedAccessToken.iat).toBeGreaterThan(0);
|
||||
|
||||
expect(decodedRefreshToken.clientId).toBe(oAuthClient.id);
|
||||
expect(decodedRefreshToken.ownerId).toBe(user.id);
|
||||
expect(decodedRefreshToken.type).toBe("refresh_token");
|
||||
expect(decodedRefreshToken.expiresAt).toBe(new Date(refreshTokenExpiresAt).valueOf());
|
||||
expect(decodedRefreshToken.iat).toBeGreaterThan(0);
|
||||
|
||||
responseRefreshToken = refreshToken;
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+13
-28
@@ -1,4 +1,3 @@
|
||||
import { getEnv } from "@/env";
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { isOriginAllowed } from "@/lib/is-origin-allowed/is-origin-allowed";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
@@ -25,11 +24,7 @@ import {
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
ApiTags as DocsTags,
|
||||
ApiExcludeController as DocsExcludeController,
|
||||
ApiOperation as DocsOperation,
|
||||
ApiOkResponse as DocsOkResponse,
|
||||
ApiExcludeEndpoint as DocsExcludeEndpoint,
|
||||
ApiBadRequestResponse as DocsBadRequestResponse,
|
||||
ApiHeader as DocsHeader,
|
||||
ApiOperation,
|
||||
} from "@nestjs/swagger";
|
||||
@@ -37,6 +32,9 @@ import { Response as ExpressResponse } from "express";
|
||||
|
||||
import { SUCCESS_STATUS, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
|
||||
|
||||
export const TOKENS_DOCS = `Access token is valid for 60 minutes and refresh token for 1 year. Make sure to store them in your database, for example, in your User database model \`calAccessToken\` and \`calRefreshToken\` fields.
|
||||
Response also contains \`accessTokenExpiresAt\` and \`refreshTokenExpiresAt\` fields, but if you decode the jwt token the payload will contain \`clientId\` (OAuth client ID), \`ownerId\` (user to whom token belongs ID), \`iat\` (issued at time) and \`expiresAt\` (when does the token expire) fields.`;
|
||||
|
||||
@Controller({
|
||||
path: "/v2/oauth/:clientId",
|
||||
version: API_VERSIONS_VALUES,
|
||||
@@ -96,20 +94,15 @@ export class OAuthFlowController {
|
||||
throw new BadRequestException("Missing 'Bearer' Authorization header.");
|
||||
}
|
||||
|
||||
const { accessToken, refreshToken, accessTokenExpiresAt } =
|
||||
await this.oAuthFlowService.exchangeAuthorizationToken(
|
||||
authorizeEndpointCode,
|
||||
clientId,
|
||||
body.clientSecret
|
||||
);
|
||||
const tokens = await this.oAuthFlowService.exchangeOAuthClientAuthorizationToken(
|
||||
authorizeEndpointCode,
|
||||
clientId,
|
||||
body.clientSecret
|
||||
);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: {
|
||||
accessToken,
|
||||
accessTokenExpiresAt: accessTokenExpiresAt.valueOf(),
|
||||
refreshToken,
|
||||
},
|
||||
data: tokens,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -124,27 +117,19 @@ export class OAuthFlowController {
|
||||
})
|
||||
@ApiOperation({
|
||||
summary: "Refresh managed user tokens",
|
||||
description: `If managed user access token is expired then get a new one using this endpoint. Each access token is valid for 60 minutes and
|
||||
each refresh token for 1 year. Make sure to store them later in your database, for example, by updating the User model to have \`calAccessToken\` and \`calRefreshToken\` columns.`,
|
||||
description: `If managed user access token is expired then get a new one using this endpoint - it will also refresh the refresh token, because we use
|
||||
"refresh token rotation" mechanism. ${TOKENS_DOCS}`,
|
||||
})
|
||||
async refreshTokens(
|
||||
@Param("clientId") clientId: string,
|
||||
@Headers(X_CAL_SECRET_KEY) secretKey: string,
|
||||
@Body() body: RefreshTokenInput
|
||||
): Promise<KeysResponseDto> {
|
||||
const { accessToken, refreshToken, accessTokenExpiresAt } = await this.oAuthFlowService.refreshToken(
|
||||
clientId,
|
||||
secretKey,
|
||||
body.refreshToken
|
||||
);
|
||||
const tokens = await this.oAuthFlowService.refreshUserTokens(clientId, secretKey, body.refreshToken);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: {
|
||||
accessToken: accessToken,
|
||||
accessTokenExpiresAt: accessTokenExpiresAt.valueOf(),
|
||||
refreshToken: refreshToken,
|
||||
},
|
||||
data: tokens,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+4
-1
@@ -4,7 +4,7 @@ import { ValidateNested, IsEnum, IsString, IsNotEmptyObject, IsNumber } from "cl
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
class KeysDto {
|
||||
export class KeysDto {
|
||||
@ApiProperty({
|
||||
example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9",
|
||||
})
|
||||
@@ -19,6 +19,9 @@ class KeysDto {
|
||||
|
||||
@IsNumber()
|
||||
accessTokenExpiresAt!: number;
|
||||
|
||||
@IsNumber()
|
||||
refreshTokenExpiresAt!: number;
|
||||
}
|
||||
|
||||
export class KeysResponseDto {
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||
import { EventTypesService_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/services/event-types.service";
|
||||
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
|
||||
import { Locales } from "@/lib/enums/locales";
|
||||
import { GetManagedUsersInput } from "@/modules/oauth-clients/controllers/oauth-client-users/inputs/get-managed-users.input";
|
||||
import { KeysDto } from "@/modules/oauth-clients/controllers/oauth-flow/responses/KeysResponse.dto";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { CreateManagedUserInput } from "@/modules/users/inputs/create-managed-user.input";
|
||||
import { UpdateManagedUserInput } from "@/modules/users/inputs/update-managed-user.input";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { BadRequestException, ConflictException, Injectable, Logger } from "@nestjs/common";
|
||||
import { User, CreationSource, PlatformOAuthClient } from "@prisma/client";
|
||||
import { User, CreationSource, PlatformOAuthClient, AccessToken, RefreshToken } from "@prisma/client";
|
||||
|
||||
import { createNewUsersConnectToOrgIfExists, slugify } from "@calcom/platform-libraries";
|
||||
|
||||
@@ -64,6 +66,7 @@ export class OAuthClientUsersService {
|
||||
timeFormat: body.timeFormat,
|
||||
weekStart: body.weekStart,
|
||||
timeZone: body.timeZone,
|
||||
language: body.locale ?? Locales.EN,
|
||||
})
|
||||
)[0];
|
||||
await this.userRepository.addToOAuthClient(user.id, oAuthClientId);
|
||||
@@ -77,9 +80,9 @@ export class OAuthClientUsersService {
|
||||
user.avatarUrl = updatedUser.avatarUrl;
|
||||
}
|
||||
|
||||
const { accessToken, refreshToken, accessTokenExpiresAt } = await this.tokensRepository.createOAuthTokens(
|
||||
oAuthClientId,
|
||||
user.id
|
||||
const { accessToken, refreshToken } = await this.tokensRepository.createOAuthTokens(
|
||||
user.id,
|
||||
oAuthClientId
|
||||
);
|
||||
|
||||
if (oAuthClient.areDefaultEventTypesEnabled) {
|
||||
@@ -100,11 +103,16 @@ export class OAuthClientUsersService {
|
||||
|
||||
return {
|
||||
user,
|
||||
tokens: {
|
||||
accessToken,
|
||||
accessTokenExpiresAt,
|
||||
refreshToken,
|
||||
},
|
||||
tokens: this.getResponseOAuthTokens(accessToken, refreshToken),
|
||||
};
|
||||
}
|
||||
|
||||
getResponseOAuthTokens(accessToken: AccessToken, refreshToken: RefreshToken): KeysDto {
|
||||
return {
|
||||
accessToken: accessToken.secret,
|
||||
refreshToken: refreshToken.secret,
|
||||
accessTokenExpiresAt: accessToken.expiresAt.valueOf(),
|
||||
refreshTokenExpiresAt: refreshToken.expiresAt.valueOf(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { TokenExpiredException } from "@/modules/auth/guards/api-auth/token-expired.exception";
|
||||
import { KeysDto } from "@/modules/oauth-clients/controllers/oauth-flow/responses/KeysResponse.dto";
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
import { RedisService } from "@/modules/redis/redis.service";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
@@ -6,6 +7,7 @@ import { BadRequestException, Injectable, Logger, UnauthorizedException } from "
|
||||
import { DateTime } from "luxon";
|
||||
|
||||
import { INVALID_ACCESS_TOKEN } from "@calcom/platform-constants";
|
||||
import { AccessToken, RefreshToken } from "@calcom/prisma/client";
|
||||
|
||||
@Injectable()
|
||||
export class OAuthFlowService {
|
||||
@@ -55,8 +57,8 @@ export class OAuthFlowService {
|
||||
|
||||
if (!ownerIdFromDb) throw new Error("Invalid Access Token, not present in Redis or DB");
|
||||
|
||||
// await in case of race conditions, but void it's return since cache writes shouldn't halt execution.
|
||||
void (await this.redisService.redis.setex(cacheKey, 3600, ownerIdFromDb)); // expires in 1 hour
|
||||
// await in case of race conditions
|
||||
await this.redisService.redis.setex(cacheKey, 3600, ownerIdFromDb); // expires in 1 hour
|
||||
|
||||
return ownerIdFromDb;
|
||||
}
|
||||
@@ -81,9 +83,8 @@ export class OAuthFlowService {
|
||||
}
|
||||
|
||||
// we can't use a Promise#all or similar here because we care about execution order
|
||||
// however we can't allow caches to fail a validation hence the results are voided.
|
||||
void (await this.redisService.redis.hmset(cacheKey, { expiresAt: tokenExpiresAt.toJSON() }));
|
||||
void (await this.redisService.redis.expireat(cacheKey, Math.floor(tokenExpiresAt.getTime() / 1000)));
|
||||
await this.redisService.redis.hmset(cacheKey, { expiresAt: tokenExpiresAt.toJSON() });
|
||||
await this.redisService.redis.expireat(cacheKey, Math.floor(tokenExpiresAt.getTime() / 1000));
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -99,11 +100,11 @@ export class OAuthFlowService {
|
||||
return { status: "CACHE_MISS", cacheKey };
|
||||
}
|
||||
|
||||
async exchangeAuthorizationToken(
|
||||
async exchangeOAuthClientAuthorizationToken(
|
||||
tokenId: string,
|
||||
clientId: string,
|
||||
clientSecret: string
|
||||
): Promise<{ accessToken: string; refreshToken: string; accessTokenExpiresAt: Date }> {
|
||||
): Promise<KeysDto> {
|
||||
const oauthClient = await this.oAuthClientRepository.getOAuthClientWithAuthTokens(
|
||||
tokenId,
|
||||
clientId,
|
||||
@@ -120,47 +121,51 @@ export class OAuthFlowService {
|
||||
throw new BadRequestException("Invalid Authorization Token.");
|
||||
}
|
||||
|
||||
const { accessToken, refreshToken, accessTokenExpiresAt } = await this.tokensRepository.createOAuthTokens(
|
||||
clientId,
|
||||
authorizationToken.owner.id
|
||||
const { accessToken, refreshToken } = await this.tokensRepository.createOAuthTokens(
|
||||
authorizationToken.owner.id,
|
||||
clientId
|
||||
);
|
||||
await this.tokensRepository.invalidateAuthorizationToken(authorizationToken.id);
|
||||
void this.propagateAccessToken(accessToken); // void result, ignored.
|
||||
await this.propagateAccessToken(accessToken.secret);
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
accessTokenExpiresAt,
|
||||
refreshToken,
|
||||
};
|
||||
return this.getResponseOAuthTokens(accessToken, refreshToken);
|
||||
}
|
||||
|
||||
async refreshToken(clientId: string, clientSecret: string, tokenSecret: string) {
|
||||
async refreshUserTokens(
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
userRefreshTokenSecret: string
|
||||
): Promise<KeysDto> {
|
||||
const oauthClient = await this.oAuthClientRepository.getOAuthClientWithRefreshSecret(
|
||||
clientId,
|
||||
clientSecret,
|
||||
tokenSecret
|
||||
userRefreshTokenSecret
|
||||
);
|
||||
|
||||
if (!oauthClient) {
|
||||
throw new BadRequestException("Invalid OAuthClient credentials.");
|
||||
}
|
||||
|
||||
const currentRefreshToken = oauthClient.refreshToken[0];
|
||||
const currentUserRefreshToken = oauthClient.refreshToken[0];
|
||||
|
||||
if (!currentRefreshToken) {
|
||||
throw new BadRequestException("Invalid refresh token");
|
||||
if (!currentUserRefreshToken) {
|
||||
throw new BadRequestException("Invalid managed user refresh token");
|
||||
}
|
||||
|
||||
const { accessToken, refreshToken } = await this.tokensRepository.refreshOAuthTokens(
|
||||
clientId,
|
||||
currentRefreshToken.secret,
|
||||
currentRefreshToken.userId
|
||||
currentUserRefreshToken.userId,
|
||||
clientId
|
||||
);
|
||||
|
||||
return this.getResponseOAuthTokens(accessToken, refreshToken);
|
||||
}
|
||||
|
||||
getResponseOAuthTokens(accessToken: AccessToken, refreshToken: RefreshToken): KeysDto {
|
||||
return {
|
||||
accessToken: accessToken.secret,
|
||||
accessTokenExpiresAt: accessToken.expiresAt,
|
||||
refreshToken: refreshToken.secret,
|
||||
accessTokenExpiresAt: accessToken.expiresAt.valueOf(),
|
||||
refreshTokenExpiresAt: refreshToken.expiresAt.valueOf(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
||||
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
|
||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
|
||||
import {
|
||||
ConferencingAppsOauthUrlOutputDto,
|
||||
GetConferencingAppsOauthUrlResponseDto,
|
||||
} from "@/modules/conferencing/outputs/get-conferencing-apps-oauth-url";
|
||||
import {
|
||||
ConferencingAppsOutputResponseDto,
|
||||
ConferencingAppOutputResponseDto,
|
||||
ConferencingAppsOutputDto,
|
||||
DisconnectConferencingAppOutputResponseDto,
|
||||
} from "@/modules/conferencing/outputs/get-conferencing-apps.output";
|
||||
import { GetDefaultConferencingAppOutputResponseDto } from "@/modules/conferencing/outputs/get-default-conferencing-app.output";
|
||||
import { SetDefaultConferencingAppOutputResponseDto } from "@/modules/conferencing/outputs/set-default-conferencing-app.output";
|
||||
import { ConferencingService } from "@/modules/conferencing/services/conferencing.service";
|
||||
import { OrganizationsConferencingService } from "@/modules/organizations/conferencing/services/organizations-conferencing.service";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Query,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
UseGuards,
|
||||
Post,
|
||||
Param,
|
||||
Delete,
|
||||
Headers,
|
||||
Req,
|
||||
ParseIntPipe,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags as DocsTags, ApiParam } from "@nestjs/swagger";
|
||||
import { plainToInstance } from "class-transformer";
|
||||
import { Request } from "express";
|
||||
|
||||
import { GOOGLE_MEET, ZOOM, SUCCESS_STATUS, OFFICE_365_VIDEO, CAL_VIDEO } from "@calcom/platform-constants";
|
||||
|
||||
export type OAuthCallbackState = {
|
||||
accessToken: string;
|
||||
teamId?: string;
|
||||
orgId?: string;
|
||||
fromApp?: boolean;
|
||||
returnTo?: string;
|
||||
onErrorReturnTo?: string;
|
||||
};
|
||||
|
||||
@Controller({
|
||||
path: "/v2/organizations/:orgId",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@DocsTags("Organizations/Teams Conferencing")
|
||||
export class OrganizationsConferencingController {
|
||||
constructor(
|
||||
private readonly conferencingService: ConferencingService,
|
||||
private readonly organizationsConferencingService: OrganizationsConferencingService
|
||||
) {}
|
||||
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@ApiParam({
|
||||
name: "app",
|
||||
description: "Conferencing application type",
|
||||
enum: [GOOGLE_MEET],
|
||||
required: true,
|
||||
})
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@Post("/teams/:teamId/conferencing/:app/connect")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Connect your conferencing application to a team" })
|
||||
async connectTeamApp(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("app") app: string
|
||||
): Promise<ConferencingAppOutputResponseDto> {
|
||||
const credential = await this.organizationsConferencingService.connectTeamNonOauthApps({
|
||||
teamId,
|
||||
app,
|
||||
});
|
||||
|
||||
return { status: SUCCESS_STATUS, data: plainToInstance(ConferencingAppsOutputDto, credential) };
|
||||
}
|
||||
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@ApiParam({
|
||||
name: "app",
|
||||
description: "Conferencing application type",
|
||||
enum: [ZOOM, OFFICE_365_VIDEO],
|
||||
required: true,
|
||||
})
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@Get("/teams/:teamId/conferencing/:app/oauth/auth-url")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Get OAuth conferencing app's auth url for a team" })
|
||||
async getTeamOAuthUrl(
|
||||
@Req() req: Request,
|
||||
@Headers("Authorization") authorization: string,
|
||||
@Param("teamId") teamId: string,
|
||||
@Param("orgId") orgId: string,
|
||||
@Param("app") app: string,
|
||||
@Query("returnTo") returnTo?: string,
|
||||
@Query("onErrorReturnTo") onErrorReturnTo?: string
|
||||
): Promise<GetConferencingAppsOauthUrlResponseDto> {
|
||||
const origin = req.headers.origin;
|
||||
const accessToken = authorization.replace("Bearer ", "");
|
||||
|
||||
const state: OAuthCallbackState = {
|
||||
returnTo: returnTo ?? origin,
|
||||
onErrorReturnTo: onErrorReturnTo ?? origin,
|
||||
fromApp: false,
|
||||
accessToken,
|
||||
teamId,
|
||||
orgId,
|
||||
};
|
||||
|
||||
const credential = await this.conferencingService.generateOAuthUrl(app, state);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToInstance(ConferencingAppsOauthUrlOutputDto, credential),
|
||||
};
|
||||
}
|
||||
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@Get("/teams/:teamId/conferencing")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "List team conferencing applications" })
|
||||
async listTeamConferencingApps(
|
||||
@Param("teamId", ParseIntPipe) teamId: number
|
||||
): Promise<ConferencingAppsOutputResponseDto> {
|
||||
const conferencingApps = await this.organizationsConferencingService.getConferencingApps({
|
||||
teamId,
|
||||
});
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: conferencingApps.map((app) => plainToInstance(ConferencingAppsOutputDto, app)),
|
||||
};
|
||||
}
|
||||
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@Post("/teams/:teamId/conferencing/:app/default")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Set team default conferencing application" })
|
||||
@ApiParam({
|
||||
name: "app",
|
||||
description: "Conferencing application type",
|
||||
enum: [GOOGLE_MEET, ZOOM, OFFICE_365_VIDEO, CAL_VIDEO],
|
||||
required: true,
|
||||
})
|
||||
async setTeamDefaultApp(
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Param("app") app: string
|
||||
): Promise<SetDefaultConferencingAppOutputResponseDto> {
|
||||
await this.organizationsConferencingService.setDefaultConferencingApp({
|
||||
teamId,
|
||||
app,
|
||||
});
|
||||
|
||||
return { status: SUCCESS_STATUS };
|
||||
}
|
||||
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@Get("/teams/:teamId/conferencing/default")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Get team default conferencing application" })
|
||||
@ApiParam({
|
||||
name: "app",
|
||||
description: "Conferencing application type",
|
||||
enum: [GOOGLE_MEET, ZOOM, OFFICE_365_VIDEO, CAL_VIDEO],
|
||||
required: true,
|
||||
})
|
||||
async getTeamDefaultApp(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Param("teamId", ParseIntPipe) teamId: number
|
||||
): Promise<GetDefaultConferencingAppOutputResponseDto> {
|
||||
const defaultConferencingApp = await this.organizationsConferencingService.getDefaultConferencingApp({
|
||||
teamId,
|
||||
});
|
||||
|
||||
return { status: SUCCESS_STATUS, data: defaultConferencingApp };
|
||||
}
|
||||
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@Delete("/teams/:teamId/conferencing/:app/disconnect")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Disconnect team conferencing application" })
|
||||
@ApiParam({
|
||||
name: "app",
|
||||
description: "Conferencing application type",
|
||||
enum: [GOOGLE_MEET, ZOOM, OFFICE_365_VIDEO],
|
||||
required: true,
|
||||
})
|
||||
async disconnectTeamApp(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Param("app") app: string
|
||||
): Promise<DisconnectConferencingAppOutputResponseDto> {
|
||||
await this.organizationsConferencingService.disconnectConferencingApp({
|
||||
teamId,
|
||||
user,
|
||||
app,
|
||||
});
|
||||
|
||||
return { status: SUCCESS_STATUS };
|
||||
}
|
||||
}
|
||||
+265
@@ -0,0 +1,265 @@
|
||||
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
||||
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
|
||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
|
||||
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
||||
import { PlatformPlanType } from "@/modules/billing/types";
|
||||
import { OAuthCallbackState } from "@/modules/conferencing/controllers/conferencing.controller";
|
||||
import { DefaultConferencingAppsOutputDto } from "@/modules/conferencing/outputs/get-default-conferencing-app.output";
|
||||
import { ConferencingRepository } from "@/modules/conferencing/repositories/conferencing.repository";
|
||||
import { ConferencingService } from "@/modules/conferencing/services/conferencing.service";
|
||||
import { GoogleMeetService } from "@/modules/conferencing/services/google-meet.service";
|
||||
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
InternalServerErrorException,
|
||||
Logger,
|
||||
} from "@nestjs/common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { GOOGLE_MEET } from "@calcom/platform-constants";
|
||||
import { CONFERENCING_APPS, CAL_VIDEO } from "@calcom/platform-constants";
|
||||
import { teamMetadataSchema } from "@calcom/platform-libraries";
|
||||
import { handleDeleteCredential } from "@calcom/platform-libraries/app-store";
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationsConferencingService {
|
||||
private logger = new Logger("OrganizationsConferencingService");
|
||||
|
||||
constructor(
|
||||
private readonly conferencingRepository: ConferencingRepository,
|
||||
private teamsRepository: TeamsRepository,
|
||||
private usersRepository: UsersRepository,
|
||||
private readonly googleMeetService: GoogleMeetService,
|
||||
private readonly conferencingService: ConferencingService,
|
||||
private readonly platformPlanGuard: PlatformPlanGuard,
|
||||
private readonly isAdminAPIEnabledGuard: IsAdminAPIEnabledGuard,
|
||||
private readonly isOrgGuard: IsOrgGuard,
|
||||
private readonly isTeamInOrg: IsTeamInOrg,
|
||||
private readonly rolesGuard: RolesGuard
|
||||
) {}
|
||||
|
||||
async verifyAccess({
|
||||
user,
|
||||
orgId,
|
||||
teamId,
|
||||
requiredRole,
|
||||
minimumPlan,
|
||||
}: {
|
||||
user: UserWithProfile;
|
||||
orgId: string;
|
||||
teamId?: string;
|
||||
requiredRole?: string;
|
||||
minimumPlan?: PlatformPlanType;
|
||||
}): Promise<{ orgId: number; teamId: number | null }> {
|
||||
const userWithAdminFlag = {
|
||||
...user,
|
||||
isSystemAdmin: user.role === "ADMIN",
|
||||
};
|
||||
|
||||
if (!orgId) {
|
||||
throw new BadRequestException("Organization ID is required");
|
||||
}
|
||||
|
||||
await this.ensureOrganizationAccess(orgId);
|
||||
await this.ensureAdminAPIEnabled(orgId);
|
||||
|
||||
// Only validate team if a teamId is provided
|
||||
if (teamId && teamId.trim() !== "") {
|
||||
await this.ensureTeamBelongsToOrg(orgId, teamId);
|
||||
}
|
||||
|
||||
await this.ensureUserRoleAccess(userWithAdminFlag, orgId, teamId, requiredRole);
|
||||
await this.ensurePlatformPlanAccess({ teamId, orgId, user: userWithAdminFlag, minimumPlan });
|
||||
|
||||
const parsedOrgId = parseInt(orgId, 10);
|
||||
// For organization-level operations, teamId might be empty or null
|
||||
const parsedTeamId = teamId && teamId.trim() !== "" ? parseInt(teamId, 10) : null;
|
||||
|
||||
return {
|
||||
orgId: parsedOrgId,
|
||||
teamId: parsedTeamId,
|
||||
};
|
||||
}
|
||||
|
||||
private async ensureOrganizationAccess(orgId: string): Promise<void> {
|
||||
const { canAccess } = await this.isOrgGuard.checkOrgAccess(orgId);
|
||||
if (!canAccess) {
|
||||
throw new ForbiddenException("Organization validation failed.");
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureAdminAPIEnabled(orgId: string): Promise<void> {
|
||||
const { canAccess } = await this.isAdminAPIEnabledGuard.checkAdminAPIEnabled(orgId);
|
||||
if (!canAccess) {
|
||||
throw new ForbiddenException("Admin API is not enabled for this organization.");
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureTeamBelongsToOrg(orgId: string, teamId: string): Promise<void> {
|
||||
const { canAccess } = await this.isTeamInOrg.checkIfTeamIsInOrg(orgId, teamId);
|
||||
if (!canAccess) {
|
||||
throw new ForbiddenException("Team is not part of the organization.");
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureUserRoleAccess(
|
||||
user: ApiAuthGuardUser,
|
||||
orgId: string,
|
||||
teamId?: string,
|
||||
requiredRole?: string
|
||||
): Promise<void> {
|
||||
if (!requiredRole) return;
|
||||
|
||||
// For team-level roles, we need both orgId and teamId
|
||||
if (requiredRole.startsWith("TEAM_") && (!teamId || teamId.trim() === "")) {
|
||||
throw new ForbiddenException("Team ID is required for team-level role access");
|
||||
}
|
||||
|
||||
// Use empty string for teamId if it's an org-level operation
|
||||
const effectiveTeamId = teamId && teamId.trim() !== "" ? teamId : "";
|
||||
|
||||
const { canAccess } = await this.rolesGuard.checkUserRoleAccess(
|
||||
user,
|
||||
orgId,
|
||||
effectiveTeamId,
|
||||
requiredRole
|
||||
);
|
||||
if (!canAccess) {
|
||||
throw new ForbiddenException("User does not have the required role.");
|
||||
}
|
||||
}
|
||||
|
||||
private async ensurePlatformPlanAccess({
|
||||
teamId,
|
||||
orgId,
|
||||
user,
|
||||
minimumPlan,
|
||||
}: {
|
||||
teamId?: string;
|
||||
orgId?: string;
|
||||
user: ApiAuthGuardUser;
|
||||
minimumPlan?: PlatformPlanType;
|
||||
}): Promise<void> {
|
||||
if (!minimumPlan) return;
|
||||
|
||||
const { canAccess } = await this.platformPlanGuard.checkPlatformPlanAccess({
|
||||
teamId,
|
||||
orgId,
|
||||
user,
|
||||
minimumPlan,
|
||||
});
|
||||
if (!canAccess) {
|
||||
throw new ForbiddenException("User's organization does not meet the required subscription plan.");
|
||||
}
|
||||
}
|
||||
|
||||
async connectTeamNonOauthApps({ teamId, app }: { teamId: number; app: string }): Promise<any> {
|
||||
switch (app) {
|
||||
case GOOGLE_MEET:
|
||||
return this.googleMeetService.connectGoogleMeetToTeam(teamId);
|
||||
default:
|
||||
throw new BadRequestException("Invalid conferencing app. Available apps: GOOGLE_MEET.");
|
||||
}
|
||||
}
|
||||
|
||||
async connectTeamOauthApps({
|
||||
decodedCallbackState,
|
||||
app,
|
||||
code,
|
||||
userId,
|
||||
}: {
|
||||
userId: number;
|
||||
app: string;
|
||||
decodedCallbackState: OAuthCallbackState;
|
||||
code: string;
|
||||
}) {
|
||||
const user = await this.usersRepository.findByIdWithProfile(userId);
|
||||
const { orgId, teamId } = decodedCallbackState;
|
||||
|
||||
if (!orgId) {
|
||||
throw new BadRequestException("orgId required");
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
throw new BadRequestException("user not found");
|
||||
}
|
||||
|
||||
// Determine if this is a team-level or organization-level operation
|
||||
const isTeamLevel = !!teamId;
|
||||
const requiredRole = isTeamLevel ? "TEAM_ADMIN" : "ORG_ADMIN";
|
||||
|
||||
// Verify access with appropriate parameters
|
||||
const { orgId: validatedOrgId, teamId: validatedTeamId } = await this.verifyAccess({
|
||||
user,
|
||||
orgId,
|
||||
teamId,
|
||||
requiredRole,
|
||||
minimumPlan: "ESSENTIALS",
|
||||
});
|
||||
|
||||
const entityId = isTeamLevel && validatedTeamId !== null ? validatedTeamId : validatedOrgId;
|
||||
|
||||
return this.conferencingService.connectOauthApps(app, code, userId, decodedCallbackState, entityId);
|
||||
}
|
||||
|
||||
async getConferencingApps({ teamId }: { teamId: number }) {
|
||||
return this.conferencingRepository.findTeamConferencingApps(teamId);
|
||||
}
|
||||
|
||||
async getDefaultConferencingApp({
|
||||
teamId,
|
||||
}: {
|
||||
teamId: number;
|
||||
}): Promise<DefaultConferencingAppsOutputDto | undefined> {
|
||||
const team = await this.teamsRepository.getById(teamId);
|
||||
return teamMetadataSchema.parse(team?.metadata)?.defaultConferencingApp;
|
||||
}
|
||||
|
||||
async checkAppIsValidAndConnected(teamId: number, app: string) {
|
||||
if (!CONFERENCING_APPS.includes(app)) {
|
||||
throw new BadRequestException("Invalid app, available apps are: ", CONFERENCING_APPS.join(", "));
|
||||
}
|
||||
const credential = await this.conferencingRepository.findTeamConferencingApp(teamId, app);
|
||||
|
||||
if (!credential) {
|
||||
throw new BadRequestException(`${app} not connected.`);
|
||||
}
|
||||
return credential;
|
||||
}
|
||||
|
||||
async disconnectConferencingApp({
|
||||
teamId,
|
||||
user,
|
||||
app,
|
||||
}: {
|
||||
teamId: number;
|
||||
user: UserWithProfile;
|
||||
app: string;
|
||||
}) {
|
||||
const credential = await this.checkAppIsValidAndConnected(teamId, app);
|
||||
return handleDeleteCredential({
|
||||
userId: user.id,
|
||||
teamId,
|
||||
userMetadata: user?.metadata,
|
||||
credentialId: credential.id,
|
||||
});
|
||||
}
|
||||
|
||||
async setDefaultConferencingApp({ teamId, app }: { teamId: number; app: string }) {
|
||||
// cal-video is global, so we can skip this check
|
||||
if (app !== CAL_VIDEO) {
|
||||
await this.checkAppIsValidAndConnected(teamId, app);
|
||||
}
|
||||
const team = await this.teamsRepository.setDefaultConferencingApp(teamId, app);
|
||||
const metadata = team.metadata as { defaultConferencingApp?: { appSlug?: string } };
|
||||
if (metadata?.defaultConferencingApp?.appSlug !== app) {
|
||||
throw new InternalServerErrorException(`Could not set ${app} as default conferencing app`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { InputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/input-event-types.service";
|
||||
import { OrganizationsConferencingService } from "@/modules/organizations/conferencing/services/organizations-conferencing.service";
|
||||
import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-event-types.repository";
|
||||
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
@@ -25,13 +26,15 @@ export class InputOrganizationsEventTypesService {
|
||||
private readonly inputEventTypesService: InputEventTypesService_2024_06_14,
|
||||
private readonly teamsRepository: TeamsRepository,
|
||||
private readonly usersRepository: UsersRepository,
|
||||
private readonly teamsEventTypesRepository: TeamsEventTypesRepository
|
||||
private readonly teamsEventTypesRepository: TeamsEventTypesRepository,
|
||||
private readonly conferencingService: OrganizationsConferencingService
|
||||
) {}
|
||||
async transformAndValidateCreateTeamEventTypeInput(
|
||||
userId: number,
|
||||
teamId: number,
|
||||
inputEventType: CreateTeamEventTypeInput_2024_06_14
|
||||
) {
|
||||
await this.validateInputLocations(teamId, inputEventType.locations);
|
||||
await this.validateHosts(teamId, inputEventType.hosts);
|
||||
await this.validateTeamEventTypeSlug(teamId, inputEventType.slug);
|
||||
|
||||
@@ -62,6 +65,7 @@ export class InputOrganizationsEventTypesService {
|
||||
teamId: number,
|
||||
inputEventType: UpdateTeamEventTypeInput_2024_06_14
|
||||
) {
|
||||
await this.validateInputLocations(teamId, inputEventType.locations);
|
||||
await this.validateHosts(teamId, inputEventType.hosts);
|
||||
if (inputEventType.slug) {
|
||||
await this.validateTeamEventTypeSlug(teamId, inputEventType.slug);
|
||||
@@ -293,6 +297,22 @@ export class InputOrganizationsEventTypesService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async validateInputLocations(
|
||||
teamId: number,
|
||||
inputLocations?: CreateTeamEventTypeInput_2024_06_14["locations"]
|
||||
) {
|
||||
await Promise.all(
|
||||
inputLocations?.map(async (location) => {
|
||||
if (location.type === "integration") {
|
||||
// cal-video is global, so we can skip this check
|
||||
if (location.integration !== "cal-video") {
|
||||
await this.conferencingService.checkAppIsValidAndConnected(teamId, location.integration);
|
||||
}
|
||||
}
|
||||
}) ?? []
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getPriorityValue(priority: keyof typeof HostPriority): number {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
|
||||
import { SchedulesModule_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.module";
|
||||
import { AuthModule } from "@/modules/auth/auth.module";
|
||||
import { ConferencingModule } from "@/modules/conferencing/conferencing.module";
|
||||
import { EmailModule } from "@/modules/email/email.module";
|
||||
import { EmailService } from "@/modules/email/email.service";
|
||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||
@@ -11,6 +13,8 @@ import { OrganizationAttributesService } from "@/modules/organizations/attribute
|
||||
import { OrganizationAttributeOptionRepository } from "@/modules/organizations/attributes/options/organization-attribute-options.repository";
|
||||
import { OrganizationsAttributesOptionsController } from "@/modules/organizations/attributes/options/organizations-attributes-options.controller";
|
||||
import { OrganizationAttributeOptionService } from "@/modules/organizations/attributes/options/services/organization-attributes-option.service";
|
||||
import { OrganizationsConferencingController } from "@/modules/organizations/conferencing/organizations-conferencing.controller";
|
||||
import { OrganizationsConferencingService } from "@/modules/organizations/conferencing/services/organizations-conferencing.service";
|
||||
import { OrganizationsDelegationCredentialModule } from "@/modules/organizations/delegation-credentials/organizations-delegation-credential.module";
|
||||
import { OrganizationsEventTypesController } from "@/modules/organizations/event-types/organizations-event-types.controller";
|
||||
import { OrganizationsEventTypesRepository } from "@/modules/organizations/event-types/organizations-event-types.repository";
|
||||
@@ -52,7 +56,7 @@ import { TeamsModule } from "@/modules/teams/teams/teams.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { WebhooksService } from "@/modules/webhooks/services/webhooks.service";
|
||||
import { WebhooksRepository } from "@/modules/webhooks/webhooks.repository";
|
||||
import { Module } from "@nestjs/common";
|
||||
import { forwardRef, Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -65,6 +69,8 @@ import { Module } from "@nestjs/common";
|
||||
EventTypesModule_2024_06_14,
|
||||
TeamsEventTypesModule,
|
||||
TeamsModule,
|
||||
forwardRef(() => ConferencingModule),
|
||||
forwardRef(() => AuthModule),
|
||||
OrganizationsDelegationCredentialModule,
|
||||
OrganizationsOrganizationsModule,
|
||||
OrganizationsTeamsRoutingFormsModule,
|
||||
@@ -101,6 +107,7 @@ import { Module } from "@nestjs/common";
|
||||
UserOOORepository,
|
||||
OrgUsersOOOService,
|
||||
OrgUsersOOORepository,
|
||||
OrganizationsConferencingService,
|
||||
],
|
||||
exports: [
|
||||
OrganizationsService,
|
||||
@@ -121,6 +128,7 @@ import { Module } from "@nestjs/common";
|
||||
WebhooksRepository,
|
||||
WebhooksService,
|
||||
OrganizationsEventTypesService,
|
||||
OrganizationsConferencingService,
|
||||
],
|
||||
controllers: [
|
||||
OrganizationsTeamsController,
|
||||
@@ -134,6 +142,7 @@ import { Module } from "@nestjs/common";
|
||||
OrganizationsWebhooksController,
|
||||
OrganizationsTeamsSchedulesController,
|
||||
OrganizationsUsersOOOController,
|
||||
OrganizationsConferencingController,
|
||||
],
|
||||
})
|
||||
export class OrganizationsModule {}
|
||||
|
||||
+73
@@ -29,6 +29,7 @@ import { BookingSeatRepositoryFixture } from "test/fixtures/repository/booking-s
|
||||
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
|
||||
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||
import { OOORepositoryFixture } from "test/fixtures/repository/ooo.repository.fixture";
|
||||
import { SelectedSlotsRepositoryFixture } from "test/fixtures/repository/selected-slots.repository.fixture";
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
@@ -56,6 +57,7 @@ describe("Slots 2024-09-04 Endpoints", () => {
|
||||
let apiKeyString: string;
|
||||
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
||||
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||
let oooRepositoryFixture: OOORepositoryFixture;
|
||||
|
||||
const userEmail = `slots-2024-09-04-user-${randomString()}@example.com`;
|
||||
let user: User;
|
||||
@@ -80,6 +82,8 @@ describe("Slots 2024-09-04 Endpoints", () => {
|
||||
|
||||
let reservedSlot: ReserveSlotOutputData_2024_09_04;
|
||||
|
||||
const oooTestUserEmail = `oooTestUser-${randomString()}@cal.com`;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [
|
||||
@@ -107,6 +111,7 @@ describe("Slots 2024-09-04 Endpoints", () => {
|
||||
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
|
||||
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
oooRepositoryFixture = new OOORepositoryFixture(moduleRef);
|
||||
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
@@ -1344,8 +1349,76 @@ describe("Slots 2024-09-04 Endpoints", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("out of office", () => {
|
||||
let oooTestUser: User;
|
||||
let oooTestUserEventType: EventType;
|
||||
|
||||
let oooEntryId: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
oooTestUser = await userRepositoryFixture.create({
|
||||
email: oooTestUserEmail,
|
||||
name: oooTestUserEmail,
|
||||
username: oooTestUserEmail,
|
||||
});
|
||||
|
||||
const oooUserSchedule: CreateScheduleInput_2024_06_11 = {
|
||||
name: `slots-2024-09-04-schedule-${randomString()}`,
|
||||
timeZone: "Europe/Rome",
|
||||
isDefault: true,
|
||||
};
|
||||
// note(Lauris): this creates default schedule monday to friday from 9AM to 5PM in Europe/Rome timezone
|
||||
await schedulesService.createUserSchedule(oooTestUser.id, oooUserSchedule);
|
||||
|
||||
const event = await eventTypesRepositoryFixture.create(
|
||||
{ title: "frisbee match", slug: `slots-2024-09-04-event-type-${randomString()}`, length: 60 },
|
||||
oooTestUser.id
|
||||
);
|
||||
oooTestUserEventType = event;
|
||||
});
|
||||
|
||||
it("should not returns slots for ooo days", async () => {
|
||||
const oooStart = new Date("2050-09-06T00:00:00.000Z");
|
||||
const oooEnd = new Date("2050-09-09T23:59:59.999Z");
|
||||
|
||||
const oooEntry = await oooRepositoryFixture.create({
|
||||
uuid: randomString(),
|
||||
start: oooStart,
|
||||
end: oooEnd,
|
||||
user: { connect: { id: oooTestUser.id } },
|
||||
toUser: { connect: { id: oooTestUser.id } },
|
||||
createdAt: new Date(),
|
||||
reason: {
|
||||
connect: {
|
||||
id: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
oooEntryId = oooEntry.id;
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/v2/slots?eventTypeId=${oooTestUserEventType.id}&start=2050-09-05&end=2050-09-09&duration=60`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: GetSlotsOutput_2024_09_04 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const slots = responseBody.data;
|
||||
|
||||
expect(slots).toBeDefined();
|
||||
const days = Object.keys(slots);
|
||||
expect(days.length).toBe(1);
|
||||
expect(slots).toEqual({
|
||||
"2050-09-05": expectedSlotsUTC["2050-09-05"],
|
||||
});
|
||||
|
||||
await oooRepositoryFixture.delete(oooEntryId);
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await userRepositoryFixture.deleteByEmail(user.email);
|
||||
await userRepositoryFixture.deleteByEmail(oooTestUserEmail);
|
||||
await userRepositoryFixture.deleteByEmail(unrelatedUser.email);
|
||||
await selectedSlotsRepositoryFixture.deleteByUId(reservedSlot.reservationUid);
|
||||
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
|
||||
|
||||
@@ -24,7 +24,7 @@ export class SlotsInputService_2024_09_04 {
|
||||
}
|
||||
const isTeamEvent = !!eventType?.teamId;
|
||||
|
||||
const startTime = query.start;
|
||||
const startTime = this.adjustStartTime(query.start);
|
||||
const endTime = this.adjustEndTime(query.end);
|
||||
const duration = query.duration;
|
||||
const eventTypeId = eventType.id;
|
||||
@@ -82,6 +82,20 @@ export class SlotsInputService_2024_09_04 {
|
||||
}
|
||||
}
|
||||
|
||||
private adjustStartTime(startTime: string) {
|
||||
let dateTime = DateTime.fromISO(startTime, { zone: "utc" });
|
||||
if (dateTime.hour === 0 && dateTime.minute === 0 && dateTime.second === 0) {
|
||||
dateTime = dateTime.set({ hour: 0, minute: 0, second: 0, millisecond: 0 });
|
||||
}
|
||||
|
||||
const ISOStartTime = dateTime.toISO();
|
||||
if (ISOStartTime === null) {
|
||||
throw new BadRequestException("Invalid start date");
|
||||
}
|
||||
|
||||
return ISOStartTime;
|
||||
}
|
||||
|
||||
private adjustEndTime(endTime: string) {
|
||||
let dateTime = DateTime.fromISO(endTime, { zone: "utc" });
|
||||
if (dateTime.hour === 0 && dateTime.minute === 0 && dateTime.second === 0) {
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
import { SelectedSlots } from "@calcom/prisma/client";
|
||||
|
||||
type GetAvailableSlots = {
|
||||
slots: Record<string, { time: string; attendees?: number; bookingUid?: string }[]>;
|
||||
slots: Record<string, { time: string; attendees?: number; bookingUid?: string; away?: boolean }[]>;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
@@ -46,34 +46,37 @@ export class SlotsOutputService_2024_09_04 {
|
||||
|
||||
const slots: { [key: string]: (Slot_2024_09_04 | SeatedSlot_2024_09_04)[] } = {};
|
||||
for (const date in availableSlots.slots) {
|
||||
slots[date] = availableSlots.slots[date].map((slot) => {
|
||||
if (!timeZone) {
|
||||
const availableTimeSlots = availableSlots.slots[date].filter((slot) => !slot.away);
|
||||
if (availableTimeSlots.length > 0) {
|
||||
slots[date] = availableTimeSlots.map((slot) => {
|
||||
if (!timeZone) {
|
||||
if (!eventType?.seatsPerTimeSlot) {
|
||||
return this.getAvailableTimeSlot(slot.time);
|
||||
}
|
||||
return this.getAvailableTimeSlotSeated(
|
||||
slot.time,
|
||||
slot.attendees || 0,
|
||||
eventType.seatsPerTimeSlot || 0,
|
||||
slot.bookingUid
|
||||
);
|
||||
}
|
||||
const slotTimezoneAdjusted = DateTime.fromISO(slot.time, { zone: "utc" }).setZone(timeZone).toISO();
|
||||
if (!slotTimezoneAdjusted) {
|
||||
throw new BadRequestException(
|
||||
`Could not adjust timezone for slot ${slot.time} with timezone ${timeZone}`
|
||||
);
|
||||
}
|
||||
if (!eventType?.seatsPerTimeSlot) {
|
||||
return this.getAvailableTimeSlot(slot.time);
|
||||
return this.getAvailableTimeSlot(slotTimezoneAdjusted);
|
||||
}
|
||||
return this.getAvailableTimeSlotSeated(
|
||||
slot.time,
|
||||
slotTimezoneAdjusted,
|
||||
slot.attendees || 0,
|
||||
eventType.seatsPerTimeSlot || 0,
|
||||
slot.bookingUid
|
||||
);
|
||||
}
|
||||
const slotTimezoneAdjusted = DateTime.fromISO(slot.time, { zone: "utc" }).setZone(timeZone).toISO();
|
||||
if (!slotTimezoneAdjusted) {
|
||||
throw new BadRequestException(
|
||||
`Could not adjust timezone for slot ${slot.time} with timezone ${timeZone}`
|
||||
);
|
||||
}
|
||||
if (!eventType?.seatsPerTimeSlot) {
|
||||
return this.getAvailableTimeSlot(slotTimezoneAdjusted);
|
||||
}
|
||||
return this.getAvailableTimeSlotSeated(
|
||||
slotTimezoneAdjusted,
|
||||
slot.attendees || 0,
|
||||
eventType.seatsPerTimeSlot || 0,
|
||||
slot.bookingUid
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return slots;
|
||||
@@ -113,56 +116,59 @@ export class SlotsOutputService_2024_09_04 {
|
||||
const slots = Object.entries(availableSlots.slots).reduce<
|
||||
Record<string, (RangeSlot_2024_09_04 | SeatedRangeSlot_2024_09_04)[]>
|
||||
>((acc, [date, slots]) => {
|
||||
acc[date] = slots.map((slot) => {
|
||||
if (timeZone) {
|
||||
const start = DateTime.fromISO(slot.time, { zone: "utc" }).setZone(timeZone).toISO();
|
||||
if (!start) {
|
||||
throw new BadRequestException(
|
||||
`Could not adjust timezone for slot ${slot.time} with timezone ${timeZone}`
|
||||
const availableTimeSlots = slots.filter((slot) => !slot.away);
|
||||
if (availableTimeSlots.length > 0) {
|
||||
acc[date] = availableTimeSlots.map((slot) => {
|
||||
if (timeZone) {
|
||||
const start = DateTime.fromISO(slot.time, { zone: "utc" }).setZone(timeZone).toISO();
|
||||
if (!start) {
|
||||
throw new BadRequestException(
|
||||
`Could not adjust timezone for slot ${slot.time} with timezone ${timeZone}`
|
||||
);
|
||||
}
|
||||
|
||||
const end = DateTime.fromISO(slot.time, { zone: "utc" })
|
||||
.plus({ minutes: slotDuration })
|
||||
.setZone(timeZone)
|
||||
.toISO();
|
||||
|
||||
if (!end) {
|
||||
throw new BadRequestException(
|
||||
`Could not adjust timezone for slot end time ${slot.time} with timezone ${timeZone}`
|
||||
);
|
||||
}
|
||||
|
||||
if (!eventType?.seatsPerTimeSlot) {
|
||||
return this.getAvailableRangeSlot(start, end);
|
||||
}
|
||||
return this.getAvailableRangeSlotSeated(
|
||||
start,
|
||||
end,
|
||||
slot.attendees || 0,
|
||||
eventType.seatsPerTimeSlot ?? undefined,
|
||||
slot.bookingUid
|
||||
);
|
||||
} else {
|
||||
const start = DateTime.fromISO(slot.time, { zone: "utc" }).toISO();
|
||||
const end = DateTime.fromISO(slot.time, { zone: "utc" }).plus({ minutes: slotDuration }).toISO();
|
||||
|
||||
if (!start || !end) {
|
||||
throw new BadRequestException(`Could not create UTC time for slot ${slot.time}`);
|
||||
}
|
||||
|
||||
if (!eventType?.seatsPerTimeSlot) {
|
||||
return this.getAvailableRangeSlot(start, end);
|
||||
}
|
||||
return this.getAvailableRangeSlotSeated(
|
||||
start,
|
||||
end,
|
||||
slot.attendees || 0,
|
||||
eventType.seatsPerTimeSlot ?? undefined,
|
||||
slot.bookingUid
|
||||
);
|
||||
}
|
||||
|
||||
const end = DateTime.fromISO(slot.time, { zone: "utc" })
|
||||
.plus({ minutes: slotDuration })
|
||||
.setZone(timeZone)
|
||||
.toISO();
|
||||
|
||||
if (!end) {
|
||||
throw new BadRequestException(
|
||||
`Could not adjust timezone for slot end time ${slot.time} with timezone ${timeZone}`
|
||||
);
|
||||
}
|
||||
|
||||
if (!eventType?.seatsPerTimeSlot) {
|
||||
return this.getAvailableRangeSlot(start, end);
|
||||
}
|
||||
return this.getAvailableRangeSlotSeated(
|
||||
start,
|
||||
end,
|
||||
slot.attendees || 0,
|
||||
eventType.seatsPerTimeSlot ?? undefined,
|
||||
slot.bookingUid
|
||||
);
|
||||
} else {
|
||||
const start = DateTime.fromISO(slot.time, { zone: "utc" }).toISO();
|
||||
const end = DateTime.fromISO(slot.time, { zone: "utc" }).plus({ minutes: slotDuration }).toISO();
|
||||
|
||||
if (!start || !end) {
|
||||
throw new BadRequestException(`Could not create UTC time for slot ${slot.time}`);
|
||||
}
|
||||
|
||||
if (!eventType?.seatsPerTimeSlot) {
|
||||
return this.getAvailableRangeSlot(start, end);
|
||||
}
|
||||
return this.getAvailableRangeSlotSeated(
|
||||
start,
|
||||
end,
|
||||
slot.attendees || 0,
|
||||
eventType.seatsPerTimeSlot ?? undefined,
|
||||
slot.bookingUid
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
|
||||
@@ -142,13 +142,19 @@ export class StripeService {
|
||||
}
|
||||
|
||||
async checkIfIndividualStripeAccountConnected(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
|
||||
const stripeCredentials = await this.credentialRepository.getByTypeAndUserId("stripe_payment", userId);
|
||||
const stripeCredentials = await this.credentialRepository.findCredentialByTypeAndUserId(
|
||||
"stripe_payment",
|
||||
userId
|
||||
);
|
||||
|
||||
return await this.validateStripeCredentials(stripeCredentials);
|
||||
}
|
||||
|
||||
async checkIfTeamStripeAccountConnected(teamId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
|
||||
const stripeCredentials = await this.credentialRepository.getByTypeAndTeamId("stripe_payment", teamId);
|
||||
const stripeCredentials = await this.credentialRepository.findCredentialByTypeAndTeamId(
|
||||
"stripe_payment",
|
||||
teamId
|
||||
);
|
||||
|
||||
return await this.validateStripeCredentials(stripeCredentials);
|
||||
}
|
||||
|
||||
+42
@@ -217,6 +217,32 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("should not allow creating an event type with integration not installed on team", async () => {
|
||||
const body: CreateTeamEventTypeInput_2024_06_14 = {
|
||||
title: "Coding consultation",
|
||||
slug: "coding-consultation",
|
||||
description: "Our team will review your codebase.",
|
||||
lengthInMinutes: 60,
|
||||
locations: [
|
||||
{
|
||||
type: "integration",
|
||||
integration: "zoom",
|
||||
},
|
||||
],
|
||||
schedulingType: "COLLECTIVE",
|
||||
hosts: [
|
||||
{
|
||||
userId: teamMember1.id,
|
||||
},
|
||||
{
|
||||
userId: teamMember2.id,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return request(app.getHttpServer()).post(`/v2/teams/${team.id}/event-types`).send(body).expect(400);
|
||||
});
|
||||
|
||||
it("should create a collective team event-type", async () => {
|
||||
const body: CreateTeamEventTypeInput_2024_06_14 = {
|
||||
title: `teams-event-types-collective-${randomString()}`,
|
||||
@@ -447,6 +473,22 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("should not allow to update event type with integration not installed on team", async () => {
|
||||
const body: UpdateTeamEventTypeInput_2024_06_14 = {
|
||||
locations: [
|
||||
{
|
||||
type: "integration",
|
||||
integration: "office365-video",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/v2/teams/${team.id}/event-types/${collectiveEventType.id}`)
|
||||
.send(body)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("should update collective event-type", async () => {
|
||||
const newHosts: UpdateTeamEventTypeInput_2024_06_14["hosts"] = [
|
||||
{
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
|
||||
import { AuthModule } from "@/modules/auth/auth.module";
|
||||
import { ConferencingModule } from "@/modules/conferencing/conferencing.module";
|
||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||
import { OrganizationsConferencingService } from "@/modules/organizations/conferencing/services/organizations-conferencing.service";
|
||||
import { OutputTeamEventTypesResponsePipe } from "@/modules/organizations/event-types/pipes/team-event-types-response.transformer";
|
||||
import { InputOrganizationsEventTypesService } from "@/modules/organizations/event-types/services/input.service";
|
||||
import { OutputOrganizationsEventTypesService } from "@/modules/organizations/event-types/services/output.service";
|
||||
@@ -11,7 +14,7 @@ import { TeamsEventTypesService } from "@/modules/teams/event-types/services/tea
|
||||
import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-event-types.repository";
|
||||
import { TeamsModule } from "@/modules/teams/teams/teams.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { Module } from "@nestjs/common";
|
||||
import { forwardRef, Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -21,6 +24,8 @@ import { Module } from "@nestjs/common";
|
||||
EventTypesModule_2024_06_14,
|
||||
UsersModule,
|
||||
TeamsModule,
|
||||
forwardRef(() => ConferencingModule),
|
||||
forwardRef(() => AuthModule),
|
||||
],
|
||||
providers: [
|
||||
TeamsEventTypesRepository,
|
||||
@@ -29,6 +34,7 @@ import { Module } from "@nestjs/common";
|
||||
OrganizationsTeamsRepository,
|
||||
OutputTeamEventTypesResponsePipe,
|
||||
OutputOrganizationsEventTypesService,
|
||||
OrganizationsConferencingService,
|
||||
],
|
||||
exports: [TeamsEventTypesRepository, TeamsEventTypesService],
|
||||
controllers: [TeamsEventTypesController],
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
|
||||
import { teamMetadataSchema } from "@calcom/platform-libraries";
|
||||
import { Prisma } from "@calcom/prisma/client";
|
||||
|
||||
@Injectable()
|
||||
@@ -83,4 +84,30 @@ export class TeamsRepository {
|
||||
where: { id: teamId },
|
||||
});
|
||||
}
|
||||
|
||||
async setDefaultConferencingApp(teamId: number, appSlug?: string, appLink?: string) {
|
||||
const team = await this.getById(teamId);
|
||||
const teamMetadata = teamMetadataSchema.parse(team?.metadata);
|
||||
|
||||
if (!team) {
|
||||
throw new NotFoundException("user not found");
|
||||
}
|
||||
|
||||
return await this.dbWrite.prisma.team.update({
|
||||
data: {
|
||||
metadata:
|
||||
typeof teamMetadata === "object"
|
||||
? {
|
||||
...teamMetadata,
|
||||
defaultConferencingApp: {
|
||||
appSlug: appSlug,
|
||||
appLink: appLink,
|
||||
},
|
||||
}
|
||||
: {},
|
||||
},
|
||||
|
||||
where: { id: teamId },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,14 @@ import { JwtService } from "@/modules/jwt/jwt.service";
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { Logger } from "@nestjs/common";
|
||||
import { PlatformAuthorizationToken } from "@prisma/client";
|
||||
import { DateTime } from "luxon";
|
||||
|
||||
@Injectable()
|
||||
export class TokensRepository {
|
||||
private readonly logger = new Logger("TokensRepository");
|
||||
|
||||
constructor(
|
||||
private readonly dbRead: PrismaReadService,
|
||||
private readonly dbWrite: PrismaWriteService,
|
||||
@@ -47,53 +50,6 @@ export class TokensRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async createOAuthTokens(clientId: string, ownerId: number, deleteOld?: boolean) {
|
||||
if (deleteOld) {
|
||||
try {
|
||||
await this.dbWrite.prisma.$transaction([
|
||||
this.dbWrite.prisma.accessToken.deleteMany({
|
||||
where: { client: { id: clientId }, userId: ownerId, expiresAt: { lte: new Date() } },
|
||||
}),
|
||||
this.dbWrite.prisma.refreshToken.deleteMany({
|
||||
where: {
|
||||
client: { id: clientId },
|
||||
userId: ownerId,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
} catch (err) {
|
||||
// discard.
|
||||
}
|
||||
}
|
||||
|
||||
const accessExpiry = DateTime.now().plus({ minute: 60 }).startOf("minute").toJSDate();
|
||||
const refreshExpiry = DateTime.now().plus({ year: 1 }).startOf("day").toJSDate();
|
||||
const [accessToken, refreshToken] = await this.dbWrite.prisma.$transaction([
|
||||
this.dbWrite.prisma.accessToken.create({
|
||||
data: {
|
||||
secret: this.jwtService.signAccessToken({ clientId, ownerId }),
|
||||
expiresAt: accessExpiry,
|
||||
client: { connect: { id: clientId } },
|
||||
owner: { connect: { id: ownerId } },
|
||||
},
|
||||
}),
|
||||
this.dbWrite.prisma.refreshToken.create({
|
||||
data: {
|
||||
secret: this.jwtService.signRefreshToken({ clientId, ownerId }),
|
||||
expiresAt: refreshExpiry,
|
||||
client: { connect: { id: clientId } },
|
||||
owner: { connect: { id: ownerId } },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
accessToken: accessToken.secret,
|
||||
accessTokenExpiresAt: accessToken.expiresAt,
|
||||
refreshToken: refreshToken.secret,
|
||||
};
|
||||
}
|
||||
|
||||
async getAccessTokenExpiryDate(accessTokenSecret: string) {
|
||||
const accessToken = await this.dbRead.prisma.accessToken.findFirst({
|
||||
where: {
|
||||
@@ -119,34 +75,57 @@ export class TokensRepository {
|
||||
return accessToken?.userId;
|
||||
}
|
||||
|
||||
async refreshOAuthTokens(clientId: string, refreshTokenSecret: string, tokenUserId: number) {
|
||||
async refreshOAuthTokens(ownerId: number, clientId: string) {
|
||||
try {
|
||||
await this.deleteOAuthTokens(ownerId, clientId);
|
||||
} catch (err) {
|
||||
this.logger.error("refreshOAuthTokens - Failed to delete old tokens", err);
|
||||
}
|
||||
|
||||
return await this.createOAuthTokens(ownerId, clientId);
|
||||
}
|
||||
|
||||
private async deleteOAuthTokens(ownerId: number, clientId: string) {
|
||||
await this.dbWrite.prisma.$transaction([
|
||||
this.dbWrite.prisma.accessToken.deleteMany({
|
||||
where: { client: { id: clientId }, userId: ownerId },
|
||||
}),
|
||||
this.dbWrite.prisma.refreshToken.deleteMany({
|
||||
where: {
|
||||
client: { id: clientId },
|
||||
userId: ownerId,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
async createOAuthTokens(ownerId: number, clientId: string) {
|
||||
const accessExpiry = DateTime.now().plus({ minute: 60 }).startOf("minute").toJSDate();
|
||||
const refreshExpiry = DateTime.now().plus({ year: 1 }).startOf("day").toJSDate();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [_, _refresh, accessToken, refreshToken] = await this.dbWrite.prisma.$transaction([
|
||||
this.dbWrite.prisma.accessToken.deleteMany({
|
||||
where: { client: { id: clientId }, expiresAt: { lte: new Date() } },
|
||||
}),
|
||||
this.dbWrite.prisma.refreshToken.delete({ where: { secret: refreshTokenSecret } }),
|
||||
const [accessToken, refreshToken] = await this.dbWrite.prisma.$transaction([
|
||||
this.dbWrite.prisma.accessToken.create({
|
||||
data: {
|
||||
secret: this.jwtService.signAccessToken({ clientId, userId: tokenUserId }),
|
||||
secret: this.jwtService.signAccessToken({ clientId, ownerId, expiresAt: accessExpiry.valueOf() }),
|
||||
expiresAt: accessExpiry,
|
||||
client: { connect: { id: clientId } },
|
||||
owner: { connect: { id: tokenUserId } },
|
||||
owner: { connect: { id: ownerId } },
|
||||
},
|
||||
}),
|
||||
this.dbWrite.prisma.refreshToken.create({
|
||||
data: {
|
||||
secret: this.jwtService.signRefreshToken({ clientId, userId: tokenUserId }),
|
||||
secret: this.jwtService.signRefreshToken({ clientId, ownerId, expiresAt: refreshExpiry.valueOf() }),
|
||||
expiresAt: refreshExpiry,
|
||||
client: { connect: { id: clientId } },
|
||||
owner: { connect: { id: tokenUserId } },
|
||||
owner: { connect: { id: ownerId } },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
return { accessToken, refreshToken };
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
};
|
||||
}
|
||||
|
||||
async getAccessTokenClient(accessToken: string) {
|
||||
|
||||
@@ -276,7 +276,7 @@
|
||||
"post": {
|
||||
"operationId": "OAuthClientUsersController_forceRefresh",
|
||||
"summary": "Force refresh tokens",
|
||||
"description": "If you have lost managed user access or refresh token, then you can get new ones by using OAuth credentials.\n Each access token is valid for 60 minutes and each refresh token for 1 year. Make sure to store them later in your database, for example, by updating the User model to have `calAccessToken` and `calRefreshToken` columns.",
|
||||
"description": "If you have lost managed user access or refresh token, then you can get new ones by using OAuth credentials. Access token is valid for 60 minutes and refresh token for 1 year. Make sure to store them in your database, for example, in your User database model `calAccessToken` and `calRefreshToken` fields.\nResponse also contains `accessTokenExpiresAt` and `refreshTokenExpiresAt` fields, but if you decode the jwt token the payload will contain `clientId` (OAuth client ID), `ownerId` (user to whom token belongs ID), `iat` (issued at time) and `expiresAt` (when does the token expire) fields.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "x-cal-secret-key",
|
||||
@@ -325,7 +325,7 @@
|
||||
"post": {
|
||||
"operationId": "OAuthFlowController_refreshTokens",
|
||||
"summary": "Refresh managed user tokens",
|
||||
"description": "If managed user access token is expired then get a new one using this endpoint. Each access token is valid for 60 minutes and \n each refresh token for 1 year. Make sure to store them later in your database, for example, by updating the User model to have `calAccessToken` and `calRefreshToken` columns.",
|
||||
"description": "If managed user access token is expired then get a new one using this endpoint - it will also refresh the refresh token, because we use\n \"refresh token rotation\" mechanism. Access token is valid for 60 minutes and refresh token for 1 year. Make sure to store them in your database, for example, in your User database model `calAccessToken` and `calRefreshToken` fields.\nResponse also contains `accessTokenExpiresAt` and `refreshTokenExpiresAt` fields, but if you decode the jwt token the payload will contain `clientId` (OAuth client ID), `ownerId` (user to whom token belongs ID), `iat` (issued at time) and `expiresAt` (when does the token expire) fields.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "clientId",
|
||||
@@ -9618,24 +9618,30 @@
|
||||
"CreateManagedUserData": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"accessToken": {
|
||||
"type": "string",
|
||||
"example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
|
||||
},
|
||||
"refreshToken": {
|
||||
"type": "string",
|
||||
"example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
|
||||
},
|
||||
"user": {
|
||||
"$ref": "#/components/schemas/ManagedUserOutput"
|
||||
},
|
||||
"accessToken": {
|
||||
"type": "string"
|
||||
},
|
||||
"refreshToken": {
|
||||
"type": "string"
|
||||
},
|
||||
"accessTokenExpiresAt": {
|
||||
"type": "number"
|
||||
},
|
||||
"refreshTokenExpiresAt": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"user",
|
||||
"accessToken",
|
||||
"refreshToken",
|
||||
"accessTokenExpiresAt"
|
||||
"user",
|
||||
"accessTokenExpiresAt",
|
||||
"refreshTokenExpiresAt"
|
||||
]
|
||||
},
|
||||
"CreateManagedUserOutput": {
|
||||
@@ -9786,12 +9792,16 @@
|
||||
},
|
||||
"accessTokenExpiresAt": {
|
||||
"type": "number"
|
||||
},
|
||||
"refreshTokenExpiresAt": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"accessToken",
|
||||
"refreshToken",
|
||||
"accessTokenExpiresAt"
|
||||
"accessTokenExpiresAt",
|
||||
"refreshTokenExpiresAt"
|
||||
]
|
||||
},
|
||||
"KeysResponseDto": {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { TestingModule } from "@nestjs/testing";
|
||||
import { PlatformOAuthClient, Prisma } from "@prisma/client";
|
||||
|
||||
import { CreateOAuthClientInput } from "@calcom/platform-types";
|
||||
|
||||
export class OOORepositoryFixture {
|
||||
private prismaReadClient: PrismaReadService["prisma"];
|
||||
private prismaWriteClient: PrismaWriteService["prisma"];
|
||||
|
||||
constructor(private readonly module: TestingModule) {
|
||||
this.prismaReadClient = module.get(PrismaReadService).prisma;
|
||||
this.prismaWriteClient = module.get(PrismaWriteService).prisma;
|
||||
}
|
||||
|
||||
async create(data: Prisma.OutOfOfficeEntryCreateInput) {
|
||||
return this.prismaWriteClient.outOfOfficeEntry.create({
|
||||
data: {
|
||||
...data,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async delete(id: number) {
|
||||
return this.prismaWriteClient.outOfOfficeEntry.delete({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# Source maps
|
||||
*.source/*
|
||||
@@ -0,0 +1,36 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
@@ -0,0 +1,9 @@
|
||||
import { HomeLayout } from "fumadocs-ui/layouts/home";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { baseOptions } from "../layout.config";
|
||||
|
||||
export default function Layout({ children }: { children: ReactNode }) {
|
||||
// @ts-expect-error weird type check
|
||||
return <HomeLayout {...baseOptions}>{children}</HomeLayout>;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export default function Page() {
|
||||
return (
|
||||
<div className="font-cal -mt-16 flex min-h-screen w-screen items-center justify-center border pt-16 text-center ">
|
||||
<div>
|
||||
<h1 className="text-7xl font-bold uppercase leading-none tracking-tight md:text-8xl">
|
||||
Open UI for absolutely
|
||||
<br /> Everyone
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { componentSource } from "@/app/source";
|
||||
import { createSearchAPI } from "fumadocs-core/search/server";
|
||||
|
||||
const indexes = [componentSource].flatMap((src) =>
|
||||
src.getPages().map((page) => ({
|
||||
title: page.data.title,
|
||||
description: page.data.description,
|
||||
structuredData: page.data.structuredData,
|
||||
id: page.url,
|
||||
url: page.url,
|
||||
}))
|
||||
);
|
||||
|
||||
export const { GET } = createSearchAPI("advanced", {
|
||||
indexes,
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
|
||||
type Props = {
|
||||
name: string;
|
||||
};
|
||||
export const Icon: React.FC<PropsWithChildren<Props>> = (props) => {
|
||||
return (
|
||||
<div className="text-gray-12 flex flex-col items-center justify-center gap-4">
|
||||
<div className="size-12 border-gray-5 bg-gray-3 flex aspect-square items-center justify-center rounded-lg border ">
|
||||
{props.children}
|
||||
</div>
|
||||
<span className="font-mono text-sm">
|
||||
{"<"}
|
||||
{props.name}
|
||||
{"/>"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Icon.displayName = "Icon";
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { type PropsWithChildren, useState } from "react";
|
||||
|
||||
import className from "@calcom/ui/classNames";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
|
||||
type Props = {
|
||||
customCodeSnippet?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const RenderComponentWithSnippet: React.FC<PropsWithChildren<Props>> = (props) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
// const snippet =
|
||||
// props.customCodeSnippet ??
|
||||
// reactElementToJSXString(props.children, {
|
||||
// showFunctions: true,
|
||||
// useBooleanShorthandSyntax: true,
|
||||
|
||||
// displayName: (node) => {
|
||||
// // @ts-ignore
|
||||
// return node?.type.displayName ?? node?.type ?? "Unknown";
|
||||
// },
|
||||
// });
|
||||
return (
|
||||
<div className="border-gray-6 bg-gray-1 rounded-lg border">
|
||||
<div className={className("not-prose p-8 xl:p-12", props.className)}>{props.children}</div>
|
||||
|
||||
<div className="bg-gray-3 border-gray-6 flex items-center justify-start border-b border-t p-2">
|
||||
<Button
|
||||
color="minimal"
|
||||
onClick={() => {
|
||||
setOpen(!open);
|
||||
}}>
|
||||
Code (WIP)
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={className(
|
||||
"bg-gray-2 max-h-96 w-full overflow-y-scroll transition-all",
|
||||
open ? "block" : "hidden"
|
||||
)}>
|
||||
<div className="flex items-start">
|
||||
{/* <pre className="py-0 text-gray-8 text-right">
|
||||
{snippet
|
||||
.split("\n")
|
||||
.map((_line, i) => i + 1)
|
||||
.join("\n")}
|
||||
</pre>
|
||||
<pre className="py-0 text-gray-12 w-full">{snippet}</pre> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
|
||||
export const Row: React.FC<PropsWithChildren> = (props) => {
|
||||
return <div className="flex w-full items-center justify-between gap-8">{props.children}</div>;
|
||||
};
|
||||
|
||||
Row.displayName = "Row";
|
||||
@@ -0,0 +1,56 @@
|
||||
import { componentSource } from "@/app/source";
|
||||
import { TooltipProvider } from "@radix-ui/react-tooltip";
|
||||
import { createTypeTable } from "fumadocs-typescript/ui";
|
||||
import defaultMdxComponents from "fumadocs-ui/mdx";
|
||||
import { DocsBody, DocsDescription, DocsPage, DocsTitle } from "fumadocs-ui/page";
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
|
||||
export default async function Page(props: { params: Promise<{ slug?: string[] }> }) {
|
||||
const params = await props.params;
|
||||
|
||||
const page = componentSource.getPage(params.slug);
|
||||
|
||||
if (!page) {
|
||||
notFound();
|
||||
}
|
||||
const { AutoTypeTable } = createTypeTable();
|
||||
|
||||
const MDX = page.data.body;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* @ts-expect-error idk why fumadocs doersnt like this */}
|
||||
<DocsPage toc={page.data.toc} full={page.data.full}>
|
||||
<Toaster />
|
||||
<DocsTitle>{page.data.title}</DocsTitle>
|
||||
|
||||
<DocsDescription>{page.data.description}</DocsDescription>
|
||||
|
||||
<DocsBody>
|
||||
<TooltipProvider>
|
||||
{/* @ts-expect-error idk why fumadocs doersnt like this */}
|
||||
<MDX components={{ ...defaultMdxComponents, AutoTypeTable }} />
|
||||
</TooltipProvider>
|
||||
</DocsBody>
|
||||
</DocsPage>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
return componentSource.generateParams();
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ slug?: string[] }> }) {
|
||||
const page = componentSource.getPage((await params).slug);
|
||||
if (!page) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return {
|
||||
title: page.data.title,
|
||||
description: page.data.description,
|
||||
} satisfies Metadata;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { componentSource } from "@/app/source";
|
||||
import { TooltipProvider } from "@radix-ui/react-tooltip";
|
||||
import { DocsLayout } from "fumadocs-ui/layouts/notebook";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { IconSprites } from "@calcom/ui/components/icon";
|
||||
|
||||
import { baseOptions } from "../layout.config";
|
||||
|
||||
export default function Layout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<DocsLayout tree={componentSource.pageTree} {...baseOptions}>
|
||||
<TooltipProvider>{children}</TooltipProvider>
|
||||
<IconSprites />
|
||||
</DocsLayout>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
||||
import type { HomeLayoutProps } from "fumadocs-ui/layouts/home";
|
||||
|
||||
/**
|
||||
* Shared layout configurations
|
||||
*
|
||||
* you can configure layouts individually from:
|
||||
* Home Layout: app/(home)/layout.tsx
|
||||
* Docs Layout: app/docs/layout.tsx
|
||||
*/
|
||||
export const baseOptions: HomeLayoutProps = {
|
||||
nav: {
|
||||
title: "Cal.com",
|
||||
},
|
||||
githubUrl: "https://github.com/calcom/cal.com",
|
||||
links: [
|
||||
{
|
||||
text: "Design",
|
||||
url: "/design",
|
||||
active: "nested-url",
|
||||
},
|
||||
{
|
||||
text: "GitHub",
|
||||
url: "https://github.com/calcom/cal.com",
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import { RootProvider } from "fumadocs-ui/provider";
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
|
||||
import "./global.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
|
||||
<RootProvider>{children}</RootProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { components, docs, meta } from "@/.source";
|
||||
import { loader } from "fumadocs-core/source";
|
||||
import { createMDXSource } from "fumadocs-mdx";
|
||||
|
||||
export const source = loader({
|
||||
baseUrl: "/docs",
|
||||
source: createMDXSource(docs, meta),
|
||||
});
|
||||
|
||||
export const componentSource = loader({
|
||||
baseUrl: "/design",
|
||||
source: createMDXSource(components, []),
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Toaster } from "react-hot-toast";
|
||||
|
||||
import { Status } from "./Status";
|
||||
|
||||
export function MDXLayout({ children, frontmatter }: { children: React.ReactNode; frontmatter: any }) {
|
||||
return (
|
||||
<div className="prose max-w-none">
|
||||
<Toaster />
|
||||
{frontmatter.airtableId && (
|
||||
<Status
|
||||
airtableId={frontmatter.airtableId}
|
||||
designStatus={frontmatter.designStatus}
|
||||
devStatus={frontmatter.devStatus}
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
interface BorderRadiusToken {
|
||||
name: string;
|
||||
value: string;
|
||||
className: string;
|
||||
}
|
||||
|
||||
interface BorderRadiusTableProps {
|
||||
tokens: BorderRadiusToken[];
|
||||
}
|
||||
|
||||
const tokens: BorderRadiusToken[] = [
|
||||
{ name: "None", value: "0px", className: "rounded-none" },
|
||||
{ name: "Small", value: "0.125rem", className: "rounded-sm" },
|
||||
{ name: "Default", value: "0.25rem", className: "rounded" },
|
||||
{ name: "Medium", value: "0.375rem", className: "rounded-md" },
|
||||
{ name: "Large", value: "0.5rem", className: "rounded-lg" },
|
||||
{ name: "XLarge", value: "0.75rem", className: "rounded-xl" },
|
||||
{ name: "2XLarge", value: "1rem", className: "rounded-2xl" },
|
||||
{ name: "3XLarge", value: "1.5rem", className: "rounded-3xl" },
|
||||
{ name: "Full", value: "9999px", className: "rounded-full" },
|
||||
];
|
||||
|
||||
export const BorderRadiusTable: React.FC<BorderRadiusTableProps> = () => {
|
||||
const handleCopy = async (value: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
showToast(`Copied ${value}`, "success");
|
||||
} catch (error) {
|
||||
console.error("Failed to copy:", error);
|
||||
showToast("Failed to copy", "error");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Toaster position="bottom-right" />
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{tokens.map((token) => (
|
||||
<div
|
||||
key={token.name}
|
||||
onClick={() => handleCopy(token.className)}
|
||||
className="border-subtle bg-default hover:bg-subtle group relative cursor-pointer overflow-hidden rounded-lg border p-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-emphasis text-sm font-medium">{token.name}</p>
|
||||
<p className="text-subtle mt-1 text-xs">{token.className}</p>
|
||||
</div>
|
||||
<p className="text-subtle text-sm">{token.value}</p>
|
||||
</div>
|
||||
<div className={`bg-emphasis h-16 w-full ${token.className}`} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,371 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
type IconName =
|
||||
| "activity"
|
||||
| "arrow-down"
|
||||
| "arrow-left"
|
||||
| "arrow-right"
|
||||
| "arrow-up-right"
|
||||
| "arrow-up"
|
||||
| "asterisk"
|
||||
| "at-sign"
|
||||
| "atom"
|
||||
| "badge-check"
|
||||
| "ban"
|
||||
| "bell"
|
||||
| "binary"
|
||||
| "blocks"
|
||||
| "bold"
|
||||
| "book-open-check"
|
||||
| "book-open"
|
||||
| "book-user"
|
||||
| "building"
|
||||
| "calendar-check-2"
|
||||
| "calendar-days"
|
||||
| "calendar-heart"
|
||||
| "calendar-range"
|
||||
| "calendar-search"
|
||||
| "calendar-x-2"
|
||||
| "calendar"
|
||||
| "chart-bar"
|
||||
| "chart-line"
|
||||
| "check-check"
|
||||
| "check"
|
||||
| "chevron-down"
|
||||
| "chevron-left"
|
||||
| "chevron-right"
|
||||
| "chevron-up"
|
||||
| "chevrons-down-up"
|
||||
| "chevrons-left"
|
||||
| "chevrons-right"
|
||||
| "chevrons-up-down"
|
||||
| "circle-alert"
|
||||
| "circle-arrow-up"
|
||||
| "circle-check-big"
|
||||
| "circle-check"
|
||||
| "circle-help"
|
||||
| "circle-x"
|
||||
| "circle"
|
||||
| "clipboard-check"
|
||||
| "clipboard"
|
||||
| "clock"
|
||||
| "code"
|
||||
| "columns-3"
|
||||
| "command"
|
||||
| "contact"
|
||||
| "copy"
|
||||
| "corner-down-left"
|
||||
| "corner-down-right"
|
||||
| "credit-card"
|
||||
| "disc"
|
||||
| "dot"
|
||||
| "download"
|
||||
| "ellipsis-vertical"
|
||||
| "ellipsis"
|
||||
| "external-link"
|
||||
| "eye-off"
|
||||
| "eye"
|
||||
| "file-down"
|
||||
| "file-text"
|
||||
| "file"
|
||||
| "filter"
|
||||
| "fingerprint"
|
||||
| "flag"
|
||||
| "folder"
|
||||
| "gift"
|
||||
| "git-merge"
|
||||
| "github"
|
||||
| "globe"
|
||||
| "grid-3x3"
|
||||
| "handshake"
|
||||
| "info"
|
||||
| "italic"
|
||||
| "key"
|
||||
| "layers"
|
||||
| "layout-dashboard"
|
||||
| "link-2"
|
||||
| "link"
|
||||
| "loader"
|
||||
| "lock-open"
|
||||
| "lock"
|
||||
| "log-out"
|
||||
| "mail-open"
|
||||
| "mail"
|
||||
| "map-pin"
|
||||
| "map"
|
||||
| "menu"
|
||||
| "message-circle"
|
||||
| "messages-square"
|
||||
| "moon"
|
||||
| "paintbrush"
|
||||
| "paperclip"
|
||||
| "pencil"
|
||||
| "phone-call"
|
||||
| "phone"
|
||||
| "plus"
|
||||
| "refresh-ccw"
|
||||
| "refresh-cw"
|
||||
| "repeat"
|
||||
| "rocket"
|
||||
| "rotate-ccw"
|
||||
| "rotate-cw"
|
||||
| "search"
|
||||
| "send"
|
||||
| "settings"
|
||||
| "share-2"
|
||||
| "shield-check"
|
||||
| "shield"
|
||||
| "shuffle"
|
||||
| "sliders-horizontal"
|
||||
| "sliders-vertical"
|
||||
| "smartphone"
|
||||
| "sparkles"
|
||||
| "square-check"
|
||||
| "star"
|
||||
| "sun"
|
||||
| "sunrise"
|
||||
| "sunset"
|
||||
| "tags"
|
||||
| "terminal"
|
||||
| "trash-2"
|
||||
| "trash"
|
||||
| "trello"
|
||||
| "triangle-alert"
|
||||
| "upload"
|
||||
| "user-check"
|
||||
| "user-plus"
|
||||
| "user-x"
|
||||
| "user"
|
||||
| "users"
|
||||
| "venetian-mask"
|
||||
| "video"
|
||||
| "waypoints"
|
||||
| "webhook"
|
||||
| "x"
|
||||
| "zap";
|
||||
|
||||
interface IconGridProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface CopyMenuProps {
|
||||
name: IconName;
|
||||
onCopy: (format: string) => void;
|
||||
}
|
||||
|
||||
const CopyMenu: React.FC<CopyMenuProps> = ({ name, onCopy }) => {
|
||||
return (
|
||||
<div className="absolute inset-2 flex items-center justify-center bg-black/10 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<div className="bg-default space-y-1 rounded-md p-2 shadow-lg">
|
||||
<button
|
||||
onClick={() => onCopy(`<Icon name="${name}" className="h-4 w-4" />`)}
|
||||
className="hover:bg-subtle w-full rounded px-3 py-1 text-left text-sm">
|
||||
Copy Component
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const IconGrid: React.FC<IconGridProps> = ({ className }) => {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
const handleCopy = async (value: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
showToast(`Copied ${value}`, "success");
|
||||
} catch (error) {
|
||||
console.error("Failed to copy:", error);
|
||||
showToast("Failed to copy", "error");
|
||||
}
|
||||
};
|
||||
|
||||
const icons: IconName[] = [
|
||||
"activity",
|
||||
"arrow-down",
|
||||
"arrow-left",
|
||||
"arrow-right",
|
||||
"arrow-up-right",
|
||||
"arrow-up",
|
||||
"asterisk",
|
||||
"at-sign",
|
||||
"atom",
|
||||
"badge-check",
|
||||
"ban",
|
||||
"bell",
|
||||
"binary",
|
||||
"blocks",
|
||||
"bold",
|
||||
"book-open-check",
|
||||
"book-open",
|
||||
"book-user",
|
||||
"building",
|
||||
"calendar-check-2",
|
||||
"calendar-days",
|
||||
"calendar-heart",
|
||||
"calendar-range",
|
||||
"calendar-search",
|
||||
"calendar-x-2",
|
||||
"calendar",
|
||||
"chart-bar",
|
||||
"chart-line",
|
||||
"check-check",
|
||||
"check",
|
||||
"chevron-down",
|
||||
"chevron-left",
|
||||
"chevron-right",
|
||||
"chevron-up",
|
||||
"chevrons-down-up",
|
||||
"chevrons-left",
|
||||
"chevrons-right",
|
||||
"chevrons-up-down",
|
||||
"circle-alert",
|
||||
"circle-arrow-up",
|
||||
"circle-check-big",
|
||||
"circle-check",
|
||||
"circle-help",
|
||||
"circle-x",
|
||||
"circle",
|
||||
"clipboard-check",
|
||||
"clipboard",
|
||||
"clock",
|
||||
"code",
|
||||
"columns-3",
|
||||
"command",
|
||||
"contact",
|
||||
"copy",
|
||||
"corner-down-left",
|
||||
"corner-down-right",
|
||||
"credit-card",
|
||||
"disc",
|
||||
"dot",
|
||||
"download",
|
||||
"ellipsis-vertical",
|
||||
"ellipsis",
|
||||
"external-link",
|
||||
"eye-off",
|
||||
"eye",
|
||||
"file-down",
|
||||
"file-text",
|
||||
"file",
|
||||
"filter",
|
||||
"fingerprint",
|
||||
"flag",
|
||||
"folder",
|
||||
"gift",
|
||||
"git-merge",
|
||||
"github",
|
||||
"globe",
|
||||
"grid-3x3",
|
||||
"handshake",
|
||||
"info",
|
||||
"italic",
|
||||
"key",
|
||||
"layers",
|
||||
"layout-dashboard",
|
||||
"link-2",
|
||||
"link",
|
||||
"loader",
|
||||
"lock-open",
|
||||
"lock",
|
||||
"log-out",
|
||||
"mail-open",
|
||||
"mail",
|
||||
"map-pin",
|
||||
"map",
|
||||
"menu",
|
||||
"message-circle",
|
||||
"messages-square",
|
||||
"moon",
|
||||
"paintbrush",
|
||||
"paperclip",
|
||||
"pencil",
|
||||
"phone-call",
|
||||
"phone",
|
||||
"plus",
|
||||
"refresh-ccw",
|
||||
"refresh-cw",
|
||||
"repeat",
|
||||
"rocket",
|
||||
"rotate-ccw",
|
||||
"rotate-cw",
|
||||
"search",
|
||||
"send",
|
||||
"settings",
|
||||
"share-2",
|
||||
"shield-check",
|
||||
"shield",
|
||||
"shuffle",
|
||||
"sliders-horizontal",
|
||||
"sliders-vertical",
|
||||
"smartphone",
|
||||
"sparkles",
|
||||
"square-check",
|
||||
"star",
|
||||
"sun",
|
||||
"sunrise",
|
||||
"sunset",
|
||||
"tags",
|
||||
"terminal",
|
||||
"trash-2",
|
||||
"trash",
|
||||
"trello",
|
||||
"triangle-alert",
|
||||
"upload",
|
||||
"user-check",
|
||||
"user-plus",
|
||||
"user-x",
|
||||
"user",
|
||||
"users",
|
||||
"venetian-mask",
|
||||
"video",
|
||||
"waypoints",
|
||||
"webhook",
|
||||
"x",
|
||||
"zap",
|
||||
];
|
||||
|
||||
const filteredIcons = useMemo(() => {
|
||||
if (!searchQuery) return icons;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return icons.filter((icon) => icon.toLowerCase().includes(query));
|
||||
}, [searchQuery]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Toaster position="bottom-right" />
|
||||
<div className={className}>
|
||||
<div className="mb-8">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search icons..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="border-subtle bg-default text-emphasis focus:ring-emphasis placeholder:text-subtle w-full rounded-md border px-4 py-2 focus:outline-none focus:ring-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
|
||||
{filteredIcons.map((iconName) => (
|
||||
<div
|
||||
key={iconName}
|
||||
className="border-subtle bg-default group relative overflow-hidden rounded-lg border p-4">
|
||||
<div className="flex flex-col items-center space-y-2">
|
||||
<div className="bg-subtle flex h-12 w-12 items-center justify-center rounded-md">
|
||||
<Icon name={iconName} className="text-emphasis h-6 w-6" />
|
||||
</div>
|
||||
<p className="text-emphasis text-center text-sm">{iconName}</p>
|
||||
</div>
|
||||
<CopyMenu name={iconName} onCopy={handleCopy} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
interface ShadowToken {
|
||||
name: string;
|
||||
className: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface ShadowTableProps {
|
||||
tokens: ShadowToken[];
|
||||
}
|
||||
|
||||
const tokens: ShadowToken[] = [
|
||||
{
|
||||
name: "Dropdown",
|
||||
className: "shadow-dropdown",
|
||||
description: "Used for dropdown menus and popovers",
|
||||
},
|
||||
{
|
||||
name: "Solid Gray Rested",
|
||||
className: "shadow-solid-gray-rested",
|
||||
description: "Default state for solid gray buttons",
|
||||
},
|
||||
{
|
||||
name: "Solid Gray Hover",
|
||||
className: "shadow-solid-gray-hover",
|
||||
description: "Hover state for solid gray buttons",
|
||||
},
|
||||
{
|
||||
name: "Solid Gray Active",
|
||||
className: "shadow-solid-gray-active",
|
||||
description: "Active/pressed state for solid gray buttons",
|
||||
},
|
||||
{
|
||||
name: "Outline Gray Rested",
|
||||
className: "shadow-outline-gray-rested",
|
||||
description: "Default state for outline gray buttons",
|
||||
},
|
||||
{
|
||||
name: "Outline Gray Hover",
|
||||
className: "shadow-outline-gray-hover",
|
||||
description: "Hover state for outline gray buttons",
|
||||
},
|
||||
{
|
||||
name: "Outline Gray Active",
|
||||
className: "shadow-outline-gray-active",
|
||||
description: "Active/pressed state for outline gray buttons",
|
||||
},
|
||||
{
|
||||
name: "Outline Red Rested",
|
||||
className: "shadow-outline-red-rested",
|
||||
description: "Default state for outline red buttons",
|
||||
},
|
||||
{
|
||||
name: "Outline Red Hover",
|
||||
className: "shadow-outline-red-hover",
|
||||
description: "Hover state for outline red buttons",
|
||||
},
|
||||
{
|
||||
name: "Outline Red Active",
|
||||
className: "shadow-outline-red-active",
|
||||
description: "Active/pressed state for outline red buttons",
|
||||
},
|
||||
{
|
||||
name: "Elevation Low",
|
||||
className: "shadow-elevation-low",
|
||||
description: "Subtle elevation for UI elements",
|
||||
},
|
||||
{
|
||||
name: "Button Solid Brand Default",
|
||||
className: "shadow-button-solid-brand-default",
|
||||
description: "Default state for solid brand buttons",
|
||||
},
|
||||
{
|
||||
name: "Button Solid Brand Hover",
|
||||
className: "shadow-button-solid-brand-hover",
|
||||
description: "Hover state for solid brand buttons",
|
||||
},
|
||||
{
|
||||
name: "Button Solid Brand Active",
|
||||
className: "shadow-button-solid-brand-active",
|
||||
description: "Active/pressed state for solid brand buttons",
|
||||
},
|
||||
];
|
||||
|
||||
export const ShadowTable: React.FC<ShadowTableProps> = () => {
|
||||
const handleCopy = async (value: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
showToast(`Copied ${value}`, "success");
|
||||
} catch (error) {
|
||||
console.error("Failed to copy:", error);
|
||||
showToast("Failed to copy", "error");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Toaster position="bottom-right" />
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||
{tokens.map((token) => (
|
||||
<div
|
||||
key={token.name}
|
||||
onClick={() => handleCopy(token.className)}
|
||||
className="group relative cursor-pointer">
|
||||
<div
|
||||
className={`border-subtle bg-default hover:bg-subtle relative overflow-hidden rounded-lg border p-4 transition-colors ${token.className}`}>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<p className="text-emphasis text-sm font-medium">{token.name}</p>
|
||||
<p className="text-subtle mt-1 text-xs">{token.className}</p>
|
||||
</div>
|
||||
<p className="text-subtle text-sm">{token.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
|
||||
interface SpacingToken {
|
||||
name: string;
|
||||
value: string;
|
||||
pixels: string;
|
||||
}
|
||||
|
||||
interface SpacingTableProps {
|
||||
tokens: SpacingToken[];
|
||||
}
|
||||
|
||||
export const SpacingTable: React.FC<SpacingTableProps> = ({ tokens }) => {
|
||||
return (
|
||||
<>
|
||||
<Toaster position="bottom-right" />
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{tokens.map((token, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="border-subtle bg-default group relative overflow-hidden rounded-lg border">
|
||||
<div className="flex items-center gap-4 p-4">
|
||||
<div className="w-[100px] space-y-1">
|
||||
<p className="text-emphasis text-sm font-medium">
|
||||
{token.name === "px" ? "px" : `${token.name} (${token.pixels})`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-subtle relative h-8 rounded">
|
||||
<div
|
||||
className="absolute left-0 top-1/2 h-2 -translate-y-1/2 bg-red-500"
|
||||
style={{ width: token.pixels }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
|
||||
import { getColorFormats, copyToClipboard } from "@/lib/colorUtils";
|
||||
import React from "react";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
interface ColorSwatch {
|
||||
name: string;
|
||||
value: string;
|
||||
className: string;
|
||||
}
|
||||
|
||||
interface ColorSection {
|
||||
title: string;
|
||||
description?: string;
|
||||
colors: ColorSwatch[];
|
||||
}
|
||||
|
||||
interface TypeTableProps {
|
||||
sections: ColorSection[];
|
||||
}
|
||||
|
||||
interface CopyMenuProps {
|
||||
color: string;
|
||||
className: string;
|
||||
onCopy: (format: string) => void;
|
||||
}
|
||||
|
||||
const CopyMenu: React.FC<CopyMenuProps> = ({ color, className, onCopy }) => {
|
||||
const formats = getColorFormats(color, className);
|
||||
if (!formats) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute inset-2 flex items-center justify-center opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<div className="bg-default space-y-1 rounded-md p-2 shadow-lg">
|
||||
<button
|
||||
onClick={() => onCopy(formats.hex)}
|
||||
className="hover:bg-subtle w-full rounded px-3 py-1 text-left text-sm">
|
||||
Copy as HEX
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onCopy(formats.rgb)}
|
||||
className="hover:bg-subtle w-full rounded px-3 py-1 text-left text-sm">
|
||||
Copy as RGB
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onCopy(formats.hsl)}
|
||||
className="hover:bg-subtle w-full rounded px-3 py-1 text-left text-sm">
|
||||
Copy as HSL
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onCopy(formats.tailwind)}
|
||||
className="hover:bg-subtle w-full rounded px-3 py-1 text-left text-sm">
|
||||
Copy Tailwind Class
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const TypeTable: React.FC<TypeTableProps> = ({ sections }) => {
|
||||
const handleCopy = async (colorValue: string) => {
|
||||
try {
|
||||
await copyToClipboard(colorValue);
|
||||
showToast(`Copied ${colorValue}`, "success");
|
||||
} catch (error) {
|
||||
console.error("Failed to copy color:", error);
|
||||
showToast("Failed to copy color", "error");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Toaster position="bottom-right" />
|
||||
<div className="space-y-8">
|
||||
{sections.map((section, sectionIndex) => (
|
||||
<div key={sectionIndex} className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-emphasis text-lg font-semibold">{section.title}</h3>
|
||||
{section.description && <p className="text-subtle text-sm">{section.description}</p>}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{section.colors.map((color, colorIndex) => (
|
||||
<div key={colorIndex} className="border-subtle bg-default overflow-hidden rounded-lg border">
|
||||
<div className="group relative">
|
||||
<div className={`h-36 w-full ${color.className}`} title={color.value} />
|
||||
<CopyMenu color={color.value} className={color.className} onCopy={handleCopy} />
|
||||
</div>
|
||||
<div className="space-y-1 p-2">
|
||||
<p className="text-emphasis text-sm font-medium">{color.name}</p>
|
||||
<p className="text-subtle text-xs">{color.value}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
interface TypographyStyle {
|
||||
name: string;
|
||||
className: string;
|
||||
specs: {
|
||||
fontFamily: string;
|
||||
fontSize: string;
|
||||
lineHeight: string;
|
||||
weight: string;
|
||||
};
|
||||
category?: string;
|
||||
}
|
||||
|
||||
interface TypographySection {
|
||||
title: string;
|
||||
description?: string;
|
||||
styles: TypographyStyle[];
|
||||
}
|
||||
|
||||
interface TypographyTableProps {
|
||||
sections: TypographySection[];
|
||||
}
|
||||
|
||||
interface CopyMenuProps {
|
||||
style: TypographyStyle;
|
||||
onCopy: (format: string) => void;
|
||||
}
|
||||
|
||||
const CopyMenu: React.FC<CopyMenuProps> = ({ style, onCopy }) => {
|
||||
return (
|
||||
<div className="absolute inset-2 flex items-center justify-center bg-black/10 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<div className="bg-default space-y-1 rounded-md p-2 shadow-lg">
|
||||
<button
|
||||
onClick={() => onCopy(style.className)}
|
||||
className="hover:bg-subtle w-full rounded px-3 py-1 text-left text-sm">
|
||||
Copy Tailwind Class
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
onCopy(`font-family: ${style.specs.fontFamily};
|
||||
font-size: ${style.specs.fontSize}px;
|
||||
line-height: ${style.specs.lineHeight};
|
||||
font-weight: ${style.specs.weight};`)
|
||||
}
|
||||
className="hover:bg-subtle w-full rounded px-3 py-1 text-left text-sm">
|
||||
Copy CSS
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const TypographyTable: React.FC<TypographyTableProps> = ({ sections }) => {
|
||||
const handleCopy = async (value: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
showToast(`Copied to clipboard`, "success");
|
||||
} catch (error) {
|
||||
console.error("Failed to copy:", error);
|
||||
showToast("Failed to copy", "error");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Toaster position="bottom-right" />
|
||||
<div className="space-y-12">
|
||||
{sections.map((section, sectionIndex) => (
|
||||
<div key={sectionIndex} className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-emphasis text-2xl font-semibold">{section.title}</h3>
|
||||
{section.description && <p className="text-subtle mt-2 text-base">{section.description}</p>}
|
||||
</div>
|
||||
<div className="grid gap-6">
|
||||
{section.styles.map((style, styleIndex) => (
|
||||
<div
|
||||
key={styleIndex}
|
||||
className="border-subtle bg-default group relative overflow-hidden rounded-lg border p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-emphasis text-sm font-medium">{style.name}</p>
|
||||
<p className="text-subtle mt-1 text-xs">{style.className}</p>
|
||||
</div>
|
||||
<div className={style.className}>The quick brown fox jumps over the lazy dog</div>
|
||||
</div>
|
||||
<div className="text-subtle space-y-2 text-sm">
|
||||
<p>Font: {style.specs.fontFamily}</p>
|
||||
<p>Size: {style.specs.fontSize}px</p>
|
||||
<p>Line Height: {style.specs.lineHeight}</p>
|
||||
<p>Weight: {style.specs.weight}</p>
|
||||
</div>
|
||||
</div>
|
||||
<CopyMenu style={style} onCopy={handleCopy} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
title: Border Radius
|
||||
description: Cal.com's border radius system
|
||||
---
|
||||
|
||||
import { BorderRadiusTable } from '@/components/ui/BorderRadiusTable';
|
||||
|
||||
# Border Radius
|
||||
|
||||
Our design system uses a consistent set of border radius values to create a cohesive and modern interface. The border radius scale ranges from none (sharp corners) to full (completely rounded).
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
// No border radius
|
||||
<div className="rounded-none">Sharp corners</div>
|
||||
|
||||
// Default radius
|
||||
<div className="rounded">Default rounded corners</div>
|
||||
|
||||
// Larger radius
|
||||
<div className="rounded-lg">Larger rounded corners</div>
|
||||
|
||||
// Fully rounded (useful for pills, badges)
|
||||
<div className="rounded-full">Completely rounded</div>
|
||||
```
|
||||
|
||||
## Border Radius Scale
|
||||
|
||||
Click on any radius to copy its Tailwind class name. Each preview shows how the radius appears when applied.
|
||||
|
||||
<BorderRadiusTable />
|
||||
@@ -0,0 +1,144 @@
|
||||
---
|
||||
title: Colors
|
||||
description: Cal.com Color System Documentation
|
||||
---
|
||||
|
||||
import { TypeTable } from '@/components/ui/TypeTable';
|
||||
|
||||
export const backgroundTokens = {
|
||||
title: 'Background Tokens',
|
||||
colors: [
|
||||
{ name: 'emphasis', value: 'var(--cal-bg-emphasis)', className: 'bg-emphasis' },
|
||||
{ name: 'default', value: 'var(--cal-bg, white)', className: 'bg-default' },
|
||||
{ name: 'subtle', value: 'var(--cal-bg-subtle)', className: 'bg-subtle' },
|
||||
{ name: 'muted', value: 'var(--cal-bg-muted)', className: 'bg-muted' },
|
||||
{ name: 'inverted', value: 'var(--cal-bg-inverted)', className: 'bg-inverted' },
|
||||
],
|
||||
};
|
||||
|
||||
export const primaryBackgroundTokens = {
|
||||
title: 'Primary Background Tokens',
|
||||
colors: [
|
||||
{ name: 'primary.default', value: 'var(--cal-bg-primary)', className: 'bg-primary-default' },
|
||||
{ name: 'primary.emphasis', value: 'var(--cal-bg-primary-emphasis)', className: 'bg-primary-emphasis' },
|
||||
{ name: 'primary.muted', value: 'var(--cal-bg-primary-muted)', className: 'bg-primary-muted' },
|
||||
],
|
||||
};
|
||||
|
||||
export const brandTokens = {
|
||||
title: 'Brand Colors',
|
||||
colors: [
|
||||
{ name: 'brand.default', value: 'var(--cal-brand,#111827)', className: 'bg-brand-default' },
|
||||
{ name: 'brand.emphasis', value: 'var(--cal-brand-emphasis,#101010)', className: 'bg-brand-emphasis' },
|
||||
{ name: 'brand.subtle', value: 'var(--cal-brand-subtle,#9CA3AF)', className: 'bg-brand-subtle' },
|
||||
{ name: 'brand.accent', value: 'var(--cal-brand-accent,white)', className: 'bg-brand-accent' },
|
||||
],
|
||||
};
|
||||
|
||||
export const semanticBackgroundTokens = {
|
||||
title: 'Semantic Background Tokens',
|
||||
colors: [
|
||||
{ name: 'semantic.info.subtle', value: 'var(--cal-bg-semantic-info-subtle)', className: 'bg-semantic-info-subtle' },
|
||||
{ name: 'semantic.info.emphasis', value: 'var(--cal-bg-semantic-info-emphasis)', className: 'bg-semantic-info-emphasis' },
|
||||
{ name: 'semantic.success.subtle', value: 'var(--cal-bg-semantic-success-subtle)', className: 'bg-semantic-success-subtle' },
|
||||
{ name: 'semantic.success.emphasis', value: 'var(--cal-bg-semantic-success-emphasis)', className: 'bg-semantic-success-emphasis' },
|
||||
{ name: 'semantic.attention.subtle', value: 'var(--cal-bg-semantic-attention-subtle)', className: 'bg-semantic-attention-subtle' },
|
||||
{ name: 'semantic.attention.emphasis', value: 'var(--cal-bg-semantic-attention-emphasis)', className: 'bg-semantic-attention-emphasis' },
|
||||
{ name: 'semantic.error.subtle', value: 'var(--cal-bg-semantic-error-subtle)', className: 'bg-semantic-error-subtle' },
|
||||
{ name: 'semantic.error.emphasis', value: 'var(--cal-bg-semantic-error-emphasis)', className: 'bg-semantic-error-emphasis' },
|
||||
],
|
||||
};
|
||||
|
||||
export const borderTokens = {
|
||||
title: 'Border Tokens',
|
||||
colors: [
|
||||
{ name: 'border.emphasis', value: 'var(--cal-border-emphasis)', className: 'border-emphasis' },
|
||||
{ name: 'border.default', value: 'var(--cal-border)', className: 'border-default' },
|
||||
{ name: 'border.subtle', value: 'var(--cal-border-subtle)', className: 'border-subtle' },
|
||||
{ name: 'border.muted', value: 'var(--cal-border-muted)', className: 'border-muted' },
|
||||
{ name: 'border.semantic.error', value: 'var(--cal-border-semantic-error)', className: 'border-semantic-error' },
|
||||
],
|
||||
};
|
||||
|
||||
export const textTokens = {
|
||||
title: 'Text Tokens',
|
||||
description: 'Semantic text colors for various purposes',
|
||||
colors: [
|
||||
{ name: 'text.emphasis', value: 'var(--cal-text-emphasis)', className: 'text-emphasis' },
|
||||
{ name: 'text.default', value: 'var(--cal-text)', className: 'text-default' },
|
||||
{ name: 'text.subtle', value: 'var(--cal-text-subtle)', className: 'text-subtle' },
|
||||
{ name: 'text.muted', value: 'var(--cal-text-muted)', className: 'text-muted' },
|
||||
{ name: 'text.inverted', value: 'var(--cal-text-inverted)', className: 'text-inverted' },
|
||||
],
|
||||
};
|
||||
|
||||
# Color System
|
||||
|
||||
Our color system is built on semantic tokens that provide consistent theming across light and dark modes. The system is organized into five main categories:
|
||||
|
||||
1. Background Colors
|
||||
2. Primary Colors
|
||||
3. Brand Colors
|
||||
4. Semantic Colors
|
||||
5. Text & Border Colors
|
||||
|
||||
## Usage Guidelines
|
||||
|
||||
1. **Use Semantic Tokens**: Always prefer semantic tokens over direct color values to maintain consistency and theme support.
|
||||
2. **Dark Mode Support**: Our semantic tokens automatically handle dark mode transitions.
|
||||
3. **Component Design**: When creating new components, follow the semantic token system:
|
||||
- Use `bg-*` tokens for backgrounds
|
||||
- Use `text-*` tokens for text content
|
||||
- Use `border-*` tokens for borders
|
||||
4. **Semantic States**: Use semantic color variations to convey meaning:
|
||||
- Info: For informational UI elements
|
||||
- Success: For successful actions and positive states
|
||||
- Attention: For warnings and important notices
|
||||
- Error: For error states and destructive actions
|
||||
5. **Brand Colors**: Use brand colors sparingly and primarily for:
|
||||
- Primary CTAs
|
||||
- Brand identity elements
|
||||
- Key interactive components
|
||||
|
||||
|
||||
## Using Semantic Tokens
|
||||
|
||||
Our color tokens are CSS variables that automatically handle light and dark mode transitions. Here's how to use them:
|
||||
|
||||
### Background Colors
|
||||
```tsx
|
||||
// Basic backgrounds
|
||||
<div className="bg-default">Default background</div>
|
||||
<div className="bg-subtle">Subtle background</div>
|
||||
<div className="bg-emphasis">Emphasis background</div>
|
||||
|
||||
// Primary backgrounds
|
||||
<div className="bg-primary-default">Primary background</div>
|
||||
<div className="bg-primary-emphasis">Primary emphasis</div>
|
||||
```
|
||||
|
||||
### Semantic Colors
|
||||
```tsx
|
||||
// Info states
|
||||
<div className="bg-semantic-info-subtle">Info subtle</div>
|
||||
<div className="bg-semantic-info-emphasis">Info emphasis</div>
|
||||
|
||||
// Success states
|
||||
<div className="bg-semantic-success-subtle">Success subtle</div>
|
||||
<div className="bg-semantic-success-emphasis">Success emphasis</div>
|
||||
|
||||
// Error states
|
||||
<div className="bg-semantic-error-subtle">Error subtle</div>
|
||||
<div className="bg-semantic-error-emphasis">Error emphasis</div>
|
||||
```
|
||||
|
||||
### Text Colors
|
||||
```tsx
|
||||
<p className="text-emphasis">High emphasis text</p>
|
||||
<p className="text-default">Default text</p>
|
||||
<p className="text-subtle">Subtle text</p>
|
||||
<p className="text-muted">Muted text</p>
|
||||
```
|
||||
|
||||
<TypeTable sections={[backgroundTokens, primaryBackgroundTokens, brandTokens, semanticBackgroundTokens, borderTokens, textTokens]} />
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { RenderComponentWithSnippet } from "@/app/components/render";
|
||||
|
||||
import { Alert } from "@calcom/ui/components/alert";
|
||||
|
||||
const severities = ["neutral", "info", "warning", "error"] as const;
|
||||
|
||||
export const ActionsExample: React.FC = () => (
|
||||
<RenderComponentWithSnippet>
|
||||
<div className="no-prose space-y-4">
|
||||
{severities.map((severity) => (
|
||||
<Alert
|
||||
key={severity}
|
||||
severity={severity}
|
||||
title={`${severity.charAt(0).toUpperCase() + severity.slice(1)} Alert with Actions`}
|
||||
message={`This is a ${severity} alert with action buttons.`}
|
||||
actions={
|
||||
<div className="flex space-x-2">
|
||||
<button className="rounded-md px-3 py-2 text-sm font-medium hover:opacity-90">Dismiss</button>
|
||||
<button className="rounded-md px-3 py-2 text-sm font-medium hover:opacity-90">View</button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</RenderComponentWithSnippet>
|
||||
);
|
||||
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { RenderComponentWithSnippet } from "@/app/components/render";
|
||||
|
||||
import { Alert } from "@calcom/ui/components/alert";
|
||||
|
||||
const severities = ["neutral", "info", "warning", "error"] as const;
|
||||
|
||||
export const BasicExample: React.FC = () => (
|
||||
<RenderComponentWithSnippet>
|
||||
<div className="not-prose space-y-4">
|
||||
{severities.map((severity) => (
|
||||
<Alert
|
||||
key={severity}
|
||||
severity={severity}
|
||||
title={`${severity.charAt(0).toUpperCase() + severity.slice(1)} Alert`}
|
||||
message={`This is a ${severity} alert message to show important information to users.`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</RenderComponentWithSnippet>
|
||||
);
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { RenderComponentWithSnippet } from "@/app/components/render";
|
||||
|
||||
import { Alert } from "@calcom/ui/components/alert";
|
||||
|
||||
const severities = ["neutral", "info", "warning", "error"] as const;
|
||||
|
||||
export const CustomIconColorExample: React.FC = () => (
|
||||
<RenderComponentWithSnippet>
|
||||
<div className="not-prose space-y-4">
|
||||
{severities.map((severity) => (
|
||||
<Alert
|
||||
key={severity}
|
||||
severity={severity}
|
||||
CustomIcon="bell"
|
||||
customIconColor="text-emphasis"
|
||||
title={`${severity.charAt(0).toUpperCase() + severity.slice(1)} Alert with Custom Icon Color`}
|
||||
message="This alert uses a custom icon color."
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</RenderComponentWithSnippet>
|
||||
);
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { RenderComponentWithSnippet } from "@/app/components/render";
|
||||
|
||||
import { Alert } from "@calcom/ui/components/alert";
|
||||
|
||||
const severities = ["neutral", "info", "warning", "error"] as const;
|
||||
const customIcons = ["info", "alert-triangle", "circle-x", "circle-check", "bell"] as const;
|
||||
|
||||
export const CustomIconsExample: React.FC = () => (
|
||||
<RenderComponentWithSnippet>
|
||||
<div className="not-prose space-y-4">
|
||||
{severities.map((severity, index) => (
|
||||
<Alert
|
||||
key={severity}
|
||||
severity={severity}
|
||||
// @ts-expect-error Didnt type this as IconName for CustomIcon
|
||||
CustomIcon={customIcons[index % customIcons.length]}
|
||||
title={`${severity.charAt(0).toUpperCase() + severity.slice(1)} Alert with Custom Icon`}
|
||||
message="This alert uses a custom icon instead of the default one."
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</RenderComponentWithSnippet>
|
||||
);
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
title: Alert
|
||||
airtableId: recnvwaXqOucqLo9Y
|
||||
---
|
||||
import { Alert } from "@calcom/ui/components/alert"
|
||||
import { RenderComponentWithSnippet } from "@/app/components/render"
|
||||
import { Row } from "@/app/components/row"
|
||||
import { BasicExample } from "./alert.basic"
|
||||
import { ActionsExample } from "./alert.actions"
|
||||
import { CustomIconsExample } from "./alert.customIcons"
|
||||
import { CustomIconColorExample } from "./alert.customIconColor"
|
||||
import { NoTitleExample } from "./alert.noTitle"
|
||||
|
||||
## Basic Alerts
|
||||
|
||||
Alerts come in different severities to convey different levels of importance.
|
||||
|
||||
<BasicExample/>
|
||||
|
||||
## Alerts with Actions
|
||||
|
||||
Alerts can include action buttons to provide users with immediate options.
|
||||
|
||||
<ActionsExample/>
|
||||
|
||||
## Custom Icons
|
||||
|
||||
You can customize the alert icon to better match your message context.
|
||||
|
||||
<CustomIconsExample/>
|
||||
|
||||
## Custom Icon Colors
|
||||
|
||||
The icon color can be customized independently of the alert severity.
|
||||
|
||||
<CustomIconColorExample/>
|
||||
|
||||
## Without Title
|
||||
|
||||
For simpler messages, alerts can be displayed without a title.
|
||||
|
||||
<NoTitleExample/>
|
||||
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { RenderComponentWithSnippet } from "@/app/components/render";
|
||||
|
||||
import { Alert } from "@calcom/ui/components/alert";
|
||||
|
||||
const severities = ["neutral", "info", "warning", "error"] as const;
|
||||
|
||||
export const NoTitleExample: React.FC = () => (
|
||||
<RenderComponentWithSnippet>
|
||||
<div className="not-prose space-y-4">
|
||||
{severities.map((severity) => (
|
||||
<Alert
|
||||
key={severity}
|
||||
severity={severity}
|
||||
message={`This is a ${severity} alert without a title to show a more compact version.`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</RenderComponentWithSnippet>
|
||||
);
|
||||
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { RenderComponentWithSnippet } from "@/app/components/render";
|
||||
|
||||
import { Avatar } from "@calcom/ui/components/avatar";
|
||||
|
||||
const sampleImage = "https://cal.com/stakeholder/peer.jpg";
|
||||
|
||||
export const ImageExample: React.FC = () => (
|
||||
<RenderComponentWithSnippet>
|
||||
<div className="flex items-center gap-8">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Avatar size="md" alt="With image" imageSrc={sampleImage} />
|
||||
<span className="text-subtle text-xs">With Image</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Avatar size="md" alt="Without image" imageSrc="https://cal.com/avatar.svg" />
|
||||
<span className="text-subtle text-xs">Without Image</span>
|
||||
</div>
|
||||
</div>
|
||||
</RenderComponentWithSnippet>
|
||||
);
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { RenderComponentWithSnippet } from "@/app/components/render";
|
||||
|
||||
import { Avatar } from "@calcom/ui/components/avatar";
|
||||
|
||||
const sampleImage = "https://cal.com/stakeholder/peer.jpg";
|
||||
|
||||
export const IndicatorExample: React.FC = () => (
|
||||
<RenderComponentWithSnippet>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Avatar
|
||||
size="md"
|
||||
alt="With indicator"
|
||||
imageSrc={sampleImage}
|
||||
indicator={
|
||||
<div className="absolute bottom-0 right-0 h-2.5 w-2.5 rounded-full bg-green-500 ring-2 ring-white" />
|
||||
}
|
||||
/>
|
||||
<span className="text-subtle text-xs">With Status Indicator</span>
|
||||
</div>
|
||||
</div>
|
||||
</RenderComponentWithSnippet>
|
||||
);
|
||||
@@ -0,0 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { RenderComponentWithSnippet } from "@/app/components/render";
|
||||
|
||||
import { Avatar } from "@calcom/ui/components/avatar";
|
||||
|
||||
const sampleImage = "https://cal.com/stakeholder/peer.jpg";
|
||||
|
||||
export const LinkExample: React.FC = () => (
|
||||
<RenderComponentWithSnippet>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Avatar size="md" alt="With link" imageSrc={sampleImage} href="https://cal.com" />
|
||||
<span className="text-subtle text-xs">Clickable</span>
|
||||
</div>
|
||||
</div>
|
||||
</RenderComponentWithSnippet>
|
||||
);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user