Compare commits

...
52 Commits
Author SHA1 Message Date
GitHub Actions 6f4e51b859 chore: release v5.2.7
Post release / release (push) Canceled after 0s
2025-04-26 01:52:58 +00:00
Omar López 4f5d0db792 docs: add JSDoc documentation to FeaturesRepository class and methods (#20980) 2025-04-26 01:44:37 +00:00
Keith Williams 8733bcaf7a perf: Add credentialId index to selectedCalendar (#20979) 2025-04-26 01:40:28 +00:00
Keith Williams 5aaca2b7aa revert: "feat: add membership creation date to Organization Member List table (CAL-5406) (#20595)" (#20978)
This reverts commit d21f7ccbc8.
2025-04-26 00:46:49 +00:00
Alex van Andel eacd05bf3b perf: Improve performance of period limits (#20974)
* perf: Improve performance of period limits

* Type fixes

* Fixed correct check for instanceof -> isDayjs
2025-04-25 21:07:18 +01:00
Hariom Balhara d51e0182b5 fix: dont persist last booking response, temporarily (#20975)
* dont persist last booking response

* remove dead code to fix ts error
2025-04-25 23:07:48 +05:30
Benny Joo 220897ba86 chore: better code - migrate getServerSideProps file directly to RSC for /apps (#20926)
* migrate getServerSideProps file directly to RSC for /apps

* fix
2025-04-25 06:41:21 -07:00
Benny Joo 78c0203a45 chore: leverage error.tsx in app router (#20955) 2025-04-25 13:55:52 +01:00
Syed Ali Shahbaz 720fd90066 fix: Don't delete Cal video room upon requesting reschedule (#20969)
* Update requestReschedule.handler.ts

* Update requestReschedule.handler.ts
2025-04-25 08:30:44 -03:00
Omar López ca8ee749a2 fix: double end-date parsing when saving availability (#20963)
* fix: double end-date parsing when saving availablity

Signed-off-by: Omar López <zomars@me.com>

* fix: cache hits in current and next month

* Update CalendarService.test.ts

---------

Signed-off-by: Omar López <zomars@me.com>
2025-04-24 19:33:55 -03:00
Anik Dhabal Babu ce2d3a4b00 fix: added order by (#20959) 2025-04-24 19:21:21 +00:00
Anik Dhabal Babu 2923684c7c fix: exclude email from replyTo (#20953)
* fix: exclude email from replyTo

* update

* Update scheduleEmailReminders.ts
2025-04-24 19:20:45 +00:00
Alex van Andel 879ae597be fix: Offset is required but not passed everywhere, default to zero. (#20917) 2025-04-24 16:46:19 +01:00
d21f7ccbc8 feat: add membership creation date to Organization Member List table (CAL-5406) (#20595)
* feat: add membership creation date to Organization Member List table (CAL-5406)

Co-Authored-By: keith@cal.com <keith@cal.com>

* feat: add migration for membership creation date (CAL-5406)

Co-Authored-By: keith@cal.com <keith@cal.com>

* feat: make createdAt and updatedAt nullable (CAL-5406)

Co-Authored-By: keith@cal.com <keith@cal.com>

* feat: add updatedAt column to Organization Member List table (CAL-5406)

Co-Authored-By: keith@cal.com <keith@cal.com>

* fix: use type assertion to access createdAt and updatedAt fields (CAL-5406)

Co-Authored-By: keith@cal.com <keith@cal.com>

* fix: display N/A for null date values (CAL-5406)

Co-Authored-By: keith@cal.com <keith@cal.com>

* fix: use proper type assertions for createdAt and updatedAt fields (CAL-5406)

Co-Authored-By: keith@cal.com <keith@cal.com>

* fix: add createdAt and updatedAt to UserTableUser mock in test (CAL-5406)

Co-Authored-By: keith@cal.com <keith@cal.com>

* feat: add PostgreSQL trigger for membership timestamps (CAL-5406)

Co-Authored-By: keith@cal.com <keith@cal.com>

* fix: use empty string instead of N/A and add translations for column headers (CAL-5406)

Co-Authored-By: keith@cal.com <keith@cal.com>

* add i18n text

* clean up type issue

* feat: add translation keys for column headers (CAL-5406)

Co-Authored-By: keith@cal.com <keith@cal.com>

* disable sort

* remove duplicated i18n texts

* feat: add filters for lastActiveAt, createdAt, and updatedAt (CAL-5406)

Co-Authored-By: keith@cal.com <keith@cal.com>

* support date range filter

* fix date range for end date

* hide columns by default

* revert wrong change

* add missing selects

* fix e2e test

* fix: remove PostgreSQL trigger and let application handle timestamps (CAL-5406)

Co-Authored-By: keith@cal.com <keith@cal.com>

* feat: add application-level timestamp handling for Membership model (CAL-5406)

Co-Authored-By: keith@cal.com <keith@cal.com>

* add more timestamp handling

* refactor: use Prisma's built-in decorators for Membership timestamps (CAL-5406)

Co-Authored-By: keith@cal.com <keith@cal.com>

* refactor: remove application-level timestamp handling in favor of Prisma decorators (CAL-5406)

Co-Authored-By: keith@cal.com <keith@cal.com>

* refactor: remove more application-level timestamp handling in favor of Prisma decorators (CAL-5406)

Co-Authored-By: keith@cal.com <keith@cal.com>

* fix e2e test

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: keith@cal.com <keith@cal.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Eunjae Lee <hey@eunjae.dev>
2025-04-24 10:48:06 -03:00
Lauris Skraucis 4cc1bc6746 fix: v2 sentry errors (#20902)
Post release / release (push) Canceled after 0s
* refactor: no_available_users_found_error for team event

* fix: hosts_unavailable_for_booking

* fix: Cannot read properties of undefined (reading 'replace')

* fix: Cannot read properties of undefined (reading 'phoneNumber')

* fix: No SelectedCalendar found.

* fix: Cannot read properties of undefined (reading 'length')

* refactor: add bookings errors service
2025-04-24 16:06:37 +03:00
GitHub Actions 49a2c60212 chore: release v5.2.6 2025-04-24 12:55:32 +00:00
Lauris Skraucis 7dfbb42ced docs: global platform event types and custom booking flows (#20940)
* docs: global event types

* docs: custom booking flow
2025-04-24 12:47:09 +00:00
Anik Dhabal Babu 41fafb9e5e fix: Include ordered attendees in booking select query (#20935)
* fix: correct ordering

* another
2025-04-24 12:33:20 +00:00
Benny Joo 6815f2f9c7 perf: server-side fetching for /availability (#20804)
* availability

* refactor

* add loader

* improve

* update

* fix loader

* rename

* satisfying types

* refactor

* remove log

* add CTA to skeleton

* refactor

* fix revalidations

* refactor

* update

* finish
2025-04-24 11:10:54 +02:00
Benny Joo 91ab27e070 feat: add loading component for /apps main page (#20924) 2025-04-24 10:48:03 +02:00
Lauris Skraucis 396ccb1cd2 refactor: v2 automatic team slugs (#20897)
* refactor: automatic team slugs

* refactor: automatic organizations team slugs

* refactor: automatic managed organizations slugs

* docs

* refactor: test
2025-04-24 10:00:01 +02:00
Benny Joo 4e8d5cebca perf: ssr for /settings/organizations/teams/other (#20927) 2025-04-24 09:42:09 +05:30
Benny Joo 80f2e61189 perf: remove next.redirect by going straight to event-types (#20920) 2025-04-23 20:23:50 +00:00
Benny Joo d844fd6ef7 perf: Slim down trpc queries in Settings Layout (#20918)
* slim down settings layout by removing server queries

* add check method

* fix type check

* refactor
2025-04-23 20:13:26 +00:00
Anik Dhabal Babu 513f54f4fe feat: ability to hide organizer email (#20782)
* feat: hide orgainzer email

* fix type error

* update

* update

* Update schema.prisma
2025-04-23 19:58:29 +00:00
Keith Williams 2e6d71f493 fix: Docs for deleting reserved slot (#20922) 2025-04-23 19:58:04 +00:00
GitHub Actions 55488c39f4 chore: release v5.2.5
Post release / release (push) Canceled after 0s
2025-04-23 17:22:16 +00:00
Joe Au-YeungandAnik Dhabal Babu c0e123a917 Pass customReplyTo as a managed event type prop (#20909)
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-04-23 17:55:46 +01:00
Morgan ec88f16f1f fix: getBookings max 1 month in the past when no date filters specified (#20911)
* fix: getBookings max 1 month in the past

* bump platform libraries
2025-04-23 13:22:20 -03:00
Benny Joo a957c15062 perf: SSR - /availablilty/[schedule] (#20829)
* ssr for schedule page

* improve skeleton

* improve skeleton

* improve skeleton
2025-04-23 11:31:01 -03:00
Benny Joo b32b31ccdc perf: SSR for /teams (#20824)
* finish

* improve

* refactor

* add revalidations

* /settings/teams is not needed

* refactor

* rename

* redundant type casting
2025-04-23 15:09:25 +01:00
e29f6af540 feat(insights): add progress indicator to download button on /insights and /insights/routing (CAL-5408) (#20631)
* feat(insights): add progress indicator to routing form responses download button (CAL-5408)

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* fix: address PR feedback - remove initial progress update and ensure no decimal parts in percentage

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* feat(insights): add progress indicator to Download component

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* feat: paginate rawData endpoint and implement batch fetching in Download component

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* refactor: simplify pagination logic in trpc-router.ts

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* show progress on tooltip

* fix download for /insights

* fix download when there is no data

* feat: replace tooltip progress indicator with toast notification

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* fix: remove duplicate ProgressToast component from showToast.tsx

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* fix: export ProgressToast component from index.ts

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* fix: add missing translation for downloading_data

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* update progress toast implementation

* fix error

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: eunjae@cal.com <eunjae@cal.com>
Co-authored-by: Eunjae Lee <hey@eunjae.dev>
2025-04-23 15:59:09 +02:00
Anik Dhabal BabuandJoe Au-Yeung c6cb3423fa feat: custom reply To Email (#20771)
Co-authored-by: Joe Au-Yeung <j.auyeung419@gmail.com>
2025-04-23 06:44:44 -07:00
Eunjae Lee 45904c07bc fix: eventType filter on /insights (#20823)
* fix: eventType filter on /insights

* remove log
2025-04-23 13:42:39 +00:00
Omar López 0623887ff8 refactor: Returns 400 error for ZodErrors (#20831) 2025-04-23 13:47:29 +01:00
Lauris Skraucis f31c45a153 fix: platform org members cannot access their public booking page (#20821)
* fix: platform member event pages

* chore: update slots docs

* refactor: user repository call

* refactor: remove newline
2025-04-23 14:29:26 +03:00
Vijay f5217caef2 fix: caching for sprite.svg (#20832)
* update cache-control header for sprite.svg

* updated regex
2025-04-23 11:18:04 +00:00
GitHub Actions e1beec0487 chore: release v5.2.4
Post release / release (push) Canceled after 0s
2025-04-23 01:47:22 +00:00
Joe Au-Yeung 6bbf263981 feat: Optout of SMS workflows (#20769)
* Do not call dub if API key isn't present

* Add `WorkflowOptOutContact` table

* Add `sendTo` column to `WorkflowReminder`

* Add `workflowOptOutContactRepository`

* Add `WorkflowReminderRepository`

* Write `sendTo` number when scheduling SMS messages

* Add `p-limit` package

* Add `determineOptOutType` to `TwilioProvider`

* Add `deleteMultipleScheduledSMS`

* Create `WorkflowOptOutService`

* Add endpoint handler for Twilio SMS responses

* Add `isOptedOut` method to `workflowOptOutContactRepository`

* Verify phone number is not opted out before scheduling SMS

* Use `smsReminderNumber` instead of `sendTo`

* Type fix

* Add .env variable if opt out is available

* Add opt out message to SMS

* Import `pLimit` directly in method

* Address select comment

* Guard against undefined phone number from form

* Add early return to `deleteWorkflowReminders`

* Add request validation from Twilio

* Add fallback message as i18N string

* Only delete SMS attendee scheduled reminders
2025-04-22 22:00:19 +01:00
Benny Joo d86a28bf7f simplify metadata in /availability/[schedule] route (#20827) 2025-04-22 19:41:24 +00:00
Benny Joo c00e8cfa54 perf: Fetch data server-side for Api Keys page (#20677)
* server-side fetching for api keys page

* refactor billing page

* fix
2025-04-22 18:20:41 +00:00
Alex van Andel e82cc328d3 refactor: Remove kysely as its unused (#20817) 2025-04-22 14:53:41 -03:00
Rajiv Sahal 7dbfc1b2e0 fix: user is already part of a team (#20774)
* better error message

* redirect to login if no user session

* fixup

* remove callback url since the same function is used by org and platform both

* remove extra exclamation mark

* implement PR feedback
2025-04-22 16:07:13 +00:00
Benny Joo 02fb7c0ae0 fix: remove window usage in not-found client (#20822)
* remove window usage in not-found client

* fix
2025-04-22 16:04:36 +00:00
Eunjae Lee 9004dcb93b feat: open dropdown right after adding a filter (#20819)
* feat: open dropdown right after adding a filter

* clean up timeout
2025-04-22 15:29:56 +00:00
devin-ai-integration[bot]andeunjae@cal.com cf83b0a236 feat: update DeleteSegmentDialog to use ConfirmationDialogContent CAL-5399 (#20820)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: eunjae@cal.com <eunjae@cal.com>
2025-04-22 15:24:06 +00:00
Lauris Skraucis 7f02464fed refactor: forbidden 403 v2 guard messages (#20794)
* refactor: delete unused proxy guard

* refactor: roles.guard.ts

* refactor: is-org.guard.ts

* refactor: permissions.guard.ts

* refactor: oauth-client-guard.ts

* refactor: is-user-event-type-webhook-guard.ts

* refactor: platform-plan.guard.ts

* refactor: roles.guard.ts

* refactor: cache guard result if canAccess=true

* refactor: is-admin-api-enabled.guard.ts

* refactor: is-team-in-org.guard.ts

* refactor: platform-plan.guard.ts

* refactor: is-webhook-in-org.guard.ts

* refactor: is-user-in-org.guard.ts

* refactor: is-user-webhook-guard.ts

* refactor: is-user-in-org-team.guard.ts

* refactor: is-membership-in-org.guard.ts

* refactor: is-oauth-client-webhook-guard.ts

* refactor: is-routing-form-in-team.guard.ts

* refactor: is-managed-org-in-manager-org.guard.ts

* fill the gaps

* refactor: api-auth.strategy.ts

* fix: test

* implement feedback

* fix: tests

* fix: refactor

* fix: test
2025-04-22 16:28:53 +03:00
Keith Williams 2371253cb5 chore: Add more Foundation and Consumer team CODEOWNERS configuration (#20816) 2025-04-22 09:58:13 -03:00
Morgan 859e7f1c88 fix: api v2 license check (#20813)
* fix: api v2 license check

* update cache key to avoid conflicts
2025-04-22 09:02:43 -03:00
amrit f1e8481878 refactor: remove iframe usage note from Preview tab in EmbedTabs component (#20810)
* fix: trigger lingo.dev by removing duplicate value

* refactor: remove iframe usage note from Preview tab in EmbedTabs component
2025-04-22 10:03:19 +00:00
amritandHariom Balhara acf3a7c54e feat: add "React (Atom)" to embed generator and rename rest (#20618)
* fix: trigger lingo.dev by removing duplicate value

* feat: add support for React Atom embed options

* fix: update locator for React code tab in embed code generator E2E tests

* fix: update embed tab names to clarify iframe usage

* fix failing test

* fix: indentation and installation commands for atoms

* fix: add note about iframe usage in Preview tab

* refactor: replace Cal component with BookerEmbed in EmbedCodes

* fix: Add EmbedConfig type import in Embed.tsx

- Added missing EmbedConfig type to the existing types import
- Resolves TypeScript error "Cannot find name 'EmbedConfig'"

* fix: update BookerEmbed usage in EmbedCodes component

* fix: enhance BookerEmbed implementation and update installation instructions in EmbedTabs

* fix: improve comments in EmbedCodes for clarity on layout options and styling references

* fix: streamline BookerEmbed usage in EmbedCodes with improved class handling and layout options

* fix: embed type selection

* fix: update layout selection in EmbedCodes to use dynamic configuration

* fix: update embed configuration to use theme instead of layout

* fix: conditionally render embed options based on if we are selecting atom

* fix: use simple props instead of embed specific logic to fix the active embed bug

* Fix double tabs activeness

* Revert isCurrent change

---------

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2025-04-22 04:51:10 +00:00
Hariom Balhara f163e4638b fix: Convert an error in webhook to info (#20806)
* Convert error to info

* Convert error to info
2025-04-22 10:10:00 +05:30
290 changed files with 4101 additions and 2233 deletions
+1
View File
@@ -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=
+6
View File
@@ -1,6 +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
@@ -22,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
View File
View File
+7
View File
@@ -0,0 +1,7 @@
undecided:
- "@calcom/app-store-cli"
- "@calcom/platform-constants"
- "@calcom/platform-enums"
- "@calcom/platform-types"
- "@calcom/platform-utils"
- "@calcom/prisma"
+8
View File
@@ -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"
+8
View File
@@ -107,6 +107,14 @@ $ 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".
3. If you use ApiAuthGuard but want that only specific auth method is allowed, for example, api key, then you also need to add `@ApiAuthGuardOnlyAllow(["API_KEY"])` under the `@UseGuards(ApiAuthGuard)`. Shortly, use `ApiAuthGuardOnlyAllow` to specify which auth methods are allowed by `ApiAuthGuard`. If `ApiAuthGuardOnlyAllow` is not used or nothing is passed to it or empty array it means that
all auth methods are allowed.
## 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).
+1 -1
View File
@@ -38,7 +38,7 @@
"@axiomhq/winston": "^1.2.0",
"@calcom/platform-constants": "*",
"@calcom/platform-enums": "*",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.174",
"@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,6 +1,7 @@
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
import { BookingsController_2024_08_13 } from "@/ee/bookings/2024-08-13/controllers/bookings.controller";
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
import { ErrorsBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/errors.service";
import { InputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/input.service";
import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output.service";
import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service";
@@ -67,6 +68,7 @@ import { Module } from "@nestjs/common";
SelectedCalendarsRepository,
OrganizationsTeamsRepository,
OrganizationsRepository,
ErrorsBookingsService_2024_08_13,
],
controllers: [BookingsController_2024_08_13],
exports: [InputBookingsService_2024_08_13, OutputBookingsService_2024_08_13, BookingsService_2024_08_13],
@@ -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;
@@ -1,5 +1,6 @@
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
import { CalendarLink } from "@/ee/bookings/2024-08-13/outputs/calendar-links.output";
import { ErrorsBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/errors.service";
import { InputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/input.service";
import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output.service";
import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service";
@@ -85,34 +86,19 @@ export class BookingsService_2024_08_13 {
private readonly organizationsTeamsRepository: OrganizationsTeamsRepository,
private readonly organizationsRepository: OrganizationsRepository,
private readonly teamsRepository: TeamsRepository,
private readonly teamsEventTypesRepository: TeamsEventTypesRepository
private readonly teamsEventTypesRepository: TeamsEventTypesRepository,
private readonly errorsBookingsService: ErrorsBookingsService_2024_08_13
) {}
async createBooking(request: Request, body: CreateBookingInput) {
let bookingTeamEventType = false;
try {
const eventType = await this.getBookedEventType(body);
if (eventType?.team) {
bookingTeamEventType = true;
}
if (!eventType) {
if (body.username && body.eventTypeSlug && !body.organizationSlug) {
throw new NotFoundException(
`Event type with slug ${body.eventTypeSlug} belonging to user ${body.username} not found.`
);
}
if (body.username && body.eventTypeSlug && body.organizationSlug) {
throw new NotFoundException(
`Event type with slug ${body.eventTypeSlug} belonging to user ${body.username} within organization ${body.organizationSlug} not found.`
);
}
if (body.teamSlug && body.eventTypeSlug && !body.organizationSlug) {
throw new NotFoundException(
`Event type with slug ${body.eventTypeSlug} belonging to team ${body.teamSlug} not found.`
);
}
if (body.teamSlug && body.eventTypeSlug && body.organizationSlug) {
throw new NotFoundException(
`Event type with slug ${body.eventTypeSlug} belonging to team ${body.teamSlug} within organization ${body.organizationSlug} not found.`
);
}
throw new NotFoundException(`Event type with id ${body.eventTypeId} not found.`);
this.errorsBookingsService.handleEventTypeToBeBookedNotFound(body);
}
body.eventTypeId = eventType.id;
@@ -138,18 +124,7 @@ export class BookingsService_2024_08_13 {
return await this.createRegularBooking(request, body, eventType);
} catch (error) {
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;
this.errorsBookingsService.handleBookingError(error, bookingTeamEventType);
}
}
@@ -0,0 +1,55 @@
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import { Logger } from "@nestjs/common";
import { CreateBookingInput } from "@calcom/platform-types";
@Injectable()
export class ErrorsBookingsService_2024_08_13 {
private readonly logger = new Logger("ErrorsBookingsService_2024_08_13");
handleEventTypeToBeBookedNotFound(body: CreateBookingInput): never {
if (body.username && body.eventTypeSlug && !body.organizationSlug) {
throw new NotFoundException(
`Event type with slug ${body.eventTypeSlug} belonging to user ${body.username} not found.`
);
}
if (body.username && body.eventTypeSlug && body.organizationSlug) {
throw new NotFoundException(
`Event type with slug ${body.eventTypeSlug} belonging to user ${body.username} within organization ${body.organizationSlug} not found.`
);
}
if (body.teamSlug && body.eventTypeSlug && !body.organizationSlug) {
throw new NotFoundException(
`Event type with slug ${body.eventTypeSlug} belonging to team ${body.teamSlug} not found.`
);
}
if (body.teamSlug && body.eventTypeSlug && body.organizationSlug) {
throw new NotFoundException(
`Event type with slug ${body.eventTypeSlug} belonging to team ${body.teamSlug} within organization ${body.organizationSlug} not found.`
);
}
throw new NotFoundException(`Event type with id ${body.eventTypeId} not found.`);
}
handleBookingError(error: unknown, bookingTeamEventType: boolean): never {
const hostsUnavaile = "One of the hosts either already has booking at this time or is not available";
if (error instanceof Error) {
if (error.message === "no_available_users_found_error") {
if (bookingTeamEventType) {
throw new BadRequestException(hostsUnavaile);
}
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 the "start" time provided. 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.");
} else if (error.message === "hosts_unavailable_for_booking") {
throw new BadRequestException(hostsUnavaile);
}
}
throw error;
}
}
@@ -15,6 +15,7 @@ import { IcsFeedService } from "@/ee/calendars/services/ics-feed.service";
import { OutlookService } from "@/ee/calendars/services/outlook.service";
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
import { ApiAuthGuardOnlyAllow } from "@/modules/auth/decorators/api-auth-guard-only-allow.decorator";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
@@ -51,7 +52,7 @@ import {
APPLE_CALENDAR,
CREDENTIAL_CALENDARS,
} from "@calcom/platform-constants";
import { ApiResponse, CalendarBusyTimesInput } from "@calcom/platform-types";
import { ApiResponse, CalendarBusyTimesInput, CreateCalendarCredentialsInput } from "@calcom/platform-types";
@Controller({
path: "/v2/calendars",
@@ -135,6 +136,7 @@ export class CalendarsController {
name: "calendar",
})
@UseGuards(ApiAuthGuard)
@ApiAuthGuardOnlyAllow(["API_KEY", "ACCESS_TOKEN"])
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Get("/:calendar/connect")
@HttpCode(HttpStatus.OK)
@@ -173,7 +175,7 @@ export class CalendarsController {
@Get("/:calendar/save")
@HttpCode(HttpStatus.OK)
@Redirect(undefined, 301)
@ApiOperation({ summary: "Save an oAuth calendar credentials" })
@ApiOperation({ summary: "Save Google or Outlook calendar credentials" })
async save(
@Query("state") state: string,
@Query("code") code: string,
@@ -221,11 +223,11 @@ export class CalendarsController {
@UseGuards(ApiAuthGuard)
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Post("/:calendar/credentials")
@ApiOperation({ summary: "Sync credentials" })
@ApiOperation({ summary: "Save Apple calendar credentials" })
async syncCredentials(
@GetUser() user: User,
@Param("calendar") calendar: string,
@Body() body: { username: string; password: string }
@Body() body: CreateCalendarCredentialsInput
): Promise<{ status: string }> {
const { username, password } = body;
@@ -60,8 +60,9 @@ export class AppleCalendarService implements CredentialSyncCalendarApp {
}
async saveCalendarCredentials(userId: number, userEmail: string, username: string, password: string) {
if (username.length <= 1 || password.length <= 1)
if (!username || !password || username.length <= 1 || password.length <= 1) {
throw new BadRequestException(`Username or password cannot be empty`);
}
const existingAppleCalendarCredentials = await this.credentialRepository.getAllUserCredentialsByTypeAndId(
APPLE_CALENDAR_TYPE,
@@ -9,8 +9,6 @@ import { Permissions } from "@/modules/auth/decorators/permissions/permissions.d
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import {
BadRequestException,
Controller,
@@ -48,8 +46,6 @@ export class GcalController {
constructor(
private readonly credentialRepository: CredentialsRepository,
private readonly tokensRepository: TokensRepository,
private readonly selectedCalendarsRepository: SelectedCalendarsRepository,
private readonly config: ConfigService,
private readonly gcalService: GCalService,
private readonly calendarsService: CalendarsService
+2 -1
View File
@@ -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 {}
+3 -3
View File
@@ -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({
@@ -0,0 +1,5 @@
import { Reflector } from "@nestjs/core";
export type AllowedAuthMethod = "OAUTH_CLIENT_CREDENTIALS" | "API_KEY" | "ACCESS_TOKEN" | "NEXT_AUTH";
export const ApiAuthGuardOnlyAllow = Reflector.createDecorator<AllowedAuthMethod[]>();
@@ -1,7 +1,20 @@
import { ExecutionContext, Inject } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { AuthGuard } from "@nestjs/passport";
import { ApiAuthGuardOnlyAllow } from "../../decorators/api-auth-guard-only-allow.decorator";
export class ApiAuthGuard extends AuthGuard("api-auth") {
constructor() {
constructor(@Inject(Reflector) private readonly reflector: Reflector) {
super();
}
getRequest(context: ExecutionContext) {
const request = context.switchToHttp().getRequest();
const allowedMethods = this.reflector.get(ApiAuthGuardOnlyAllow, context.getHandler());
request.allowedAuthMethods = allowedMethods;
return request;
}
}
@@ -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 };
}
}
@@ -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;
}
}
@@ -18,11 +18,14 @@ import { getToken } from "next-auth/jwt";
import { INVALID_ACCESS_TOKEN, X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
import type { AllowedAuthMethod } from "../../decorators/api-auth-guard-only-allow.decorator";
export type ApiAuthGuardUser = UserWithProfile & { isSystemAdmin: boolean };
export type ApiAuthGuardRequest = Request & {
authMethod: AuthMethods;
organizationId: number | null;
user: ApiAuthGuardUser;
allowedAuthMethods?: AllowedAuthMethod[];
};
export const NO_AUTH_PROVIDED_MESSAGE =
"No authentication method provided. Either pass an API key as 'Bearer' header or OAuth client credentials as 'x-cal-secret-key' and 'x-cal-client-id' headers";
@@ -49,12 +52,19 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
const oAuthClientId = params.clientId || request.get(X_CAL_CLIENT_ID);
const bearerToken = request.get("Authorization")?.replace("Bearer ", "");
if (oAuthClientId && oAuthClientSecret) {
const allowedMethods = request.allowedAuthMethods;
const noSpecificAuthExpected = !allowedMethods || !allowedMethods.length;
const oAuthAllowed = noSpecificAuthExpected || allowedMethods.includes("OAUTH_CLIENT_CREDENTIALS");
const apiKeyAllowed = noSpecificAuthExpected || allowedMethods.includes("API_KEY");
const accessTokenAllowed = noSpecificAuthExpected || allowedMethods.includes("ACCESS_TOKEN");
const nextAuthAllowed = noSpecificAuthExpected || allowedMethods.includes("NEXT_AUTH");
if (oAuthClientId && oAuthClientSecret && oAuthAllowed) {
request.authMethod = AuthMethods["OAUTH_CLIENT"];
return await this.authenticateOAuthClient(oAuthClientId, oAuthClientSecret, request);
}
if (bearerToken) {
if (bearerToken && (apiKeyAllowed || accessTokenAllowed)) {
const requestOrigin = request.get("Origin");
request.authMethod = isApiKey(bearerToken, this.config.get<string>("api.apiKeyPrefix") ?? "cal_")
? AuthMethods["API_KEY"]
@@ -64,19 +74,26 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
const nextAuthSecret = this.config.get("next.authSecret", { infer: true });
const nextAuthToken = await getToken({ req: request, secret: nextAuthSecret });
if (nextAuthToken) {
if (nextAuthToken && nextAuthAllowed) {
request.authMethod = AuthMethods["NEXT_AUTH"];
return await this.authenticateNextAuth(nextAuthToken, request);
}
throw new UnauthorizedException(NO_AUTH_PROVIDED_MESSAGE);
const noAuthProvided = !oAuthClientId && !oAuthClientSecret && !bearerToken && !nextAuthToken;
if (noAuthProvided) {
throw new UnauthorizedException(`ApiAuthStrategy - ${NO_AUTH_PROVIDED_MESSAGE}`);
}
throw new UnauthorizedException(
`ApiAuthStrategy - Invalid authentication method. Please provide one of the allowed methods: ${allowedMethods}`
);
} 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 +123,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 +164,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 +185,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 +216,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 +255,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 {}
@@ -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;
}
}
@@ -10,6 +10,16 @@ export class CreateOrganizationInput extends RefreshApiKeyInput {
@ApiProperty({ description: "Name of the organization", example: "CalTeam" })
readonly name!: string;
@IsOptional()
@IsString()
@ApiPropertyOptional({
type: String,
description:
"Organization slug in kebab-case - if not provided will be generated automatically based on name.",
example: "cal-tel",
})
readonly slug?: string;
@ApiPropertyOptional({
type: Object,
description: METADATA_DOCS,
@@ -46,6 +46,7 @@ import {
X_CAL_SECRET_KEY,
} from "@calcom/platform-constants";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { slugify } from "@calcom/platform-libraries";
import { ApiSuccessResponse, CreateOAuthClientInput } from "@calcom/platform-types";
import { Team } from "@calcom/prisma/client";
@@ -176,6 +177,7 @@ describe("Organizations Organizations Endpoints", () => {
managedOrg = responseBody.data;
expect(managedOrg?.id).toBeDefined();
expect(managedOrg?.name).toEqual(body.name);
expect(managedOrg?.slug).toEqual(slugify(body.name));
expect(managedOrg?.metadata).toEqual(body.metadata);
expect(managedOrg?.apiKey).toBeDefined();
@@ -16,6 +16,12 @@ export class ManagedOrganizationOutput {
@ApiProperty()
readonly name!: string;
@IsString()
@IsOptional()
@Expose()
@ApiPropertyOptional()
readonly slug?: string;
@ApiPropertyOptional({
type: Object,
example: { key: "value" },
@@ -10,6 +10,8 @@ import { ManagedOrganizationsOutputService } from "@/modules/organizations/organ
import { ProfilesRepository } from "@/modules/profiles/profiles.repository";
import { ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
import { slugify } from "@calcom/platform-libraries";
@Injectable()
export class ManagedOrganizationsService {
constructor(
@@ -36,6 +38,10 @@ export class ManagedOrganizationsService {
const { apiKeyDaysValid, apiKeyNeverExpires, ...organizationData } = organizationInput;
if (!organizationData.slug) {
organizationData.slug = slugify(organizationData.name);
}
const organization = await this.managedOrganizationsRepository.createManagedOrganization(
managerOrganizationId,
{
@@ -36,6 +36,7 @@ describe("Organizations Team Endpoints", () => {
let team2: Team;
let teamCreatedViaApi: Team;
let teamCreatedViaApi2: Team;
let teamCreatedViaApi3: Team;
const userEmail = `organizations-teams-admin-${randomString()}@api.com`;
let user: User;
@@ -245,11 +246,38 @@ describe("Organizations Team Endpoints", () => {
});
});
it("should create the team of the org with automatically set slug", async () => {
const teamName = `Organizations Teams Automatic Slug`;
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/teams`)
.send({
name: teamName,
bio: "This is our test team created via API",
} satisfies CreateOrgTeamDto)
.expect(201)
.then(async (response) => {
const responseBody: ApiSuccessResponse<Team> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
teamCreatedViaApi3 = responseBody.data;
expect(teamCreatedViaApi3.name).toEqual(teamName);
expect(teamCreatedViaApi3.slug).toEqual("organizations-teams-automatic-slug");
expect(teamCreatedViaApi3.bio).toEqual("This is our test team created via API");
expect(teamCreatedViaApi3.parentId).toEqual(org.id);
const membership = await membershipsRepositoryFixture.getUserMembershipByTeamId(
user.id,
teamCreatedViaApi3.id
);
expect(membership?.role ?? "").toEqual("OWNER");
expect(membership?.accepted).toEqual(true);
});
});
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(user.email);
await teamsRepositoryFixture.delete(team.id);
await teamsRepositoryFixture.delete(team2.id);
await teamsRepositoryFixture.delete(teamCreatedViaApi2.id);
await teamsRepositoryFixture.delete(teamCreatedViaApi3.id);
await teamsRepositoryFixture.delete(org.id);
await app.close();
});
@@ -5,6 +5,8 @@ import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/inde
import { UserWithProfile } from "@/modules/users/users.repository";
import { Injectable } from "@nestjs/common";
import { slugify } from "@calcom/platform-libraries";
@Injectable()
export class OrganizationsTeamsService {
constructor(
@@ -40,6 +42,10 @@ export class OrganizationsTeamsService {
async createOrgTeam(organizationId: number, data: CreateOrgTeamDto, user: UserWithProfile) {
const { autoAcceptCreator, ...rest } = data;
if (!rest.slug) {
rest.slug = slugify(rest.name);
}
const team = await this.organizationsTeamRepository.createOrgTeam(organizationId, rest);
if (user.role !== "ADMIN") {
@@ -56,6 +62,10 @@ export class OrganizationsTeamsService {
) {
const { autoAcceptCreator, ...rest } = data;
if (!rest.slug) {
rest.slug = slugify(rest.name);
}
const team = await this.organizationsTeamRepository.createPlatformOrgTeam(
organizationId,
oAuthClientId,
@@ -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;
}
}
@@ -7,6 +7,9 @@ const ensureUserLevelWhere = {
eventTypeId: null,
};
export const NO_SELECTED_CALENDAR_FOUND = "No SelectedCalendar found.";
export const MULTIPLE_SELECTED_CALENDARS_FOUND = "Multiple SelecteCalendars found. Skipping deletion";
@Injectable()
export class SelectedCalendarsRepository {
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
@@ -113,11 +116,11 @@ export class SelectedCalendarsRepository {
// Make the behaviour same as .delete which throws error if no record is found
if (records.length === 0) {
throw new Error("No SelectedCalendar found.");
throw new Error(NO_SELECTED_CALENDAR_FOUND);
}
if (records.length > 1) {
throw new Error("Multiple SelecteCalendars found. Skipping deletion");
throw new Error(MULTIPLE_SELECTED_CALENDARS_FOUND);
}
return await this.dbWrite.prisma.selectedCalendar.delete({
@@ -5,9 +5,13 @@ import {
SelectedCalendarsInputDto,
SelectedCalendarsQueryParamsInputDto,
} from "@/modules/selected-calendars/inputs/selected-calendars.input";
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
import {
MULTIPLE_SELECTED_CALENDARS_FOUND,
NO_SELECTED_CALENDAR_FOUND,
SelectedCalendarsRepository,
} from "@/modules/selected-calendars/selected-calendars.repository";
import { UserWithProfile } from "@/modules/users/users.repository";
import { Injectable, NotFoundException } from "@nestjs/common";
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import { SelectedCalendarRepository } from "@calcom/platform-libraries";
@@ -111,13 +115,23 @@ export class SelectedCalendarsService {
}
}
const removedCalendarEntry = await this.selectedCalendarsRepository.removeUserSelectedCalendar(
user.id,
integration,
externalId,
delegationCredentialId
);
try {
const removedCalendarEntry = await this.selectedCalendarsRepository.removeUserSelectedCalendar(
user.id,
integration,
externalId,
delegationCredentialId
);
return removedCalendarEntry;
return removedCalendarEntry;
} catch (error) {
if (error instanceof Error) {
if (error.message === NO_SELECTED_CALENDAR_FOUND) {
throw new NotFoundException(NO_SELECTED_CALENDAR_FOUND);
} else if (error.message === MULTIPLE_SELECTED_CALENDARS_FOUND) {
throw new BadRequestException(MULTIPLE_SELECTED_CALENDARS_FOUND);
}
}
}
}
}
@@ -61,7 +61,7 @@ export class SlotsController_2024_09_04 {
summary: "Find out when is an event type ready to be booked.",
description: `
There are 4 ways to get available slots for event type of an individual user:
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'
2. By event type slug + username. Example '/v2/slots?eventTypeSlug=intro&username=bob&start=2050-09-05&end=2050-09-06'
@@ -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'
@@ -109,9 +109,9 @@ export class SlotsController_2024_09_04 {
name: "usernames",
required: false,
description: `The usernames for which available slots should be checked separated by a comma.
Checking slots by usernames is used mainly for dynamic events where there is no specific event but we just want to know when 2 or more people are available.
Must contain at least 2 usernames.`,
example: "alice,bob",
})
@@ -154,9 +154,9 @@ export class SlotsController_2024_09_04 {
required: true,
description: `
Time until which available slots should be checked.
Must be in UTC timezone as ISO 8601 datestring.
You can pass date without hours which defaults to end of day or specify hours:
2024-08-20 (will have hours 23:59:59 aka at the very end of the date) or you can specify hours manually like 2024-08-20T18:00:00Z.`,
example: "2050-09-06",
@@ -166,9 +166,9 @@ export class SlotsController_2024_09_04 {
required: true,
description: `
Time starting from which available slots should be checked.
Must be in UTC timezone as ISO 8601 datestring.
You can pass date without hours which defaults to start of day or specify hours:
2024-08-13 (will have hours 00:00:00 aka at very beginning of the date) or you can specify hours manually like 2024-08-13T09:00:00Z.`,
example: "2050-09-05",
@@ -300,6 +300,9 @@ export class SlotsController_2024_09_04 {
}
@Delete("/reservations/:uid")
@ApiOperation({
summary: "Delete a reserved slot",
})
@HttpCode(HttpStatus.OK)
@DocsResponse({
status: 200,
@@ -20,6 +20,7 @@ import { UserRepositoryFixture } from "test/fixtures/repository/users.repository
import { randomString } from "test/utils/randomString";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { slugify } from "@calcom/platform-libraries";
import { TeamOutputDto } from "@calcom/platform-types";
describe("Teams endpoint", () => {
@@ -75,7 +76,7 @@ describe("Teams endpoint", () => {
describe("User has membership in created team", () => {
it("should create first team", async () => {
const body: CreateTeamInput = {
name: `teams-dog-${randomString()}`,
name: `teams dog ${randomString()}`,
metadata: {
teamKey: "teamValue",
},
@@ -93,6 +94,7 @@ describe("Teams endpoint", () => {
expect(responseData).toBeDefined();
expect(responseData.id).toBeDefined();
expect(responseData.name).toEqual(body.name);
expect(responseData.slug).toEqual(slugify(body.name));
expect(responseData.metadata).toEqual(body.metadata);
team1 = responseData;
});
@@ -11,7 +11,11 @@ export class CreateTeamInput {
@IsOptional()
@IsString()
@ApiPropertyOptional({ type: String, description: "Team slug", example: "caltel" })
@ApiPropertyOptional({
type: String,
description: "Team slug in kebab-case - if not provided will be generated automatically based on name.",
example: "caltel",
})
readonly slug?: string;
@IsOptional()
@@ -6,6 +6,8 @@ import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
import { BadRequestException, Injectable, InternalServerErrorException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { slugify } from "@calcom/platform-libraries";
@Injectable()
export class TeamsService {
private isTeamBillingEnabled = this.configService.get("stripe.isTeamBillingEnabled");
@@ -19,6 +21,9 @@ export class TeamsService {
async createTeam(input: CreateTeamInput, ownerId: number) {
const { autoAcceptCreator, ...teamData } = input;
if (!teamData.slug) {
teamData.slug = slugify(teamData.name);
}
const existingTeam = await this.teamsMembershipsRepository.findTeamMembershipsByNameAndUser(
input.name,
@@ -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;
}
}
+45 -8
View File
@@ -7697,7 +7697,7 @@
"/v2/calendars/{calendar}/save": {
"get": {
"operationId": "CalendarsController_save",
"summary": "Save an oAuth calendar credentials",
"summary": "Save Google or Outlook calendar credentials",
"parameters": [
{
"name": "state",
@@ -7741,7 +7741,7 @@
"/v2/calendars/{calendar}/credentials": {
"post": {
"operationId": "CalendarsController_syncCredentials",
"summary": "Sync credentials",
"summary": "Save Apple calendar credentials",
"parameters": [
{
"name": "calendar",
@@ -7764,6 +7764,16 @@
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateCalendarCredentialsInput"
}
}
}
},
"responses": {
"201": {
"description": ""
@@ -9942,7 +9952,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",
@@ -9958,7 +9968,7 @@
"name": "start",
"required": true,
"in": "query",
"description": "\n Time starting from which available slots should be checked.\n \n Must be in UTC timezone as ISO 8601 datestring.\n \n You can pass date without hours which defaults to start of day or specify hours:\n 2024-08-13 (will have hours 00:00:00 aka at very beginning of the date) or you can specify hours manually like 2024-08-13T09:00:00Z.",
"description": "\n Time starting from which available slots should be checked.\n\n Must be in UTC timezone as ISO 8601 datestring.\n\n You can pass date without hours which defaults to start of day or specify hours:\n 2024-08-13 (will have hours 00:00:00 aka at very beginning of the date) or you can specify hours manually like 2024-08-13T09:00:00Z.",
"example": "2050-09-05",
"schema": {}
},
@@ -9966,7 +9976,7 @@
"name": "end",
"required": true,
"in": "query",
"description": "\n Time until which available slots should be checked.\n \n Must be in UTC timezone as ISO 8601 datestring.\n \n You can pass date without hours which defaults to end of day or specify hours:\n 2024-08-20 (will have hours 23:59:59 aka at the very end of the date) or you can specify hours manually like 2024-08-20T18:00:00Z.",
"description": "\n Time until which available slots should be checked.\n\n Must be in UTC timezone as ISO 8601 datestring.\n\n You can pass date without hours which defaults to end of day or specify hours:\n 2024-08-20 (will have hours 23:59:59 aka at the very end of the date) or you can specify hours manually like 2024-08-20T18:00:00Z.",
"example": "2050-09-06",
"schema": {}
},
@@ -10014,7 +10024,7 @@
"name": "usernames",
"required": false,
"in": "query",
"description": "The usernames for which available slots should be checked separated by a comma.\n \n Checking slots by usernames is used mainly for dynamic events where there is no specific event but we just want to know when 2 or more people are available.\n \n Must contain at least 2 usernames.",
"description": "The usernames for which available slots should be checked separated by a comma.\n\n Checking slots by usernames is used mainly for dynamic events where there is no specific event but we just want to know when 2 or more people are available.\n\n Must contain at least 2 usernames.",
"example": "alice,bob",
"schema": {}
},
@@ -10288,6 +10298,7 @@
},
"delete": {
"operationId": "SlotsController_2024_09_04_deleteReservedSlot",
"summary": "Delete a reserved slot",
"parameters": [
{
"name": "cal-api-version",
@@ -16154,7 +16165,7 @@
},
"slug": {
"type": "string",
"description": "Team slug",
"description": "Team slug in kebab-case - if not provided will be generated automatically based on name.",
"example": "caltel"
},
"logoUrl": {
@@ -19353,7 +19364,7 @@
},
"slug": {
"type": "string",
"description": "Team slug",
"description": "Team slug in kebab-case - if not provided will be generated automatically based on name.",
"example": "caltel"
},
"logoUrl": {
@@ -20177,6 +20188,21 @@
"data"
]
},
"CreateCalendarCredentialsInput": {
"type": "object",
"properties": {
"username": {
"type": "string"
},
"password": {
"type": "string"
}
},
"required": [
"username",
"password"
]
},
"DeleteCalendarCredentialsInputBodyDto": {
"type": "object",
"properties": {
@@ -20266,6 +20292,11 @@
"description": "Name of the organization",
"example": "CalTeam"
},
"slug": {
"type": "string",
"description": "Organization slug in kebab-case - if not provided will be generated automatically based on name.",
"example": "cal-tel"
},
"metadata": {
"type": "object",
"description": "You can store any additional data you want here.\nMetadata must have at most 50 keys, each key up to 40 characters.\nValues can be strings (up to 500 characters), numbers, or booleans.",
@@ -20288,6 +20319,9 @@
"type": "string",
"minLength": 1
},
"slug": {
"type": "string"
},
"metadata": {
"type": "object",
"example": {
@@ -20334,6 +20368,9 @@
"type": "string",
"minLength": 1
},
"slug": {
"type": "string"
},
"metadata": {
"type": "object",
"example": {
@@ -0,0 +1,7 @@
"use server";
import { revalidatePath } from "next/cache";
export async function revalidateAvailabilityList() {
revalidatePath("/availability");
}
@@ -0,0 +1,7 @@
"use client";
import { Alert } from "@calcom/ui/components/alert";
export default function Error() {
return <Alert severity="error" title="Something went wrong" />;
}
@@ -0,0 +1,5 @@
import AvailabilityLoader from "app/(use-page-wrapper)/(main-nav)/availability/skeleton";
export default function Loading() {
return <AvailabilityLoader />;
}
@@ -1,11 +1,13 @@
import { createRouterCaller } from "app/_trpc/context";
import type { PageProps } from "app/_types";
import { _generateMetadata, getTranslate } from "app/_utils";
// import { cookies, headers } from "next/headers";
import { notFound } from "next/navigation";
// import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
// import { buildLegacyRequest } from "@lib/buildLegacyCtx";
// import { OrganizationRepository } from "@calcom/lib/server/repository/organization";
import AvailabilityPage, { AvailabilityCTA } from "~/availability/availability-view";
import { AvailabilitySliderTable } from "@calcom/features/timezone-buddy/components/AvailabilitySliderTable";
import { OrganizationRepository } from "@calcom/lib/server/repository/organization";
import { availabilityRouter } from "@calcom/trpc/server/routers/viewer/availability/_router";
import { meRouter } from "@calcom/trpc/server/routers/viewer/me/_router";
import { AvailabilityList, AvailabilityCTA } from "~/availability/availability-view";
import { ShellMainAppDir } from "../ShellMainAppDir";
@@ -19,31 +21,43 @@ export const generateMetadata = async () => {
);
};
const Page = async () => {
// const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
// const userId = session?.user?.id;
// const orgId = session?.user?.org?.id;
// if (!userId || !orgId) {
// notFound();
// }
const Page = async ({ searchParams: _searchParams }: PageProps) => {
const searchParams = await _searchParams;
const t = await getTranslate();
try {
// const currentOrg = await OrganizationRepository.findCurrentOrg({
// orgId,
// userId,
// });
const t = await getTranslate();
return (
<ShellMainAppDir
heading={t("availability")}
subtitle={t("configure_availability")}
CTA={<AvailabilityCTA />}>
<AvailabilityPage />
</ShellMainAppDir>
);
} catch {
notFound();
}
const [meCaller, availabilityCaller] = await Promise.all([
createRouterCaller(meRouter),
createRouterCaller(availabilityRouter),
]);
const [me, availabilities] = await Promise.all([meCaller.get(), availabilityCaller.list()]);
const organizationId = me.organization?.id ?? me.profiles[0]?.organizationId;
const isOrgPrivate = organizationId
? await OrganizationRepository.checkIfPrivate({
orgId: organizationId,
})
: false;
const canViewTeamAvailability = me.organization?.isOrgAdmin || !isOrgPrivate;
return (
<ShellMainAppDir
heading={t("availability")}
subtitle={t("configure_availability")}
CTA={
<AvailabilityCTA
toggleGroupOptions={[
{ value: "mine", label: t("my_availability") },
...(canViewTeamAvailability ? [{ value: "team", label: t("team_availability") }] : []),
]}
/>
}>
{searchParams?.type === "team" && canViewTeamAvailability ? (
<AvailabilitySliderTable userTimeFormat={me?.timeFormat ?? null} isOrg={!!organizationId} />
) : (
<AvailabilityList availabilities={availabilities ?? { schedules: [] }} me={me} />
)}
</ShellMainAppDir>
);
};
export default Page;
@@ -0,0 +1,25 @@
"use client";
import { ShellMainAppDir } from "app/(use-page-wrapper)/(main-nav)/ShellMainAppDir";
import SkeletonLoader from "@calcom/features/availability/components/SkeletonLoader";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { AvailabilityCTA } from "~/availability/availability-view";
export default function AvailabilityLoader() {
const { t } = useLocale();
const toggleGroupOptions = [
{ value: "mine", label: t("my_availability") },
{ value: "team", label: t("team_availability") },
];
return (
<ShellMainAppDir
heading={t("availability")}
subtitle={t("configure_availability")}
CTA={<AvailabilityCTA toggleGroupOptions={toggleGroupOptions} />}>
<SkeletonLoader />
</ShellMainAppDir>
);
}
@@ -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,7 +8,7 @@ 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(
@@ -30,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>
);
};
@@ -0,0 +1,5 @@
import { AppHomePageSkeleton } from "app/(use-page-wrapper)/apps/(homepage)/skeleton";
export default function Loading() {
return <AppHomePageSkeleton />;
}
@@ -1,15 +1,28 @@
import type { GetServerSidePropsContext } from "next";
import { _generateMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
import { getAppRegistry, getAppRegistryWithCredentials } from "@calcom/app-store/_appRegistry";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { UserRepository } from "@calcom/lib/server/repository/user";
import type { AppCategories } from "@calcom/prisma/enums";
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const { req } = context;
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import AppsPage from "~/apps/apps-view";
export const generateMetadata = async () => {
return await _generateMetadata(
(t) => t("app_store"),
(t) => t("app_store_description"),
undefined,
undefined,
"/apps"
);
};
const ServerPage = async () => {
const req = buildLegacyRequest(await headers(), await cookies());
const session = await getServerSession({ req });
let appStore, userAdminTeamsIds: number[];
if (session?.user?.id) {
const userAdminTeams = await UserRepository.getUserAdminTeams(session.user.id);
@@ -30,18 +43,20 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
return c;
}, {} as Record<string, number>);
return {
props: {
categories: Object.entries(categories)
.map(([name, count]): { name: AppCategories; count: number } => ({
name: name as AppCategories,
count,
}))
.sort(function (a, b) {
return b.count - a.count;
}),
appStore,
userAdminTeams: userAdminTeamsIds,
},
const props = {
categories: Object.entries(categories)
.map(([name, count]): { name: AppCategories; count: number } => ({
name: name as AppCategories,
count,
}))
.sort(function (a, b) {
return b.count - a.count;
}),
appStore,
userAdminTeams: userAdminTeamsIds,
};
return <AppsPage {...props} isAdmin={session?.user?.role === "ADMIN"} />;
};
export default ServerPage;
@@ -0,0 +1,106 @@
"use client";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { SkeletonText, SkeletonContainer, SkeletonButton } from "@calcom/ui/components/skeleton";
import AppsLayout from "@components/apps/layouts/AppsLayout";
export function AppHomePageSkeleton() {
const { t } = useLocale();
return (
<AppsLayout
isPublic
heading={t("app_store")}
subtitle={t("app_store_description")}
headerClassName="sm:hidden lg:block hidden"
isAdmin={false}>
<SkeletonContainer>
<div className="flex flex-col gap-y-8">
{/* Featured Categories section */}
<div className="mb-4">
<div className="flex items-center justify-between">
<SkeletonText className="h-6 w-40 font-semibold" />
<div className="flex">
<SkeletonButton className="mx-1 h-8 w-8 rounded-md" /> {/* Left arrow */}
<SkeletonButton className="mx-1 h-8 w-8 rounded-md" /> {/* Right arrow */}
</div>
</div>
<div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5">
{/* Category cards - 5 items based on screenshot */}
{Array(5)
.fill(0)
.map((_, i) => (
<div
key={`category-${i}`}
className="bg-subtle flex h-48 flex-col items-center justify-center rounded-md p-4">
<div className="mb-3 h-14 w-14 rounded-full bg-gray-200" /> {/* Icon placeholder */}
<SkeletonText className="h-5 w-24 text-center" /> {/* Category name */}
<SkeletonText className="mt-1 h-4 w-16 text-center" /> {/* Apps count */}
</div>
))}
</div>
</div>
{/* Most Popular section */}
<div className="mb-4">
<div className="flex items-center justify-between">
<SkeletonText className="h-6 w-40 font-semibold" />
<div className="flex">
<SkeletonButton className="mx-1 h-8 w-8 rounded-md" /> {/* Left arrow */}
<SkeletonButton className="mx-1 h-8 w-8 rounded-md" /> {/* Right arrow */}
</div>
</div>
<div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3">
{/* App cards - 3 items */}
{Array(3)
.fill(0)
.map((_, i) => (
<div key={`app-${i}`} className="border-subtle flex flex-col rounded-md border p-4">
<div className="flex items-center">
<div className="mr-3 h-10 w-10 rounded-md bg-gray-200" /> {/* App icon */}
<div className="flex-1">
<SkeletonText className="h-5 w-32" /> {/* App name */}
<SkeletonText className="mt-1 h-4 w-24" /> {/* Developer name */}
</div>
</div>
<SkeletonText className="mt-3 h-4 w-full" /> {/* Description line 1 */}
<SkeletonText className="mt-2 h-4 w-3/4" /> {/* Description line 2 */}
<div className="mt-4 flex justify-between">
<SkeletonButton className="h-9 w-24 rounded-md" /> {/* Details button */}
</div>
</div>
))}
</div>
</div>
{/* All apps section */}
<div className="mt-4">
<SkeletonText className="h-6 w-32 font-semibold" />
<div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3">
{/* App cards - 6 items */}
{Array(6)
.fill(0)
.map((_, i) => (
<div key={`all-app-${i}`} className="border-subtle flex flex-col rounded-md border p-4">
<div className="flex items-center">
<div className="mr-3 h-10 w-10 rounded-md bg-gray-200" /> {/* App icon */}
<div className="flex-1">
<SkeletonText className="h-5 w-32" /> {/* App name */}
<SkeletonText className="mt-1 h-4 w-24" /> {/* Developer name */}
</div>
</div>
<SkeletonText className="mt-3 h-4 w-full" /> {/* Description line 1 */}
<SkeletonText className="mt-2 h-4 w-3/4" /> {/* Description line 2 */}
<div className="mt-4 flex justify-between">
<SkeletonButton className="h-9 w-24 rounded-md" /> {/* Details button */}
</div>
</div>
))}
</div>
</div>
</div>
</SkeletonContainer>
</AppsLayout>
);
}
@@ -1,30 +0,0 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
import { getServerSideProps } from "@lib/apps/getServerSideProps";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import AppsPage from "~/apps/apps-view";
export const generateMetadata = async () => {
return await _generateMetadata(
(t) => t("app_store"),
(t) => t("app_store_description"),
undefined,
undefined,
"/apps"
);
};
const getData = withAppDirSsr(getServerSideProps);
const ServerPage = async ({ params, searchParams }: PageProps) => {
const context = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
const props = await getData(context);
return <AppsPage {...props} />;
};
export default ServerPage;
@@ -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,26 +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/${parsed.data.schedule}`
"/availability"
);
};
@@ -50,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>
);
}
@@ -0,0 +1,7 @@
"use client";
import { Alert } from "@calcom/ui/components/alert";
export default function Error() {
return <Alert severity="error" title="Something went wrong" />;
}
@@ -1,6 +1,5 @@
import { type Params } from "app/_types";
import { _generateMetadata, getTranslate } from "app/_utils";
import { notFound } from "next/navigation";
import { z } from "zod";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
@@ -36,23 +35,17 @@ export const generateMetadata = async ({ params }: { params: Params }) => {
const Page = async ({ params }: { params: Params }) => {
const input = orgIdSchema.safeParse(await params);
if (!input.success) notFound();
if (!input.success) throw new Error("Invalid access");
try {
const org = await OrganizationRepository.adminFindById({ id: input.data.id });
const t = await getTranslate();
return (
<SettingsHeader
title={`${t("editing_org")}: ${org.name}`}
description={t("admin_orgs_edit_description")}>
<LicenseRequired>
<OrgForm org={org} />
</LicenseRequired>
</SettingsHeader>
);
} catch {
notFound();
}
const org = await OrganizationRepository.adminFindById({ id: input.data.id });
const t = await getTranslate();
return (
<SettingsHeader title={`${t("editing_org")}: ${org.name}`} description={t("admin_orgs_edit_description")}>
<LicenseRequired>
<OrgForm org={org} />
</LicenseRequired>
</SettingsHeader>
);
};
export default Page;
@@ -0,0 +1,7 @@
"use client";
import { Alert } from "@calcom/ui/components/alert";
export default function Error() {
return <Alert severity="error" title="Something went wrong" />;
}
@@ -1,6 +1,5 @@
import { type Params } from "app/_types";
import { _generateMetadata, getTranslate } from "app/_utils";
import { notFound } from "next/navigation";
import { z } from "zod";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
@@ -36,24 +35,18 @@ export const generateMetadata = async ({ params }: { params: Params }) => {
const Page = async ({ params }: { params: Params }) => {
const input = userIdSchema.safeParse(await params);
if (!input.success) {
notFound();
}
if (!input.success) throw new Error("Invalid access");
try {
const user = await UserRepository.adminFindById(input.data.id);
const t = await getTranslate();
const user = await UserRepository.adminFindById(input.data.id);
const t = await getTranslate();
return (
<SettingsHeader title={t("editing_user")} description={t("admin_users_edit_description")}>
<LicenseRequired>
<UsersEditView user={user} />
</LicenseRequired>
</SettingsHeader>
);
} catch {
notFound();
}
return (
<SettingsHeader title={t("editing_user")} description={t("admin_users_edit_description")}>
<LicenseRequired>
<UsersEditView user={user} />
</LicenseRequired>
</SettingsHeader>
);
};
export default Page;
@@ -16,7 +16,6 @@ import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { OrganizationRepository } from "@calcom/lib/server/repository/organization";
import { IdentityProvider, UserPermissionRole } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
import classNames from "@calcom/ui/classNames";
@@ -255,7 +254,7 @@ const useTabs = ({ isDelegationCredentialEnabled }: { isDelegationCredentialEnab
const BackButtonInSidebar = ({ name }: { name: string }) => {
return (
<Link
href="/"
href="/event-types"
className="hover:bg-subtle todesktop:mt-10 [&[aria-current='page']]:bg-emphasis [&[aria-current='page']]:text-emphasis group-hover:text-default text-emphasis group my-6 flex h-6 max-h-6 w-full flex-row items-center rounded-md px-3 py-2 text-sm font-medium leading-4 transition"
data-testid={`vertical-tab-${name}`}>
<Icon
@@ -273,8 +272,6 @@ interface SettingsSidebarContainerProps {
className?: string;
navigationIsOpenedOnMobile?: boolean;
bannersHeight?: number;
currentOrg: SettingsLayoutProps["currentOrg"];
otherTeams: SettingsLayoutProps["otherTeams"];
}
const TeamListCollapsible = () => {
@@ -441,10 +438,9 @@ const SettingsSidebarContainer = ({
className = "",
navigationIsOpenedOnMobile,
bannersHeight,
currentOrg: currentOrgProp,
otherTeams: otherTeamsProp,
}: SettingsSidebarContainerProps) => {
const searchParams = useCompatSearchParams();
const orgBranding = useOrgBranding();
const { t } = useLocale();
const [otherTeamMenuState, setOtherTeamMenuState] = useState<
{
@@ -453,19 +449,17 @@ const SettingsSidebarContainer = ({
}[]
>();
const session = useSession();
const { data: _currentOrg } = trpc.viewer.organizations.listCurrent.useQuery(undefined, {
enabled: !!session.data?.user?.org && !currentOrgProp,
const { data } = trpc.viewer.delegationCredential.check.useQuery(undefined, {
enabled: !!session.data?.user?.org,
});
const tabsWithPermissions = useTabs({
isDelegationCredentialEnabled: !!_currentOrg?.features?.delegationCredential,
isDelegationCredentialEnabled: data?.hasDelegationCredential ?? false,
});
const { data: _otherTeams } = trpc.viewer.organizations.listOtherTeams.useQuery(undefined, {
enabled: !!session.data?.user?.org && !otherTeamsProp,
const { data: otherTeams } = trpc.viewer.organizations.listOtherTeams.useQuery(undefined, {
enabled: !!session.data?.user?.org,
});
const currentOrg = currentOrgProp ?? _currentOrg;
const otherTeams = otherTeamsProp ?? _otherTeams;
// Same as above but for otherTeams
useEffect(() => {
if (otherTeams) {
@@ -485,8 +479,7 @@ const SettingsSidebarContainer = ({
}
}, [searchParams?.get("id"), otherTeams]);
const isOrgAdminOrOwner =
currentOrg && currentOrg?.user?.role && ["OWNER", "ADMIN"].includes(currentOrg?.user?.role);
const isOrgAdminOrOwner = checkAdminOrOwner(orgBranding?.role);
return (
<nav
@@ -569,7 +562,7 @@ const SettingsSidebarContainer = ({
</div>
</Link>
<TeamListCollapsible />
{(!currentOrg || (currentOrg && currentOrg?.user?.role !== "MEMBER")) && (
{(!orgBranding?.id || isOrgAdminOrOwner) && (
<VerticalTabItem
name={t("add_a_team")}
href={`${WEBAPP_URL}/settings/teams/new`}
@@ -733,17 +726,10 @@ const MobileSettingsContainer = (props: { onSideContainerOpen?: () => void }) =>
export type SettingsLayoutProps = {
children: React.ReactNode;
currentOrg: Awaited<ReturnType<typeof OrganizationRepository.findCurrentOrg>> | null;
otherTeams: Awaited<ReturnType<typeof OrganizationRepository.findTeamsInOrgIamNotPartOf>> | null;
containerClassName?: string;
} & ComponentProps<typeof Shell>;
export default function SettingsLayoutAppDirClient({
children,
currentOrg,
otherTeams,
...rest
}: SettingsLayoutProps) {
export default function SettingsLayoutAppDirClient({ children, ...rest }: SettingsLayoutProps) {
const pathname = usePathname();
const state = useState(false);
const [sideContainerOpen, setSideContainerOpen] = state;
@@ -773,8 +759,6 @@ export default function SettingsLayoutAppDirClient({
{...rest}
SidebarContainer={
<SidebarContainerElement
currentOrg={currentOrg}
otherTeams={otherTeams}
sideContainerOpen={sideContainerOpen}
setSideContainerOpen={setSideContainerOpen}
/>
@@ -798,8 +782,6 @@ const SidebarContainerElement = ({
sideContainerOpen,
bannersHeight,
setSideContainerOpen,
currentOrg,
otherTeams,
}: SidebarContainerElementProps) => {
const { t } = useLocale();
return (
@@ -815,8 +797,6 @@ const SidebarContainerElement = ({
<SettingsSidebarContainer
navigationIsOpenedOnMobile={sideContainerOpen}
bannersHeight={bannersHeight}
currentOrg={currentOrg}
otherTeams={otherTeams}
/>
</>
);
@@ -826,6 +806,4 @@ type SidebarContainerElementProps = {
sideContainerOpen: boolean;
bannersHeight?: number;
setSideContainerOpen: React.Dispatch<React.SetStateAction<boolean>>;
currentOrg: SettingsLayoutProps["currentOrg"];
otherTeams: SettingsLayoutProps["otherTeams"];
};
@@ -0,0 +1,7 @@
"use server";
import { revalidatePath } from "next/cache";
export async function revalidateApiKeysList() {
revalidatePath("/settings/developer/api-keys");
}
@@ -0,0 +1,5 @@
import { SkeletonLoader } from "~/settings/developer/api-keys-skeleton";
export default function Loading() {
return <SkeletonLoader />;
}
@@ -1,9 +1,10 @@
import { getTranslate, _generateMetadata } from "app/_utils";
import { createRouterCaller } from "app/_trpc/context";
import { _generateMetadata } from "app/_utils";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { APP_NAME } from "@calcom/lib/constants";
import { apiKeysRouter } from "@calcom/trpc/server/routers/viewer/apiKeys/_router";
import ApiKeysView, { NewApiKeyButton } from "~/settings/developer/api-keys-view";
import ApiKeysView from "~/settings/developer/api-keys-view";
export const generateMetadata = async () =>
await _generateMetadata(
@@ -15,17 +16,9 @@ export const generateMetadata = async () =>
);
const Page = async () => {
const t = await getTranslate();
return (
<SettingsHeader
title={t("api_keys")}
description={t("create_first_api_key_description", { appName: APP_NAME })}
CTA={<NewApiKeyButton />}
borderInShellHeader={true}>
<ApiKeysView />
</SettingsHeader>
);
const caller = await createRouterCaller(apiKeysRouter);
const apiKeys = await caller.list();
return <ApiKeysView apiKeys={apiKeys} />;
};
export default Page;
@@ -1,38 +1,24 @@
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import React from "react";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { OrganizationRepository } from "@calcom/lib/server/repository/organization";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import type { SettingsLayoutProps } from "./SettingsLayoutAppDirClient";
import SettingsLayoutAppDirClient from "./SettingsLayoutAppDirClient";
type SettingsLayoutAppDirProps = Omit<SettingsLayoutProps, "currentOrg" | "otherTeams">;
export default async function SettingsLayoutAppDir(props: SettingsLayoutAppDirProps) {
export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
const userId = session?.user?.id ?? -1;
const orgId = session?.user?.org?.id ?? -1;
let currentOrg = null;
let otherTeams = null;
try {
currentOrg = await OrganizationRepository.findCurrentOrg({ userId, orgId });
} catch (err) {}
try {
otherTeams = await OrganizationRepository.findTeamsInOrgIamNotPartOf({
userId,
parentId: orgId,
});
} catch (err) {}
const userId = session?.user?.id;
if (!userId) {
return redirect("/auth/login");
}
return (
<>
<SettingsLayoutAppDirClient {...props} currentOrg={currentOrg} otherTeams={otherTeams} />
<SettingsLayoutAppDirClient {...props} />
</>
);
}
@@ -0,0 +1,5 @@
import { SkeletonLoader } from "./skeleton";
export default function Loading() {
return <SkeletonLoader />;
}
@@ -0,0 +1,43 @@
import { _generateMetadata, getTranslate } from "app/_utils";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { OtherTeamsListing } from "@calcom/features/ee/organizations/pages/components/OtherTeamsListing";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { OrganizationRepository } from "@calcom/lib/server/repository/organization";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("org_admin_other_teams"),
(t) => t("org_admin_other_teams_description"),
undefined,
undefined,
"/settings/organizations/teams/other"
);
const Page = async () => {
const t = await getTranslate();
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user?.id) {
redirect("/auth/login");
}
const organizationId = session?.user?.org?.id;
const otherTeams = organizationId
? await OrganizationRepository.findTeamsInOrgIamNotPartOf({
userId: session?.user.id,
parentId: organizationId,
})
: [];
return (
<SettingsHeader title={t("org_admin_other_teams")} description={t("org_admin_other_teams_description")}>
<OtherTeamsListing teams={otherTeams} />
</SettingsHeader>
);
};
export default Page;
@@ -0,0 +1,14 @@
"use client";
import SkeletonLoaderTeamList from "@calcom/features/ee/teams/components/SkeletonloaderTeamList";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { useLocale } from "@calcom/lib/hooks/useLocale";
export function SkeletonLoader() {
const { t } = useLocale();
return (
<SettingsHeader title={t("org_admin_other_teams")} description={t("org_admin_other_teams_description")}>
<SkeletonLoaderTeamList />
</SettingsHeader>
);
}
@@ -1,25 +0,0 @@
import { _generateMetadata, getTranslate } from "app/_utils";
import LegacyPage from "@calcom/features/ee/organizations/pages/settings/other-team-listing-view";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("org_admin_other_teams"),
(t) => t("org_admin_other_teams_description"),
undefined,
undefined,
"/settings/organizations/teams/other"
);
const Page = async () => {
const t = await getTranslate();
return (
<SettingsHeader title={t("org_admin_other_teams")} description={t("org_admin_other_teams_description")}>
<LegacyPage />
</SettingsHeader>
);
};
export default Page;
@@ -1,25 +0,0 @@
import { _generateMetadata, getTranslate } from "app/_utils";
import LegacyPage from "@calcom/features/ee/teams/pages/team-listing-view";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("teams"),
(t) => t("create_manage_teams_collaborative"),
undefined,
undefined,
"/settings/teams"
);
const Page = async () => {
const t = await getTranslate();
return (
<SettingsHeader title={t("teams")} description={t("create_manage_teams_collaborative")}>
<LegacyPage />
</SettingsHeader>
);
};
export default Page;
@@ -1,4 +1,11 @@
import { _generateMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import ManagedUsersView from "~/settings/platform/managed-users/managed-users-view";
@@ -11,7 +18,14 @@ export const generateMetadata = async () =>
"/settings/platform/managed-users"
);
const ServerPageWrapper = () => {
const ServerPageWrapper = async () => {
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
const callbackUrl = `${WEBAPP_URL}/settings/platform/managed-users`;
if (!session?.user) {
redirect(`/login?callbackUrl=${callbackUrl}`);
}
return <ManagedUsersView />;
};
@@ -1,19 +1,15 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import { getServerSideProps } from "@lib/settings/organizations/new/getServerSideProps";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import LegacyPage, { LayoutWrapper } from "~/settings/platform/new/create-new-view";
type Props = {
isOrg: boolean;
};
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("set_up_your_platform_organization"),
@@ -23,10 +19,14 @@ export const generateMetadata = async () =>
"/settings/platform/new"
);
const getData = withAppDirSsr<Props>(getServerSideProps);
const ServerPage = async () => {
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
const callbackUrl = `${WEBAPP_URL}/settings/platform/new`;
if (!session?.user) {
redirect(`/login?callbackUrl=${callbackUrl}`);
}
const ServerPage = async ({ params, searchParams }: PageProps) => {
await getData(buildLegacyCtx(await headers(), await cookies(), await params, await searchParams));
return (
<LayoutWrapper>
<LicenseRequired>
@@ -1,4 +1,11 @@
import { _generateMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import PlatformPlansView from "~/settings/platform/plans/platform-plans-view";
@@ -12,7 +19,14 @@ export const generateMetadata = async () => {
);
};
const ServerPageWrapper = () => {
const ServerPageWrapper = async () => {
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
const callbackUrl = `${WEBAPP_URL}/settings/platform/plans`;
if (!session?.user) {
redirect(`/login?callbackUrl=${callbackUrl}`);
}
return <PlatformPlansView />;
};
@@ -0,0 +1,7 @@
"use client";
import { Alert } from "@calcom/ui/components/alert";
export default function Error() {
return <Alert severity="error" title="Something went wrong" />;
}
@@ -44,9 +44,7 @@ const Page = async ({ params }: PageProps) => {
// const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
// const user = session?.user;
const parsed = querySchema.safeParse(await params);
if (!parsed.success) {
notFound();
}
if (!parsed.success) throw new Error("Invalid workflow id");
// const workflow = await WorkflowRepository.getById({ id: +parsed.data.workflow });
// let verifiedEmails, verifiedNumbers;
+1 -1
View File
@@ -6,8 +6,8 @@ import { NextResponse } from "next/server";
import { z } from "zod";
import dayjs from "@calcom/dayjs";
import { UserRepository } from "@calcom/lib/server/repository/user";
import { timeZoneSchema } from "@calcom/lib/dayjs/timeZone.schema";
import { UserRepository } from "@calcom/lib/server/repository/user";
import { userMetadata } from "@calcom/prisma/zod-utils";
import { CardComponent } from "@lib/plain/card-components";
@@ -0,0 +1,5 @@
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
import handleSMSResponse from "@calcom/features/ee/workflows/api/handleSMSResponse";
export const POST = defaultResponderForAppDir(handleSMSResponse);
+3 -1
View File
@@ -22,9 +22,11 @@ export const generateMetadata = async () => {
const ServerPage = async () => {
const h = await headers();
const nonce = h.get("x-nonce") ?? undefined;
const host = h.get("x-forwarded-host") ?? "";
return (
<PageWrapper requiresLicense={false} nonce={nonce}>
<NotFound />
<NotFound host={host} />
</PageWrapper>
);
};
+1 -3
View File
@@ -47,11 +47,9 @@ function getPageInfo(pathname: string, host: string) {
}
}
export function NotFound() {
export function NotFound({ host }: { host: string }) {
const { t } = useLocale();
const pathname = usePathname() ?? "";
const host = window.location.hostname;
const { username, pageType, url } = getPageInfo(pathname, host);
const isBookingSuccessPage = pathname?.startsWith("/booking");
const isSubpage = pathname?.includes("/", 2) || isBookingSuccessPage;
@@ -1,4 +1,3 @@
import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import type { ComponentProps } from "react";
import React from "react";
@@ -9,17 +8,15 @@ import { EmptyScreen } from "@calcom/ui/components/empty-screen";
type AppsLayoutProps = {
children: React.ReactNode;
isAdmin: boolean;
actions?: (className?: string) => JSX.Element;
emptyStore?: boolean;
} & Omit<ComponentProps<typeof Shell>, "actions">;
export default function AppsLayout({ children, actions, emptyStore, ...rest }: AppsLayoutProps) {
export default function AppsLayout({ children, actions, emptyStore, isAdmin, ...rest }: AppsLayoutProps) {
const { t } = useLocale();
const session = useSession();
const router = useRouter();
const isAdmin = session.data?.user.role === "ADMIN";
if (session.status === "loading") return <></>;
return (
<Shell {...rest} actions={actions?.("block")}>
<div className="flex flex-col xl:flex-row">

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