Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55488c39f4 | ||
|
|
c0e123a917 | ||
|
|
ec88f16f1f | ||
|
|
a957c15062 | ||
|
|
b32b31ccdc | ||
|
|
e29f6af540 | ||
|
|
c6cb3423fa | ||
|
|
45904c07bc | ||
|
|
0623887ff8 | ||
|
|
f31c45a153 | ||
|
|
f5217caef2 | ||
|
|
e1beec0487 | ||
|
|
6bbf263981 | ||
|
|
d86a28bf7f | ||
|
|
c00e8cfa54 | ||
|
|
e82cc328d3 | ||
|
|
7dbfc1b2e0 | ||
|
|
02fb7c0ae0 | ||
|
|
9004dcb93b | ||
|
|
cf83b0a236 | ||
|
|
7f02464fed | ||
|
|
2371253cb5 | ||
|
|
859e7f1c88 | ||
|
|
f1e8481878 | ||
|
|
acf3a7c54e | ||
|
|
f163e4638b | ||
|
|
9dab915ddd | ||
|
|
8fca8f3910 | ||
|
|
1638465607 | ||
|
|
ab7eb7f4b1 | ||
|
|
0592badd56 | ||
|
|
7e82b8fa86 | ||
|
|
7dfb71ca30 | ||
|
|
d99ecde0e2 | ||
|
|
d81b905ad7 | ||
|
|
46c6582328 | ||
|
|
396811b954 | ||
|
|
f6c7d8d97f | ||
|
|
930165b6e8 | ||
|
|
a77bd7bbe0 | ||
|
|
51212cdacf | ||
|
|
3731b1ef3d | ||
|
|
f631c4aa79 | ||
|
|
95cd76e732 | ||
|
|
7e525779b7 | ||
|
|
f176ebf1da | ||
|
|
5058e0e750 | ||
|
|
dc409e1c5a | ||
|
|
89b5d1ebc3 | ||
|
|
0d24664a72 | ||
|
|
69efc26f36 |
@@ -187,6 +187,7 @@ TWILIO_WHATSAPP_PHONE_NUMBER=
|
||||
# For NEXT_PUBLIC_SENDER_ID only letters, numbers and spaces are allowed (max. 11 characters)
|
||||
NEXT_PUBLIC_SENDER_ID=
|
||||
TWILIO_VERIFY_SID=
|
||||
TWILIO_OPT_OUT_ENABLED=
|
||||
|
||||
# Set it to "1" if you need to run E2E tests locally.
|
||||
NEXT_PUBLIC_IS_E2E=
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
/**/package.json @calcom/Foundation
|
||||
/apps/api/**/* @calcom/Platform @calcom/Foundation
|
||||
/apps/ui-playground/**/* @calcom/Consumer
|
||||
/apps/web/components/**/* @calcom/Consumer
|
||||
/apps/web/modules/**/* @calcom/Consumer
|
||||
/apps/web/**/layout.tsx @calcom/Foundation
|
||||
/apps/web/lib/**/* @calcom/Foundation
|
||||
/apps/web/middleware.ts @calcom/Foundation
|
||||
/deploy/**/* @calcom/Foundation
|
||||
/infra/**/* @calcom/Foundation
|
||||
/packages/app-store/applecalendar/**/* @calcom/Foundation
|
||||
@@ -21,3 +27,4 @@
|
||||
/packages/platform/**/* @calcom/Platform @calcom/Foundation
|
||||
/packages/prisma/**/* @calcom/Foundation
|
||||
/packages/trpc/server/routers/viewer/slots/**/* @calcom/Foundation
|
||||
/packages/ui/**/* @calcom/Consumer
|
||||
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
undecided:
|
||||
- "@calcom/prisma"
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
undecided:
|
||||
- "@calcom/app-store-cli"
|
||||
- "@calcom/platform-constants"
|
||||
- "@calcom/platform-enums"
|
||||
- "@calcom/platform-types"
|
||||
- "@calcom/platform-utils"
|
||||
- "@calcom/prisma"
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
undecided:
|
||||
- calcom-monorepo
|
||||
- "@calcom/app-store-cli"
|
||||
- "@calcom/platform-constants"
|
||||
- "@calcom/platform-enums"
|
||||
- "@calcom/platform-types"
|
||||
- "@calcom/platform-utils"
|
||||
- "@calcom/prisma"
|
||||
@@ -107,6 +107,12 @@ $ yarn test:e2e:watch --testPathPattern=filePath
|
||||
$ yarn run test:cov
|
||||
```
|
||||
|
||||
## Conventions
|
||||
|
||||
### Guards
|
||||
1. In case a guard would return "false" for "canActivate" instead throw ForbiddenException with an error message containing guard name and the error.
|
||||
2. In case a guard would return "false" for "canActivate" DO NOT cache the result in redis, because we don't want that someone is forbidden, updates whatever was the problem, and then has to wait for cache to expire. We only cache in redis guard results where "canAccess" is "true".
|
||||
|
||||
## Support
|
||||
|
||||
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
"@axiomhq/winston": "^1.2.0",
|
||||
"@calcom/platform-constants": "*",
|
||||
"@calcom/platform-enums": "*",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.173",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.176",
|
||||
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
|
||||
"@calcom/platform-types": "*",
|
||||
"@calcom/platform-utils": "*",
|
||||
|
||||
+1
-627
@@ -58,14 +58,11 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
let user: User;
|
||||
|
||||
let seatedEventTypeId: number;
|
||||
let recurringSeatedEventTypeId: number;
|
||||
const maxRecurrenceCount = 3;
|
||||
|
||||
const seatedEventSlug = `seated-bookings-event-type-${randomString()}`;
|
||||
const recurringSeatedEventSlug = `seated-bookings-event-type-${randomString()}`;
|
||||
|
||||
let createdSeatedBooking: CreateSeatedBookingOutput_2024_08_13;
|
||||
let createdRecurringSeatedBooking: CreateRecurringSeatedBookingOutput_2024_08_13[];
|
||||
|
||||
const emailAttendeeOne = `seated-bookings-attendee1-${randomString()}@api.com`;
|
||||
const nameAttendeeOne = `Attendee One ${randomString()}`;
|
||||
@@ -126,22 +123,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
);
|
||||
seatedEventTypeId = seatedEvent.id;
|
||||
|
||||
const recurringSeatedEvent = await eventTypesRepositoryFixture.create(
|
||||
// note(Lauris): freq 2 means weekly, interval 1 means every week and count 3 means 3 weeks in a row
|
||||
{
|
||||
title: `seated-bookings-2024-08-13-recurring-event-type-${randomString()}`,
|
||||
slug: recurringSeatedEventSlug,
|
||||
length: 60,
|
||||
recurringEvent: { freq: 2, count: maxRecurrenceCount, interval: 1 },
|
||||
seatsPerTimeSlot: 5,
|
||||
seatsShowAttendees: true,
|
||||
seatsShowAvailabilityCount: true,
|
||||
locations: [{ type: "inPerson", address: "via 10, rome, italy" }],
|
||||
},
|
||||
user.id
|
||||
);
|
||||
recurringSeatedEventTypeId = recurringSeatedEvent.id;
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
@@ -321,127 +302,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should book a recurring event type with seats", async () => {
|
||||
const recurrenceCount = 2;
|
||||
const body: CreateRecurringBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 0, 8, 11, 0, 0)).toISOString(),
|
||||
eventTypeId: recurringSeatedEventTypeId,
|
||||
attendee: {
|
||||
name: "Mr Proper",
|
||||
email: "mr_proper@gmail.com",
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
location: googleMeetUrl,
|
||||
bookingFieldsResponses: {
|
||||
codingLanguage: "TypeScript",
|
||||
},
|
||||
recurrenceCount,
|
||||
metadata: {
|
||||
userId: "300",
|
||||
},
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseDataIsCreateRecurringSeatedBooking(responseBody.data)).toBe(true);
|
||||
|
||||
if (responseDataIsCreateRecurringSeatedBooking(responseBody.data)) {
|
||||
const data: CreateRecurringSeatedBookingOutput_2024_08_13[] = responseBody.data;
|
||||
expect(data.length).toEqual(recurrenceCount);
|
||||
|
||||
const firstBooking = data[0];
|
||||
expect(firstBooking.seatUid).toBeDefined();
|
||||
const seatUid = firstBooking.seatUid;
|
||||
expect(firstBooking.id).toBeDefined();
|
||||
expect(firstBooking.uid).toBeDefined();
|
||||
expect(firstBooking.hosts[0].id).toEqual(user.id);
|
||||
expect(firstBooking.status).toEqual("accepted");
|
||||
expect(firstBooking.start).toEqual(body.start);
|
||||
expect(firstBooking.end).toEqual(
|
||||
DateTime.fromISO(body.start, { zone: "utc" }).plus({ hours: 1 }).toISO()
|
||||
);
|
||||
expect(firstBooking.duration).toEqual(60);
|
||||
expect(firstBooking.eventTypeId).toEqual(recurringSeatedEventTypeId);
|
||||
expect(firstBooking.eventType).toEqual({
|
||||
id: recurringSeatedEventTypeId,
|
||||
slug: recurringSeatedEventSlug,
|
||||
});
|
||||
expect(firstBooking.attendees.length).toEqual(1);
|
||||
expect(firstBooking.attendees[0]).toEqual({
|
||||
name: body.attendee.name,
|
||||
email: body.attendee.email,
|
||||
timeZone: body.attendee.timeZone,
|
||||
language: body.attendee.language,
|
||||
absent: false,
|
||||
seatUid,
|
||||
bookingFieldsResponses: {
|
||||
name: body.attendee.name,
|
||||
...body.bookingFieldsResponses,
|
||||
location: {
|
||||
optionValue: googleMeetUrl,
|
||||
value: "link",
|
||||
},
|
||||
},
|
||||
metadata: body.metadata,
|
||||
});
|
||||
expect(firstBooking.location).toEqual(body.location);
|
||||
expect(firstBooking.absentHost).toEqual(false);
|
||||
|
||||
const secondBooking = data[1];
|
||||
expect(secondBooking.seatUid).toBeDefined();
|
||||
const secondSeatUid = secondBooking.seatUid;
|
||||
expect(secondBooking.id).toBeDefined();
|
||||
expect(secondBooking.uid).toBeDefined();
|
||||
expect(secondBooking.hosts[0].id).toEqual(user.id);
|
||||
expect(secondBooking.status).toEqual("accepted");
|
||||
const expectedStart = DateTime.fromISO(body.start, { zone: "utc" }).plus({ weeks: 1 }).toISO();
|
||||
expect(secondBooking.start).toEqual(expectedStart);
|
||||
expect(secondBooking.end).toEqual(
|
||||
DateTime.fromISO(expectedStart!, { zone: "utc" }).plus({ hours: 1 }).toISO()
|
||||
);
|
||||
expect(secondBooking.duration).toEqual(60);
|
||||
expect(secondBooking.eventTypeId).toEqual(recurringSeatedEventTypeId);
|
||||
expect(secondBooking.eventType).toEqual({
|
||||
id: recurringSeatedEventTypeId,
|
||||
slug: recurringSeatedEventSlug,
|
||||
});
|
||||
expect(secondBooking.attendees.length).toEqual(1);
|
||||
expect(secondBooking.attendees[0]).toEqual({
|
||||
name: body.attendee.name,
|
||||
email: body.attendee.email,
|
||||
timeZone: body.attendee.timeZone,
|
||||
language: body.attendee.language,
|
||||
absent: false,
|
||||
seatUid: secondSeatUid,
|
||||
bookingFieldsResponses: {
|
||||
name: body.attendee.name,
|
||||
...body.bookingFieldsResponses,
|
||||
location: {
|
||||
optionValue: googleMeetUrl,
|
||||
value: "link",
|
||||
},
|
||||
},
|
||||
metadata: body.metadata,
|
||||
});
|
||||
expect(secondBooking.location).toEqual(body.location);
|
||||
expect(secondBooking.absentHost).toEqual(false);
|
||||
createdRecurringSeatedBooking = data;
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected recurring booking but received non array response"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should get a booking for an event type with seats", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/bookings/${createdSeatedBooking.uid}`)
|
||||
@@ -469,62 +329,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should get a booking for a recurring event type with seats", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/bookings/${createdRecurringSeatedBooking[1].recurringBookingUid}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: GetBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseDataIsGetRecurringSeatedBooking(responseBody.data)).toBe(true);
|
||||
|
||||
if (responseDataIsGetRecurringSeatedBooking(responseBody.data)) {
|
||||
const data: GetRecurringSeatedBookingOutput_2024_08_13[] = responseBody.data;
|
||||
const expected = structuredClone(createdRecurringSeatedBooking);
|
||||
for (const booking of expected) {
|
||||
// note(Lauris): seatUid in get response resides only in each attendee object
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
delete booking.seatUid;
|
||||
}
|
||||
expect(data).toEqual(expected);
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected recurring booking but received non array response"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should get a specific recurrence of a booking for a recurring event type with seats", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/bookings/${createdRecurringSeatedBooking[0].uid}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: GetBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseDataIsGetSeatedBooking(responseBody.data)).toBe(true);
|
||||
|
||||
if (responseDataIsGetSeatedBooking(responseBody.data)) {
|
||||
const data: GetSeatedBookingOutput_2024_08_13 = responseBody.data;
|
||||
const expected = structuredClone(createdRecurringSeatedBooking[0]);
|
||||
// note(Lauris): seatUid in get response resides only in each attendee object
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
delete expected.seatUid;
|
||||
expect(data).toEqual(expected);
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected recurring booking but received non array response"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should get all seated bookings", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get("/v2/bookings?sortCreated=asc")
|
||||
@@ -534,7 +338,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data.length).toEqual(3);
|
||||
expect(responseBody.data.length).toEqual(1);
|
||||
|
||||
const seatedBooking = responseBody.data[0];
|
||||
const seatedBookingExpected = structuredClone(createdSeatedBooking);
|
||||
@@ -543,16 +347,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
// @ts-ignore
|
||||
delete seatedBookingExpected.seatUid;
|
||||
expect(seatedBooking).toEqual(seatedBookingExpected);
|
||||
|
||||
const recurringSeatedBookings = [responseBody.data[1], responseBody.data[2]];
|
||||
const recurringSeatedBookingsExpected = structuredClone(createdRecurringSeatedBooking);
|
||||
for (const booking of recurringSeatedBookingsExpected) {
|
||||
// note(Lauris): seatUid in get response resides only in each attendee object
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
delete booking.seatUid;
|
||||
}
|
||||
expect(recurringSeatedBookings).toEqual(recurringSeatedBookingsExpected);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -682,424 +476,4 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Recurring seated bookings creation", () => {
|
||||
let app: INestApplication;
|
||||
let organization: Team;
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let bookingsRepositoryFixture: BookingsRepositoryFixture;
|
||||
let schedulesService: SchedulesService_2024_04_15;
|
||||
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
||||
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
||||
let oAuthClient: PlatformOAuthClient;
|
||||
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||
|
||||
const userEmail = `seated-bookings-user-${randomString()}@api.com`;
|
||||
let user: User;
|
||||
|
||||
let seatedEventTypeId: number;
|
||||
let recurringSeatedEventTypeId: number;
|
||||
const maxRecurrenceCount = 3;
|
||||
|
||||
const seatedEventSlug = `seated-bookings-event-type-${randomString()}`;
|
||||
const recurringSeatedEventSlug = `seated-bookings-event-type-${randomString()}`;
|
||||
|
||||
let createdSeatedBooking: CreateSeatedBookingOutput_2024_08_13;
|
||||
let createdRecurringSeatedBooking: CreateRecurringSeatedBookingOutput_2024_08_13[];
|
||||
|
||||
const emailAttendeeOne = `seated-bookings-attendee1-${randomString()}@api.com`;
|
||||
const nameAttendeeOne = `Attendee One ${randomString()}`;
|
||||
const emailAttendeeTwo = `seated-bookings-attendee2-${randomString()}@api.com`;
|
||||
const nameAttendeeTwo = `Attendee Two ${randomString()}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
userEmail,
|
||||
Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
|
||||
})
|
||||
)
|
||||
.overrideGuard(PermissionsGuard)
|
||||
.useValue({
|
||||
canActivate: () => true,
|
||||
})
|
||||
.compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
|
||||
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
||||
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
|
||||
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
|
||||
|
||||
organization = await teamRepositoryFixture.create({
|
||||
name: `seated-bookings-organization-${randomString()}`,
|
||||
});
|
||||
oAuthClient = await createOAuthClient(organization.id);
|
||||
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
platformOAuthClients: {
|
||||
connect: {
|
||||
id: oAuthClient.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const userSchedule: CreateScheduleInput_2024_04_15 = {
|
||||
name: `seated-bookings-2024-08-13-schedule-${randomString()}`,
|
||||
timeZone: "Europe/Rome",
|
||||
isDefault: true,
|
||||
};
|
||||
await schedulesService.createUserSchedule(user.id, userSchedule);
|
||||
const seatedEvent = await eventTypesRepositoryFixture.create(
|
||||
{
|
||||
title: `seated-bookings-2024-08-13-event-type-${randomString()}`,
|
||||
slug: seatedEventSlug,
|
||||
length: 60,
|
||||
seatsPerTimeSlot: 3,
|
||||
},
|
||||
user.id
|
||||
);
|
||||
seatedEventTypeId = seatedEvent.id;
|
||||
|
||||
const recurringSeatedEvent = await eventTypesRepositoryFixture.create(
|
||||
// note(Lauris): freq 2 means weekly, interval 1 means every week and count 3 means 3 weeks in a row
|
||||
{
|
||||
title: `seated-bookings-2024-08-13-recurring-event-type-${randomString()}`,
|
||||
slug: recurringSeatedEventSlug,
|
||||
length: 60,
|
||||
seatsPerTimeSlot: 3,
|
||||
recurringEvent: { freq: 2, count: maxRecurrenceCount, interval: 1 },
|
||||
},
|
||||
user.id
|
||||
);
|
||||
recurringSeatedEventTypeId = recurringSeatedEvent.id;
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
await app.init();
|
||||
});
|
||||
|
||||
async function createOAuthClient(organizationId: number) {
|
||||
const data = {
|
||||
logo: "logo-url",
|
||||
name: "name",
|
||||
redirectUris: ["http://localhost:5555"],
|
||||
permissions: 32,
|
||||
};
|
||||
const secret = "secret";
|
||||
|
||||
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
|
||||
return client;
|
||||
}
|
||||
|
||||
it("should book a recurring event type with seats for the first time", async () => {
|
||||
const recurrenceCount = 2;
|
||||
const body: CreateRecurringBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 0, 8, 11, 0, 0)).toISOString(),
|
||||
eventTypeId: recurringSeatedEventTypeId,
|
||||
attendee: {
|
||||
name: nameAttendeeOne,
|
||||
email: emailAttendeeOne,
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
location: googleMeetUrl,
|
||||
bookingFieldsResponses: {
|
||||
codingLanguage: "TypeScript",
|
||||
},
|
||||
metadata: {
|
||||
userId: "1000",
|
||||
},
|
||||
recurrenceCount,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseDataIsCreateRecurringSeatedBooking(responseBody.data)).toBe(true);
|
||||
|
||||
if (responseDataIsCreateRecurringSeatedBooking(responseBody.data)) {
|
||||
const data: CreateRecurringSeatedBookingOutput_2024_08_13[] = responseBody.data;
|
||||
expect(data.length).toEqual(recurrenceCount);
|
||||
|
||||
const firstBooking = data[0];
|
||||
expect(firstBooking.seatUid).toBeDefined();
|
||||
const seatUid = firstBooking.seatUid;
|
||||
expect(firstBooking.id).toBeDefined();
|
||||
expect(firstBooking.uid).toBeDefined();
|
||||
expect(firstBooking.hosts[0].id).toEqual(user.id);
|
||||
expect(firstBooking.status).toEqual("accepted");
|
||||
expect(firstBooking.start).toEqual(body.start);
|
||||
expect(firstBooking.end).toEqual(
|
||||
DateTime.fromISO(body.start, { zone: "utc" }).plus({ hours: 1 }).toISO()
|
||||
);
|
||||
expect(firstBooking.duration).toEqual(60);
|
||||
expect(firstBooking.eventTypeId).toEqual(recurringSeatedEventTypeId);
|
||||
expect(firstBooking.eventType).toEqual({
|
||||
id: recurringSeatedEventTypeId,
|
||||
slug: recurringSeatedEventSlug,
|
||||
});
|
||||
expect(firstBooking.attendees.length).toEqual(1);
|
||||
expect(firstBooking.attendees[0]).toEqual({
|
||||
name: body.attendee.name,
|
||||
email: body.attendee.email,
|
||||
timeZone: body.attendee.timeZone,
|
||||
language: body.attendee.language,
|
||||
absent: false,
|
||||
seatUid,
|
||||
bookingFieldsResponses: {
|
||||
name: body.attendee.name,
|
||||
...body.bookingFieldsResponses,
|
||||
location: {
|
||||
optionValue: googleMeetUrl,
|
||||
value: "link",
|
||||
},
|
||||
},
|
||||
metadata: body.metadata,
|
||||
});
|
||||
expect(firstBooking.location).toEqual(body.location);
|
||||
expect(firstBooking.absentHost).toEqual(false);
|
||||
|
||||
const secondBooking = data[1];
|
||||
expect(secondBooking.seatUid).toBeDefined();
|
||||
const secondSeatUid = secondBooking.seatUid;
|
||||
expect(secondBooking.id).toBeDefined();
|
||||
expect(secondBooking.uid).toBeDefined();
|
||||
expect(secondBooking.hosts[0].id).toEqual(user.id);
|
||||
expect(secondBooking.status).toEqual("accepted");
|
||||
const expectedStart = DateTime.fromISO(body.start, { zone: "utc" }).plus({ weeks: 1 }).toISO();
|
||||
expect(secondBooking.start).toEqual(expectedStart);
|
||||
expect(secondBooking.end).toEqual(
|
||||
DateTime.fromISO(expectedStart!, { zone: "utc" }).plus({ hours: 1 }).toISO()
|
||||
);
|
||||
expect(secondBooking.duration).toEqual(60);
|
||||
expect(secondBooking.eventTypeId).toEqual(recurringSeatedEventTypeId);
|
||||
expect(secondBooking.eventType).toEqual({
|
||||
id: recurringSeatedEventTypeId,
|
||||
slug: recurringSeatedEventSlug,
|
||||
});
|
||||
expect(secondBooking.attendees.length).toEqual(1);
|
||||
expect(secondBooking.attendees[0]).toEqual({
|
||||
name: body.attendee.name,
|
||||
email: body.attendee.email,
|
||||
timeZone: body.attendee.timeZone,
|
||||
language: body.attendee.language,
|
||||
absent: false,
|
||||
seatUid: secondSeatUid,
|
||||
bookingFieldsResponses: {
|
||||
name: body.attendee.name,
|
||||
...body.bookingFieldsResponses,
|
||||
location: {
|
||||
optionValue: googleMeetUrl,
|
||||
value: "link",
|
||||
},
|
||||
},
|
||||
metadata: body.metadata,
|
||||
});
|
||||
expect(secondBooking.location).toEqual(body.location);
|
||||
expect(secondBooking.absentHost).toEqual(false);
|
||||
createdRecurringSeatedBooking = data;
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected recurring booking but received non array response"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should book a recurring event type with seats for the second time", async () => {
|
||||
const recurrenceCount = 2;
|
||||
const body: CreateRecurringBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 0, 8, 11, 0, 0)).toISOString(),
|
||||
eventTypeId: recurringSeatedEventTypeId,
|
||||
attendee: {
|
||||
name: nameAttendeeTwo,
|
||||
email: emailAttendeeTwo,
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
location: googleMeetUrl,
|
||||
bookingFieldsResponses: {
|
||||
codingLanguage: "TypeScript",
|
||||
},
|
||||
metadata: {
|
||||
userId: "2000",
|
||||
},
|
||||
recurrenceCount,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseDataIsCreateRecurringSeatedBooking(responseBody.data)).toBe(true);
|
||||
|
||||
if (responseDataIsCreateRecurringSeatedBooking(responseBody.data)) {
|
||||
const data: CreateRecurringSeatedBookingOutput_2024_08_13[] = responseBody.data;
|
||||
expect(data.length).toEqual(recurrenceCount);
|
||||
|
||||
const firstBooking = data[0];
|
||||
expect(firstBooking.seatUid).toBeDefined();
|
||||
const seatUid = firstBooking.seatUid;
|
||||
expect(firstBooking.id).toBeDefined();
|
||||
expect(firstBooking.uid).toBeDefined();
|
||||
expect(firstBooking.hosts[0].id).toEqual(user.id);
|
||||
expect(firstBooking.status).toEqual("accepted");
|
||||
expect(firstBooking.start).toEqual(body.start);
|
||||
expect(firstBooking.end).toEqual(
|
||||
DateTime.fromISO(body.start, { zone: "utc" }).plus({ hours: 1 }).toISO()
|
||||
);
|
||||
expect(firstBooking.duration).toEqual(60);
|
||||
expect(firstBooking.eventTypeId).toEqual(recurringSeatedEventTypeId);
|
||||
expect(firstBooking.eventType).toEqual({
|
||||
id: recurringSeatedEventTypeId,
|
||||
slug: recurringSeatedEventSlug,
|
||||
});
|
||||
expect(firstBooking.attendees.length).toEqual(2);
|
||||
const firstAttendee = firstBooking.attendees.find(
|
||||
(attendee) => attendee.name === nameAttendeeOne
|
||||
);
|
||||
expect(firstAttendee).toEqual({
|
||||
name: nameAttendeeOne,
|
||||
email: emailAttendeeOne,
|
||||
timeZone: body.attendee.timeZone,
|
||||
language: body.attendee.language,
|
||||
absent: false,
|
||||
seatUid: createdRecurringSeatedBooking[0].seatUid,
|
||||
bookingFieldsResponses: {
|
||||
name: nameAttendeeOne,
|
||||
...body.bookingFieldsResponses,
|
||||
location: {
|
||||
optionValue: googleMeetUrl,
|
||||
value: "link",
|
||||
},
|
||||
},
|
||||
metadata: createdRecurringSeatedBooking[0].attendees[0].metadata,
|
||||
});
|
||||
const secondAttendee = firstBooking.attendees.find(
|
||||
(attendee) => attendee.name === nameAttendeeTwo
|
||||
);
|
||||
expect(secondAttendee).toEqual({
|
||||
name: nameAttendeeTwo,
|
||||
email: emailAttendeeTwo,
|
||||
timeZone: body.attendee.timeZone,
|
||||
language: body.attendee.language,
|
||||
absent: false,
|
||||
seatUid,
|
||||
bookingFieldsResponses: {
|
||||
name: nameAttendeeTwo,
|
||||
...body.bookingFieldsResponses,
|
||||
location: {
|
||||
optionValue: googleMeetUrl,
|
||||
value: "link",
|
||||
},
|
||||
},
|
||||
metadata: body.metadata,
|
||||
});
|
||||
expect(firstBooking.location).toEqual(body.location);
|
||||
expect(firstBooking.absentHost).toEqual(false);
|
||||
|
||||
const secondBooking = data[1];
|
||||
expect(secondBooking.seatUid).toBeDefined();
|
||||
const secondSeatUid = secondBooking.seatUid;
|
||||
expect(secondBooking.id).toBeDefined();
|
||||
expect(secondBooking.uid).toBeDefined();
|
||||
expect(secondBooking.hosts[0].id).toEqual(user.id);
|
||||
expect(secondBooking.status).toEqual("accepted");
|
||||
const expectedStart = DateTime.fromISO(body.start, { zone: "utc" }).plus({ weeks: 1 }).toISO();
|
||||
expect(secondBooking.start).toEqual(expectedStart);
|
||||
expect(secondBooking.end).toEqual(
|
||||
DateTime.fromISO(expectedStart!, { zone: "utc" }).plus({ hours: 1 }).toISO()
|
||||
);
|
||||
expect(secondBooking.duration).toEqual(60);
|
||||
expect(secondBooking.eventTypeId).toEqual(recurringSeatedEventTypeId);
|
||||
expect(secondBooking.eventType).toEqual({
|
||||
id: recurringSeatedEventTypeId,
|
||||
slug: recurringSeatedEventSlug,
|
||||
});
|
||||
expect(secondBooking.attendees.length).toEqual(2);
|
||||
expect(secondBooking.attendees[0]).toEqual({
|
||||
name: nameAttendeeOne,
|
||||
email: emailAttendeeOne,
|
||||
timeZone: body.attendee.timeZone,
|
||||
language: body.attendee.language,
|
||||
absent: false,
|
||||
seatUid: createdRecurringSeatedBooking[1].seatUid,
|
||||
bookingFieldsResponses: {
|
||||
name: nameAttendeeOne,
|
||||
...body.bookingFieldsResponses,
|
||||
location: {
|
||||
optionValue: googleMeetUrl,
|
||||
value: "link",
|
||||
},
|
||||
},
|
||||
metadata: createdRecurringSeatedBooking[0].attendees[0].metadata,
|
||||
});
|
||||
expect(secondBooking.attendees[1]).toEqual({
|
||||
name: nameAttendeeTwo,
|
||||
email: emailAttendeeTwo,
|
||||
timeZone: body.attendee.timeZone,
|
||||
language: body.attendee.language,
|
||||
absent: false,
|
||||
seatUid: secondSeatUid,
|
||||
bookingFieldsResponses: {
|
||||
name: nameAttendeeTwo,
|
||||
...body.bookingFieldsResponses,
|
||||
location: {
|
||||
optionValue: googleMeetUrl,
|
||||
value: "link",
|
||||
},
|
||||
},
|
||||
metadata: body.metadata,
|
||||
});
|
||||
expect(secondBooking.location).toEqual(body.location);
|
||||
expect(secondBooking.absentHost).toEqual(false);
|
||||
createdRecurringSeatedBooking = data;
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected recurring booking but received non array response"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function responseDataIsCreateRecurringSeatedBooking(
|
||||
data: any
|
||||
): data is CreateRecurringSeatedBookingOutput_2024_08_13[] {
|
||||
return Array.isArray(data);
|
||||
}
|
||||
|
||||
function responseDataIsGetSeatedBooking(data: any): data is GetSeatedBookingOutput_2024_08_13 {
|
||||
return data?.attendees?.every((attendee: any) => attendee?.hasOwnProperty("seatUid"));
|
||||
}
|
||||
|
||||
function responseDataIsGetRecurringSeatedBooking(
|
||||
data: any
|
||||
): data is GetRecurringSeatedBookingOutput_2024_08_13[] {
|
||||
return Array.isArray(data);
|
||||
}
|
||||
|
||||
afterAll(async () => {
|
||||
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
||||
await teamRepositoryFixture.delete(organization.id);
|
||||
await userRepositoryFixture.deleteByEmail(user.email);
|
||||
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ export class BookingUidGuard implements CanActivate {
|
||||
const bookingUid = request.params.bookingUid;
|
||||
|
||||
if (!bookingUid) {
|
||||
throw new BadRequestException("Booking UID missing in the request path");
|
||||
throw new BadRequestException("BookingUidGuard - Booking UID missing in the request path");
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
@@ -15,7 +15,7 @@ import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-eve
|
||||
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
|
||||
import { UsersService } from "@/modules/users/services/users.service";
|
||||
import { UsersRepository, UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
import { ConflictException, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
import { BadRequestException } from "@nestjs/common";
|
||||
import { Request } from "express";
|
||||
import { z } from "zod";
|
||||
@@ -141,6 +141,12 @@ export class BookingsService_2024_08_13 {
|
||||
if (error instanceof Error) {
|
||||
if (error.message === "no_available_users_found_error") {
|
||||
throw new BadRequestException("User either already has booking at this time or is not available");
|
||||
} else if (error.message === "booking_time_out_of_bounds_error") {
|
||||
throw new BadRequestException(
|
||||
`The event type can't be booked at selected time ${body.start}. This could be because it's too soon (violating the minimum booking notice) or too far in the future (outside the event's scheduling window). Try fetching available slots first using the GET /v2/slots endpoint and then make a booking with "start" time equal to one of the available slots: https://cal.com/docs/api-reference/v2/slots`
|
||||
);
|
||||
} else if (error.message === "Attempting to book a meeting in the past.") {
|
||||
throw new BadRequestException("Attempting to book a meeting in the past.");
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
@@ -223,6 +229,12 @@ export class BookingsService_2024_08_13 {
|
||||
body: CreateInstantBookingInput_2024_08_13,
|
||||
eventType: EventTypeWithOwnerAndTeam
|
||||
) {
|
||||
if (!eventType.team?.id) {
|
||||
throw new BadRequestException(
|
||||
"Instant bookings are only supported for team event types, not individual user event types."
|
||||
);
|
||||
}
|
||||
|
||||
const bookingRequest = await this.inputService.createBookingRequest(request, body, eventType);
|
||||
const booking = await handleInstantMeeting(bookingRequest);
|
||||
|
||||
@@ -313,29 +325,37 @@ export class BookingsService_2024_08_13 {
|
||||
eventType: EventTypeWithOwnerAndTeam
|
||||
) {
|
||||
const bookingRequest = await this.inputService.createBookingRequest(request, body, eventType);
|
||||
const booking = await handleNewBooking({
|
||||
bookingData: bookingRequest.body,
|
||||
userId: bookingRequest.userId,
|
||||
hostname: bookingRequest.headers?.host || "",
|
||||
platformClientId: bookingRequest.platformClientId,
|
||||
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
|
||||
platformCancelUrl: bookingRequest.platformCancelUrl,
|
||||
platformBookingUrl: bookingRequest.platformBookingUrl,
|
||||
platformBookingLocation: bookingRequest.platformBookingLocation,
|
||||
});
|
||||
try {
|
||||
const booking = await handleNewBooking({
|
||||
bookingData: bookingRequest.body,
|
||||
userId: bookingRequest.userId,
|
||||
hostname: bookingRequest.headers?.host || "",
|
||||
platformClientId: bookingRequest.platformClientId,
|
||||
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
|
||||
platformCancelUrl: bookingRequest.platformCancelUrl,
|
||||
platformBookingUrl: bookingRequest.platformBookingUrl,
|
||||
platformBookingLocation: bookingRequest.platformBookingLocation,
|
||||
});
|
||||
|
||||
if (!booking.uid) {
|
||||
throw new Error("Booking missing uid");
|
||||
if (!booking.uid) {
|
||||
throw new Error("Booking missing uid");
|
||||
}
|
||||
|
||||
const databaseBooking =
|
||||
await this.bookingsRepository.getByUidWithAttendeesWithBookingSeatAndUserAndEvent(booking.uid);
|
||||
if (!databaseBooking) {
|
||||
throw new Error(`Booking with uid=${booking.uid} was not found in the database`);
|
||||
}
|
||||
|
||||
return this.outputService.getOutputCreateSeatedBooking(databaseBooking, booking.seatReferenceUid || "");
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message === "booking_seats_full_error") {
|
||||
throw new ConflictException("No more seats left at this seated booking.");
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const databaseBooking = await this.bookingsRepository.getByUidWithAttendeesWithBookingSeatAndUserAndEvent(
|
||||
booking.uid
|
||||
);
|
||||
if (!databaseBooking) {
|
||||
throw new Error(`Booking with uid=${booking.uid} was not found in the database`);
|
||||
}
|
||||
|
||||
return this.outputService.getOutputCreateSeatedBooking(databaseBooking, booking.seatReferenceUid || "");
|
||||
}
|
||||
|
||||
async getBooking(uid: string) {
|
||||
@@ -697,17 +717,28 @@ export class BookingsService_2024_08_13 {
|
||||
|
||||
const profile = this.usersService.getUserMainProfile(user);
|
||||
|
||||
const reassigned = await roundRobinManualReassignment({
|
||||
bookingId: booking.id,
|
||||
newUserId,
|
||||
orgId: profile?.organizationId || null,
|
||||
reassignReason: body.reason,
|
||||
reassignedById,
|
||||
emailsEnabled,
|
||||
platformClientParams,
|
||||
});
|
||||
try {
|
||||
const reassigned = await roundRobinManualReassignment({
|
||||
bookingId: booking.id,
|
||||
newUserId,
|
||||
orgId: profile?.organizationId || null,
|
||||
reassignReason: body.reason,
|
||||
reassignedById,
|
||||
emailsEnabled,
|
||||
platformClientParams,
|
||||
});
|
||||
|
||||
return this.outputService.getOutputReassignedBooking(reassigned);
|
||||
return this.outputService.getOutputReassignedBooking(reassigned);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message === "invalid_round_robin_host") {
|
||||
throw new BadRequestException(
|
||||
`User with id=${newUserId} is not a valid Round Robin host - the user to which you reassign this booking must be one of the booking hosts. Fetch the booking using following endpoint and select id of one of the hosts: https://cal.com/docs/api-reference/v2/bookings/get-a-booking`
|
||||
);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async confirmBooking(bookingUid: string, requestUser: UserWithProfile) {
|
||||
|
||||
@@ -150,7 +150,7 @@ export class CalendarsController {
|
||||
@Headers("Authorization") authorization: string,
|
||||
@Param("calendar") calendar: string,
|
||||
@Query("redir") redir?: string | null,
|
||||
@Query("isDryRun", ParseBoolPipe) isDryRun?: boolean
|
||||
@Query("isDryRun", new ParseBoolPipe({ optional: true })) isDryRun?: boolean
|
||||
): Promise<ApiResponse<{ authUrl: string }>> {
|
||||
switch (calendar) {
|
||||
case OFFICE_365_CALENDAR:
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { MeController } from "@/ee/me/me.controller";
|
||||
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
|
||||
import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [UsersModule, SchedulesModule_2024_04_15, TokensModule],
|
||||
imports: [UsersModule, SchedulesModule_2024_04_15, TokensModule, OAuthClientModule],
|
||||
controllers: [MeController],
|
||||
})
|
||||
export class MeModule {}
|
||||
|
||||
@@ -38,6 +38,10 @@ export const getEnv = <K extends keyof Environment>(key: K, fallback?: Environme
|
||||
const value = process.env[key] as Environment[K] | undefined;
|
||||
|
||||
if (value === undefined) {
|
||||
// handle fallback falsy cases that should still be used as value
|
||||
if (fallback === false || fallback === "" || fallback === 0) {
|
||||
return fallback;
|
||||
}
|
||||
if (fallback) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
|
||||
}
|
||||
|
||||
if (allLimitsBlocked) {
|
||||
throw new ThrottlerException("Too many requests. Please try again later.");
|
||||
throw new ThrottlerException("CustomThrottlerGuard - Too many requests. Please try again later.");
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -96,7 +96,7 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
|
||||
|
||||
const { isBlocked } = await this.incrementRateLimit(tracker, rateLimit, response);
|
||||
if (isBlocked) {
|
||||
throw new ThrottlerException("Too many requests. Please try again later.");
|
||||
throw new ThrottlerException("CustomThrottlerGuard - Too many requests. Please try again later.");
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -150,7 +150,7 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
|
||||
});
|
||||
|
||||
if (!apiKeyRecord) {
|
||||
throw new UnauthorizedException("Invalid API Key");
|
||||
throw new UnauthorizedException("CustomThrottlerGuard - Invalid API Key");
|
||||
}
|
||||
|
||||
rateLimits = await this.dbRead.prisma.rateLimit.findMany({
|
||||
|
||||
@@ -81,6 +81,10 @@ export class EventTypesAtomService {
|
||||
this.eventTypeService.checkUserOwnsEventType(user.id, eventType.eventType);
|
||||
}
|
||||
|
||||
// note (Lauris): don't show platform owner as one of the people that can be assigned to managed team event type
|
||||
const onlyManagedTeamMembers = eventType.teamMembers.filter((user) => user.isPlatformManaged);
|
||||
eventType.teamMembers = onlyManagedTeamMembers;
|
||||
|
||||
return eventType;
|
||||
}
|
||||
|
||||
|
||||
@@ -104,13 +104,6 @@ describe("PlatformPlanGuard", () => {
|
||||
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("should throw ForbiddenException if the result is cached as false in Redis", async () => {
|
||||
jest.spyOn(reflector, "get").mockReturnValue("ESSENTIALS");
|
||||
jest.spyOn(redisService.redis, "get").mockResolvedValue(JSON.stringify(false));
|
||||
|
||||
await expect(guard.canActivate(mockContext)).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
function createMockExecutionContext(context: Record<string, string | object>): ExecutionContext {
|
||||
return createMock<ExecutionContext>({
|
||||
switchToHttp: () => ({
|
||||
|
||||
@@ -16,65 +16,63 @@ export class PlatformPlanGuard implements CanActivate {
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const teamId = request.params.teamId as string;
|
||||
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;
|
||||
if (!minimumPlan) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const orgId = request.params.orgId;
|
||||
const user = request.user as ApiAuthGuardUser;
|
||||
if (!user) {
|
||||
throw new ForbiddenException("PlatformPlanGuard - No user associated with the request.");
|
||||
}
|
||||
if (!orgId) {
|
||||
throw new ForbiddenException("PlatformPlanGuard - No organization associated with the request.");
|
||||
}
|
||||
|
||||
return await this.checkPlatformPlanAccess(orgId, minimumPlan);
|
||||
}
|
||||
|
||||
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}`;
|
||||
|
||||
const cachedAccess = JSON.parse((await this.redisService.redis.get(REDIS_CACHE_KEY)) ?? "false");
|
||||
|
||||
if (cachedAccess) {
|
||||
return { canAccess: cachedAccess };
|
||||
async checkPlatformPlanAccess(orgId: string, minimumPlan: PlatformPlanType) {
|
||||
const REDIS_CACHE_KEY = `apiv2:org:${orgId}:guard:platformbilling:${minimumPlan}`;
|
||||
const cachedValue = await this.redisService.redis.get(REDIS_CACHE_KEY);
|
||||
if (cachedValue !== null) {
|
||||
return cachedValue === "true";
|
||||
}
|
||||
|
||||
let canAccess = false;
|
||||
const organization = await this.organizationsRepository.findByIdIncludeBilling(Number(orgId));
|
||||
const isPlatform = organization?.isPlatform;
|
||||
const hasSubscription = organization?.platformBilling?.subscriptionId;
|
||||
|
||||
if (user && orgId) {
|
||||
const team = await this.organizationsRepository.findByIdIncludeBilling(Number(orgId));
|
||||
const isPlatform = team?.isPlatform;
|
||||
const hasSubscription = team?.platformBilling?.subscriptionId;
|
||||
|
||||
if (!team) {
|
||||
canAccess = false;
|
||||
} else if (!isPlatform) {
|
||||
canAccess = true;
|
||||
} else if (!hasSubscription) {
|
||||
canAccess = false;
|
||||
} else {
|
||||
canAccess = hasMinimumPlan({
|
||||
currentPlan: team.platformBilling?.plan as PlatformPlanType,
|
||||
minimumPlan: minimumPlan,
|
||||
plans: ["FREE", "STARTER", "ESSENTIALS", "SCALE", "ENTERPRISE"],
|
||||
});
|
||||
}
|
||||
if (!organization) {
|
||||
throw new ForbiddenException(`PlatformPlanGuard - No organization found with id=${orgId}.`);
|
||||
}
|
||||
if (!isPlatform) {
|
||||
await this.redisService.redis.set(REDIS_CACHE_KEY, "true", "EX", 300);
|
||||
return true;
|
||||
}
|
||||
if (!hasSubscription) {
|
||||
throw new ForbiddenException(
|
||||
`PlatformPlanGuard - No platform subscription found for organization with id=${orgId}.`
|
||||
);
|
||||
}
|
||||
if (
|
||||
!hasMinimumPlan({
|
||||
currentPlan: organization.platformBilling?.plan as PlatformPlanType,
|
||||
minimumPlan: minimumPlan,
|
||||
plans: ["FREE", "STARTER", "ESSENTIALS", "SCALE", "ENTERPRISE"],
|
||||
})
|
||||
) {
|
||||
throw new ForbiddenException(
|
||||
`PlatformPlanGuard - organization with id=${orgId} does not have required plan for this operation. Minimum plan is ${minimumPlan} while the organization has ${
|
||||
organization.platformBilling?.plan || "undefined"
|
||||
}.`
|
||||
);
|
||||
}
|
||||
|
||||
await this.redisService.redis.set(REDIS_CACHE_KEY, String(canAccess), "EX", 300);
|
||||
if (canAccess) {
|
||||
return { canAccess };
|
||||
}
|
||||
throw new ForbiddenException(
|
||||
`Platform plan - you do not have required plan for this operation. Minimum plan is ${minimumPlan}.`
|
||||
);
|
||||
await this.redisService.redis.set(REDIS_CACHE_KEY, "true", "EX", 300);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,7 +88,7 @@ export function hasMinimumPlan(props: HasMinimumPlanProp): boolean {
|
||||
|
||||
if (currentPlanIndex === -1 || minimumPlanIndex === -1) {
|
||||
throw new Error(
|
||||
`Invalid platform billing plan provided. Current plan: ${props.currentPlan}, Minimum plan: ${props.minimumPlan}`
|
||||
`PlatformPlanGuard - Invalid platform billing plan provided. Current plan: ${props.currentPlan}, Minimum plan: ${props.minimumPlan}`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -20,11 +20,11 @@ export class IsMembershipInOrg implements CanActivate {
|
||||
const orgId: string = request.params.orgId;
|
||||
|
||||
if (!orgId) {
|
||||
throw new ForbiddenException("No org id found in request params.");
|
||||
throw new ForbiddenException("IsMembershipInOrg - No org id found in request params.");
|
||||
}
|
||||
|
||||
if (!membershipId) {
|
||||
throw new ForbiddenException("No membership id found in request params.");
|
||||
throw new ForbiddenException("IsMembershipInOrg - No membership id found in request params.");
|
||||
}
|
||||
|
||||
const membership = await this.organizationsMembershipRepository.findOrgMembership(
|
||||
@@ -33,7 +33,7 @@ export class IsMembershipInOrg implements CanActivate {
|
||||
);
|
||||
|
||||
if (!membership) {
|
||||
throw new NotFoundException(`Membership (${membershipId}) not found.`);
|
||||
throw new NotFoundException(`IsMembershipInOrg - Membership (${membershipId}) not found.`);
|
||||
}
|
||||
|
||||
request.membership = membership;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { NO_AUTH_PROVIDED_MESSAGE } from "@/modules/auth/strategies/api-auth/api
|
||||
export class OptionalApiAuthGuard extends ApiAuthGuard {
|
||||
handleRequest(error: Error, user: any) {
|
||||
// note(Lauris): optional means that auth is not required but if it is invalid then still throw error.
|
||||
const noAuthProvided = error && error.message === NO_AUTH_PROVIDED_MESSAGE;
|
||||
const noAuthProvided = error && error.message.includes(NO_AUTH_PROVIDED_MESSAGE);
|
||||
if (user || noAuthProvided || !error) {
|
||||
return user || null;
|
||||
} else {
|
||||
|
||||
@@ -22,14 +22,19 @@ export class IsAdminAPIEnabledGuard implements CanActivate {
|
||||
const organizationId: string = request.params.orgId;
|
||||
|
||||
if (!organizationId) {
|
||||
throw new ForbiddenException("No organization id found in request params.");
|
||||
throw new ForbiddenException("IsAdminAPIEnabledGuard - No organization id found in request params.");
|
||||
}
|
||||
|
||||
const { canAccess, organization } = await this.checkAdminAPIEnabled(organizationId);
|
||||
if (organization) {
|
||||
request.organization = organization;
|
||||
}
|
||||
return canAccess;
|
||||
if (!canAccess) {
|
||||
throw new ForbiddenException(
|
||||
`IsAdminAPIEnabledGuard - Organization with id=${organizationId} does not have Admin API access. Please contact https://cal.com/sales to upgrade.`
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async checkAdminAPIEnabled(
|
||||
@@ -57,18 +62,21 @@ export class IsAdminAPIEnabledGuard implements CanActivate {
|
||||
);
|
||||
if (!adminAPIAccessIsEnabledInOrg) {
|
||||
throw new ForbiddenException(
|
||||
`Organization does not have Admin API access, please contact https://cal.com/sales to upgrade`
|
||||
`IsAdminAPIEnabledGuard - Organization does not have Admin API access, please contact https://cal.com/sales to upgrade`
|
||||
);
|
||||
}
|
||||
}
|
||||
canAccess = true;
|
||||
org &&
|
||||
(await this.redisService.redis.set(
|
||||
|
||||
if (org && canAccess) {
|
||||
await this.redisService.redis.set(
|
||||
REDIS_CACHE_KEY,
|
||||
JSON.stringify({ org: org, canAccess } satisfies CachedData),
|
||||
"EX",
|
||||
300
|
||||
));
|
||||
);
|
||||
}
|
||||
|
||||
return { canAccess, organization: org };
|
||||
}
|
||||
}
|
||||
|
||||
+5
-11
@@ -1,11 +1,5 @@
|
||||
import { ManagedOrganizationsRepository } from "@/modules/organizations/organizations/managed-organizations.repository";
|
||||
import {
|
||||
Injectable,
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common";
|
||||
import { Request } from "express";
|
||||
|
||||
import { Team } from "@calcom/prisma/client";
|
||||
@@ -20,11 +14,11 @@ export class IsManagedOrgInManagerOrg implements CanActivate {
|
||||
const managerOrgId: string = request.params.orgId;
|
||||
|
||||
if (!managerOrgId) {
|
||||
throw new ForbiddenException("No manager org id found in request params.");
|
||||
throw new ForbiddenException("IsManagedOrgInManagerOrg - No manager org id found in request params.");
|
||||
}
|
||||
|
||||
if (!managedOrgId) {
|
||||
throw new ForbiddenException("No managed org id found in request params.");
|
||||
throw new ForbiddenException("IsManagedOrgInManagerOrg - No managed org id found in request params.");
|
||||
}
|
||||
|
||||
const managedOrganization = await this.managedOrganizationsRepository.getByManagerManagedIds(
|
||||
@@ -33,8 +27,8 @@ export class IsManagedOrgInManagerOrg implements CanActivate {
|
||||
);
|
||||
|
||||
if (!managedOrganization) {
|
||||
throw new NotFoundException(
|
||||
`Managed organization with id=${managedOrgId} within manager organization with id=${managerOrgId} not found.`
|
||||
throw new ForbiddenException(
|
||||
`IsManagedOrgInManagerOrg - Managed organization with id=${managedOrgId} is not owned by manager organization with id=${managerOrgId}.`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ export class IsOrgGuard implements CanActivate {
|
||||
const organizationId: string = request.params.orgId;
|
||||
|
||||
if (!organizationId) {
|
||||
throw new ForbiddenException("No organization id found in request params.");
|
||||
throw new ForbiddenException("IsOrgGuard - No organization id found in request params.");
|
||||
}
|
||||
|
||||
const { canAccess, org } = await this.checkOrgAccess(organizationId);
|
||||
@@ -31,7 +31,13 @@ export class IsOrgGuard implements CanActivate {
|
||||
request.organization = org;
|
||||
}
|
||||
|
||||
return canAccess;
|
||||
if (!canAccess) {
|
||||
throw new ForbiddenException(
|
||||
`IsOrgGuard - provided organization id=${organizationId} does not represent any existing organization.`
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async checkOrgAccess(organizationId: string): Promise<{ canAccess: boolean; org?: Team | null }> {
|
||||
@@ -55,7 +61,7 @@ export class IsOrgGuard implements CanActivate {
|
||||
canAccess = true;
|
||||
}
|
||||
|
||||
if (org) {
|
||||
if (org && canAccess) {
|
||||
await this.redisService.redis.set(
|
||||
REDIS_CACHE_KEY,
|
||||
JSON.stringify({ org, canAccess } satisfies CachedData),
|
||||
|
||||
@@ -26,10 +26,10 @@ export class IsWebhookInOrg implements CanActivate {
|
||||
const organizationId: string = request.params.orgId;
|
||||
|
||||
if (!organizationId) {
|
||||
throw new ForbiddenException("No organization id found in request params.");
|
||||
throw new ForbiddenException("IsWebhookInOrg - No organization id found in request params.");
|
||||
}
|
||||
if (!webhookId) {
|
||||
throw new ForbiddenException("No webhook id found in request params.");
|
||||
throw new ForbiddenException("IsWebhookInOrg - No webhook id found in request params.");
|
||||
}
|
||||
|
||||
const REDIS_CACHE_KEY = `apiv2:org:${webhookId}:guard:isWebhookInOrg`;
|
||||
@@ -53,7 +53,7 @@ export class IsWebhookInOrg implements CanActivate {
|
||||
if (isWebhookInOrg) canAccess = true;
|
||||
}
|
||||
|
||||
if (org) {
|
||||
if (org && canAccess) {
|
||||
await this.redisService.redis.set(
|
||||
REDIS_CACHE_KEY,
|
||||
JSON.stringify({ org: org, canAccess } satisfies CachedData),
|
||||
@@ -62,6 +62,12 @@ export class IsWebhookInOrg implements CanActivate {
|
||||
);
|
||||
}
|
||||
|
||||
return canAccess;
|
||||
if (!canAccess) {
|
||||
throw new ForbiddenException(
|
||||
`IsWebhookInOrg - webhook with id=${webhookId} is not part of the organization with id=${organizationId}.`
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
import { OAuthClientsOutputService } from "@/modules/oauth-clients/services/oauth-clients/oauth-clients-output.service";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { createMock } from "@golevelup/ts-jest";
|
||||
import { ExecutionContext } from "@nestjs/common";
|
||||
@@ -28,7 +29,21 @@ describe("PermissionsGuard", () => {
|
||||
}
|
||||
}),
|
||||
}),
|
||||
createMock<OAuthClientRepository>()
|
||||
createMock<OAuthClientRepository>(),
|
||||
createMock<OAuthClientsOutputService>({
|
||||
transformOAuthClientPermission: jest.fn().mockImplementation((permission: number) => {
|
||||
switch (permission) {
|
||||
case SCHEDULE_WRITE:
|
||||
return "SCHEDULE_WRITE";
|
||||
case SCHEDULE_READ:
|
||||
return "SCHEDULE_READ";
|
||||
case APPS_WRITE:
|
||||
return "APPS_WRITE";
|
||||
default:
|
||||
return "UNKNOWN";
|
||||
}
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -36,13 +51,22 @@ describe("PermissionsGuard", () => {
|
||||
expect(guard).toBeDefined();
|
||||
});
|
||||
|
||||
function getMockOAuthClient(permissions: number) {
|
||||
return {
|
||||
id: "100",
|
||||
permissions,
|
||||
};
|
||||
}
|
||||
|
||||
describe("when access token is missing", () => {
|
||||
it("should return false", async () => {
|
||||
const mockContext = createMockExecutionContext({});
|
||||
jest.spyOn(reflector, "get").mockReturnValue([SCHEDULE_WRITE]);
|
||||
jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(0);
|
||||
jest.spyOn(guard, "getOAuthClientByAccessToken").mockResolvedValue(getMockOAuthClient(0));
|
||||
|
||||
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
|
||||
await expect(guard.canActivate(mockContext)).rejects.toThrow(
|
||||
"PermissionsGuard - no authentication provided. Provide either authorization bearer token containing managed user access token or oAuth client id in 'x-cal-client-id' header."
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -53,7 +77,9 @@ describe("PermissionsGuard", () => {
|
||||
|
||||
let oAuthClientPermissions = 0;
|
||||
oAuthClientPermissions |= SCHEDULE_WRITE;
|
||||
jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(oAuthClientPermissions);
|
||||
jest
|
||||
.spyOn(guard, "getOAuthClientByAccessToken")
|
||||
.mockResolvedValue(getMockOAuthClient(oAuthClientPermissions));
|
||||
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
@@ -64,7 +90,9 @@ describe("PermissionsGuard", () => {
|
||||
let oAuthClientPermissions = 0;
|
||||
oAuthClientPermissions |= SCHEDULE_WRITE;
|
||||
oAuthClientPermissions |= SCHEDULE_READ;
|
||||
jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(oAuthClientPermissions);
|
||||
jest
|
||||
.spyOn(guard, "getOAuthClientByAccessToken")
|
||||
.mockResolvedValue(getMockOAuthClient(oAuthClientPermissions));
|
||||
|
||||
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
|
||||
});
|
||||
@@ -75,7 +103,9 @@ describe("PermissionsGuard", () => {
|
||||
|
||||
let oAuthClientPermissions = 0;
|
||||
oAuthClientPermissions |= SCHEDULE_WRITE;
|
||||
jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(oAuthClientPermissions);
|
||||
jest
|
||||
.spyOn(guard, "getOAuthClientByAccessToken")
|
||||
.mockResolvedValue(getMockOAuthClient(oAuthClientPermissions));
|
||||
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
@@ -85,9 +115,13 @@ describe("PermissionsGuard", () => {
|
||||
|
||||
let oAuthClientPermissions = 0;
|
||||
oAuthClientPermissions |= APPS_WRITE;
|
||||
jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(oAuthClientPermissions);
|
||||
jest
|
||||
.spyOn(guard, "getOAuthClientByAccessToken")
|
||||
.mockResolvedValue(getMockOAuthClient(oAuthClientPermissions));
|
||||
|
||||
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
|
||||
await expect(guard.canActivate(mockContext)).rejects.toThrow(
|
||||
"PermissionsGuard - oAuth client with id=100 does not have the required permissions=SCHEDULE_WRITE"
|
||||
);
|
||||
});
|
||||
|
||||
it("should return false for a missing permission", async () => {
|
||||
@@ -96,9 +130,13 @@ describe("PermissionsGuard", () => {
|
||||
|
||||
let oAuthClientPermissions = 0;
|
||||
oAuthClientPermissions |= SCHEDULE_WRITE;
|
||||
jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(oAuthClientPermissions);
|
||||
jest
|
||||
.spyOn(guard, "getOAuthClientByAccessToken")
|
||||
.mockResolvedValue(getMockOAuthClient(oAuthClientPermissions));
|
||||
|
||||
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
|
||||
await expect(guard.canActivate(mockContext)).rejects.toThrow(
|
||||
"PermissionsGuard - oAuth client with id=100 does not have the required permissions=SCHEDULE_WRITE, SCHEDULE_READ"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -109,7 +147,7 @@ describe("PermissionsGuard", () => {
|
||||
|
||||
let oAuthClientPermissions = 0;
|
||||
oAuthClientPermissions |= SCHEDULE_WRITE;
|
||||
jest.spyOn(guard, "getOAuthClientPermissionsById").mockResolvedValue(oAuthClientPermissions);
|
||||
jest.spyOn(guard, "getOAuthClientById").mockResolvedValue(getMockOAuthClient(oAuthClientPermissions));
|
||||
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
@@ -120,7 +158,7 @@ describe("PermissionsGuard", () => {
|
||||
let oAuthClientPermissions = 0;
|
||||
oAuthClientPermissions |= SCHEDULE_WRITE;
|
||||
oAuthClientPermissions |= SCHEDULE_READ;
|
||||
jest.spyOn(guard, "getOAuthClientPermissionsById").mockResolvedValue(oAuthClientPermissions);
|
||||
jest.spyOn(guard, "getOAuthClientById").mockResolvedValue(getMockOAuthClient(oAuthClientPermissions));
|
||||
|
||||
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
|
||||
});
|
||||
@@ -131,7 +169,7 @@ describe("PermissionsGuard", () => {
|
||||
|
||||
let oAuthClientPermissions = 0;
|
||||
oAuthClientPermissions |= SCHEDULE_WRITE;
|
||||
jest.spyOn(guard, "getOAuthClientPermissionsById").mockResolvedValue(oAuthClientPermissions);
|
||||
jest.spyOn(guard, "getOAuthClientById").mockResolvedValue(getMockOAuthClient(oAuthClientPermissions));
|
||||
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
@@ -141,9 +179,11 @@ describe("PermissionsGuard", () => {
|
||||
|
||||
let oAuthClientPermissions = 0;
|
||||
oAuthClientPermissions |= APPS_WRITE;
|
||||
jest.spyOn(guard, "getOAuthClientPermissionsById").mockResolvedValue(oAuthClientPermissions);
|
||||
jest.spyOn(guard, "getOAuthClientById").mockResolvedValue(getMockOAuthClient(oAuthClientPermissions));
|
||||
|
||||
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
|
||||
await expect(guard.canActivate(mockContext)).rejects.toThrow(
|
||||
"PermissionsGuard - oAuth client with id=100 does not have the required permissions=SCHEDULE_WRITE"
|
||||
);
|
||||
});
|
||||
|
||||
it("should return false for a missing permission", async () => {
|
||||
@@ -152,9 +192,11 @@ describe("PermissionsGuard", () => {
|
||||
|
||||
let oAuthClientPermissions = 0;
|
||||
oAuthClientPermissions |= SCHEDULE_WRITE;
|
||||
jest.spyOn(guard, "getOAuthClientPermissionsById").mockResolvedValue(oAuthClientPermissions);
|
||||
jest.spyOn(guard, "getOAuthClientById").mockResolvedValue(getMockOAuthClient(oAuthClientPermissions));
|
||||
|
||||
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
|
||||
await expect(guard.canActivate(mockContext)).rejects.toThrow(
|
||||
"PermissionsGuard - oAuth client with id=100 does not have the required permissions=SCHEDULE_WRITE, SCHEDULE_READ"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { isApiKey } from "@/lib/api-key";
|
||||
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
import { OAuthClientsOutputService } from "@/modules/oauth-clients/services/oauth-clients/oauth-clients-output.service";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { Injectable, CanActivate, ExecutionContext } from "@nestjs/common";
|
||||
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
import { getToken } from "next-auth/jwt";
|
||||
|
||||
import { X_CAL_CLIENT_ID } from "@calcom/platform-constants";
|
||||
import { hasPermissions } from "@calcom/platform-utils";
|
||||
import { PlatformOAuthClient } from "@calcom/prisma/client";
|
||||
|
||||
@Injectable()
|
||||
export class PermissionsGuard implements CanActivate {
|
||||
@@ -16,7 +18,8 @@ export class PermissionsGuard implements CanActivate {
|
||||
private reflector: Reflector,
|
||||
private tokensRepository: TokensRepository,
|
||||
private readonly config: ConfigService,
|
||||
private readonly oAuthClientRepository: OAuthClientRepository
|
||||
private readonly oAuthClientRepository: OAuthClientRepository,
|
||||
private readonly oAuthClientsOutputService: OAuthClientsOutputService
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
@@ -27,42 +30,61 @@ export class PermissionsGuard implements CanActivate {
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const authString = request.get("Authorization")?.replace("Bearer ", "");
|
||||
const bearerToken = request.get("Authorization")?.replace("Bearer ", "");
|
||||
const nextAuthSecret = this.config.get("next.authSecret", { infer: true });
|
||||
const nextAuthToken = await getToken({ req: request, secret: nextAuthSecret });
|
||||
const oAuthClientId = request.params?.clientId || request.get(X_CAL_CLIENT_ID);
|
||||
const apiKey = bearerToken && isApiKey(bearerToken, this.config.get("api.apiKeyPrefix") ?? "cal_");
|
||||
|
||||
if (nextAuthToken) {
|
||||
// only check permissions for accessTokens attached to an oAuth Client or oAuth credentials, not for next token or api key
|
||||
if (nextAuthToken || apiKey) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!authString && !oAuthClientId) {
|
||||
return false;
|
||||
if (!bearerToken && !oAuthClientId) {
|
||||
throw new ForbiddenException(
|
||||
"PermissionsGuard - no authentication provided. Provide either authorization bearer token containing managed user access token or oAuth client id in 'x-cal-client-id' header."
|
||||
);
|
||||
}
|
||||
|
||||
// only check permissions for accessTokens attached to an oAuth Client
|
||||
if (isApiKey(authString, this.config.get("api.apiKeyPrefix") ?? "cal_")) {
|
||||
return true;
|
||||
const oAuthClient = bearerToken
|
||||
? await this.getOAuthClientByAccessToken(bearerToken)
|
||||
: await this.getOAuthClientById(oAuthClientId);
|
||||
|
||||
const hasRequiredPermissions = hasPermissions(oAuthClient.permissions, [...requiredPermissions]);
|
||||
|
||||
if (!hasRequiredPermissions) {
|
||||
throw new ForbiddenException(
|
||||
`PermissionsGuard - oAuth client with id=${
|
||||
oAuthClient.id
|
||||
} does not have the required permissions=${requiredPermissions
|
||||
.map((permission) => this.oAuthClientsOutputService.transformOAuthClientPermission(permission))
|
||||
.join(
|
||||
", "
|
||||
)}. Go to platform dashboard settings and add the required permissions to the oAuth client.`
|
||||
);
|
||||
}
|
||||
|
||||
const oAuthClientPermissions = authString
|
||||
? await this.getOAuthClientPermissionsByAccessToken(authString)
|
||||
: await this.getOAuthClientPermissionsById(oAuthClientId);
|
||||
|
||||
if (!oAuthClientPermissions) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return hasPermissions(oAuthClientPermissions, [...requiredPermissions]);
|
||||
return true;
|
||||
}
|
||||
|
||||
async getOAuthClientPermissionsByAccessToken(accessToken: string) {
|
||||
async getOAuthClientByAccessToken(
|
||||
accessToken: string
|
||||
): Promise<Pick<PlatformOAuthClient, "id" | "permissions">> {
|
||||
const oAuthClient = await this.tokensRepository.getAccessTokenClient(accessToken);
|
||||
return oAuthClient?.permissions;
|
||||
if (!oAuthClient) {
|
||||
throw new ForbiddenException(
|
||||
`PermissionsGuard - no oAuth client found for access token=${accessToken}`
|
||||
);
|
||||
}
|
||||
return oAuthClient;
|
||||
}
|
||||
|
||||
async getOAuthClientPermissionsById(id: string) {
|
||||
async getOAuthClientById(id: string): Promise<Pick<PlatformOAuthClient, "id" | "permissions">> {
|
||||
const oAuthClient = await this.oAuthClientRepository.getOAuthClient(id);
|
||||
return oAuthClient?.permissions;
|
||||
if (!oAuthClient) {
|
||||
throw new ForbiddenException(`PermissionsGuard - no oAuth client found for client id=${id}`);
|
||||
}
|
||||
return oAuthClient;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,27 @@ export class RolesGuard implements CanActivate {
|
||||
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;
|
||||
|
||||
if (!canAccess) {
|
||||
this.throwForbiddenError(user, orgId, teamId, allowedRole);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
throwForbiddenError(user: ApiAuthGuardUser, orgId: string, teamId: string, allowedRole: string) {
|
||||
let errorMessage = `RolesGuard - user with id=${user.id} does not have the minimum required role=${allowedRole} within`;
|
||||
if (orgId) {
|
||||
errorMessage += ` organization with id=${orgId}`;
|
||||
}
|
||||
if (teamId) {
|
||||
errorMessage += ` team with id=${teamId}`;
|
||||
}
|
||||
errorMessage += `.`;
|
||||
|
||||
throw new ForbiddenException(errorMessage);
|
||||
}
|
||||
|
||||
async checkUserRoleAccess(
|
||||
user: ApiAuthGuardUser,
|
||||
orgId: string,
|
||||
@@ -67,7 +86,9 @@ export class RolesGuard implements CanActivate {
|
||||
const membership = await this.membershipRepository.findMembershipByOrgId(Number(orgId), user.id);
|
||||
if (!membership) {
|
||||
this.logger.log(`User (${user.id}) is not a member of the organization (${orgId}), denying access.`);
|
||||
throw new ForbiddenException(`User is not a member of the organization.`);
|
||||
throw new ForbiddenException(
|
||||
`RolesGuard - User is not a member of the organization with id=${orgId}.`
|
||||
);
|
||||
}
|
||||
|
||||
if (ORG_ROLES.includes(allowedRole as unknown as (typeof ORG_ROLES)[number])) {
|
||||
@@ -84,7 +105,7 @@ export class RolesGuard implements CanActivate {
|
||||
const membership = await this.membershipRepository.findMembershipByTeamId(Number(teamId), user.id);
|
||||
if (!membership) {
|
||||
this.logger.log(`User (${user.id}) is not a member of the team (${teamId}), denying access.`);
|
||||
throw new ForbiddenException(`User is not a member of the team.`);
|
||||
throw new ForbiddenException(`RolesGuard - User is not a member of the team with id=${teamId}.`);
|
||||
}
|
||||
if (TEAM_ROLES.includes(allowedRole as unknown as (typeof TEAM_ROLES)[number])) {
|
||||
canAccess = hasMinimumRole({
|
||||
@@ -102,7 +123,7 @@ export class RolesGuard implements CanActivate {
|
||||
|
||||
if (!orgMembership) {
|
||||
this.logger.log(`User (${user.id}) is not part of the organization (${orgId}), denying access.`);
|
||||
throw new ForbiddenException(`User is not part of the organization.`);
|
||||
throw new ForbiddenException(`RolesGuard - User is not part of the organization with id=${orgId}.`);
|
||||
}
|
||||
|
||||
// if the role checked is a TEAM role
|
||||
@@ -116,7 +137,7 @@ export class RolesGuard implements CanActivate {
|
||||
`User (${user.id}) is not part of the team (${teamId}) and/or, is not an admin nor an owner of the organization (${orgId}).`
|
||||
);
|
||||
throw new ForbiddenException(
|
||||
"User is not part of the team and/or, is not an admin nor an owner of the organization."
|
||||
`RolesGuard - User is not part of the team with id=${teamId} and/or, is not an admin nor an owner of the organization with id=${orgId}.`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -138,7 +159,11 @@ export class RolesGuard implements CanActivate {
|
||||
});
|
||||
}
|
||||
}
|
||||
await this.redisService.redis.set(REDIS_CACHE_KEY, String(canAccess), "EX", 300);
|
||||
|
||||
if (canAccess) {
|
||||
await this.redisService.redis.set(REDIS_CACHE_KEY, String(canAccess), "EX", 300);
|
||||
}
|
||||
|
||||
return { canAccess };
|
||||
}
|
||||
}
|
||||
@@ -165,7 +190,7 @@ export function hasMinimumRole(props: HasMinimumRoleProp): boolean {
|
||||
|
||||
// minimum role given does not exist
|
||||
if (checkedRoleIndex === -1 || requiredRoleIndex === -1) {
|
||||
throw new Error("Invalid role");
|
||||
throw new Error("RolesGuard - Invalid role");
|
||||
}
|
||||
|
||||
return checkedRoleIndex <= requiredRoleIndex;
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import { RoutingFormsRepository } from "@/modules/routing-forms/routing-forms.repository";
|
||||
import {
|
||||
Injectable,
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common";
|
||||
import { Request } from "express";
|
||||
|
||||
import { Team } from "@calcom/prisma/client";
|
||||
@@ -20,18 +14,18 @@ export class IsRoutingFormInTeam implements CanActivate {
|
||||
const routingFormId: string = request.params.routingFormId;
|
||||
|
||||
if (!routingFormId) {
|
||||
throw new ForbiddenException("No routing form id found in request params.");
|
||||
throw new ForbiddenException("IsRoutingFormInTeam - No routing form id found in request params.");
|
||||
}
|
||||
|
||||
if (!teamId) {
|
||||
throw new ForbiddenException("No team id found in request params.");
|
||||
throw new ForbiddenException("IsRoutingFormInTeam - No team id found in request params.");
|
||||
}
|
||||
|
||||
const routingForm = await this.routingFormsRepository.getTeamRoutingForm(Number(teamId), routingFormId);
|
||||
|
||||
if (!routingForm) {
|
||||
throw new NotFoundException(
|
||||
`Team with id=(${teamId}) routing form with id=(${routingFormId}) not found.`
|
||||
throw new ForbiddenException(
|
||||
`IsRoutingFormInTeam - team with id=(${teamId}) does not own routing form with id=(${routingFormId}).`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -20,26 +20,30 @@ export class IsTeamInOrg implements CanActivate {
|
||||
const orgId: string = request.params.orgId;
|
||||
|
||||
if (!orgId) {
|
||||
throw new ForbiddenException("No org id found in request params.");
|
||||
throw new ForbiddenException("IsTeamInOrg - No org id found in request params.");
|
||||
}
|
||||
|
||||
if (!teamId) {
|
||||
throw new ForbiddenException("No team id found in request params.");
|
||||
throw new ForbiddenException("IsTeamInOrg - No team id found in request params.");
|
||||
}
|
||||
|
||||
const { canAccess, team } = await this.checkIfTeamIsInOrg(orgId, teamId);
|
||||
|
||||
if (canAccess && team) {
|
||||
request.team = team;
|
||||
if (!canAccess) {
|
||||
throw new ForbiddenException(
|
||||
`IsTeamInOrg - Team with id=${teamId} is not part of the organization with id=${orgId}.`
|
||||
);
|
||||
}
|
||||
return canAccess;
|
||||
|
||||
request.team = team;
|
||||
return true;
|
||||
}
|
||||
|
||||
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.`);
|
||||
throw new NotFoundException(`IsTeamInOrg - Team (${teamId}) not found.`);
|
||||
}
|
||||
|
||||
if (!team.isOrganization && team.parentId === Number(orgId)) {
|
||||
|
||||
@@ -15,15 +15,15 @@ export class IsUserInOrgTeam implements CanActivate {
|
||||
const userId: string = request.params.userId;
|
||||
|
||||
if (!userId) {
|
||||
throw new ForbiddenException("No user id found in request params.");
|
||||
throw new ForbiddenException("IsUserInOrgTeam - No user id found in request params.");
|
||||
}
|
||||
|
||||
if (!orgId) {
|
||||
throw new ForbiddenException("No org id found in request params.");
|
||||
throw new ForbiddenException("IsUserInOrgTeam - No org id found in request params.");
|
||||
}
|
||||
|
||||
if (!teamId) {
|
||||
throw new ForbiddenException("No team id found in request params.");
|
||||
throw new ForbiddenException("IsUserInOrgTeam - No team id found in request params.");
|
||||
}
|
||||
|
||||
const user = await this.organizationsRepository.findOrgTeamUser(
|
||||
@@ -37,6 +37,8 @@ export class IsUserInOrgTeam implements CanActivate {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
throw new ForbiddenException(
|
||||
`IsUserInOrgTeam - user with id=(${userId}) is not part of the team with id=(${teamId}) in the organization with id=(${orgId})`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,20 +14,22 @@ export class IsUserInOrg implements CanActivate {
|
||||
const userId: string = request.params.userId;
|
||||
|
||||
if (!userId) {
|
||||
throw new ForbiddenException("No user id found in request params.");
|
||||
throw new ForbiddenException("IsUserInOrg - No user id found in request params.");
|
||||
}
|
||||
|
||||
if (!orgId) {
|
||||
throw new ForbiddenException("No org id found in request params.");
|
||||
throw new ForbiddenException("IsUserInOrg - No org id found in request params.");
|
||||
}
|
||||
|
||||
const user = await this.organizationsRepository.findOrgUser(Number(orgId), Number(userId));
|
||||
|
||||
if (user) {
|
||||
request.user = user;
|
||||
return true;
|
||||
if (!user) {
|
||||
throw new ForbiddenException(
|
||||
`IsUserInOrg - user with id=${userId} is not part of the organization with id=${orgId}.`
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
request.user = user;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,13 +70,15 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
|
||||
return await this.authenticateNextAuth(nextAuthToken, request);
|
||||
}
|
||||
|
||||
throw new UnauthorizedException(NO_AUTH_PROVIDED_MESSAGE);
|
||||
throw new UnauthorizedException(`ApiAuthStrategy - ${NO_AUTH_PROVIDED_MESSAGE}`);
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
return this.error(err);
|
||||
}
|
||||
return this.error(
|
||||
new InternalServerErrorException("An error occurred while authenticating the request")
|
||||
new InternalServerErrorException(
|
||||
"ApiAuthStrategy - An error occurred while authenticating the request"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -106,23 +108,29 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
|
||||
const client = await this.oauthRepository.getOAuthClient(oAuthClientId);
|
||||
|
||||
if (!client) {
|
||||
throw new UnauthorizedException(`Client with ID ${oAuthClientId} not found`);
|
||||
throw new UnauthorizedException(
|
||||
`ApiAuthStrategy - oAuth client - Client with ID ${oAuthClientId} not found`
|
||||
);
|
||||
}
|
||||
|
||||
if (client.secret !== oAuthClientSecret) {
|
||||
throw new UnauthorizedException("Invalid client secret");
|
||||
throw new UnauthorizedException("ApiAuthStrategy - oAuth client - Invalid client secret");
|
||||
}
|
||||
|
||||
const platformCreatorId = await this.profilesRepository.getPlatformOwnerUserId(client.organizationId);
|
||||
|
||||
if (!platformCreatorId) {
|
||||
throw new UnauthorizedException("No owner ID found for this OAuth client");
|
||||
throw new UnauthorizedException(
|
||||
"ApiAuthStrategy - oAuth client - No owner ID found for this OAuth client"
|
||||
);
|
||||
}
|
||||
|
||||
const user = await this.userRepository.findByIdWithProfile(platformCreatorId);
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException("No user associated with the provided OAuth client");
|
||||
throw new UnauthorizedException(
|
||||
"ApiAuthStrategy - oAuth client - No user associated with the provided OAuth client"
|
||||
);
|
||||
}
|
||||
|
||||
request.organizationId = client.organizationId;
|
||||
@@ -141,7 +149,11 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
|
||||
: await this.accessTokenStrategy(authString, request, requestOrigin);
|
||||
|
||||
if (!user) {
|
||||
return this.error(new UnauthorizedException("No user associated with the provided token"));
|
||||
return this.error(
|
||||
new UnauthorizedException(
|
||||
"ApiAuthStrategy - bearer token - No user associated with the provided token"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return this.success(this.getSuccessUser(user));
|
||||
@@ -158,24 +170,26 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
|
||||
async apiKeyStrategy(apiKey: string, request: ApiAuthGuardRequest) {
|
||||
const isLicenseValid = await this.deploymentsService.checkLicense();
|
||||
if (!isLicenseValid) {
|
||||
throw new UnauthorizedException("Invalid or missing CALCOM_LICENSE_KEY environment variable");
|
||||
throw new UnauthorizedException(
|
||||
"ApiAuthStrategy - api key - Invalid or missing CALCOM_LICENSE_KEY environment variable"
|
||||
);
|
||||
}
|
||||
const strippedApiKey = stripApiKey(apiKey, this.config.get<string>("api.keyPrefix"));
|
||||
const apiKeyHash = hashAPIKey(strippedApiKey);
|
||||
const keyData = await this.apiKeyRepository.getApiKeyFromHash(apiKeyHash);
|
||||
if (!keyData) {
|
||||
throw new UnauthorizedException("Your api key is not valid");
|
||||
throw new UnauthorizedException("ApiAuthStrategy - api key - Your api key is not valid");
|
||||
}
|
||||
|
||||
const isKeyExpired =
|
||||
keyData.expiresAt && new Date().setHours(0, 0, 0, 0) > keyData.expiresAt.setHours(0, 0, 0, 0);
|
||||
if (isKeyExpired) {
|
||||
throw new UnauthorizedException("Your api key is expired");
|
||||
throw new UnauthorizedException("ApiAuthStrategy - api key - Your api key is expired");
|
||||
}
|
||||
|
||||
const apiKeyOwnerId = keyData.userId;
|
||||
if (!apiKeyOwnerId) {
|
||||
throw new UnauthorizedException("No user tied to this apiKey");
|
||||
throw new UnauthorizedException("ApiAuthStrategy - api key - No user tied to this apiKey");
|
||||
}
|
||||
|
||||
const user: UserWithProfile | null = await this.userRepository.findByIdWithProfile(apiKeyOwnerId);
|
||||
@@ -187,29 +201,35 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
|
||||
async accessTokenStrategy(accessToken: string, request: ApiAuthGuardRequest, origin?: string) {
|
||||
const accessTokenValid = await this.oauthFlowService.validateAccessToken(accessToken);
|
||||
if (!accessTokenValid) {
|
||||
throw new UnauthorizedException(INVALID_ACCESS_TOKEN);
|
||||
throw new UnauthorizedException(`ApiAuthStrategy - access token - ${INVALID_ACCESS_TOKEN}`);
|
||||
}
|
||||
|
||||
const client = await this.tokensRepository.getAccessTokenClient(accessToken);
|
||||
if (!client) {
|
||||
throw new UnauthorizedException("OAuth client not found given the access token");
|
||||
throw new UnauthorizedException(
|
||||
"ApiAuthStrategy - access token - OAuth client not found given the access token"
|
||||
);
|
||||
}
|
||||
|
||||
if (origin && !isOriginAllowed(origin, client.redirectUris)) {
|
||||
throw new UnauthorizedException(
|
||||
`Invalid request origin - please open https://app.cal.com/settings/platform and add the origin '${origin}' to the 'Redirect uris' of your OAuth client with ID '${client.id}'`
|
||||
`ApiAuthStrategy - access token - Invalid request origin - please open https://app.cal.com/settings/platform and add the origin '${origin}' to the 'Redirect uris' of your OAuth client with ID '${client.id}'`
|
||||
);
|
||||
}
|
||||
|
||||
const ownerId = await this.tokensRepository.getAccessTokenOwnerId(accessToken);
|
||||
|
||||
if (!ownerId) {
|
||||
throw new UnauthorizedException(INVALID_ACCESS_TOKEN);
|
||||
throw new UnauthorizedException(
|
||||
`ApiAuthStrategy - access token - ${INVALID_ACCESS_TOKEN}. No owner found for this access token.`
|
||||
);
|
||||
}
|
||||
|
||||
const user: UserWithProfile | null = await this.userRepository.findByIdWithProfile(ownerId);
|
||||
if (!user) {
|
||||
throw new UnauthorizedException("User associated with the authentication api key not found.");
|
||||
throw new UnauthorizedException(
|
||||
"ApiAuthStrategy - access token - User associated with the access token not found."
|
||||
);
|
||||
}
|
||||
|
||||
const organizationId = this.usersService.getUserMainOrgId(user) as number;
|
||||
@@ -220,12 +240,16 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
|
||||
|
||||
async nextAuthStrategy(token: { email?: string | null }, request: ApiAuthGuardRequest) {
|
||||
if (!token.email) {
|
||||
throw new UnauthorizedException("Email not found in the authentication token.");
|
||||
throw new UnauthorizedException(
|
||||
"ApiAuthStrategy - next auth - Email not found in the authentication token."
|
||||
);
|
||||
}
|
||||
|
||||
const user = await this.userRepository.findByEmailWithProfile(token.email);
|
||||
if (!user) {
|
||||
throw new UnauthorizedException("User associated with the authentication token email not found.");
|
||||
throw new UnauthorizedException(
|
||||
"ApiAuthStrategy - next auth - User associated with the authentication token email not found."
|
||||
);
|
||||
}
|
||||
const organizationId = this.usersService.getUserMainOrgId(user) as number;
|
||||
request.organizationId = organizationId;
|
||||
|
||||
@@ -18,23 +18,27 @@ export class NextAuthStrategy extends PassportStrategy(NextAuthPassportStrategy,
|
||||
const payload = await getToken({ req, secret: nextAuthSecret });
|
||||
|
||||
if (!payload) {
|
||||
throw new UnauthorizedException("Authentication token is missing or invalid.");
|
||||
throw new UnauthorizedException("NextAuthStrategy - Authentication token is missing or invalid.");
|
||||
}
|
||||
|
||||
if (!payload.email) {
|
||||
throw new UnauthorizedException("Email not found in the authentication token.");
|
||||
throw new UnauthorizedException("NextAuthStrategy - Email not found in the authentication token.");
|
||||
}
|
||||
|
||||
const user = await this.userRepository.findByEmailWithProfile(payload.email);
|
||||
if (!user) {
|
||||
throw new UnauthorizedException("User associated with the authentication token email not found.");
|
||||
throw new UnauthorizedException(
|
||||
"NextAuthStrategy - User associated with the authentication token email not found."
|
||||
);
|
||||
}
|
||||
|
||||
return this.success(user);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) return this.error(error);
|
||||
return this.error(
|
||||
new InternalServerErrorException("An error occurred while authenticating the request")
|
||||
new InternalServerErrorException(
|
||||
"NextAuthStrategy - An error occurred while authenticating the request"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,10 @@ import { ConfigService } from "@nestjs/config";
|
||||
|
||||
const CACHING_TIME = 86400000; // 24 hours in milliseconds
|
||||
|
||||
const getLicenseCacheKey = (key: string) => `api-v2-license-key-url-${key}`;
|
||||
const getLicenseCacheKey = (key: string) => `api-v2-license-key-goblin-url-${key}`;
|
||||
|
||||
type LicenseCheckResponse = {
|
||||
valid: boolean;
|
||||
status: boolean;
|
||||
};
|
||||
@Injectable()
|
||||
export class DeploymentsService {
|
||||
@@ -36,12 +36,12 @@ export class DeploymentsService {
|
||||
const licenseKeyUrl = this.configService.get("api.licenseKeyUrl") + `/${licenseKey}`;
|
||||
const cachedData = await this.redisService.redis.get(getLicenseCacheKey(licenseKey));
|
||||
if (cachedData) {
|
||||
return (JSON.parse(cachedData) as LicenseCheckResponse)?.valid;
|
||||
return (JSON.parse(cachedData) as LicenseCheckResponse)?.status;
|
||||
}
|
||||
const response = await fetch(licenseKeyUrl, { mode: "cors" });
|
||||
const data = (await response.json()) as LicenseCheckResponse;
|
||||
const cacheKey = getLicenseCacheKey(licenseKey);
|
||||
this.redisService.redis.set(cacheKey, JSON.stringify(data), "EX", CACHING_TIME);
|
||||
return data.valid;
|
||||
return data.status;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
ForbiddenException,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { Request } from "express";
|
||||
|
||||
@Injectable()
|
||||
export class OAuthClientGuard implements CanActivate {
|
||||
@@ -35,7 +34,13 @@ export class OAuthClientGuard implements CanActivate {
|
||||
throw new NotFoundException("OAuthClientGuard - OAuth client not found.");
|
||||
}
|
||||
|
||||
return Boolean(user.isSystemAdmin || oAuthClient.organizationId === organizationId);
|
||||
const allowed = Boolean(user.isSystemAdmin || oAuthClient.organizationId === organizationId);
|
||||
if (!allowed) {
|
||||
throw new ForbiddenException(
|
||||
`OAuthClientGuard - forbidden. oAuth client with id=${oAuthClientId} does not belong to the organization with id=${organizationId}.`
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
getOrganizationId(context: ExecutionContext) {
|
||||
|
||||
@@ -62,6 +62,6 @@ import { JwtService } from "@nestjs/jwt";
|
||||
OAuthClientUsersOutputService,
|
||||
],
|
||||
controllers: [OAuthClientUsersController, OAuthClientsController, OAuthFlowController],
|
||||
exports: [OAuthClientRepository, OAuthClientUsersOutputService],
|
||||
exports: [OAuthClientRepository, OAuthClientsOutputService, OAuthClientUsersOutputService],
|
||||
})
|
||||
export class OAuthClientModule {}
|
||||
|
||||
+13
-14
@@ -6,17 +6,8 @@ import { PlatformOAuthClientDto } from "@calcom/platform-types";
|
||||
|
||||
@Injectable()
|
||||
export class OAuthClientsOutputService {
|
||||
transformPermissions(permissionsNumber: number): Array<keyof typeof PERMISSION_MAP> {
|
||||
const permissionNumbers = PERMISSIONS.filter(
|
||||
(permission) => (permissionsNumber & permission) === permission
|
||||
);
|
||||
|
||||
return permissionNumbers.map(
|
||||
(permission) =>
|
||||
Object.entries(PERMISSION_MAP).find(
|
||||
([_, value]) => value === permission
|
||||
)?.[0] as keyof typeof PERMISSION_MAP
|
||||
);
|
||||
transformOAuthClients(clients: PlatformOAuthClient[]): PlatformOAuthClientDto[] {
|
||||
return clients.map((client) => this.transformOAuthClient(client));
|
||||
}
|
||||
|
||||
transformOAuthClient(client: PlatformOAuthClient): PlatformOAuthClientDto {
|
||||
@@ -24,7 +15,7 @@ export class OAuthClientsOutputService {
|
||||
id: client.id,
|
||||
name: client.name,
|
||||
secret: client.secret,
|
||||
permissions: this.transformPermissions(client.permissions),
|
||||
permissions: this.transformOAuthClientPermissions(client.permissions),
|
||||
logo: client.logo,
|
||||
redirectUris: client.redirectUris,
|
||||
organizationId: client.organizationId,
|
||||
@@ -37,7 +28,15 @@ export class OAuthClientsOutputService {
|
||||
};
|
||||
}
|
||||
|
||||
transformOAuthClients(clients: PlatformOAuthClient[]): PlatformOAuthClientDto[] {
|
||||
return clients.map((client) => this.transformOAuthClient(client));
|
||||
transformOAuthClientPermissions(permissions: number): Array<keyof typeof PERMISSION_MAP> {
|
||||
const permissionsNumbers = PERMISSIONS.filter((permission) => (permissions & permission) === permission);
|
||||
|
||||
return permissionsNumbers.map((permission) => this.transformOAuthClientPermission(permission));
|
||||
}
|
||||
|
||||
transformOAuthClientPermission(permission: (typeof PERMISSIONS)[number]): keyof typeof PERMISSION_MAP {
|
||||
return Object.entries(PERMISSION_MAP).find(
|
||||
([_, value]) => value === permission
|
||||
)?.[0] as keyof typeof PERMISSION_MAP;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { ThrottlerGuard } from "@nestjs/throttler";
|
||||
|
||||
@Injectable()
|
||||
export class ThrottlerBehindProxyGuard extends ThrottlerGuard {
|
||||
// TODO: adapt if required for CF / AWS / FlightControl proxying.
|
||||
protected async getTracker(req: Record<string, any>): Promise<string> {
|
||||
return req.ips.length ? req.ips[0] : req.ip;
|
||||
}
|
||||
}
|
||||
@@ -72,7 +72,7 @@ export class SlotsController_2024_09_04 {
|
||||
|
||||
And 3 ways to get available slots for team event type:
|
||||
|
||||
1. By team event type id. Example '/v2/slots?teamEventTypeId=10&start=2050-09-05&end=2050-09-06&timeZone=Europe/Rome'
|
||||
1. By team event type id. Example '/v2/slots?eventTypeId=10&start=2050-09-05&end=2050-09-06&timeZone=Europe/Rome'
|
||||
|
||||
2. By team event type slug + team slug. Example '/v2/slots?eventTypeSlug=intro&teamSlug=team-slug&start=2050-09-05&end=2050-09-06'
|
||||
|
||||
|
||||
@@ -32,21 +32,25 @@ export class IsOAuthClientWebhookGuard implements CanActivate {
|
||||
const organizationId = this.usersService.getUserMainOrgId(user);
|
||||
|
||||
if (!user) {
|
||||
throw new ForbiddenException("User not authenticated");
|
||||
throw new ForbiddenException("IsOAuthClientWebhookGuard - User not authenticated");
|
||||
}
|
||||
|
||||
if (!webhookId) {
|
||||
throw new BadRequestException("webhookId parameter not specified in the request");
|
||||
throw new BadRequestException(
|
||||
"IsOAuthClientWebhookGuard - webhookId parameter not specified in the request"
|
||||
);
|
||||
}
|
||||
|
||||
if (!oAuthClientId) {
|
||||
throw new BadRequestException("oAuthClientId parameter not specified in the request");
|
||||
throw new BadRequestException(
|
||||
"IsOAuthClientWebhookGuard - oAuthClientId parameter not specified in the request"
|
||||
);
|
||||
}
|
||||
|
||||
const oAuthClient = await this.oAuthClientRepository.getOAuthClient(oAuthClientId);
|
||||
|
||||
if (!oAuthClient) {
|
||||
throw new NotFoundException(`OAuthClient (${oAuthClientId}) not found`);
|
||||
throw new NotFoundException(`IsOAuthClientWebhookGuard - OAuthClient (${oAuthClientId}) not found`);
|
||||
}
|
||||
|
||||
const webhook = await this.webhooksService.getWebhookById(webhookId);
|
||||
@@ -56,7 +60,7 @@ export class IsOAuthClientWebhookGuard implements CanActivate {
|
||||
}
|
||||
|
||||
if (webhook.platformOAuthClientId !== oAuthClientId) {
|
||||
throw new ForbiddenException("Webhook does not belong to this oAuthClient");
|
||||
throw new ForbiddenException("IsOAuthClientWebhookGuard - Webhook does not belong to this oAuthClient");
|
||||
}
|
||||
|
||||
request.webhook = webhook;
|
||||
|
||||
@@ -28,16 +28,18 @@ export class IsUserEventTypeWebhookGuard implements CanActivate {
|
||||
const eventTypeId = request.params.eventTypeId;
|
||||
|
||||
if (!user) {
|
||||
return false;
|
||||
throw new ForbiddenException("IsUserEventTypeWebhookGuard - No user associated with the request.");
|
||||
}
|
||||
|
||||
if (eventTypeId) {
|
||||
const eventType = await this.eventtypesRepository.getEventTypeById(parseInt(eventTypeId));
|
||||
if (!eventType) {
|
||||
throw new NotFoundException(`Event type (${eventTypeId}) not found`);
|
||||
throw new NotFoundException(`IsUserEventTypeWebhookGuard - Event type (${eventTypeId}) not found`);
|
||||
}
|
||||
if (eventType.userId !== user.id) {
|
||||
throw new ForbiddenException(`User (${user.id}) is not the owner of event type (${eventTypeId})`);
|
||||
throw new ForbiddenException(
|
||||
`IsUserEventTypeWebhookGuard - User (${user.id}) is not the owner of event type (${eventTypeId})`
|
||||
);
|
||||
}
|
||||
request.eventType = eventType;
|
||||
}
|
||||
@@ -45,11 +47,13 @@ export class IsUserEventTypeWebhookGuard implements CanActivate {
|
||||
if (webhookId) {
|
||||
const webhook = await this.webhooksService.getWebhookById(webhookId);
|
||||
if (!webhook.eventTypeId) {
|
||||
throw new BadRequestException(`Webhook (${webhookId}) is not associated with an event type`);
|
||||
throw new BadRequestException(
|
||||
`IsUserEventTypeWebhookGuard - Webhook (${webhookId}) is not associated with an event type`
|
||||
);
|
||||
}
|
||||
if (webhook.eventTypeId !== parseInt(eventTypeId)) {
|
||||
throw new ForbiddenException(
|
||||
`Webhook (${webhookId}) is not associated with event type (${eventTypeId})`
|
||||
`IsUserEventTypeWebhookGuard - Webhook (${webhookId}) is not associated with event type (${eventTypeId})`
|
||||
);
|
||||
}
|
||||
request.webhook = webhook;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
||||
import { WebhooksService } from "@/modules/webhooks/services/webhooks.service";
|
||||
import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common";
|
||||
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from "@nestjs/common";
|
||||
import { Request } from "express";
|
||||
|
||||
import { Webhook } from "@calcom/prisma/client";
|
||||
@@ -14,18 +14,23 @@ export class IsUserWebhookGuard implements CanActivate {
|
||||
const user = request.user as ApiAuthGuardUser;
|
||||
const webhookId = request.params.webhookId;
|
||||
|
||||
if (!user || !webhookId) {
|
||||
return false;
|
||||
if (!user) {
|
||||
throw new ForbiddenException("IsUserWebhookGuard - No user associated with the request.");
|
||||
}
|
||||
|
||||
if (!webhookId) {
|
||||
throw new ForbiddenException("IsUserWebhookGuard - No webhook id found in request params.");
|
||||
}
|
||||
|
||||
const webhook = await this.webhooksService.getWebhookById(webhookId);
|
||||
|
||||
if (webhook.userId !== user.id) {
|
||||
return user.isSystemAdmin;
|
||||
if (webhook.userId !== user.id && !user.isSystemAdmin) {
|
||||
throw new ForbiddenException(
|
||||
`IsUserWebhookGuard - user with id=(${user.id}) is not the owner of webhook with id=(${webhookId})`
|
||||
);
|
||||
}
|
||||
|
||||
request.webhook = webhook;
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9942,7 +9942,7 @@
|
||||
"get": {
|
||||
"operationId": "SlotsController_2024_09_04_getAvailableSlots",
|
||||
"summary": "Find out when is an event type ready to be booked.",
|
||||
"description": "\n There are 4 ways to get available slots for event type of an individual user:\n \n 1. By event type id. Event type id can be of user and team event types. Example '/v2/slots?eventTypeId=10&start=2050-09-05&end=2050-09-06&timeZone=Europe/Rome'\n\n 2. By event type slug + username. Example '/v2/slots?eventTypeSlug=intro&username=bob&start=2050-09-05&end=2050-09-06'\n\n 3. By event type slug + username + organization slug when searching within an organization. Example '/v2/slots?organizationSlug=org-slug&eventTypeSlug=intro&username=bob&start=2050-09-05&end=2050-09-06'\n\n 4. By usernames only (used for dynamic event type - there is no specific event but you want to know when 2 or more people are available). Example '/v2/slots?usernames=alice,bob&username=bob&organizationSlug=org-slug&start=2050-09-05&end=2050-09-06'. As you see you also need to provide the slug of the organization to which each user in the 'usernames' array belongs.\n\n And 3 ways to get available slots for team event type:\n\n 1. By team event type id. Example '/v2/slots?teamEventTypeId=10&start=2050-09-05&end=2050-09-06&timeZone=Europe/Rome'\n\n 2. By team event type slug + team slug. Example '/v2/slots?eventTypeSlug=intro&teamSlug=team-slug&start=2050-09-05&end=2050-09-06'\n\n 3. By team event type slug + team slug + organization slug when searching within an organization. Example '/v2/slots?organizationSlug=org-slug&eventTypeSlug=intro&teamSlug=team-slug&start=2050-09-05&end=2050-09-06'\n\n All of them require \"start\" and \"end\" query parameters which define the time range for which available slots should be checked.\n Optional parameters are:\n - timeZone: Time zone in which the available slots should be returned. Defaults to UTC.\n - duration: Only use for event types that allow multiple durations or for dynamic event types. If not passed for multiple duration event types defaults to default duration. For dynamic event types defaults to 30 aka each returned slot is 30 minutes long. So duration=60 means that returned slots will be each 60 minutes long.\n - slotFormat: Format of the slots. By default return is an object where each key is date and value is array of slots as string. If you want to get start and end of each slot use \"range\" as value.\n ",
|
||||
"description": "\n There are 4 ways to get available slots for event type of an individual user:\n \n 1. By event type id. Event type id can be of user and team event types. Example '/v2/slots?eventTypeId=10&start=2050-09-05&end=2050-09-06&timeZone=Europe/Rome'\n\n 2. By event type slug + username. Example '/v2/slots?eventTypeSlug=intro&username=bob&start=2050-09-05&end=2050-09-06'\n\n 3. By event type slug + username + organization slug when searching within an organization. Example '/v2/slots?organizationSlug=org-slug&eventTypeSlug=intro&username=bob&start=2050-09-05&end=2050-09-06'\n\n 4. By usernames only (used for dynamic event type - there is no specific event but you want to know when 2 or more people are available). Example '/v2/slots?usernames=alice,bob&username=bob&organizationSlug=org-slug&start=2050-09-05&end=2050-09-06'. As you see you also need to provide the slug of the organization to which each user in the 'usernames' array belongs.\n\n And 3 ways to get available slots for team event type:\n\n 1. By team event type id. Example '/v2/slots?eventTypeId=10&start=2050-09-05&end=2050-09-06&timeZone=Europe/Rome'\n\n 2. By team event type slug + team slug. Example '/v2/slots?eventTypeSlug=intro&teamSlug=team-slug&start=2050-09-05&end=2050-09-06'\n\n 3. By team event type slug + team slug + organization slug when searching within an organization. Example '/v2/slots?organizationSlug=org-slug&eventTypeSlug=intro&teamSlug=team-slug&start=2050-09-05&end=2050-09-06'\n\n All of them require \"start\" and \"end\" query parameters which define the time range for which available slots should be checked.\n Optional parameters are:\n - timeZone: Time zone in which the available slots should be returned. Defaults to UTC.\n - duration: Only use for event types that allow multiple durations or for dynamic event types. If not passed for multiple duration event types defaults to default duration. For dynamic event types defaults to 30 aka each returned slot is 30 minutes long. So duration=60 means that returned slots will be each 60 minutes long.\n - slotFormat: Format of the slots. By default return is an object where each key is date and value is array of slots as string. If you want to get start and end of each slot use \"range\" as value.\n ",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "cal-api-version",
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
NEXT_PUBLIC_SWAGGER_DOCS_URL=http://localhost:3002/api/docs
|
||||
@@ -1,34 +0,0 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
@@ -1,34 +0,0 @@
|
||||
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `pages/index.js`. The page auto-updates as you edit the file.
|
||||
|
||||
[API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.js`.
|
||||
|
||||
The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages.
|
||||
|
||||
## 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/deployment) for more details.
|
||||
@@ -1,353 +0,0 @@
|
||||
import * as OpenAPISnippet from "openapi-snippet";
|
||||
|
||||
export const requestSnippets = {
|
||||
generators: {
|
||||
curl_bash: {
|
||||
title: "cURL (bash)",
|
||||
syntax: "bash",
|
||||
},
|
||||
curl_powershell: {
|
||||
title: "cURL (PowerShell)",
|
||||
syntax: "powershell",
|
||||
},
|
||||
curl_cmd: {
|
||||
title: "cURL (CMD)",
|
||||
syntax: "bash",
|
||||
},
|
||||
node: {
|
||||
title: "Node",
|
||||
syntax: "node",
|
||||
},
|
||||
},
|
||||
defaultExpanded: true,
|
||||
languages: ["node", "curl_bash"],
|
||||
};
|
||||
// Since swagger-ui-react was not configured to change the request snippets some workarounds required
|
||||
// configuration will be added programatically
|
||||
// Custom Plugin
|
||||
export const SnippedGenerator = {
|
||||
statePlugins: {
|
||||
// extend some internals to gain information about current path, method and spec in the generator function metioned later
|
||||
spec: {
|
||||
wrapSelectors: {
|
||||
requestFor: (ori) => (state, path, method) => {
|
||||
return ori(path, method)
|
||||
?.set("spec", state.get("json", {}))
|
||||
?.setIn(["oasPathMethod", "path"], path)
|
||||
?.setIn(["oasPathMethod", "method"], method);
|
||||
},
|
||||
mutatedRequestFor: (ori) => (state, path, method) => {
|
||||
return ori(path, method)
|
||||
?.set("spec", state.get("json", {}))
|
||||
?.setIn(["oasPathMethod", "path"], path)
|
||||
?.setIn(["oasPathMethod", "method"], method);
|
||||
},
|
||||
},
|
||||
},
|
||||
// extend the request snippets core plugin
|
||||
requestSnippets: {
|
||||
wrapSelectors: {
|
||||
// add additional snippet generators here
|
||||
getSnippetGenerators:
|
||||
(ori, system) =>
|
||||
(state, ...args) =>
|
||||
ori(state, ...args)
|
||||
// add node native snippet generator
|
||||
// .set(
|
||||
// // key
|
||||
// "node_native",
|
||||
// // config and generator function
|
||||
// system.Im.fromJS({
|
||||
// title: "NodeJs Native",
|
||||
// syntax: "javascript",
|
||||
// hostname: "test",
|
||||
// fn: (req) => {
|
||||
// // get extended info about request
|
||||
// const { spec, oasPathMethod } = req.toJS();
|
||||
// const { path, method } = oasPathMethod;
|
||||
|
||||
// // run OpenAPISnippet for target node
|
||||
// const targets = ["node_native"];
|
||||
// let snippet;
|
||||
// try {
|
||||
// // set request snippet content
|
||||
// snippet = OpenAPISnippet.getEndpointSnippets(
|
||||
// spec,
|
||||
// path,
|
||||
// method,
|
||||
// targets
|
||||
// // Since I don't know why hostname was undefinedundefined, I harcoded it here
|
||||
// ).snippets[0].content;
|
||||
// } catch (err) {
|
||||
// // set to error in case it happens the npm package has some flaws
|
||||
// snippet = JSON.stringify(snippet);
|
||||
// }
|
||||
// // return stringified snipped
|
||||
// return snippet;
|
||||
// },
|
||||
// })
|
||||
// )
|
||||
.set(
|
||||
// key
|
||||
"node_fetch",
|
||||
// config and generator function
|
||||
system.Im.fromJS({
|
||||
title: "NodeJS",
|
||||
syntax: "javascript",
|
||||
fn: (req) => {
|
||||
// get extended info about request
|
||||
const { spec, oasPathMethod } = req.toJS();
|
||||
const { path, method } = oasPathMethod;
|
||||
|
||||
// run OpenAPISnippet for target node
|
||||
const targets = ["node_fetch"];
|
||||
let snippet;
|
||||
try {
|
||||
// set request snippet content
|
||||
snippet = OpenAPISnippet.getEndpointSnippets(spec, path, method, targets).snippets[0]
|
||||
.content;
|
||||
} catch (err) {
|
||||
// set to error in case it happens the npm package has some flaws
|
||||
snippet = JSON.stringify(snippet);
|
||||
}
|
||||
// return stringified snipped
|
||||
return snippet;
|
||||
},
|
||||
})
|
||||
)
|
||||
.set(
|
||||
// key
|
||||
"shell_httpie",
|
||||
// config and generator function
|
||||
system.Im.fromJS({
|
||||
title: "HTTPie",
|
||||
syntax: "bash",
|
||||
fn: (req) => {
|
||||
// get extended info about request
|
||||
const { spec, oasPathMethod } = req.toJS();
|
||||
const { path, method } = oasPathMethod;
|
||||
|
||||
// run OpenAPISnippet for target node
|
||||
const targets = ["shell_httpie"];
|
||||
let snippet;
|
||||
try {
|
||||
// set request snippet content
|
||||
snippet = OpenAPISnippet.getEndpointSnippets(spec, path, method, targets).snippets[0]
|
||||
.content;
|
||||
} catch (err) {
|
||||
// set to error in case it happens the npm package has some flaws
|
||||
snippet = JSON.stringify(snippet);
|
||||
}
|
||||
// return stringified snipped
|
||||
return snippet;
|
||||
},
|
||||
})
|
||||
)
|
||||
.set(
|
||||
// key
|
||||
"php_curl",
|
||||
// config and generator function
|
||||
system.Im.fromJS({
|
||||
title: "PHP",
|
||||
syntax: "php",
|
||||
fn: (req) => {
|
||||
// get extended info about request
|
||||
const { spec, oasPathMethod } = req.toJS();
|
||||
const { path, method } = oasPathMethod;
|
||||
|
||||
// run OpenAPISnippet for target node
|
||||
const targets = ["php_curl"];
|
||||
let snippet;
|
||||
try {
|
||||
// set request snippet content
|
||||
snippet = OpenAPISnippet.getEndpointSnippets(spec, path, method, targets).snippets[0]
|
||||
.content;
|
||||
} catch (err) {
|
||||
// set to error in case it happens the npm package has some flaws
|
||||
snippet = JSON.stringify(snippet);
|
||||
}
|
||||
// return stringified snipped
|
||||
return snippet;
|
||||
},
|
||||
})
|
||||
)
|
||||
.set(
|
||||
// key
|
||||
"java_okhttp",
|
||||
// config and generator function
|
||||
system.Im.fromJS({
|
||||
title: "Java",
|
||||
syntax: "java",
|
||||
fn: (req) => {
|
||||
// get extended info about request
|
||||
const { spec, oasPathMethod } = req.toJS();
|
||||
const { path, method } = oasPathMethod;
|
||||
console.log(spec, oasPathMethod, path, method);
|
||||
// run OpenAPISnippet for target node
|
||||
const targets = ["java_okhttp"];
|
||||
let snippet;
|
||||
try {
|
||||
// set request snippet content
|
||||
snippet = OpenAPISnippet.getEndpointSnippets(spec, path, method, targets).snippets[0]
|
||||
.content;
|
||||
} catch (err) {
|
||||
// set to error in case it happens the npm package has some flaws
|
||||
snippet = JSON.stringify(snippet);
|
||||
}
|
||||
// return stringified snipped
|
||||
return snippet;
|
||||
},
|
||||
})
|
||||
)
|
||||
// .set(
|
||||
// // key
|
||||
// "java",
|
||||
// // config and generator function
|
||||
// system.Im.fromJS({
|
||||
// title: "Java (Unirest)",
|
||||
// syntax: "java",
|
||||
// fn: (req) => {
|
||||
// // get extended info about request
|
||||
// const { spec, oasPathMethod } = req.toJS();
|
||||
// const { path, method } = oasPathMethod;
|
||||
|
||||
// // run OpenAPISnippet for target node
|
||||
// const targets = ["java"];
|
||||
// let snippet;
|
||||
// try {
|
||||
// // set request snippet content
|
||||
// snippet = OpenAPISnippet.getEndpointSnippets(
|
||||
// spec,
|
||||
// path,
|
||||
// method,
|
||||
// targets
|
||||
// ).snippets[0].content;
|
||||
// } catch (err) {
|
||||
// // set to error in case it happens the npm package has some flaws
|
||||
// snippet = JSON.stringify(snippet);
|
||||
// }
|
||||
// // return stringified snipped
|
||||
// return snippet;
|
||||
// },
|
||||
// })
|
||||
// )
|
||||
// .set(
|
||||
// // key
|
||||
// "c_libcurl",
|
||||
// // config and generator function
|
||||
// system.Im.fromJS({
|
||||
// title: "C (libcurl) ",
|
||||
// syntax: "bash",
|
||||
// fn: (req) => {
|
||||
// // get extended info about request
|
||||
// const { spec, oasPathMethod } = req.toJS();
|
||||
// const { path, method } = oasPathMethod;
|
||||
|
||||
// // run OpenAPISnippet for target node
|
||||
// const targets = ["c_libcurl"];
|
||||
// let snippet;
|
||||
// try {
|
||||
// // set request snippet content
|
||||
// snippet = OpenAPISnippet.getEndpointSnippets(
|
||||
// spec,
|
||||
// path,
|
||||
// method,
|
||||
// targets
|
||||
// ).snippets[0].content;
|
||||
// } catch (err) {
|
||||
// // set to error in case it happens the npm package has some flaws
|
||||
// snippet = JSON.stringify(snippet);
|
||||
// }
|
||||
// // return stringified snipped
|
||||
// return snippet;
|
||||
// },
|
||||
// })
|
||||
// )
|
||||
.set(
|
||||
// key
|
||||
"go_native",
|
||||
// config and generator function
|
||||
system.Im.fromJS({
|
||||
title: "Go",
|
||||
syntax: "bash",
|
||||
fn: (req) => {
|
||||
// get extended info about request
|
||||
const { spec, oasPathMethod } = req.toJS();
|
||||
const { path, method } = oasPathMethod;
|
||||
|
||||
// run OpenAPISnippet for target node
|
||||
const targets = ["go_native"];
|
||||
let snippet;
|
||||
try {
|
||||
// set request snippet content
|
||||
snippet = OpenAPISnippet.getEndpointSnippets(spec, path, method, targets).snippets[0]
|
||||
.content;
|
||||
} catch (err) {
|
||||
// set to error in case it happens the npm package has some flaws
|
||||
snippet = JSON.stringify(snippet);
|
||||
}
|
||||
// return stringified snipped
|
||||
return snippet;
|
||||
},
|
||||
})
|
||||
)
|
||||
.set(
|
||||
// key
|
||||
"ruby",
|
||||
// config and generator function
|
||||
system.Im.fromJS({
|
||||
title: "Ruby",
|
||||
syntax: "ruby",
|
||||
fn: (req) => {
|
||||
// get extended info about request
|
||||
const { spec, oasPathMethod } = req.toJS();
|
||||
const { path, method } = oasPathMethod;
|
||||
|
||||
// run OpenAPISnippet for target node
|
||||
const targets = ["ruby"];
|
||||
let snippet;
|
||||
try {
|
||||
// set request snippet content
|
||||
snippet = OpenAPISnippet.getEndpointSnippets(spec, path, method, targets).snippets[0]
|
||||
.content;
|
||||
} catch (err) {
|
||||
// set to error in case it happens the npm package has some flaws
|
||||
snippet = JSON.stringify(snippet);
|
||||
}
|
||||
// return stringified snipped
|
||||
return snippet;
|
||||
},
|
||||
})
|
||||
)
|
||||
.set(
|
||||
// key
|
||||
"python",
|
||||
// config and generator function
|
||||
system.Im.fromJS({
|
||||
title: "Python",
|
||||
syntax: "python",
|
||||
fn: (req) => {
|
||||
// get extended info about request
|
||||
const { spec, oasPathMethod } = req.toJS();
|
||||
const { path, method } = oasPathMethod;
|
||||
|
||||
// run OpenAPISnippet for target node
|
||||
const targets = ["python"];
|
||||
let snippet;
|
||||
try {
|
||||
// set request snippet content
|
||||
snippet = OpenAPISnippet.getEndpointSnippets(spec, path, method, targets).snippets[0]
|
||||
.content;
|
||||
} catch (err) {
|
||||
// set to error in case it happens the npm package has some flaws
|
||||
snippet = JSON.stringify(snippet);
|
||||
}
|
||||
// return stringified snipped
|
||||
return snippet;
|
||||
},
|
||||
})
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
Vendored
-5
@@ -1,5 +0,0 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"name": "@calcom/swagger",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "next build",
|
||||
"clean": "rm -rf .turbo && rm -rf node_modules && rm -rf .next",
|
||||
"dev": "PORT=4200 next dev",
|
||||
"lint": "eslint . --ignore-path .gitignore",
|
||||
"lint:fix": "eslint . --ext .ts,.js,.tsx,.jsx --fix",
|
||||
"lint:report": "eslint . --format json --output-file ../../lint-results/swagger.json",
|
||||
"start": "PORT=4200 next start"
|
||||
},
|
||||
"dependencies": {
|
||||
"highlight.js": "^11.6.0",
|
||||
"isarray": "2.0.5",
|
||||
"next": "^13.5.6",
|
||||
"openapi-snippet": "^0.13.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"swagger-ui-react": "4.11.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "18.0.26",
|
||||
"@types/react-dom": "^18.0.9",
|
||||
"@types/swagger-ui-react": "^4.18.3",
|
||||
"typescript": "^4.9.4"
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import "highlight.js/styles/default.css";
|
||||
import { type AppProps } from "next/app";
|
||||
import "swagger-ui-react/swagger-ui.css";
|
||||
|
||||
import "../styles/globals.css";
|
||||
|
||||
function MyApp({ Component, pageProps }: AppProps) {
|
||||
return <Component {...pageProps} />;
|
||||
}
|
||||
|
||||
export default MyApp;
|
||||
@@ -1,23 +0,0 @@
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
import { SnippedGenerator, requestSnippets } from "@lib/snippets";
|
||||
|
||||
const SwaggerUIDynamic = dynamic(() => import("swagger-ui-react"), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
export default function APIDocs() {
|
||||
return (
|
||||
<SwaggerUIDynamic
|
||||
url={process.env.NEXT_PUBLIC_SWAGGER_DOCS_URL || "https://api.cal.com/docs"}
|
||||
persistAuthorization={true}
|
||||
supportedSubmitMethods={["get", "post", "delete", "put", "options", "patch"]}
|
||||
requestSnippetsEnabled={true}
|
||||
requestSnippets={requestSnippets}
|
||||
plugins={[SnippedGenerator]}
|
||||
tryItOutEnabled={true}
|
||||
docExpansion="list"
|
||||
filter={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
@@ -1,103 +0,0 @@
|
||||
html,
|
||||
body {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell, Fira Sans,
|
||||
Droid Sans, Helvetica Neue, sans-serif;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.swagger-ui .opblock-tag {
|
||||
font-size: 90% !important;
|
||||
}
|
||||
.swagger-ui .opblock .opblock-summary {
|
||||
display: grid;
|
||||
flex-direction: column;
|
||||
}
|
||||
.opblock-summary-path {
|
||||
flex-shrink: 0;
|
||||
max-width: 100% !important;
|
||||
padding: 10px 5px !important;
|
||||
}
|
||||
.opblock-summary-description {
|
||||
font-size: 16px !important;
|
||||
padding: 0px 5px;
|
||||
}
|
||||
.swagger-ui .scheme-container .schemes {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.swagger-ui .info .title {
|
||||
color: #3b4151;
|
||||
font-family: sans-serif;
|
||||
font-size: 22px;
|
||||
}
|
||||
.swagger-ui .scheme-container {
|
||||
padding: 14px 0;
|
||||
}
|
||||
.swagger-ui .info {
|
||||
margin: 10px 0;
|
||||
}
|
||||
.swagger-ui .auth-wrapper {
|
||||
margin: 10px 0;
|
||||
}
|
||||
.swagger-ui .authorization__btn {
|
||||
display: none;
|
||||
}
|
||||
.swagger-ui .opblock {
|
||||
margin: 0 0 5px;
|
||||
}
|
||||
button.opblock-summary-control > svg {
|
||||
display: none;
|
||||
}
|
||||
.swagger-ui .filter .operation-filter-input {
|
||||
border: 2px solid #d8dde7;
|
||||
margin: 5px 5px;
|
||||
padding: 5px;
|
||||
width: 100vw;
|
||||
}
|
||||
.swagger-ui .wrapper {
|
||||
padding: 0 4px;
|
||||
width: 100%;
|
||||
}
|
||||
.swagger-ui .info .title small {
|
||||
top: 5px;
|
||||
}
|
||||
.swagger-ui a.nostyle,
|
||||
.swagger-ui a.nostyle:visited {
|
||||
width: 100%;
|
||||
}
|
||||
div.request-snippets > div.curl-command > div:nth-child(1) {
|
||||
overscroll-behavior: contain;
|
||||
overflow-x: scroll;
|
||||
}
|
||||
.swagger-ui .opblock-body pre.microlight {
|
||||
font-size: 9px;
|
||||
}
|
||||
.swagger-ui table tbody tr td {
|
||||
padding: 0px 0 0;
|
||||
vertical-align: none;
|
||||
}
|
||||
td.response-col_description > div > div > p {
|
||||
font-size: 12px;
|
||||
}
|
||||
div.no-margin > div > div.responses-wrapper > div.responses-inner > div > div > table > tbody > tr {
|
||||
display: flex;
|
||||
width: 100vw;
|
||||
flex-direction: column;
|
||||
font-size: 60%;
|
||||
}
|
||||
div.no-margin > div > div.responses-wrapper > div.responses-inner > div > div > table > thead > tr {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"extends": "@calcom/tsconfig/nextjs.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@lib/*": ["lib/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"../../packages/types/*.d.ts",
|
||||
"../../packages/types/next-auth.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx"
|
||||
],
|
||||
"exclude": ["**/node_modules/**"]
|
||||
}
|
||||
@@ -20,6 +20,7 @@ const getData = withAppDirSsr<OrgPageProps>(getServerSideProps);
|
||||
export const generateMetadata = async ({ params, searchParams }: PageProps) => {
|
||||
const legacyCtx = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
|
||||
const props = await getData(legacyCtx);
|
||||
const decodedParams = decodeParams(await params);
|
||||
|
||||
if ((props as TeamPageProps)?.team) {
|
||||
const { team, markdownStrippedBio, isSEOIndexable, currentOrgDomain } = props as TeamPageProps;
|
||||
@@ -30,7 +31,6 @@ export const generateMetadata = async ({ params, searchParams }: PageProps) => {
|
||||
image: getOrgOrTeamAvatar(team),
|
||||
},
|
||||
};
|
||||
const decodedParams = decodeParams(await params);
|
||||
return {
|
||||
...(await generateMeetingMetadata(
|
||||
meeting,
|
||||
@@ -67,7 +67,8 @@ export const generateMetadata = async ({ params, searchParams }: PageProps) => {
|
||||
() => profile.name,
|
||||
() => markdownStrippedBio,
|
||||
false,
|
||||
getOrgFullOrigin(entity.orgSlug ?? null)
|
||||
getOrgFullOrigin(entity.orgSlug ?? null),
|
||||
`/${decodedParams.user}`
|
||||
)),
|
||||
robots: {
|
||||
index: allowSEOIndexing,
|
||||
|
||||
@@ -12,7 +12,10 @@ import { ShellMainAppDir } from "../ShellMainAppDir";
|
||||
export const generateMetadata = async () => {
|
||||
return await _generateMetadata(
|
||||
(t) => t("availability"),
|
||||
(t) => t("configure_availability")
|
||||
(t) => t("configure_availability"),
|
||||
undefined,
|
||||
undefined,
|
||||
"/availability"
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -11,10 +11,13 @@ const querySchema = z.object({
|
||||
status: z.enum(validStatuses),
|
||||
});
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
export const generateMetadata = async ({ params }: { params: Promise<{ status: string }> }) =>
|
||||
await _generateMetadata(
|
||||
(t) => t("bookings"),
|
||||
(t) => t("bookings_description")
|
||||
(t) => t("bookings_description"),
|
||||
undefined,
|
||||
undefined,
|
||||
`/bookings/${(await params).status}`
|
||||
);
|
||||
|
||||
const Page = async ({ params }: PageProps) => {
|
||||
|
||||
@@ -16,7 +16,10 @@ import EventTypes, { EventTypesCTA } from "~/event-types/views/event-types-listi
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
(t) => t("event_types_page_title"),
|
||||
(t) => t("event_types_page_subtitle")
|
||||
(t) => t("event_types_page_subtitle"),
|
||||
undefined,
|
||||
undefined,
|
||||
"/event-types"
|
||||
);
|
||||
|
||||
const Page = async ({ params, searchParams }: PageProps) => {
|
||||
|
||||
+2
-11
@@ -1,15 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { TeamsListing } from "@calcom/features/ee/teams/components";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
|
||||
export const TeamsCTA = () => {
|
||||
const { t } = useLocale();
|
||||
const [user] = trpc.viewer.me.get.useSuspenseQuery();
|
||||
return !user.organizationId || user.organization.isOrgAdmin ? (
|
||||
return (
|
||||
<Button
|
||||
data-testid="new-team-btn"
|
||||
variant="fab"
|
||||
@@ -18,11 +15,5 @@ export const TeamsCTA = () => {
|
||||
href={`${WEBAPP_URL}/settings/teams/new?returnTo=${WEBAPP_URL}/teams`}>
|
||||
{t("new")}
|
||||
</Button>
|
||||
) : null;
|
||||
);
|
||||
};
|
||||
|
||||
function Teams() {
|
||||
return <TeamsListing />;
|
||||
}
|
||||
|
||||
export default Teams;
|
||||
@@ -0,0 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
export async function revalidateTeamsList() {
|
||||
revalidatePath("/teams");
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { TeamsListSkeleton } from "./skeleton";
|
||||
|
||||
export default function Loading() {
|
||||
return <TeamsListSkeleton />;
|
||||
}
|
||||
@@ -8,12 +8,15 @@ import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
import TeamsView, { TeamsCTA } from "~/teams/teams-view";
|
||||
import { ServerTeamsListing } from "./server-page";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
(t) => t("teams"),
|
||||
(t) => t("create_manage_teams_collaborative")
|
||||
(t) => t("create_manage_teams_collaborative"),
|
||||
undefined,
|
||||
undefined,
|
||||
"/teams"
|
||||
);
|
||||
|
||||
const ServerPage = async ({ searchParams: _searchParams }: ServerPageProps) => {
|
||||
@@ -27,13 +30,11 @@ const ServerPage = async ({ searchParams: _searchParams }: ServerPageProps) => {
|
||||
}
|
||||
|
||||
const t = await getTranslate();
|
||||
const { Main, CTA } = await ServerTeamsListing({ searchParams });
|
||||
|
||||
return (
|
||||
<ShellMainAppDir
|
||||
CTA={<TeamsCTA />}
|
||||
heading={t("teams")}
|
||||
subtitle={t("create_manage_teams_collaborative")}>
|
||||
<TeamsView />
|
||||
<ShellMainAppDir CTA={CTA} heading={t("teams")} subtitle={t("create_manage_teams_collaborative")}>
|
||||
{Main}
|
||||
</ShellMainAppDir>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { createRouterCaller } from "app/_trpc/context";
|
||||
import type { SearchParams } from "app/_types";
|
||||
|
||||
import { TeamsListing } from "@calcom/features/ee/teams/components/TeamsListing";
|
||||
import { CreationSource } from "@calcom/prisma/enums";
|
||||
import { meRouter } from "@calcom/trpc/server/routers/viewer/me/_router";
|
||||
import { viewerTeamsRouter } from "@calcom/trpc/server/routers/viewer/teams/_router";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { TeamsCTA } from "./CTA";
|
||||
|
||||
export const ServerTeamsListing = async ({ searchParams }: { searchParams: SearchParams }) => {
|
||||
const token = Array.isArray(searchParams?.token) ? searchParams.token[0] : searchParams?.token;
|
||||
|
||||
const [teamsCaller, meCaller] = await Promise.all([
|
||||
createRouterCaller(viewerTeamsRouter),
|
||||
createRouterCaller(meRouter),
|
||||
]);
|
||||
|
||||
let teamNameFromInvite,
|
||||
errorMsgFromInvite = null;
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
teamNameFromInvite = await teamsCaller.inviteMemberByToken({
|
||||
token,
|
||||
creationSource: CreationSource.WEBAPP,
|
||||
});
|
||||
} catch (e) {
|
||||
errorMsgFromInvite = "Error while fetching teams";
|
||||
if (e instanceof TRPCError) errorMsgFromInvite = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
const [user, teams] = await Promise.all([
|
||||
meCaller.get(),
|
||||
teamsCaller.list({
|
||||
includeOrgs: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
Main: (
|
||||
<TeamsListing
|
||||
teams={teams}
|
||||
user={user}
|
||||
teamNameFromInvite={teamNameFromInvite ?? null}
|
||||
errorMsgFromInvite={errorMsgFromInvite}
|
||||
/>
|
||||
),
|
||||
CTA: !user.organizationId || user.organization.isOrgAdmin ? <TeamsCTA /> : null,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { ShellMainAppDir } from "app/(use-page-wrapper)/(main-nav)/ShellMainAppDir";
|
||||
import { TeamsCTA } from "app/(use-page-wrapper)/(main-nav)/teams/CTA";
|
||||
|
||||
import SkeletonLoaderTeamList from "@calcom/features/ee/teams/components/SkeletonloaderTeamList";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
export const TeamsListSkeleton = () => {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<ShellMainAppDir
|
||||
heading={t("teams")}
|
||||
subtitle={t("create_manage_teams_collaborative")}
|
||||
CTA={<TeamsCTA />}>
|
||||
<SkeletonLoaderTeamList />
|
||||
</ShellMainAppDir>
|
||||
);
|
||||
};
|
||||
@@ -6,7 +6,10 @@ import { Button } from "@calcom/ui/components/button";
|
||||
export const generateMetadata = () =>
|
||||
_generateMetadata(
|
||||
(t) => `${t("access_denied")} | ${APP_NAME}`,
|
||||
() => ""
|
||||
() => "",
|
||||
undefined,
|
||||
undefined,
|
||||
"/403"
|
||||
);
|
||||
|
||||
async function Error403() {
|
||||
|
||||
@@ -8,7 +8,10 @@ import CopyButton from "./copy-button";
|
||||
export const generateMetadata = () =>
|
||||
_generateMetadata(
|
||||
(t) => `${t("something_unexpected_occurred")} | ${APP_NAME}`,
|
||||
() => ""
|
||||
() => "",
|
||||
undefined,
|
||||
undefined,
|
||||
"/500"
|
||||
);
|
||||
|
||||
async function Error500({ searchParams }: { searchParams: { error?: string } }) {
|
||||
|
||||
@@ -28,7 +28,10 @@ export const generateMetadata = async ({ params }: _PageProps) => {
|
||||
return await generateAppMetadata(
|
||||
{ slug: logo, name, description },
|
||||
() => name,
|
||||
() => description
|
||||
() => description,
|
||||
undefined,
|
||||
undefined,
|
||||
`/apps/${p.data.slug}`
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -13,7 +13,10 @@ export const generateMetadata = async ({ params: _params }: ServerPageProps) =>
|
||||
const params = await _params;
|
||||
const metadata = await _generateMetadata(
|
||||
() => `${params.slug}`,
|
||||
() => ""
|
||||
() => "",
|
||||
undefined,
|
||||
undefined,
|
||||
`/apps/${params.slug}/setup`
|
||||
);
|
||||
return {
|
||||
...metadata,
|
||||
|
||||
@@ -3,7 +3,10 @@ import { _generateMetadata } from "app/_utils";
|
||||
export const generateMetadata = async () => {
|
||||
return await _generateMetadata(
|
||||
(t) => t("app_store"),
|
||||
(t) => t("app_store_description")
|
||||
(t) => t("app_store_description"),
|
||||
undefined,
|
||||
undefined,
|
||||
"/apps/categories"
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -13,9 +13,14 @@ export const generateMetadata = async ({ params, searchParams }: PageProps) => {
|
||||
const legacyCtx = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
|
||||
|
||||
const { appMetadata } = await getData(legacyCtx);
|
||||
const stepParam = (await params).step;
|
||||
const step = stepParam && Array.isArray(stepParam) ? stepParam.join("/") : "";
|
||||
return await _generateMetadata(
|
||||
(t) => `${t("install")} ${appMetadata?.name ?? ""}`,
|
||||
() => ""
|
||||
() => "",
|
||||
undefined,
|
||||
undefined,
|
||||
`/apps/installation${step ? `/${step}` : ""}`
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -11,10 +11,13 @@ const querySchema = z.object({
|
||||
category: z.nativeEnum(AppCategories),
|
||||
});
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
export const generateMetadata = async ({ params }: { params: Promise<{ category: string }> }) => {
|
||||
return await _generateMetadata(
|
||||
(t) => t("installed_apps"),
|
||||
(t) => t("manage_your_connected_apps")
|
||||
(t) => t("manage_your_connected_apps"),
|
||||
undefined,
|
||||
undefined,
|
||||
`/apps/installed/${(await params).category}`
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -11,7 +11,10 @@ import AppsPage from "~/apps/apps-view";
|
||||
export const generateMetadata = async () => {
|
||||
return await _generateMetadata(
|
||||
(t) => t("app_store"),
|
||||
(t) => t("app_store_description")
|
||||
(t) => t("app_store_description"),
|
||||
undefined,
|
||||
undefined,
|
||||
"/apps"
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -25,7 +25,10 @@ export const generateMetadata = async ({ params }: { params: Promise<{ pages: st
|
||||
return await _generateMetadata(
|
||||
// TODO: Need to show the actual form name instead of "Form"
|
||||
(t) => (mainPage === "routing-link" ? `Form | Cal.com Forms` : `${t("routing_forms")} | Cal.com Forms`),
|
||||
() => ""
|
||||
() => "",
|
||||
undefined,
|
||||
undefined,
|
||||
`/apps/routing-forms/${(await params).pages.join("/")}`
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { _generateMetadata, getTranslate } from "app/_utils";
|
||||
import Link from "next/link";
|
||||
import { z } from "zod";
|
||||
|
||||
import { IdentityProvider } from "@calcom/prisma/client";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
|
||||
@@ -11,18 +12,47 @@ import AuthContainer from "@components/ui/AuthContainer";
|
||||
export const generateMetadata = async () => {
|
||||
return await _generateMetadata(
|
||||
(t) => t("error"),
|
||||
() => ""
|
||||
() => "",
|
||||
undefined,
|
||||
undefined,
|
||||
"/auth/error"
|
||||
);
|
||||
};
|
||||
|
||||
const querySchema = z.object({
|
||||
error: z.string().optional(),
|
||||
provider: z.string().optional(),
|
||||
});
|
||||
|
||||
const ServerPage = async ({ searchParams }: PageProps) => {
|
||||
const t = await getTranslate();
|
||||
const { error } = querySchema.parse({ error: (await searchParams)?.error || undefined });
|
||||
const errorMsg = t("error_during_login") + (error ? ` Error code: ${error}` : "");
|
||||
|
||||
const { error, provider } = querySchema.parse({
|
||||
error: (await searchParams)?.error || undefined,
|
||||
provider: (await searchParams)?.provider || undefined,
|
||||
});
|
||||
|
||||
// generate error message based on different errors
|
||||
const getErrorMessage = (error: string | undefined, provider: string | undefined) => {
|
||||
if (error === "user-creation-error") {
|
||||
return t("user_creation_error");
|
||||
} else if (error === "wrong-provider") {
|
||||
// showing user their original identity provider by which account is managed.
|
||||
const providerName =
|
||||
provider === IdentityProvider.GOOGLE
|
||||
? "Google"
|
||||
: provider === IdentityProvider.CAL
|
||||
? "Email and Password"
|
||||
: provider === IdentityProvider.SAML
|
||||
? "SAML (like Okta)"
|
||||
: "your original login method";
|
||||
return t("account_managed_by_identity_provider_error", { provider: providerName });
|
||||
}
|
||||
return t("error_during_login") + (error ? ` Error code: ${error}` : "");
|
||||
};
|
||||
|
||||
const errorMsg = getErrorMessage(error, provider);
|
||||
|
||||
return (
|
||||
<AuthContainer>
|
||||
<div>
|
||||
|
||||
@@ -10,10 +10,13 @@ import { getServerSideProps } from "@server/lib/auth/forgot-password/[id]/getSer
|
||||
import type { PageProps as ClientPageProps } from "~/auth/forgot-password/[id]/forgot-password-single-view";
|
||||
import SetNewUserPassword from "~/auth/forgot-password/[id]/forgot-password-single-view";
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
export const generateMetadata = async ({ params }: { params: Promise<{ id: string }> }) => {
|
||||
return await _generateMetadata(
|
||||
(t) => t("reset_password"),
|
||||
(t) => t("change_your_password")
|
||||
(t) => t("change_your_password"),
|
||||
undefined,
|
||||
undefined,
|
||||
`/auth/forgot-password/${(await params).id}`
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -13,7 +13,10 @@ import ForgotPassword from "~/auth/forgot-password/forgot-password-view";
|
||||
export const generateMetadata = async () => {
|
||||
return await _generateMetadata(
|
||||
(t) => t("forgot_password"),
|
||||
(t) => t("request_password_reset")
|
||||
(t) => t("request_password_reset"),
|
||||
undefined,
|
||||
undefined,
|
||||
"/auth/forgot-password"
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -13,7 +13,10 @@ import Login from "~/auth/login-view";
|
||||
export const generateMetadata = async () => {
|
||||
return await _generateMetadata(
|
||||
(t) => t("login"),
|
||||
(t) => t("login")
|
||||
(t) => t("login"),
|
||||
undefined,
|
||||
undefined,
|
||||
"/auth/login"
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -9,7 +9,10 @@ import Logout from "~/auth/logout-view";
|
||||
export const generateMetadata = async () => {
|
||||
return await _generateMetadata(
|
||||
(t) => t("logged_out"),
|
||||
(t) => t("youve_been_logged_out")
|
||||
(t) => t("youve_been_logged_out"),
|
||||
undefined,
|
||||
undefined,
|
||||
"/auth/logout"
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -5,7 +5,10 @@ import Page from "~/auth/oauth2/authorize-view";
|
||||
export const generateMetadata = async () => {
|
||||
return await _generateMetadata(
|
||||
(t) => t("authorize"),
|
||||
() => ""
|
||||
() => "",
|
||||
undefined,
|
||||
undefined,
|
||||
"/auth/oauth2/authorize"
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -5,7 +5,10 @@ import Page from "~/auth/platform/authorize-view";
|
||||
export const generateMetadata = async () => {
|
||||
return await _generateMetadata(
|
||||
(t) => t("authorize"),
|
||||
() => ""
|
||||
() => "",
|
||||
undefined,
|
||||
undefined,
|
||||
"/auth/platform/authorize"
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -13,7 +13,10 @@ import type { PageProps as ClientPageProps } from "~/auth/setup-view";
|
||||
export const generateMetadata = async () => {
|
||||
return await _generateMetadata(
|
||||
(t) => t("setup"),
|
||||
(t) => t("setup_description")
|
||||
(t) => t("setup_description"),
|
||||
undefined,
|
||||
undefined,
|
||||
"/auth/setup"
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -13,7 +13,10 @@ import VerifyEmailChange from "~/auth/verify-email-change-view";
|
||||
export const generateMetadata = async () => {
|
||||
return await _generateMetadata(
|
||||
(t) => t("verify_email_change"),
|
||||
() => ""
|
||||
() => "",
|
||||
undefined,
|
||||
undefined,
|
||||
"/auth/verify-email-change"
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -5,7 +5,10 @@ import VerifyEmailPage from "~/auth/verify-email-view";
|
||||
export const generateMetadata = async () => {
|
||||
return await _generateMetadata(
|
||||
(t) => t("verify_email_button"),
|
||||
() => ""
|
||||
() => "",
|
||||
undefined,
|
||||
undefined,
|
||||
"/auth/verify-email"
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -26,7 +26,10 @@ export const generateMetadata = async ({ params, searchParams }: _PageProps) =>
|
||||
return await _generateMetadata(
|
||||
() =>
|
||||
hasPaymentFailed ? "Your payment failed" : sessionId ? "Payment successful!" : `Verify your email`,
|
||||
() => ""
|
||||
() => "",
|
||||
undefined,
|
||||
undefined,
|
||||
"/auth/verify"
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
export async function revalidateSchedulePage(scheduleId: number) {
|
||||
revalidatePath(`/availability/${scheduleId}`);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { AvailabilityScheduleSkeleton } from "./skeleton";
|
||||
|
||||
export default function Loading() {
|
||||
return <AvailabilityScheduleSkeleton />;
|
||||
}
|
||||
@@ -1,16 +1,12 @@
|
||||
import { createRouterCaller } from "app/_trpc/context";
|
||||
import type { PageProps } from "app/_types";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { notFound } from "next/navigation";
|
||||
import { cache } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
// import { cookies, headers } from "next/headers";
|
||||
// import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
// import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
import { ScheduleRepository } from "@calcom/lib/server/repository/schedule";
|
||||
import { availabilityRouter } from "@calcom/trpc/server/routers/viewer/availability/_router";
|
||||
import { travelSchedulesRouter } from "@calcom/trpc/server/routers/viewer/travelSchedules/_router";
|
||||
|
||||
// import { TravelScheduleRepository } from "@calcom/lib/server/repository/travelSchedule";
|
||||
// import { UserRepository } from "@calcom/lib/server/repository/user";
|
||||
import { AvailabilitySettingsWebWrapper } from "~/availability/[schedule]/schedule-view";
|
||||
|
||||
const querySchema = z.object({
|
||||
@@ -22,23 +18,13 @@ const querySchema = z.object({
|
||||
.transform((val) => Number(val)),
|
||||
});
|
||||
|
||||
const getSchedule = cache((id: number) => ScheduleRepository.findScheduleById({ id }));
|
||||
|
||||
export const generateMetadata = async ({ params }: PageProps) => {
|
||||
const parsed = querySchema.safeParse(await params);
|
||||
if (!parsed.success) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const schedule = await getSchedule(parsed.data.schedule);
|
||||
|
||||
if (!schedule) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
return await _generateMetadata(
|
||||
(t) => (schedule.name ? `${schedule.name} | ${t("availability")}` : t("availability")),
|
||||
() => ""
|
||||
(t) => t("availability"),
|
||||
() => "",
|
||||
undefined,
|
||||
undefined,
|
||||
"/availability"
|
||||
);
|
||||
};
|
||||
|
||||
@@ -47,46 +33,24 @@ const Page = async ({ params }: PageProps) => {
|
||||
if (!parsed.success) {
|
||||
notFound();
|
||||
}
|
||||
// const scheduleId = Number(await params.schedule);
|
||||
const scheduleId = parsed.data.schedule;
|
||||
|
||||
// const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
// const userId = session?.user?.id;
|
||||
// if (!userId) {
|
||||
// notFound();
|
||||
// }
|
||||
const [availabilityCaller, travelSchedulesCaller] = await Promise.all([
|
||||
createRouterCaller(availabilityRouter),
|
||||
createRouterCaller(travelSchedulesRouter),
|
||||
]);
|
||||
|
||||
// let userData, schedule, travelSchedules;
|
||||
const [scheduleData, travelSchedulesData] = await Promise.all([
|
||||
availabilityCaller.schedule.get({ scheduleId }),
|
||||
travelSchedulesCaller.get(),
|
||||
]);
|
||||
|
||||
// try {
|
||||
// userData = await UserRepository.getTimeZoneAndDefaultScheduleId({
|
||||
// userId,
|
||||
// });
|
||||
// if (!userData?.timeZone || !userData?.defaultScheduleId) {
|
||||
// throw new Error("timeZone and defaultScheduleId not found");
|
||||
// }
|
||||
// } catch (e) {
|
||||
// notFound();
|
||||
// }
|
||||
|
||||
// try {
|
||||
// schedule = await ScheduleRepository.findDetailedScheduleById({
|
||||
// scheduleId,
|
||||
// isManagedEventType: false,
|
||||
// userId,
|
||||
// timeZone: userData.timeZone,
|
||||
// defaultScheduleId: userData.defaultScheduleId,
|
||||
// });
|
||||
// } catch (e) {}
|
||||
|
||||
// try {
|
||||
// travelSchedules = await TravelScheduleRepository.findTravelSchedulesByUserId(userId);
|
||||
// } catch (e) {}
|
||||
if (!scheduleData) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<AvailabilitySettingsWebWrapper
|
||||
|
||||
// scheduleFetched={schedule} travelSchedules={travelSchedules}
|
||||
/>
|
||||
<AvailabilitySettingsWebWrapper scheduleData={scheduleData} travelSchedulesData={travelSchedulesData} />
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { SkeletonText, SkeletonContainer, SkeletonButton } from "@calcom/ui/components/skeleton";
|
||||
|
||||
export function AvailabilityScheduleSkeleton() {
|
||||
return (
|
||||
<Shell withoutMain={true}>
|
||||
<SkeletonContainer>
|
||||
<div className="max-w-full">
|
||||
{/* Header section */}
|
||||
<div className="bg-default sticky top-0 z-10 mb-0 flex items-center py-2 md:mb-6 md:mt-0 lg:mb-9">
|
||||
{/* Back button */}
|
||||
<div className="flex items-center">
|
||||
<SkeletonText className="h-6 w-6 rounded-md ltr:mr-2 rtl:ml-2" /> {/* Back button */}
|
||||
</div>
|
||||
|
||||
{/* Title section */}
|
||||
<header className="flex w-full max-w-full items-center truncate">
|
||||
<div className="w-full truncate ltr:mr-4 rtl:ml-4 md:block">
|
||||
<SkeletonText className="font-cal max-w-28 sm:max-w-72 md:max-w-80 hidden h-6 truncate text-xl font-semibold tracking-wide md:block" />
|
||||
{/* Title */}
|
||||
<SkeletonText className="mt-1 hidden h-4 w-64 text-sm md:block" /> {/* Subtitle */}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="relative flex-shrink-0 md:relative md:bottom-auto md:right-auto">
|
||||
{/* Desktop buttons - Set to Default, Delete, and Save */}
|
||||
<div className="flex items-center justify-end">
|
||||
{/* Set to Default - desktop only */}
|
||||
<div className="hidden items-center rounded-md sm:flex">
|
||||
<SkeletonText className="h-4 w-28" /> {/* "Set to Default" text */}
|
||||
<div className="ml-2 h-5 w-11 rounded-full bg-gray-200" /> {/* Toggle */}
|
||||
<div className="mx-3 h-4 w-px bg-gray-200" /> {/* Divider */}
|
||||
</div>
|
||||
{/* Delete button */}
|
||||
<SkeletonButton className="mr-2 hidden h-9 w-9 rounded-md sm:inline-flex" />
|
||||
{/* Second divider */}
|
||||
<div className="mx-3 hidden h-4 w-px bg-gray-200 sm:inline" /> {/* Divider */}
|
||||
{/* Save button - updated to match screenshot */}
|
||||
<SkeletonButton className="h-9 w-20 rounded-md bg-gray-200" /> {/* Save button */}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="mt-4 w-full md:mt-0">
|
||||
<div className="flex flex-col sm:mx-0 xl:flex-row xl:space-x-6">
|
||||
<div className="flex-1 flex-row xl:mr-0 xl:w-[75%]">
|
||||
{/* Schedule container */}
|
||||
<div className="border-subtle mb-6 rounded-md border">
|
||||
<div className="flex flex-col gap-4 p-2 sm:p-4">
|
||||
{/* Days of the week */}
|
||||
{Array(7)
|
||||
.fill(0)
|
||||
.map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex w-full flex-col gap-4 last:mb-0 sm:flex-row sm:gap-6 sm:px-0">
|
||||
{/* Day toggle and name */}
|
||||
<div className="flex h-[36px] items-center justify-between sm:w-32">
|
||||
<div>
|
||||
<div className="text-default flex flex-row items-center space-x-2 rtl:space-x-reverse">
|
||||
<div className="mr-2 h-6 w-11 rounded-full bg-gray-200" /> {/* Toggle */}
|
||||
<SkeletonText className="h-5 w-16 text-sm" /> {/* Day name */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time inputs - conditional rendering based on day being active */}
|
||||
{i !== 0 &&
|
||||
i !== 6 && ( // Only show time inputs for Monday-Friday
|
||||
<div className="flex sm:gap-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex gap-1 last:mb-0 sm:gap-2">
|
||||
<div className="flex flex-row gap-2 sm:gap-3">
|
||||
<SkeletonText className="h-9 w-[90px] rounded-md sm:w-[100px]" />{" "}
|
||||
{/* Time input */}
|
||||
<span className="text-default w-2 self-center"> - </span>
|
||||
<SkeletonText className="h-9 w-[90px] rounded-md sm:w-[100px]" />{" "}
|
||||
{/* Time input */}
|
||||
</div>
|
||||
<SkeletonText className="h-9 w-9 rounded-md" /> {/* Add button */}
|
||||
</div>
|
||||
</div>
|
||||
<SkeletonText className="h-9 w-9 rounded-md" /> {/* Copy button */}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date overrides section */}
|
||||
<div className="p-6">
|
||||
<div className="flex items-center">
|
||||
<SkeletonText className="h-5 w-32 font-medium" /> {/* Date overrides */}
|
||||
<SkeletonText className="ml-2 h-4 w-4 rounded-full" /> {/* Info icon */}
|
||||
</div>
|
||||
<SkeletonText className="mb-4 mt-1 h-4 w-full max-w-md text-sm" /> {/* Description */}
|
||||
<div className="space-y-2">
|
||||
<SkeletonButton className="h-9 w-36 rounded-md" /> {/* Add an override button */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar - only visible on desktop */}
|
||||
<div className="min-w-40 col-span-3 hidden space-y-2 md:block lg:col-span-1 xl:w-[25%]">
|
||||
<div className="xl:max-w-80 w-full pr-4 sm:ml-0 sm:mr-36 sm:p-0">
|
||||
<div>
|
||||
<SkeletonText className="mb-1 h-4 w-24 text-sm" /> {/* Timezone */}
|
||||
<SkeletonText className="mt-1 h-9 w-72 rounded-md" /> {/* Timezone selector */}
|
||||
</div>
|
||||
|
||||
<hr className="border-subtle my-6 mr-8" />
|
||||
|
||||
<div className="rounded-md">
|
||||
<SkeletonText className="h-5 w-64 text-sm" /> {/* Something doesn't look right? */}
|
||||
<div className="mt-3 flex">
|
||||
<SkeletonButton className="h-9 w-40 rounded-md" /> {/* Launch troubleshooter */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SkeletonContainer>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,10 @@ import Troubleshoot from "~/availability/troubleshoot/troubleshoot-view";
|
||||
export const generateMetadata = async () => {
|
||||
return await _generateMetadata(
|
||||
(t) => t("troubleshoot"),
|
||||
(t) => t("troubleshoot_availability")
|
||||
(t) => t("troubleshoot_availability"),
|
||||
undefined,
|
||||
undefined,
|
||||
"/availability/troubleshoot"
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -26,7 +26,8 @@ export const generateMetadata = async ({ params, searchParams }: _PageProps) =>
|
||||
(t) =>
|
||||
t(`booking_${needsConfirmation ? "submitted" : "confirmed"}${recurringBookings ? "_recurring" : ""}`),
|
||||
false,
|
||||
getOrgFullOrigin(orgSlug)
|
||||
getOrgFullOrigin(orgSlug),
|
||||
`/booking/${(await params).uid}`
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -5,7 +5,10 @@ import BookingDryRunSuccessView from "~/bookings/views/booking-dry-run-success-v
|
||||
export const generateMetadata = async () => {
|
||||
return await _generateMetadata(
|
||||
(t) => t("booking_dry_run_successful"),
|
||||
(t) => t("booking_dry_run_successful_description")
|
||||
(t) => t("booking_dry_run_successful_description"),
|
||||
undefined,
|
||||
undefined,
|
||||
"/booking/dry-run-successful"
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -7,7 +7,10 @@ import LegacyPage from "~/connect-and-join/connect-and-join-view";
|
||||
export const generateMetadata = async () => {
|
||||
return await _generateMetadata(
|
||||
(t) => t("connect_and_join"),
|
||||
() => ""
|
||||
() => "",
|
||||
undefined,
|
||||
undefined,
|
||||
"/connect-and-join"
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -10,7 +10,8 @@ import { type PageProps } from "@lib/d/[link]/[slug]/getServerSideProps";
|
||||
import Type from "~/d/[link]/d-type-view";
|
||||
|
||||
export const generateMetadata = async ({ params, searchParams }: _PageProps) => {
|
||||
const legacyCtx = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
|
||||
const _params = await params;
|
||||
const legacyCtx = buildLegacyCtx(await headers(), await cookies(), _params, await searchParams);
|
||||
const pageProps = await getData(legacyCtx);
|
||||
|
||||
const { booking, eventData, isBrandingHidden } = pageProps;
|
||||
@@ -21,7 +22,9 @@ export const generateMetadata = async ({ params, searchParams }: _PageProps) =>
|
||||
return await _generateMetadata(
|
||||
(t) => `${rescheduleUid && !!booking ? t("reschedule") : ""} ${title} | ${profileName}`,
|
||||
(t) => `${rescheduleUid ? t("reschedule") : ""} ${title}`,
|
||||
isBrandingHidden
|
||||
isBrandingHidden,
|
||||
undefined,
|
||||
`/d/${_params.link}/${_params.slug}`
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -5,7 +5,10 @@ import EnterprisePage from "@components/EnterprisePage";
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
(t) => t("create_your_org"),
|
||||
(t) => t("create_your_org_description")
|
||||
(t) => t("create_your_org_description"),
|
||||
undefined,
|
||||
undefined,
|
||||
"/enterprise"
|
||||
);
|
||||
|
||||
const ServerPageWrapper = async () => {
|
||||
|
||||
@@ -24,7 +24,10 @@ export const generateMetadata = async ({ params }: _PageProps) => {
|
||||
if (!parsed.success) {
|
||||
return await _generateMetadata(
|
||||
(t) => `${t("event_type")}`,
|
||||
() => ""
|
||||
() => "",
|
||||
undefined,
|
||||
undefined,
|
||||
"/event-types"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,7 +37,10 @@ export const generateMetadata = async ({ params }: _PageProps) => {
|
||||
|
||||
return await _generateMetadata(
|
||||
(t) => (data?.title ? `${data.title} | ${t("event_type")}` : `${t("event_type")}`),
|
||||
() => ""
|
||||
() => "",
|
||||
undefined,
|
||||
undefined,
|
||||
`/event-types/${parsed.data.type}`
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -11,11 +11,15 @@ import { getServerSideProps } from "@lib/getting-started/[[...step]]/getServerSi
|
||||
import type { PageProps as ClientPageProps } from "~/getting-started/[[...step]]/onboarding-view";
|
||||
import Page from "~/getting-started/[[...step]]/onboarding-view";
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
export const generateMetadata = async ({ params }: ServerPageProps) => {
|
||||
const stepParam = (await params).step;
|
||||
const step = stepParam && Array.isArray(stepParam) ? stepParam.join("/") : "";
|
||||
return await _generateMetadata(
|
||||
(t) => `${APP_NAME} - ${t("getting_started")}`,
|
||||
() => "",
|
||||
true
|
||||
true,
|
||||
undefined,
|
||||
`/getting-started${step ? `/${step}` : ""}`
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -8,7 +8,10 @@ import InsightsPage from "~/insights/insights-view";
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
(t) => t("insights"),
|
||||
(t) => t("insights_subtitle")
|
||||
(t) => t("insights_subtitle"),
|
||||
undefined,
|
||||
undefined,
|
||||
"/insights"
|
||||
);
|
||||
|
||||
export default async function Page() {
|
||||
|
||||
@@ -8,7 +8,10 @@ import InsightsVirtualQueuesPage from "~/insights/insights-virtual-queues-view";
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
(t) => t("insights"),
|
||||
(t) => t("insights_subtitle")
|
||||
(t) => t("insights_subtitle"),
|
||||
undefined,
|
||||
undefined,
|
||||
"/insights/router-position"
|
||||
);
|
||||
|
||||
export default async function Page() {
|
||||
|
||||
@@ -8,7 +8,10 @@ import InsightsRoutingPage from "~/insights/insights-routing-view";
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
(t) => t("insights"),
|
||||
(t) => t("insights_subtitle")
|
||||
(t) => t("insights_subtitle"),
|
||||
undefined,
|
||||
undefined,
|
||||
"/insights/routing"
|
||||
);
|
||||
|
||||
export default async function Page() {
|
||||
|
||||
@@ -7,7 +7,10 @@ import LegacyPage from "~/maintenance/maintenance-view";
|
||||
export const generateMetadata = async () => {
|
||||
return await _generateMetadata(
|
||||
(t) => t("under_maintenance"),
|
||||
(t) => t("under_maintenance_description", { appName: APP_NAME })
|
||||
(t) => t("under_maintenance_description", { appName: APP_NAME }),
|
||||
undefined,
|
||||
undefined,
|
||||
"/maintenance"
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -5,7 +5,10 @@ import Page from "~/more/more-page-view";
|
||||
export const generateMetadata = async () => {
|
||||
return await _generateMetadata(
|
||||
(t) => t("more"),
|
||||
() => ""
|
||||
() => "",
|
||||
undefined,
|
||||
undefined,
|
||||
"/more"
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user