Compare commits

...
Author SHA1 Message Date
github-actions[bot] 2965450929 chore: version packages (#22890)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-08-05 06:15:25 +00:00
ddoemonnandKartik Saini db9b9c45dc fix(event-types): remove gap in URL input for event creation (#22123)
* fix(event-types): remove gap in URL input for event creation

* fix: event setup tab spacing

* fix: space in profile view

---------

Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
2025-08-05 03:51:30 +00:00
emrysal 37f500526c chore: release v5.5.11 2025-08-05 00:46:56 +00:00
Eunjae Lee 006aecb066 fix: add backfill migration for routing response denormalized tables (#21474)
* fix: add backfill migration for routing response denormalized tables

* update script

* implement select type

* improve number handling

* clean up queries

* rename

* handle zero record case

* revert

* rename

* fix division problem

* log clean up

* force adding new rows after removing existing rows

* backfill only missing or wrong data

* remove sleep
2025-08-05 01:40:22 +01:00
Amit Sharma 0829929e98 perf: use repository for webhooks list query & caching in /settings/developer/webhooks/... RSC (#21781)
* perf: use repository for webhooks list query & caching in /settings/developer/webhooks/... RSC

* fix type check

* invalidate cache on webhook list item actions

* fix review comment

* fix merge conflicts
2025-08-05 01:25:31 +01:00
a61148cb51 test: add no show webhooks integration tests (#21697)
* test: add no show webhooks integration tests

* fix

* chore

* chore

* chore

---------

Co-authored-by: Benny Joo <sldisek783@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Volnei Munhoz <volnei.munhoz@gmail.com>
2025-08-05 01:22:44 +01:00
Eunjae Lee 593c48c8b4 refactor: replace tremor with recharts (#22791)
* refactor: replace tremor with recharts

* update tooltip

* clean up types

* replace tremor with recharts

* replace BarList with recharts

* replace ProgressBar

* remove tremor from the repository

* clean up

* fix UserStatsTable

* fix type error

* add explicit return type
2025-08-05 01:21:23 +01:00
Lauris Skraucis fb364971fa fix: atoms fonts for non next apps (#22886)
* fix: atoms fonts for non next apps

* chore: add changeset
2025-08-04 16:51:05 +00:00
Eunjae LeeandAlex van Andel a2205d497d feat: add booking status filter to /insights (#22842)
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-08-04 13:48:17 +00:00
Eunjae LeeandDevanshu Sharma cf4e7a5d2c refactor: add useInsightsRoutingParameters() for InsightsRoutingService (#22792)
Co-authored-by: Devanshu Sharma <devanshusharma658@gmail.com>
2025-08-04 14:51:23 +02:00
Anik Dhabal Babu 0e2ee3adb2 chore: add logging for office365 calendar (#22867) 2025-08-04 12:26:57 +00:00
Anik Dhabal Babu dbe8a85cf5 fix: enable auto reording for organizer (#22882) 2025-08-04 12:23:56 +00:00
Anik Dhabal Babu 84210dc1b4 fix: build docs workflow failing in ci (#22883)
* fix: flaky e2e

* fix: build docs failure
2025-08-04 17:36:35 +05:30
Benny Joo 4061bfb3fe fix: refactor i18n loadTranslations and set timeout to 3s (#22878)
* refactor

* use abort controller

* address comment

* fix tests

* fix time

* fix tests

* fix platform library build
2025-08-04 10:30:18 +01:00
github-actions[bot]andLauris Skraucis 18059e50fa chore: version packages (#22813)
* chore: version packages

* Update CHANGELOG.md

* Update package.json

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Lauris Skraucis <lauris.skraucis@gmail.com>
2025-08-04 10:41:55 +02:00
Carina WollendorferandCarinaWolli 852eb346ed fix: minor UI improvements for end of month design (#22837)
* minor UI improvements

* revert classname changes

* remove not needed code

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2025-08-04 03:02:42 +00:00
b7e11dc112 chore: Add logging to handleNewBooking (#22868)
* Add logging

* Apply suggestion from @coderabbitai[bot]

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Update logger config, fix Create booking log

* Added oooExcludedDateRanges + type error fix

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-08-02 17:15:30 +01:00
Syed Ali Shahbaz 35a373e70a docs: Create add-new-booking-charts.mdx (#22865)
* Create add-charts.mdx

* Update mint.json

* Rename add-charts.mdx to add-new-booking-charts.mdx

* Update mint.json
2025-08-01 17:20:27 +00:00
Alex van Andel 02c0b721b9 Revert "fix: refactor i18n loadTranslations and set timeout to 3s (#22633)"
This reverts commit 6ff20b98f4.
2025-08-01 16:42:31 +01:00
Ashish manhas 29349845f8 fix:safe HTML rendering for radio label (#22645) 2025-08-01 15:56:05 +01:00
Eunjae Lee 28cb2cff64 chore: upgrade TypeScript to v5.9 (#22861) 2025-08-01 15:16:11 +01:00
Anik Dhabal Babu 9cb2eb3b8c fix: store attendee phone number on instant booking (#22823)
* fix: flaky e2e

* fix: not storing attendee phone number
2025-08-01 13:34:57 +00:00
Benny Joo 6ff20b98f4 fix: refactor i18n loadTranslations and set timeout to 3s (#22633)
* refactor

* use abort controller

* address comment

* fix tests

* fix time

* fix tests
2025-08-01 22:30:28 +09:00
Hariom Balhara aa618dbaa2 fix: Missing bookingId in BOOKING_CANCELLED webhook payload (#22713)
* send bookingId in BOOKING_CANCELLED through requestReschedule

* fix: make customInputs nullable in BookingWebhookFactory

* fix: make customInputs nullable in BookingWebhookFactory
2025-08-01 06:04:53 +00:00
emrysal 6e3de84067 chore: release v5.5.10
Post release / release (push) Canceled after 0s
2025-08-01 01:08:17 +00:00
Alex van Andel c6b19200a9 chore: Add node_modules/dist to ignore + convert config to .ts (#22852) 2025-08-01 00:02:46 +00:00
Joe Au-Yeung 91932212e3 fix: Cal Video sidebar not showing meeting time in timezone (#22821)
* Pass params as object to `formatToLocalizedTime`

* Refactor videos-single-view

* Refactor bookings-single-view
2025-07-31 16:34:02 -04:00
f81bb92beb fix: increment iCalSequence when changing booking location (#22847)
* fix: increment iCalSequence when changing booking location

- Add iCalSequence parameter to buildCalEventFromBooking function
- Update editLocation handler to increment sequence by 1 in CalendarEvent
- Update database with incremented iCalSequence when location changes
- Update buildCalEventFromBooking test to include new fields
- Ensures ICS files properly update existing calendar events instead of creating duplicates

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* `BookingRepository.updateLocationById` to accept responses and iCalSequence

* `editLocation.handler` use `BookingRepository`

* Update iCalSequence

* Use iCalSequence from booking param

* Add iCalUID to evt object

* Clean up

* Fix test

* Fix test

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-07-31 20:58:48 +01:00
1d1a242a72 refactor: convert getShouldServeCache to CacheService with dependency injection (#22814)
* refactor: convert getShouldServeCache to CacheService with dependency injection

- Create CacheService class following AvailableSlotsService DI pattern
- Add FeaturesRepository and CacheService to DI tokens and modules
- Create cache container with proper dependency injection setup
- Update handleNewBooking.ts and slots/util.ts to use new service
- Maintain backward compatibility with error-throwing wrapper function
- Follow established service patterns for clean architecture

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

* feat: inject CacheService into AvailableSlotsService via dependency injection

- Add cacheService to IAvailableSlotsService interface
- Update available-slots container to load cache modules
- Update available-slots module to inject CacheService dependency
- Replace direct getShouldServeCache call with injected service method
- Add CacheService import to util.ts for proper typing

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

* chore: DI api v2 cache service

* refactor: convert FeaturesRepository to use factory pattern in DI

- Change from constructor injection to factory pattern to avoid PRISMA_CLIENT binding issues in tests
- FeaturesRepository now uses default prisma instance instead of DI injection
- Resolves test failures while maintaining DI container compatibility
- Tests reduced from 123+ failures to only 5 unrelated failures

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

* revert: use direct FeaturesRepository instantiation in most usage points

- Revert getFeaturesRepository() calls back to new FeaturesRepository()
- Tests require direct instantiation for mocking compatibility
- Keep DI container for specific use cases that need dependency injection
- Resolves test failures while maintaining both DI and direct usage patterns

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

* fix: resolve FeaturesRepository DI container issues

- Update cache module to use factory pattern with proper ICacheService interface
- Remove featuresModule loading from cache and available-slots containers
- Use direct FeaturesRepository instantiation via getFeaturesRepository()
- Resolves 'No binding found for key: Symbol(FeaturesRepository)' errors
- Reduces test failures from 125 to 7 (remaining failures appear unrelated)

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

* fix: update all FeaturesRepository instantiations to include prisma parameter

- Add prisma parameter to all new FeaturesRepository() calls across the codebase
- Update API v2 services to match main repo interfaces
- Fix PrismaFeaturesRepository to implement IFeaturesRepository directly
- Update CacheService in API v2 to expose required dependencies and getShouldServeCache
- Implement CheckBookingLimitsService in API v2 with proper interface
- Resolves type assignment errors between API v2 and main repo implementations

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

* fix: add prisma parameter to remaining FeaturesRepository instantiations in apps/web/lib

- Update getServerSideProps files to pass prisma parameter to FeaturesRepository
- Ensures all FeaturesRepository instantiations follow the new constructor pattern
- Completes the refactoring to use direct instantiation with prisma parameter

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

* refactor clean and fix devin issues

* chore: bump platform libs

* chore: bump platform libs

* chore: bump platform libs

* chore: bump platform libs

* fix: missing di

* fix workflow test

* fix workflow test

* fix integration test

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: morgan@cal.com <morgan@cal.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2025-07-31 20:39:27 +03:00
Syed Ali Shahbaz ad4af64036 fix: return rescheduledByEmail in V2 bookings (#22804)
* Update output.service.ts

* Update output.service.ts

* Update output.service.ts

* Update user-bookings.e2e-spec.ts

* fix type err

* Update failing test
2025-07-31 16:27:08 +01:00
+5 2cc7827b0e test: add comprehensive unit tests for handleInstantMeeting (#22820)
* fix: resolve username constraint violation in removeMember

- Update username to - format when removing users from organizations
- Fix unique constraint violation on (username, organizationId) when organizationId is null
- Add test case to verify username format change and successful removal
- Fix skipped test by adding proper imports and removing describe.skip

Co-Authored-By: anik@cal.com <adhabal2002@gmail.com>

* test: update removeMember test to verify constraint violation scenario

- Create two users with same username (one with null orgId, one with orgId)
- Verify removing org user updates username without unique constraint error
- Test ensures username gets updated to - format

Co-Authored-By: anik@cal.com <adhabal2002@gmail.com>

* test: add comprehensive unit tests for handleInstantMeeting

- Revert previous removeMember changes
- Add full test coverage for instant meeting functionality
- Test team validation, booking creation, token generation
- Test webhook triggers and browser notifications
- Mock external dependencies for isolated unit tests
- Translation issue to be addressed in future iteration

Co-Authored-By: anik@cal.com <adhabal2002@gmail.com>

* update

* Update handleInstantMeeting.test.ts

* fix: redir parameter for connect atoms (#22815)

* encode redirect url to make sure it has all parameters

* add changesets

* feat: Support an array response for a field when used as `Value of Field` (#22740)

* Passing tests and fixed

* self review addressed adn more tests

* fix: flaky e2e (#22819)

* fix: merge working hours when adjacent (#21912)

* fix: Adjacency issue when working hours connect over multiple days

* Add tests to validate the new merging of day end logic

* Update to correct annotation.

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* Implement subsequent date ranges for date overrides also

* The map needs to be updated on successful resolve.

* test: add failing test for overlapping ranges with same end time

Demonstrates bug where overlapping working hour ranges (6:00-10:00 and 8:00-10:00)
lose the earlier portion (6:00-8:00), showing only 8:00 and 9:00 slots
instead of all 4 slots (6:00, 7:00, 8:00, 9:00).

Related to Carina's comment on PR #21912.

Co-Authored-By: alex@cal.com <me@alexvanandel.com>

* fix: properly merge overlapping ranges with same end time

Fixes bug where overlapping working hour ranges with the same end time
(e.g., 6:00-10:00 and 8:00-10:00) would lose the earlier portion of the
first range. The merging logic now correctly preserves the earliest
start time when ranges overlap and share the same end time.

This ensures all expected time slots are available (6:00, 7:00, 8:00, 9:00)
instead of losing the earlier slots (6:00, 7:00).

Resolves the issue identified in Carina's comment on PR #21912.

Co-Authored-By: alex@cal.com <me@alexvanandel.com>

* perf: optimize overlapping range detection from O(n²) to O(n)

Replaces Object.keys().find() with Map-based lookup for ranges with same end time.
This optimization handles 2000+ date ranges efficiently, reducing complexity from
4M operations to linear time while maintaining the same merging behavior.

Performance improvement for high-volume event types with many availability ranges.

Co-Authored-By: alex@cal.com <me@alexvanandel.com>

---------

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat: Improving Booking Visibility at Month-End (#22770)

* remove first weeks and add last

* fix added last week

* prefetch availability of next month

* don't switch month

* On hover show month

* only show new UI in monthly view

* show month tooldtip only when needed

* show month on first day of month

* remove isFirstDayOfNextMonth

* fix prefetching next month

* fix datePicker tests

* preventMonthSwitching in monthly view

* add tests

* code clean up

* code clean up

* code clena up for ooo days

* push first day of month

* remove bg color for the month badge

* fix text colour

* remove not needed

* use object param

* revert: use object param

* use object param

* fix DatePicker tests

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Sean Brydon <sean@cal.com>
Co-authored-by: Eunjae Lee <hey@eunjae.dev>

* chore: Refactor logs (#22824)

* Refactor logs

* Add specific info to log

* fix: Errors in org onboarding in some edge cases (#22711)

* Automatically enable migration of a team that conflicts with Orgs slug

* Allow changing the orgs slug and name if user goes back and changes it

* avoid crashing on some scenarios

* Revert "Allow changing the orgs slug and name if user goes back and changes it"

This reverts commit f8872b0116868d59757ef911c4940df79f93c6e0.

* fix: handle unpublished teams gracefully in org migration

- Change 'No oldSlug for team' from error to warning for unpublished teams
- Keep 'No slug for team' as error since org onboarding ensures teams have names
- Add test for migrating unpublished teams without oldSlug
- Add clarifying comments about when each condition can occur

* feat: enable PBAC checking on organization settings page (#22467)

* refactor: convert checkBookingLimits to class service with dependency injection (#22768)

* refactor: convert checkBookingLimits to class service with dependency injection

- Create CheckBookingLimitsService class following AvailableSlotsService pattern
- Add countBookingsByEventTypeAndDateRange method to BookingRepository
- Move direct prisma calls from service to repository layer
- Implement dependency injection with proper DI tokens and modules
- Update all usage points to use the new service through DI
- Maintain backward compatibility with error-throwing wrapper functions
- Update tests to use the new service pattern
- Resolve TODO comment in AvailableSlotsService for DI integration

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

* chore: DI CheckBookingLimitsService in v2 slots service

* chore: bump libraries

* chore: create getCheckBookingLimitsService

* refactor: convert checkBookingAndDurationLimits to service class with DI

- Create CheckBookingAndDurationLimitsService class following DI pattern
- Add DI tokens and module for the new service
- Update booking-limits container to provide the new service
- Refactor handleNewBooking.ts to use service through DI
- Maintain backward compatibility with deprecated function export
- Preserve all existing functionality while improving code organization

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

* chore: CheckBookingAndDurationLimitsService

* chore: bump platform libs

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: morgan@cal.com <morgan@cal.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>

* fix: Return empty available days if error querying calendar (#22828)

* Return a busy block placeholder if calendar throws an error

* Refactor `getCalendarsEvents` to return an object with a success prop

* Throw error in `getBusyTimes` if failed to fetch calendar availability

* Return empty available days if error getting busy times

* yeet.

* Type fix

* Fix type error in getLuckyUsers

* Type fixes

* Type fix

* Type fix

* Fix test

* Fix test mocks

* Refactor calendars.service to use new calendarBusyTimesQuery

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>

* chore: release v5.5.9

* include mobile layout (#22836)

Co-authored-by: CarinaWolli <wollencarina@gmail.com>

* test: fix handleInstantMeeting test with setupAndTeardown and proper mocking

- Add setupAndTeardown() for proper test environment setup
- Use mockNoTranslations() to fix translation function mocking
- Simplify NextApiRequest mock to resolve TypeScript errors
- Both test cases now pass successfully

Co-Authored-By: anik@cal.com <adhabal2002@gmail.com>

* fix typo

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Rajiv Sahal <sahalrajiv-extc@atharvacoe.ac.in>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Sean Brydon <sean@cal.com>
Co-authored-by: Eunjae Lee <hey@eunjae.dev>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
Co-authored-by: morgan@cal.com <morgan@cal.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2025-07-31 10:46:53 +00:00
ef6618743d feat: add optional startTime prop to BookerPlatformWrapper (#22731)
* feat: add optional startTime prop to BookerPlatformWrapper

- Add startTime prop to BookerPlatformWrapperAtomProps interface
- Allow users to override calculated startTime from useTimesForSchedule
- Maintain backward compatibility when prop is not provided
- Add JSDoc documentation for proper usage

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

* feat: add custom start time support to Booker component with date object input

* feat: update documentation and added changelog

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: somay@cal.com <somay@cal.com>
Co-authored-by: Somay Chauhan <somaychauhan98@gmail.com>
2025-07-31 10:25:11 +00:00
Carina WollendorferandCarinaWolli 3601a9adf1 include mobile layout (#22836)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2025-07-31 09:27:56 +00:00
emrysal 0cc2367556 chore: release v5.5.9
Post release / release (push) Canceled after 0s
2025-07-31 01:16:31 +00:00
Joe Au-YeungandAlex van Andel f7b201b0b5 fix: Return empty available days if error querying calendar (#22828)
* Return a busy block placeholder if calendar throws an error

* Refactor `getCalendarsEvents` to return an object with a success prop

* Throw error in `getBusyTimes` if failed to fetch calendar availability

* Return empty available days if error getting busy times

* yeet.

* Type fix

* Fix type error in getLuckyUsers

* Type fixes

* Type fix

* Type fix

* Fix test

* Fix test mocks

* Refactor calendars.service to use new calendarBusyTimesQuery

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-07-31 02:09:09 +01:00
82063cc9a1 refactor: convert checkBookingLimits to class service with dependency injection (#22768)
* refactor: convert checkBookingLimits to class service with dependency injection

- Create CheckBookingLimitsService class following AvailableSlotsService pattern
- Add countBookingsByEventTypeAndDateRange method to BookingRepository
- Move direct prisma calls from service to repository layer
- Implement dependency injection with proper DI tokens and modules
- Update all usage points to use the new service through DI
- Maintain backward compatibility with error-throwing wrapper functions
- Update tests to use the new service pattern
- Resolve TODO comment in AvailableSlotsService for DI integration

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

* chore: DI CheckBookingLimitsService in v2 slots service

* chore: bump libraries

* chore: create getCheckBookingLimitsService

* refactor: convert checkBookingAndDurationLimits to service class with DI

- Create CheckBookingAndDurationLimitsService class following DI pattern
- Add DI tokens and module for the new service
- Update booking-limits container to provide the new service
- Refactor handleNewBooking.ts to use service through DI
- Maintain backward compatibility with deprecated function export
- Preserve all existing functionality while improving code organization

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

* chore: CheckBookingAndDurationLimitsService

* chore: bump platform libs

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: morgan@cal.com <morgan@cal.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2025-07-31 02:03:18 +01:00
sean-brydon 4b2f89d6e8 feat: enable PBAC checking on organization settings page (#22467) 2025-07-31 00:56:25 +00:00
Hariom Balhara 5360cb511d fix: Errors in org onboarding in some edge cases (#22711)
* Automatically enable migration of a team that conflicts with Orgs slug

* Allow changing the orgs slug and name if user goes back and changes it

* avoid crashing on some scenarios

* Revert "Allow changing the orgs slug and name if user goes back and changes it"

This reverts commit f8872b0116868d59757ef911c4940df79f93c6e0.

* fix: handle unpublished teams gracefully in org migration

- Change 'No oldSlug for team' from error to warning for unpublished teams
- Keep 'No slug for team' as error since org onboarding ensures teams have names
- Add test for migrating unpublished teams without oldSlug
- Add clarifying comments about when each condition can occur
2025-07-31 01:53:41 +01:00
Joe Au-Yeung 704bbf2757 chore: Refactor logs (#22824)
* Refactor logs

* Add specific info to log
2025-07-31 01:47:38 +01:00
7fef683abf feat: Improving Booking Visibility at Month-End (#22770)
* remove first weeks and add last

* fix added last week

* prefetch availability of next month

* don't switch month

* On hover show month

* only show new UI in monthly view

* show month tooldtip only when needed

* show month on first day of month

* remove isFirstDayOfNextMonth

* fix prefetching next month

* fix datePicker tests

* preventMonthSwitching in monthly view

* add tests

* code clean up

* code clean up

* code clena up for ooo days

* push first day of month

* remove bg color for the month badge

* fix text colour

* remove not needed

* use object param

* revert: use object param

* use object param

* fix DatePicker tests

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Sean Brydon <sean@cal.com>
Co-authored-by: Eunjae Lee <hey@eunjae.dev>
2025-07-30 16:20:16 +00:00
63df3d9c14 fix: merge working hours when adjacent (#21912)
* fix: Adjacency issue when working hours connect over multiple days

* Add tests to validate the new merging of day end logic

* Update to correct annotation.

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* Implement subsequent date ranges for date overrides also

* The map needs to be updated on successful resolve.

* test: add failing test for overlapping ranges with same end time

Demonstrates bug where overlapping working hour ranges (6:00-10:00 and 8:00-10:00)
lose the earlier portion (6:00-8:00), showing only 8:00 and 9:00 slots
instead of all 4 slots (6:00, 7:00, 8:00, 9:00).

Related to Carina's comment on PR #21912.

Co-Authored-By: alex@cal.com <me@alexvanandel.com>

* fix: properly merge overlapping ranges with same end time

Fixes bug where overlapping working hour ranges with the same end time
(e.g., 6:00-10:00 and 8:00-10:00) would lose the earlier portion of the
first range. The merging logic now correctly preserves the earliest
start time when ranges overlap and share the same end time.

This ensures all expected time slots are available (6:00, 7:00, 8:00, 9:00)
instead of losing the earlier slots (6:00, 7:00).

Resolves the issue identified in Carina's comment on PR #21912.

Co-Authored-By: alex@cal.com <me@alexvanandel.com>

* perf: optimize overlapping range detection from O(n²) to O(n)

Replaces Object.keys().find() with Map-based lookup for ranges with same end time.
This optimization handles 2000+ date ranges efficiently, reducing complexity from
4M operations to linear time while maintaining the same merging behavior.

Performance improvement for high-volume event types with many availability ranges.

Co-Authored-By: alex@cal.com <me@alexvanandel.com>

---------

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-07-30 16:15:40 +00:00
Anik Dhabal Babu 32d2fd51e5 fix: flaky e2e (#22819) 2025-07-30 15:14:16 +00:00
Hariom Balhara 016a966057 feat: Support an array response for a field when used as Value of Field (#22740)
* Passing tests and fixed

* self review addressed adn more tests
2025-07-30 15:07:16 +00:00
Rajiv Sahal dc967cffbe fix: redir parameter for connect atoms (#22815)
* encode redirect url to make sure it has all parameters

* add changesets
2025-07-30 15:00:54 +00:00
4489cb8fde refactor: rename repository files to include Repository suffix (#22717)
* refactor: rename repository files to include Repository suffix

- Rename attribute.ts -> attributeRepository.ts
- Rename attributeOption.ts -> attributeOptionRepository.ts
- Rename attributeToUser.ts -> attributeToUserRepository.ts
- Update all import statements throughout codebase

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

* refactor: rename repository files and classes with Prisma prefix

- Rename attribute.ts -> PrismaAttributeRepository.ts
- Rename attributeOption.ts -> PrismaAttributeOptionRepository.ts
- Rename attributeToUser.ts -> PrismaAttributeToUserRepository.ts
- Update class names to PrismaAttributeRepository, PrismaAttributeOptionRepository, PrismaAttributeToUserRepository
- Update all import statements and references throughout codebase

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

* fix: update missed AttributeRepository import to PrismaAttributeRepository in teams members page

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-07-30 14:33:25 +02:00
Rajiv Sahal babd514c64 fix: date overrides for availability settings atom (#22701)
* only make api call once access token is verified

* add date fns tz

* use date-fns to format ranges for platform

* update yarn.lock

* fix merge conflicts

* fix updating overrides for atoms

* add changesets
2025-07-30 14:28:37 +03:00
Lauris Skraucis 2e5cc11f29 refactor: remove atoms dev from package.json (#22811) 2025-07-30 11:17:35 +00:00
Eunjae LeeandDevin AI 84bc330fa8 feat: rename repository files to include Repository suffix (#22716)
* feat: rename repository files to include Repository suffix

- Rename apiKey.ts to apiKeyRepository.ts
- Rename assignmentReason.ts to assignmentReasonRepository.ts
- Update all import statements to use new file names

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

* feat: rename repository files to PrismaRepository with Prisma prefix

- Rename apiKeyRepository.ts to PrismaApiKeyRepository.ts
- Rename assignmentReasonRepository.ts to PrismaAssignmentReasonRepository.ts
- Update class names to PrismaApiKeyRepository and PrismaAssignmentReasonRepository
- Update all import statements and usages across 3 files

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-07-30 13:17:18 +02:00
Eunjae LeeandDevin AI 4cb7e4a91a refactor: rename app.ts to appRepository.ts for better naming consistency (#22693)
* refactor: rename app.ts to appRepository.ts for better naming consistency

- Renamed packages/lib/server/repository/app.ts to appRepository.ts
- Updated import statement in deleteCredential.test.ts
- No functional changes, only file rename and import path update

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

* rename

* update usages

* add missing import

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-07-30 13:17:13 +02:00
Morgan d1bd05a791 chore: hash rate limit trackers api v2 (#22767) 2025-07-30 13:35:12 +03:00
Rajiv SahalandDevanshu Sharma 96a6350143 skip last name check when creating a booking (#22794)
Co-authored-by: Devanshu Sharma <devanshusharma658@gmail.com>
2025-07-30 14:37:24 +05:30
Sahitya Chandra c8bea5b143 fix: center-align timezone and edit availability button on mobile layout (#22760)
* fix: center-align timezone andavailability button on mobile layout

* chore

* chore
2025-07-30 10:17:41 +05:30
Anirban Singha 1cdd8ebc53 fix: alignment of time unit select box (#22800) 2025-07-30 10:16:11 +05:30
d78ed21a9f feat: add race condition test with dynamic date selection (#22533)
* feat: add race condition test with dynamic date selection

- Recreate race condition test from PR #22401 for calendar-cache-serve booking flow
- Replace hardcoded TEST_DATE with dynamic first available slot selection
- Use getDynamicBookingDate function to select first available day dynamically
- Prevent test flakiness by avoiding hardcoded tomorrow dates
- Test validates concurrent booking prevention and cache functionality

Co-Authored-By: anik@cal.com <adhabal2002@gmail.com>

* fix: replace dynamic date selection with fixed future date for efficiency

Co-Authored-By: anik@cal.com <adhabal2002@gmail.com>

* Revert "fix: replace dynamic date selection with fixed future date for efficiency"

This reverts commit b4b7d358de719ec8fb495352180eea774a5d76c1.

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-07-30 10:13:26 +05:30
Joe Au-Yeung 4e92b6edeb chore: Log availability snapshot when booking is created (#22782)
* Add userAvailability type

* Add user availability to return value of getUsersAvailability

* Log availabilitySnapshot when booking is created

* Type fix
2025-07-29 12:10:21 -04:00
7b2f811bfe feat: BTCPay Server App (#21197)
* app initialization

* btcpay-calcom payment

* include logo and images

* resolve comments

* include USD and webhook cleaning

* currency display

* fix type error

* payment service create error

* type error fix

* icon update

* bot feedback update

* Remove console

* Remove currency suffix in price

* fix coderRabbit comment

* resolve extra comments

* Use repositories and declarative installation ocode for app

* use PrismaBookingPaymentRepository as well as fix UI view

* Avoid fetching booking just for title which is already passed to create fn in handlePayment

* fix type issues

* return 200 if payment is already processed

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: Omar López <zomars@me.com>
2025-07-29 16:08:19 +00:00
Sahitya Chandra 7b6d5d6de3 fix: improve error messages for PayPal credential validation (#22786)
* fix: improve error messages for PayPal credential validation

* chore

* chore
2025-07-29 15:43:10 +00:00
Anik Dhabal Babu 12b418b532 fix: fix type error failing in ci (#22793) 2025-07-29 15:17:18 +00:00
Eunjae Lee 3e299f9511 refactor: update charts on /insights to use InsightsBookingService (#22744)
Post release / release (push) Canceled after 0s
* refactor: update PopuplarEvents chart to use InsightsBookingService

* use buildHashMapForUsers

* refactor least booked

* refactor charts

* combine methods

* refactor booking kpi cards

* remove unused code

* fix booking kpi cards

* restore code that was removed accidentally

* Revert "restore code that was removed accidentally"

This reverts commit 0c0b444b02498b94fb29ef470da065bad3ad7ece.

* Revert "fix booking kpi cards"

This reverts commit b1e8e9afbd3632a5239495566551ae12bd926b66.

* refactor booking kpi cards

* revert method and remove unused code

* fix previous period

* fix metrics

* clean up trpc call params

* clean up Download.tsx

* split utils

* restructure folders for components

* clean up imports

* rename TotalUserFeedbackTable to UserStatsTable

* add guide

* use client

* add curly braces to case

* fix tests
2025-07-29 12:37:18 +02:00
emrysal 1b9691ddaf chore: release v5.5.8 2025-07-29 10:30:09 +00:00
Anik Dhabal Babu a11ea3dd1e feat: Ability to set a locked timezone for event type (#22531)
* feat: ability to set locked timezone

* update

* Update update-event-type.input.ts

* Update get-event-type-public.output.ts

* Update documentation.json

* revert

* nit

* feat: locked timezone

* update

* fix

* Update handleChildrenEventTypes.test.ts

* update

* fix test
2025-07-29 10:20:35 +00:00
Anik Dhabal Babu b21400c613 fix: show seated attendees to host even when seatsShowAttendees is disabled (#22565)
* fix: show attendee

* Update get.handler.ts

* update
2025-07-29 11:01:46 +01:00
6c936472d2 feat: add cancelledBy and rescheduledBy parameters to workflow links (#22438)
* feat: add cancelledBy and rescheduledBy parameters to workflow links

- Extended workflow reminder selection to include user and team data
- Added getWorkflowAssigneeEmail helper function to resolve assignee email
- Updated email and SMS workflow templates to include assignee in cancel/reschedule links
- For user workflows, uses user email; for team workflows, uses team owner email
- Maintains backward compatibility when assignee data is unavailable

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: use workflow recipient email instead of assignee email for link parameters

- Updated getWorkflowRecipientEmail to determine recipient based on workflow action type
- EMAIL_ADDRESS uses sendTo email, EMAIL_HOST uses organizer, EMAIL_ATTENDEE uses attendee
- SMS_ATTENDEE/WHATSAPP_ATTENDEE use attendee email for cancelledBy/rescheduledBy params
- Maintains backward compatibility when recipient data unavailable

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: use emailReminderManager for email workflows with recipient parameters

- Updated emailReminderManager to include recipient email in cancel/reschedule links
- Added getEmailWorkflowRecipientEmail function for proper recipient determination
- Removed changes from deprecated scheduleEmailReminders.ts
- SMS_ATTENDEE correctly uses attendee email like EMAIL_ATTENDEE

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* Get attendee's email when sending SMS

* Remove unused query

* feat: add cancelledBy and rescheduledBy parameters to getSMSMessageWithVariables

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* Clean up Devin

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-07-28 16:43:23 +00:00
Anik Dhabal Babu 3b823670ea fix: type error (#22780) 2025-07-28 16:21:51 +00:00
Lauris SkraucisandMorgan 6b10127d97 fix: changesets atoms release env variables (#22773)
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2025-07-28 15:52:03 +02:00
Anik Dhabal Babu 9cd806ff61 fix: CSV generation from insights returns empty results (#22775)
* fix: CSV generation from insights returns empty results

* update

* fix type error
2025-07-28 13:17:14 +00:00
mintlify[bot] 628b169f53 Documentation edits made through Mintlify web editor (#22774)
Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2025-07-28 12:22:27 +00:00
Lauris Skraucis 19d7764e9d refactor: platform managed user org admin access (#22597)
* refactor: examples app setup

* wip: view org event types as org administrator

* fix: create event type has org admin

* fix: create event type has org admin

* fix: allow org admin team event type access

* fix: uncaught error

* chore: bump platform libs

* refactor: getting event type for org admin

* add test

* chore: libraries
2025-07-28 13:50:12 +02:00
sean-brydon 30061c0d58 fix: remove fallback permssions on organization Id. (#22769)
* add concept of manage permissions instea of fallback

* fix common json

* fix usePermission hook
2025-07-28 11:49:36 +00:00
emrysal ee073cdf35 chore: release v5.5.7
Post release / release (push) Canceled after 0s
2025-07-26 01:41:59 +00:00
Eunjae LeeandAlex van Andel 4ff5f5f3fa refactor: use dependency injection for InsightsRoutingService (#22677)
* refactor: use dependency injection for InsightsRoutingService

* remove unused getter

* clean up

* Revert "clean up"

This reverts commit a95b83d5dfd4c3236e44268df40bab935237101e.

* Update insightsRoutingDI.ts

* Revert "Update insightsRoutingDI.ts"

This reverts commit 39ead809fa8dabfc17da043c6416bbc6d1fed58d.

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-07-26 02:32:27 +01:00
Hariom Balhara f905ae7502 fix: Add CSP back to login page (#22688)
* Middleware only logic to add CSP header as Next.js App Router doesnt allow setting header in server component

* add tests

* Remove nonce from Page Router and fix ts issues

* Add checkly test
2025-07-26 01:38:20 +01:00
Anik Dhabal Babu a1c0daa1b5 fix: buy credit option not visible (#22721) 2025-07-26 01:08:39 +01:00
sean-brydon b155d736d8 fix: (PBAC) Add organization fallback on resources (#22733)
* add fallback to service

* fix: org memebership fallback

* remove log

* remove log

* add merge test case

* add test for no parentId on team for getResources
2025-07-26 01:07:05 +01:00
Anirban Singha e42013bee1 docs: fix path to coding style guide (#22749) 2025-07-26 00:03:51 +00:00
fde1d671fb refactor: rename SelectedSlotsRepository to PrismaSelectedSlotRepository (#22705)
* refactor: rename SelectedSlotsRepository to PrismaSelectedSlotRepository

- Rename class from SelectedSlotsRepository to PrismaSelectedSlotRepository for consistency
- Update all imports and type references throughout the codebase
- Update DI module bindings and variable names
- Maintain type safety without breaking changes

Co-Authored-By: alex@cal.com <me@alexvanandel.com>

* refactor: complete PrismaSelectedSlotRepository rename

- Update DI module binding property name from selectedSlotsRepo to selectedSlotRepo
- Update test mock to use PrismaSelectedSlotRepository class name
- Ensure all references are consistently updated

Co-Authored-By: alex@cal.com <me@alexvanandel.com>

* fix: rename file to prismaSelectedSlotRepository.ts and fix plural variable references

- Rename apps/api/v2/src/lib/repositories/prisma-selected-slots.repository.ts to prismaSelectedSlotRepository.ts
- Update class name from PrismaSelectedSlotsRepository to PrismaSelectedSlotRepository
- Fix plural variable reference selectedSlotsRepository to selectedSlotRepository in service
- Update all import paths to reference the new file name
- Maintain consistency with camelCase naming convention

Co-Authored-By: alex@cal.com <me@alexvanandel.com>

* fix: rename SelectedSlotsRepositoryFixture to SelectedSlotRepositoryFixture for consistency

- Rename test fixture class from SelectedSlotsRepositoryFixture to SelectedSlotRepositoryFixture (singular)
- Update all import statements in test files to use the renamed class
- Resolves naming mismatch between fixture class and variable declarations
- Fixes TypeScript compilation errors in CI tests

Co-Authored-By: alex@cal.com <me@alexvanandel.com>

* feat: add SelectedSlotRepositoryInterface and update DI to use interface

Co-Authored-By: alex@cal.com <me@alexvanandel.com>

* refactor: rename selectedSlots.ts to selectedSlot.ts and update all imports

Co-Authored-By: alex@cal.com <me@alexvanandel.com>

* fix: update test mock import path after file rename

Co-Authored-By: alex@cal.com <me@alexvanandel.com>

* Update apps/api/v2/src/lib/modules/available-slots.module.ts

* Update apps/api/v2/src/lib/modules/available-slots.module.ts

* Implement DTO

* dont declare dependencies locally, duplicating

* Small DTO/token fix

* chore: bump @calcom/platform-libraries from 0.0.266 to 0.0.267

* oops.

* Update fixture names also

* Omg these vscode actions preventing saves

* Final fix, hopefully

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Somay Chauhan <somaychauhan98@gmail.com>
2025-07-25 16:59:32 -07:00
Udit Takkar 9ccf5f3723 chore: add auto recording (#22723)
* feat: add auto recording setting in API v2

* test: add e2e test

* test: add tests

* chore: save progress

* chore: update zod
2025-07-25 18:43:27 +00:00
Alex van Andel 91ee225f14 chore: remove engines node>=18 from monorepo root (#22726) 2025-07-25 11:16:00 -07:00
Eunjae LeeandAnik Dhabal Babu e6a0a0fc69 refactor: event trends chart on /insights to use InsightsBookingService (#22741)
* refactor: event trends chart on /insights to use InsightsBookingService

* extract service instantiation

* remove unused code

* rename from eventsTimeline to eventTrends

* clean up

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-07-25 15:58:41 +00:00
Peer Richelsen e98024b08e chore: small UI changes to plain chat box (#22742)
* Update PlainContactForm.tsx

* Update apps/web/lib/plain/PlainContactForm.tsx
2025-07-25 20:44:41 +05:30
Amit Sharma 8d54d80dee Fix: use email instead of id for customer id (#22739) 2025-07-25 14:59:56 +02:00
Sahitya ChandraandAlex van Andel 751aa62935 fix(seed): skip team creation if slug already exists to avoid unique constraint error (#22738)
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-07-25 12:34:21 +00:00
Syed Ali ShahbazandEunjae Lee f149650911 docs: Update contributors-guide (#22734)
* Update contributors-guide.mdx

* Update contributors-guide.mdx

* update cursor rule

* update CONTRIBUTING.md

* update CONTRIBUTING.md

---------

Co-authored-by: Eunjae Lee <hey@eunjae.dev>
2025-07-25 13:48:01 +02:00
Eunjae Lee f901fba0fc fix: use InsightsBookingService for Download button on /insights (#22698) 2025-07-25 10:27:13 +01:00
Eunjae Lee cd50719b03 fix: refactor AverageEventDurationChart to use InsightsBookingService (#22702) 2025-07-25 08:59:11 +01:00
Anik Dhabal Babu 000324c02d fix: a.trim is not a function type error (#22728) 2025-07-25 04:32:24 +00:00
Hariom Balhara cc6beedfb0 fix: App Crash inside Embed when Cross Domain cookies are disabled in browser(Firefox) (#22679)
* Fix: embed crash when third party cookies are disabled

* :Fix: embed crash when third party cookies are disabled

* fix missing import
2025-07-25 07:44:54 +04:00
448 changed files with 16990 additions and 5562 deletions
+8
View File
@@ -1,5 +1,13 @@
# Review Instructions
## File Naming Conventions
- **Repository Files**: Must include `Repository` suffix, prefix with technology if applicable (e.g., `PrismaAppRepository.ts`), use PascalCase matching exported class
- **Service Files**: Must include `Service` suffix, use PascalCase matching exported class, avoid generic names (e.g., `MembershipService.ts`)
- **New files**: Avoid dot-suffixes like `.service.ts` or `.repository.ts`; reserve `.test.ts`, `.spec.ts`, `.types.ts` for their specific purposes
## Code Quality
- Prefer early returns. It is recommended to throw/return early so we can ensure null-checks and prevent further nesting.
- Prefer Composition over Prop Drilling. Instead of relying on prop drilling, let's try to take advantage of react children feature.
- For Prisma queries:
+4 -1
View File
@@ -34,4 +34,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
VITE_BOOKER_EMBED_OAUTH_CLIENT_ID: ${{ secrets.VITE_BOOKER_EMBED_OAUTH_CLIENT_ID }}
VITE_BOOKER_EMBED_API_URL: ${{ secrets.VITE_BOOKER_EMBED_API_URL }}
NEXT_PUBLIC_WEBAPP_URL: ${{ secrets.NEXT_PUBLIC_WEBAPP_URL }}
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
+3
View File
@@ -0,0 +1,3 @@
undecided:
- calcom-monorepo
- "@calcom/prisma"
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
+3
View File
@@ -0,0 +1,3 @@
undecided:
- calcom-monorepo
- "@calcom/prisma"
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
+51
View File
@@ -95,6 +95,57 @@ Write with the future in mind. If there are trade-offs, edge cases, or temporary
</tr>
</table>
## File Naming Conventions
To ensure consistency and make files easy to fuzzy-find, we follow the naming conventions below for **services**, **repositories**, and other class-based files.
### Repository Files
- Repository class files must include the `Repository` suffix.
- If the repository is backed by a specific technology (e.g. Prisma), prefix the filename and class name with it.
- File name must match the exported class exactly (PascalCase).
**Pattern:**
`Prisma<Entity>Repository.ts`
**Examples:**
```ts
// File: PrismaAppRepository.ts
export class PrismaAppRepository { ... }
// File: PrismaMembershipRepository.ts
export class PrismaMembershipRepository { ... }
```
This avoids ambiguous filenames like app.ts and improves discoverability in editors.
### Service Files
- Service class files must include the Service suffix.
- File name should be in PascalCase, matching the exported class.
- Keep naming specific — avoid generic names like AppService.ts.
**Pattern:**
`<Entity>Service.ts`
**Examples:**
```ts
// File: MembershipService.ts
export class MembershipService { ... }
// File: HashedLinkService.ts
export class HashedLinkService { ... }
```
**Note:**
- New files must avoid dot-suffixes like .service.ts or .repository.ts; these will be migrated from the existing codebase progressively.
- We still reserve suffixes such as .test.ts, .spec.ts, and .types.ts for their respective use cases.
## Developing
[See README](https://github.com/calcom/cal.com#development)
+2
View File
@@ -1,4 +1,6 @@
# Checkly Tests
Run as `yarn checkly test`
Optionally, run as `yarn checkly test --record` to be able to get detailed debugging view in Checkly UI
Run the tests for a particylar file as `yarn checkly test {filePattern}`
Deploy the tests as `yarn checkly deploy`
+22
View File
@@ -0,0 +1,22 @@
import { test, expect } from "@playwright/test";
test.describe("CSP Headers", () => {
test("Login page should have CSP header with nonce", async ({ page }) => {
const targetUrl = process.env.ENVIRONMENT_URL || "https://app.cal.com";
const response = await page.goto(`${targetUrl}/auth/login`);
expect(response?.status()).toBe(200);
const cspHeader = response?.headers()["content-security-policy"];
expect(cspHeader).toBeTruthy();
// Verify nonce is present in CSP header
const nonceMatch = cspHeader?.match(/'nonce-([^']+)'/);
expect(nonceMatch).toBeTruthy();
expect(nonceMatch![1]).toHaveLength(24);
expect(nonceMatch![1]).toMatch(/==$/);
await page.screenshot({ path: "screenshot.jpg" });
});
});
+1 -1
View File
@@ -38,7 +38,7 @@
"next-axiom": "^0.17.0",
"next-swagger-doc": "^0.3.6",
"next-validations": "^0.2.0",
"typescript": "^5.7.2",
"typescript": "^5.9.0-beta",
"tzdata": "^1.0.30",
"uuid": "^8.3.2",
"zod": "^3.22.4"
-18
View File
@@ -1,18 +0,0 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"moduleNameMapper": {
"@/(.*)": "<rootDir>/src/$1",
"test/(.*)": "<rootDir>/test/$1"
},
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"setupFiles": ["<rootDir>/test/setEnvVars.ts", "jest-date-mock"],
"setupFilesAfterEnv": ["<rootDir>/test/jest.setup-e2e.ts"],
"reporters": ["default", "jest-summarizing-reporter"],
"workerIdleMemoryLimit": "512MB",
"maxWorkers": 8
}
+25
View File
@@ -0,0 +1,25 @@
import type { Config } from "jest";
const config: Config = {
preset: "ts-jest",
moduleFileExtensions: ["js", "json", "ts"],
rootDir: ".",
moduleNameMapper: {
"@/(.*)": "<rootDir>/src/$1",
"test/(.*)": "<rootDir>/test/$1",
},
testEnvironment: "node",
testRegex: ".e2e-spec.ts$",
transform: {
"^.+\\.ts$": "ts-jest",
},
setupFiles: ["<rootDir>/test/setEnvVars.ts", "jest-date-mock"],
setupFilesAfterEnv: ["<rootDir>/test/jest.setup-e2e.ts"],
reporters: ["default", "jest-summarizing-reporter"],
workerIdleMemoryLimit: "512MB",
maxWorkers: 8,
testPathIgnorePatterns: ["/dist/", "/node_modules/"],
transformIgnorePatterns: ["/dist/", "/node_modules/"],
};
export default config;
-14
View File
@@ -1,14 +0,0 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"moduleNameMapper": {
"@/(.*)": "<rootDir>/src/$1",
"test/(.*)": "<rootDir>/test/$1"
},
"testEnvironment": "node",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.ts$": "ts-jest"
},
"setupFiles": ["<rootDir>/test/setEnvVars.ts"]
}
+21
View File
@@ -0,0 +1,21 @@
import type { Config } from "jest";
const config: Config = {
preset: "ts-jest",
moduleFileExtensions: ["ts", "js", "json"],
rootDir: ".",
moduleNameMapper: {
"@/(.*)": "<rootDir>/src/$1",
"test/(.*)": "<rootDir>/test/$1",
},
testEnvironment: "node",
testRegex: ".*\\.spec\\.ts$",
transform: {
"^.+\\.ts$": "ts-jest",
},
setupFiles: ["<rootDir>/test/setEnvVars.ts"],
testPathIgnorePatterns: ["/dist/", "/node_modules/"],
transformIgnorePatterns: ["/dist/", "/node_modules/"],
};
export default config;
+4 -4
View File
@@ -25,9 +25,9 @@
"test:watch": "yarn dev:build && jest --watch",
"test:cov": "yarn dev:build && jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "yarn dev:build && NODE_OPTIONS='--max_old_space_size=8192 --experimental-vm-modules' jest --ci --forceExit --config ./jest-e2e.json",
"test:e2e": "yarn dev:build && NODE_OPTIONS='--max_old_space_size=8192 --experimental-vm-modules' jest --ci --forceExit --config ./jest-e2e.ts",
"test:e2e:local": "yarn test:e2e --maxWorkers=4",
"test:e2e:watch": "yarn dev:build && jest --runInBand --detectOpenHandles --forceExit --config ./jest-e2e.json --watch",
"test:e2e:watch": "yarn dev:build && jest --runInBand --detectOpenHandles --forceExit --config ./jest-e2e.ts --watch",
"prisma": "yarn workspace @calcom/prisma prisma",
"generate-schemas": "yarn prisma generate && yarn prisma format",
"copy-swagger-module": "ts-node -r tsconfig-paths/register swagger/copy-swagger-module.ts",
@@ -38,7 +38,7 @@
"@axiomhq/winston": "^1.2.0",
"@calcom/platform-constants": "*",
"@calcom/platform-enums": "*",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.258",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.276",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
"@calcom/prisma": "*",
@@ -108,7 +108,7 @@
"ts-loader": "^9.4.3",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.1.0",
"typescript": "^5.7.2"
"typescript": "^5.9.0-beta"
},
"prisma": {
"schema": "../../../packages/prisma/schema.prisma"
@@ -5,7 +5,7 @@ import { GetBookingOutput_2024_04_15 } from "@/ee/bookings/2024-04-15/outputs/ge
import { GetBookingsOutput_2024_04_15 } from "@/ee/bookings/2024-04-15/outputs/get-bookings.output";
import { MarkNoShowOutput_2024_04_15 } from "@/ee/bookings/2024-04-15/outputs/mark-no-show.output";
import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service";
import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key";
import { sha256Hash, isApiKey, stripApiKey } from "@/lib/api-key";
import { VERSION_2024_04_15, VERSION_2024_06_11, VERSION_2024_06_14 } from "@/lib/api-versions";
import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
@@ -386,7 +386,7 @@ export class BookingsController_2024_04_15 {
if (bearerToken) {
if (isApiKey(bearerToken, this.config.get<string>("api.apiKeyPrefix") ?? "cal_")) {
const strippedApiKey = stripApiKey(bearerToken, this.config.get<string>("api.keyPrefix"));
const apiKeyHash = hashAPIKey(strippedApiKey);
const apiKeyHash = sha256Hash(strippedApiKey);
const keyData = await this.apiKeyRepository.getApiKeyFromHash(apiKeyHash);
return keyData?.userId;
} else {
@@ -31,12 +31,7 @@ function ValidateBookingName(validationOptions?: ValidationOptions) {
return value.trim().length > 0;
}
if (typeof value === "object" && value !== null) {
return (
typeof value.firstName === "string" &&
typeof value.lastName === "string" &&
value.firstName.trim().length > 0 &&
value.lastName.trim().length > 0
);
return typeof value.firstName === "string" && value.firstName.trim().length > 0;
}
return false;
},
@@ -1834,6 +1834,88 @@ describe("Bookings Endpoints 2024-08-13", () => {
}
});
});
it("should return who rescheduled the booking, in the new booking", async () => {
const rescheduledByEmail = `user-bookings-rescheduler-${randomString()}@rescheduler.com`;
// Create the original booking that will be rescheduled
const originalBooking = await bookingsRepositoryFixture.create({
uid: `original-booking-uid-${eventTypeId}`,
title: "original booking title",
startTime: "2050-09-05T10:00:00.000Z",
endTime: "2050-09-05T11:00:00.000Z",
eventType: {
connect: {
id: eventTypeId,
},
},
status: "CANCELLED",
rescheduledBy: rescheduledByEmail,
metadata: {},
responses: {
name: "original tester",
email: "original@example.com",
guests: [],
},
user: {
connect: {
id: user.id,
},
},
});
// Create the new booking that is the result of the reschedule
const newBooking = await bookingsRepositoryFixture.create({
uid: `new-booking-uid-${eventTypeId}`,
title: "rescheduled booking title",
startTime: "2050-09-05T14:00:00.000Z",
endTime: "2050-09-05T15:00:00.000Z",
eventType: {
connect: {
id: eventTypeId,
},
},
status: "ACCEPTED",
fromReschedule: originalBooking.uid,
metadata: {},
responses: {
name: "new tester",
email: "newtester@example.com",
guests: [],
},
user: {
connect: {
id: user.id,
},
},
});
return request(app.getHttpServer())
.get(`/v2/bookings/${newBooking.uid}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
// Fetch the original booking to get its rescheduledBy value
const originalBookingFromDb = await bookingsRepositoryFixture.getByUid(originalBooking.uid);
const expectedRescheduledBy = originalBookingFromDb?.rescheduledBy;
await bookingsRepositoryFixture.deleteById(originalBooking.id);
await bookingsRepositoryFixture.deleteById(newBooking.id);
const responseBody: GetBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsBooking(responseBody.data)).toBe(true);
if (responseDataIsBooking(responseBody.data)) {
const data: BookingOutput_2024_08_13 = responseBody.data;
expect(data.uid).toEqual(newBooking.uid);
expect(rescheduledByEmail).toEqual(expectedRescheduledBy);
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibly recurring bookings"
);
}
});
});
});
describe("book by username and event type slug", () => {
@@ -11,7 +11,7 @@ import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
import { OutputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/output-event-types.service";
import { apiToInternalintegrationsMapping } from "@/ee/event-types/event-types_2024_06_14/transformers";
import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key";
import { sha256Hash, isApiKey, stripApiKey } from "@/lib/api-key";
import { defaultBookingResponses } from "@/lib/safe-parse/default-responses-booking";
import { safeParse } from "@/lib/safe-parse/safe-parse";
import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
@@ -675,7 +675,7 @@ export class InputBookingsService_2024_08_13 {
if (bearerToken) {
if (isApiKey(bearerToken, this.config.get<string>("api.apiKeyPrefix") ?? "cal_")) {
const strippedApiKey = stripApiKey(bearerToken, this.config.get<string>("api.keyPrefix"));
const apiKeyHash = hashAPIKey(strippedApiKey);
const apiKeyHash = sha256Hash(strippedApiKey);
const keyData = await this.apiKeyRepository.getApiKeyFromHash(apiKeyHash);
return keyData?.userId;
} else {
@@ -101,9 +101,13 @@ export class OutputBookingsService_2024_08_13 {
);
const metadata = safeParse(bookingMetadataSchema, databaseBooking.metadata, defaultBookingMetadata);
const location = metadata?.videoCallUrl || databaseBooking.location;
const rescheduledToUid = databaseBooking.rescheduled
? await this.getRescheduledToUid(databaseBooking.uid)
const rescheduledToInfo = databaseBooking.rescheduled
? await this.getRescheduledToInfo(databaseBooking.uid)
: undefined;
const rescheduledToUid = rescheduledToInfo?.uid;
const rescheduledByEmail = databaseBooking.rescheduled ? rescheduledToInfo?.rescheduledBy : databaseBooking.rescheduledBy;
const booking = {
id: databaseBooking.id,
@@ -116,7 +120,6 @@ export class OutputBookingsService_2024_08_13 {
databaseBooking.status === "CANCELLED" ? databaseBooking.cancellationReason : undefined,
cancelledByEmail: databaseBooking.status === "CANCELLED" ? databaseBooking.cancelledBy : undefined,
reschedulingReason: bookingResponses?.rescheduledReason,
rescheduledByEmail: databaseBooking.rescheduledBy || undefined,
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
start: databaseBooking.startTime,
end: databaseBooking.endTime,
@@ -142,6 +145,7 @@ export class OutputBookingsService_2024_08_13 {
rating: databaseBooking.rating,
icsUid: databaseBooking.iCalUID,
rescheduledToUid,
rescheduledByEmail,
};
const bookingTransformed = plainToClass(BookingOutput_2024_08_13, booking, { strategy: "excludeAll" });
@@ -151,11 +155,15 @@ export class OutputBookingsService_2024_08_13 {
return bookingTransformed;
}
async getRescheduledToUid(bookingUid: string) {
async getRescheduledToInfo(bookingUid: string): Promise<{ uid?: string; rescheduledBy?: string | null }> {
const rescheduledTo = await this.bookingsRepository.getByFromReschedule(bookingUid);
return rescheduledTo?.uid;
return {
uid: rescheduledTo?.uid,
rescheduledBy: rescheduledTo?.rescheduledBy
};
}
getUserDefinedMetadata(databaseMetadata: DatabaseMetadata) {
if (databaseMetadata === null) return {};
@@ -218,7 +226,6 @@ export class OutputBookingsService_2024_08_13 {
databaseBooking.status === "CANCELLED" ? databaseBooking.cancellationReason : undefined,
cancelledByEmail: databaseBooking.status === "CANCELLED" ? databaseBooking.cancelledBy : undefined,
reschedulingReason: bookingResponses?.rescheduledReason,
rescheduledByEmail: databaseBooking.rescheduledBy || undefined,
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
start: databaseBooking.startTime,
end: databaseBooking.endTime,
@@ -269,9 +276,12 @@ export class OutputBookingsService_2024_08_13 {
const duration = dateEnd.diff(dateStart, "minutes").minutes;
const metadata = safeParse(bookingMetadataSchema, databaseBooking.metadata, defaultBookingMetadata);
const location = metadata?.videoCallUrl || databaseBooking.location;
const rescheduledToUid = databaseBooking.rescheduled
? await this.getRescheduledToUid(databaseBooking.uid)
const rescheduledToInfo = databaseBooking.rescheduled
? await this.getRescheduledToInfo(databaseBooking.uid)
: undefined;
const rescheduledToUid = rescheduledToInfo?.uid;
const rescheduledByEmail = databaseBooking.rescheduled ? rescheduledToInfo?.rescheduledBy : databaseBooking.rescheduledBy;
const booking = {
id: databaseBooking.id,
@@ -299,6 +309,7 @@ export class OutputBookingsService_2024_08_13 {
rating: databaseBooking.rating,
icsUid: databaseBooking.iCalUID,
rescheduledToUid,
rescheduledByEmail,
};
const parsed = plainToClass(GetSeatedBookingOutput_2024_08_13, booking, { strategy: "excludeAll" });
@@ -83,32 +83,31 @@ export class CalendarsService {
calendarsToLoad,
userId
);
try {
const calendarBusyTimes = await getBusyCalendarTimes(
this.buildNonDelegationCredentials(credentials),
dateFrom,
dateTo,
composedSelectedCalendars
);
const calendarBusyTimesConverted = calendarBusyTimes.map(
(busyTime: EventBusyDate & { timeZone?: string }) => {
const busyTimeStart = DateTime.fromJSDate(new Date(busyTime.start)).setZone(timezone);
const busyTimeEnd = DateTime.fromJSDate(new Date(busyTime.end)).setZone(timezone);
const busyTimeStartDate = busyTimeStart.toJSDate();
const busyTimeEndDate = busyTimeEnd.toJSDate();
return {
...busyTime,
start: busyTimeStartDate,
end: busyTimeEndDate,
};
}
);
return calendarBusyTimesConverted;
} catch (error) {
const calendarBusyTimesQuery = await getBusyCalendarTimes(
this.buildNonDelegationCredentials(credentials),
dateFrom,
dateTo,
composedSelectedCalendars
);
if (!calendarBusyTimesQuery.success) {
throw new InternalServerErrorException(
"Unable to fetch connected calendars events. Please try again later."
);
}
const calendarBusyTimesConverted = calendarBusyTimesQuery.data.map(
(busyTime: EventBusyDate & { timeZone?: string }) => {
const busyTimeStart = DateTime.fromJSDate(new Date(busyTime.start)).setZone(timezone);
const busyTimeEnd = DateTime.fromJSDate(new Date(busyTime.end)).setZone(timezone);
const busyTimeStartDate = busyTimeStart.toJSDate();
const busyTimeEndDate = busyTimeEnd.toJSDate();
return {
...busyTime,
start: busyTimeStartDate,
end: busyTimeEndDate,
};
}
);
return calendarBusyTimesConverted;
}
async getUniqCalendarCredentials(calendarsToLoad: Calendar[], userId: User["id"]) {
@@ -967,6 +967,7 @@ describe("Event types Endpoints", () => {
calVideoSettings: {
disableRecordingForGuests: true,
disableRecordingForOrganizer: true,
enableAutomaticRecordingForOrganizer: true,
},
bookingFields: [
nameBookingField,
@@ -1095,6 +1096,9 @@ describe("Event types Endpoints", () => {
expect(updatedEventType.calVideoSettings?.disableRecordingForOrganizer).toEqual(
body.calVideoSettings?.disableRecordingForOrganizer
);
expect(updatedEventType.calVideoSettings?.enableAutomaticRecordingForOrganizer).toEqual(
body.calVideoSettings?.enableAutomaticRecordingForOrganizer
);
eventType.title = newTitle;
eventType.scheduleId = secondSchedule.id;
@@ -1115,6 +1119,7 @@ describe("Event types Endpoints", () => {
eventType.lockTimeZoneToggleOnBookingPage = updatedEventType.lockTimeZoneToggleOnBookingPage;
eventType.color = updatedEventType.color;
eventType.bookingFields = updatedEventType.bookingFields;
eventType.calVideoSettings = updatedEventType.calVideoSettings;
});
});
@@ -1871,6 +1876,7 @@ describe("Event types Endpoints", () => {
calVideoSettings: {
disableRecordingForGuests: true,
disableRecordingForOrganizer: true,
enableAutomaticRecordingForOrganizer: true,
},
locations: [
{
@@ -1893,6 +1899,7 @@ describe("Event types Endpoints", () => {
expect(createdEventType.locations).toEqual(body.locations);
expect(createdEventType.calVideoSettings?.disableRecordingForGuests).toEqual(true);
expect(createdEventType.calVideoSettings?.disableRecordingForOrganizer).toEqual(true);
expect(createdEventType.calVideoSettings?.enableAutomaticRecordingForOrganizer).toEqual(true);
firstCreatedEventType = responseBody.data;
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
import { createHash } from "crypto";
export const hashAPIKey = (apiKey: string): string => createHash("sha256").update(apiKey).digest("hex");
export const sha256Hash = (token: string): string => createHash("sha256").update(token).digest("hex");
export const isApiKey = (authString: string, prefix: string): boolean =>
authString?.startsWith(prefix ?? "cal_");
@@ -1,12 +1,15 @@
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
import { PrismaEventTypeRepository } from "@/lib/repositories/prisma-event-type.repository";
import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.repository";
import { PrismaOOORepository } from "@/lib/repositories/prisma-ooo.repository";
import { PrismaRoutingFormResponseRepository } from "@/lib/repositories/prisma-routing-form-response.repository";
import { PrismaScheduleRepository } from "@/lib/repositories/prisma-schedule.repository";
import { PrismaSelectedSlotsRepository } from "@/lib/repositories/prisma-selected-slots.repository";
import { PrismaSelectedSlotRepository } from "@/lib/repositories/prisma-selected-slot.repository";
import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository";
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
import { AvailableSlotsService } from "@/lib/services/available-slots.service";
import { CacheService } from "@/lib/services/cache.service";
import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { Module } from "@nestjs/common";
@@ -16,11 +19,14 @@ import { Module } from "@nestjs/common";
PrismaOOORepository,
PrismaScheduleRepository,
PrismaBookingRepository,
PrismaSelectedSlotsRepository,
PrismaSelectedSlotRepository,
PrismaUserRepository,
PrismaEventTypeRepository,
PrismaRoutingFormResponseRepository,
PrismaTeamRepository,
PrismaFeaturesRepository,
CheckBookingLimitsService,
CacheService,
AvailableSlotsService,
],
exports: [AvailableSlotsService],
@@ -1,11 +1,11 @@
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import { PrismaSelectedSlotsRepository as PrismaSelectedSlotsRepositoryLib } from "@calcom/platform-libraries/repositories";
import { PrismaFeaturesRepository as PrismaFeaturesRepositoryLib } from "@calcom/platform-libraries/repositories";
import { PrismaClient } from "@calcom/prisma";
@Injectable()
export class PrismaSelectedSlotsRepository extends PrismaSelectedSlotsRepositoryLib {
export class PrismaFeaturesRepository extends PrismaFeaturesRepositoryLib {
constructor(private readonly dbWrite: PrismaWriteService) {
super(dbWrite.prisma as unknown as PrismaClient);
}
@@ -0,0 +1,12 @@
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import { PrismaSelectedSlotRepository as PrismaSelectedSlotRepositoryLib } from "@calcom/platform-libraries/repositories";
import { PrismaClient } from "@calcom/prisma";
@Injectable()
export class PrismaSelectedSlotRepository extends PrismaSelectedSlotRepositoryLib {
constructor(private readonly dbWrite: PrismaWriteService) {
super(dbWrite.prisma as unknown as PrismaClient);
}
}
@@ -1,11 +1,14 @@
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
import { PrismaEventTypeRepository } from "@/lib/repositories/prisma-event-type.repository";
import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.repository";
import { PrismaOOORepository } from "@/lib/repositories/prisma-ooo.repository";
import { PrismaRoutingFormResponseRepository } from "@/lib/repositories/prisma-routing-form-response.repository";
import { PrismaScheduleRepository } from "@/lib/repositories/prisma-schedule.repository";
import { PrismaSelectedSlotsRepository } from "@/lib/repositories/prisma-selected-slots.repository";
import { PrismaSelectedSlotRepository } from "@/lib/repositories/prisma-selected-slot.repository";
import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository";
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
import { CacheService } from "@/lib/services/cache.service";
import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
import { Injectable } from "@nestjs/common";
import { AvailableSlotsService as BaseAvailableSlotsService } from "@calcom/platform-libraries/slots";
@@ -13,14 +16,15 @@ import { AvailableSlotsService as BaseAvailableSlotsService } from "@calcom/plat
@Injectable()
export class AvailableSlotsService extends BaseAvailableSlotsService {
constructor(
private readonly oooRepoDependency: PrismaOOORepository,
private readonly scheduleRepoDependency: PrismaScheduleRepository,
private readonly teamRepository: PrismaTeamRepository,
private readonly routingFormResponseRepository: PrismaRoutingFormResponseRepository,
private readonly bookingRepository: PrismaBookingRepository,
private readonly selectedSlotsRepository: PrismaSelectedSlotsRepository,
private readonly eventTypeRepository: PrismaEventTypeRepository,
private readonly userRepository: PrismaUserRepository
oooRepoDependency: PrismaOOORepository,
scheduleRepoDependency: PrismaScheduleRepository,
teamRepository: PrismaTeamRepository,
routingFormResponseRepository: PrismaRoutingFormResponseRepository,
bookingRepository: PrismaBookingRepository,
selectedSlotRepository: PrismaSelectedSlotRepository,
eventTypeRepository: PrismaEventTypeRepository,
userRepository: PrismaUserRepository,
featuresRepository: PrismaFeaturesRepository
) {
super({
oooRepo: oooRepoDependency,
@@ -28,9 +32,11 @@ export class AvailableSlotsService extends BaseAvailableSlotsService {
teamRepo: teamRepository,
routingFormResponseRepo: routingFormResponseRepository,
bookingRepo: bookingRepository,
selectedSlotsRepo: selectedSlotsRepository,
selectedSlotRepo: selectedSlotRepository,
eventTypeRepo: eventTypeRepository,
userRepo: userRepository,
checkBookingLimitsService: new CheckBookingLimitsService(bookingRepository) as any,
cacheService: new CacheService(featuresRepository),
});
}
}
@@ -0,0 +1,11 @@
import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.repository";
import { Injectable } from "@nestjs/common";
import { CacheService as BaseCacheService } from "@calcom/platform-libraries";
@Injectable()
export class CacheService extends BaseCacheService {
constructor(featuresRepository: PrismaFeaturesRepository) {
super({ featuresRepository });
}
}
@@ -0,0 +1,13 @@
import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
import { Injectable } from "@nestjs/common";
import { CheckBookingAndDurationLimitsService as BaseCheckBookingAndDurationLimitsService } from "@calcom/platform-libraries/bookings";
@Injectable()
export class CheckBookingAndDurationLimitsService extends BaseCheckBookingAndDurationLimitsService {
constructor(checkBookingLimitsService: CheckBookingLimitsService) {
super({
checkBookingLimitsService: checkBookingLimitsService,
});
}
}
@@ -0,0 +1,13 @@
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
import { Injectable } from "@nestjs/common";
import { CheckBookingLimitsService as BaseCheckBookingLimitsService } from "@calcom/platform-libraries/bookings";
@Injectable()
export class CheckBookingLimitsService extends BaseCheckBookingLimitsService {
constructor(bookingRepository: PrismaBookingRepository) {
super({
bookingRepo: bookingRepository,
});
}
}
+5 -6
View File
@@ -1,5 +1,5 @@
import { getEnv } from "@/env";
import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key";
import { sha256Hash, isApiKey, stripApiKey } from "@/lib/api-key";
import { Throttle } from "@/lib/endpoint-throttler-decorator";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { ThrottlerStorageRedisService } from "@nest-lab/throttler-storage-redis";
@@ -10,7 +10,6 @@ import {
ThrottlerException,
ThrottlerRequest,
ThrottlerModuleOptions,
seconds,
} from "@nestjs/throttler";
import { Request, Response } from "express";
import { z } from "zod";
@@ -218,18 +217,18 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
if (authorizationHeader) {
const apiKeyPrefix = getEnv("API_KEY_PREFIX", "cal_");
return isApiKey(authorizationHeader, apiKeyPrefix)
? `api_key_${hashAPIKey(stripApiKey(authorizationHeader, apiKeyPrefix))}`
: `access_token_${authorizationHeader}`;
? `api_key_${sha256Hash(stripApiKey(authorizationHeader, apiKeyPrefix))}`
: `access_token_${sha256Hash(authorizationHeader)}`;
}
const oauthClientId = request.get(X_CAL_CLIENT_ID);
if (oauthClientId) {
return `oauth_client_${oauthClientId}`;
return `oauth_client_${sha256Hash(oauthClientId)}`;
}
if (IP) {
return `ip_${IP}`;
return `ip_${sha256Hash(IP.toString())}`;
}
this.logger.verbose(`no tracker found: ${request.url}`);
@@ -1,4 +1,4 @@
import { hashAPIKey, stripApiKey } from "@/lib/api-key";
import { sha256Hash, stripApiKey } from "@/lib/api-key";
import { AuthMethods } from "@/lib/enums/auth-methods";
import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
import { CreateApiKeyInput } from "@/modules/api-keys/inputs/create-api-key.input";
@@ -61,7 +61,7 @@ export class ApiKeysService {
async refreshApiKey(authUserId: number, apiKey: string, refreshApiKeyInput: RefreshApiKeyInput) {
const strippedApiKey = stripApiKey(apiKey, this.config.get<string>("api.keyPrefix"));
const apiKeyHash = hashAPIKey(strippedApiKey);
const apiKeyHash = sha256Hash(strippedApiKey);
const apiKeyInDb = await this.apiKeysRepository.getApiKeyFromHash(apiKeyHash);
if (!apiKeyInDb) {
throw new UnauthorizedException("ApiKeysService - provided api key is not valid.");
@@ -11,6 +11,7 @@ import { SchedulesAtomsService } from "@/modules/atoms/services/schedules-atom.s
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { RedisService } from "@/modules/redis/redis.service";
import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module";
@@ -21,6 +22,7 @@ import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule, EventTypesModule_2024_06_14, OrganizationsModule, TeamsEventTypesModule],
providers: [
OrganizationsTeamsRepository,
EventTypesAtomService,
ConferencingAtomsService,
AttributesAtomsService,
@@ -3,6 +3,7 @@ import { systemBeforeFieldEmail } from "@/ee/event-types/event-types_2024_06_14/
import { AtomsRepository } from "@/modules/atoms/atoms.repository";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { TeamsEventTypesService } from "@/modules/teams/event-types/services/teams-event-types.service";
@@ -53,7 +54,8 @@ export class EventTypesAtomService {
private readonly dbWrite: PrismaWriteService,
private readonly dbRead: PrismaReadService,
private readonly eventTypeService: EventTypesService_2024_06_14,
private readonly teamEventTypeService: TeamsEventTypesService
private readonly teamEventTypeService: TeamsEventTypesService,
private readonly organizationsTeamsRepository: OrganizationsTeamsRepository
) {}
private async getTeamSlug(teamId: number): Promise<string> {
@@ -88,10 +90,12 @@ export class EventTypesAtomService {
throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
}
if (eventType?.team?.id) {
await this.checkTeamOwnsEventType(user.id, eventType.eventType.id, eventType.team.id);
} else {
this.eventTypeService.checkUserOwnsEventType(user.id, eventType.eventType);
if (!isUserOrganizationAdmin) {
if (eventType?.team?.id) {
await this.checkTeamOwnsEventType(user.id, eventType.eventType.id, eventType.team.id);
} else {
this.eventTypeService.checkUserOwnsEventType(user.id, eventType.eventType);
}
}
// note (Lauris): don't show platform owner as one of the people that can be assigned to managed team event type
@@ -115,7 +119,8 @@ export class EventTypesAtomService {
user: UserWithProfile,
teamId: number
) {
await this.checkCanUpdateTeamEventType(user.id, eventTypeId, teamId, body.scheduleId);
await this.checkCanUpdateTeamEventType(user, eventTypeId, teamId, body.scheduleId);
const eventTypeUser = await this.eventTypeService.getUserToUpdateEvent(user);
const bookingFields = body.bookingFields ? [...body.bookingFields] : undefined;
@@ -175,14 +180,31 @@ export class EventTypesAtomService {
}
async checkCanUpdateTeamEventType(
userId: number,
user: UserWithProfile,
eventTypeId: number,
teamId: number,
scheduleId: number | null | undefined
) {
await this.checkTeamOwnsEventType(userId, eventTypeId, teamId);
const organizationId = this.usersService.getUserMainOrgId(user);
if (organizationId) {
const isUserOrganizationAdmin = await this.membershipsRepository.isUserOrganizationAdmin(
user.id,
organizationId
);
if (isUserOrganizationAdmin) {
const orgTeam = await this.organizationsTeamsRepository.findOrgTeam(organizationId, teamId);
if (orgTeam) {
await this.teamEventTypeService.validateEventTypeExists(teamId, eventTypeId);
return;
}
}
}
await this.checkTeamOwnsEventType(user.id, eventTypeId, teamId);
await this.teamEventTypeService.validateEventTypeExists(teamId, eventTypeId);
await this.eventTypeService.checkUserOwnsSchedule(userId, scheduleId);
await this.eventTypeService.checkUserOwnsSchedule(user.id, scheduleId);
}
async checkTeamOwnsEventType(userId: number, eventTypeId: number, teamId: number) {
@@ -1,4 +1,4 @@
import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key";
import { sha256Hash, isApiKey, stripApiKey } from "@/lib/api-key";
import { AuthMethods } from "@/lib/enums/auth-methods";
import { isOriginAllowed } from "@/lib/is-origin-allowed/is-origin-allowed";
import { BaseStrategy } from "@/lib/passport/strategies/types";
@@ -226,7 +226,7 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
);
}
const strippedApiKey = stripApiKey(apiKey, this.config.get<string>("api.keyPrefix"));
const apiKeyHash = hashAPIKey(strippedApiKey);
const apiKeyHash = sha256Hash(strippedApiKey);
const keyData = await this.apiKeyRepository.getApiKeyFromHash(apiKeyHash);
if (!keyData) {
throw new UnauthorizedException("ApiAuthStrategy - api key - Your api key is not valid");
@@ -27,6 +27,17 @@ export class OrganizationsMembershipService {
return this.organizationsMembershipOutputService.getOrgMembershipOutput(membership);
}
async isOrgAdminOrOwner(organizationId: number, userId: number) {
const membership = await this.organizationsMembershipRepository.findOrgMembershipByUserId(
organizationId,
userId
);
if (!membership) {
return false;
}
return membership.role === "ADMIN" || membership.role === "OWNER";
}
async getOrgMembershipByUserId(organizationId: number, userId: number) {
const membership = await this.organizationsMembershipRepository.findOrgMembershipByUserId(
organizationId,
@@ -1,7 +1,7 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { getEnv } from "@/env";
import { hashAPIKey, stripApiKey } from "@/lib/api-key";
import { sha256Hash, stripApiKey } from "@/lib/api-key";
import { RefreshApiKeyOutput } from "@/modules/api-keys/outputs/refresh-api-key.output";
import { CreateOAuthClientResponseDto } from "@/modules/oauth-clients/controllers/oauth-clients/responses/CreateOAuthClientResponse.dto";
import { GetOAuthClientResponseDto } from "@/modules/oauth-clients/controllers/oauth-clients/responses/GetOAuthClientResponse.dto";
@@ -283,7 +283,7 @@ describe("Organizations Organizations Endpoints", () => {
expect(managedOrgApiKeys?.length).toEqual(1);
expect(managedOrgApiKeys?.[0]?.id).toBeDefined();
const apiKeyPrefix = getEnv("API_KEY_PREFIX", "cal_");
const hashedApiKey = `${hashAPIKey(stripApiKey(managedOrg?.apiKey, apiKeyPrefix))}`;
const hashedApiKey = `${sha256Hash(stripApiKey(managedOrg?.apiKey, apiKeyPrefix))}`;
expect(managedOrgApiKeys?.[0]?.hashedKey).toEqual(hashedApiKey);
const expectedExpiresAt = DateTime.fromJSDate(newDate).setZone("utc").plus({ days: 30 }).toJSDate();
expect(managedOrgApiKeys?.[0]?.expiresAt).toEqual(expectedExpiresAt);
@@ -484,7 +484,7 @@ describe("Organizations Organizations Endpoints", () => {
expect(managedOrgApiKeys?.length).toEqual(1);
expect(managedOrgApiKeys?.[0]?.id).toBeDefined();
const apiKeyPrefix = getEnv("API_KEY_PREFIX", "cal_");
const hashedApiKey = `${hashAPIKey(stripApiKey(newApiKey, apiKeyPrefix))}`;
const hashedApiKey = `${sha256Hash(stripApiKey(newApiKey, apiKeyPrefix))}`;
expect(managedOrgApiKeys?.[0]?.hashedKey).toEqual(hashedApiKey);
const expectedExpiresAt = DateTime.fromJSDate(newDate).setZone("utc").plus({ days: 60 }).toJSDate();
expect(managedOrgApiKeys?.[0]?.expiresAt).toEqual(expectedExpiresAt);
@@ -14,6 +14,7 @@ import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-a
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
import { OrganizationsMembershipService } from "@/modules/organizations/memberships/services/organizations-membership.service";
import { CreateOrgTeamDto } from "@/modules/organizations/teams/index/inputs/create-organization-team.input";
import { UpdateOrgTeamDto } from "@/modules/organizations/teams/index/inputs/update-organization-team.input";
import {
@@ -56,7 +57,10 @@ import { Team } from "@calcom/prisma/client";
@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER)
@ApiHeader(OPTIONAL_API_KEY_HEADER)
export class OrganizationsTeamsController {
constructor(private organizationsTeamsService: OrganizationsTeamsService) {}
constructor(
private organizationsTeamsService: OrganizationsTeamsService,
private organizationsMembershipService: OrganizationsMembershipService
) {}
@Get()
@ApiOperation({ summary: "Get all teams" })
@@ -84,12 +88,11 @@ export class OrganizationsTeamsController {
@GetUser() user: UserWithProfile
): Promise<OrgMeTeamsOutputResponseDto> {
const { skip, take } = queryParams;
const teams = await this.organizationsTeamsService.getPaginatedOrgUserTeams(
orgId,
user.id,
skip ?? 0,
take ?? 250
);
const isOrgAdminOrOwner = await this.organizationsMembershipService.isOrgAdminOrOwner(orgId, user.id);
const teams = isOrgAdminOrOwner
? await this.organizationsTeamsService.getPaginatedOrgTeamsWithMembers(orgId, skip ?? 0, take ?? 250)
: await this.organizationsTeamsService.getPaginatedOrgUserTeams(orgId, user.id, skip ?? 0, take ?? 250);
return {
status: SUCCESS_STATUS,
data: teams.map((team) => {
@@ -107,4 +107,17 @@ export class OrganizationsTeamsRepository {
take,
});
}
async findOrgTeamsPaginatedWithMembers(organizationId: number, skip: number, take: number) {
return this.dbRead.prisma.team.findMany({
where: {
parentId: organizationId,
},
include: {
members: { select: { accepted: true, userId: true, role: true } },
},
skip,
take,
});
}
}
@@ -24,6 +24,15 @@ export class OrganizationsTeamsService {
return teams;
}
async getPaginatedOrgTeamsWithMembers(organizationId: number, skip = 0, take = 250) {
const teams = await this.organizationsTeamRepository.findOrgTeamsPaginatedWithMembers(
organizationId,
skip,
take
);
return teams;
}
async getPaginatedOrgTeams(organizationId: number, skip = 0, take = 250) {
const teams = await this.organizationsTeamRepository.findOrgTeamsPaginated(organizationId, skip, take);
return teams;
@@ -16,7 +16,7 @@ import { AttendeeRepositoryFixture } from "test/fixtures/repository/attendee.rep
import { BookingSeatRepositoryFixture } from "test/fixtures/repository/booking-seat.repository.fixture";
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
import { SelectedSlotsRepositoryFixture } from "test/fixtures/repository/selected-slots.repository.fixture";
import { SelectedSlotRepositoryFixture } from "test/fixtures/repository/selected-slot.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { randomString } from "test/utils/randomString";
import { withApiAuth } from "test/utils/withApiAuth";
@@ -250,7 +250,7 @@ describe("Slots 2024-04-15 Endpoints", () => {
let userRepositoryFixture: UserRepositoryFixture;
let schedulesService: SchedulesService_2024_06_11;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let selectedSlotsRepositoryFixture: SelectedSlotsRepositoryFixture;
let selectedSlotRepositoryFixture: SelectedSlotRepositoryFixture;
let bookingsRepositoryFixture: BookingsRepositoryFixture;
let bookingSeatsRepositoryFixture: BookingSeatRepositoryFixture;
let attendeesRepositoryFixture: AttendeeRepositoryFixture;
@@ -288,7 +288,7 @@ describe("Slots 2024-04-15 Endpoints", () => {
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
schedulesService = moduleRef.get<SchedulesService_2024_06_11>(SchedulesService_2024_06_11);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
selectedSlotsRepositoryFixture = new SelectedSlotsRepositoryFixture(moduleRef);
selectedSlotRepositoryFixture = new SelectedSlotRepositoryFixture(moduleRef);
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
bookingSeatsRepositoryFixture = new BookingSeatRepositoryFixture(moduleRef);
attendeesRepositoryFixture = new AttendeeRepositoryFixture(moduleRef);
@@ -773,7 +773,7 @@ describe("Slots 2024-04-15 Endpoints", () => {
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(user.email);
await selectedSlotsRepositoryFixture.deleteByUId(reservedSlotUid);
await selectedSlotRepositoryFixture.deleteByUId(reservedSlotUid);
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
await app.close();
@@ -21,7 +21,7 @@ import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repo
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { SelectedSlotsRepositoryFixture } from "test/fixtures/repository/selected-slots.repository.fixture";
import { SelectedSlotRepositoryFixture } from "test/fixtures/repository/selected-slot.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { randomString } from "test/utils/randomString";
@@ -44,7 +44,7 @@ describe("Slots 2024-09-04 Endpoints", () => {
let membershipsRepositoryFixture: MembershipRepositoryFixture;
let bookingsRepositoryFixture: BookingsRepositoryFixture;
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
let selectedSlotsRepositoryFixture: SelectedSlotsRepositoryFixture;
let selectedSlotRepositoryFixture: SelectedSlotRepositoryFixture;
const teammateEmailOne = `slots-2024-09-04-user-1-team-slots-${randomString()}`;
let teammateApiKeyString: string;
@@ -93,7 +93,7 @@ describe("Slots 2024-09-04 Endpoints", () => {
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
selectedSlotsRepositoryFixture = new SelectedSlotsRepositoryFixture(moduleRef);
selectedSlotRepositoryFixture = new SelectedSlotRepositoryFixture(moduleRef);
teammateOne = await userRepositoryFixture.create({
email: teammateEmailOne,
@@ -368,14 +368,14 @@ describe("Slots 2024-09-04 Endpoints", () => {
);
expect(slots).toEqual({ ...expectedSlotsUTC, "2050-09-05": expectedSlotsUTC2050_09_05 });
const dbSlot = await selectedSlotsRepositoryFixture.getByUid(reservedSlot.reservationUid);
const dbSlot = await selectedSlotRepositoryFixture.getByUid(reservedSlot.reservationUid);
expect(dbSlot).toBeDefined();
if (dbSlot) {
const dbReleaseAt = DateTime.fromJSDate(dbSlot.releaseAt, { zone: "UTC" }).toISO();
const expectedReleaseAt = DateTime.fromISO(now, { zone: "UTC" }).plus({ minutes: 10 }).toISO();
expect(dbReleaseAt).toEqual(expectedReleaseAt);
}
await selectedSlotsRepositoryFixture.deleteByUId(reservedSlot.reservationUid);
await selectedSlotRepositoryFixture.deleteByUId(reservedSlot.reservationUid);
clear();
});
@@ -30,7 +30,7 @@ import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.rep
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { OOORepositoryFixture } from "test/fixtures/repository/ooo.repository.fixture";
import { SelectedSlotsRepositoryFixture } from "test/fixtures/repository/selected-slots.repository.fixture";
import { SelectedSlotRepositoryFixture } from "test/fixtures/repository/selected-slot.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { randomString } from "test/utils/randomString";
@@ -49,7 +49,7 @@ describe("Slots 2024-09-04 Endpoints", () => {
let userRepositoryFixture: UserRepositoryFixture;
let schedulesService: SchedulesService_2024_06_11;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let selectedSlotsRepositoryFixture: SelectedSlotsRepositoryFixture;
let selectedSlotRepositoryFixture: SelectedSlotRepositoryFixture;
let bookingsRepositoryFixture: BookingsRepositoryFixture;
let bookingSeatsRepositoryFixture: BookingSeatRepositoryFixture;
let attendeesRepositoryFixture: AttendeeRepositoryFixture;
@@ -104,7 +104,7 @@ describe("Slots 2024-09-04 Endpoints", () => {
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
schedulesService = moduleRef.get<SchedulesService_2024_06_11>(SchedulesService_2024_06_11);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
selectedSlotsRepositoryFixture = new SelectedSlotsRepositoryFixture(moduleRef);
selectedSlotRepositoryFixture = new SelectedSlotRepositoryFixture(moduleRef);
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
bookingSeatsRepositoryFixture = new BookingSeatRepositoryFixture(moduleRef);
attendeesRepositoryFixture = new AttendeeRepositoryFixture(moduleRef);
@@ -483,7 +483,7 @@ describe("Slots 2024-09-04 Endpoints", () => {
);
expect(slots).toEqual({ ...expectedSlotsUTC, "2050-09-05": expectedSlotsUTC2050_09_05 });
const dbSlot = await selectedSlotsRepositoryFixture.getByUid(reservedSlot.reservationUid);
const dbSlot = await selectedSlotRepositoryFixture.getByUid(reservedSlot.reservationUid);
expect(dbSlot).toBeDefined();
if (dbSlot) {
const dbReleaseAt = DateTime.fromJSDate(dbSlot.releaseAt, { zone: "UTC" }).toISO();
@@ -617,7 +617,7 @@ describe("Slots 2024-09-04 Endpoints", () => {
);
expect(slots).toEqual({ ...expectedSlotsUTC, "2050-09-05": expectedSlotsUTC2050_09_05 });
const dbSlot = await selectedSlotsRepositoryFixture.getByUid(reservedSlot.reservationUid);
const dbSlot = await selectedSlotRepositoryFixture.getByUid(reservedSlot.reservationUid);
expect(dbSlot).toBeDefined();
if (dbSlot) {
const dbReleaseAt = DateTime.fromJSDate(dbSlot.releaseAt, { zone: "UTC" }).toISO();
@@ -719,14 +719,14 @@ describe("Slots 2024-09-04 Endpoints", () => {
);
expect(slots).toEqual({ ...expectedSlotsUTC, "2050-09-05": expectedSlotsUTC2050_09_05 });
const dbSlot = await selectedSlotsRepositoryFixture.getByUid(reservedSlot.reservationUid);
const dbSlot = await selectedSlotRepositoryFixture.getByUid(reservedSlot.reservationUid);
expect(dbSlot).toBeDefined();
if (dbSlot) {
const dbReleaseAt = DateTime.fromJSDate(dbSlot.releaseAt, { zone: "UTC" }).toISO();
const expectedReleaseAt = DateTime.fromISO(now, { zone: "UTC" }).plus({ minutes: 10 }).toISO();
expect(dbReleaseAt).toEqual(expectedReleaseAt);
}
await selectedSlotsRepositoryFixture.deleteByUId(reservedSlot.reservationUid);
await selectedSlotRepositoryFixture.deleteByUId(reservedSlot.reservationUid);
clear();
});
@@ -775,14 +775,14 @@ describe("Slots 2024-09-04 Endpoints", () => {
);
expect(slots).toEqual({ ...expectedSlotsUTC, "2050-09-05": expectedSlotsUTC2050_09_05 });
const dbSlot = await selectedSlotsRepositoryFixture.getByUid(reservedSlot.reservationUid);
const dbSlot = await selectedSlotRepositoryFixture.getByUid(reservedSlot.reservationUid);
expect(dbSlot).toBeDefined();
if (dbSlot) {
const dbReleaseAt = DateTime.fromJSDate(dbSlot.releaseAt, { zone: "UTC" }).toISO();
const expectedReleaseAt = DateTime.fromISO(now, { zone: "UTC" }).plus({ minutes: 10 }).toISO();
expect(dbReleaseAt).toEqual(expectedReleaseAt);
}
await selectedSlotsRepositoryFixture.deleteByUId(reservedSlot.reservationUid);
await selectedSlotRepositoryFixture.deleteByUId(reservedSlot.reservationUid);
clear();
});
@@ -1397,7 +1397,7 @@ describe("Slots 2024-09-04 Endpoints", () => {
);
expect(slots).toEqual({ ...expectedSlotsUTC, "2050-09-05": expectedSlotsUTC2050_09_05 });
const dbSlot = await selectedSlotsRepositoryFixture.getByUid(
const dbSlot = await selectedSlotRepositoryFixture.getByUid(
responseReservedVariableSlot.reservationUid
);
expect(dbSlot).toBeDefined();
@@ -1674,7 +1674,7 @@ describe("Slots 2024-09-04 Endpoints", () => {
);
expect(responseReservedSlot.reservationDuration).toEqual(5);
await bookingsRepositoryFixture.deleteById(booking.id);
await selectedSlotsRepositoryFixture.deleteByUId(responseReservedSlot.reservationUid);
await selectedSlotRepositoryFixture.deleteByUId(responseReservedSlot.reservationUid);
});
it("should be able to reserve a slot if booking is rejected during that time", async () => {
@@ -1872,7 +1872,7 @@ describe("Slots 2024-09-04 Endpoints", () => {
expect(reserveResponse.body.data.reservationUid).toBeDefined();
await selectedSlotsRepositoryFixture.deleteByUId(reserveResponse.body.data.reservationUid);
await selectedSlotRepositoryFixture.deleteByUId(reserveResponse.body.data.reservationUid);
await bookingsRepositoryFixture.deleteById(existingBooking.id);
});
@@ -1888,7 +1888,7 @@ describe("Slots 2024-09-04 Endpoints", () => {
expect(reserveResponse.body.data.reservationUid).toBeDefined();
await selectedSlotsRepositoryFixture.deleteByUId(reserveResponse.body.data.reservationUid);
await selectedSlotRepositoryFixture.deleteByUId(reserveResponse.body.data.reservationUid);
await bookingsRepositoryFixture.deleteById(existingBooking.id);
});
@@ -1903,7 +1903,7 @@ describe("Slots 2024-09-04 Endpoints", () => {
.expect(201);
expect(reserveResponse.body.data.reservationUid).toBeDefined();
await selectedSlotsRepositoryFixture.deleteByUId(reserveResponse.body.data.reservationUid);
await selectedSlotRepositoryFixture.deleteByUId(reserveResponse.body.data.reservationUid);
await bookingsRepositoryFixture.deleteById(existingBooking.id);
});
@@ -1918,7 +1918,7 @@ describe("Slots 2024-09-04 Endpoints", () => {
.expect(201);
expect(reserveResponse.body.data.reservationUid).toBeDefined();
await selectedSlotsRepositoryFixture.deleteByUId(reserveResponse.body.data.reservationUid);
await selectedSlotRepositoryFixture.deleteByUId(reserveResponse.body.data.reservationUid);
await bookingsRepositoryFixture.deleteById(existingBooking.id);
});
});
@@ -1928,7 +1928,7 @@ describe("Slots 2024-09-04 Endpoints", () => {
await userRepositoryFixture.deleteByEmail(user.email);
await userRepositoryFixture.deleteByEmail(oooTestUserEmail);
await userRepositoryFixture.deleteByEmail(unrelatedUser.email);
await selectedSlotsRepositoryFixture.deleteByUId(reservedSlot.reservationUid);
await selectedSlotRepositoryFixture.deleteByUId(reservedSlot.reservationUid);
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
await teamRepositoryFixture.delete(team.id);
clear();
+4
View File
@@ -15705,6 +15705,10 @@
"redirectUrlOnExit": {
"type": "object",
"description": "URL to which participants are redirected when they exit the call"
},
"enableAutomaticRecordingForOrganizer": {
"type": "boolean",
"description": "If true, enables the automatic recording for the event when organizer joins the call"
}
}
},
@@ -3,11 +3,11 @@ import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { TestingModule } from "@nestjs/testing";
import { SelectedSlots } from "@prisma/client";
export class SelectedSlotsRepositoryFixture {
export class SelectedSlotRepositoryFixture {
private prismaReadClient: PrismaReadService["prisma"];
private prismaWriteClient: PrismaWriteService["prisma"];
constructor(private readonly module: TestingModule) {
constructor(module: TestingModule) {
this.prismaReadClient = module.get(PrismaReadService).prisma;
this.prismaWriteClient = module.get(PrismaWriteService).prisma;
}
+2 -1
View File
@@ -26,7 +26,8 @@
"@calcom/platform-libraries/app-store": ["../../../packages/platform/libraries/app-store.ts"],
"@calcom/platform-libraries/workflows": ["../../../packages/platform/libraries/workflows.ts"],
"@calcom/platform-libraries/conferencing": ["../../../packages/platform/libraries/conferencing.ts"],
"@calcom/platform-libraries/repositories": ["../../../packages/platform/libraries/repositories.ts"]
"@calcom/platform-libraries/repositories": ["../../../packages/platform/libraries/repositories.ts"],
"@calcom/platform-libraries/bookings": ["../../../packages/platform/libraries/bookings.ts"]
},
"incremental": true,
"skipLibCheck": true,
+1 -1
View File
@@ -34,6 +34,6 @@
"eslint-config-next": "15.1.6",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"typescript": "^5"
"typescript": "^5.9.0-beta"
}
}
@@ -4,7 +4,7 @@ import PageWrapper from "@components/PageWrapperAppDir";
export default async function BookingPageWrapperLayout({ children }: { children: React.ReactNode }) {
const h = await headers();
const nonce = h.get("x-nonce") ?? undefined;
const nonce = h.get("x-csp-nonce") ?? undefined;
return (
<>
@@ -7,6 +7,7 @@ import { cookies, headers } from "next/headers";
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { loadTranslations } from "@calcom/lib/server/i18n";
import { prisma } from "@calcom/prisma";
import { buildLegacyCtx, decodeParams } from "@lib/buildLegacyCtx";
import { getServerSideProps } from "@lib/team/[slug]/[type]/getServerSideProps";
@@ -17,7 +18,7 @@ import type { PageProps as LegacyPageProps } from "~/team/type-view";
import CachedTeamBooker, { generateMetadata as generateCachedMetadata } from "./pageWithCachedData";
async function isCachedTeamBookingEnabled(searchParams: SearchParams): Promise<boolean> {
const featuresRepository = new FeaturesRepository();
const featuresRepository = new FeaturesRepository(prisma);
const isGloballyEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally(
"team-booking-page-cache"
);
@@ -102,7 +102,7 @@ export async function getEnrichedEventType({
}
export async function shouldUseApiV2ForTeamSlots(teamId: number): Promise<boolean> {
const featureRepo = new FeaturesRepository();
const featureRepo = new FeaturesRepository(prisma);
const teamHasApiV2Route = await featureRepo.checkIfTeamHasFeature(teamId, "use-api-v2-for-team-slots");
const useApiV2 = teamHasApiV2Route && Boolean(process.env.NEXT_PUBLIC_API_V2_URL);
@@ -4,11 +4,12 @@ import { notFound } from "next/navigation";
import { CTA_CONTAINER_CLASS_NAME } from "@calcom/features/data-table/lib/utils";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import Shell from "@calcom/features/shell/Shell";
import { prisma } from "@calcom/prisma";
import UpgradeTipWrapper from "./UpgradeTipWrapper";
export default async function InsightsLayout({ children }: { children: React.ReactNode }) {
const featuresRepository = new FeaturesRepository();
const featuresRepository = new FeaturesRepository(prisma);
const insightsEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("insights");
if (!insightsEnabled) {
+1 -1
View File
@@ -5,7 +5,7 @@ import PageWrapper from "@components/PageWrapperAppDir";
export default async function PageWrapperLayout({ children }: { children: React.ReactNode }) {
const h = await headers();
const nonce = h.get("x-nonce") ?? undefined;
const nonce = h.get("x-csp-nonce") ?? undefined;
const headScript = process.env.NEXT_PUBLIC_HEAD_SCRIPTS;
const bodyScript = process.env.NEXT_PUBLIC_BODY_SCRIPTS;
@@ -1,7 +1,7 @@
"use client";
import { BookingsByHourChartContent } from "@calcom/features/insights/components/BookingsByHourChart";
import { ChartCard } from "@calcom/features/insights/components/ChartCard";
import { BookingsByHourChartContent } from "@calcom/features/insights/components/booking/BookingsByHourChart";
import { useLocale } from "@calcom/lib/hooks/useLocale";
// Sample data for playground testing
@@ -1,7 +1,10 @@
"use client";
import { ChartCard } from "@calcom/features/insights/components/ChartCard";
import { RoutingFunnelContent, legend } from "@calcom/features/insights/components/RoutingFunnelContent";
import {
RoutingFunnelContent,
legend,
} from "@calcom/features/insights/components/routing/RoutingFunnelContent";
import { useLocale } from "@calcom/lib/hooks/useLocale";
// Random sample data for playground testing
@@ -5,7 +5,7 @@ import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { APP_NAME } from "@calcom/lib/constants";
import { ApiKeyRepository } from "@calcom/lib/server/repository/apiKey";
import { PrismaApiKeyRepository } from "@calcom/lib/server/repository/PrismaApiKeyRepository";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
@@ -22,7 +22,7 @@ export const generateMetadata = async () =>
const getCachedApiKeys = unstable_cache(
async (userId: number) => {
return await ApiKeyRepository.findApiKeysFromUserId({ userId });
return await PrismaApiKeyRepository.findApiKeysFromUserId({ userId });
},
undefined,
{ revalidate: 3600, tags: ["viewer.apiKeys.list"] } // Cache for 1 hour
@@ -1,7 +1,7 @@
"use server";
import { revalidatePath } from "next/cache";
import { revalidateTag } from "next/cache";
export async function revalidateWebhooksList() {
revalidatePath("/settings/developer/webhooks");
export async function revalidateWebhooksListGetByViewer() {
revalidateTag("viewer.webhook.getByViewer");
}
@@ -1,13 +1,13 @@
import { createRouterCaller } from "app/_trpc/context";
import { _generateMetadata } from "app/_utils";
import { unstable_cache } from "next/cache";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import WebhooksView from "@calcom/features/webhooks/pages/webhooks-view";
import { APP_NAME } from "@calcom/lib/constants";
import { WebhookRepository } from "@calcom/lib/server/repository/webhook";
import { UserPermissionRole } from "@calcom/prisma/enums";
import { webhookRouter } from "@calcom/trpc/server/routers/viewer/webhook/_router";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
@@ -20,6 +20,17 @@ export const generateMetadata = async () =>
"/settings/developer/webhooks"
);
const getCachedWebhooksList = unstable_cache(
async ({ userId, userRole }: { userId: number; userRole?: UserPermissionRole }) => {
return await WebhookRepository.getAllWebhooksByUserId({
userId,
userRole,
});
},
undefined,
{ revalidate: 3600, tags: ["viewer.webhook.getByViewer"] }
);
const WebhooksViewServerWrapper = async () => {
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user?.id) {
@@ -27,8 +38,9 @@ const WebhooksViewServerWrapper = async () => {
}
const isAdmin = session.user.role === UserPermissionRole.ADMIN;
const caller = await createRouterCaller(webhookRouter);
const data = await caller.getByViewer();
const userRole = session.user.role !== "INACTIVE_ADMIN" ? session.user.role : undefined;
const data = await getCachedWebhooksList({ userId: session.user.id, userRole });
return <WebhooksView data={data} isAdmin={isAdmin} />;
};
@@ -0,0 +1,7 @@
"use server";
import { revalidateTag } from "next/cache";
export async function revalidateWebhookById(id: string) {
revalidateTag(`viewer.webhook.get:${id}`);
}
@@ -1,5 +1,6 @@
import type { PageProps } from "app/_types";
import { getTranslate, _generateMetadata } from "app/_utils";
import { unstable_cache } from "next/cache";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { EditWebhookView } from "@calcom/features/webhooks/pages/webhook-edit-view";
@@ -15,12 +16,24 @@ export const generateMetadata = async ({ params }: { params: Promise<{ id: strin
`/settings/developer/webhooks/${(await params).id}`
);
const getCachedWebhook = (id?: string) => {
const fn = unstable_cache(
async () => {
return await WebhookRepository.findByWebhookId(id);
},
undefined,
{ revalidate: 3600, tags: [`viewer.webhook.get:${id}`] }
);
return fn();
};
const Page = async ({ params: _params }: PageProps) => {
const t = await getTranslate();
const params = await _params;
const id = typeof params?.id === "string" ? params.id : undefined;
const webhook = await WebhookRepository.findByWebhookId(id);
const webhook = await getCachedWebhook(id);
return (
<SettingsHeader
@@ -0,0 +1,7 @@
"use server";
import { revalidateTag } from "next/cache";
export async function revalidateWebhookList() {
revalidateTag("viewer.webhook.list");
}
@@ -1,10 +1,16 @@
import { createRouterCaller } from "app/_trpc/context";
import { _generateMetadata } from "app/_utils";
import { unstable_cache } from "next/cache";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { NewWebhookView } from "@calcom/features/webhooks/pages/webhook-new-view";
import { APP_NAME } from "@calcom/lib/constants";
import { WebhookRepository } from "@calcom/lib/server/repository/webhook";
import { appsRouter } from "@calcom/trpc/server/routers/viewer/apps/_router";
import { webhookRouter } from "@calcom/trpc/server/routers/viewer/webhook/_router";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
export const generateMetadata = async () =>
await _generateMetadata(
@@ -15,15 +21,25 @@ export const generateMetadata = async () =>
"/settings/developer/webhooks/new"
);
const getCachedWebhooksList = unstable_cache(
async ({ userId }: { userId: number }) => {
return await WebhookRepository.findWebhooksByFilters({ userId });
},
undefined,
{ revalidate: 3600, tags: ["viewer.webhook.list"] }
);
const Page = async () => {
const [appsCaller, webhookCaller] = await Promise.all([
createRouterCaller(appsRouter),
createRouterCaller(webhookRouter),
]);
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user?.id) {
redirect("/auth/login");
}
const appsCaller = await createRouterCaller(appsRouter);
const [installedApps, webhooks] = await Promise.all([
appsCaller.integrations({ variant: "other", onlyInstalled: true }),
webhookCaller.list(),
getCachedWebhooksList({ userId: session.user.id }),
]);
return <NewWebhookView webhooks={webhooks} installedApps={installedApps} />;
@@ -9,6 +9,7 @@ import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { PermissionMapper } from "@calcom/features/pbac/domain/mappers/PermissionMapper";
import { Resource, CrudAction } from "@calcom/features/pbac/domain/types/permission-registry";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { prisma } from "@calcom/prisma";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
@@ -17,7 +18,7 @@ import SettingsLayoutAppDirClient from "./SettingsLayoutAppDirClient";
const getTeamFeatures = unstable_cache(
async (teamId: number) => {
const featuresRepository = new FeaturesRepository();
const featuresRepository = new FeaturesRepository(prisma);
return await featuresRepository.getTeamFeatures(teamId);
},
["team-features"],
@@ -1,7 +1,15 @@
import { _generateMetadata, getTranslate } from "app/_utils";
import { headers, cookies } from "next/headers";
import { redirect } from "next/navigation";
import OrgSettingsAttributesPage from "@calcom/ee/organizations/pages/settings/attributes/attributes-list-view";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { MembershipRole } from "@calcom/prisma/enums";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
export const generateMetadata = async () =>
await _generateMetadata(
@@ -14,10 +22,40 @@ export const generateMetadata = async () =>
const Page = async () => {
const t = await getTranslate();
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user.id || !session?.user.profile?.organizationId || !session?.user.org) {
return redirect("/settings/profile");
}
const { canRead, canEdit, canDelete, canCreate } = await getResourcePermissions({
userId: session.user.id,
teamId: session.user.profile.organizationId,
resource: Resource.Attributes,
userRole: session.user.org.role,
fallbackRoles: {
read: {
roles: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER],
},
update: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
delete: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
create: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
},
});
if (!canRead) {
return redirect("/settings/profile");
}
return (
<SettingsHeader title={t("attributes")} description={t("attribute_meta_description")}>
<OrgSettingsAttributesPage />
<OrgSettingsAttributesPage permissions={{ canEdit, canDelete, canCreate }} />
</SettingsHeader>
);
};
@@ -1,7 +1,15 @@
import { _generateMetadata, getTranslate } from "app/_utils";
import { headers, cookies } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import DirectorySyncTeamView from "@calcom/features/ee/dsync/page/team-dsync-view";
import { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { MembershipRole } from "@calcom/prisma/enums";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
export const generateMetadata = async () =>
await _generateMetadata(
@@ -14,10 +22,27 @@ export const generateMetadata = async () =>
const Page = async () => {
const t = await getTranslate();
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user.id || !session?.user.profile?.organizationId || !session?.user.org) {
return redirect("/settings/organizations/general");
}
const { canEdit } = await getResourcePermissions({
userId: session.user.id,
teamId: session.user.profile.organizationId,
resource: Resource.Organization,
userRole: session.user.org.role,
fallbackRoles: {
update: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
},
});
return (
<SettingsHeader title={t("directory_sync")} description={t("directory_sync_description")}>
<DirectorySyncTeamView />
<DirectorySyncTeamView permissions={{ canEdit }} />
</SettingsHeader>
);
};
@@ -1,7 +1,15 @@
import { _generateMetadata, getTranslate } from "app/_utils";
import { headers, cookies } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import PrivacyView from "@calcom/features/ee/organizations/pages/settings/privacy";
import { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { MembershipRole } from "@calcom/prisma/enums";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
export const generateMetadata = async () =>
await _generateMetadata(
@@ -15,9 +23,34 @@ export const generateMetadata = async () =>
const Page = async () => {
const t = await getTranslate();
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user.id || !session?.user.profile?.organizationId || !session?.user.org) {
return redirect("/settings/profile");
}
const { canRead, canEdit } = await getResourcePermissions({
userId: session.user.id,
teamId: session.user.profile.organizationId,
resource: Resource.Organization,
userRole: session.user.org.role,
fallbackRoles: {
read: {
roles: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER],
},
update: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
},
});
if (!canRead) {
return redirect("/settings/profile");
}
return (
<SettingsHeader title={t("privacy")} description={t("privacy_organization_description")}>
<PrivacyView />
<PrivacyView permissions={{ canRead, canEdit }} />
</SettingsHeader>
);
};
@@ -1,7 +1,15 @@
import { _generateMetadata, getTranslate } from "app/_utils";
import { headers, cookies } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import OrgSSOView from "@calcom/features/ee/sso/page/orgs-sso-view";
import { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { MembershipRole } from "@calcom/prisma/enums";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
export const generateMetadata = async () =>
await _generateMetadata(
@@ -14,10 +22,27 @@ export const generateMetadata = async () =>
const Page = async () => {
const t = await getTranslate();
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user.id || !session?.user.profile?.organizationId || !session?.user.org) {
return redirect("/settings/organizations/general");
}
const { canEdit } = await getResourcePermissions({
userId: session.user.id,
teamId: session.user.profile.organizationId,
resource: Resource.Organization,
userRole: session.user.org.role,
fallbackRoles: {
update: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
},
});
return (
<SettingsHeader title={t("sso_configuration")} description={t("sso_configuration_description_orgs")}>
<OrgSSOView />
<OrgSSOView permissions={{ canEdit }} />
</SettingsHeader>
);
};
@@ -1,7 +1,15 @@
import { _generateMetadata, getTranslate } from "app/_utils";
import { headers, cookies } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import LegacyPage from "@calcom/features/ee/organizations/pages/settings/general";
import { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { MembershipRole } from "@calcom/prisma/enums";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
export const generateMetadata = async () =>
await _generateMetadata(
@@ -15,9 +23,30 @@ export const generateMetadata = async () =>
const Page = async () => {
const t = await getTranslate();
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user.id || !session?.user.profile?.organizationId || !session?.user.org) {
return redirect("/settings/profile");
}
const { canRead, canEdit } = await getResourcePermissions({
userId: session.user.id,
teamId: session.user.profile.organizationId,
resource: Resource.Organization,
userRole: session.user.org.role,
fallbackRoles: {
read: {
roles: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER],
},
update: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
},
});
return (
<SettingsHeader title={t("general")} description={t("general_description")} borderInShellHeader={true}>
<LegacyPage />
<LegacyPage permissions={{ canRead, canEdit }} />
</SettingsHeader>
);
};
@@ -1,7 +1,16 @@
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 LegacyPage from "@calcom/features/ee/organizations/pages/settings/profile";
import { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import type { Membership } from "@calcom/prisma/client";
import { MembershipRole } from "@calcom/prisma/enums";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
export const generateMetadata = async () =>
await _generateMetadata(
@@ -13,14 +22,47 @@ export const generateMetadata = async () =>
);
const Page = async () => {
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
const t = await getTranslate();
const orgRole = session?.user.profile?.organization.members?.find(
(member: Membership) => member.userId === session?.user.id
)?.role;
if (!session?.user.id || !session?.user.profile?.organizationId || !orgRole) {
return redirect("/settings/profile");
}
const { canRead, canEdit, canDelete } = await getResourcePermissions({
userId: session.user.id,
teamId: session?.user.profile?.organizationId,
resource: Resource.Organization,
userRole: orgRole,
fallbackRoles: {
read: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
update: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
delete: {
roles: [MembershipRole.OWNER],
},
},
});
return (
<SettingsHeader
title={t("profile")}
description={t("profile_org_description")}
borderInShellHeader={true}>
<LegacyPage />
<LegacyPage
permissions={{
canEdit,
canRead,
canDelete,
}}
/>
</SettingsHeader>
);
};
@@ -44,5 +44,17 @@ describe("usePermissions", () => {
expect(getResourcePermissionLevel("eventType", permissions)).toBe("all");
});
it("should return 'all' for resource with manage permission", () => {
const permissions = ["eventType.manage"];
expect(getResourcePermissionLevel("eventType", permissions)).toBe("all");
});
it("should return 'all' for resource with manage permission even if other permissions are missing", () => {
const permissions = ["eventType.manage", "eventType.read"]; // Has manage and read, but missing create, update, delete
expect(getResourcePermissionLevel("eventType", permissions)).toBe("all");
});
});
});
@@ -51,14 +51,20 @@ export function usePermissions(): UsePermissionsReturn {
return "all";
}
// Filter out internal keys like _resource when checking permissions
const allResourcePerms = Object.keys(resourceConfig)
.filter((action) => !action.startsWith("_"))
// Check if user has manage permission for this resource
const hasManagePermission = permissions.includes(`${resource}.manage`);
if (hasManagePermission) {
return "all";
}
// Filter out internal keys like _resource and manage when checking for individual permissions
const crudPermissions = Object.keys(resourceConfig)
.filter((action) => !action.startsWith("_") && action !== "manage")
.map((action) => `${resource}.${action}`);
const hasAllPerms = allResourcePerms.every((p) => permissions.includes(p));
const hasAllCrudPerms = crudPermissions.every((p) => permissions.includes(p));
const hasReadPerm = permissions.includes(`${resource}.${CrudAction.Read}`);
if (hasAllPerms) return "all";
if (hasAllCrudPerms) return "all";
if (hasReadPerm) return "read";
return "none";
};
@@ -11,6 +11,7 @@ import { Resource, CrudAction } from "@calcom/features/pbac/domain/types/permiss
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { RoleService } from "@calcom/features/pbac/services/role.service";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { prisma } from "@calcom/prisma";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
@@ -29,7 +30,7 @@ const getCachedTeamRoles = unstable_cache(
const getCachedTeamFeature = unstable_cache(
async (teamId: number, feature: keyof AppFlags) => {
const featureRepo = new FeaturesRepository();
const featureRepo = new FeaturesRepository(prisma);
const res = await featureRepo.checkIfTeamHasFeature(teamId, feature);
return res;
},
@@ -4,7 +4,7 @@ import { unstable_cache } from "next/cache";
import { RoleManagementFactory } from "@calcom/features/pbac/services/role-management.factory";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { AttributeRepository } from "@calcom/lib/server/repository/attribute";
import { PrismaAttributeRepository } from "@calcom/lib/server/repository/PrismaAttributeRepository";
import { viewerTeamsRouter } from "@calcom/trpc/server/routers/viewer/teams/_router";
import { TeamMembersView } from "~/teams/team-members-view";
@@ -37,7 +37,7 @@ const getCachedTeamAttributes = unstable_cache(
async (organizationId?: number) => {
if (!organizationId) return [];
try {
return await AttributeRepository.findAllByOrgIdWithOptions({ orgId: organizationId });
return await PrismaAttributeRepository.findAllByOrgIdWithOptions({ orgId: organizationId });
} catch (error) {
return [];
}
@@ -12,6 +12,7 @@ import { PermissionCheckService } from "@calcom/features/pbac/services/permissio
import { RoleService } from "@calcom/features/pbac/services/role.service";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { getTeamWithMembers } from "@calcom/lib/server/queries/teams";
import { prisma } from "@calcom/prisma";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
@@ -32,7 +33,7 @@ const getCachedTeamRoles = (teamId: number) =>
const getCachedTeamFeature = (teamId: number, feature: keyof AppFlags) =>
unstable_cache(
async () => {
const featureRepo = new FeaturesRepository();
const featureRepo = new FeaturesRepository(prisma);
const res = await featureRepo.checkIfTeamHasFeature(teamId, feature);
return res;
},
@@ -3,7 +3,7 @@ import { _generateMetadata } from "app/_utils";
import { unstable_cache } from "next/cache";
import { RoleManagementFactory } from "@calcom/features/pbac/services/role-management.factory";
import { AttributeRepository } from "@calcom/lib/server/repository/attribute";
import { PrismaAttributeRepository } from "@calcom/lib/server/repository/PrismaAttributeRepository";
import { viewerOrganizationsRouter } from "@calcom/trpc/server/routers/viewer/organizations/_router";
import { MembersView } from "~/members/members-view";
@@ -19,7 +19,7 @@ export const generateMetadata = async () =>
const getCachedAttributes = unstable_cache(
async (orgId: number) => {
return await AttributeRepository.findAllByOrgIdWithOptions({ orgId });
return await PrismaAttributeRepository.findAllByOrgIdWithOptions({ orgId });
},
undefined,
{ revalidate: 3600, tags: ["viewer.attributes.list"] } // Cache for 1 hour
+2 -1
View File
@@ -10,6 +10,7 @@ import getIP from "@calcom/lib/getIP";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { checkCfTurnstileToken } from "@calcom/lib/server/checkCfTurnstileToken";
import { prisma } from "@calcom/prisma";
import { signupSchema } from "@calcom/prisma/zod-utils";
async function ensureSignupIsEnabled(body: Record<string, string>) {
@@ -22,7 +23,7 @@ async function ensureSignupIsEnabled(body: Record<string, string>) {
// Still allow signups if there is a team invite
if (token) return;
const featuresRepository = new FeaturesRepository();
const featuresRepository = new FeaturesRepository(prisma);
const signupDisabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("disable-signup");
if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === "true" || signupDisabled) {
+1 -1
View File
@@ -208,7 +208,7 @@ async function getHandler(request: NextRequest) {
}
// Create a new response with the image buffer
const imageResponse = new NextResponse(buffer);
const imageResponse = new NextResponse(buffer as BodyInit);
// Set the appropriate headers
imageResponse.headers.set("Content-Type", response.headers.get("content-type") || "image/png");
+2 -2
View File
@@ -96,7 +96,7 @@ const getInitialProps = async () => {
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const h = await headers();
const nonce = h.get("x-csp") ?? "";
const nonce = h.get("x-csp-nonce") ?? "";
const { locale, direction, isEmbed, embedColorScheme } = await getInitialProps();
@@ -151,7 +151,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
]}
/>
<Providers isEmbed={isEmbed}>
<Providers isEmbed={isEmbed} nonce={nonce}>
<AppRouterI18nProvider translations={translations} locale={locale} ns={ns}>
{children}
</AppRouterI18nProvider>
+1 -1
View File
@@ -21,7 +21,7 @@ export const generateMetadata = async () => {
const ServerPage = async () => {
const h = await headers();
const nonce = h.get("x-nonce") ?? undefined;
const nonce = h.get("x-csp-nonce") ?? undefined;
const host = h.get("x-forwarded-host") ?? "";
return (
+3 -2
View File
@@ -13,14 +13,15 @@ import PlainChat from "@lib/plain/dynamicProvider";
type ProvidersProps = {
isEmbed: boolean;
children: React.ReactNode;
nonce: string | undefined;
};
export function Providers({ isEmbed, children }: ProvidersProps) {
export function Providers({ isEmbed, children, nonce }: ProvidersProps) {
const isBookingPage = useIsBookingPage();
return (
<SessionProvider>
<TrpcProvider>
{!isBookingPage ? <PlainChat /> : null}
{!isBookingPage ? <PlainChat nonce={nonce} /> : null}
{!isEmbed && !isBookingPage && <NotificationSoundHandler />}
{/* @ts-expect-error FIXME remove this comment when upgrading typescript to v5 */}
<CacheProvider>
-10
View File
@@ -44,17 +44,8 @@ function PageWrapper(props: AppProps) {
pageStatus = "403";
}
// On client side don't let nonce creep into DOM
// It also avoids hydration warning that says that Client has the nonce value but server has "" because browser removes nonce attributes before DOM is built
// See https://github.com/kentcdodds/nonce-hydration-issues
// Set "" only if server had it set otherwise keep it undefined because server has to match with client to avoid hydration error
const nonce = typeof window !== "undefined" ? (pageProps.nonce ? "" : undefined) : pageProps.nonce;
const providerProps = {
...props,
pageProps: {
...props.pageProps,
nonce,
},
};
// Use the layout defined at the page level, if available
const getLayout = Component.getLayout ?? ((page) => page);
@@ -79,7 +70,6 @@ function PageWrapper(props: AppProps) {
{...seoConfig.defaultNextSeo}
/>
<Script
nonce={nonce}
id="page-status"
// It is strictly not necessary to disable, but in a future update of react/no-danger this will error.
// And we don't want it to error here anyways
@@ -347,7 +347,9 @@ function BookingListItem(booking: BookingItemProps) {
const title = booking.title;
const isCalVideoLocation =
!booking.location || booking.location === "integrations:daily" || booking?.location?.trim() === "";
!booking.location ||
booking.location === "integrations:daily" ||
(typeof booking.location === "string" && booking.location.trim() === "");
const showPendingPayment = paymentAppData.enabled && booking.payment.length && !booking.paid;
@@ -513,7 +515,9 @@ function BookingListItem(booking: BookingItemProps) {
</div>
{!isPending && (
<div>
{(provider?.label || locationToDisplay?.startsWith("https://")) &&
{(provider?.label ||
(typeof locationToDisplay === "string" &&
locationToDisplay?.startsWith("https://"))) &&
locationToDisplay.startsWith("http") && (
<a
href={locationToDisplay}
@@ -124,8 +124,9 @@ const UsernameTextfield = (props: ICustomUsernameProps & Partial<React.Component
autoComplete="none"
autoCapitalize="none"
autoCorrect="none"
containerClassName="[&>div]:gap-0"
className={classNames(
"mb-0 mt-0 rounded-md rounded-l-none",
"mb-0 mt-0 rounded-md rounded-l-none pl-0",
markAsError
? "focus:shadow-0 focus:ring-shadow-0 border-red-500 focus:border-red-500 focus:outline-none focus:ring-0"
: ""
+5 -7
View File
@@ -16,7 +16,6 @@ import { useFlags } from "@calcom/features/flags/hooks";
import useIsBookingPage from "@lib/hooks/useIsBookingPage";
import useIsThemeSupported from "@lib/hooks/useIsThemeSupported";
import type { WithLocaleProps } from "@lib/withLocale";
import type { WithNonceProps } from "@lib/withNonce";
import type { PageWrapperProps } from "@components/PageWrapperAppDir";
@@ -25,12 +24,11 @@ import { getThemeProviderProps } from "./getThemeProviderProps";
// Workaround for https://github.com/vercel/next.js/issues/8592
export type AppProps = Omit<
NextAppProps<
WithLocaleProps<
WithNonceProps<{
themeBasis?: string;
session: Session;
}>
>
WithLocaleProps<{
nonce: string | undefined;
themeBasis?: string;
session: Session;
}>
>,
"Component"
> & {
+8 -28
View File
@@ -20,7 +20,6 @@ import { useFlags } from "@calcom/features/flags/hooks";
import useIsBookingPage from "@lib/hooks/useIsBookingPage";
import type { WithLocaleProps } from "@lib/withLocale";
import type { WithNonceProps } from "@lib/withNonce";
import { useViewerI18n } from "@components/I18nLanguageHandler";
@@ -33,13 +32,11 @@ const I18nextAdapter = appWithTranslation<
// Workaround for https://github.com/vercel/next.js/issues/8592
export type AppProps = Omit<
NextAppProps<
WithLocaleProps<
WithNonceProps<{
themeBasis?: string;
session: Session;
i18n?: SSRConfig;
}>
>
WithLocaleProps<{
themeBasis?: string;
session: Session;
i18n?: SSRConfig;
}>
>,
"Component"
> & {
@@ -72,12 +69,7 @@ const getEmbedNamespace = (query: ParsedUrlQuery) => {
return typeof window !== "undefined" ? window.getEmbedNamespace() : (query.embed as string) || null;
};
// We dont need to pass nonce to the i18n provider - this was causing x2-x3 re-renders on a hard refresh
type AppPropsWithoutNonce = Omit<AppPropsWithChildren, "pageProps"> & {
pageProps: Omit<AppPropsWithChildren["pageProps"], "nonce">;
};
const CustomI18nextProvider = (props: AppPropsWithoutNonce) => {
const CustomI18nextProvider = (props: AppPropsWithChildren) => {
/**
* i18n should never be clubbed with other queries, so that it's caching can be managed independently.
**/
@@ -141,7 +133,7 @@ const enum ThemeSupport {
type CalcomThemeProps = PropsWithChildren<
Pick<AppProps, "router"> &
Pick<AppProps["pageProps"], "nonce" | "themeBasis"> &
Pick<AppProps["pageProps"], "themeBasis"> &
Pick<AppProps["Component"], "isBookingPage" | "isThemeSupported">
>;
const CalcomThemeProvider = (props: CalcomThemeProps) => {
@@ -253,7 +245,6 @@ function getThemeProviderProps({
storageKey,
forcedTheme,
themeSupport,
nonce: props.nonce,
enableColorScheme: false,
enableSystem: themeSupport !== ThemeSupport.None,
// next-themes doesn't listen to changes on storageKey. So we need to force a re-render when storageKey changes
@@ -280,24 +271,13 @@ function OrgBrandProvider({ children }: { children: React.ReactNode }) {
const AppProviders = (props: AppPropsWithChildren) => {
const isBookingPage = useIsBookingPage();
const { pageProps, ...rest } = props;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { nonce, ...restPageProps } = pageProps;
const propsWithoutNonce = {
pageProps: {
...restPageProps,
},
...rest,
};
const RemainingProviders = (
<EventCollectionProvider options={{ apiPath: "/api/collect-events" }}>
<CustomI18nextProvider {...propsWithoutNonce}>
<CustomI18nextProvider {...props}>
<TooltipProvider>
<CalcomThemeProvider
themeBasis={props.pageProps.themeBasis}
nonce={props.pageProps.nonce}
isThemeSupported={props.Component.isThemeSupported}
isBookingPage={props.Component.isBookingPage || isBookingPage}
router={props.router}>
+19 -75
View File
@@ -1,6 +1,3 @@
import type { IncomingMessage, OutgoingMessage } from "http";
import type { NextRequest, NextResponse } from "next/server";
import { IS_PRODUCTION } from "@calcom/lib/constants";
import { WEBAPP_URL } from "@calcom/lib/constants";
@@ -21,7 +18,7 @@ function getCspPolicy(nonce: string) {
default-src 'self' ${IS_PRODUCTION ? "" : "data:"};
script-src ${
IS_PRODUCTION
? // 'self' 'unsafe-inline' https: added for Browsers not supporting strict-dynamic not supporting strict-dynamic
? // 'self' 'unsafe-inline' https: added for Browsers not supporting strict-dynamic
`'nonce-${nonce}' 'strict-dynamic' 'self' 'unsafe-inline' https:`
: // Note: We could use 'strict-dynamic' with 'nonce-..' instead of unsafe-inline but there are some streaming related scripts that get blocked(because they don't have nonce on them). It causes a really frustrating full page error model by Next.js to show up sometimes
"'unsafe-inline' 'unsafe-eval' https: http:"
@@ -38,77 +35,24 @@ function getCspPolicy(nonce: string) {
`;
}
// Taken from @next-safe/middleware
const isPagePathRequest = (url: URL) => {
const isNonPagePathPrefix = /^\/(?:_next|api)\//;
const isFile = /\..*$/;
const { pathname } = url;
return !isNonPagePathPrefix.test(pathname) && !isFile.test(pathname);
};
function safeParseString(value: unknown): { success: boolean; data?: string } {
if (typeof value === "string") {
return { success: true, data: value };
}
return { success: false };
}
export function csp(req: IncomingMessage | NextRequest | null, res: OutgoingMessage | NextResponse | null) {
if (!req) {
return { nonce: undefined };
}
const existingNonce = "cache" in req ? req.headers.get("x-nonce") : req.headers["x-nonce"];
if (existingNonce) {
const existingNoneParsed = safeParseString(existingNonce);
return { nonce: existingNoneParsed.success ? existingNoneParsed.data : "" };
}
if (!req.url) {
return { nonce: undefined };
}
const CSP_POLICY = process.env.CSP_POLICY;
const cspEnabledForInstance = CSP_POLICY;
export function getCspNonce() {
const nonce = buildNonce(crypto.getRandomValues(new Uint8Array(22)));
const parsedUrl = new URL(req.url, "http://base_url");
const cspEnabledForPage = cspEnabledForInstance && isPagePathRequest(parsedUrl);
if (!cspEnabledForPage) {
return {
nonce: undefined,
};
}
// Set x-nonce request header to be used by `getServerSideProps` or similar fns and `Document.getInitialProps` to read the nonce from
// It is generated for all page requests but only used by pages that need CSP
if ("cache" in req) {
req.headers.set("x-nonce", nonce);
} else {
req.headers["x-nonce"] = nonce;
}
if (res) {
const enforced =
"cache" in req ? req.headers.get("x-csp-enforce") === "true" : req.headers["x-csp-enforce"] === "true";
// No need to enable REPORT ONLY mode for CSP unless we start actively working on it. See https://github.com/calcom/cal.com/issues/13844
const name = enforced ? "Content-Security-Policy" : /*"Content-Security-Policy-Report-Only"*/ null;
if (!name) {
return {
nonce: undefined,
};
}
const value = getCspPolicy(nonce)
.replace(/\s{2,}/g, " ")
.trim();
if ("body" in res) {
res.headers.set(name, value);
} else {
res.setHeader(name, value);
}
}
return { nonce };
return nonce;
}
export function getCspHeader({ shouldEnforceCsp, nonce }: { shouldEnforceCsp: boolean; nonce: string }) {
const cspHeaderName = shouldEnforceCsp
? "Content-Security-Policy"
: /*"Content-Security-Policy-Report-Only"*/ null;
if (!cspHeaderName) {
return null;
}
const cspHeaderValue = getCspPolicy(nonce)
.replace(/\s{2,}/g, " ")
.trim();
return { name: cspHeaderName, value: cspHeaderValue };
}
@@ -120,7 +120,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
// Check if team has API v2 feature flag enabled (same logic as team pages)
let useApiV2 = false;
if (isTeamEvent && hashedLink.eventType.team?.id) {
const featureRepo = new FeaturesRepository();
const featureRepo = new FeaturesRepository(prisma);
const teamHasApiV2Route = await featureRepo.checkIfTeamHasFeature(
hashedLink.eventType.team.id,
"use-api-v2-for-team-slots"
+1 -1
View File
@@ -156,7 +156,7 @@ const PlainContactForm = () => {
<PopoverContent
style={{ maxWidth: "450px", maxHeight: "650px" }}
className="mb-2 mr-8 w-[450px] overflow-hidden overflow-y-scroll px-6 py-4">
className="!bg-muted no-scrollbar mr-8 mb-2 w-[450px] overflow-hidden overflow-y-scroll px-6 py-4">
<div className="flex w-full justify-between">
<p className="mb-5 text-lg font-semibold">Contact support</p>
<Button
+1
View File
@@ -1,4 +1,5 @@
import dynamic from "next/dynamic";
import { Fragment } from "react";
// Preload caused by dynamic import doesn't seem to add nonce and thus preload fails but the functionality still works - https://github.com/vercel/next.js/issues/81260
export default process.env.NEXT_PUBLIC_PLAIN_CHAT_ID ? dynamic(() => import("./plainChat")) : Fragment;
+1 -1
View File
@@ -17,7 +17,7 @@ export const upsertPlainCustomer = async (user: PlainUser) => {
return await plain.upsertCustomer({
identifier: {
externalId: `${user.id}`,
emailAddress: email,
},
onCreate: {
fullName,
+3 -1
View File
@@ -80,7 +80,7 @@ interface PlainChatConfig {
}
const PlainChat = IS_PLAIN_CHAT_ENABLED
? () => {
? ({ nonce }: { nonce: string | undefined }) => {
const [config, setConfig] = useState<PlainChatConfig | null>(null);
const [isSmallScreen, setIsSmallScreen] = useState(false);
const { data: session } = useSession();
@@ -297,12 +297,14 @@ const PlainChat = IS_PLAIN_CHAT_ENABLED
return (
<>
<Script
nonce={nonce}
id="plain-chat"
src="https://chat.cdn-plain.com/index.js"
strategy="afterInteractive"
onLoad={() => window.plainScriptLoaded?.()}
/>
<Script
nonce={nonce}
id="plain-chat-init"
strategy="afterInteractive"
dangerouslySetInnerHTML={{ __html: plainChatScript }}
@@ -1,9 +1,10 @@
import type { GetServerSidePropsResult } from "next";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { prisma } from "@calcom/prisma";
export const getServerSideProps = async (): Promise<GetServerSidePropsResult<{ isOrg: boolean }>> => {
const featuresRepository = new FeaturesRepository();
const featuresRepository = new FeaturesRepository(prisma);
const organizations = await featuresRepository.checkIfFeatureIsEnabledGlobally("organizations");
// Check if organizations are enabled
if (!organizations) {
+1 -1
View File
@@ -24,7 +24,7 @@ const querySchema = z.object({
export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
const prisma = await import("@calcom/prisma").then((mod) => mod.default);
const featuresRepository = new FeaturesRepository();
const featuresRepository = new FeaturesRepository(prisma);
const emailVerificationEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally(
"email-verification"
);
@@ -127,7 +127,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
const organizationSettings = getOrganizationSEOSettings(team);
const allowSEOIndexing = organizationSettings?.allowSEOIndexing ?? false;
const featureRepo = new FeaturesRepository();
const featureRepo = new FeaturesRepository(prisma);
const teamHasApiV2Route = await featureRepo.checkIfTeamHasFeature(team.id, "use-api-v2-for-team-slots");
const useApiV2 = teamHasApiV2Route && hasApiV2RouteInEnv();
@@ -71,7 +71,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
// Provided by Rewrite from next.config.js
const isOrgProfile = context.query?.isOrgProfile === "1";
const featuresRepository = new FeaturesRepository();
const featuresRepository = new FeaturesRepository(prisma);
const organizationsEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("organizations");
log.debug("getServerSideProps", {
@@ -117,7 +117,7 @@ const checkIfUserIsHost = async ({
});
// If eventType exists, it means user is either owner, host or user
return !eventType;
return !!eventType;
};
export async function getServerSideProps(context: GetServerSidePropsContext) {
@@ -247,7 +247,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
const videoReference = getCalVideoReference(bookingObj.references);
const featureRepo = new FeaturesRepository();
const featureRepo = new FeaturesRepository(prisma);
const displayLogInOverlay = profile?.organizationId
? await featureRepo.checkIfTeamHasFeature(profile.organizationId, "cal-video-log-in-overlay")
: false;
-44
View File
@@ -1,44 +0,0 @@
import type { GetServerSideProps } from "next";
import { csp } from "@lib/csp";
export type WithNonceProps<T extends Record<string, unknown>> = T & {
nonce?: string;
};
/**
* Make any getServerSideProps fn return the nonce so that it can be used by Components in the page to add any script tag.
* Note that if the Components are not adding any script tag then this is not needed. Even in absence of this, Document.getInitialProps would be able to generate nonce itself which it needs to add script tags common to all pages
* There is no harm in wrapping a `getServerSideProps` fn with this even if it doesn't add any script tag.
*/
export default function withNonce<T extends Record<string, unknown>>(
getServerSideProps: GetServerSideProps<T>
): GetServerSideProps<WithNonceProps<T>> {
return async (context) => {
const ssrResponse = await getServerSideProps(context);
if (!("props" in ssrResponse)) {
return ssrResponse;
}
const { nonce } = csp(context.req, context.res);
// Skip nonce property if it's not available instead of setting it to undefined because undefined can't be serialized.
const nonceProps = nonce
? {
nonce,
}
: null;
// Helps in debugging that withNonce was used but a valid nonce couldn't be set
context.res.setHeader("x-csp", nonce ? "ssr" : "false");
return {
...ssrResponse,
props: {
...ssrResponse.props,
...nonceProps,
},
};
};
}
+443 -1
View File
@@ -1,9 +1,154 @@
// Import mocked functions
import { get as edgeConfigGet } from "@vercel/edge-config";
import { NextRequest } from "next/server";
import { describe, it, expect } from "vitest";
import type { Mock } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { checkPostMethod } from "./middleware";
// We'll test the wrapped middleware as it would be used in production
import middleware from "./middleware";
// Mock dependencies at module level
vi.mock("@vercel/edge-config", () => ({
get: vi.fn(),
}));
vi.mock("next-collect/server", () => ({
collectEvents: vi.fn((config: any) => config.middleware),
}));
vi.mock("@calcom/lib/telemetry", () => ({
extendEventData: vi.fn(),
nextCollectBasicSettings: {},
}));
// Mock NextResponse.json since it's not available in test environment
vi.mock("next/server", async () => {
const actual = await vi.importActual<typeof import("next/server")>("next/server");
// Create a NextResponse constructor that returns Response objects
const NextResponse = function (body: any, init?: ResponseInit) {
return new Response(body, init);
};
// Add static methods
NextResponse.json = (body: any, init?: ResponseInit) => {
return new Response(JSON.stringify(body), {
...init,
headers: {
...init?.headers,
"content-type": "application/json",
},
});
};
NextResponse.next = (init?: ResponseInit) => {
const response = new Response(null, {
status: 200,
...init,
headers: {
...init?.headers,
"x-middleware-next": "1",
},
});
// Add cookies property
(response as any).cookies = {
delete: vi.fn(),
set: vi.fn(),
get: vi.fn(),
};
return response;
};
NextResponse.rewrite = (url: URL | string, init?: ResponseInit) => {
const rewriteUrl = typeof url === "string" ? new URL(url) : url;
return new Response(null, {
status: 200,
...init,
headers: {
...init?.headers,
"x-middleware-rewrite": rewriteUrl.toString(),
},
});
};
NextResponse.redirect = (url: URL | string, statusOrInit?: number | ResponseInit) => {
const redirectUrl = typeof url === "string" ? new URL(url) : url;
const status = typeof statusOrInit === "number" ? statusOrInit : statusOrInit?.status || 307;
const init = typeof statusOrInit === "object" ? statusOrInit : {};
const response = new Response(null, {
...init,
status,
headers: {
...init.headers,
location: redirectUrl.toString(),
},
});
// Add cookies property
(response as any).cookies = {
delete: vi.fn(),
set: vi.fn(),
get: vi.fn(),
};
return response;
};
return {
...actual,
NextResponse,
};
});
const createTestRequest = (overrides?: {
url?: string;
method?: string;
headers?: Record<string, string>;
cookies?: Record<string, string>;
}) => {
const url = overrides?.url || `${WEBAPP_URL}/test-path`;
const method = overrides?.method || "GET";
const headers = new Headers(overrides?.headers || {});
const req = new NextRequest(new Request(url, { method, headers }));
// Add cookies if provided
if (overrides?.cookies) {
Object.entries(overrides.cookies).forEach(([name, value]) => {
req.cookies.set(name, value);
});
}
return req;
};
const createEdgeConfigMock = (config: Record<string, any>) => {
return (key: string) => {
if (key in config) return Promise.resolve(config[key]);
return Promise.resolve(undefined);
};
};
// Helper to safely get header value
const getHeader = (res: Response, name: string) => {
return res.headers.get(name);
};
// Helper to check response status
const expectStatus = (res: Response, status: number) => {
expect(res.status).toBe(status);
};
// Wrapper for middleware calls to handle type casting
const callMiddleware = async (req: NextRequest): Promise<Response> => {
return (await (middleware as any)(req)) as Response;
};
describe("Middleware - POST requests restriction", () => {
const createRequest = (path: string, method: string) => {
@@ -41,3 +186,300 @@ describe("Middleware - POST requests restriction", () => {
expect(res).toBeNull();
});
});
describe("Middleware Integration Tests", () => {
beforeEach(() => {
vi.clearAllMocks();
// Set test environment
vi.stubEnv("NEXT_PUBLIC_WEBAPP_URL", WEBAPP_URL);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("Static File Handling", () => {
it("should skip processing for static files", async () => {
const staticPaths = ["/images/logo.png", "/_next/static/main.js", "/favicon.ico"];
for (const path of staticPaths) {
const req = createTestRequest({ url: `${WEBAPP_URL}${path}` });
const res = await callMiddleware(req);
expect(res).toBeDefined();
expectStatus(res, 200);
expect(getHeader(res, "x-middleware-next")).toBe("1");
}
});
});
describe("POST Method Protection", () => {
it("should allow POST to /api/auth/signup", async () => {
const req = createTestRequest({
url: `${WEBAPP_URL}/api/auth/signup`,
method: "POST",
});
const res = await callMiddleware(req);
expectStatus(res, 200);
});
it("should block POST to regular routes", async () => {
const req = createTestRequest({
url: `${WEBAPP_URL}/team/test`,
method: "POST",
});
const res = await callMiddleware(req);
expectStatus(res, 405);
expect(getHeader(res, "Allow")).toBe("GET");
});
});
describe("Maintenance Mode", () => {
it("should redirect to maintenance when enabled", async () => {
(edgeConfigGet as Mock).mockImplementation(
createEdgeConfigMock({
isInMaintenanceMode: true,
})
);
const req = createTestRequest({
url: `${WEBAPP_URL}/team/test`,
});
const res = await callMiddleware(req);
expect(res).toBeDefined();
expectStatus(res, 200);
});
it("should not affect API routes in maintenance mode", async () => {
(edgeConfigGet as Mock).mockImplementation(
createEdgeConfigMock({
isInMaintenanceMode: true,
})
);
const req = createTestRequest({
url: `${WEBAPP_URL}/api/bookings`,
});
const res = await callMiddleware(req);
expect(res).toBeDefined();
expectStatus(res, 200);
});
});
describe("Signup Control", () => {
it("should block signup when disabled", async () => {
(edgeConfigGet as Mock).mockImplementation(
createEdgeConfigMock({
isSignupDisabled: true,
})
);
const req = createTestRequest({
url: `${WEBAPP_URL}/api/auth/signup`,
method: "POST",
});
const res = await callMiddleware(req);
expectStatus(res, 503);
const body = await res.text();
expect(body).toContain("Signup is disabled");
});
it("should allow signup when enabled", async () => {
(edgeConfigGet as Mock).mockImplementation(
createEdgeConfigMock({
isSignupDisabled: false,
})
);
const req = createTestRequest({
url: `${WEBAPP_URL}/api/auth/signup`,
method: "POST",
});
const res = await callMiddleware(req);
expectStatus(res, 200);
});
});
describe("Cookie Management", () => {
it("should handle return-to cookie redirect", async () => {
const returnUrl = "/dashboard";
const req = createTestRequest({
url: `${WEBAPP_URL}/apps/installed`,
cookies: { "return-to": returnUrl },
});
const res = await callMiddleware(req);
expectStatus(res, 307);
expect(getHeader(res, "location")).toContain(returnUrl);
});
it("should not redirect without return-to cookie", async () => {
const req = createTestRequest({
url: `${WEBAPP_URL}/apps/installed`,
});
const res = await callMiddleware(req);
expectStatus(res, 200);
});
it("should delete session cookie on logout", async () => {
const req = createTestRequest({
url: `${WEBAPP_URL}/auth/logout`,
});
const res = await callMiddleware(req);
const setCookie = getHeader(res, "set-cookie");
if (setCookie) {
expect(setCookie).toContain("next-auth.session-token");
}
});
});
describe("Embed Routes", () => {
it("should add embed headers", async () => {
const req = createTestRequest({
url: `${WEBAPP_URL}/team/test/embed`,
});
const res = await callMiddleware(req);
expect(getHeader(res, "x-isEmbed")).toBe("true");
});
it("should add COEP header when requested", async () => {
const req = createTestRequest({
url: `${WEBAPP_URL}/team/test/embed?flag.coep=true`,
});
const res = await callMiddleware(req);
expect(getHeader(res, "Cross-Origin-Embedder-Policy")).toBe("require-corp");
});
it("should add color scheme header", async () => {
const req = createTestRequest({
url: `${WEBAPP_URL}/team/test/embed?ui.color-scheme=dark`,
});
const res = await callMiddleware(req);
expect(getHeader(res, "x-embedColorScheme")).toBe("dark");
});
});
describe("CSP Headers", () => {
beforeEach(() => {
vi.stubEnv("CSP_POLICY", "strict");
});
afterEach(() => {
vi.unstubAllEnvs();
});
it("should add CSP headers to login pages", async () => {
const req = createTestRequest({
url: `${WEBAPP_URL}/auth/login`,
});
const res = await callMiddleware(req);
const cspHeader = getHeader(res, "content-security-policy");
expect(cspHeader).toBeTruthy();
expect(cspHeader).toContain("default-src");
expect(cspHeader).toContain("script-src");
});
it("should not add CSP headers to non-login pages", async () => {
const req = createTestRequest({
url: `${WEBAPP_URL}/team/test`,
});
const res = await callMiddleware(req);
const cspHeader = getHeader(res, "content-security-policy");
expect(cspHeader).toBeNull();
});
it("should add x-csp-status when CSP_POLICY not set", async () => {
vi.unstubAllEnvs();
const req = createTestRequest({
url: `${WEBAPP_URL}/auth/login`,
});
const res = await callMiddleware(req);
expect(getHeader(res, "x-csp-status")).toBe("not-opted-in");
});
});
describe("Routing Forms", () => {
it("should rewrite legacy routing_forms URLs", async () => {
const req = createTestRequest({
url: `${WEBAPP_URL}/apps/routing_forms/form-id`,
});
const res = await callMiddleware(req);
expect(res).toBeDefined();
expectStatus(res, 200);
});
});
describe("Multiple Features", () => {
it("should handle POST protection before maintenance mode", async () => {
(edgeConfigGet as Mock).mockImplementation(
createEdgeConfigMock({
isInMaintenanceMode: true,
})
);
const req = createTestRequest({
url: `${WEBAPP_URL}/team/test`,
method: "POST",
});
const res = await callMiddleware(req);
// POST protection should trigger first
expectStatus(res, 405);
});
it("should handle embed route with routing forms rewrite", async () => {
const req = createTestRequest({
url: `${WEBAPP_URL}/apps/routing_forms/form/embed?ui.color-scheme=light`,
});
const res = await callMiddleware(req);
expect(getHeader(res, "x-isEmbed")).toBe("true");
expect(getHeader(res, "x-embedColorScheme")).toBe("light");
});
});
describe("Error Handling", () => {
it("should handle Edge Config errors gracefully", async () => {
(edgeConfigGet as Mock).mockImplementation(() => {
throw new Error("Config error");
});
const req = createTestRequest({
url: `${WEBAPP_URL}/team/test`,
});
const res = await callMiddleware(req);
expect(res).toBeDefined();
expectStatus(res, 200);
});
it("should handle malformed URLs", async () => {
const req = createTestRequest({
url: `${WEBAPP_URL}//double-slash`,
});
const res = await callMiddleware(req);
expect(res).toBeDefined();
});
});
});
+47 -22
View File
@@ -5,7 +5,7 @@ import { NextResponse } from "next/server";
import { extendEventData, nextCollectBasicSettings } from "@calcom/lib/telemetry";
import { csp } from "./lib/csp";
import { getCspHeader, getCspNonce } from "@lib/csp";
const safeGet = async <T = any>(key: string): Promise<T | undefined> => {
try {
@@ -38,6 +38,17 @@ export function checkStaticFiles(pathname: string) {
}
}
const isPagePathRequest = (url: URL) => {
const isNonPagePathPrefix = /^\/(?:_next|api)\//;
const isFile = /\..*$/;
const { pathname } = url;
return !isNonPagePathPrefix.test(pathname) && !isFile.test(pathname);
};
const shouldEnforceCsp = (url: URL) => {
return url.pathname.startsWith("/auth/login") || url.pathname.startsWith("/login");
};
const middleware = async (req: NextRequest): Promise<NextResponse<unknown>> => {
const postCheckResult = checkPostMethod(req);
if (postCheckResult) return postCheckResult;
@@ -46,7 +57,8 @@ const middleware = async (req: NextRequest): Promise<NextResponse<unknown>> => {
if (isStaticFile) return isStaticFile;
const url = req.nextUrl;
const requestHeaders = new Headers(req.headers);
const reqWithEnrichedHeaders = enrichRequestWithHeaders({ req });
const requestHeaders = new Headers(reqWithEnrichedHeaders.headers);
if (!url.pathname.startsWith("/api")) {
//
@@ -59,14 +71,14 @@ const middleware = async (req: NextRequest): Promise<NextResponse<unknown>> => {
const isInMaintenanceMode = await safeGet<boolean>("isInMaintenanceMode");
// If is in maintenance mode, point the url pathname to the maintenance page
if (isInMaintenanceMode) {
req.nextUrl.pathname = `/maintenance`;
return NextResponse.rewrite(req.nextUrl);
reqWithEnrichedHeaders.nextUrl.pathname = `/maintenance`;
return NextResponse.rewrite(reqWithEnrichedHeaders.nextUrl);
}
}
const routingFormRewriteResponse = routingForms.handleRewrite(url);
if (routingFormRewriteResponse) {
return responseWithHeaders({ url, res: routingFormRewriteResponse, req });
return responseWithHeaders({ url, res: routingFormRewriteResponse, req: reqWithEnrichedHeaders });
}
if (url.pathname.startsWith("/api/auth/signup")) {
@@ -78,16 +90,13 @@ const middleware = async (req: NextRequest): Promise<NextResponse<unknown>> => {
}
}
if (url.pathname.startsWith("/auth/login") || url.pathname.startsWith("/login")) {
// Use this header to actually enforce CSP, otherwise it is running in Report Only mode on all pages.
requestHeaders.set("x-csp-enforce", "true");
}
if (url.pathname.startsWith("/apps/installed")) {
const returnTo = req.cookies.get("return-to");
const returnTo = reqWithEnrichedHeaders.cookies.get("return-to");
if (returnTo?.value) {
const response = NextResponse.redirect(new URL(returnTo.value, req.url), { headers: requestHeaders });
const response = NextResponse.redirect(new URL(returnTo.value, reqWithEnrichedHeaders.url), {
headers: requestHeaders,
});
response.cookies.delete("return-to");
return response;
}
@@ -103,7 +112,7 @@ const middleware = async (req: NextRequest): Promise<NextResponse<unknown>> => {
res.cookies.delete("next-auth.session-token");
}
return responseWithHeaders({ url, res, req });
return responseWithHeaders({ url, res, req: reqWithEnrichedHeaders });
};
const routingForms = {
@@ -138,18 +147,29 @@ const embeds = {
const contentSecurityPolicy = {
addResponseHeaders: ({ res, req }: { res: NextResponse; req: NextRequest }) => {
const { nonce } = csp(req, res ?? null);
if (!process.env.CSP_POLICY) {
res.headers.set("x-csp", "not-opted-in");
} else if (!res.headers.get("x-csp")) {
// If x-csp not set by gSSP, then it's initialPropsOnly
res.headers.set("x-csp", "initialPropsOnly");
} else {
res.headers.set("x-csp", nonce ?? "");
const nonce = req.headers.get("x-csp-nonce");
if (!nonce) {
res.headers.set("x-csp-status", "not-opted-in");
return res;
}
const cspHeader = getCspHeader({ shouldEnforceCsp: shouldEnforceCsp(req.nextUrl), nonce });
if (cspHeader) {
res.headers.set(cspHeader.name, cspHeader.value);
}
return res;
},
addRequestHeaders: ({ req }: { req: NextRequest }) => {
if (!process.env.CSP_POLICY) {
return req;
}
const isCspApplicable = isPagePathRequest(req.nextUrl);
if (!isCspApplicable) {
return req;
}
const nonce = getCspNonce();
req.headers.set("x-csp-nonce", nonce);
return req;
},
};
function responseWithHeaders({ url, res, req }: { url: URL; res: NextResponse; req: NextRequest }) {
@@ -158,6 +178,11 @@ function responseWithHeaders({ url, res, req }: { url: URL; res: NextResponse; r
return resWithEmbeds;
}
function enrichRequestWithHeaders({ req }: { req: NextRequest }) {
const reqWithCSP = contentSecurityPolicy.addRequestHeaders({ req });
return reqWithCSP;
}
export const config = {
// Next.js Doesn't support spread operator in config matcher, so, we must list all paths explicitly here.
// https://github.com/vercel/next.js/discussions/42458
+1 -3
View File
@@ -25,7 +25,6 @@ import { Button } from "@calcom/ui/components/button";
import { EmailField, PasswordField } from "@calcom/ui/components/form";
import type { inferSSRProps } from "@lib/types/inferSSRProps";
import type { WithNonceProps } from "@lib/withNonce";
import AddToHomescreen from "@components/AddToHomescreen";
import BackupCode from "@components/auth/BackupCode";
@@ -53,8 +52,7 @@ export default function Login({
samlTenantID,
samlProductID,
totpEmail,
}: // eslint-disable-next-line @typescript-eslint/ban-types
PageProps & WithNonceProps<{}>) {
}: PageProps) {
const searchParams = useCompatSearchParams();
const { t } = useLocale();
const router = useRouter();

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