Compare commits

...
56 Commits
Author SHA1 Message Date
Alex van Andel 8167fe0d05 chore: release v5.9.7
Post release / release (push) Canceled after 0s
Release Docker / Release AMD64 (push) Canceled after 0s
Release Docker / Release ARM (push) Canceled after 0s
2025-12-02 04:22:35 +00:00
KartikandPallav c23c2d128a fix: allow false for custom field 'fieldRequired' by using z.boolean() (#25322)
* fix: allow false for custom field 'fieldRequired' by using z.boolean()

* updated to z.boolean().optional()

---------

Co-authored-by: Pallav <90088723+Pallava-Joshi@users.noreply.github.com>
2025-12-02 02:46:29 +00:00
65893ae943 fix: [object object] problem (#24643)
* fix: object problem

* revert and improve logic

* revert

* fix

* Update apps/web/modules/bookings/views/bookings-single-view.tsx

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

---------

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
Co-authored-by: Pallav <90088723+Pallava-Joshi@users.noreply.github.com>
2025-12-02 02:32:51 +00:00
f33edb2b99 feat: Generate email workflow payload at time of sending (#25446)
* wip

* wip

* feature: Booking Tasker without DI yet

* feature: Booking Tasker with DI

* fix type check 1

* fix type check 2

* fix

* comment booking tasker for now

* fix: DI regularBookingService api v2

* fix: convert trigger.dev SDK imports to dynamic imports to fix unit tests

The unit tests were failing because BookingEmailAndSmsTriggerTasker.ts had static imports of trigger files that depend on @trigger.dev/sdk. This caused Vitest to try to resolve the SDK at module load time, even though it should be optional.

Changed all imports in BookingEmailAndSmsTriggerTasker.ts from static to dynamic (using await import()) so the trigger files are only loaded when the tasker methods are actually called, not at module load time during tests.

This fixes the 'Failed to load url @trigger.dev/sdk' errors that were causing 28+ test failures.

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

* fix unit tests

* keep inline smsAndEmailHandler.send calls

* chore: add team feature flag

* add satisfies ModuleLoader

* fix type check app flags

* move trigger in feature

* fix: add trigger.dev prisma  generator

* fix: email app statuses

* fix: CalEvtBuilder unit test

* chore: improvements, schema, config, retry

* fixup! chore: improvements, schema, config, retry

* chore: cleanup code

* chore: cleanup code

* chore: clean code and give full payload

* remove log

* add booking notifications queue

* add attendee phone number for sms

* bump trigger to 4.1.0

* add missing booking seat data in attendee

* update config

* fix logger regular booking service

* fix: prisma as external deps of trigger

* fix yarn.lock

* revert change to example app booking page

* fix: resolve circular dependencies and improve cold start performance in trigger tasks

- Convert BookingRepository import to type-only in CalendarEventBuilder.ts to eliminate circular dependency risk
- Convert EventNameObjectType, CalendarEvent, and JsonObject imports to type-only in BookingEmailAndSmsTaskService.ts
- Use dynamic imports in all trigger notification tasks (confirm, request, reschedule, rr-reschedule) to reduce cold start time
- Move heavy imports (BookingEmailSmsHandler, BookingRepository, prisma, TriggerDevLogger, BookingEmailAndSmsTaskService) inside run functions
- Eliminates module-level prisma import which violates repo guidelines and adds cold start overhead
- Reduces initial module dependency graph by deferring heavy imports (email templates, workflows, large repositories) until task execution

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

* fix: improve cold start performance in reminderScheduler with dynamic imports

- Remove module-level prisma import (violates 'No prisma outside repositories' guideline)
- Use dynamic imports for UserRepository (1,168 lines) - only loaded when needed in EMAIL_ATTENDEE action
- Use dynamic imports for twilio provider (386 lines) - only loaded in cancelScheduledMessagesAndScheduleEmails
- Use dynamic imports for all manager functions by action type:
  - scheduleSMSReminder (387 lines) - loaded only for SMS actions
  - scheduleEmailReminder (459 lines) - loaded only for Email actions
  - scheduleWhatsappReminder (266 lines) - loaded only for WhatsApp actions
  - scheduleAIPhoneCall (478 lines) - loaded only for AI phone call actions
- Use dynamic imports for sendOrScheduleWorkflowEmails in cancelScheduledMessagesAndScheduleEmails
- Significantly reduces cold start time by deferring heavy module loading until execution paths need them
- Eliminates module-level prisma import that violated repository pattern guidelines

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

* fix: improve cold start performance in BookingEmailSmsHandler with dynamic imports

- Remove module-level imports of all email-manager functions (653 LOC + 30+ email templates)
- Add dynamic imports in each method (_handleRescheduled, _handleRoundRobinRescheduled, _handleConfirmed, _handleRequested, handleAddGuests)
- Defer heavy email-manager loading until method execution
- Verified no circular dependencies between email-manager and bookings
- Significantly reduces cold start time for RegularBookingService and BookingEmailAndSmsTaskService

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

* fix: use dynamic imports

* update yarn lock

* code review

* trigger config project ref in env

* update yarn lock

* add .env.example trigger variables

* add .env.example trigger variables

* fix: cleanup error handling and loggin

* fix: trigger config from env

* fix: small typo fix

* fix: ai review comments

* fix: ai review comments

* ai review

* Add `create` on `WorkflowReminderRepository`

* `sendWorkflowEmails` tasker to accept lazy payload

* Add `scheduleLazyEmailWorkflow` to `WorkflowService

* Process scheduled date in `scheduleLazyEmailWorkflow`

* Type fixes

* Use `WorkflowService` to schedule

* Refactor `scheduleEmailReminderForEvt` to use
`WorkflowService.processWorkflowScheduledDate`

* Pass seat reference to lazy scheduled workflow reminder

* Refactor `WorkflowReminderRepository` to accept prisma as constructor

* Abstract `FormSubmissionData` type

* Abstract select statement and add get by uid to `BookingRepository`

* Add `FormSubmissionData` type

* Add `findByIdIncludeStepAndWorkflow` to `WorkflowReminderRepository`

* Tasker payload to accept `workflowReminderId`

* Create `BookingSeatRepository`

* Write `workflowReminderId` to tasker payload

* Add `generateCommonScheduleFunctionParams` to `WorkflowService`

* Init

* Use services in tasker

* Abstract types

* In reminderScheduler use workflowService to generate common params

* Type fix

* Return params from emailWorkflowService.generateParametersToBuildEmailWorkflowContent

* Use emailWorkflowService to generate params

* Abstract types

* Generate email content and send in EmailWorkflowService

* Move check to caller

* Use EmailWorkflowService to generate email payload in
emailReminderManager

* Fix initalizing repository

* Use evt.videoCallData first before the booking metadata

* Only get non-deleted references to build calendar event

* Remove check for videoCallData.id

* Dynamic import credit service

* BookingRepository.getByUid to only return what we need from attendee

* Type fixes

* test: Add comprehensive tests for lazy email workflow generation and fix broken tests

- Fix prisma mocks in sms-manager.test.ts and outOfOfficeCreateOrUpdate.handler.test.ts
  to export both 'default' and named 'prisma' exports
- Add EmailWorkflowService.test.ts with 4 tests for error handling paths
- Add sendWorkflowEmails.test.ts with 7 tests for schema validation and email sending
- Add tests to WorkflowService.test.ts for scheduleLazyEmailWorkflow,
  processWorkflowScheduledDate, and generateCommonScheduleFunctionParams methods

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

* Write `seatReferenceUid`

* Return promise

* Fix log

* Change to `Promise.allSettled`

* Type fix

* Fix failing test

* fix: reorder workflow step checks to fix test failure

The test 'should throw error if workflow step not found on reminder' was failing because the code checked workflowStep.verifiedAt before checking if workflowStep exists. When workflowStep is null, this caused the error message to include 'undefined' instead of the expected workflow step id.

Fixed by reordering the checks:
1. First check if workflowStep exists
2. Then check if workflowStep.verifiedAt exists

Also updated the test expectation to match the correct error message.

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

---------

Co-authored-by: cal.com <morgan@cal.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: hbjORbj <sldisek783@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-12-02 02:25:07 +00:00
5d8c4ad13d fix: Allow i18n of form builder max/min character count. Earlier strings missed due to variable. (#22367)
* added german translation

* fixed count issue

* fixed count issue

* resolved type safety errors

* fix

* Update getBookingResponsesSchema.ts

* fixes

* removed 1 character case

* fixes

* fix

* chore

* fix

* type-check

* type-fix

---------

Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
Co-authored-by: Volnei Munhoz <volnei.munhoz@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Dhairyashil Shinde <93669429+dhairyashiil@users.noreply.github.com>
Co-authored-by: Pallav <90088723+Pallava-Joshi@users.noreply.github.com>
2025-12-02 02:24:54 +00:00
f359d37799 feat(apps): add databuddy analytics (#24689)
* feat(apps): add databuddy analytics

* Update packages/app-store/databuddy/DESCRIPTION.md

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

---------

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-12-02 02:18:25 +00:00
Anik Dhabal Babu b98bb8b852 chore: profile repository refactor (#25328)
* chore: profile repository refactor

* remove comment

* remove comment

* cleaning

* review addressed

* rename the method name
2025-12-01 22:47:37 -03:00
abhix4 7ca2082c62 fix: availability cta hover state (#25504) 2025-12-01 23:50:35 +00:00
Anshumancanrock c11865fba9 fix: ProgressBar styling is hardcoded in dark mode (#25516) 2025-12-01 23:18:53 +00:00
Tamal ChakrabortyandPallav a3c0d06c13 fix: encode JWT in 2FA redirect URL to prevent invalid header error (#25331)
Co-authored-by: Pallav <90088723+Pallava-Joshi@users.noreply.github.com>
2025-12-01 17:53:39 +00:00
cd74f292ee fix: keyPress behaviour for Add Variable dropdown (#23405)
* Fixed variable dropdown

* Radix fix

* addressed coderabits comments

* Adjust styles and structure in AddVariablesDropdown

---------

Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
Co-authored-by: Dhairyashil <dhairyashil10101010@gmail.com>
Co-authored-by: Dhairyashil Shinde <93669429+dhairyashiil@users.noreply.github.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-12-01 17:34:52 +00:00
Alex van AndelandAnik Dhabal Babu 2c52e31c76 chore: Pin lingo.dev version to 0.117.6 (#25475)
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-12-01 16:59:16 +00:00
Eunjae LeeandDevin AI 6479adf14e fix: integer to text comparison in routing insights query (#25019)
* fix: integer to text comparison in routing insights query

Add explicit integer array cast to prevent PostgreSQL type mismatch error when comparing bookingUserId (integer) with user_id array values in getRoutedToPerPeriodData query

* add e2e tests

* refactor: remove LoadingInsight component and handle loading in ChartCard

- Enhanced ChartCard to render default loading UI when isPending is true
- Replaced all LoadingInsight usages with ChartCard that accepts isPending/isError props
- Removed LoadingInsight component and updated exports
- Updated 16 chart components to use the new pattern
- ChartCard now shows spinner and skeleton title during loading state

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

* fix: make children prop optional in ChartCard when isPending is true

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

* refactor: remove unused loadingState prop from ChartCard

- Remove loadingState prop and ChartLoadingState type export
- Simplify computedLoadingState to derive state only from isPending/isError
- No functional changes - loadingState was not being used by any components
- data-loading-state attribute behavior remains unchanged for E2E tests

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

* refactor: remove duplicate isPending early returns from chart components

- Update all 16 chart components to use single ChartCard return pattern
- Gate children rendering with !isPending && isSuccess && data checks
- Prevents data processing code from executing during loading state
- Improves code consistency and maintainability across all charts

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

* refactor: remove redundant !isPending check from chart conditionals

- Simplify conditional rendering to use just 'isSuccess && data' or 'isSuccess'
- In TanStack Query, isSuccess and isPending are mutually exclusive
- The !isPending check was redundant since isSuccess already implies !isPending
- Applied to all 16 chart components for consistency
- Components with safe defaults (data ?? []) use just 'isSuccess'
- Components requiring data check use 'isSuccess && data'

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

* revert the mistake

* apply feedback

* apply feedback

* fix e2e

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-12-01 15:07:57 +01:00
Rajiv Sahal 3c46c35b69 init: doc to setup platform billing locally via stripe (#25490) 2025-12-01 13:36:25 +00:00
Eunjae LeeandDevin AI 41ad0f718d refactor: use Button component instead of plain button in CopyButton (#25489)
* refactor: use Button component instead of plain button in CopyButton

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

* add shrink-0 to StartIcon and EndIcon of Button

* remove unused class names

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-12-01 13:13:01 +00:00
Syed Ali Shahbaz 0103321b71 feat: managed event reassignment (#24809)
* init

* --

* update dialog

* reassignment

* further changes

* add unit test

* fix

* type fix??

* fix type??

* emails

* workflows

* reason recorder

* refactor reassigned email

* fix reassigned reason

* fix type

* improve dialog

* add integration tests

* -

* remove unnecessary comments --1

* removed unnecessary comments

* fix type?

* address cubic

* address feedback

* --

* fix type?

* address cubic

* type-fix?

* fix type

* further fixes

* fix location update

* type fix

* propagate error to top

* fix mocking

* fix reported bugs

* fix

* fix success page video URL

* remove PII from logs

* persist video URL

* better audit trail

* revert email function name change

* fix test

* fix flake

* di

* fixes

* extract logic and other repo access from BookingRepository

* fixes

* (☞゚∀゚)☞ udit

* integration test fixes

* mroe fixes

* extract to repo

* extract to repo --2

* fix type

* cubic

* address feedback --1

* --2

* wip

* addressed feedback

* type fixes

* type fixes

* fix issues

* feedback --1

* feedback --2

* BookingAccessService DI

* fix

* break down function into small functions

* fixes --1

* fixes

* typefix

* addressing feedback

* fix merge conflict lost change
2025-12-01 14:04:24 +04:00
Anik Dhabal Babu 38890dad30 fix: org admin/owner can access routng form (#25412) 2025-12-01 09:46:50 +00:00
spandevandAnik Dhabal Babu 0776657faf fix: made playground page use same layout as other pages (#25216)
* fix: made playground page use same layout as other pages

* update: added feature key

* Change link display from table to list format

Refactor page layout to use a list instead of a table for links.

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-12-01 09:33:46 +00:00
Dhairyashil ShindeandKeith Williams 3cf7266048 refactor: hide duplicate translations warning (#25472)
* refactor: hide duplicate translations warning

* Apply suggestion from @keithwillcode

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2025-12-01 09:28:30 +00:00
Eunjae Lee 86b9aa5f23 fix: exclude AWAITING_HOST from seed-insights random status selection (#25414)
## What does this PR do?

Fixes a bug in the seed-insights script where `AWAITING_HOST` status was randomly assigned to bookings along with a `userId`, which violates the correct behavior of instant meetings.

**Problem:**
- The seed script randomly picked from all 5 booking statuses (including `AWAITING_HOST`)
- When `AWAITING_HOST` was selected, the script still assigned a `userId`
- Real instant meetings with `AWAITING_HOST` status should have `userId = NULL` until a host joins
- This created incorrect test data that doesn't match production behavior

**Solution:**
- Filter out `AWAITING_HOST` from `BookingStatus` values using `Object.values(BookingStatus).filter()`
- This approach is more maintainable than listing valid statuses explicitly - if new statuses are added in the future, they will automatically be included
- Moved `validStatusesForSeed` to module level so it's calculated once at load time instead of on every shuffle call
- Added comments explaining why this status requires special handling

## Mandatory Tasks (DO NOT REMOVE)

- [x] I have self-reviewed the code (A decent size PR without self-review might be rejected).
- [x] I have updated the developer docs in /docs if this PR makes changes that would require a [documentation change](https://cal.com/docs). N/A - seed script only.
- [x] I confirm automated tests are in place that prove my fix is effective or that my feature works. N/A - this is a seed script for test data generation.

## How should this be tested?

1. Run the seed script: `yarn seed-insights`
2. Verify no `AWAITING_HOST` bookings are created with a `userId`:
```sql
SELECT COUNT(*) FROM "Booking" 
WHERE status = 'awaiting_host' AND "userId" IS NOT NULL 
AND "createdAt" > NOW() - INTERVAL '1 day';
-- Should return 0 for newly seeded data
```
3. Verify other statuses are still generated:
```sql
SELECT status, COUNT(*) FROM "Booking" 
WHERE "createdAt" > NOW() - INTERVAL '1 day' 
GROUP BY status;
-- Should show ACCEPTED, PENDING, CANCELLED, REJECTED (but not AWAITING_HOST)
```

## Checklist

- [x] I have read the [contributing guide](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md)
- [x] My code follows the style guidelines of this project
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] I have checked if my changes generate no new warnings

---

> **Link to Devin run:** https://app.devin.ai/sessions/08f634e7210d415982d71c8f687b4468
> **Requested by:** eunjae@cal.com (@eunjae-lee)
2025-12-01 09:24:22 +00:00
Dhairyashil Shinde b94f107493 fix: dark-mode-multi-location-toggle-booking-page (#25469) 2025-12-01 09:21:40 +00:00
Benny Joo 4a451b411c chore: make app-store eslint config more strict to prevent circular deps (#25483) 2025-12-01 09:14:05 +00:00
sean-brydon b64cfc9e66 feat: remove min seat usage from org creation (#25448)
* remove min seat usage

* update constants and also turbo json

* Remove comments

* remove redundant tests as no min exists
2025-12-01 07:55:44 +00:00
KartikandPallav ce279da7c4 fix: stabilize date range calculation in column view (#25384)
* fix: stabilize date range calculation in column view

* fix: restore eslint-disable for scrollIntoViewSmooth

---------

Co-authored-by: Pallav <90088723+Pallava-Joshi@users.noreply.github.com>
2025-11-30 03:36:13 +05:30
Anshumancanrockandsean-brydon 7dbc888848 fix: End Impersonation Without Logging Out Admin (#25395)
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2025-11-29 21:38:31 +00:00
Rajiv Sahal 8fcbf2c167 fix: Connect atoms not working inside iframe (#25418)
* fix: google connect broken for iframe
* chore: add changesets
* chore: update atoms exports
* chore: update atoms exports
2025-11-28 11:44:20 -03:00
a050ccb4ee feat: Booking EmailAndSms Notifications Tasker (#24944)
* wip

* wip

* feature: Booking Tasker without DI yet

* feature: Booking Tasker with DI

* fix type check 1

* fix type check 2

* fix

* comment booking tasker for now

* fix: DI regularBookingService api v2

* fix: convert trigger.dev SDK imports to dynamic imports to fix unit tests

The unit tests were failing because BookingEmailAndSmsTriggerTasker.ts had static imports of trigger files that depend on @trigger.dev/sdk. This caused Vitest to try to resolve the SDK at module load time, even though it should be optional.

Changed all imports in BookingEmailAndSmsTriggerTasker.ts from static to dynamic (using await import()) so the trigger files are only loaded when the tasker methods are actually called, not at module load time during tests.

This fixes the 'Failed to load url @trigger.dev/sdk' errors that were causing 28+ test failures.

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

* fix unit tests

* keep inline smsAndEmailHandler.send calls

* chore: add team feature flag

* add satisfies ModuleLoader

* fix type check app flags

* move trigger in feature

* fix: add trigger.dev prisma  generator

* fix: email app statuses

* fix: CalEvtBuilder unit test

* chore: improvements, schema, config, retry

* fixup! chore: improvements, schema, config, retry

* chore: cleanup code

* chore: cleanup code

* chore: clean code and give full payload

* remove log

* add booking notifications queue

* add attendee phone number for sms

* bump trigger to 4.1.0

* add missing booking seat data in attendee

* update config

* fix logger regular booking service

* fix: prisma as external deps of trigger

* fix yarn.lock

* revert change to example app booking page

* fix: resolve circular dependencies and improve cold start performance in trigger tasks

- Convert BookingRepository import to type-only in CalendarEventBuilder.ts to eliminate circular dependency risk
- Convert EventNameObjectType, CalendarEvent, and JsonObject imports to type-only in BookingEmailAndSmsTaskService.ts
- Use dynamic imports in all trigger notification tasks (confirm, request, reschedule, rr-reschedule) to reduce cold start time
- Move heavy imports (BookingEmailSmsHandler, BookingRepository, prisma, TriggerDevLogger, BookingEmailAndSmsTaskService) inside run functions
- Eliminates module-level prisma import which violates repo guidelines and adds cold start overhead
- Reduces initial module dependency graph by deferring heavy imports (email templates, workflows, large repositories) until task execution

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

* fix: improve cold start performance in reminderScheduler with dynamic imports

- Remove module-level prisma import (violates 'No prisma outside repositories' guideline)
- Use dynamic imports for UserRepository (1,168 lines) - only loaded when needed in EMAIL_ATTENDEE action
- Use dynamic imports for twilio provider (386 lines) - only loaded in cancelScheduledMessagesAndScheduleEmails
- Use dynamic imports for all manager functions by action type:
  - scheduleSMSReminder (387 lines) - loaded only for SMS actions
  - scheduleEmailReminder (459 lines) - loaded only for Email actions
  - scheduleWhatsappReminder (266 lines) - loaded only for WhatsApp actions
  - scheduleAIPhoneCall (478 lines) - loaded only for AI phone call actions
- Use dynamic imports for sendOrScheduleWorkflowEmails in cancelScheduledMessagesAndScheduleEmails
- Significantly reduces cold start time by deferring heavy module loading until execution paths need them
- Eliminates module-level prisma import that violated repository pattern guidelines

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

* fix: improve cold start performance in BookingEmailSmsHandler with dynamic imports

- Remove module-level imports of all email-manager functions (653 LOC + 30+ email templates)
- Add dynamic imports in each method (_handleRescheduled, _handleRoundRobinRescheduled, _handleConfirmed, _handleRequested, handleAddGuests)
- Defer heavy email-manager loading until method execution
- Verified no circular dependencies between email-manager and bookings
- Significantly reduces cold start time for RegularBookingService and BookingEmailAndSmsTaskService

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

* fix: use dynamic imports

* update yarn lock

* code review

* trigger config project ref in env

* update yarn lock

* add .env.example trigger variables

* add .env.example trigger variables

* fix: cleanup error handling and loggin

* fix: trigger config from env

* fix: small typo fix

* fix: ai review comments

* fix: ai review comments

* ai review

* prettier

---------

Co-authored-by: hbjORbj <sldisek783@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-11-28 14:00:04 +00:00
Pallav 2522638414 fixed description for disable rescheduling and cancelling (#25447) 2025-11-28 13:13:40 +00:00
Pasquale Vitiello 0ddb456a91 fix: video page dialog bg in light mode (#25455) 2025-11-28 12:57:33 +00:00
9048d053ac feat: posthog version upgrade and added trackings (#24401)
* posthog version upgrade and calai banner tracking

* disable posthog for EU

* bunch more posthog tracking

* Revert yarn.lock changes

* add posthog package yarn changes

* fix: add missing posthog import and fix lint warning in MemberInvitationModal

Co-Authored-By: amit@cal.com <samit91848@gmail.com>

* fix: type check

* cubic fixes

* refactor

* remove ui playground

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-11-28 12:43:37 +00:00
Hariom Balhara cbc8d3d7a8 Add DI for BookingAudit (#25123) 2025-11-28 09:14:48 +00:00
e6ba89961c refactor: consolidate error handlers to use getServerErrorFromUnknown (#25114)
* refactor: consolidate error handlers to use getServerErrorFromUnknown

- Migrate server-only code to use getServerErrorFromUnknown for better error handling
- Add JSDoc documentation to both getErrorFromUnknown and getServerErrorFromUnknown
- Update webhook handlers, payment services, email service, and booking service
- Keep getErrorFromUnknown for client-side and isomorphic code
- Improve error message extraction by using err.cause?.stack instead of err.stack
- Fix ESLint warnings: replace 'any' with 'unknown' types, fix hasOwnProperty usage

Co-Authored-By: benny@cal.com <sldisek783@gmail.com>

* Update packages/app-store/paypal/api/webhook.ts

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

* refactor

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2025-11-28 09:14:06 +01:00
Dhairyashil Shinde 724204843a fix: Add PBAC permission checks for insights access (#25381)
* fix: Add PBAC permission checks for insights access

- Add checkInsightsPermission() helper that properly checks insights.read permission with PBAC support
- Update userBelongsToTeamProcedure to use PBAC-aware permission check for org-level access
- Update teamListForUser query to filter teams based on insights.read permission instead of only checking base ADMIN/OWNER roles
- Maintain backward compatibility with fallback to traditional role checks (ADMIN/OWNER) when PBAC is disabled
- Org admins (base role ADMIN/OWNER) continue to have automatic insights access as a privileged position
- Team-level admins with custom roles now properly checked for insights.read permission

Fixes issue where users with custom PBAC roles couldn't access insights even if they had insights.read permission.

Related: CAL-XXXX

* perf: Optimize team permission checks to avoid N+1 queries

Replace individual permission checks per team with bulk query using getTeamIdsWithPermission().
This reduces database queries from N (one per team) to a single optimized query.

- Use PermissionCheckService.getTeamIdsWithPermission() for bulk permission checking
- Filter teams based on returned team IDs instead of individual checks
- Maintains same functionality with significantly better performance for users with many teams

* perf: Fetch only teams with insights access instead of filtering after

Move permission check before team query to filter at database level.
Previously fetched all teams then filtered in JavaScript.
Now only fetches teams the user has insights access to.

- Check permissions first using getTeamIdsWithPermission()
- Add teamId filter to membership query (teamId: { in: teamIdsWithAccess })
- Remove JavaScript filter step (done at DB level)
- Reduces data transfer and improves query efficiency
2025-11-28 02:26:04 +05:30
Udit Takkar f136b7145f chore: update transcript endpoint description (#25385)
* chore: update transcript endpoint description

* chore: update transcript endpoint description
2025-11-27 20:17:40 +00:00
Udit Takkar 179ace23e1 fix: sign upsert error (#25437) 2025-11-27 12:33:29 -05:00
Anik Dhabal Babu 3af6fee05d fix: eventypes description overflow issue (#25436)
* fix: add overflow-y-auto to prevent description text from spilling over

* Rename setter for seatedEventData in EventMeta
2025-11-27 16:29:08 +00:00
Udit Takkar 0f84cedad2 fix: signup username collision (#25435)
* fix: signup username collision

* tests: add unit tests
2025-11-27 15:41:38 +00:00
Pasquale Vitiello a23400777a fix: prevent buttons from looking active when interacting with parent divs (#25431) 2025-11-27 14:18:35 +00:00
Dhairyashil Shinde 33c3bf3558 fix: allow-reschedule-on-prevent-impersonation (#25427)
* fix: allow-reschedule-on-prevent-impersonation

* remove selfexplanatory comment
2025-11-27 11:39:40 +00:00
emrysal 2701b4de79 chore: release v5.9.6
Post release / release (push) Canceled after 0s
Release Docker / Release AMD64 (push) Canceled after 0s
Release Docker / Release ARM (push) Canceled after 0s
2025-11-27 11:05:14 +00:00
Pasquale Vitiello a9f83ac5e2 fix: minor styling issues (#25421)
* style: remove unwanted bg

* fix: adjust FAB positioning

* style: skeleton improvements

* fix: reset password button

* fix: remove left border from remove icon

* style: improve fields

* style: wrap base form styles into layer base

* style: remove email-specific focus styles from TextField component
2025-11-27 11:00:44 +00:00
sean-brydonandcubic-dev-ai[bot] ff1533d344 chore: UI Nits onboarding v3 (#25409)
* Fix UI nits

* fix bio + team name on invite orgs

* use bg-cal-muted

* Fix error from mass edit

* Update apps/web/public/static/locales/en/common.json

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

* fix org labels

* Fix i18n

---------

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2025-11-27 08:18:43 +00:00
Benny JooandDevin AI d9cddd85ff fix: break circular dependency by passing creditCheckFn in messageDispatcher (#25343)
* fix: break circular dependency in messageDispatcher via dependency injection

Break the 4-file circular dependency chain:
credit-service → reminderScheduler → smsReminderManager → messageDispatcher → credit-service

Solution:
- Add optional creditCheckFn parameter to messageDispatcher functions
- Thread creditCheckFn through the call chain: scheduleWorkflowReminders → scheduleSMSReminder/scheduleWhatsappReminder → messageDispatcher
- When creditCheckFn is provided, use it; otherwise fall back to dynamic CreditService import for backward compatibility
- This breaks the workflows → billing import while preserving immediate fallback behavior

Changes:
- messageDispatcher: Accept optional creditCheckFn parameter, use it if provided
- smsReminderManager: Thread creditCheckFn through scheduleSMSReminder
- whatsappReminderManager: Thread creditCheckFn through scheduleWhatsappReminder
- reminderScheduler: Add creditCheckFn to ScheduleWorkflowRemindersArgs and pass through processWorkflowStep

All type checks, lint checks, and unit tests pass.

Co-Authored-By: benny@cal.com <sldisek783@gmail.com>

* feat: wire creditCheckFn from all callers to complete circular dependency fix

- Add creditCheckFn parameter to WorkflowService.scheduleFormWorkflows
- Wire creditCheckFn from all 10 entry points that call workflow scheduling:
  * formSubmissionUtils.ts (form submissions)
  * roundRobinManualReassignment.ts (round-robin reassignment)
  * triggerFormSubmittedNoEventWorkflow.ts (form workflow trigger)
  * handleBookingRequested.ts (booking requests)
  * RegularBookingService.ts (2 calls - payment initiated & new bookings)
  * handleSeats.ts (seated bookings)
  * handleConfirmation.ts (2 calls - confirmation & payment)
  * handleMarkNoShow.ts (no-show updates)
  * confirm.handler.ts (booking rejection)
- Update test expectations to use expect.objectContaining()
- Fix pre-existing lint warning in handleMarkNoShow.ts (any type)
- This completes the messageDispatcher circular dependency fix by ensuring
  creditCheckFn is actually passed through the call chain, breaking the
  4-file circular dependency at runtime

Co-Authored-By: benny@cal.com <sldisek783@gmail.com>

* fix: use generic type with type guard in logFailedResults to fix type check error

- Replace constrained type with generic type parameter
- Add proper type guard for rejected promises
- Fixes CI type check failure in handleMarkNoShow.ts:385
- Avoids 'any' type while accepting any fulfilled value shape

Co-Authored-By: benny@cal.com <sldisek783@gmail.com>

* wip

* wip

* wip

* revert

* revert

* feat: wire creditCheckFn from all remaining callers to eliminate fallbacks

- Wire creditCheckFn in packages/sms/sms-manager.ts (can safely import CreditService)
- Create makeHandler factory pattern for CRON endpoints (scheduleSMSReminders.ts, scheduleWhatsappReminders.ts)
- Wire creditCheckFn from apps/web CRON routes to factories
- Add warning log in messageDispatcher when fallback is used
- Complete creditCheckFn wiring from all direct callers (activateEventType.handler.ts, util.ts)

This eliminates all fallbacks to dynamic import except as a safety net for unforeseen call sites.
The circular dependency (workflows ↔ billing) remains acceptable as discussed with user (Option C).

Co-Authored-By: benny@cal.com <sldisek783@gmail.com>

* test: update formSubmissionUtils tests to expect creditCheckFn parameter

The scheduleFormWorkflows function now receives creditCheckFn as a parameter.
Updated test assertions to use expect.objectContaining() with creditCheckFn: expect.any(Function)
to account for the new dependency injection parameter.

Co-Authored-By: benny@cal.com <sldisek783@gmail.com>

* test: update sms-manager test to expect creditCheckFn parameter

The sendSmsOrFallbackEmail function now receives creditCheckFn as a parameter.
Updated test assertion to use expect.objectContaining() with creditCheckFn: expect.any(Function)
to account for the new dependency injection parameter. Also removed teamId: undefined
assertion as the key may be omitted entirely from the actual call.

Co-Authored-By: benny@cal.com <sldisek783@gmail.com>

* feat: make creditCheckFn required to fully break circular dependency

This commit completes the circular dependency fix by making creditCheckFn
required throughout the call chain, eliminating the dynamic import fallback
entirely.

Changes:
- Make creditCheckFn required in messageDispatcher functions (sendSmsOrFallbackEmail, scheduleSmsOrFallbackEmail)
- Remove dynamic import fallback and warning log from messageDispatcher
- Make creditCheckFn required in ScheduleTextReminderArgs (smsReminderManager)
- Make creditCheckFn required in processWorkflowStep and ScheduleWorkflowRemindersArgs (reminderScheduler)
- Add creditCheckFn to SendCancelledRemindersArgs and wire from handleCancelBooking

The circular dependency is now fully broken - no more dynamic imports of
CreditService from within the workflows package. All callers must explicitly
provide creditCheckFn via dependency injection.

Co-Authored-By: benny@cal.com <sldisek783@gmail.com>

* fix: make creditCheckFn required in WorkflowService.scheduleFormWorkflows

This commit fixes the CI type check error by making creditCheckFn required
in WorkflowService.scheduleFormWorkflows. Previously, creditCheckFn was
optional in scheduleFormWorkflows but required in scheduleWorkflowReminders,
causing a type mismatch.

Changes:
- Make creditCheckFn required in scheduleFormWorkflows signature
- Update WorkflowService.test.ts to pass mock creditCheckFn in all test cases
- Add responseId and routedEventTypeId to test calls for completeness

All callers of scheduleFormWorkflows already pass creditCheckFn, so this
change is safe and completes the circular dependency fix.

Co-Authored-By: benny@cal.com <sldisek783@gmail.com>

* remove

* fix

* refactor

* refactor

* refactor

* wip

* fix

* fix

* rm

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-11-27 16:31:26 +09:00
Alex van Andel 1ab4b9dfab chore: Fix circular dependency in tanstack-table.d.ts (#25411)
* chore: Fix circular dependency in tanstack-table.d.ts

* fix: Re-export TextFilterOperator

* Unable to export non-type values from @calcom/types

* Refactor data-table types in a way that ensures type-safety as before

* Add FilterPopover and further fixups

* Fix further type errors missed earlier

* More hidden type errors

* Type error in useFilterValue
2025-11-26 14:05:55 -03:00
Anik Dhabal Babu f3d0475840 fix: role isn't updating properly (#25415) 2025-11-26 16:13:59 +00:00
Lauris Skraucis 061283bab9 fix: api v2 cancellation reason and by not being returned (#25413)
* fix: api v2 cancellation reason and by not being returned

* fix: e2e
2025-11-26 17:09:18 +01:00
4e0798577a feat: OAuth PKCE (#25313)
* add public client

* implement PKCE

* pass codeChallenge and codeChallengeMethod to handler

* fixes for secure oauth flow

* fix type error

* clean up refresh token endpoint

* only support S256

* fix type error

* remove comment

* add tests

* fix type errors in route.test.ts

* add missing support for refresh token

* add e2e test for public client refresh tokens

* allow pkce for confidential clients

* fix type error

* fix e2e

* fix option pkce for confidential clients

* e2e test improvements

* fix test

* remove only

* add delay

* fix e2e tests

* remove only

* don't skip pkce if codeChallenge is set

* add service functions for token endpoint

* use service function in refreshToken endpoint

* use repository

* remove return types

* e2e test fixes

* fix e2e test

* remove .only in e2e test

* remove pause

* fix error responses in token endpoints

* adjust tests to new error responses

* fix error responses

* e2e improvements

* redirect on error

* adjust tests

* Update apps/web/modules/auth/oauth2/authorize-view.tsx

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

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2025-11-26 17:02:42 +01:00
Anik Dhabal Babu c95b0833c1 fix: oauth screen go back button not functional (#25401)
* fix: oauth screen back

* update
2025-11-26 15:18:53 +00:00
sean-brydon 2a7c6590a3 feat: add permission for editUsers + implement UI (#25402)
## What does this PR do?
This PR implements attributes PBAC - router checks + UI

## Visual Demo (For contributors especially)

A visual demonstration is strongly recommended, for both the original and new change **(video / image - any one)**.

#### Video Demo (if applicable):

- Show screen recordings of the issue or feature.
- Demonstrate how to reproduce the issue, the behavior before and after the change.

#### Image Demo (if applicable):

- Add side-by-side screenshots of the original and updated change.
- Highlight any significant change(s).

## Mandatory Tasks (DO NOT REMOVE)

- [ ] I have self-reviewed the code (A decent size PR without self-review might be rejected).
- [ ] I have updated the developer docs in /docs if this PR makes changes that would require a [documentation change](https://cal.com/docs). If N/A, write N/A here and check the checkbox.
- [ ] I confirm automated tests are in place that prove my fix is effective or that my feature works.

## How should this be tested?
Enable PBAC on an org 
Create a custom role -> advanced -> organizations -> "editUser" 
Assign it to a user
impersonate user
test they have access to all things attributes
remove permissions
check they dont have permissions. 

## Checklist

<!-- Remove bullet points below that don't apply to you -->

- I haven't read the [contributing guide](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md)
- My code doesn't follow the style guidelines of this project
- I haven't commented my code, particularly in hard-to-understand areas
- I haven't checked if my changes generate no new warnings










<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Adds a new PBAC “editUsers” permission and read gating for Attributes, updating the UI and backend so users can view and edit attributes only when allowed.

- **New Features**
  - Added CustomAction.EditUsers to the permission registry for organization-scoped attribute editing.
  - Settings computes canViewAttributes and shows the Attributes tab only when allowed.
  - Members page fetches Attributes permissions and exposes canViewAttributes and canEditAttributesForUser to the UI.
  - User Edit Sheet hides attributes without read permission and shows attribute editing and the bulk “Mass Assign Attributes” action only with editUsers; other user edits depend on changeMemberRole.
  - Attributes TRPC router gates create/edit/delete/toggle via PBAC and requires organization.attributes.editUsers for assign/bulk-assign; added a helper to create PBAC-aware procedures.

- **Migration**
  - Run the new Prisma migration to seed the admin role with editUsers.
  - If using custom roles, grant Edit Users under Attributes as needed.

<sup>Written for commit 856aa2e8e1521fc22cd71cb0fa6d720036efe8a2. Summary will update automatically on new commits.</sup>

<!-- End of auto-generated description by cubic. -->
2025-11-26 13:58:29 +00:00
Anik Dhabal Babu 7d2a06ddb6 fix: hide userresponses (#25370) 2025-11-26 13:27:13 +00:00
Lauris Skraucis d34ad2b0f6 fix: v2 api rescheduledByEmail not returned (#25103)
* fix: output

* fix: creation

* test: e2e rescheduling

* refactor: remove explicit return type

* fix: e2e

* fix: e2e

* fix: e2e

* fix: e2e tests

* fix: e2e tests

* fix: e2e

* logs: add debug logs for e2e

* debug e2e

* Revert "debug e2e"

This reverts commit 3d92d2bfbc351021ab06492b7279ed95843bf36a.

* Revert "logs: add debug logs for e2e"

This reverts commit f3b321fd96853bc2cf408c7f3667ad38d0931435.

* try to fix e2e

* extract e2e in separate file

* fix: e2e
2025-11-26 14:58:45 +02:00
Anik Dhabal Babu e06249d2df fix: wrong credentials (#25394) 2025-11-26 11:23:58 +00:00
sean-brydon a09dfe37a1 fix: redirect when creating orgs onboarding v3 (#25390)
* Fix redirect

* fix redirect
2025-11-26 09:54:16 +00:00
71e7f16b29 fix: prevent calendar credentials from leaking into video adapter calls (#25200)
* fix: prevent calendar credentials from leaking into video adapter calls

Split getCredentialAndWarnIfNotFound into two category-specific functions:
- getVideoCredentialAndWarnIfNotFound: only searches video credentials
- getCalendarCredentialAndWarnIfNotFound: only searches calendar credentials

This prevents the bug where calendar credentials (like google_calendar) were
being passed to getVideoAdapters() during booking deletion, causing
'Couldn't get adapter for googlecalendar' errors.

The root cause was the fallback logic that searched both videoCredentials
and calendarCredentials when a credential wasn't found by ID. Now each
function only searches within its own credential category and validates
the returned credential has the expected type suffix (_video/_conferencing
or _calendar).

Also fixed pre-existing ESLint warnings in EventManager.ts:
- Prefixed unused delegatedCredentialLast with underscore
- Replaced 'any' types with 'unknown' for better type safety
- Fixed unused variable warning in updateAllCalendarEvents error handler

Fixes the issue where deleteVideoEventForBookingReference could receive
a calendar credential when the video credential was missing, leading to
errors in getVideoAdapters.

Co-Authored-By: benny@cal.com <sldisek783@gmail.com>

* refactor: remove shared helper and implement logic directly in each function

- Removed getCredentialInternal shared helper
- Implemented logic directly in getVideoCredentialAndWarnIfNotFound
- Implemented logic directly in getCalendarCredentialAndWarnIfNotFound
- Changed fallback to explicitly use this.videoCredentials and this.calendarCredentials

Co-Authored-By: benny@cal.com <sldisek783@gmail.com>

* remove duplicate

* revert

* revert

* revert

* refactor

* add tests

* address

* clean up

* simplify

* simplify

* rename

* rename

* rename

---------

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-11-26 00:04:21 +00:00
d6546c3107 feat: upgrade tailwind v4 (#24598)
* chore: refactor config files to prevent migration tool errors

* refactor: upgrade with the tailwind migration tool

* chore: restore pre-commit command + mc

* refactor(wip): update dependencies and migrate to Tailwind CSS v4 (mainly web)

* chore: resolve Tailwind v4 migration conflicts from merging main

* chore: remove unused Tailwind packages from config and update dependencies for v4 migration

* chore: uncomment Tailwind CSS utility classes in globals.css

* fix: resolve token conflicts between calcom and coss ui

* fix: textarea scrollbar

* Fix CUI-16

* fix: added @tailwindcss/forms plugin and cleaned up CSS classes in various components to remove unnecessary dark mode styles

* fix: selects and inputs of different sizes

* fix: remove unnecessary leading-20 class from modal titles in various components

* fix: update Checkbox component styles to remove unnecessary border on checked state

* fix: clean up styles in RequiresConfirmationController, Checkbox, and Radio components to enhance consistency

* fix: update button and filter component styles to remove unnecessary rounded classes for consistency

* fix: calendar

* fix: update KBarSearch

* fix: refine styles in Empty and Checkbox components for improved consistency

* Fix focus state email input

* fix: update button hover and active states to use 'not-disabled' instead of 'enabled'

* fix: line-height issues

* fix: update class name for muted background in BookingListItem component

* fix: sidebar spacing

* chore: update class names to use new Tailwind CSS color utilities

* fix embed

* chore: upgrade Tailwind CSS to version 4.1.16 and update related dependencies

* Map css variables and add a playground test for heavy css customization

* suggestion for coss-ui

* refactor: update CSS variable usage and clean up styles

- Replace instances of `--cal-brand-color` with `--cal-brand` in embed-related HTML files.
- Remove the now-unnecessary `addAppCssVars` function from the embed core.
- Import theme tokens in the embed core styles for better consistency.
- Clean up whitespace and formatting in CSS files for improved readability.
- Add a comment in `tokens.css` regarding its usage in both embed and webapp contexts.

* Handle within tokens.css instead of fixing coss-ui

* Remove initial, not needed. Also, remove tailwind.config.js as tailwidn scans the html automaically

* fix: examples app breaking

* fix: modal not resizing correctly

* feat: upgrade atoms to tailwind v4

* fix: atoms build breaking

* fix: atoms build breaking

* chore: upgrate examples/base to tailwind 4

* chore: update globals.css

* fix: add missing scheduler css variables

* fix: PlatformAdditionalCalendarSelector

* chore: update global styles

* chore: update tailwindcss and postcss dependencies to stable versions

* chore: remove unneeded class

* fix: dialog and toast animation

* fix: replace flex-shrink-0 with shrink-0 for consistent styling in various components

* fix: dialog modal for Apple connect

* add margin in SaveFilterSegmentButton

* Fix radix button nested states

* add cursor pointer to buttons but keep dsabled state

* Fix commandK selectors and adds cursor pointer

* Fix teams filter

* fix - round checkboxes

* fix filter checkbox

* fix select indicator's margin

* command group font size

* style: fix badge and tooltip radius

* chore: remove unneeded files

* Delete PR_REVIEW_MANAGED_EVENT_REASSIGNMENT.md

* remove ui-playground leftover

* fix: add missing react phone input styles in atoms

* Delete managed-event-reassignment-flow-and-architecture.mermaid

* fix: inter font not loading

* Add theme to skeleton container so that it can support dark mode

* fix: create custom stack-y-* utilities post tw4 upgrade

* fix: typo

* fix: atoms stack class + remove unused css file

* fix default radius valiue

* fix space-y in embed

* fix skeleton background

* Hardcode radius values to match production

* fix border in embed

* add missing externalThemeClass

* feat: create a custom stack-y-* utility

* fix: add stack utility to atom global css

* fix: Skeleton loader class modalbox

* Add stack-y utility in embed

* fix: add missing stack utilities in atoms globals.css

* update yarn.lock

* add popover portla

* update

---------

Co-authored-by: Sean Brydon <sean@cal.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: Ryukemeister <sahalrajiv6900@gmail.com>
Co-authored-by: cal.com <morgan@cal.com>
Co-authored-by: Eunjae Lee <hey@eunjae.dev>
Co-authored-by: Anik Dhabal Babu <adhabal2002@gmail.com>
2025-11-25 17:32:28 -03:00
625c260e40 fix: OOO days not correctly blocked (#25259)
* fix: OOO check in buildSlotsWithDateRanges

* added testcase

---------

Co-authored-by: chauhan_s <somaychauhan98@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2025-11-25 20:14:05 +00:00
802 changed files with 32113 additions and 32634 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@calcom/atoms": patch
---
This PR fixes the issue of connect atoms not working inside iframes. It also updates the atoms exports to include the `useAvailableSlots` hook.
+6
View File
@@ -496,3 +496,9 @@ TZ=UTC
# test oauth client for `packages/platform/examples/base` (optional)
SEED_PLATFORM_OAUTH_CLIENT_ID=
SEED_PLATFORM_OAUTH_CLIENT_SECRET=
# Trigger.dev
ENABLE_ASYNC_TASKER="false" # set to "true" to enable
TRIGGER_SECRET_KEY=
TRIGGER_API_URL=https://api.trigger.dev
TRIGGER_DEV_PROJECT_REF=
+3
View File
@@ -105,3 +105,6 @@ packages/**/.yarn/ci-cache/
# AI
.claude
# trigger.dev
.trigger
-1
View File
@@ -4,7 +4,6 @@
"yzhang.markdown-all-in-one", // nicer markdown support
"esbenp.prettier-vscode", // prettier plugin
"dbaeumer.vscode-eslint", // eslint plugin
"bradlc.vscode-tailwindcss", // hinting / autocompletion for tailwind
"ban.spellright", // Spell check for docs
"stripe.vscode-stripe", // stripe VSCode extension
"Prisma.prisma", // syntax|format|completion for prisma
+1 -1
View File
@@ -8,5 +8,5 @@
"typescript.preferences.importModuleSpecifier": "non-relative",
"spellright.language": ["en"],
"spellright.documentTypes": ["markdown", "typescript", "typescriptreact"],
"tailwindCSS.experimental.classRegex": [["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"]]
"tailwindCSS.classFunctions": ["cva"]
}
View File
+6
View File
@@ -113,6 +113,12 @@ export const schemaBookingReadPublic = Booking.extend({
)
.optional(),
responses: z.record(z.any()).nullable(),
// Override metadata to handle reassignment objects from Round Robin/Managed Events
// Safe to use z.any() here because:
// 1. API v1 POST only accepts z.record(z.string()) for metadata (user input restricted)
// 2. API v1 PATCH does not accept metadata changes at all
// 3. Complex metadata (objects) are only set by trusted internal features
metadata: z.record(z.any()).nullable(),
}).pick({
id: true,
userId: true,
@@ -1,7 +1,7 @@
import type { Request, Response } from "express";
import type { NextApiRequest, NextApiResponse } from "next";
import { createMocks } from "node-mocks-http";
import { describe, it, expect, beforeAll } from "vitest";
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import prisma from "@calcom/prisma";
@@ -11,69 +11,114 @@ type CustomNextApiRequest = NextApiRequest & Request;
type CustomNextApiResponse = NextApiResponse & Response;
describe("PATCH /api/bookings", () => {
let member1Booking: Awaited<ReturnType<typeof prisma.booking.create>>;
let member0Booking: Awaited<ReturnType<typeof prisma.booking.create>>;
const createdBookingIds: number[] = [];
let testAdminUserId: number | null = null;
beforeAll(async () => {
const acmeOrg = await prisma.team.findFirst({
where: {
slug: "acme",
isOrganization: true,
},
const member1 = await prisma.user.findFirstOrThrow({
where: { email: "member1-acme@example.com" },
});
if (acmeOrg) {
await prisma.organizationSettings.upsert({
where: {
organizationId: acmeOrg.id,
},
update: {
isAdminAPIEnabled: true,
},
create: {
organizationId: acmeOrg.id,
orgAutoAcceptEmail: "acme.com",
isAdminAPIEnabled: true,
},
const member0 = await prisma.user.findFirstOrThrow({
where: { email: "member0-acme@example.com" },
});
// Create bookings for testing
member1Booking = await prisma.booking.create({
data: {
uid: `test-member1-booking-${Date.now()}`,
title: "Member 1 Test Booking",
startTime: new Date(Date.now() + 86400000), // Tomorrow
endTime: new Date(Date.now() + 90000000), // Tomorrow + 1 hour
userId: member1.id,
status: "ACCEPTED",
},
});
createdBookingIds.push(member1Booking.id);
member0Booking = await prisma.booking.create({
data: {
uid: `test-member0-booking-${Date.now()}`,
title: "Member 0 Test Booking",
startTime: new Date(Date.now() + 172800000), // Day after tomorrow
endTime: new Date(Date.now() + 176400000), // Day after tomorrow + 1 hour
userId: member0.id,
status: "ACCEPTED",
},
});
createdBookingIds.push(member0Booking.id);
});
afterAll(async () => {
if (createdBookingIds.length > 0) {
await prisma.booking.deleteMany({
where: { id: { in: createdBookingIds } },
});
}
// Clean up test admin user if created
if (testAdminUserId) {
await prisma.user.delete({
where: { id: testAdminUserId },
});
}
});
it("Returns 403 when user has no permission to the booking", async () => {
const memberUser = await prisma.user.findFirstOrThrow({ where: { email: "member2-acme@example.com" } });
const proUser = await prisma.user.findFirstOrThrow({ where: { email: "pro@example.com" } });
const booking = await prisma.booking.findFirstOrThrow({ where: { userId: proUser.id } });
// Member2 tries to access Member0's booking - should fail
const member2 = await prisma.user.findFirstOrThrow({ where: { email: "member2-acme@example.com" } });
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "PATCH",
body: {
title: booking.title,
startTime: booking.startTime.toISOString(),
endTime: booking.endTime.toISOString(),
userId: memberUser.id,
title: member0Booking.title,
startTime: member0Booking.startTime.toISOString(),
endTime: member0Booking.endTime.toISOString(),
userId: member2.id,
},
query: {
id: booking.id,
id: member0Booking.id,
},
});
req.userId = memberUser.id;
req.userId = member2.id;
await handler(req, res);
expect(res.statusCode).toBe(403);
});
it("Allows PATCH when user is system-wide admin", async () => {
const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "admin@example.com" } });
const proUser = await prisma.user.findFirstOrThrow({ where: { email: "pro@example.com" } });
const booking = await prisma.booking.findFirstOrThrow({ where: { userId: proUser.id } });
// Check if admin user already exists before upserting
const existingAdmin = await prisma.user.findUnique({ where: { email: "test-admin@example.com" } });
// Create a system-wide admin user for this test
const adminUser = await prisma.user.upsert({
where: { email: "test-admin@example.com" },
update: { role: "ADMIN" },
create: {
email: "test-admin@example.com",
username: "test-admin",
name: "Test Admin",
role: "ADMIN",
},
});
// Only track for cleanup if we created it (not if it already existed)
if (!existingAdmin) {
testAdminUserId = adminUser.id;
}
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "PATCH",
body: {
title: booking.title,
startTime: booking.startTime.toISOString(),
endTime: booking.endTime.toISOString(),
userId: proUser.id,
title: member0Booking.title,
startTime: member0Booking.startTime.toISOString(),
endTime: member0Booking.endTime.toISOString(),
userId: member0Booking.userId,
},
query: {
id: booking.id,
id: member0Booking.id,
},
});
@@ -86,19 +131,17 @@ describe("PATCH /api/bookings", () => {
it("Allows PATCH when user is org-wide admin", async () => {
const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "owner1-acme@example.com" } });
const memberUser = await prisma.user.findFirstOrThrow({ where: { email: "member1-acme@example.com" } });
const booking = await prisma.booking.findFirstOrThrow({ where: { userId: memberUser.id } });
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "PATCH",
body: {
title: booking.title,
startTime: booking.startTime.toISOString(),
endTime: booking.endTime.toISOString(),
userId: memberUser.id,
title: member1Booking.title,
startTime: member1Booking.startTime.toISOString(),
endTime: member1Booking.endTime.toISOString(),
userId: member1Booking.userId,
},
query: {
id: booking.id,
id: member1Booking.id,
},
});
@@ -1,7 +1,7 @@
import type { Request, Response } from "express";
import type { NextApiRequest, NextApiResponse } from "next";
import { createMocks } from "node-mocks-http";
import { describe, expect, it, beforeAll } from "vitest";
import { describe, expect, it, beforeAll, afterAll } from "vitest";
import { ZodError } from "zod";
import { prisma } from "@calcom/prisma";
@@ -16,51 +16,67 @@ const DefaultPagination = {
skip: 0,
};
describe("GET /api/bookings", async () => {
describe("GET /api/bookings", () => {
let proUser: Awaited<ReturnType<typeof prisma.user.findFirstOrThrow>>;
let proUserBooking: Awaited<ReturnType<typeof prisma.booking.findFirstOrThrow>>;
let memberUser: Awaited<ReturnType<typeof prisma.user.findFirstOrThrow>>;
let memberUserBooking: Awaited<ReturnType<typeof prisma.booking.create>>;
beforeAll(async () => {
const acmeOrg = await prisma.team.findFirst({
proUser = await prisma.user.findFirstOrThrow({ where: { email: "pro@example.com" } });
proUserBooking = await prisma.booking.findFirstOrThrow({ where: { userId: proUser.id } });
memberUser = await prisma.user.findFirstOrThrow({ where: { email: "member2-acme@example.com" } });
// Find an event type for memberUser or use a simple booking
const memberEventType = await prisma.eventType.findFirst({
where: {
slug: "acme",
isOrganization: true,
},
OR: [
{ userId: memberUser.id },
{ team: { members: { some: { userId: memberUser.id } } } }
]
}
});
if (acmeOrg) {
await prisma.organizationSettings.upsert({
where: {
organizationId: acmeOrg.id,
},
update: {
isAdminAPIEnabled: true,
},
create: {
organizationId: acmeOrg.id,
orgAutoAcceptEmail: "acme.com",
isAdminAPIEnabled: true,
},
memberUserBooking = await prisma.booking.create({
data: {
uid: `test-member-booking-${Date.now()}`,
title: "Member Test Booking",
startTime: new Date(Date.now() + 86400000), // Tomorrow
endTime: new Date(Date.now() + 90000000), // Tomorrow + 1 hour
userId: memberUser.id,
eventTypeId: memberEventType?.id,
status: "ACCEPTED",
},
});
});
afterAll(async () => {
// Clean up the test booking created in beforeAll
if (memberUserBooking?.id) {
await prisma.booking.delete({
where: { id: memberUserBooking.id },
});
}
});
const proUser = await prisma.user.findFirstOrThrow({ where: { email: "pro@example.com" } });
const proUserBooking = await prisma.booking.findFirstOrThrow({ where: { userId: proUser.id } });
it("Does not return bookings of other users when user has no permission", async () => {
const memberUser = await prisma.user.findFirstOrThrow({ where: { email: "member2-acme@example.com" } });
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
query: {
userId: proUser.id,
userId: proUser.id, // Try to access proUser's bookings
},
pagination: DefaultPagination,
});
req.userId = memberUser.id;
req.userId = memberUser.id; // But request is from memberUser
const responseData = await handler(req);
const groupedUsers = new Set(responseData.bookings.map((b) => b.userId));
// Should only return memberUser's own bookings, not proUser's
expect(responseData.bookings.find((b) => b.userId === memberUser.id)).toBeDefined();
expect(responseData.bookings.find((b) => b.id === memberUserBooking.id)).toBeDefined();
expect(groupedUsers.size).toBe(1);
const firstEntry = groupedUsers.entries().next().value;
expect(firstEntry?.[0]).toBe(memberUser.id);
+8 -1
View File
@@ -41,4 +41,11 @@ LOGGER_BRIDGE_LOG_LEVEL="1"
# 1: Rewrite /api/v2 to /v2
# 0: Don't rewrite
REWRITE_API_V2_PREFIX="1"
REWRITE_API_V2_PREFIX="1"
# Trigger.dev
ENABLE_ASYNC_TASKER="false" # set to "true" to enable
TRIGGER_SECRET_KEY=
TRIGGER_API_URL=https://api.trigger.dev
TRIGGER_DEV_PROJECT_REF=
@@ -230,9 +230,13 @@ export class BookingsController_2024_08_13 {
@UseGuards(BookingUidGuard)
@ApiOperation({
summary: "Get Cal Video real time transcript download links for the booking",
description: `Fetches all the transcripts download links for the booking \`:bookingUid\`
description: `Fetches all the transcript download links for the booking \`:bookingUid\`
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
<Note>
Transcripts are generated when clicking "Transcribe" during a Cal Video meeting. Download links are valid for 1 hour only - make a new request to generate fresh links after expiration.
Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.
</Note>
`,
})
async getBookingTranscripts(@Param("bookingUid") bookingUid: string): Promise<GetBookingTranscriptsOutput> {
@@ -0,0 +1,230 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output";
import { RescheduleBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reschedule-booking.output";
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import * as request from "supertest";
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { randomString } from "test/utils/randomString";
import { withApiAuth } from "test/utils/withApiAuth";
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
import type {
CreateBookingInput_2024_08_13,
BookingOutput_2024_08_13,
RescheduleBookingInput_2024_08_13,
GetBookingOutput_2024_08_13,
} from "@calcom/platform-types";
import type { EventType, User } from "@calcom/prisma/client";
describe("Reschedule bookings 2024-08-13", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let schedulesService: SchedulesService_2024_04_15;
let normalBookingUser: User;
let normalBookingEventType: EventType;
let normalBookingEventTypeId: number;
const normalBookingUserEmail = `user-bookings-normal-user-${randomString(5)}@api.com`;
const normalBookingEventTypeSlug = `user-bookings-normal-event-type-${randomString(5)}`;
let oldNormalBooking: BookingOutput_2024_08_13;
let newNormalBooking: BookingOutput_2024_08_13;
const RESCHEDULE_REASON = "Flying to venus that day";
const RESCHEDULED_BY = `user-venus-bookings-rescheduler-${randomString(5)}@api.com`;
beforeAll(async () => {
const moduleRef = await withApiAuth(
normalBookingUserEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
})
)
.overrideGuard(PermissionsGuard)
.useValue({
canActivate: () => true,
})
.compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
// initialize normal booking user, schedule and event type
normalBookingUser = await userRepositoryFixture.create({
email: normalBookingUserEmail,
username: normalBookingUserEmail,
});
const userSchedule: CreateScheduleInput_2024_04_15 = {
name: `user-bookings-normal-schedule-${randomString()}`,
timeZone: "Europe/Rome",
isDefault: true,
};
await schedulesService.createUserSchedule(normalBookingUser.id, userSchedule);
normalBookingEventType = await eventTypesRepositoryFixture.create(
{
title: `user-bookings-normal-event-type-${randomString()}`,
slug: normalBookingEventTypeSlug,
length: 60,
},
normalBookingUser.id
);
normalBookingEventTypeId = normalBookingEventType.id;
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
describe("reschedule normal booking", () => {
it("should create a booking to be rescheduled", async () => {
const bookingBody: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2040, 1, 9, 10, 0, 0)).toISOString(),
eventTypeId: normalBookingEventTypeId,
attendee: {
name: `user-venus-bookings-attendee-${randomString(10)}`,
email: `user-venus-bookings-attendee-${randomString(10)}@gmail.com`,
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/oj12u83128u9312ou3",
};
const bookingResponse = await request(app.getHttpServer())
.post("/v2/bookings")
.send(bookingBody)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201);
const bookingResponseBody: CreateBookingOutput_2024_08_13 = bookingResponse.body;
expect(bookingResponseBody.data).toBeDefined();
expect(responseDataIsBooking(bookingResponseBody.data)).toBe(true);
if (responseDataIsBooking(bookingResponseBody.data)) {
const created: BookingOutput_2024_08_13 = bookingResponseBody.data;
oldNormalBooking = created;
expect(oldNormalBooking).toBeDefined();
expect(oldNormalBooking.uid).toBeDefined();
} else {
throw new Error("should create a booking to be rescheduled - Invalid response data");
}
});
it("should reschedule", async () => {
const body: RescheduleBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2040, 1, 9, 12, 0, 0)).toISOString(),
reschedulingReason: RESCHEDULE_REASON,
rescheduledBy: RESCHEDULED_BY,
};
const beforeCreate = new Date();
return request(app.getHttpServer())
.post(`/v2/bookings/${oldNormalBooking.uid}/reschedule`)
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const afterCreate = new Date();
const responseBody: RescheduleBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const data: BookingOutput_2024_08_13 = responseBody.data as BookingOutput_2024_08_13;
expect(data.reschedulingReason).toEqual(RESCHEDULE_REASON);
expect(data.rescheduledFromUid).toEqual(oldNormalBooking.uid);
expect(data.rescheduledByEmail).toEqual(RESCHEDULED_BY);
expect(data.start).toEqual(body.start);
expect(data.end).toEqual(new Date(Date.UTC(2040, 1, 9, 13, 0, 0)).toISOString());
expect(data.id).toBeDefined();
expect(data.uid).toBeDefined();
expect(data.hosts[0].id).toEqual(normalBookingUser.id);
expect(data.hosts[0].username).toEqual(normalBookingUser.username);
expect(data.hosts[0].email).toEqual(normalBookingUser.email);
expect(data.status).toEqual(oldNormalBooking.status);
expect(data.duration).toEqual(oldNormalBooking.duration);
expect(data.eventTypeId).toEqual(oldNormalBooking.eventTypeId);
expect(data.attendees[0]).toEqual(oldNormalBooking.attendees[0]);
expect(data.location).toEqual(oldNormalBooking.location);
expect(data.absentHost).toEqual(oldNormalBooking.absentHost);
expect(data.metadata).toEqual(oldNormalBooking.metadata);
newNormalBooking = data;
expect(newNormalBooking).toBeDefined();
const createdAtDate = new Date(data.createdAt);
expect(createdAtDate.getTime()).toBeGreaterThanOrEqual(beforeCreate.getTime());
expect(createdAtDate.getTime()).toBeLessThanOrEqual(afterCreate.getTime());
});
});
it("should fetch old booking and verify rescheduledToUid and rescheduledByEmail and status cancelled", async () => {
return request(app.getHttpServer())
.get(`/v2/bookings/${oldNormalBooking.uid}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsBooking(responseBody.data)).toBe(true);
if (responseDataIsBooking(responseBody.data)) {
const data: BookingOutput_2024_08_13 = responseBody.data;
expect(data.rescheduledToUid).toEqual(newNormalBooking.uid);
expect(data.rescheduledByEmail).toEqual(RESCHEDULED_BY);
expect(data.status).toEqual("cancelled");
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibly recurring bookings"
);
}
});
});
it("should fetch new booking and verify rescheduledFromUid, rescheduledByEmail and reschedulingReason", async () => {
return request(app.getHttpServer())
.get(`/v2/bookings/${newNormalBooking.uid}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsBooking(responseBody.data)).toBe(true);
if (responseDataIsBooking(responseBody.data)) {
const data: BookingOutput_2024_08_13 = responseBody.data;
expect(data.rescheduledFromUid).toEqual(oldNormalBooking.uid);
expect(data.rescheduledByEmail).toEqual(RESCHEDULED_BY);
expect(data.reschedulingReason).toEqual(RESCHEDULE_REASON);
expect(data.status).toEqual("accepted");
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibly recurring bookings"
);
}
});
});
});
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(normalBookingUser.email);
});
function responseDataIsBooking(data: unknown): data is BookingOutput_2024_08_13 {
return !Array.isArray(data) && typeof data === "object" && data !== null && data && "id" in data;
}
});
@@ -79,7 +79,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
const eventTypeRequiresConfirmationSlug = `user-bookings-event-type-requires-confirmation-${randomString()}`;
let createdBooking: BookingOutput_2024_08_13;
let rescheduledBooking: BookingOutput_2024_08_13;
let createdRecurringBooking: RecurringBookingOutput_2024_08_13[];
let bookingInThePast: Booking;
@@ -1360,139 +1359,217 @@ describe("Bookings Endpoints 2024-08-13", () => {
});
});
describe("reschedule bookings", () => {
it("should reschedule normal booking", async () => {
const body: RescheduleBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2035, 0, 8, 14, 0, 0)).toISOString(),
reschedulingReason: "Flying to mars that day",
};
const beforeCreate = new Date();
return request(app.getHttpServer())
.post(`/v2/bookings/${createdBooking.uid}/reschedule`)
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const afterCreate = new Date();
const responseBody: RescheduleBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const data: BookingOutput_2024_08_13 = responseBody.data;
expect(data.reschedulingReason).toEqual(body.reschedulingReason);
expect(data.start).toEqual(body.start);
expect(data.end).toEqual(new Date(Date.UTC(2035, 0, 8, 15, 0, 0)).toISOString());
expect(data.rescheduledFromUid).toEqual(createdBooking.uid);
expect(data.id).toBeDefined();
expect(data.uid).toBeDefined();
expect(data.hosts[0].id).toEqual(user.id);
expect(data.hosts[0].username).toEqual(user.username);
expect(data.hosts[0].email).toEqual(user.email);
expect(data.status).toEqual(createdBooking.status);
expect(data.duration).toEqual(createdBooking.duration);
expect(data.eventTypeId).toEqual(createdBooking.eventTypeId);
expect(data.attendees[0]).toEqual(createdBooking.attendees[0]);
expect(data.location).toEqual(createdBooking.location);
expect(data.absentHost).toEqual(createdBooking.absentHost);
expect(data.metadata).toEqual(createdBooking.metadata);
// When a booking is rescheduled, a new booking is created and the old booking is cancelled.
// We want to make sure the createdAt date of the new booking is between the beforeCreate and afterCreate dates.
const createdAtDate = new Date(data.createdAt);
expect(createdAtDate.getTime()).toBeGreaterThanOrEqual(beforeCreate.getTime());
expect(createdAtDate.getTime()).toBeLessThanOrEqual(afterCreate.getTime());
rescheduledBooking = data;
describe("rescheduling bookings", () => {
describe("cant't reschedule cancelled or rescheduled booking booking", () => {
it("should not be able to reschedule cancelled booking", async () => {
const cancelledBooking = await bookingsRepositoryFixture.create({
status: "CANCELLED",
user: {
connect: {
id: user.id,
},
},
startTime: new Date(Date.UTC(2050, 0, 8, 13, 0, 0)),
endTime: new Date(Date.UTC(2050, 0, 8, 14, 0, 0)),
title: "peer coding lets goo",
uid: `cancelled-booking-${randomString()}`,
eventType: {
connect: {
id: eventTypeId,
},
},
location: "integrations:daily",
customInputs: {},
metadata: {},
responses: {
name: "Oldie",
email: "oldie@gmail.com",
},
attendees: {
create: {
email: "oldie@gmail.com",
name: "Oldie",
locale: "lv",
timeZone: "Europe/Rome",
},
},
});
const body: RescheduleBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2040, 0, 9, 14, 0, 0)).toISOString(),
reschedulingReason: "Flying to mars that day",
};
const response = await request(app.getHttpServer())
.post(`/v2/bookings/${cancelledBooking.uid}/reschedule`)
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(400);
expect(response.body.error.message).toEqual(
`Can't reschedule booking with uid=${cancelledBooking.uid} because it has been cancelled. Please provide uid of a booking that is not cancelled.`
);
await bookingsRepositoryFixture.deleteById(cancelledBooking.id);
});
it("should not be able to reschedule rescheduled booking", async () => {
const rescheduledBooking = await bookingsRepositoryFixture.create({
status: "CANCELLED",
rescheduled: true,
user: {
connect: {
id: user.id,
},
},
startTime: new Date(Date.UTC(2050, 0, 8, 13, 0, 0)),
endTime: new Date(Date.UTC(2050, 0, 8, 14, 0, 0)),
title: "peer coding lets goo",
uid: `cancelled-booking-${randomString()}`,
eventType: {
connect: {
id: eventTypeId,
},
},
location: "integrations:daily",
customInputs: {},
metadata: {},
responses: {
name: "Oldie",
email: "oldie@gmail.com",
},
attendees: {
create: {
email: "oldie@gmail.com",
name: "Oldie",
locale: "lv",
timeZone: "Europe/Rome",
},
},
});
const newBooking = await bookingsRepositoryFixture.create({
status: "ACCEPTED",
user: {
connect: {
id: user.id,
},
},
fromReschedule: rescheduledBooking.uid,
startTime: new Date(Date.UTC(2050, 0, 8, 13, 0, 0)),
endTime: new Date(Date.UTC(2050, 0, 8, 14, 0, 0)),
title: "peer coding lets goo",
uid: `new-booking-${randomString()}`,
eventType: {
connect: {
id: eventTypeId,
},
},
location: "integrations:daily",
customInputs: {},
metadata: {},
responses: {
name: "Oldie",
email: "oldie@gmail.com",
},
attendees: {
create: {
email: "oldie@gmail.com",
name: "Oldie",
locale: "lv",
timeZone: "Europe/Rome",
},
},
});
const body: RescheduleBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2040, 0, 9, 14, 0, 0)).toISOString(),
reschedulingReason: "Flying to mars that day",
};
const response = await request(app.getHttpServer())
.post(`/v2/bookings/${rescheduledBooking.uid}/reschedule`)
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(400);
expect(response.body.error.message).toEqual(
`Can't reschedule booking with uid=${rescheduledBooking.uid} because it has been cancelled and rescheduled already to booking with uid=${newBooking.uid}. You probably want to reschedule ${newBooking.uid} instead by passing it within the request URL.`
);
await bookingsRepositoryFixture.deleteById(rescheduledBooking.id);
await bookingsRepositoryFixture.deleteById(newBooking.id);
});
});
it("should set rescheduled booking status to cancelled", async () => {
return request(app.getHttpServer())
.get(`/v2/bookings/${createdBooking.uid}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: RescheduleBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const data: BookingOutput_2024_08_13 = responseBody.data;
expect(data.status).toEqual("cancelled");
expect(data.rescheduledToUid).toEqual(rescheduledBooking.uid);
});
});
describe("recurring booking", () => {
it("should reschedule recurrence of a recurring booking", async () => {
const body: RescheduleBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2035, 0, 9, 14, 0, 0)).toISOString(),
reschedulingReason: "Flying to mars again",
};
it("should reschedule recurrence of a recurring booking", async () => {
const body: RescheduleBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2035, 0, 9, 14, 0, 0)).toISOString(),
reschedulingReason: "Flying to mars again",
};
return request(app.getHttpServer())
.post(`/v2/bookings/${createdRecurringBooking[0].uid}/reschedule`)
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const responseBody: RescheduleBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const data: RecurringBookingOutput_2024_08_13 = responseBody.data;
expect(data.id).toBeDefined();
expect(data.uid).toBeDefined();
expect(data.hosts[0].id).toEqual(user.id);
expect(data.hosts[0].username).toEqual(user.username);
expect(data.hosts[0].email).toEqual(user.email);
expect(data.status).toEqual(createdRecurringBooking[0].status);
expect(data.start).toEqual(body.start);
expect(data.end).toEqual(new Date(Date.UTC(2035, 0, 9, 15, 0, 0)).toISOString());
expect(data.duration).toEqual(createdRecurringBooking[0].duration);
expect(data.recurringBookingUid).toEqual(createdRecurringBooking[0].recurringBookingUid);
expect(data.eventTypeId).toEqual(createdRecurringBooking[0].eventTypeId);
expect(data.attendees[0]).toEqual(createdRecurringBooking[0].attendees[0]);
expect(data.location).toEqual(createdRecurringBooking[0].location);
expect(data.absentHost).toEqual(createdRecurringBooking[0].absentHost);
expect(data.metadata).toEqual(createdRecurringBooking[0].metadata);
return request(app.getHttpServer())
.post(`/v2/bookings/${createdRecurringBooking[0].uid}/reschedule`)
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const responseBody: RescheduleBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const data: RecurringBookingOutput_2024_08_13 = responseBody.data;
expect(data.id).toBeDefined();
expect(data.uid).toBeDefined();
expect(data.hosts[0].id).toEqual(user.id);
expect(data.hosts[0].username).toEqual(user.username);
expect(data.hosts[0].email).toEqual(user.email);
expect(data.status).toEqual(createdRecurringBooking[0].status);
expect(data.start).toEqual(body.start);
expect(data.end).toEqual(new Date(Date.UTC(2035, 0, 9, 15, 0, 0)).toISOString());
expect(data.duration).toEqual(createdRecurringBooking[0].duration);
expect(data.recurringBookingUid).toEqual(createdRecurringBooking[0].recurringBookingUid);
expect(data.eventTypeId).toEqual(createdRecurringBooking[0].eventTypeId);
expect(data.attendees[0]).toEqual(createdRecurringBooking[0].attendees[0]);
expect(data.location).toEqual(createdRecurringBooking[0].location);
expect(data.absentHost).toEqual(createdRecurringBooking[0].absentHost);
expect(data.metadata).toEqual(createdRecurringBooking[0].metadata);
const oldBooking = await bookingsRepositoryFixture.getByUid(createdRecurringBooking[0].uid);
expect(oldBooking).toBeDefined();
expect(oldBooking?.status).toEqual("CANCELLED");
});
});
const oldBooking = await bookingsRepositoryFixture.getByUid(createdRecurringBooking[0].uid);
expect(oldBooking).toBeDefined();
expect(oldBooking?.status).toEqual("CANCELLED");
});
});
it("should get recurring booking recurrences after rescheduling one", async () => {
const recurringBookingUid = createdRecurringBooking[0].recurringBookingUid;
return request(app.getHttpServer())
.get(`/v2/bookings/${recurringBookingUid}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsRecurringBooking(responseBody.data)).toBe(true);
it("should get recurring booking recurrences after rescheduling one", async () => {
const recurringBookingUid = createdRecurringBooking[0].recurringBookingUid;
return request(app.getHttpServer())
.get(`/v2/bookings/${recurringBookingUid}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsRecurringBooking(responseBody.data)).toBe(true);
if (responseDataIsRecurringBooking(responseBody.data)) {
const data: RecurringBookingOutput_2024_08_13[] = responseBody.data;
expect(data.length).toEqual(4);
const cancelled = data.find((booking) => booking.status === "cancelled");
expect(cancelled).toBeDefined();
const rescheduledNew = data.find(
(booking) => booking.start === new Date(Date.UTC(2035, 0, 9, 14, 0, 0)).toISOString()
);
expect(rescheduledNew).toBeDefined();
createdRecurringBooking = data;
} else {
throw new Error(
"Invalid response data - expected recurring booking but received non array response"
);
}
});
if (responseDataIsRecurringBooking(responseBody.data)) {
const data: RecurringBookingOutput_2024_08_13[] = responseBody.data;
expect(data.length).toEqual(4);
const cancelled = data.find((booking) => booking.status === "cancelled");
expect(cancelled).toBeDefined();
const rescheduledNew = data.find(
(booking) => booking.start === new Date(Date.UTC(2035, 0, 9, 14, 0, 0)).toISOString()
);
expect(rescheduledNew).toBeDefined();
createdRecurringBooking = data;
} else {
throw new Error(
"Invalid response data - expected recurring booking but received non array response"
);
}
});
});
});
});
@@ -2385,95 +2462,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
});
});
describe("rescheduling booking with invalid booking responses", () => {
it("should reschedule", async () => {
const email = `user-bookings-attendee-${randomString(10)}@gmail.com`;
const username = `user-bookings-attendee-${randomString(10)}`;
const locale = "it";
const timeZone = "Europe/Rome";
const attendee = await userRepositoryFixture.create({
email,
username,
locale,
timeZone,
});
const booking = await bookingsRepositoryFixture.create({
uid: `booking-uid-${randomString(10)}`,
title: "booking title",
startTime: "2060-09-05T11:00:00.000Z",
endTime: "2060-09-05T12:00:00.000Z",
eventType: {
connect: {
id: eventTypeId,
},
},
status: "ACCEPTED",
metadata: {},
responses: "null",
user: {
connect: {
id: user.id,
},
},
attendees: {
create: {
email: email,
name: username,
timeZone,
locale,
},
},
});
const body: RescheduleBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2060, 0, 9, 14, 0, 0)).toISOString(),
reschedulingReason: "Flying to mars that day",
};
return request(app.getHttpServer())
.post(`/v2/bookings/${booking.uid}/reschedule`)
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const responseBody: RescheduleBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const data: BookingOutput_2024_08_13 = responseBody.data;
expect(data.reschedulingReason).toEqual(body.reschedulingReason);
expect(data.start).toEqual(body.start);
expect(data.end).toEqual(new Date(Date.UTC(2060, 0, 9, 15, 0, 0)).toISOString());
expect(data.rescheduledFromUid).toEqual(booking.uid);
expect(data.id).toBeDefined();
expect(data.uid).toBeDefined();
expect(data.hosts[0].id).toEqual(user.id);
expect(data.hosts[0].username).toEqual(user.username);
expect(data.hosts[0].email).toEqual(user.email);
expect(data.status).toEqual("accepted");
expect(data.eventTypeId).toEqual(booking.eventTypeId);
expect(data.attendees.length).toEqual(1);
expect(data.attendees[0]).toEqual({
name: attendee.username,
email: attendee.email,
timeZone: attendee.timeZone,
language: attendee.locale,
absent: false,
});
expect(data.bookingFieldsResponses).toEqual({
name: attendee.username,
email: attendee.email,
rescheduledReason: body.reschedulingReason,
guests: [],
});
await bookingsRepositoryFixture.deleteById(data.id);
await userRepositoryFixture.delete(attendee.id);
});
});
});
describe("create booking with attendee sms reminder", () => {
it("should not be able create a booking if attendee sms reminder is missing", async () => {
const body: CreateBookingInput_2024_08_13 = {
@@ -2559,146 +2547,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
});
});
describe("cant't reschedule cancelled booking", () => {
it("should not be able to reschedule cancelled booking", async () => {
const cancelledBooking = await bookingsRepositoryFixture.create({
status: "CANCELLED",
user: {
connect: {
id: user.id,
},
},
startTime: new Date(Date.UTC(2050, 0, 8, 13, 0, 0)),
endTime: new Date(Date.UTC(2050, 0, 8, 14, 0, 0)),
title: "peer coding lets goo",
uid: `cancelled-booking-${randomString()}`,
eventType: {
connect: {
id: eventTypeId,
},
},
location: "integrations:daily",
customInputs: {},
metadata: {},
responses: {
name: "Oldie",
email: "oldie@gmail.com",
},
attendees: {
create: {
email: "oldie@gmail.com",
name: "Oldie",
locale: "lv",
timeZone: "Europe/Rome",
},
},
});
const body: RescheduleBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2040, 0, 9, 14, 0, 0)).toISOString(),
reschedulingReason: "Flying to mars that day",
};
const response = await request(app.getHttpServer())
.post(`/v2/bookings/${cancelledBooking.uid}/reschedule`)
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(400);
expect(response.body.error.message).toEqual(
`Can't reschedule booking with uid=${cancelledBooking.uid} because it has been cancelled. Please provide uid of a booking that is not cancelled.`
);
await bookingsRepositoryFixture.deleteById(cancelledBooking.id);
});
it("should not be able to reschedule rescheduled booking", async () => {
const rescheduledBooking = await bookingsRepositoryFixture.create({
status: "CANCELLED",
rescheduled: true,
user: {
connect: {
id: user.id,
},
},
startTime: new Date(Date.UTC(2050, 0, 8, 13, 0, 0)),
endTime: new Date(Date.UTC(2050, 0, 8, 14, 0, 0)),
title: "peer coding lets goo",
uid: `cancelled-booking-${randomString()}`,
eventType: {
connect: {
id: eventTypeId,
},
},
location: "integrations:daily",
customInputs: {},
metadata: {},
responses: {
name: "Oldie",
email: "oldie@gmail.com",
},
attendees: {
create: {
email: "oldie@gmail.com",
name: "Oldie",
locale: "lv",
timeZone: "Europe/Rome",
},
},
});
const newBooking = await bookingsRepositoryFixture.create({
status: "ACCEPTED",
user: {
connect: {
id: user.id,
},
},
fromReschedule: rescheduledBooking.uid,
startTime: new Date(Date.UTC(2050, 0, 8, 13, 0, 0)),
endTime: new Date(Date.UTC(2050, 0, 8, 14, 0, 0)),
title: "peer coding lets goo",
uid: `new-booking-${randomString()}`,
eventType: {
connect: {
id: eventTypeId,
},
},
location: "integrations:daily",
customInputs: {},
metadata: {},
responses: {
name: "Oldie",
email: "oldie@gmail.com",
},
attendees: {
create: {
email: "oldie@gmail.com",
name: "Oldie",
locale: "lv",
timeZone: "Europe/Rome",
},
},
});
const body: RescheduleBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2040, 0, 9, 14, 0, 0)).toISOString(),
reschedulingReason: "Flying to mars that day",
};
const response = await request(app.getHttpServer())
.post(`/v2/bookings/${rescheduledBooking.uid}/reschedule`)
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(400);
expect(response.body.error.message).toEqual(
`Can't reschedule booking with uid=${rescheduledBooking.uid} because it has been cancelled and rescheduled already to booking with uid=${newBooking.uid}. You probably want to reschedule ${newBooking.uid} instead by passing it within the request URL.`
);
await bookingsRepositoryFixture.deleteById(rescheduledBooking.id);
await bookingsRepositoryFixture.deleteById(newBooking.id);
});
});
describe("cant't cancel already cancelled booking", () => {
it("should not be able to cancel alraedy cancelled booking", async () => {
const cancelledBooking = await bookingsRepositoryFixture.create({
@@ -2752,6 +2600,56 @@ describe("Bookings Endpoints 2024-08-13", () => {
});
});
describe("cancellation reason and cancelled by returned if null in database", () => {
it("should return cancellation reason and cancelled by if null in database", async () => {
const dbBooking = await bookingsRepositoryFixture.create({
uid: randomString(10),
title: "test",
startTime: new Date(Date.UTC(2040, 0, 13, 16, 0, 0)),
endTime: new Date(Date.UTC(2040, 0, 13, 17, 0, 0)),
eventType: {
connect: {
id: eventTypeId,
},
},
user: {
connect: {
id: user.id,
},
},
metadata: {},
responses: {
name: "tester",
email: "tester@example.com",
guests: [],
},
cancellationReason: null,
cancelledBy: null,
});
const response = await request(app.getHttpServer())
.get(`/v2/bookings/${dbBooking.uid}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200);
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.id).toBeDefined();
expect(data.cancellationReason).toBe("");
expect(data.cancelledByEmail).toBe("");
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibly recurring bookings"
);
}
});
});
describe("calendar events", () => {
beforeEach(() => {
jest.restoreAllMocks();
@@ -544,12 +544,18 @@ export class InputBookingsService_2024_08_13 {
Object.assign(newRequest, { userId, ...oAuthClientParams, platformBookingLocation: location });
newRequest.body = {
...bodyTransformed,
rescheduledBy: request.body.rescheduledBy,
noEmail: !oAuthClientParams.arePlatformEmailsEnabled,
creationSource: CreationSource.API_V2,
};
} else {
Object.assign(newRequest, { userId, platformBookingLocation: location });
newRequest.body = { ...bodyTransformed, noEmail: false, creationSource: CreationSource.API_V2 };
newRequest.body = {
...bodyTransformed,
noEmail: false,
creationSource: CreationSource.API_V2,
rescheduledBy: request.body.rescheduledBy,
};
}
return newRequest as unknown as BookingRequest;
@@ -25,7 +25,6 @@ import type {
ReassignBookingOutput_2024_08_13,
} from "@calcom/platform-types";
import type { Booking, BookingSeat } from "@calcom/prisma/client";
import { BookingStatus } from "@calcom/prisma/enums";
export const bookingResponsesSchema = z
.object({
@@ -105,14 +104,8 @@ export class OutputBookingsService_2024_08_13 {
);
const metadata = safeParse(bookingMetadataSchema, databaseBooking.metadata, defaultBookingMetadata);
const location = metadata?.videoCallUrl || databaseBooking.location;
const rescheduledToInfo = databaseBooking.rescheduled
? await this.getRescheduledToInfo(databaseBooking.uid)
: undefined;
const rescheduledToUid = rescheduledToInfo?.uid;
const rescheduledByEmail = databaseBooking.rescheduled
? rescheduledToInfo?.rescheduledBy
: databaseBooking.rescheduledBy;
const rescheduledToUid = await this.getRescheduledToUid(databaseBooking);
const rescheduledByEmail = await this.getRescheduledByEmail(databaseBooking);
const booking = {
id: databaseBooking.id,
@@ -121,9 +114,8 @@ export class OutputBookingsService_2024_08_13 {
description: databaseBooking.description,
hosts: [this.getHost(databaseBooking.user)],
status: databaseBooking.status.toLowerCase(),
cancellationReason:
databaseBooking.status === "CANCELLED" ? databaseBooking.cancellationReason : undefined,
cancelledByEmail: databaseBooking.status === "CANCELLED" ? databaseBooking.cancelledBy : undefined,
cancellationReason: databaseBooking.cancellationReason || "",
cancelledByEmail: databaseBooking.cancelledBy || "",
reschedulingReason: bookingResponses?.rescheduledReason,
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
start: databaseBooking.startTime,
@@ -168,6 +160,23 @@ export class OutputBookingsService_2024_08_13 {
};
}
private async getRescheduledToUid(databaseBooking: DatabaseBooking) {
if (!databaseBooking.rescheduled) return undefined;
const rescheduledTo = await this.bookingsRepository.getByFromReschedule(databaseBooking.uid);
return rescheduledTo?.uid;
}
private async getRescheduledByEmail(databaseBooking: DatabaseBooking) {
if (databaseBooking.rescheduled) {
return databaseBooking.rescheduledBy;
}
if (databaseBooking.fromReschedule) {
const previousBooking = await this.bookingsRepository.getByUid(databaseBooking.fromReschedule);
return previousBooking?.rescheduledBy ?? databaseBooking.rescheduledBy;
}
return databaseBooking.rescheduledBy;
}
getUserDefinedMetadata(databaseMetadata: DatabaseMetadata) {
if (databaseMetadata === null) return {};
@@ -227,10 +236,8 @@ export class OutputBookingsService_2024_08_13 {
description: databaseBooking.description,
hosts: [this.getHost(databaseBooking.user)],
status: databaseBooking.status.toLowerCase(),
cancellationReason:
databaseBooking.status === BookingStatus.CANCELLED ? databaseBooking.cancellationReason : undefined,
cancelledByEmail:
databaseBooking.status === BookingStatus.CANCELLED ? databaseBooking.cancelledBy : undefined,
cancellationReason: databaseBooking.cancellationReason || "",
cancelledByEmail: databaseBooking.cancelledBy || "",
reschedulingReason: bookingResponses?.rescheduledReason,
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
start: databaseBooking.startTime,
@@ -284,14 +291,8 @@ 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 rescheduledToInfo = databaseBooking.rescheduled
? await this.getRescheduledToInfo(databaseBooking.uid)
: undefined;
const rescheduledToUid = rescheduledToInfo?.uid;
const rescheduledByEmail = databaseBooking.rescheduled
? rescheduledToInfo?.rescheduledBy
: databaseBooking.rescheduledBy;
const rescheduledToUid = await this.getRescheduledToUid(databaseBooking);
const rescheduledByEmail = await this.getRescheduledByEmail(databaseBooking);
const booking = {
id: databaseBooking.id,
@@ -300,8 +301,7 @@ export class OutputBookingsService_2024_08_13 {
description: databaseBooking.description,
hosts: [this.getHost(databaseBooking.user)],
status: databaseBooking.status.toLowerCase(),
cancellationReason:
databaseBooking.status === BookingStatus.CANCELLED ? databaseBooking.cancellationReason : undefined,
cancellationReason: databaseBooking.cancellationReason || "",
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
start: databaseBooking.startTime,
end: databaseBooking.endTime,
@@ -428,8 +428,7 @@ export class OutputBookingsService_2024_08_13 {
description: databaseBooking.description,
hosts: [this.getHost(databaseBooking.user)],
status: databaseBooking.status.toLowerCase(),
cancellationReason:
databaseBooking.status === BookingStatus.CANCELLED ? databaseBooking.cancellationReason : undefined,
cancellationReason: databaseBooking.cancellationReason || "",
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
start: databaseBooking.startTime,
end: databaseBooking.endTime,
@@ -318,7 +318,7 @@ const CustomFieldsSchema = z.object({
id: z.literal("user"),
type: z.literal("user"),
label: z.literal("User"),
fieldRequired: z.literal(true),
fieldRequired: z.boolean().optional(),
})
),
editable: z.enum(["user", "user-readonly"]),
+43 -21
View File
@@ -1,11 +1,12 @@
import { Injectable, Logger as NestLogger, Scope } from "@nestjs/common";
import type { Logger as TsLogger } from "tslog";
// 1. Define an interface for the settings
interface IMyLoggerSettings {
minLevel: number; // Example: 0=debug, 1=info, 2=warn, 3=error
displayTimestamp: boolean;
logFormat: "pretty" | "json" | "simple";
// Add any other settings you need, mimicking tslog or your own requirements
// Add unknown other settings you need, mimicking tslog or your own requirements
// e.g., name?: string; displayFunctionName?: boolean; etc.
}
@@ -23,7 +24,10 @@ const LogLevel = {
* (e.g., sending to Axiom) and adds an optional prefix for context.
*/
@Injectable({ scope: Scope.TRANSIENT }) // TRANSIENT ensures getSubLogger provides truly independent instances if needed elsewhere
export class Logger {
export class Logger
implements
Pick<TsLogger<unknown>, "log" | "silly" | "trace" | "debug" | "info" | "warn" | "error" | "getSubLogger">
{
// Use NestLogger for the actual logging output
private readonly nestLogger = new NestLogger("LoggerBridge");
// Prefix to add to messages for this instance
@@ -57,50 +61,68 @@ export class Logger {
* @param options.prefix - An array of strings to join as the log prefix.
* @returns A new LoggerBridge instance with the specified prefix.
*/
getSubLogger(options: { prefix?: string[] }): Logger {
getSubLogger(options: { prefix?: string[] }): TsLogger<unknown> {
const newLogger = new Logger();
// Set the prefix for the *new* instance
newLogger.prefix = options?.prefix?.join(" ") ?? "";
return newLogger;
return newLogger as unknown as TsLogger<unknown>;
}
// --- Public logging methods ---
info(...args: any[]) {
this.settings.minLevel <= 1 && this.logInternal("log", ...args);
log(...args: unknown[]): undefined {
if (this.settings.minLevel <= LogLevel.INFO) this.logInternal("log", ...args);
}
warn(...args: any[]) {
this.settings.minLevel <= 2 && this.logInternal("warn", ...args);
info(...args: unknown[]): undefined {
if (this.settings.minLevel <= 1) {
this.logInternal("log", ...args);
}
}
error(...args: any[]) {
this.settings.minLevel <= 3 && this.logInternal("error", ...args);
warn(...args: unknown[]): undefined {
if (this.settings.minLevel <= 2) {
this.logInternal("warn", ...args);
}
}
debug(...args: any[]) {
this.settings.minLevel === 0 && this.logInternal("debug", ...args);
error(...args: unknown[]): undefined {
if (this.settings.minLevel <= 3) {
this.logInternal("error", ...args);
}
}
trace(...args: any[]) {
this.settings.minLevel === 0 && this.logInternal("verbose", ...args);
debug(...args: unknown[]): undefined {
if (this.settings.minLevel === 0) {
this.logInternal("debug", ...args);
}
}
fatal(...args: any[]) {
trace(...args: unknown[]): undefined {
if (this.settings.minLevel === 0) {
this.logInternal("verbose", ...args);
}
}
fatal(...args: unknown[]): undefined {
// Prepend FATAL: to the message and log as error
const fatalMessage = `fatal: ${this.formatArgsAsString(args)}`;
this.settings.minLevel <= 3 && this.logInternal("error", fatalMessage);
if (this.settings.minLevel <= 3) {
this.logInternal("error", fatalMessage);
}
}
silly(...args: any[]) {
silly(...args: unknown[]): undefined {
const sillyMessage = `silly: ${this.formatArgsAsString(args)}`;
this.settings.minLevel === 0 && this.logInternal("verbose", sillyMessage);
if (this.settings.minLevel === 0) {
this.logInternal("verbose", sillyMessage);
}
}
// --- Internal logging implementation ---
/** Formats arguments into a single string */
private formatArgsAsString(args: any[]): string {
private formatArgsAsString(args: unknown[]): string {
return args
.map((arg) => {
if (typeof arg === "string") {
@@ -109,7 +131,7 @@ export class Logger {
// Attempt to stringify non-string arguments
try {
return JSON.stringify(arg);
} catch (e) {
} catch {
return "[Unserializable Object]";
}
})
@@ -117,7 +139,7 @@ export class Logger {
}
/** Central method to forward logs to NestLogger */
private logInternal(level: "log" | "warn" | "error" | "debug" | "verbose", ...args: any[]) {
private logInternal(level: "log" | "warn" | "error" | "debug" | "verbose", ...args: unknown[]) {
try {
// Format message from potentially multiple arguments
const formattedMessage = this.formatArgsAsString(args);
@@ -1,16 +1,23 @@
import { Logger } from "@/lib/logger.bridge";
import { PrismaAttributeRepository } from "@/lib/repositories/prisma-attribute.repository";
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.repository";
import { PrismaHostRepository } from "@/lib/repositories/prisma-host.repository";
import { PrismaOOORepository } from "@/lib/repositories/prisma-ooo.repository";
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
import { BookingEmailSmsService } from "@/lib/services/booking-emails-sms-service";
import { BookingEventHandlerService } from "@/lib/services/booking-event-handler.service";
import { CheckBookingAndDurationLimitsService } from "@/lib/services/check-booking-and-duration-limits.service";
import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
import { HashedLinkService } from "@/lib/services/hashed-link.service";
import { LuckyUserService } from "@/lib/services/lucky-user.service";
import { RegularBookingService } from "@/lib/services/regular-booking.service";
import { BookingEmailAndSmsSyncTaskerService } from "@/lib/services/tasker/booking-emails-sms-sync-tasker.service";
import { BookingEmailAndSmsTaskService } from "@/lib/services/tasker/booking-emails-sms-task.service";
import { BookingEmailAndSmsTasker } from "@/lib/services/tasker/booking-emails-sms-tasker.service";
import { BookingEmailAndSmsTriggerTaskerService } from "@/lib/services/tasker/booking-emails-sms-trigger-tasker.service";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { Module } from "@nestjs/common";
import { Module, Scope } from "@nestjs/common";
@Module({
imports: [PrismaModule],
@@ -21,12 +28,25 @@ import { Module } from "@nestjs/common";
PrismaHostRepository,
PrismaOOORepository,
PrismaUserRepository,
{
provide: Logger,
useFactory: () => {
return new Logger();
},
scope: Scope.TRANSIENT,
},
BookingEventHandlerService,
CheckBookingAndDurationLimitsService,
CheckBookingLimitsService,
HashedLinkService,
LuckyUserService,
BookingEmailSmsService,
BookingEmailAndSmsTaskService,
BookingEmailAndSmsSyncTaskerService,
BookingEmailAndSmsTriggerTaskerService,
BookingEmailAndSmsTasker,
RegularBookingService,
],
exports: [RegularBookingService],
})
export class RegularBookingModule { }
export class RegularBookingModule {}
@@ -1,7 +1,7 @@
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import { BookingCancelService as BaseBookingCancelService } from "@calcom/features/bookings/lib/handleCancelBooking";
import { BookingCancelService as BaseBookingCancelService } from "@calcom/platform-libraries/bookings";
@Injectable()
export class BookingCancelService extends BaseBookingCancelService {
@@ -0,0 +1,13 @@
import { Logger } from "@/lib/logger.bridge";
import { Injectable } from "@nestjs/common";
import { BookingEmailSmsHandler } from "@calcom/platform-libraries/bookings";
@Injectable()
export class BookingEmailSmsService extends BookingEmailSmsHandler {
constructor(logger: Logger) {
super({
logger,
});
}
}
@@ -0,0 +1,18 @@
import { Injectable } from "@nestjs/common";
import { BookingEventHandlerService as BaseBookingEventHandlerService } from "@calcom/platform-libraries/bookings";
import { Logger } from "@/lib/logger.bridge";
import { HashedLinkService } from "./hashed-link.service";
@Injectable()
export class BookingEventHandlerService extends BaseBookingEventHandlerService {
constructor(hashedLinkService: HashedLinkService, bridgeLogger: Logger) {
super({
log: bridgeLogger,
hashedLinkService,
});
}
}
@@ -1,8 +1,11 @@
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.repository";
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
import { BookingEventHandlerService } from "@/lib/services/booking-event-handler.service";
import { CheckBookingAndDurationLimitsService } from "@/lib/services/check-booking-and-duration-limits.service";
import { HashedLinkService } from "@/lib/services/hashed-link.service";
import { LuckyUserService } from "@/lib/services/lucky-user.service";
import { BookingEmailAndSmsTasker } from "@/lib/services/tasker/booking-emails-sms-tasker.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
@@ -17,7 +20,10 @@ export class RegularBookingService extends BaseRegularBookingService {
bookingRepository: PrismaBookingRepository,
hashedLinkService: HashedLinkService,
luckyUserService: LuckyUserService,
userRepository: PrismaUserRepository
userRepository: PrismaUserRepository,
bookingEmailAndSmsTasker: BookingEmailAndSmsTasker,
featuresRepository: PrismaFeaturesRepository,
bookingEventHandler: BookingEventHandlerService
) {
super({
checkBookingAndDurationLimitsService,
@@ -26,6 +32,9 @@ export class RegularBookingService extends BaseRegularBookingService {
hashedLinkService,
luckyUserService,
userRepository,
bookingEmailAndSmsTasker,
featuresRepository,
bookingEventHandler,
});
}
}
@@ -0,0 +1,15 @@
import { Logger } from "@/lib/logger.bridge";
import { BookingEmailAndSmsTaskService } from "@/lib/services/tasker/booking-emails-sms-task.service";
import { Injectable } from "@nestjs/common";
import { BookingEmailAndSmsSyncTasker as BaseBookingEmailAndSmsSyncTaskerService } from "@calcom/platform-libraries/bookings";
@Injectable()
export class BookingEmailAndSmsSyncTaskerService extends BaseBookingEmailAndSmsSyncTaskerService {
constructor(bookingTaskService: BookingEmailAndSmsTaskService, logger: Logger) {
super({
logger,
bookingTaskService,
});
}
}
@@ -0,0 +1,21 @@
import { Logger } from "@/lib/logger.bridge";
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
import { BookingEmailSmsService } from "@/lib/services/booking-emails-sms-service";
import { Injectable } from "@nestjs/common";
import { BookingEmailAndSmsTaskService as BaseBookingEmailAndSmsTaskService } from "@calcom/platform-libraries/bookings";
@Injectable()
export class BookingEmailAndSmsTaskService extends BaseBookingEmailAndSmsTaskService {
constructor(
bookingEmailSmsService: BookingEmailSmsService,
bookingRepository: PrismaBookingRepository,
logger: Logger
) {
super({
logger,
emailsAndSmsHandler: bookingEmailSmsService,
bookingRepository,
});
}
}
@@ -0,0 +1,21 @@
import { Logger } from "@/lib/logger.bridge";
import { BookingEmailAndSmsSyncTaskerService } from "@/lib/services/tasker/booking-emails-sms-sync-tasker.service";
import { BookingEmailAndSmsTriggerTaskerService } from "@/lib/services/tasker/booking-emails-sms-trigger-tasker.service";
import { Injectable } from "@nestjs/common";
import { BookingEmailAndSmsTasker as BaseBookingEmailAndSmsTasker } from "@calcom/platform-libraries/bookings";
@Injectable()
export class BookingEmailAndSmsTasker extends BaseBookingEmailAndSmsTasker {
constructor(
syncTasker: BookingEmailAndSmsSyncTaskerService,
asyncTasker: BookingEmailAndSmsTriggerTaskerService,
logger: Logger
) {
super({
logger,
asyncTasker: asyncTasker,
syncTasker: syncTasker,
});
}
}
@@ -0,0 +1,13 @@
import { Logger } from "@/lib/logger.bridge";
import { Injectable } from "@nestjs/common";
import { BookingEmailAndSmsTriggerDevTasker as BaseBookingEmailAndSmsTriggerDevTasker } from "@calcom/platform-libraries/bookings";
@Injectable()
export class BookingEmailAndSmsTriggerTaskerService extends BaseBookingEmailAndSmsTriggerDevTasker {
constructor(logger: Logger) {
super({
logger,
});
}
}
@@ -45,8 +45,8 @@ export function ShellMainAppDir(props: LayoutProps) {
className={classNames(
props.backPath
? "relative"
: "pwa:bottom-[max(7rem,_calc(5rem_+_env(safe-area-inset-bottom)))] fixed bottom-32 z-40 ltr:right-4 rtl:left-4 md:z-auto md:ltr:right-0 md:rtl:left-0",
"flex-shrink-0 [-webkit-app-region:no-drag] md:relative md:bottom-auto md:right-auto"
: "pwa:bottom-[max(7rem,_calc(5rem_+_env(safe-area-inset-bottom)))] fixed bottom-20 z-40 ltr:right-4 rtl:left-4 md:z-auto md:ltr:right-0 md:rtl:left-0",
"shrink-0 [-webkit-app-region:no-drag] md:relative md:bottom-auto md:right-auto"
)}>
{props.CTA}
</div>
@@ -64,8 +64,8 @@ const Page = async ({ searchParams: _searchParams }: PageProps) => {
const organizationRepository = getOrganizationRepository();
const isOrgPrivate = organizationId
? await organizationRepository.checkIfPrivate({
orgId: organizationId,
})
orgId: organizationId,
})
: false;
const permissionService = new PermissionCheckService();
@@ -81,12 +81,7 @@ const Page = async ({ searchParams: _searchParams }: PageProps) => {
heading={t("availability")}
subtitle={t("configure_availability")}
CTA={
<AvailabilityCTA
toggleGroupOptions={[
{ value: "mine", label: t("my_availability") },
...(canViewTeamAvailability ? [{ value: "team", label: t("team_availability") }] : []),
]}
/>
<AvailabilityCTA canViewTeamAvailability={canViewTeamAvailability} />
}>
{searchParams?.type === "team" && canViewTeamAvailability ? (
<AvailabilitySliderTable isOrg={!!organizationId} />
@@ -9,16 +9,12 @@ import { AvailabilityCTA } from "~/availability/availability-view";
export default function AvailabilityLoader() {
const { t } = useLocale();
const toggleGroupOptions = [
{ value: "mine", label: t("my_availability") },
{ value: "team", label: t("team_availability") },
];
return (
<ShellMainAppDir
heading={t("availability")}
subtitle={t("configure_availability")}
CTA={<AvailabilityCTA toggleGroupOptions={toggleGroupOptions} />}>
CTA={<AvailabilityCTA canViewTeamAvailability />}>
<SkeletonLoader />
</ShellMainAppDir>
);
@@ -1,4 +1,5 @@
"use client";
import posthog from "posthog-js";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@@ -13,6 +14,9 @@ export const TeamsCTA = () => {
StartIcon="plus"
size="sm"
type="button"
onClick={() => {
posthog.capture("add_team_button_clicked")
}}
href={`${WEBAPP_URL}/settings/teams/new?returnTo=${WEBAPP_URL}/teams`}>
{t("new")}
</Button>
@@ -97,7 +97,7 @@ function Field({
data-testid={`${hookFieldNamespace}.label`}
disabled={!!router}
label="Label"
className="flex-grow"
className="grow"
placeholder={t("this_is_what_your_users_would_see")}
defaultValue={label || routerField?.label || "Field"}
required
@@ -189,7 +189,7 @@ function Field({
/>
</div>
{["select", "multiselect"].includes(fieldType) ? (
<div className="bg-muted w-full rounded-[10px] p-2">
<div className="bg-cal-muted w-full rounded-[10px] p-2">
<Label className="text-subtle">{t("options")}</Label>
<MultiOptionInput
fieldArrayName={`${hookFieldNamespace}.options`}
@@ -319,7 +319,7 @@ const FormEdit = ({
) : (
<div className="w-full py-4 lg:py-8">
{/* TODO: remake empty screen for V3 */}
<div className="border-sublte bg-muted flex flex-col items-center gap-6 rounded-xl border p-11">
<div className="border-sublte bg-cal-muted flex flex-col items-center gap-6 rounded-xl border p-11">
<div className="mb-3 grid">
{/* Icon card - Top */}
<div className="bg-default border-subtle z-30 col-start-1 col-end-1 row-start-1 row-end-1 h-10 w-10 transform rounded-md border shadow-sm">
@@ -122,7 +122,7 @@ function Page({ form }: { form: RoutingFormWithResponseCount }) {
/>
</div>
{salesforceActionEnabled ? (
<div className="bg-muted mt-1 rounded-xl p-2">
<div className="bg-cal-muted mt-1 rounded-xl p-2">
{form.team && (
<>
<div className="bg-default mt-2 rounded-xl px-2 py-2">
@@ -307,7 +307,7 @@ const WeightedAttributesSelector = ({
}}
/>
</div>
<div className="bg-muted mt-1 rounded-xl p-2">
<div className="bg-cal-muted mt-1 rounded-xl p-2">
{attributeIdForWeights ? (
<SelectField
size="sm"
@@ -652,21 +652,21 @@ const Route = ({
<div
className={classNames(
"cal-query-builder-card w-full gap-2 p-2",
route.isFallback && "bg-muted border-subtle rounded-xl border"
route.isFallback && "bg-cal-muted border-subtle rounded-xl border"
)}>
<div className="cal-query-builder w-full ">
{formFieldsQueryBuilder}
<div>
{route.isFallback ? (
<div className="flex w-full flex-col gap-2 text-sm lg:flex-row">
<div className="flex flex-grow items-center gap-2">
{/* <div className="flex flex-grow-0 whitespace-nowrap">
<div className="flex grow items-center gap-2">
{/* <div className="flex grow-0 whitespace-nowrap">
<span>{t("send_booker_to")}</span>
</div> */}
<Select
size="sm"
isDisabled={disabled}
className="data-testid-select-routing-action block w-full flex-grow"
className="data-testid-select-routing-action block w-full grow"
required
value={RoutingPages.find((page) => page.value === route.action?.type)}
onChange={(item) => {
@@ -695,7 +695,7 @@ const Route = ({
required
disabled={disabled}
name="customPageMessage"
className="border-default flex flex-grow lg:w-fit"
className="border-default flex grow lg:w-fit"
style={{
minHeight: "38px",
}}
@@ -708,8 +708,8 @@ const Route = ({
<TextField
disabled={disabled}
name="externalRedirectUrl"
className="border-default flex flex-grow text-sm"
containerClassName="flex-grow"
className="border-default flex grow text-sm"
containerClassName="grow"
type="url"
required
labelSrOnly
@@ -720,7 +720,7 @@ const Route = ({
placeholder="https://example.com"
/>
) : (
<div className="flex-grow">
<div className="grow">
<Select
size="sm"
required
@@ -758,8 +758,8 @@ const Route = ({
<>
<TextField
disabled={disabled}
className="border-default flex w-full flex-grow text-sm"
containerClassName="flex-grow mt-2"
className="border-default flex w-full grow text-sm"
containerClassName="grow mt-2"
addOnLeading={eventTypePrefix}
required
value={customEventTypeSlug}
@@ -796,12 +796,12 @@ const Route = ({
</div>
<span className="text-emphasis ml-2 text-sm font-medium">Send booker to</span>
</div>
<div className="bg-muted flex w-full flex-col gap-2 rounded-xl p-2 text-sm lg:flex-row">
<div className="flex flex-grow items-center gap-2">
<div className="bg-cal-muted flex w-full flex-col gap-2 rounded-xl p-2 text-sm lg:flex-row">
<div className="flex grow items-center gap-2">
<Select
size="sm"
isDisabled={disabled}
className="data-testid-select-routing-action block w-full flex-grow"
className="data-testid-select-routing-action block w-full grow"
required
value={RoutingPages.find((page) => page.value === route.action?.type)}
onChange={(item) => {
@@ -830,7 +830,7 @@ const Route = ({
required
disabled={disabled}
name="customPageMessage"
className="border-default flex flex-grow lg:w-fit"
className="border-default flex grow lg:w-fit"
style={{
minHeight: "38px",
}}
@@ -844,8 +844,8 @@ const Route = ({
size="sm"
disabled={disabled}
name="externalRedirectUrl"
className="border-default flex flex-grow text-sm"
containerClassName="flex-grow"
className="border-default flex grow text-sm"
containerClassName="grow"
type="url"
required
labelSrOnly
@@ -856,7 +856,7 @@ const Route = ({
placeholder="https://example.com"
/>
) : (
<div className="flex-grow">
<div className="grow">
<Select
size="sm"
required
@@ -894,8 +894,8 @@ const Route = ({
<>
<TextField
disabled={disabled}
className="border-default flex w-full flex-grow text-sm"
containerClassName="flex-grow mt-2"
className="border-default flex w-full grow text-sm"
containerClassName="grow mt-2"
addOnLeading={eventTypePrefix}
required
value={customEventTypeSlug}
@@ -177,7 +177,7 @@ function RoutingForm({ form, profile, ...restProps }: Props) {
<>
<div className={classNames("mx-auto my-0 max-w-3xl", isEmbed ? "" : "md:my-24")}>
<div className="w-full max-w-4xl ltr:mr-2 rtl:ml-2">
<div className="main border-booker md:border-booker-width dark:bg-muted bg-default mx-0 rounded-md p-4 py-6 sm:-mx-4 sm:px-8 ">
<div className="main border-booker md:border-booker-width dark:bg-cal-muted bg-default mx-0 rounded-md p-4 py-6 sm:-mx-4 sm:px-8 ">
<Toaster position="bottom-right" />
<form onSubmit={handleOnSubmit}>
@@ -3,6 +3,7 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { useEffect, useState } from "react";
import { useFormContext } from "react-hook-form";
import posthog from "posthog-js";
import { isFallbackRoute } from "@calcom/app-store/routing-forms/lib/isFallbackRoute";
import type { RoutingFormWithResponseCount } from "@calcom/app-store/routing-forms/types/types";
@@ -46,6 +47,7 @@ function NewFormButton({ setNewFormDialogState }: { setNewFormDialogState: SetNe
data-testid="new-routing-form"
createFunction={(teamId) => {
setNewFormDialogState({ action: "new", target: teamId ? String(teamId) : "" });
posthog.capture("new_routing_form_button_clicked", { teamId });
}}
withPermission={{
permission: "routingForm.create",
@@ -76,6 +78,7 @@ export default function RoutingForms({ appUrl }: { appUrl: string }) {
useEffect(() => {
hookForm.reset({});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const filters = getTeamsFiltersFromQuery(routerQuery);
@@ -160,7 +163,7 @@ export default function RoutingForms({ appUrl }: { appUrl: string }) {
background="/tips/routing-forms"
isParentLoading={<SkeletonLoaderTeamList />}
buttons={
<div className="space-y-2 rtl:space-x-reverse sm:space-x-2">
<div className="stack-y-2 rtl:space-x-reverse sm:space-x-2">
<ButtonGroup>
<Button color="primary" href={`${WEBAPP_URL}/settings/teams/new`}>
{t("upgrade")}
@@ -278,7 +281,7 @@ export default function RoutingForms({ appUrl }: { appUrl: string }) {
action="edit"
routingForm={form}
color="minimal"
className="!flex"
className="flex!"
StartIcon="pencil">
{t("edit")}
</FormAction>
@@ -24,7 +24,7 @@ export function AvailabilityScheduleSkeleton() {
</div>
{/* Action buttons */}
<div className="relative flex-shrink-0 md:relative md:bottom-auto md:right-auto">
<div className="relative shrink-0 md:relative md:bottom-auto md:right-auto">
{/* Desktop buttons - Set to Default, Delete, and Save */}
<div className="flex items-center justify-end">
{/* Set to Default - desktop only */}
@@ -99,14 +99,14 @@ export function AvailabilityScheduleSkeleton() {
<SkeletonText className="ml-2 h-4 w-4 rounded-full" /> {/* Info icon */}
</div>
<SkeletonText className="mb-4 mt-1 h-4 w-full max-w-md text-sm" /> {/* Description */}
<div className="space-y-2">
<div className="stack-y-2">
<SkeletonButton className="h-9 w-36 rounded-md" /> {/* Add an override button */}
</div>
</div>
</div>
{/* Sidebar - only visible on desktop */}
<div className="min-w-40 col-span-3 hidden space-y-2 md:block lg:col-span-1 xl:w-[25%]">
<div className="min-w-40 col-span-3 hidden stack-y-2 md:block lg:col-span-1 xl:w-[25%]">
<div className="xl:max-w-80 w-full pr-4 sm:ml-0 sm:mr-36 sm:p-0">
<div>
<SkeletonText className="mb-1 h-4 w-24 text-sm" /> {/* Timezone */}
@@ -8,7 +8,7 @@ import { Icon } from "@calcom/ui/components/icon";
export default function TroubleshooterLayout({ children }: { children: React.ReactNode }) {
return (
<>
<div className="flex flex-1 [&>*]:flex-1">
<div className="flex flex-1 *:flex-1">
<ErrorBoundary>
<Suspense fallback={<Icon name="loader" />}>{children}</Suspense>
</ErrorBoundary>
@@ -32,8 +32,8 @@ export const EventTypeEditPageSkeleton = () => {
<Shell subtitle={<SkeletonText className="hidden h-4 w-24" />} afterHeading={backButtonSkeleton}>
<div className="flex flex-col xl:flex-row xl:space-x-6">
<div className="hidden xl:block">
<div className="primary-navigation w-64 flex-shrink-0">
<div className="flex flex-col space-y-1">
<div className="primary-navigation w-64 shrink-0">
<div className="flex flex-col stack-y-1">
{/* Tab navigation items */}
{Array.from({ length: 8 }).map((_, index) => (
<div
@@ -58,14 +58,14 @@ export const EventTypeEditPageSkeleton = () => {
<SkeletonText className="h-8 w-full max-w-md" />
</div>
<div className="mb-6 space-y-6">
<div className="mb-6 stack-y-6">
{/* Form fields */}
<div className="flex flex-col space-y-2">
<div className="flex flex-col stack-y-2">
<SkeletonText className="h-4 w-32" />
<SkeletonText className="h-10 w-full" />
</div>
<div className="flex flex-col space-y-2">
<div className="flex flex-col stack-y-2">
<SkeletonText className="h-4 w-40" />
<div className="flex items-center">
<SkeletonText className="h-10 w-full" />
@@ -73,12 +73,12 @@ export const EventTypeEditPageSkeleton = () => {
</div>
</div>
<div className="flex flex-col space-y-2">
<div className="flex flex-col stack-y-2">
<SkeletonText className="h-4 w-36" />
<SkeletonText className="h-24 w-full" />
</div>
<div className="flex flex-col space-y-2">
<div className="flex flex-col stack-y-2">
<SkeletonText className="h-4 w-28" />
<div className="flex items-center">
<SkeletonText className="h-10 w-full" />
@@ -86,7 +86,7 @@ export const EventTypeEditPageSkeleton = () => {
</div>
</div>
<div className="flex flex-col space-y-2">
<div className="flex flex-col stack-y-2">
<SkeletonText className="h-4 w-32" />
<SkeletonText className="h-10 w-full" />
</div>
@@ -38,7 +38,7 @@ export default function UpgradeTipWrapper({ children }: { children: React.ReactN
features={features}
background="/tips/insights"
buttons={
<div className="space-y-2 rtl:space-x-reverse sm:space-x-2">
<div className="stack-y-2 rtl:space-x-reverse sm:space-x-2">
<ButtonGroup>
<Button color="primary" href={`${WEBAPP_URL}/settings/teams/new`}>
{t("create_team")}
@@ -109,7 +109,7 @@ const PaymentPage: FC<PaymentPageProps> = (props) => {
aria-modal="true"
aria-labelledby="modal-headline">
<div>
<div className="bg-success mx-auto flex h-12 w-12 items-center justify-center rounded-full">
<div className="bg-cal-success mx-auto flex h-12 w-12 items-center justify-center rounded-full">
<PayIcon currency={paymentAppData.currency} className="h-8 w-8 text-green-600" />
</div>
@@ -15,7 +15,7 @@ export default function Loading() {
<SkeletonText className="h-5 w-32" />
</div>
<div className="mb-6 space-y-2">
<div className="mb-6 stack-y-2">
<SkeletonText className="h-7 w-3/4" />
<SkeletonText className="h-7 w-1/2" />
</div>
@@ -18,7 +18,7 @@ export default function AdminLayoutAppDirClient({ children }: AdminLayoutProps)
const isAppsPage = pathname?.startsWith("/settings/admin/apps");
return (
<div className="divide-subtle bg-default mx-auto flex max-w-4xl flex-row divide-y">
<div className={isAppsPage ? "min-w-0" : "flex flex-1 [&>*]:flex-1"}>
<div className={isAppsPage ? "min-w-0" : "flex flex-1 *:flex-1"}>
<ErrorBoundary>{children}</ErrorBoundary>
</div>
</div>
@@ -35,7 +35,7 @@ const sampleBookingsByHourStats = [
export default function BookingsByHourPlayground() {
const { t } = useLocale();
return (
<div className="space-y-6 p-6">
<div className="stack-y-6 p-6">
<div className="mb-6">
<h1 className="text-3xl font-bold">Bookings by Hour Playground</h1>
<p className="mt-2 text-gray-600">
@@ -1,5 +1,8 @@
import { _generateMetadata, getTranslate } from "app/_utils";
import Link from "next/link";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
const LINKS = [
{
title: "Routing Funnel",
@@ -15,20 +18,32 @@ const LINKS = [
},
];
export default function Page() {
return (
<div>
<h1 className="text-3xl font-bold">Playground</h1>
<ul className="mt-8">
{LINKS.map((link) => (
<li key={link.title}>
<Link href={link.href} className="list-item list-disc font-medium underline">
{link.title} →
</Link>
</li>
))}
</ul>
</div>
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("playground"),
(t) => t("admin_playground_description"),
undefined,
undefined,
"/settings/admin/playground"
);
}
const Page = async () => {
const t = await getTranslate();
return (
<SettingsHeader title={t("playground")} description={t("admin_playground_description")}>
<div>
<ul className="mt-8">
{LINKS.map((link) => (
<li key={link.title}>
<Link href={link.href} className="list-item list-disc font-medium underline">
{link.title} →
</Link>
</li>
))}
</ul>
</div>
</SettingsHeader>
);
};
export default Page;
@@ -56,7 +56,7 @@ const sampleRoutingFunnelData = [
export default function RoutingFunnelPlayground() {
const { t } = useLocale();
return (
<div className="space-y-6 p-6">
<div className="stack-y-6 p-6">
<div className="mb-6">
<h1 className="text-3xl font-bold">Routing Funnel Playground</h1>
<p className="mt-2 text-gray-600">
@@ -512,7 +512,7 @@ function ScenarioCard({ scenario }: { scenario: Scenario }) {
export default function WeeklyCalendarPlayground() {
return (
<div className="space-y-8 p-6">
<div className="stack-y-8 p-6">
<div>
<h1 className="text-3xl font-bold">Weekly Calendar Playground</h1>
<p className="text-subtle mt-2">
@@ -522,7 +522,7 @@ export default function WeeklyCalendarPlayground() {
</div>
{/* Grid View of All Scenarios */}
<div className="space-y-8">
<div className="stack-y-8">
<h2 className="mb-4 text-2xl font-bold">All Scenarios (Side-by-Side)</h2>
{scenarios.map((scenario, index) => (
<ScenarioCard key={index} scenario={scenario} />
@@ -532,7 +532,7 @@ export default function WeeklyCalendarPlayground() {
{/* Testing Checklist */}
<div className="border-subtle rounded-lg border p-6">
<h2 className="mb-4 text-xl font-bold">Testing Checklist</h2>
<ul className="text-subtle space-y-2 text-sm">
<ul className="text-subtle stack-y-2 text-sm">
<li>✓ Visual appearance matches expectations (80% width with 8% cascading offsets)</li>
<li>✓ Hover behavior works smoothly - hovered event appears topmost</li>
<li>✓ No visual glitches with 3+ overlapping events</li>
@@ -37,13 +37,13 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
href: "/settings/my-account",
icon: "user",
children: [
{ name: "profile", href: "/settings/my-account/profile" },
{ name: "general", href: "/settings/my-account/general" },
{ name: "calendars", href: "/settings/my-account/calendars" },
{ name: "conferencing", href: "/settings/my-account/conferencing" },
{ name: "appearance", href: "/settings/my-account/appearance" },
{ name: "out_of_office", href: "/settings/my-account/out-of-office" },
{ name: "push_notifications", href: "/settings/my-account/push-notifications" },
{ name: "profile", href: "/settings/my-account/profile", trackingMetadata: { section: "my_account", page: "profile" } },
{ name: "general", href: "/settings/my-account/general", trackingMetadata: { section: "my_account", page: "general" } },
{ name: "calendars", href: "/settings/my-account/calendars", trackingMetadata: { section: "my_account", page: "calendars" } },
{ name: "conferencing", href: "/settings/my-account/conferencing", trackingMetadata: { section: "my_account", page: "conferencing" } },
{ name: "appearance", href: "/settings/my-account/appearance", trackingMetadata: { section: "my_account", page: "appearance" } },
{ name: "out_of_office", href: "/settings/my-account/out-of-office", trackingMetadata: { section: "my_account", page: "out_of_office" } },
{ name: "push_notifications", href: "/settings/my-account/push-notifications", trackingMetadata: { section: "my_account", page: "push_notifications" } },
// TODO
// { name: "referrals", href: "/settings/my-account/referrals" },
],
@@ -53,16 +53,16 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
href: "/settings/security",
icon: "key",
children: [
{ name: "password", href: "/settings/security/password" },
{ name: "impersonation", href: "/settings/security/impersonation" },
{ name: "2fa_auth", href: "/settings/security/two-factor-auth" },
{ name: "password", href: "/settings/security/password", trackingMetadata: { section: "security", page: "password" } },
{ name: "impersonation", href: "/settings/security/impersonation", trackingMetadata: { section: "security", page: "impersonation" } },
{ name: "2fa_auth", href: "/settings/security/two-factor-auth", trackingMetadata: { section: "security", page: "2fa_auth" } },
],
},
{
name: "billing",
href: "/settings/billing",
icon: "credit-card",
children: [{ name: "manage_billing", href: "/settings/billing" }],
children: [{ name: "manage_billing", href: "/settings/billing", trackingMetadata: { section: "billing", page: "manage_billing" } }],
},
{
name: "developer",
@@ -70,9 +70,9 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
icon: "terminal",
children: [
//
{ name: "webhooks", href: "/settings/developer/webhooks" },
{ name: "api_keys", href: "/settings/developer/api-keys" },
{ name: "admin_api", href: "/settings/organizations/admin-api" },
{ name: "webhooks", href: "/settings/developer/webhooks", trackingMetadata: { section: "developer", page: "webhooks" } },
{ name: "api_keys", href: "/settings/developer/api-keys", trackingMetadata: { section: "developer", page: "api_keys" } },
{ name: "admin_api", href: "/settings/organizations/admin-api", trackingMetadata: { section: "developer", page: "admin_api" } },
// TODO: Add profile level for embeds
// { name: "embeds", href: "/v2/settings/developer/embeds" },
],
@@ -84,36 +84,43 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
{
name: "profile",
href: "/settings/organizations/profile",
trackingMetadata: { section: "organization", page: "profile" },
},
{
name: "general",
href: "/settings/organizations/general",
trackingMetadata: { section: "organization", page: "general" },
},
...(orgBranding
? [
{
name: "members",
href: `${WEBAPP_URL}/settings/organizations/${orgBranding.slug}/members`,
isExternalLink: true,
},
]
{
name: "members",
href: `${WEBAPP_URL}/settings/organizations/${orgBranding.slug}/members`,
isExternalLink: true,
trackingMetadata: { section: "organization", page: "members" },
},
]
: []),
{
name: "privacy_and_security",
href: "/settings/organizations/privacy",
trackingMetadata: { section: "organization", page: "privacy_and_security" },
},
{
name: "SSO",
href: "/settings/organizations/sso",
trackingMetadata: { section: "organization", page: "sso" },
},
{
name: "directory_sync",
href: "/settings/organizations/dsync",
trackingMetadata: { section: "organization", page: "directory_sync" },
},
{
name: "admin_api",
href: "https://cal.com/docs/enterprise-features/api/api-reference/bookings#admin-access",
isExternalLink: true,
trackingMetadata: { section: "organization", page: "admin_api" },
},
],
},
@@ -135,31 +142,31 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
icon: "lock",
children: [
//
{ name: "features", href: "/settings/admin/flags" },
{ name: "license", href: "/auth/setup?step=1" },
{ name: "impersonation", href: "/settings/admin/impersonation" },
{ name: "apps", href: "/settings/admin/apps/calendar" },
{ name: "users", href: "/settings/admin/users" },
{ name: "organizations", href: "/settings/admin/organizations" },
{ name: "lockedSMS", href: "/settings/admin/lockedSMS" },
{ name: "oAuth", href: "/settings/admin/oAuth" },
{ name: "Workspace Platforms", href: "/settings/admin/workspace-platforms" },
{ name: "Playground", href: "/settings/admin/playground" },
{ name: "features", href: "/settings/admin/flags", trackingMetadata: { section: "admin", page: "features" } },
{ name: "license", href: "/auth/setup?step=1", trackingMetadata: { section: "admin", page: "license" } },
{ name: "impersonation", href: "/settings/admin/impersonation", trackingMetadata: { section: "admin", page: "impersonation" } },
{ name: "apps", href: "/settings/admin/apps/calendar", trackingMetadata: { section: "admin", page: "apps" } },
{ name: "users", href: "/settings/admin/users", trackingMetadata: { section: "admin", page: "users" } },
{ name: "organizations", href: "/settings/admin/organizations", trackingMetadata: { section: "admin", page: "organizations" } },
{ name: "lockedSMS", href: "/settings/admin/lockedSMS", trackingMetadata: { section: "admin", page: "locked_sms" } },
{ name: "oAuth", href: "/settings/admin/oAuth", trackingMetadata: { section: "admin", page: "oauth" } },
{ name: "Workspace Platforms", href: "/settings/admin/workspace-platforms", trackingMetadata: { section: "admin", page: "workspace_platforms" } },
{ name: "Playground", href: "/settings/admin/playground", trackingMetadata: { section: "admin", page: "playground" } },
],
},
];
tabs.find((tab) => {
if (tab.name === "security" && !HOSTED_CAL_FEATURES) {
tab.children?.push({ name: "sso_configuration", href: "/settings/security/sso" });
tab.children?.push({ name: "sso_configuration", href: "/settings/security/sso", trackingMetadata: { section: "security", page: "sso_configuration" } });
// TODO: Enable dsync for self hosters
// tab.children?.push({ name: "directory_sync", href: "/settings/security/dsync" });
}
if (tab.name === "admin" && IS_CALCOM) {
tab.children?.push({ name: "create_org", href: "/settings/organizations/new" });
tab.children?.push({ name: "create_org", href: "/settings/organizations/new", trackingMetadata: { section: "admin", page: "create_org" } });
}
if (tab.name === "admin" && IS_CALCOM) {
tab.children?.push({ name: "create_license_key", href: "/settings/license-key/new" });
tab.children?.push({ name: "create_license_key", href: "/settings/license-key/new", trackingMetadata: { section: "admin", page: "create_license_key" } });
}
});
@@ -181,6 +188,7 @@ export interface SettingsPermissions {
canViewRoles?: boolean;
canViewOrganizationBilling?: boolean;
canUpdateOrganization?: boolean;
canViewAttributes?: boolean;
}
const useTabs = ({
@@ -211,10 +219,11 @@ const useTabs = ({
(child) => permissions?.canUpdateOrganization || !organizationAdminKeys.includes(child.name)
);
if (permissions?.canUpdateOrganization) {
if (permissions?.canViewAttributes) {
newArray.splice(4, 0, {
name: "attributes",
href: "/settings/organizations/attributes",
trackingMetadata: { section: "organization", page: "attributes" },
});
}
@@ -223,6 +232,7 @@ const useTabs = ({
newArray.push({
name: "delegation_credential",
href: "/settings/organizations/delegation-credential",
trackingMetadata: { section: "organization", page: "delegation_credential" },
});
}
@@ -232,6 +242,7 @@ const useTabs = ({
newArray.push({
name: "roles_and_permissions",
href: "/settings/organizations/roles",
trackingMetadata: { section: "organization", page: "roles_and_permissions" },
});
}
@@ -239,6 +250,7 @@ const useTabs = ({
newArray.push({
name: "billing",
href: "/settings/organizations/billing",
trackingMetadata: { section: "organization", page: "billing" },
});
}
} else {
@@ -246,6 +258,7 @@ const useTabs = ({
newArray.push({
name: "billing",
href: "/settings/organizations/billing",
trackingMetadata: { section: "organization", page: "billing" },
});
}
// Show roles page (modal will appear if PBAC not enabled)
@@ -253,7 +266,8 @@ const useTabs = ({
newArray.push({
name: "roles",
href: "/settings/organizations/roles",
isBadged: true, // Show "New" badge
isBadged: true, // Show "New" badge,
trackingMetadata: { section: "organization", page: "roles" },
});
}
}
@@ -344,6 +358,7 @@ const TeamRolesNavItem = ({
<VerticalTabItem
name={t("roles_and_permissions")}
href={`/settings/teams/${team.id}/roles`}
trackingMetadata={{ section: "team", page: "roles_and_permissions", teamId: team.id }}
textClassNames="px-3 text-emphasis font-medium text-sm"
disableChevron
/>
@@ -419,9 +434,8 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
setTeamMenuState(newTeamMenuState);
}
}}
aria-label={`${team.name} ${
teamMenuState[index].teamMenuOpen ? t("collapse_menu") : t("expand_menu")
}`}>
aria-label={`${team.name} ${teamMenuState[index].teamMenuOpen ? t("collapse_menu") : t("expand_menu")
}`}>
<div className="me-3">
{teamMenuState[index].teamMenuOpen ? (
<Icon name="chevron-down" className="h-4 w-4" />
@@ -429,6 +443,7 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
<Icon name="chevron-right" className="h-4 w-4" />
)}
</div>
{ }
{!team.parentId && (
<Image
src={getPlaceholderAvatar(team.logoUrl, team.name)}
@@ -446,21 +461,23 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
)}
</button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-0.5" id={`team-content-${team.id}`}>
<CollapsibleContent className="stack-y-0.5" id={`team-content-${team.id}`}>
{team.accepted && (
<VerticalTabItem
name={t("profile")}
href={`/settings/teams/${team.id}/profile`}
trackingMetadata={{ section: "team", page: "profile", teamId: team.id }}
textClassNames="px-3 text-emphasis font-medium text-sm"
className="me-5 h-7 w-auto !px-2"
className="px-2! me-5 h-7 w-auto"
disableChevron
/>
)}
<VerticalTabItem
name={t("members")}
href={`/settings/teams/${team.id}/members`}
trackingMetadata={{ section: "team", page: "members", teamId: team.id }}
textClassNames="px-3 text-emphasis font-medium text-sm"
className="me-5 h-7 w-auto !px-2"
className="px-2! me-5 h-7 w-auto"
disableChevron
/>
{/* Show roles only for sub-teams with PBAC-enabled parent */}
@@ -469,9 +486,9 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore this exists wtf?
(team.isOrgAdmin && team.isOrgAdmin)) && (
<>
{/* TODO */}
{/* <VerticalTabItem
<>
{/* TODO */}
{/* <VerticalTabItem
name={t("general")}
href={`${WEBAPP_URL}/settings/my-account/appearance`}
textClassNames="px-3 text-emphasis font-medium text-sm"
@@ -481,7 +498,8 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
name={t("appearance")}
href={`/settings/teams/${team.id}/appearance`}
textClassNames="px-3 text-emphasis font-medium text-sm"
className="me-5 h-7 w-auto !px-2"
trackingMetadata={{ section: "team", page: "appearance", teamId: team.id }}
className="px-2! me-5 h-7 w-auto"
disableChevron
/>
{/* Hide if there is a parent ID */}
@@ -491,7 +509,8 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
name={t("billing")}
href={`/settings/teams/${team.id}/billing`}
textClassNames="px-3 text-emphasis font-medium text-sm"
className="me-5 h-7 w-auto !px-2"
trackingMetadata={{ section: "team", page: "billing", teamId: team.id }}
className="px-2! me-5 h-7 w-auto"
disableChevron
/>
</>
@@ -500,7 +519,8 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
name={t("settings")}
href={`/settings/teams/${team.id}/settings`}
textClassNames="px-3 text-emphasis font-medium text-sm"
className="me-5 h-7 w-auto !px-2"
trackingMetadata={{ section: "team", page: "settings", teamId: team.id }}
className="px-2! me-5 h-7 w-auto"
disableChevron
/>
</>
@@ -578,7 +598,7 @@ const SettingsSidebarContainer = ({
<nav
style={{ maxHeight: `calc(100vh - ${bannersHeight}px)`, top: `${bannersHeight}px` }}
className={classNames(
"no-scrollbar bg-muted fixed bottom-0 left-0 top-0 z-20 flex max-h-screen w-56 flex-col space-y-1 overflow-x-hidden overflow-y-scroll px-2 pb-3 transition-transform max-lg:z-10 lg:sticky lg:flex",
"no-scrollbar bg-cal-muted stack-y-1 fixed bottom-0 left-0 top-0 z-20 flex max-h-screen w-56 flex-col overflow-x-hidden overflow-y-scroll px-2 pb-3 transition-transform max-lg:z-10 lg:sticky lg:flex",
className,
navigationIsOpenedOnMobile
? "translate-x-0 opacity-100"
@@ -592,7 +612,7 @@ const SettingsSidebarContainer = ({
<React.Fragment key={tab.href}>
{!["teams", "other_teams"].includes(tab.name) && (
<React.Fragment key={tab.href}>
<div className={`${!tab.children?.length ? "!mb-3" : ""}`}>
<div className={`${!tab.children?.length ? "mb-3!" : ""}`}>
<div className="[&[aria-current='page']]:bg-emphasis [&[aria-current='page']]:text-emphasis text-default group flex h-7 w-full flex-row items-center rounded-md px-2 text-sm font-medium leading-none">
{tab && tab.icon && (
<Icon
@@ -600,6 +620,7 @@ const SettingsSidebarContainer = ({
className="text-subtle h-[16px] w-[16px] stroke-[2px] ltr:mr-3 rtl:ml-3 md:mt-0"
/>
)}
{ }
{!tab.icon && tab?.avatar && (
<Image
width={16}
@@ -618,16 +639,17 @@ const SettingsSidebarContainer = ({
</Skeleton>
</div>
</div>
<div className="my-3 space-y-px">
<div className="stack-y-px">
{tab.children?.map((child, index) => (
<div key={child.href} className="flex items-start gap-2">
<VerticalTabItem
name={t(child.name)}
isExternalLink={child.isExternalLink}
href={child.href || "/"}
trackingMetadata={child.trackingMetadata}
textClassNames="text-emphasis font-medium text-sm"
className={`h-7 w-fit !px-2 ${
tab.children && index === tab.children?.length - 1 && "!mb-3"
className={`px-2! h-7 w-fit ${
tab.children && index === tab.children?.length - 1 && "mb-3!"
}`}
disableChevron
/>
@@ -667,8 +689,9 @@ const SettingsSidebarContainer = ({
<VerticalTabItem
name={t("add_a_team")}
href={`${WEBAPP_URL}/settings/teams/new`}
trackingMetadata={{ section: "team", page: "add_a_team" }}
textClassNames="px-3 items-center mt-2 text-emphasis font-medium text-sm"
className="me-5 h-7 w-auto !px-2"
className="px-2! me-5 h-7 w-auto"
icon="plus"
disableChevron
/>
@@ -741,11 +764,10 @@ const SettingsSidebarContainer = ({
setOtherTeamMenuState(newOtherTeamMenuState);
}
}}
aria-label={`${otherTeam.name} ${
otherTeamMenuState[index].teamMenuOpen
? t("collapse_menu")
: t("expand_menu")
}`}>
aria-label={`${otherTeam.name} ${otherTeamMenuState[index].teamMenuOpen
? t("collapse_menu")
: t("expand_menu")
}`}>
<div className="me-3">
{otherTeamMenuState[index].teamMenuOpen ? (
<Icon name="chevron-down" className="h-4 w-4" />
@@ -753,6 +775,7 @@ const SettingsSidebarContainer = ({
<Icon name="chevron-right" className="h-4 w-4" />
)}
</div>
{ }
{!otherTeam.parentId && (
<Image
src={getPlaceholderAvatar(otherTeam.logoUrl, otherTeam.name)}
@@ -766,20 +789,22 @@ const SettingsSidebarContainer = ({
</button>
</CollapsibleTrigger>
<CollapsibleContent
className="space-y-0.5"
className="stack-y-0.5"
id={`other-team-content-${otherTeam.id}`}>
<VerticalTabItem
name={t("profile")}
href={`/settings/organizations/teams/other/${otherTeam.id}/profile`}
trackingMetadata={{ section: "other_team", page: "profile", teamId: otherTeam.id }}
textClassNames="px-3 text-emphasis font-medium text-sm"
className="me-5 h-7 w-auto !px-2"
className="px-2! me-5 h-7 w-auto"
disableChevron
/>
<VerticalTabItem
name={t("members")}
href={`/settings/organizations/teams/other/${otherTeam.id}/members`}
trackingMetadata={{ section: "other_team", page: "members", teamId: otherTeam.id }}
textClassNames="px-3 text-emphasis font-medium text-sm"
className="me-5 h-7 w-auto !px-2"
className="px-2! me-5 h-7 w-auto"
disableChevron
/>
<>
@@ -812,7 +837,7 @@ const MobileSettingsContainer = (props: { onSideContainerOpen?: () => void }) =>
return (
<>
<nav className="bg-muted border-muted sticky top-0 z-20 flex w-full items-center justify-between border-b px-2 py-2 sm:relative lg:hidden">
<nav className="bg-cal-muted border-muted sticky top-0 z-20 flex w-full items-center justify-between border-b px-2 py-2 sm:relative lg:hidden">
<div className="flex items-center space-x-3">
<Button StartIcon="menu" color="minimal" variant="icon" onClick={props.onSideContainerOpen}>
<span className="sr-only">{t("show_navigation")}</span>
@@ -881,7 +906,7 @@ export default function SettingsLayoutAppDirClient({
TopNavContainer={
<MobileSettingsContainer onSideContainerOpen={() => setSideContainerOpen(!sideContainerOpen)} />
}>
<div className="flex flex-1 [&>*]:flex-1">
<div className="*:flex-1 flex flex-1">
<div
className={classNames("mx-auto max-w-full justify-center lg:max-w-3xl", rest.containerClassName)}>
<ErrorBoundary>{children}</ErrorBoundary>
@@ -48,6 +48,7 @@ export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
let canViewRoles = false;
let canViewOrganizationBilling = false;
let canUpdateOrganization = false;
let canViewAttributes = false;
const orgId = session?.user?.profile?.organizationId ?? session?.user.org?.id;
// For now we only grab organization features but it would be nice to fetch these on the server side for specific team feature flags
@@ -66,9 +67,10 @@ export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
if (isPbacEnabled) {
// Only fetch and apply PBAC permissions if the feature is enabled
const [rolePermissions, organizationPermissions] = await Promise.all([
const [rolePermissions, organizationPermissions, attributesPermissions] = await Promise.all([
getCachedResourcePermissions(userId, orgId, Resource.Role),
getCachedResourcePermissions(userId, orgId, Resource.Organization),
getCachedResourcePermissions(userId, orgId, Resource.Attributes),
]);
// Check if user has permission to read roles
@@ -77,11 +79,14 @@ export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
const orgActions = PermissionMapper.toActionMap(organizationPermissions, Resource.Organization);
canViewOrganizationBilling = orgActions[CustomAction.ManageBilling] ?? isOrgAdminOrOwner;
canUpdateOrganization = orgActions[CrudAction.Update] ?? isOrgAdminOrOwner;
const attributesActions = PermissionMapper.toActionMap(attributesPermissions, Resource.Attributes);
canViewAttributes = attributesActions[CrudAction.Read] ?? isOrgAdminOrOwner;
} else {
// Fall back to legacy permissions when PBAC is not enabled or features not loaded
canViewRoles = features ? isOrgAdminOrOwner : false;
canViewOrganizationBilling = isOrgAdminOrOwner;
canUpdateOrganization = isOrgAdminOrOwner;
canViewAttributes = isOrgAdminOrOwner;
}
}
@@ -90,7 +95,7 @@ export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
<SettingsLayoutAppDirClient
{...props}
teamFeatures={teamFeatures ?? {}}
permissions={{ canViewRoles, canViewOrganizationBilling, canUpdateOrganization }}
permissions={{ canViewRoles, canViewOrganizationBilling, canUpdateOrganization, canViewAttributes }}
/>
</>
);
@@ -87,7 +87,7 @@ export function AdvancedPermissionGroup({
};
return (
<div className="bg-muted border-subtle mb-2 rounded-xl border">
<div className="bg-cal-muted border-subtle mb-2 rounded-xl border">
<button
type="button"
className="flex cursor-pointer items-center justify-between gap-1.5 p-4"
@@ -56,7 +56,7 @@ export function PbacOptInModal({ open, onOpenChange, revalidateRolesPath }: Pbac
</div>
</div>
<div className="bg-subtle w-full space-y-px overflow-hidden rounded-xl border border-gray-200">
<div className="bg-subtle border-subtle w-full stack-y-px overflow-hidden rounded-xl border">
<div className="bg-default flex gap-3 p-3">
<div className="bg-subtle flex h-8 w-8 shrink-0 items-center justify-center rounded-md">
<Icon name="shield-check" className="text-subtle h-5 w-5" />
@@ -33,7 +33,7 @@ export default function RoleColorPicker({ value, onChange, disabled }: RoleColor
type="button"
onClick={handleColorSquareClick}
disabled={disabled}
className="bg-default text-default border-default hover:bg-muted hover:text-emphasis focus:bg-subtle focus-visible:shadow-outline-gray-focused shadow-outline-gray-rested enabled:hover:shadow-outline-gray-hover enabled:active:shadow-outline-gray-active flex h-8 w-8 items-center justify-center gap-2 rounded-[10px] border text-sm transition-shadow duration-200 focus-visible:outline-none focus-visible:ring-0 disabled:cursor-not-allowed disabled:opacity-50">
className="bg-default text-default border-default hover:bg-cal-muted hover:text-emphasis focus:bg-subtle focus-visible:shadow-outline-gray-focused shadow-outline-gray-rested enabled:hover:shadow-outline-gray-hover enabled:active:shadow-outline-gray-active flex h-8 w-8 items-center justify-center gap-2 rounded-[10px] border text-sm transition-shadow duration-200 focus-visible:outline-none focus-visible:ring-0 disabled:cursor-not-allowed disabled:opacity-50">
<div
className="h-5 w-5 rounded border shadow-sm"
style={{ backgroundColor: value }}
@@ -183,7 +183,7 @@ export function RoleSheet({
</SheetTitle>
</SheetHeader>
<Form form={form} handleSubmit={onSubmit}>
<div className="space-y-4 py-5">
<div className="stack-y-4 py-5">
<div className="flex items-end justify-end gap-2">
<div className="flex-1">
<TextField
@@ -203,7 +203,7 @@ export function RoleSheet({
<div className="">
{isAdvancedMode ? (
<div className="space-y-4">
<div className="stack-y-4">
<div>
<div className="mb-2 flex items-center justify-between">
<Label className="mb-0">{t("permissions")}</Label>
@@ -239,7 +239,7 @@ export function RoleSheet({
))}{" "}
</div>
) : (
<div className="bg-muted rounded-xl p-1">
<div className="bg-cal-muted rounded-xl p-1">
<div className="flex items-center justify-between px-3 py-2">
<Label>{t("permissions")}</Label>
<div className="flex items-center gap-2">
@@ -71,7 +71,7 @@ export function RolesList({
return (
<>
<div className="mt-4">
<div className="bg-muted border-muted flex flex-col rounded-xl border p-[1px]">
<div className="bg-cal-muted border-muted flex flex-col rounded-xl border p-px">
{/* Roles list header */}
<div className="px-5 py-4">
<h2 className="text-default text-sm font-semibold leading-none">{t("role")}</h2>
@@ -7,7 +7,7 @@ export const SkeletonLoader = () => {
const { t } = useLocale();
return (
<div className="mt-4">
<div className="bg-muted border-muted flex flex-col rounded-xl border p-[1px]">
<div className="bg-cal-muted border-muted flex flex-col rounded-xl border p-px">
{/* Roles list header */}
<div className="px-5 py-4">
<h2 className="text-default text-sm font-semibold leading-none">{t("role")}</h2>
@@ -8,7 +8,7 @@ export default async function Layout({ children }: { children: React.ReactNode }
const t = await getTranslate();
return (
<SettingsLayoutAppDirClient containerClassName="lg:max-w-screen-2xl">
<SettingsLayoutAppDirClient containerClassName="lg:max-w-(--breakpoint-2xl)">
<SettingsHeader
title={t("organization_members")}
description={t("organization_description")}
@@ -5,7 +5,7 @@ import { headers, cookies } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { Resource, CustomAction } from "@calcom/features/pbac/domain/types/permission-registry";
import { Resource, CustomAction, CrudAction } from "@calcom/features/pbac/domain/types/permission-registry";
import { getSpecificPermissions } from "@calcom/features/pbac/lib/resource-permissions";
import { RoleManagementFactory } from "@calcom/features/pbac/services/role-management.factory";
import { PrismaAttributeRepository } from "@calcom/lib/server/repository/PrismaAttributeRepository";
@@ -63,50 +63,69 @@ const Page = async () => {
}
// Get specific PBAC permissions for organization member actions
const permissions = await getSpecificPermissions({
userId: session.user.id,
teamId: session.user.profile.organizationId,
resource: Resource.Organization,
userRole: session.user.org.role,
actions: [
CustomAction.ListMembers,
CustomAction.ListMembersPrivate,
CustomAction.Invite,
CustomAction.ChangeMemberRole,
CustomAction.Remove,
CustomAction.Impersonate,
],
fallbackRoles: {
[CustomAction.ListMembers]: {
roles: fallbackRolesThatCanSeeMembers,
const [orgPermissions, attributesPermissions] = await Promise.all([
getSpecificPermissions({
userId: session.user.id,
teamId: session.user.profile.organizationId,
resource: Resource.Organization,
userRole: session.user.org.role,
actions: [
CustomAction.ListMembers,
CustomAction.ListMembersPrivate,
CustomAction.Invite,
CustomAction.ChangeMemberRole,
CustomAction.Remove,
CustomAction.Impersonate,
],
fallbackRoles: {
[CustomAction.ListMembers]: {
roles: fallbackRolesThatCanSeeMembers,
},
[CustomAction.ListMembersPrivate]: {
roles: fallbackRolesThatCanSeeMembers,
},
[CustomAction.Invite]: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
[CustomAction.ChangeMemberRole]: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
[CustomAction.Remove]: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
[CustomAction.Impersonate]: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
},
[CustomAction.ListMembersPrivate]: {
roles: fallbackRolesThatCanSeeMembers,
}),
getSpecificPermissions({
userId: session.user.id,
teamId: session.user.profile.organizationId,
resource: Resource.Attributes,
userRole: session.user.org.role,
actions: [CrudAction.Read, CustomAction.EditUsers],
fallbackRoles: {
[CrudAction.Read]: {
roles: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER],
},
[CustomAction.EditUsers]: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
},
[CustomAction.Invite]: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
[CustomAction.ChangeMemberRole]: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
[CustomAction.Remove]: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
[CustomAction.Impersonate]: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
},
});
}),
]);
// Map specific permissions to member actions
const memberPermissions = {
canListMembers: org.isPrivate
? permissions[CustomAction.ListMembersPrivate]
: permissions[CustomAction.ListMembers],
canInvite: permissions[CustomAction.Invite],
canChangeMemberRole: permissions[CustomAction.ChangeMemberRole],
canRemove: permissions[CustomAction.Remove],
canImpersonate: permissions[CustomAction.Impersonate],
? orgPermissions[CustomAction.ListMembersPrivate]
: orgPermissions[CustomAction.ListMembers],
canInvite: orgPermissions[CustomAction.Invite],
canChangeMemberRole: orgPermissions[CustomAction.ChangeMemberRole],
canRemove: orgPermissions[CustomAction.Remove],
canImpersonate: orgPermissions[CustomAction.Impersonate],
canViewAttributes: attributesPermissions[CrudAction.Read],
canEditAttributesForUser: attributesPermissions[CustomAction.EditUsers],
};
const facetedTeamValues = {
+27
View File
@@ -0,0 +1,27 @@
"use client";
import { createContext, useContext } from "react";
interface GeoContextValue {
country: string;
}
const GeoContext = createContext<GeoContextValue | undefined>(undefined);
export function GeoProvider({
country,
children,
}: {
country: string;
children: React.ReactNode;
}) {
return <GeoContext.Provider value={{ country }}>{children}</GeoContext.Provider>;
}
export function useGeo() {
const context = useContext(GeoContext);
if (context === undefined) {
throw new Error("useGeo must be used within a GeoProvider");
}
return context;
}
@@ -4,52 +4,71 @@ import jwt from "jsonwebtoken";
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { OAuthClientRepository } from "@calcom/features/oauth/repositories/OAuthClientRepository";
import { OAuthService } from "@calcom/features/oauth/services/OAuthService";
import prisma from "@calcom/prisma";
import { generateSecret } from "@calcom/trpc/server/routers/viewer/oAuth/addClient.handler";
import type { OAuthTokenPayload } from "@calcom/types/oauth";
async function handler(req: NextRequest) {
const { client_id, client_secret, grant_type } = await parseUrlFormData(req);
const { client_id, client_secret, grant_type, refresh_token, code_verifier } = await parseUrlFormData(req);
if (!client_id || !client_secret) {
return NextResponse.json({ message: "Missing client id or secret" }, { status: 400 });
if (!process.env.CALENDSO_ENCRYPTION_KEY) {
return NextResponse.json({ message: "CALENDSO_ENCRYPTION_KEY is not set" }, { status: 500 });
}
if (!client_id) {
return NextResponse.json({ error: "invalid_request" }, { status: 400 });
}
if (grant_type !== "refresh_token") {
return NextResponse.json({ message: "grant type invalid" }, { status: 400 });
return NextResponse.json({ error: "invalid_request" }, { status: 400 });
}
const [hashedSecret] = generateSecret(client_secret);
const oAuthClientRepository = new OAuthClientRepository(prisma);
const client = await prisma.oAuthClient.findFirst({
where: {
clientId: client_id,
clientSecret: hashedSecret,
},
select: {
redirectUri: true,
},
});
const client = await oAuthClientRepository.findByClientId(client_id);
if (!client) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
return NextResponse.json({ error: "invalid_client" }, { status: 401 });
}
const secretKey = process.env.CALENDSO_ENCRYPTION_KEY || "";
const isValidClient = OAuthService.validateClient(client, client_secret);
if (!isValidClient) {
return NextResponse.json({ error: "invalid_client" }, { status: 401 });
}
const secretKey = process.env.CALENDSO_ENCRYPTION_KEY;
let decodedRefreshToken: OAuthTokenPayload;
try {
const refreshToken = req.headers.get("authorization")?.split(" ")[1] || "";
decodedRefreshToken = jwt.verify(refreshToken, secretKey) as OAuthTokenPayload;
const refreshTokenValue = refresh_token || req.headers.get("authorization")?.split(" ")[1] || "";
if (!refreshTokenValue) {
return NextResponse.json({ error: "invalid_request" }, { status: 400 });
}
decodedRefreshToken = jwt.verify(refreshTokenValue, secretKey) as OAuthTokenPayload;
} catch {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
return NextResponse.json({ error: "invalid_grant" }, { status: 400 });
}
if (!decodedRefreshToken || decodedRefreshToken.token_type !== "Refresh Token") {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
return NextResponse.json({ error: "invalid_grant" }, { status: 400 });
}
if (decodedRefreshToken.clientId !== client_id) {
return NextResponse.json({ error: "invalid_grant" }, { status: 400 });
}
const pkceError = OAuthService.verifyPKCE(client, decodedRefreshToken, code_verifier);
if (pkceError) {
return NextResponse.json({ error: pkceError.error }, { status: pkceError.status });
}
const accessTokenExpiresIn = 1800; // 30 minutes
const payloadAuthToken: OAuthTokenPayload = {
userId: decodedRefreshToken.userId,
teamId: decodedRefreshToken.teamId,
@@ -64,17 +83,37 @@ async function handler(req: NextRequest) {
scope: decodedRefreshToken.scope,
token_type: "Refresh Token",
clientId: client_id,
// Preserve PKCE information for any client that used PKCE originally
...(decodedRefreshToken.codeChallenge && {
codeChallenge: decodedRefreshToken.codeChallenge,
codeChallengeMethod: decodedRefreshToken.codeChallengeMethod,
}),
};
const access_token = jwt.sign(payloadAuthToken, secretKey, {
expiresIn: 1800, // 30 min
expiresIn: accessTokenExpiresIn,
});
const refresh_token = jwt.sign(payloadRefreshToken, secretKey, {
const refresh_token_new = jwt.sign(payloadRefreshToken, secretKey, {
expiresIn: 30 * 24 * 60 * 60, // 30 days
});
return NextResponse.json({ access_token, refresh_token }, { status: 200 });
return NextResponse.json(
{
access_token,
token_type: "bearer",
refresh_token: refresh_token_new,
expires_in: accessTokenExpiresIn,
},
{
status: 200,
headers: {
"Content-Type": "application/json;charset=UTF-8",
"Cache-Control": "no-store",
Pragma: "no-cache",
},
}
);
}
export const POST = defaultResponderForAppDir(handler);
@@ -0,0 +1,540 @@
import prismaMock from "../../../../../../../../tests/libs/__mocks__/prismaMock";
import jwt from "jsonwebtoken";
// Import mocked dependencies after mocks are set up
import { NextRequest } from "next/server";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { verifyCodeChallenge } from "@calcom/lib/pkce";
import { generateSecret } from "@calcom/trpc/server/routers/viewer/oAuth/addClient.handler";
import { POST } from "../route";
// Create mock NextResponse.json function using hoisted
const mockNextResponseJson = vi.hoisted(() => {
return vi.fn((data: unknown, options?: { status?: number; headers?: Record<string, string> }) => ({
json: vi.fn().mockResolvedValue(data),
status: options?.status || 200,
headers: new Headers(options?.headers || {}),
}));
});
// Mock next/server before importing anything that uses it
vi.mock("next/server", () => ({
NextRequest: class MockNextRequest {
url: string;
method: string;
nextUrl: { pathname: string; searchParams: URLSearchParams };
private _body: string;
constructor(
url: string,
options: { method?: string; body?: string; headers?: Record<string, string> } = {}
) {
this.url = url;
this.method = options.method || "POST";
this._body = options.body || "";
const urlObj = new URL(url);
this.nextUrl = {
pathname: urlObj.pathname,
searchParams: urlObj.searchParams,
};
}
async text(): Promise<string> {
return this._body;
}
},
NextResponse: {
json: mockNextResponseJson,
},
}));
// Mock dependencies
vi.mock("@calcom/lib/pkce", () => ({
verifyCodeChallenge: vi.fn(),
}));
vi.mock("@calcom/trpc/server/routers/viewer/oAuth/addClient.handler", () => ({
generateSecret: vi.fn(),
}));
vi.mock("jsonwebtoken", () => ({
default: {
sign: vi.fn(() => "mock_jwt_token"),
},
}));
vi.mock("@calcom/prisma", () => ({
default: prismaMock,
}));
vi.mock("app/api/defaultResponderForAppDir", async () => {
const { NextResponse } = await import("next/server");
return {
defaultResponderForAppDir:
(
handler: (req: NextRequest, context: { params: Promise<Record<string, string>> }) => Promise<Response>
) =>
async (req: NextRequest, context?: { params: Promise<Record<string, string>> }) => {
try {
const result = await handler(req, context || { params: Promise.resolve({}) });
if (result) {
return result;
}
return NextResponse.json({});
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : "Internal server error";
return NextResponse.json(
{
message: errorMessage,
},
{ status: 500 }
);
}
},
};
});
vi.mock("app/api/parseRequestData", () => ({
parseUrlFormData: async (req: NextRequest): Promise<Record<string, string>> => {
const text = await req.text();
const params = new URLSearchParams(text);
return Object.fromEntries(params);
},
}));
const mockVerifyCodeChallenge = vi.mocked(verifyCodeChallenge);
const mockGenerateSecret = vi.mocked(generateSecret);
const mockJwt = vi.mocked(jwt);
// Set up environment
vi.stubEnv("CALENDSO_ENCRYPTION_KEY", "test_encryption_key");
// Helper to create token exchange request
function createTokenRequest(data: Record<string, string>) {
const formData = new URLSearchParams(data);
const request = new NextRequest("http://localhost:3000/api/auth/oauth/token", {
method: "POST",
body: formData.toString(),
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
return request;
}
describe("POST /api/auth/oauth/token", () => {
beforeEach(() => {
vi.clearAllMocks();
mockJwt.sign.mockReturnValue("mock_jwt_token" as never);
mockVerifyCodeChallenge.mockReturnValue(false);
mockGenerateSecret.mockReturnValue(["hashed_secret", "plain_secret"]);
// Reset the mock for NextResponse.json
mockNextResponseJson.mockImplementation(
(data: unknown, options?: { status?: number; headers?: Record<string, string> }) => ({
json: vi.fn().mockResolvedValue(data),
status: options?.status || 200,
headers: new Headers(options?.headers || {}),
})
);
});
afterEach(() => {
vi.resetAllMocks();
});
describe("PUBLIC clients", () => {
const mockPublicClient = {
redirectUri: "https://app.example.com/callback",
clientSecret: null,
clientType: "PUBLIC" as const,
} as const;
const mockAccessCode = {
userId: 1,
teamId: null,
scopes: [] as const,
codeChallenge: "test_challenge",
codeChallengeMethod: "S256",
} as const;
it("should exchange authorization code for tokens with valid PKCE", async () => {
prismaMock.oAuthClient.findFirst.mockResolvedValue(
mockPublicClient as Awaited<ReturnType<typeof prismaMock.oAuthClient.findFirst>>
);
prismaMock.accessCode.findFirst.mockResolvedValue(
mockAccessCode as unknown as Awaited<ReturnType<typeof prismaMock.accessCode.findFirst>>
);
prismaMock.accessCode.deleteMany.mockResolvedValue({ count: 1 });
mockVerifyCodeChallenge.mockReturnValue(true);
const tokenRequest = createTokenRequest({
grant_type: "authorization_code",
code: "test_auth_code",
client_id: "public_client_123",
redirect_uri: "https://app.example.com/callback",
code_verifier: "test_verifier",
});
const response = await POST(tokenRequest, { params: Promise.resolve({}) });
expect(response).toBeDefined();
if (!response) throw new Error("Response is undefined");
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toEqual({
access_token: "mock_jwt_token",
token_type: "bearer",
refresh_token: "mock_jwt_token",
expires_in: 1800,
});
expect(mockVerifyCodeChallenge).toHaveBeenCalledWith("test_verifier", "test_challenge", "S256");
expect(prismaMock.accessCode.deleteMany).toHaveBeenCalled();
});
it("should reject PUBLIC client without code_verifier", async () => {
prismaMock.oAuthClient.findFirst.mockResolvedValue(
mockPublicClient as Awaited<ReturnType<typeof prismaMock.oAuthClient.findFirst>>
);
prismaMock.accessCode.findFirst.mockResolvedValue(
mockAccessCode as unknown as Awaited<ReturnType<typeof prismaMock.accessCode.findFirst>>
);
const tokenRequest = createTokenRequest({
grant_type: "authorization_code",
code: "test_auth_code",
client_id: "public_client_123",
redirect_uri: "https://app.example.com/callback",
});
const response = await POST(tokenRequest, { params: Promise.resolve({}) });
expect(response).toBeDefined();
if (!response) throw new Error("Response is undefined");
const data = await response.json();
expect(response.status).toBe(400);
expect(data.error).toBe("invalid_request");
});
it("should reject PUBLIC client with missing code challenge in access code", async () => {
prismaMock.oAuthClient.findFirst.mockResolvedValue(
mockPublicClient as Awaited<ReturnType<typeof prismaMock.oAuthClient.findFirst>>
);
prismaMock.accessCode.findFirst.mockResolvedValue({
...mockAccessCode,
codeChallenge: null,
} as unknown as Awaited<ReturnType<typeof prismaMock.accessCode.findFirst>>);
prismaMock.accessCode.deleteMany.mockResolvedValue({ count: 1 });
const tokenRequest = createTokenRequest({
grant_type: "authorization_code",
code: "test_auth_code",
client_id: "public_client_123",
redirect_uri: "https://app.example.com/callback",
code_verifier: "test_verifier",
});
const response = await POST(tokenRequest, { params: Promise.resolve({}) });
expect(response).toBeDefined();
if (!response) throw new Error("Response is undefined");
const data = await response.json();
expect(response.status).toBe(400);
expect(data.error).toBe("invalid_request");
});
it("should reject PUBLIC client with invalid code_verifier", async () => {
prismaMock.oAuthClient.findFirst.mockResolvedValue(
mockPublicClient as Awaited<ReturnType<typeof prismaMock.oAuthClient.findFirst>>
);
prismaMock.accessCode.findFirst.mockResolvedValue(
mockAccessCode as unknown as Awaited<ReturnType<typeof prismaMock.accessCode.findFirst>>
);
prismaMock.accessCode.deleteMany.mockResolvedValue({ count: 1 });
mockVerifyCodeChallenge.mockReturnValue(false);
const tokenRequest = createTokenRequest({
grant_type: "authorization_code",
code: "test_auth_code",
client_id: "public_client_123",
redirect_uri: "https://app.example.com/callback",
code_verifier: "wrong_verifier",
});
const response = await POST(tokenRequest, { params: Promise.resolve({}) });
expect(response).toBeDefined();
if (!response) throw new Error("Response is undefined");
const data = await response.json();
expect(response.status).toBe(400);
expect(data.error).toBe("invalid_grant");
expect(mockVerifyCodeChallenge).toHaveBeenCalledWith("wrong_verifier", "test_challenge", "S256");
});
});
describe("CONFIDENTIAL clients", () => {
const mockConfidentialClient = {
redirectUri: "https://app.example.com/callback",
clientSecret: "hashed_secret",
clientType: "CONFIDENTIAL" as const,
} as const;
const mockAccessCode = {
userId: 1,
teamId: null,
scopes: [] as const,
codeChallenge: null,
codeChallengeMethod: null,
} as const;
it("should exchange authorization code for tokens with valid client secret", async () => {
prismaMock.oAuthClient.findFirst.mockResolvedValue(
mockConfidentialClient as Awaited<ReturnType<typeof prismaMock.oAuthClient.findFirst>>
);
prismaMock.accessCode.findFirst.mockResolvedValue(
mockAccessCode as unknown as Awaited<ReturnType<typeof prismaMock.accessCode.findFirst>>
);
prismaMock.accessCode.deleteMany.mockResolvedValue({ count: 1 });
mockGenerateSecret.mockReturnValue(["hashed_secret", "plain_secret"]);
const tokenRequest = createTokenRequest({
grant_type: "authorization_code",
code: "test_auth_code",
client_id: "confidential_client_456",
client_secret: "plain_secret",
redirect_uri: "https://app.example.com/callback",
});
const response = await POST(tokenRequest, { params: Promise.resolve({}) });
expect(response).toBeDefined();
if (!response) throw new Error("Response is undefined");
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toEqual({
access_token: "mock_jwt_token",
token_type: "bearer",
refresh_token: "mock_jwt_token",
expires_in: 1800,
});
expect(mockGenerateSecret).toHaveBeenCalledWith("plain_secret");
expect(prismaMock.accessCode.deleteMany).toHaveBeenCalled();
});
it("should reject CONFIDENTIAL client without client_secret", async () => {
prismaMock.oAuthClient.findFirst.mockResolvedValue(
mockConfidentialClient as Awaited<ReturnType<typeof prismaMock.oAuthClient.findFirst>>
);
const tokenRequest = createTokenRequest({
grant_type: "authorization_code",
code: "test_auth_code",
client_id: "confidential_client_456",
redirect_uri: "https://app.example.com/callback",
});
const response = await POST(tokenRequest, { params: Promise.resolve({}) });
expect(response).toBeDefined();
if (!response) throw new Error("Response is undefined");
const data = await response.json();
expect(response.status).toBe(401);
expect(data.error).toBe("invalid_client");
});
it("should reject CONFIDENTIAL client with invalid client_secret", async () => {
prismaMock.oAuthClient.findFirst.mockResolvedValue(
mockConfidentialClient as Awaited<ReturnType<typeof prismaMock.oAuthClient.findFirst>>
);
mockGenerateSecret.mockReturnValue(["different_hashed_secret", "wrong_secret"]);
const tokenRequest = createTokenRequest({
grant_type: "authorization_code",
code: "test_auth_code",
client_id: "confidential_client_456",
client_secret: "wrong_secret",
redirect_uri: "https://app.example.com/callback",
});
const response = await POST(tokenRequest, { params: Promise.resolve({}) });
expect(response).toBeDefined();
if (!response) throw new Error("Response is undefined");
const data = await response.json();
expect(response.status).toBe(401);
expect(data.error).toBe("invalid_client");
});
it("should accept CONFIDENTIAL client with code_verifier for enhanced security", async () => {
const mockAccessCodeWithPKCE = {
userId: 1,
teamId: null,
scopes: [] as const,
codeChallenge: "test_challenge",
codeChallengeMethod: "S256",
} as const;
prismaMock.oAuthClient.findFirst.mockResolvedValue(
mockConfidentialClient as Awaited<ReturnType<typeof prismaMock.oAuthClient.findFirst>>
);
prismaMock.accessCode.findFirst.mockResolvedValue(
mockAccessCodeWithPKCE as unknown as Awaited<ReturnType<typeof prismaMock.accessCode.findFirst>>
);
prismaMock.accessCode.deleteMany.mockResolvedValue({ count: 1 });
mockGenerateSecret.mockReturnValue(["hashed_secret", "plain_secret"]);
mockVerifyCodeChallenge.mockReturnValue(true);
const tokenRequest = createTokenRequest({
grant_type: "authorization_code",
code: "test_auth_code",
client_id: "confidential_client_456",
client_secret: "plain_secret",
redirect_uri: "https://app.example.com/callback",
code_verifier: "test_verifier",
});
const response = await POST(tokenRequest, { params: Promise.resolve({}) });
expect(response).toBeDefined();
if (!response) throw new Error("Response is undefined");
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toEqual({
access_token: "mock_jwt_token",
token_type: "bearer",
refresh_token: "mock_jwt_token",
expires_in: 1800,
});
expect(mockGenerateSecret).toHaveBeenCalledWith("plain_secret");
expect(mockVerifyCodeChallenge).toHaveBeenCalledWith("test_verifier", "test_challenge", "S256");
});
it("should reject CONFIDENTIAL client with invalid code_verifier", async () => {
const mockAccessCodeWithPKCE = {
userId: 1,
teamId: null,
scopes: [] as const,
codeChallenge: "test_challenge",
codeChallengeMethod: "S256",
} as const;
prismaMock.oAuthClient.findFirst.mockResolvedValue(
mockConfidentialClient as Awaited<ReturnType<typeof prismaMock.oAuthClient.findFirst>>
);
prismaMock.accessCode.findFirst.mockResolvedValue(
mockAccessCodeWithPKCE as unknown as Awaited<ReturnType<typeof prismaMock.accessCode.findFirst>>
);
prismaMock.accessCode.deleteMany.mockResolvedValue({ count: 1 });
mockGenerateSecret.mockReturnValue(["hashed_secret", "plain_secret"]);
mockVerifyCodeChallenge.mockReturnValue(false);
const tokenRequest = createTokenRequest({
grant_type: "authorization_code",
code: "test_auth_code",
client_id: "confidential_client_456",
client_secret: "plain_secret",
redirect_uri: "https://app.example.com/callback",
code_verifier: "wrong_verifier",
});
const response = await POST(tokenRequest, { params: Promise.resolve({}) });
expect(response).toBeDefined();
if (!response) throw new Error("Response is undefined");
const data = await response.json();
expect(response.status).toBe(400);
expect(data.error).toBe("invalid_grant");
expect(mockVerifyCodeChallenge).toHaveBeenCalledWith("wrong_verifier", "test_challenge", "S256");
});
});
describe("General validation", () => {
it("should reject invalid grant_type", async () => {
const tokenRequest = createTokenRequest({
grant_type: "implicit",
code: "test_auth_code",
client_id: "test_client",
redirect_uri: "https://app.example.com/callback",
});
const response = await POST(tokenRequest, { params: Promise.resolve({}) });
expect(response).toBeDefined();
if (!response) throw new Error("Response is undefined");
const data = await response.json();
expect(response.status).toBe(400);
expect(data.error).toBe("invalid_request");
});
it("should reject invalid client_id", async () => {
prismaMock.oAuthClient.findFirst.mockResolvedValue(null);
const tokenRequest = createTokenRequest({
grant_type: "authorization_code",
code: "test_auth_code",
client_id: "invalid_client",
redirect_uri: "https://app.example.com/callback",
});
const response = await POST(tokenRequest, { params: Promise.resolve({}) });
expect(response).toBeDefined();
if (!response) throw new Error("Response is undefined");
const data = await response.json();
expect(response.status).toBe(401);
expect(data.error).toBe("invalid_client");
});
it("should reject mismatched redirect_uri", async () => {
prismaMock.oAuthClient.findFirst.mockResolvedValue({
redirectUri: "https://app.example.com/callback",
clientSecret: null,
clientType: "PUBLIC" as const,
} as Awaited<ReturnType<typeof prismaMock.oAuthClient.findFirst>>);
const tokenRequest = createTokenRequest({
grant_type: "authorization_code",
code: "test_auth_code",
client_id: "test_client",
redirect_uri: "https://malicious.com/callback",
});
const response = await POST(tokenRequest, { params: Promise.resolve({}) });
expect(response).toBeDefined();
if (!response) throw new Error("Response is undefined");
const data = await response.json();
expect(response.status).toBe(400);
expect(data.error).toBe("invalid_grant");
});
it("should reject expired authorization code", async () => {
prismaMock.oAuthClient.findFirst.mockResolvedValue({
redirectUri: "https://app.example.com/callback",
clientSecret: null,
clientType: "PUBLIC" as const,
} as Awaited<ReturnType<typeof prismaMock.oAuthClient.findFirst>>);
prismaMock.accessCode.findFirst.mockResolvedValue(null);
const tokenRequest = createTokenRequest({
grant_type: "authorization_code",
code: "expired_auth_code",
client_id: "test_client",
redirect_uri: "https://app.example.com/callback",
code_verifier: "test_verifier",
});
const response = await POST(tokenRequest, { params: Promise.resolve({}) });
expect(response).toBeDefined();
if (!response) throw new Error("Response is undefined");
const data = await response.json();
expect(response.status).toBe(400);
expect(data.error).toBe("invalid_grant");
});
});
});
+45 -42
View File
@@ -4,64 +4,63 @@ import jwt from "jsonwebtoken";
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { AccessCodeRepository } from "@calcom/features/oauth/repositories/AccessCodeRepository";
import { OAuthClientRepository } from "@calcom/features/oauth/repositories/OAuthClientRepository";
import { OAuthService } from "@calcom/features/oauth/services/OAuthService";
import prisma from "@calcom/prisma";
import { generateSecret } from "@calcom/trpc/server/routers/viewer/oAuth/addClient.handler";
import type { OAuthTokenPayload } from "@calcom/types/oauth";
async function handler(req: NextRequest) {
const { code, client_id, client_secret, grant_type, redirect_uri } = await parseUrlFormData(req);
const { code, client_id, client_secret, grant_type, redirect_uri, code_verifier } = await parseUrlFormData(
req
);
if (!process.env.CALENDSO_ENCRYPTION_KEY) {
return NextResponse.json({ message: "CALENDSO_ENCRYPTION_KEY is not set" }, { status: 500 });
}
if (!client_id || !code || !redirect_uri) {
return NextResponse.json({ error: "invalid_request" }, { status: 400 });
}
if (grant_type !== "authorization_code") {
return NextResponse.json({ message: "grant_type invalid" }, { status: 400 });
return NextResponse.json({ error: "invalid_request" }, { status: 400 });
}
const [hashedSecret] = generateSecret(client_secret);
const oAuthClientRepository = new OAuthClientRepository(prisma);
const accessCodeRepository = new AccessCodeRepository(prisma);
const client = await prisma.oAuthClient.findFirst({
where: {
clientId: client_id,
clientSecret: hashedSecret,
},
select: {
redirectUri: true,
},
});
const client = await oAuthClientRepository.findByClientId(client_id);
if (!client || client.redirectUri !== redirect_uri) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
if (!client) {
return NextResponse.json({ error: "invalid_client" }, { status: 401 });
}
const accessCode = await prisma.accessCode.findFirst({
where: {
code: code,
clientId: client_id,
expiresAt: {
gt: new Date(),
},
},
});
if (client.redirectUri !== redirect_uri) {
return NextResponse.json({ error: "invalid_grant" }, { status: 400 });
}
const isValidClient = OAuthService.validateClient(client, client_secret);
if (!isValidClient) {
return NextResponse.json({ error: "invalid_client" }, { status: 401 });
}
const accessCode = await accessCodeRepository.findValidCode(code, client_id);
// Delete all expired accessCodes + the one that is used here
await prisma.accessCode.deleteMany({
where: {
OR: [
{
expiresAt: {
lt: new Date(),
},
},
{
code: code,
clientId: client_id,
},
],
},
});
await accessCodeRepository.deleteExpiredAndUsedCodes(code, client_id);
if (!accessCode) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
return NextResponse.json({ error: "invalid_grant" }, { status: 400 });
}
const secretKey = process.env.CALENDSO_ENCRYPTION_KEY || "";
const pkceError = OAuthService.verifyPKCE(client, accessCode, code_verifier);
if (pkceError) {
return NextResponse.json({ error: pkceError.error }, { status: pkceError.status });
}
const secretKey = process.env.CALENDSO_ENCRYPTION_KEY;
const payloadAuthToken: OAuthTokenPayload = {
userId: accessCode.userId,
@@ -77,6 +76,11 @@ async function handler(req: NextRequest) {
scope: accessCode.scopes,
token_type: "Refresh Token",
clientId: client_id,
// Include PKCE information for clients that used PKCE (PUBLIC mandatory, CONFIDENTIAL optional)
...(accessCode.codeChallenge && {
codeChallenge: accessCode.codeChallenge,
codeChallengeMethod: accessCode.codeChallengeMethod,
}),
};
const accessTokenExpiresIn = 1800; // 30 minutes
@@ -89,7 +93,6 @@ async function handler(req: NextRequest) {
expiresIn: 30 * 24 * 60 * 60, // 30 days
});
// @see https://datatracker.ietf.org/doc/html/rfc6749#section-5.1
return NextResponse.json(
{ access_token, token_type: "bearer", refresh_token, expires_in: accessTokenExpiresIn },
{
@@ -0,0 +1,84 @@
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { z } from "zod";
import stripe from "@calcom/features/ee/payments/server/stripe";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { HttpError } from "@calcom/lib/http-error";
import { prisma } from "@calcom/prisma";
const querySchema = z.object({
session_id: z.string().min(1),
paymentStatus: z.enum(["success", "failed"]),
});
async function getHandler(req: NextRequest) {
try {
const searchParams = req.nextUrl.searchParams;
const { session_id, paymentStatus } = querySchema.parse({
session_id: searchParams.get("session_id"),
paymentStatus: searchParams.get("paymentStatus"),
});
// Retrieve checkout session from Stripe to get metadata
const checkoutSession = await stripe.checkout.sessions.retrieve(session_id);
if (!checkoutSession) {
throw new HttpError({ statusCode: 404, message: "Checkout session not found" });
}
// Extract organizationOnboardingId from metadata
const organizationOnboardingId = checkoutSession.metadata?.organizationOnboardingId;
// Check if onboarding-v3 feature flag is enabled
const featuresRepository = new FeaturesRepository(prisma);
const isOnboardingV3Enabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("onboarding-v3");
// Build query params to preserve
const params = new URLSearchParams({
session_id,
paymentStatus,
});
// If onboarding-v3 is enabled AND organizationOnboardingId exists, redirect to onboarding flow
if (isOnboardingV3Enabled && organizationOnboardingId) {
const redirectUrl = new URL(
`/onboarding/personal/settings?${params.toString()}`,
WEBAPP_URL
).toString();
return NextResponse.redirect(redirectUrl);
}
// Otherwise, redirect to the current flow
// Preserve any additional query params that were passed
searchParams.forEach((value, key) => {
if (key !== "session_id" && key !== "paymentStatus") {
params.append(key, value);
}
});
const redirectUrl = new URL(
`/settings/organizations/new/status?${params.toString()}`,
WEBAPP_URL
).toString();
return NextResponse.redirect(redirectUrl);
} catch (error) {
if (error instanceof HttpError) {
throw error;
}
// If there's an error, fall back to the default status page
const searchParams = req.nextUrl.searchParams;
const fallbackParams = new URLSearchParams({
session_id: searchParams.get("session_id") || "",
paymentStatus: searchParams.get("paymentStatus") || "failed",
});
const fallbackUrl = new URL(
`/settings/organizations/new/status?${fallbackParams.toString()}`,
WEBAPP_URL
).toString();
return NextResponse.redirect(fallbackUrl);
}
}
export const GET = defaultResponderForAppDir(getHandler);
+1 -1
View File
@@ -15,7 +15,7 @@ export async function generateMetadata(): Promise<Metadata> {
return await _generateMetadataForStaticPage("Icons Showcase", "", undefined, undefined, "/icons");
}
const interFont = Inter({ subsets: ["latin"], variable: "--font-inter", preload: true, display: "swap" });
const interFont = Inter({ subsets: ["latin"], variable: "--font-sans", preload: true, display: "swap" });
const calFont = localFont({
src: "../../fonts/CalSans-SemiBold.woff2",
variable: "--font-cal",
+18 -16
View File
@@ -15,7 +15,7 @@ import { AppRouterI18nProvider } from "./AppRouterI18nProvider";
import { SpeculationRules } from "./SpeculationRules";
import { Providers } from "./providers";
const interFont = Inter({ subsets: ["latin"], variable: "--font-inter", preload: true, display: "swap" });
const interFont = Inter({ subsets: ["latin"], variable: "--font-sans", preload: true, display: "swap" });
const calFont = localFont({
src: "../fonts/CalSans-SemiBold.woff2",
variable: "--font-cal",
@@ -98,6 +98,8 @@ export default async function RootLayout({ children }: { children: React.ReactNo
const h = await headers();
const nonce = h.get("x-csp-nonce") ?? "";
const country = h.get("cf-ipcountry") || h.get("x-vercel-ip-country") || "Unknown";
const { locale, direction, isEmbed, embedColorScheme } = await getInitialProps();
const ns = "common";
@@ -115,8 +117,8 @@ export default async function RootLayout({ children }: { children: React.ReactNo
<head nonce={nonce}>
<style>{`
:root {
--font-inter: ${interFont.style.fontFamily.replace(/'/g, "")};
--font-cal: ${calFont.style.fontFamily.replace(/'/g, "")};
--font-sans: ${interFont.style.fontFamily.replace(/\'/g, "")};
--font-cal: ${calFont.style.fontFamily.replace(/\'/g, "")};
}
`}</style>
</head>
@@ -125,19 +127,19 @@ export default async function RootLayout({ children }: { children: React.ReactNo
style={
isEmbed
? {
background: "transparent",
// Keep the embed hidden till parent initializes and
// - gives it the appropriate styles if UI instruction is there.
// - gives iframe the appropriate height(equal to document height) which can only be known after loading the page once in browser.
// - Tells iframe which mode it should be in (dark/light) - if there is a a UI instruction for that
visibility: "hidden",
// This in addition to visibility: hidden is to ensure that elements with specific opacity set are not visible
opacity: 0,
}
background: "transparent",
// Keep the embed hidden till parent initializes and
// - gives it the appropriate styles if UI instruction is there.
// - gives iframe the appropriate height(equal to document height) which can only be known after loading the page once in browser.
// - Tells iframe which mode it should be in (dark/light) - if there is a a UI instruction for that
visibility: "hidden",
// This in addition to visibility: hidden is to ensure that elements with specific opacity set are not visible
opacity: 0,
}
: {
visibility: "visible",
opacity: 1,
}
visibility: "visible",
opacity: 1,
}
}>
<IconSprites />
<SpeculationRules
@@ -154,7 +156,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
]}
/>
<Providers isEmbed={isEmbed} nonce={nonce}>
<Providers isEmbed={isEmbed} nonce={nonce} country={country}>
<AppRouterI18nProvider translations={translations} locale={locale} ns={ns}>
{children}
</AppRouterI18nProvider>
+5 -5
View File
@@ -145,7 +145,7 @@ export function NotFound({ host }: { host: string }) {
target="_blank"
className="relative flex items-start space-x-4 py-6 rtl:space-x-reverse"
rel="noreferrer">
<div className="flex-shrink-0">
<div className="shrink-0">
<span className="flex h-12 w-12 items-center justify-center rounded-lg bg-green-50">
<Icon name="check" className="h-6 w-6 text-green-500" aria-hidden="true" />
</span>
@@ -164,7 +164,7 @@ export function NotFound({ host }: { host: string }) {
</h3>
<p className="text-subtle text-base">{t(`404_claim_entity_${pageType.toLowerCase()}`)}</p>
</div>
<div className="flex-shrink-0 self-center">
<div className="shrink-0 self-center">
<Icon name="chevron-right" className="text-muted h-5 w-5" aria-hidden="true" />
</div>
</a>
@@ -180,8 +180,8 @@ export function NotFound({ host }: { host: string }) {
<a
href={link.href}
className="relative flex items-start space-x-4 py-6 rtl:space-x-reverse">
<div className="flex-shrink-0">
<span className="bg-muted flex h-12 w-12 items-center justify-center rounded-lg">
<div className="shrink-0">
<span className="bg-cal-muted flex h-12 w-12 items-center justify-center rounded-lg">
<Icon name={link.icon} className="text-default h-6 w-6" aria-hidden="true" />
</span>
</div>
@@ -194,7 +194,7 @@ export function NotFound({ host }: { host: string }) {
</h3>
<p className="text-subtle text-base">{link.description}</p>
</div>
<div className="flex-shrink-0 self-center">
<div className="shrink-0 self-center">
<Icon name="chevron-right" className="text-muted h-5 w-5" aria-hidden="true" />
</div>
</a>
+15 -10
View File
@@ -9,23 +9,28 @@ import { NotificationSoundHandler } from "@calcom/web/components/notification-so
import useIsBookingPage from "@lib/hooks/useIsBookingPage";
import { GeoProvider } from "./GeoContext";
type ProvidersProps = {
isEmbed: boolean;
children: React.ReactNode;
nonce: string | undefined;
country: string;
};
export function Providers({ isEmbed, children, nonce }: ProvidersProps) {
export function Providers({ isEmbed, children, country }: ProvidersProps) {
const isBookingPage = useIsBookingPage();
return (
<SessionProvider>
<TrpcProvider>
{!isEmbed && !isBookingPage && <NotificationSoundHandler />}
{/* @ts-expect-error FIXME remove this comment when upgrading typescript to v5 */}
<CacheProvider>
<WebPushProvider>{children}</WebPushProvider>
</CacheProvider>
</TrpcProvider>
</SessionProvider>
<GeoProvider country={country}>
<SessionProvider>
<TrpcProvider>
{!isEmbed && !isBookingPage && <NotificationSoundHandler />}
{/* @ts-expect-error FIXME remove this comment when upgrading typescript to v5 */}
<CacheProvider>
<WebPushProvider>{children}</WebPushProvider>
</CacheProvider>
</TrpcProvider>
</SessionProvider>
</GeoProvider>
);
}
+2 -2
View File
@@ -18,7 +18,7 @@ export default function AddToHomescreen() {
<div className="rounded-lg p-2 shadow-lg sm:p-3" style={{ background: "#2F333D" }}>
<div className="flex flex-wrap items-center justify-between">
<div className="flex w-0 flex-1 items-center">
<span className="bg-brand text-brandcontrast dark:bg-darkmodebrand dark:text-darkmodebrandcontrast flex rounded-lg bg-opacity-30 p-2">
<span className="text-brandcontrast dark:bg-darkmodebrand/30 dark:text-darkmodebrandcontrast flex rounded-lg p-2">
<svg
className="h-7 w-7 fill-current text-[#5B93F9]"
xmlns="http://www.w3.org/2000/svg"
@@ -34,7 +34,7 @@ export default function AddToHomescreen() {
</p>
</div>
<div className="order-2 flex-shrink-0 sm:order-3">
<div className="order-2 shrink-0 sm:order-3">
<button
onClick={() => setCloseBanner(true)}
type="button"
+1 -1
View File
@@ -52,7 +52,7 @@ export default function EnterprisePage() {
features={features}
background="/tips/enterprise"
buttons={
<div className="space-y-2 rtl:space-x-reverse sm:space-x-2">
<div className="stack-y-2 rtl:space-x-reverse sm:space-x-2">
<ButtonGroup>
<Button color="primary" href="https://go.cal.com/quote" target="_blank">
{t("contact_sales")}
+2 -2
View File
@@ -24,7 +24,7 @@ export interface CalPageWrapper {
PageWrapper?: AppProps["Component"]["PageWrapper"];
}
const interFont = Inter({ subsets: ["latin"], variable: "--font-inter", preload: true, display: "swap" });
const interFont = Inter({ subsets: ["latin"], variable: "--font-sans", preload: true, display: "swap" });
const calFont = localFont({
src: "../fonts/CalSans-SemiBold.woff2",
variable: "--font-cal",
@@ -80,7 +80,7 @@ function PageWrapper(props: AppProps) {
<style jsx global>{`
:root {
--font-inter: ${interFont.style.fontFamily};
--font-sans: ${interFont.style.fontFamily};
--font-cal: ${calFont.style.fontFamily};
}
`}</style>
+1 -1
View File
@@ -497,7 +497,7 @@ export const AppPage = ({
)}
</ul>
<hr className="border-subtle my-8 border" />
<span className="leading-1 text-subtle block text-xs">
<span className="text-subtle block text-xs">
{t("every_app_published", { appName: APP_NAME, companyName: COMPANY_NAME })}
</span>
<a className="mt-2 block text-xs text-red-500" href={`mailto:${SUPPORT_MAIL_ADDRESS}`}>
@@ -25,8 +25,8 @@ export default function AppleCalendarSetup() {
return (
<div className="bg-emphasis flex h-screen dark:bg-inherit">
<div className="bg-default dark:bg-muted border-subtle m-auto rounded p-5 dark:border md:w-[560px] md:p-10">
<div className="flex flex-col space-y-5 md:flex-row md:space-x-5 md:space-y-0">
<div className="bg-default dark:bg-cal-muted border-subtle m-auto rounded p-5 dark:border md:w-[560px] md:p-10">
<div className="flex flex-col stack-y-5 md:flex-row md:space-x-5 md:stack-y-0">
<div>
{/* eslint-disable @next/next/no-img-element */}
<img
@@ -75,7 +75,7 @@ export default function AppleCalendarSetup() {
}
}}>
<fieldset
className="space-y-4"
className="stack-y-4"
disabled={form.formState.isSubmitting}
data-testid="apple-calendar-form">
<TextField
@@ -249,13 +249,13 @@ function BTCPaySetupPage(props: IBTCPaySetupProps) {
<>
<div className="bg-default flex h-screen items-center justify-center">
{showContent ? (
<div className="flex w-full w-full max-w-[43em] flex-col items-center justify-center space-y-4 p-4 lg:space-y-5">
<form className="w-full space-y-4" onSubmit={onSubmit}>
<div className="flex w-full w-full max-w-[43em] flex-col items-center justify-center stack-y-4 p-4 lg:stack-y-5">
<form className="w-full stack-y-4" onSubmit={onSubmit}>
<div className="bg-default border-subtle overflow-auto rounded border">
<div className="border-subtle flex items-center justify-between border-b-[1px] p-4 md:p-5">
<div className="border-subtle flex items-center justify-between border-b p-4 md:p-5">
<h2 className="text-2xl font-semibold">BTCPay Server Information</h2>
</div>
<div className="w-full space-y-4 p-4 md:p-5">
<div className="w-full stack-y-4 p-4 md:p-5">
<div className="w-full">
<KeyField
{...register("serverUrl", { required: true })}
@@ -26,7 +26,7 @@ export default function CalDavCalendarSetup() {
return (
<div className="bg-emphasis flex h-screen">
<div className="bg-default m-auto rounded p-5 md:w-[560px] md:p-10">
<div className="flex flex-col space-y-5 md:flex-row md:space-x-5 md:space-y-0">
<div className="flex flex-col stack-y-5 md:flex-row md:space-x-5 md:stack-y-0">
<div>
{/* eslint-disable @next/next/no-img-element */}
<img
@@ -60,7 +60,7 @@ export default function CalDavCalendarSetup() {
router.push(json.url);
}
}}>
<fieldset className="space-y-2" disabled={form.formState.isSubmitting}>
<fieldset className="stack-y-2" disabled={form.formState.isSubmitting}>
<TextField
required
type="text"
@@ -95,7 +95,7 @@ export default function CalDavCalendarSetup() {
href={errorActionUrl}
color="secondary"
target="_blank"
className="ml-5 w-32 !p-5">
className="ml-5 w-32 p-5!">
Go to Admin
</Button>
) : undefined
@@ -54,7 +54,7 @@ export default function Exchange2013CalendarSetup() {
router.push(json.url);
}
}}>
<fieldset className="space-y-2" disabled={form.formState.isSubmitting}>
<fieldset className="stack-y-2" disabled={form.formState.isSubmitting}>
<TextField
required
type="text"
@@ -54,7 +54,7 @@ export default function Exchange2016CalendarSetup() {
router.push(json.url);
}
}}>
<fieldset className="space-y-2" disabled={form.formState.isSubmitting}>
<fieldset className="stack-y-2" disabled={form.formState.isSubmitting}>
<TextField
required
type="text"
@@ -67,7 +67,7 @@ export default function ExchangeSetup() {
<>
<div className="bg-emphasis flex h-screen">
<div className="bg-default m-auto rounded p-5 md:w-[560px] md:p-10">
<div className="flex flex-col space-y-5 md:flex-row md:space-x-5 md:space-y-0">
<div className="flex flex-col stack-y-5 md:flex-row md:space-x-5 md:stack-y-0">
<div>
{/* eslint-disable @next/next/no-img-element */}
<img
@@ -98,7 +98,7 @@ export default function ExchangeSetup() {
router.push(json.url);
}
}}>
<fieldset className="space-y-4" disabled={form.formState.isSubmitting}>
<fieldset className="stack-y-4" disabled={form.formState.isSubmitting}>
<TextField
required
type="url"
+4 -4
View File
@@ -203,15 +203,15 @@ function HitPaySetupPage(props: IHitPaySetupProps) {
<>
<div className="bg-default flex h-screen items-center justify-center">
{showContent ? (
<div className="flex w-full w-full max-w-[43em] flex-col items-center justify-center space-y-4 p-4 lg:space-y-5">
<div className="flex w-full w-full max-w-[43em] flex-col items-center justify-center stack-y-4 p-4 lg:stack-y-5">
<div className="rounded bg-blue-50 p-3 text-sm text-blue-700 dark:bg-blue-950 dark:text-blue-300">
<Icon name="info" className="mb-0.5 inline-flex h-4 w-4" /> Create or connect to an existing
HitPay account to receive payments for your paid bookings.
</div>
<form className="w-full space-y-4" onSubmit={onSubmit}>
<form className="w-full stack-y-4" onSubmit={onSubmit}>
<div className="bg-default border-subtle overflow-auto rounded border">
<div className="border-subtle flex items-center justify-between border-b-[1px] p-4 md:p-5">
<div className="border-subtle flex items-center justify-between border-b p-4 md:p-5">
<h2 className="text-2xl font-semibold">Account Information</h2>
<div className="ml-auto flex items-center">
<Switch
@@ -221,7 +221,7 @@ function HitPaySetupPage(props: IHitPaySetupProps) {
/>
</div>
</div>
<div className="w-full space-y-4 p-4 md:p-5">
<div className="w-full stack-y-4 p-4 md:p-5">
<div className="w-full">
<KeyField
{...register("apiKey", {
@@ -24,7 +24,7 @@ export default function ICSFeedSetup() {
return (
<div className="bg-emphasis flex h-screen">
<div className="bg-default m-auto rounded p-5 md:w-[560px] md:p-10">
<div className="flex flex-col space-y-5 md:flex-row md:space-x-5 md:space-y-0">
<div className="flex flex-col stack-y-5 md:flex-row md:space-x-5 md:stack-y-0">
<div>
{/* eslint-disable @next/next/no-img-element */}
<img
@@ -58,7 +58,7 @@ export default function ICSFeedSetup() {
router.push(json.url);
}
}}>
<fieldset className="space-y-2" disabled={form.formState.isSubmitting}>
<fieldset className="stack-y-2" disabled={form.formState.isSubmitting}>
{urls.map((url, i) => (
<div key={i} className="flex w-full items-center gap-2">
<TextField
@@ -104,7 +104,7 @@ export default function ICSFeedSetup() {
href={errorActionUrl}
color="secondary"
target="_blank"
className="ml-5 w-32 !p-5">
className="ml-5 w-32 p-5!">
Go to Admin
</Button>
) : undefined
@@ -42,9 +42,9 @@ const AccountSelector: FC<AccountSelectorProps> = ({
return (
<div
className={classNames(
"hover:bg-muted flex cursor-pointer flex-row items-center gap-2 p-1",
"hover:bg-cal-muted flex cursor-pointer flex-row items-center gap-2 p-1",
(alreadyInstalled || loading) && "cursor-not-allowed",
selected && loading && "bg-muted animate-pulse"
selected && loading && "bg-cal-muted animate-pulse"
)}
data-testid={testId}
onClick={() => {
@@ -139,7 +139,7 @@ const EventTypeGroup = ({
keyName: "fieldId",
});
return (
<div className="ml-2 flex flex-col space-y-6">
<div className="ml-2 flex flex-col stack-y-6">
{fields.map(
(field, index) =>
field.selected && (
@@ -45,7 +45,7 @@ const EventTypeCard: FC<EventTypeCardProps> = ({
return (
<div
data-testid={`select-event-type-${id}`}
className="hover:bg-muted min-h-20 box-border flex w-full cursor-pointer select-none items-center space-x-4 px-4 py-3"
className="hover:bg-cal-muted min-h-20 box-border flex w-full cursor-pointer select-none items-center space-x-4 px-4 py-3"
onClick={() => handleSelect()}>
<input
id={`${id}`}
@@ -63,7 +63,7 @@ const EventTypeCard: FC<EventTypeCardProps> = ({
</div>
{Boolean(description) && (
<div
className="text-subtle line-clamp-4 break-words text-sm sm:max-w-[650px] [&>*:not(:first-child)]:hidden [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600"
className="text-subtle line-clamp-4 wrap-break-word text-sm sm:max-w-[650px] [&>*:not(:first-child)]:hidden [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600"
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{
__html: markdownToSafeHTML(description),
@@ -105,7 +105,7 @@ const EventTypeGroup: FC<EventTypeGroupProps> = ({ groupIndex, userName, ...prop
<div className="sm:border-subtle bg-default border dark:bg-black sm:rounded-md">
<ScrollableArea className="rounded-md">
<ul className="border-subtle max-h-97 !static w-full divide-y">
<ul className="border-subtle max-h-97 static! w-full divide-y">
{fields.length > 0 ? (
fields.map((field, index) => (
<EventTypeCard
@@ -118,7 +118,7 @@ const EventTypeGroup: FC<EventTypeGroupProps> = ({ groupIndex, userName, ...prop
/>
))
) : (
<div className="text-subtle bg-muted w-full p-2 text-center text-sm">Team has no Events</div>
<div className="text-subtle bg-cal-muted w-full p-2 text-center text-sm">Team has no Events</div>
)}
</ul>
</ScrollableArea>
@@ -95,9 +95,7 @@ function NewFormDialog({
<Dialog open={newFormDialogState !== null} onOpenChange={(open) => !open && setNewFormDialogState(null)}>
<DialogContent className="overflow-y-auto">
<div className="mb-1">
<h3
className="text-emphasis !font-cal text-semibold leading-20 text-xl font-medium"
id="modal-title">
<h3 className="text-emphasis !font-cal text-semibold text-xl font-medium" id="modal-title">
{teamId ? t("add_new_team_form") : t("add_new_form")}
</h3>
<div>
@@ -117,7 +115,7 @@ function NewFormDialog({
duplicateFrom: formToDuplicate,
});
}}>
<div className="mt-3 space-y-5">
<div className="mt-3 stack-y-5">
<TextField label={t("title")} required placeholder={t("a_routing_form")} {...register("name")} />
<div className="mb-5">
<TextAreaField
@@ -175,7 +173,10 @@ export const FormActionsDropdown = ({
type="button"
variant="icon"
color="secondary"
className={classNames(disabled && "opacity-30")}
className={classNames(
disabled && "opacity-30",
"ltr:radix-state-open:rounded-r-(--btn-group-radius) rtl:radix-state-open:rounded-l-(--btn-group-radius)"
)}
StartIcon="ellipsis"
/>
</DropdownMenuTrigger>
@@ -272,16 +273,14 @@ function Dialogs({
const actionsCtx = createContext({
appUrl: "",
// eslint-disable-next-line @typescript-eslint/no-empty-function
setNewFormDialogState: null as SetNewFormDialogState | null,
newFormDialogState: null as NewFormDialogState,
_delete: {
// eslint-disable-next-line @typescript-eslint/no-empty-function, @typescript-eslint/no-unused-vars
onAction: (_arg: { routingForm: RoutingForm | null }) => {},
isPending: false,
},
toggle: {
// eslint-disable-next-line @typescript-eslint/no-empty-function, @typescript-eslint/no-unused-vars
onAction: (_arg: { routingForm: RoutingForm | null; checked: boolean }) => {},
isPending: false,
},
@@ -191,7 +191,7 @@ export const FormSettingsSlideover = ({
)}
</div>
</div>
<SheetFooter className="flex-shrink-0 ">
<SheetFooter className="shrink-0 ">
<Button color="minimal" onClick={handleCancel} data-testid="settings-slider-over-cancel">
{t("cancel")}
</Button>
@@ -289,7 +289,7 @@ export function Header({
) : (
<div className="group flex items-center gap-1">
<span
className="text-default hover:bg-muted min-w-[100px] cursor-pointer truncate whitespace-nowrap rounded px-1 text-sm font-semibold leading-none"
className="text-default hover:bg-cal-muted min-w-[100px] cursor-pointer truncate whitespace-nowrap rounded px-1 text-sm font-semibold leading-none"
onClick={() => setIsEditing(true)}>
{watchedName || "Loading..."}
</span>
@@ -183,7 +183,7 @@ function SingleForm({
className={classNames(
"bg-default flex-1",
isDesktop && "grid gap-8",
isDesktop && isTestPreviewOpen && "grid-cols-[1fr,400px]",
isDesktop && isTestPreviewOpen && "grid-cols-[1fr_400px]",
isDesktop && !isTestPreviewOpen && "grid-cols-1",
!isDesktop && "flex flex-col"
)}>
@@ -335,7 +335,7 @@ export const TestFormRenderer = ({
if (!isMobile) {
if (isTestPreviewOpen) {
return (
<div className="border-muted bg-muted h-full border-l p-6">
<div className="border-muted bg-cal-muted h-full border-l p-6">
<TestForm
form={testForm}
supportsTeamMembersMatchingLogic={isSubTeamForm}
+4 -4
View File
@@ -39,7 +39,7 @@ export default function SendgridSetup() {
return (
<div className="bg-emphasis flex h-screen">
<div className="bg-default m-auto rounded p-5 md:w-[520px] md:p-10">
<div className="flex flex-col space-y-5 md:flex-row md:space-x-5 md:space-y-0">
<div className="flex flex-col stack-y-5 md:flex-row md:space-x-5 md:stack-y-0">
<div>
<img src="/api/app-store/sendgrid/logo.png" alt="Sendgrid" className="h-12 w-12 max-w-2xl" />
</div>
@@ -76,7 +76,7 @@ export default function SendgridSetup() {
showToast(json.message, "error");
}
}}>
<fieldset className="space-y-2" disabled={form.formState.isSubmitting}>
<fieldset className="stack-y-2" disabled={form.formState.isSubmitting}>
<Controller
name="api_key"
control={form.control}
@@ -108,8 +108,8 @@ export default function SendgridSetup() {
className={
testPassed !== undefined
? testPassed
? " !bg-success hover:bg-success !text-green-700"
: "bg-error hover:bg-error !border-red-700 !text-red-700"
? " bg-cal-success! hover:bg-cal-success text-green-700!"
: "bg-error hover:bg-error border-red-700! text-red-700!"
: "secondary"
}
color={testPassed === true ? "minimal" : "secondary"}
@@ -82,7 +82,7 @@ export const ConfirmDialog = (props: IConfirmDialogWipe) => {
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
<DialogContent onOpenAutoFocus={(e) => e.preventDefault()}>
<div className="flex flex-row space-x-3">
<div className="flex h-10 w-10 flex-shrink-0 justify-center rounded-full bg-[#FAFAFA]">
<div className="flex h-10 w-10 shrink-0 justify-center rounded-full bg-[#FAFAFA]">
<Icon name="clock" className="m-auto h-5 w-5" />
</div>
<div className="pt-1">
+1 -1
View File
@@ -9,7 +9,7 @@ export default function TwoFactor({ center = true }) {
const methods = useFormContext();
return (
<div className={center ? "mx-auto !mt-0 max-w-sm" : "!mt-0 max-w-sm"}>
<div className={center ? "mx-auto mt-0! max-w-sm" : "mt-0! max-w-sm"}>
<Label className="mt-4">{t("backup_code")}</Label>
<p className="text-subtle mb-4 text-sm">{t("backup_code_instructions")}</p>
+2 -2
View File
@@ -22,10 +22,10 @@ export default function TwoFactor({ center = true, autoFocus = true }) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]);
const className = "h-12 w-12 !text-xl text-center";
const className = "h-12 w-12 text-xl! text-center";
return (
<div className={center ? "mx-auto !mt-0 max-w-sm" : "!mt-0 max-w-sm"}>
<div className={center ? "mx-auto mt-0! max-w-sm" : "mt-0! max-w-sm"}>
<Label className="mt-4">{t("2fa_code")}</Label>
<p className="text-subtle mb-4 text-sm">{t("2fa_enabled_instructions")}</p>
@@ -354,9 +354,9 @@ function BookingListItem(booking: BookingItemProps) {
<div
data-testid="booking-item"
data-today={String(booking.isToday)}
className="hover:bg-muted group w-full">
className="hover:bg-cal-muted group w-full">
<div className="flex flex-col sm:flex-row">
<div className="hidden align-top ltr:pl-3 rtl:pr-6 sm:table-cell sm:min-w-[12rem]">
<div className="hidden align-top ltr:pl-3 rtl:pr-6 sm:table-cell sm:min-w-48">
<div className="flex h-full items-center">
{eventTypeColor && (
<div className="h-[70%] w-0.5" style={{ backgroundColor: eventTypeColor }} />
@@ -497,7 +497,7 @@ function BookingListItem(booking: BookingItemProps) {
</div>
</ConditionalLink>
</div>
<div className="flex w-full flex-col flex-wrap items-end justify-end space-x-2 space-y-2 py-4 pl-4 text-right text-sm font-medium ltr:pr-4 rtl:pl-4 sm:flex-row sm:flex-nowrap sm:items-start sm:space-y-0 sm:pl-0">
<div className="flex w-full flex-col flex-wrap items-end justify-end space-x-2 stack-y-2 py-4 pl-4 text-right text-sm font-medium ltr:pr-4 rtl:pl-4 sm:flex-row sm:flex-nowrap sm:items-start sm:stack-y-0 sm:pl-0">
{shouldShowPendingActions(actionContext) && <TableActions actions={pendingActions} />}
<BookingActionsDropdown booking={booking} context="list" />
{shouldShowRecurringCancelAction(actionContext) && <TableActions actions={[cancelEventAction]} />}
+5 -10
View File
@@ -29,7 +29,7 @@ function SkeletonItem() {
return (
<li className="group flex w-full items-start justify-between px-4 py-4 sm:px-6">
{/* Left side - Date and time info */}
<div className="flex min-w-0 flex-shrink-0 flex-col gap-2 pr-4">
<div className="flex min-w-0 shrink-0 flex-col gap-2 pr-4">
<SkeletonText className="h-4 w-24" />
<SkeletonText className="h-4 w-28" />
<div className="mt-1 flex items-center gap-2">
@@ -43,26 +43,21 @@ function SkeletonItem() {
</div>
{/* Right side - Event details */}
<div className="flex min-w-0 flex-1 flex-col space-y-2">
<div className="flex min-w-0 flex-1 flex-col stack-y-2">
{/* Event title */}
<SkeletonText className="h-5 w-36" />
{/* Event description */}
<SkeletonText className="h-4 w-48" />
<SkeletonText className="h-4 w-full max-w-48" />
{/* Attendees */}
<SkeletonText className="h-4 w-56" />
<SkeletonText className="h-4 w-full max-w-56" />
</div>
{/* Action buttons - only visible on larger screens */}
<div className="ml-4 hidden flex-shrink-0 gap-2 sm:flex">
<div className="ml-4 hidden shrink-0 gap-2 sm:flex">
<SkeletonText className="h-9 w-20 rounded-md" />
<SkeletonText className="h-9 w-20 rounded-md" />
<SkeletonText className="h-9 w-9 rounded-md" />
</div>
{/* Mobile menu button */}
<div className="ml-4 flex flex-shrink-0 sm:hidden">
<SkeletonText className="h-9 w-9 rounded-md" />
</div>
</li>
);
}
@@ -369,7 +369,7 @@ export function BookingActionsDropdown({
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent title={t("mark_as_no_show")} enableOverflow>
<div className="space-y-2">
<div className="stack-y-2">
{attendees.map((attendee, index) => (
<label key={attendee.email} className="flex items-center space-x-2">
<input
@@ -427,6 +427,7 @@ export function BookingActionsDropdown({
bookingId={booking.id}
teamId={booking.eventType?.team?.id || 0}
bookingFromRoutingForm={isBookingFromRoutingForm}
isManagedEvent={booking.eventType?.parentId != null}
/>
)}
<EditLocationDialog
@@ -109,6 +109,12 @@ export function getEditEventActions(context: BookingActionContext): ActionType[]
} = context;
const seatReferenceUid = getSeatReferenceUid();
const isReassignableRoundRobin =
booking.eventType.schedulingType === SchedulingType.ROUND_ROBIN &&
(!booking.eventType.hostGroups || booking.eventType.hostGroups.length <= 1);
const isManagedChildEvent = booking.eventType.parentId != null;
const isReassignable = isReassignableRoundRobin || isManagedChildEvent;
const actions: (ActionType | null)[] = [
{
id: "reschedule",
@@ -154,9 +160,7 @@ export function getEditEventActions(context: BookingActionContext): ActionType[]
icon: "user-plus",
disabled: false,
},
// Reassign if round robin with no or one host groups
booking.eventType.schedulingType === SchedulingType.ROUND_ROBIN &&
(!booking.eventType.hostGroups || booking.eventType.hostGroups?.length <= 1)
isReassignable
? {
id: "reassign",
label: t("reassign"),
@@ -58,7 +58,7 @@ export const AddGuestsDialog = (props: IAddGuestsDialog) => {
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
<DialogContent enableOverflow>
<div className="flex flex-row md:space-x-3">
<div className="bg-subtle hidden h-10 w-10 flex-shrink-0 justify-center rounded-full md:flex">
<div className="bg-subtle hidden h-10 w-10 shrink-0 justify-center rounded-full md:flex">
<Icon name="user-plus" className="m-auto h-6 w-6" />
</div>
<div className="w-full md:pt-1">
@@ -74,7 +74,7 @@ export const AddGuestsDialog = (props: IAddGuestsDialog) => {
{isInvalidEmail && (
<div className="my-4 flex text-sm text-red-700">
<div className="flex-shrink-0">
<div className="shrink-0">
<Icon name="triangle-alert" className="h-5 w-5" />
</div>
<div className="ml-3">
@@ -44,7 +44,7 @@ export const ChargeCardDialog = (props: IRescheduleDialog) => {
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
<DialogContent>
<div className="flex flex-row space-x-3">
<div className=" bg-subtle flex h-10 w-10 flex-shrink-0 justify-center rounded-full">
<div className=" bg-subtle flex h-10 w-10 shrink-0 justify-center rounded-full">
<Icon name="credit-card" className="m-auto h-6 w-6" />
</div>
<div className="pt-1">
@@ -257,7 +257,7 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
}
}}>
<div className="flex flex-row md:space-x-3">
<div className="bg-subtle mx-auto hidden h-12 w-12 flex-shrink-0 items-center justify-center rounded-full sm:mx-0 sm:h-10 sm:w-10 md:flex">
<div className="bg-subtle mx-auto hidden h-12 w-12 shrink-0 items-center justify-center rounded-full sm:mx-0 sm:h-10 sm:w-10 md:flex">
<Icon name="map-pin" className="text-emphasis h-6 w-6" />
</div>
<div className="w-full md:pt-1">
+138 -47
View File
@@ -25,7 +25,7 @@ import { RadioAreaGroup as RadioArea } from "@calcom/ui/components/radio";
import { showToast } from "@calcom/ui/components/toast";
enum ReassignType {
ROUND_ROBIN = "round_robin",
AUTO = "auto",
TEAM_MEMBER = "team_member",
}
@@ -35,10 +35,11 @@ type ReassignDialog = {
teamId: number;
bookingId: number;
bookingFromRoutingForm: boolean;
isManagedEvent: boolean;
};
type FormValues = {
reassignType: ReassignType.ROUND_ROBIN | ReassignType.TEAM_MEMBER;
reassignType: ReassignType.AUTO | ReassignType.TEAM_MEMBER;
teamMemberId?: number;
reassignReason?: string;
};
@@ -66,6 +67,7 @@ export const ReassignDialog = ({
teamId,
bookingId,
bookingFromRoutingForm,
isManagedEvent,
}: ReassignDialog) => {
const { t } = useLocale();
const utils = trpc.useUtils();
@@ -76,18 +78,34 @@ export const ReassignDialog = ({
const [searchTerm, setSearchTerm] = useState("");
const debouncedSearch = useDebounce(searchTerm, 500);
const { data, fetchNextPage, hasNextPage, isFetching, isFetchingNextPage } =
trpc.viewer.teams.getRoundRobinHostsToReassign.useInfiniteQuery(
{
bookingId,
exclude: "fixedHosts",
limit: 10,
searchTerm: debouncedSearch,
},
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
}
);
const managedEventQuery = trpc.viewer.teams.getManagedEventUsersToReassign.useInfiniteQuery(
{
bookingId,
limit: 10,
searchTerm: debouncedSearch,
},
{
enabled: isManagedEvent,
getNextPageParam: (lastPage) => lastPage.nextCursor,
}
);
const roundRobinQuery = trpc.viewer.teams.getRoundRobinHostsToReassign.useInfiniteQuery(
{
bookingId,
exclude: "fixedHosts",
limit: 10,
searchTerm: debouncedSearch,
},
{
enabled: !isManagedEvent,
getNextPageParam: (lastPage) => lastPage.nextCursor,
}
);
const { data, fetchNextPage, hasNextPage, isFetching, isFetchingNextPage } = isManagedEvent
? managedEventQuery
: roundRobinQuery;
const allRows = useMemo(() => {
return data?.pages.flatMap((page) => page.items) ?? [];
@@ -110,7 +128,7 @@ export const ReassignDialog = ({
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
reassignType: bookingFromRoutingForm ? ReassignType.TEAM_MEMBER : ReassignType.ROUND_ROBIN,
reassignType: bookingFromRoutingForm ? ReassignType.TEAM_MEMBER : ReassignType.AUTO,
},
});
@@ -124,7 +142,22 @@ export const ReassignDialog = ({
if (error.message.includes(ErrorCode.NoAvailableUsersFound)) {
showToast(t("no_available_hosts"), "error");
} else {
showToast(t("unexpected_error_try_again"), "error");
showToast(t(error.message), "error");
}
},
});
const managedEventReassignMutation = trpc.viewer.teams.managedEventReassign.useMutation({
onSuccess: async () => {
await utils.viewer.bookings.get.invalidate();
setIsOpenDialog(false);
showToast(t("booking_reassigned"), "success");
},
onError: async (error) => {
if (error.message.includes(ErrorCode.NoAvailableUsersFound)) {
showToast(t("no_available_hosts"), "error");
} else {
showToast(t(error.message), "error");
}
},
});
@@ -144,6 +177,21 @@ export const ReassignDialog = ({
},
});
const managedEventManualReassignMutation = trpc.viewer.teams.managedEventManualReassign.useMutation({
onSuccess: async () => {
await utils.viewer.bookings.get.invalidate();
setIsOpenDialog(false);
showToast(t("booking_reassigned"), "success");
},
onError: async (error) => {
if (error.message.includes(ErrorCode.NoAvailableUsersFound)) {
showToast(t("no_available_hosts"), "error");
} else {
showToast(t(error.message), "error");
}
},
});
const [confirmationModal, setConfirmationModal] = useState<{
show: boolean;
membersStatus: "unavailable" | "available" | null;
@@ -153,8 +201,12 @@ export const ReassignDialog = ({
});
const handleSubmit = (values: FormValues) => {
if (values.reassignType === ReassignType.ROUND_ROBIN) {
roundRobinReassignMutation.mutate({ teamId, bookingId });
if (values.reassignType === ReassignType.AUTO) {
if (isManagedEvent) {
managedEventReassignMutation.mutate({ bookingId });
} else {
roundRobinReassignMutation.mutate({ teamId, bookingId });
}
} else {
if (values.teamMemberId) {
const selectedMember = teamMemberOptions?.find((member) => member.value === values.teamMemberId);
@@ -179,8 +231,8 @@ export const ReassignDialog = ({
setIsOpenDialog(open);
}}>
<DialogContent
title={t("reassign_round_robin_host")}
description={t("reassign_to_another_rr_host")}
title={isManagedEvent ? t("reassign_booking") : t("reassign_round_robin_host")}
description={isManagedEvent ? t("reassign_to_another_user") : t("reassign_to_another_rr_host")}
enableOverflow>
<Form form={form} handleSubmit={handleSubmit} ref={animationParentRef}>
<RadioArea.Group
@@ -188,24 +240,34 @@ export const ReassignDialog = ({
const reassignType: ReassignType = z.nativeEnum(ReassignType).parse(val);
form.setValue("reassignType", reassignType);
}}
defaultValue={bookingFromRoutingForm ? ReassignType.TEAM_MEMBER : ReassignType.ROUND_ROBIN}
defaultValue={bookingFromRoutingForm ? ReassignType.TEAM_MEMBER : ReassignType.AUTO}
className="mt-1 flex flex-col gap-4">
{!bookingFromRoutingForm ? (
<RadioArea.Item
value={ReassignType.ROUND_ROBIN}
value={ReassignType.AUTO}
className="w-full text-sm"
classNames={{ container: "w-full" }}
disabled={bookingFromRoutingForm}>
<strong className="mb-1 block">{t("round_robin")}</strong>
<p>{t("round_robin_reassign_description")}</p>
disabled={bookingFromRoutingForm}
data-testid="reassign-option-auto">
<strong className="mb-1 block">
{isManagedEvent ? t("auto_reassign") : t("round_robin")}
</strong>
<p>{isManagedEvent ? t("auto_reassign_description") : t("round_robin_reassign_description")}</p>
</RadioArea.Item>
) : null}
<RadioArea.Item
value={ReassignType.TEAM_MEMBER}
className="text-sm"
classNames={{ container: "w-full" }}>
<strong className="mb-1 block">{t("team_member_round_robin_reassign")}</strong>
<p>{t("team_member_round_robin_reassign_description")}</p>
classNames={{ container: "w-full" }}
data-testid="reassign-option-specific">
<strong className="mb-1 block">
{isManagedEvent ? t("specific_team_member") : t("team_member_round_robin_reassign")}
</strong>
<p>
{isManagedEvent
? t("specific_team_member_description")
: t("team_member_round_robin_reassign_description")}
</p>
</RadioArea.Item>
</RadioArea.Group>
@@ -217,9 +279,22 @@ export const ReassignDialog = ({
type="text"
placeholder={t("search")}
onChange={(e) => setSearchTerm(e.target.value)}
data-testid="reassign-user-search"
/>
<div className="scroll-bar mt-2 flex h-[150px] flex-col gap-0.5 overflow-y-scroll rounded-md border p-1">
{teamMemberOptions.map((member) => (
{isFetching && teamMemberOptions.length === 0 ? (
<div className="flex h-full items-center justify-center">
<div className="flex flex-col items-center gap-2">
<Icon name="loader" className="text-subtle h-5 w-5 animate-spin" />
<p className="text-subtle text-sm">{t("loading")}</p>
</div>
</div>
) : teamMemberOptions.length === 0 ? (
<div className="flex h-full items-center justify-center">
<p className="text-subtle text-sm">{t("no_available_users_found_input")}</p>
</div>
) : (
teamMemberOptions.map((member) => (
<label
key={member.value}
tabIndex={watchedTeamMemberId === member.value ? -1 : 0}
@@ -232,7 +307,7 @@ export const ReassignDialog = ({
}
}}
className={classNames(
"hover:bg-subtle focus:bg-subtle focus:ring-emphasis cursor-pointer items-center justify-between gap-0.5 rounded-sm py-2 outline-none focus:ring focus:ring-2",
"hover:bg-subtle focus:bg-subtle focus:ring-emphasis cursor-pointer items-center justify-between gap-0.5 rounded-sm py-2 outline-none focus:ring-2",
watchedTeamMemberId === member.value && "bg-subtle"
)}>
<div className="flex flex-1 items-center space-x-3">
@@ -244,7 +319,7 @@ export const ReassignDialog = ({
/>
<div
className={classNames(
"h-3 w-3 flex-shrink-0 rounded-full",
"h-3 w-3 shrink-0 rounded-full",
member.status === "unavailable" ? "bg-red-500" : "bg-green-500"
)}
/>
@@ -256,16 +331,19 @@ export const ReassignDialog = ({
)}
</div>
</label>
))}
<div className="text-default text-center" ref={observerRef}>
<Button
color="minimal"
loading={isFetchingNextPage}
disabled={!hasNextPage}
onClick={() => fetchNextPage()}>
{hasNextPage ? t("load_more_results") : t("no_more_results")}
</Button>
</div>
))
)}
{teamMemberOptions.length > 0 && (
<div className="text-default text-center" ref={observerRef}>
<Button
color="minimal"
loading={isFetchingNextPage}
disabled={!hasNextPage}
onClick={() => fetchNextPage()}>
{hasNextPage ? t("load_more_results") : t("no_more_results")}
</Button>
</div>
)}
</div>
</div>
</div>
@@ -275,7 +353,12 @@ export const ReassignDialog = ({
<Button
type="submit"
data-testid="rejection-confirm"
loading={roundRobinReassignMutation.isPending || roundRobinManualReassignMutation.isPending}>
loading={
roundRobinReassignMutation.isPending ||
roundRobinManualReassignMutation.isPending ||
managedEventReassignMutation.isPending ||
managedEventManualReassignMutation.isPending
}>
{t("reassign")}
</Button>
</DialogFooter>
@@ -300,11 +383,19 @@ export const ReassignDialog = ({
if (!teamMemberId) {
return;
}
roundRobinManualReassignMutation.mutate({
bookingId,
teamMemberId,
reassignReason: form.getValues("reassignReason"),
});
if (isManagedEvent) {
managedEventManualReassignMutation.mutate({
bookingId,
teamMemberId,
reassignReason: form.getValues("reassignReason"),
});
} else {
roundRobinManualReassignMutation.mutate({
bookingId,
teamMemberId,
reassignReason: form.getValues("reassignReason"),
});
}
setConfirmationModal({
show: false,
membersStatus: null,
+5 -5
View File
@@ -373,9 +373,9 @@ const NewRoutingManager = ({
reroutingState.value?.type === "reschedule_to_different_event_new_tab";
return (
<div className="bg-muted flex flex-col space-y-3 rounded-md p-4 text-sm">
<div className="bg-cal-muted flex flex-col stack-y-3 rounded-md p-4 text-sm">
<h2 className="text-emphasis font-medium">{t("new_routing_status")}</h2>
<div className="flex flex-col space-y-2">
<div className="flex flex-col stack-y-2">
{reroutingState.status === ReroutingStatusEnum.REROUTING_NOT_INITIATED && reroutingPreview()}
{(reroutingState.status === ReroutingStatusEnum.REROUTING_NOT_INITIATED || !isReroutingInNewTab) &&
reroutingCTAs()}
@@ -567,7 +567,7 @@ const NewRoutingManager = ({
throw new Error(t("something_went_wrong"));
}
return (
<div className="flex flex-col space-y-2">
<div className="flex flex-col stack-y-2">
<span className="text-default" data-testid="reroute-preview-event-type">
<span className="font-semibold">{t("event_type")}:</span>{" "}
<a
@@ -666,9 +666,9 @@ const CurrentRoutingStatus = ({
const bookerUrl = useBookerUrl();
if (!fullSlug) return null;
return (
<div className="bg-muted flex flex-col space-y-3 rounded-md p-4 text-sm">
<div className="bg-cal-muted flex flex-col stack-y-3 rounded-md p-4 text-sm">
<h2 className="text-emphasis font-medium">{t("current_routing_status")}</h2>
<div className="flex flex-col space-y-2">
<div className="flex flex-col stack-y-2">
<span className="text-default" data-testid="current-routing-status-event-type">
<span className="font-semibold">{t("event_type")}:</span>{" "}
<a
@@ -38,7 +38,7 @@ export const RescheduleDialog = (props: IRescheduleDialog) => {
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
<DialogContent enableOverflow data-testid="reschedule-dialog">
<div className="flex flex-row md:space-x-3">
<div className="bg-subtle hidden h-10 w-10 flex-shrink-0 justify-center rounded-full md:flex">
<div className="bg-subtle hidden h-10 w-10 shrink-0 justify-center rounded-full md:flex">
<Icon name="clock" className="m-auto h-6 w-6" />
</div>
<div className="w-full md:pt-1">
+1 -1
View File
@@ -76,7 +76,7 @@ export const ErrorPage: React.FC<Props> = (props) => {
<p className="text-default mb-4 max-w-2xl text-sm">
Please provide the following text when contacting support to better help you:
</p>
<pre className="bg-emphasis text-emphasis w-full max-w-2xl whitespace-normal break-words rounded-md p-4">
<pre className="bg-emphasis text-emphasis w-full max-w-2xl whitespace-normal wrap-break-word rounded-md p-4">
{message}
</pre>
</div>

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