Compare commits

...
132 Commits
Author SHA1 Message Date
Alex van Andel 46b88a3c81 chore: release v5.9.9
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-05 10:55:08 +00:00
sean-brydon c97f1928bb fixes refresh issue (#25616) 2025-12-05 09:37:35 +00:00
dea6ec5003 perf: handleChildrenEventTypes transaction (#25602)
* perf: handleChildrenEventTypes transaction

* fixup! perf: handleChildrenEventTypes transaction

* fix: e2e tests

* test: update handleChildrenEventTypes tests for createMany API

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

* fix: tests

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-12-05 08:35:29 +00:00
Rouh 8cace7f7a0 fix (a11y): Add aria-label to progressToast close button (#25618) 2025-12-05 04:40:12 -03:00
Hariom BalharaandDevin AI b80c0692dc fix: improve Vercel domain 'forbidden' error message and add schema fields (#25520)
* fix: improve Vercel domain 'forbidden' error message

The 'forbidden' error code from Vercel is a generic permission denial,
not specifically 'domain owned by another team'. It can occur for various
reasons: wrong teamId, token lacking project access, account-level
restrictions, or domain ownership by another team.

Updated the error message to be more accurate and changed the HTTP status
code from 400 to 403 to better reflect the permission error nature.

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* fix: add message and invalidToken fields to Vercel error response schema

This allows better logging of Vercel API error responses, especially when
tokens become invalid. The new fields help with debugging permission issues.

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* fix: revert status code to 400 for Vercel forbidden errors

Keep the status code at 400 to avoid potential downstream side effects
while still using the improved error message and schema fields.

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* fix: use .nullish() instead of .optional() for message and invalidToken fields

.nullish() handles both null and undefined, which is safer for API responses.

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-12-05 05:26:42 +00:00
Kartik LabhshetwarandDhairyashil Shinde 44291be7b7 fix: show rescheduled badge on mobile view in bookings list (#25542)
Co-authored-by: Dhairyashil Shinde <93669429+dhairyashiil@users.noreply.github.com>
2025-12-05 04:00:03 +00:00
Kartik 54440cf954 fix: weekly view slots not clickable with overlay calendar (#25484) 2025-12-04 21:11:45 +00:00
Dhairyashil Shinde 43315108a2 refactor: remove cal workflow trigger message from workflow templates (#25539)
* refactor: remove cal workflow trigger message

* remove from rating template
2025-12-04 20:45:38 +00:00
Emmanuel IsenahandDhairyashil Shinde 311b6e071d feat: support hideEventTypeDetails query param on booking pages (#25565)
* fix: lint error

* feat: support `hideEventTypeDetails` via query param

Allow the event type details to be hidden based on the
hideEventTypeDetails query parameter when not in embed mode

* feat: add hideEventTypeDetails prop to EventMeta

---------

Co-authored-by: Dhairyashil Shinde <93669429+dhairyashiil@users.noreply.github.com>
2025-12-04 20:26:08 +00:00
Kartik 308c7fa12b fix: correct typos and HTML encoding in org invite emails (#25551)
* fix: correct typos and HTML encoding in org invite emails

* removed interpolation: { escapeValue: false }

* added interpolation: { escapeValue: false }
2025-12-04 19:53:44 +00:00
Anshumancanrock 51cf73218b fix: Radio buttons not filled in (CSS background-color) (#25599)
* Radio buttons not filled in (CSS background-color)

* fix: radio buttons not filled when selected in light mode
2025-12-04 19:49:02 +00:00
Joe Au-YeungandDevin AI dc44825ce5 fix: prevent overwriting existing event type values with undefined (#25624)
When updating an event type, fields like autoTranslateDescriptionEnabled
and disableGuests were being overwritten with false/default values even
when they weren't explicitly provided in the update request.

Changes:
- autoTranslateDescriptionEnabled: Only set when explicitly provided
  (not undefined) to avoid overwriting existing true values with false
- disableGuests: Only set when bookingFields is explicitly provided,
  since it's derived from the guests field in bookingFields

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-12-04 19:18:45 +00:00
Kartik Labhshetwar 908128ca5d style: improve dropdown option spacing and hover separation (#25605)
* feat(ui): improve dropdown option spacing and hover separation

* style: add flex column layout and spacing to timezone select scrollbar
2025-12-05 00:47:21 +05:30
Dhairyashil ShindeandPallav b12f14c538 feat: auto scroll sidebar to active team on navigation (#25555)
* feat: auto-scroll sidebar to active team on navigation

When navigating to team settings via "Edit team" from the teams list,
the sidebar now scrolls to show the selected team and expands its section.

- Extract team ID from URL path (/settings/teams/{id}/...)
- Use existing aria-controls attribute to find team element
- Scroll with smooth behavior centered in viewport

* revert formatting changes by prettier

---------

Co-authored-by: Pallav <90088723+Pallava-Joshi@users.noreply.github.com>
2025-12-05 00:46:18 +05:30
Anik Dhabal Babu f03e6075a2 fix: unable to confirm/reject booking from email (#25614)
* fix: unable to confirm/reject booking

* fix lint

* Refactor booking confirmation and rejection tests
2025-12-04 23:17:17 +05:30
Peer Richelsen a8aac6dc3a added eas (#25619) 2025-12-04 14:36:38 -03:00
Anik Dhabal Babu 1594567fd5 fix: issue with workflow meetingUrl (#25620) 2025-12-04 16:46:36 +00:00
Volnei Munhoz dde6e7d140 chore: bump nextjs version (#25592)
* bump nextjs version

* bump nextjs version for api v1
2025-12-04 10:46:34 -03:00
Eesh Midha d8f36d0665 fix: enable date picker taps on iOS by setting modal={false} (#25590) 2025-12-04 13:33:43 +00:00
Abhishek 9362253bfb fix: vertical tab spacing (#25612) 2025-12-04 12:56:05 +00:00
sean-brydonandEunjae Lee c91da96956 fix: reset store on getting started (#25607)
* reset store

* Update apps/web/modules/onboarding/getting-started/onboarding-view.tsx

Co-authored-by: Eunjae Lee <hey@eunjae.dev>

---------

Co-authored-by: Eunjae Lee <hey@eunjae.dev>
2025-12-04 11:23:52 +00:00
Abhishek dbb33b9632 fix(ui): improve the alignment of api keys code (#25570)
fix: api key horizontally centered
2025-12-04 10:54:48 +00:00
Anik Dhabal Babu ead0691ea3 fix: refactored few handler and repository (#25567)
* update

* update

* refactor
2025-12-04 11:43:29 +02:00
Abir Roy e4a6b97017 fix(routing-forms): correct variable encoding and URL construction in Routing Forms Event Redirect Custom URLs (#25499)
* fix: change slugification to uri encoding

* fix: add conditional joiner symbol between event type redirect url and all other search params

* fix: update integration test to expect encoded variable

* test: add tests for query parameters joiner logic
2025-12-04 08:59:22 +00:00
sean-brydon 98b862c048 fix: form issues in QA for onboarding (#25581)
## What does this PR do?
Fixes the issue where form state wasnt corectly recorded in QA

## 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?

<!-- Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration. Write details that help to start the tests -->

- Are there environment variables that should be set?
- What are the minimal test data to have?
- What is expected (happy path) to have (input and output)?
- Any other important info that could help to test that PR

## 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
2025-12-04 08:58:00 +00:00
Hariom BalharaandDevin AI 78c411621e feat(embed-core): add public close() API for modal-based embeds (#24746)
* feat(embed-core): add public close() API for modal-based embeds

Add a proper public API method close() to the CalApi class that allows
users to programmatically close modal-based embeds (ModalBox and FloatingButton).

This replaces the need for the undocumented hack:
  cal.instance.actionManager.fire('__closeIframe')

The new API is cleaner and more intuitive:
  cal('close')

Key features:
- Works for modal-based embeds only (ModalBox and FloatingButton)
- Throws a clear error if called on inline embeds
- Well-documented with JSDoc comments explaining usage and limitations

Example usage:
  cal('on', {
    action: 'bookingSuccessful',
    callback: () => {
      cal('close');
    }
  });

Also fixed pre-existing lint issues: unused variable in catch block and
eslint-disable comment for missing rule definition.

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* refactor(embed-core): rename close() to closeModal() for clarity

Rename the close() method to closeModal() to make it immediately clear
from the API name itself that this method is for modal-based embeds only.

This makes the API more self-documenting:
- Before: cal('close')
- After: cal('closeModal')

Updated:
- Method name from close() to closeModal()
- JSDoc example to use cal('closeModal')
- Error message to reference closeModal()

The TypeScript types automatically recognize the new action name through
the SingleInstructionMap type system.

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* cleanup

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-12-04 05:48:11 -03:00
Lauris Skraucis a1e1bf8b76 chore: add atoms changeset for tailwind v4 (#25577) 2025-12-04 08:42:58 +00:00
d00cf329e8 fix: workflow reminder send to all RR hosts. (#25597)
* fix: wrkflow reminder issue

* Update EmailWorkflowService.ts

* update

* Refactor team member email extraction for clarity

* add tests

* Undo changes in EmailWorkflowService

* Get team members assigned to the booking

* fix: Update test to match EmailWorkflowService behavior for ROUND_ROBIN

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

* fix: Update CalendarEventBuilder tests to include host emails in attendees

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

* Apply suggestion from @cubic-dev-ai[bot]

Filter organizer our of `hostsToInclude`

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

* Only include organizer and host destination calendar

* Type fix

* Type fix

---------

Co-authored-by: Joe Au-Yeung <j.auyeung419@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2025-12-04 09:21:48 +02:00
Udit Takkar 843329ccce feat: distributed tracing - 3 (#25092)
* feat: distributed tracing 2

* feat: distributed tracing 2

* refactor: feedback

* refactor: feedback

* fix: type error

* fix: trpc error

* feat: distributed tracing - 3

* chore: translation

* refactor: improvements

* fix: feedback

* chore: remove
2025-12-04 05:51:26 +00:00
741144bff2 fix: correct busy slot title for ics feed integration troubleshoot (#23929)
* fix: correct busy slot title for ics feed integration troubleshoot

* Prevent 'as' call by wrapping getFirstPropertyValue in string

---------

Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
Co-authored-by: Pallav <90088723+Pallava-Joshi@users.noreply.github.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-12-04 05:49:34 +00:00
Udit TakkarandMorgan a11f7e6510 feat: add session endpoint and booking access pbac (#24637)
* feat: add session endpoint

* fix: remove participant id

* refactor; feedback

* fix: e2e test

* refactor: add pbac

* refactor: use constant

* tests: add booking tests

* fix: conflicts

* chore: test

* fix: tests

* fix: tests

* chore: remove un necessary

* chore: docs

* fix: desc

* fix: booking-pbac gurd

* fix: booking-pbac gurd

* test: add unit tests

* fix: remove legacy pbac

* fix: chore remove

* fix: update tests

* test: add more test

* test: move it to new file

* chore: comment auth

* chore: add message

* chore: add message

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2025-12-04 05:45:17 +00:00
Anik Dhabal Babu 3a8bf7014c fix: add all the attendees to ics (#25601) 2025-12-04 05:38:38 +00:00
6f02fbe13b fix: expand Intercom allowed URLs and prevent double-prefixing (#25181)
Co-authored-by: peer@cal.com <peer@cal.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-12-04 05:36:24 +00:00
af3f44a7f5 feat: add data region dropdown to signup page (#25127)
* feat: add data region dropdown to signup page

Add a dropdown to the signup page that allows users to select their data region (United States or European Union). When a region is selected, the user is navigated to the appropriate domain (app.cal.com or app.cal.eu).

- Add SelectField component to signup page below the description text
- Detect current domain and set appropriate default value
- Navigate to correct domain when selection changes
- Add translation strings for data_region, united_states, and european_union

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

* added localhost

* fix: improve data region dropdown - SSR hydration, query params, and self-hosted protection (#25463)

* fix/remove-eu-us-dropdown-from-self-hosted-signup

* fix: remove localhost bypass and fix SSR hydration for data region dropdown

- Remove localhost bypass from IS_CALCOM check to prevent self-hosted instances from seeing Cal.com-specific dropdown
- Fix SSR hydration mismatch by using WEBAPP_URL instead of window.location for region detection
- Fix hostname replacement to preserve query parameters by modifying hostname directly instead of entire URL string

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Dhairyashil Shinde <93669429+dhairyashiil@users.noreply.github.com>
Co-authored-by: Dhairyashil <dhairyashil10101010@gmail.com>
2025-12-04 05:34:24 +00:00
Joe Au-YeungandAnik Dhabal Babu 0605572969 fix: Log detailed error in getConnectedCalendar (#25338)
* Log error

* Reverse sent error message changes

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-12-03 19:51:51 +00:00
AbhishekandAnik Dhabal Babu 5ddfbcf225 fix: date picker content render issue (#25584)
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-12-03 19:46:28 +00:00
Volnei Munhoz 2fc1078158 fix companion oidc url (#25572) 2025-12-03 19:38:41 +00:00
sean-brydon ad416ac9df fix error spam (#25591) 2025-12-03 19:36:05 +00:00
Anik Dhabal Babu 2a7439b58d fix: e2e flake (#25578) 2025-12-03 16:07:27 -03:00
Anik Dhabal Babu 8aaa84142b fix: unable to edit member as a team owner/admin (#25587)
* fix: unaable to edit member as owner/admin

* Clean up comments in EditMemberSheet.test.tsx

Removed comments explaining the fix for permissions prop.

* revert
2025-12-03 18:14:35 +00:00
Udit Takkar 059ca2503d fix: prevent clicking outside (#25588) 2025-12-03 17:57:23 +00:00
Udit Takkar 35d6c41fff feat: Disable booking emails to guests (#25217)
* feat: disable SMS org setting

* chore: undo

* fix: cal evnet

* feat: add more settings

* tests: add email manager unit tests

* fix: update

* fix: type error

* fix: test

* refactor: UI

* refactor: UI

* perf: fetch only once

* perf: fetch only once

* chore: use org

* fix: test

* test: add unit tests

* refactor: email manager

* fix: add confirmation dialog for individual checkbox

* fix: sms

* chore: common.json
2025-12-03 17:21:31 +00:00
Eunjae LeeandDevin AI 1c3ced5b70 fix: simplify date range picker to Airbnb-style selection (#25386)
* feat: simplify date range picker to Airbnb-style selection

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

* feat: apply Airbnb-style selection to allowPastDates branch

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

* refactor: extract date range selection logic and add unit tests

- Extract date range selection logic into pure function (dateRangeLogic.ts)
- Remove unused allowPastDates parameter from selection logic
- Add comprehensive unit tests (11 test cases covering all scenarios)
- Simplify DateRangePicker component (30+ lines -> 5 lines)
- Improve separation of concerns: allowPastDates only controls calendar date restrictions

* style update

* feat: add hover highlighting for date range selection

When a start date is selected and user hovers over other dates,
the potential range between start and hovered date now shows
bg-emphasis background for better visual feedback.

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

* memoize hovering range and update styles

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-12-03 14:52:25 +00:00
88ce7868e8 feat: companion expo and chrome (#25022)
* feat: companion expo and chrome

* fix: api-v2-fetch-bookings (#25023)

* migrated to app expo router

* feat(companion): added availability screen and fix event type fetch (#25025)

* fix(companion): add host check in bookings (#25036)

* using liquid glass bottom nav experimental

* added action sheet

* added icons to event type tabs

* added action bar for event-type details

* long press for edit event types

* added limits

* added more limits

* added more limits

* added username to event types

* feat(companion): config to set and show all the available meeting options a user has stalled (#25081)

* feat: Add tailwind support using nativewind. (#25028)

* init nativewind

* replace styles with tailwind classes

* changes

* feat(companion): all locations options, icons, and apply nativewind to remaining files (#25143)

* feat: all locations options and icons and apply nativwind to reamaining files

* icon bug fix

* cal video icon

* other location frontend

* other location api

* address cubics comment about correctly detects SVGs

* feat(companion): edit availability (#25149)

* feat: companion-edit-availability

* date override and use correct config for patch api endpoint

* Android Alert supports a maximum of 3 buttons

* feat: companion-ui-release-1 (#25150)

* feat(companion): Add event type & schedule creation flows with API expansion (#25187)

* feat(companion): event schedule creation and apis

* profile section

* limits, advanced, recurring tab

* Clean Folder Structure

* addressed cubics comments

* fix cubiics comments

* replaced text with cal.com svg logo

* added correct more button for event-types

* added copy link button

* added extension popup

* added chrome extension sidebar

* achieved liquid glass buttons

* nit

* more progress

* fixed ios bug, added icon

* added cal icon to gmail

* nit

* nit

* added dialog for one-off meeting

* added one-time dialog

* feat(companion): gmail plugin (#25327)

* Update Cal.com Companion to version 1.7.0 with enhanced background and content scripts. The background script now handles fetching event types from the Cal.com API and improved error handling. The content script has been updated to manage sidebar visibility and respond to messages more effectively. Removed deprecated dev background script and updated manifest permissions for better functionality.

* chore: update companion .gitignore to exclude build outputs and env files

* feat: insert Cal.com event link directly at cursor position in Gmail compose

- Add insertTextAtCursor function to insert booking link where user is typing
- Automatically positions cursor after inserted link for seamless typing
- Fallback to clipboard copy if compose field not found
- Improved UX: no need to manually paste the link

* feat(companion): improve notification UI and add event types caching

- Update notification to Cal.com brand style (black bg, check icon, smooth fade)
- Change notification text to 'Link inserted' for subtlety
- Move notification to bottom-right position (matching Cal.com toasts)
- Add event types caching (5min duration) to reduce API calls
- Cache automatically refreshes on page reload
- Fix TypeScript error with composeBody.focus() type assertion

* Notification toast  border

* conditional description

* conditional description

* feat(companion): Enhance UI with action buttons, tooltips, and Gmail extension improvements (#25353)

* feat: companion-and-gmail-ui-upgrade

* feat: companion-and-gmail-ui-upgrade

* feat: companion-and-gmail-ui-upgrade

* feat: companion-and-gmail-ui-upgrade

* feat(companion): one-off links (#25355)

* added one off prototype

* resolve merge conflicts

---------

Co-authored-by: Dhairyashil <dhairyashil10101010@gmail.com>

* fixed sidebar iframe

* fix: enable pointer events on sidebar while maintaining click-through on transparent area

- Added pointerEvents: 'auto' to sidebar View component in _layout.tsx
- Changed iframe width from 100% to 400px to match sidebar width
- Changed iframe pointerEvents from 'none' to 'auto' to enable clicking
- This allows sidebar items to be clickable while transparent area remains click-through

* feat(companion): center all modals on full screen in browser extension (#25380)

* fix: companion-full-width-iframe

* center the popups on extension view

* feat(companion): center all modals on full screen in browser extension

- Created FullScreenModal component that expands iframe to full width
- Updated content script to handle instant iframe expansion/collapse
- Converted 17 modals across 6 files to use FullScreenModal
- All modals now appear centered on full browser viewport
- Removed animations for instant modal appearance
- Mobile app behavior remains unchanged

Files updated:
- companion/components/FullScreenModal.tsx (new)
- companion/extension/entrypoints/content.ts
- companion/app/(tabs)/event-types.tsx (5 modals)
- companion/app/(tabs)/bookings.tsx (2 modals)
- companion/app/(tabs)/availability.tsx (3 modals)
- companion/app/booking-detail.tsx (2 modals)
- companion/app/availability-detail.tsx (4 modals)
- companion/components/Header.tsx (1 modal)

* security(companion): validate postMessage origin in content script

Add origin and source validation to postMessage listener to prevent
malicious scripts on host pages from manipulating the companion iframe.

- Verify event.source matches iframe.contentWindow
- Verify event.origin matches iframe origin (localhost:8081)
- Reject all unauthorized messages before processing

This prevents external scripts from:
- Forcing full-screen overlays
- Intercepting pointer events
- Manipulating the companion UI

* fix(companion): center 'Add new event type' modal on full screen

Replace KeyboardAvoidingView with TouchableOpacity pattern in the
create event type modal to ensure it appears centered on the full
screen like all other modals.

This was the last remaining modal that wasn't properly centered.

* fix(companion): resolve modal transition timing issue

Fixed the 'Add new event type' modal not centering properly by:

- Added 100ms delay when transitioning between modals
- Prevents conflicting expand/collapse messages
- Removed redundant postMessage calls from modal handlers
- Added max-height constraint to modal content
- Cleaned up debug console logs

The issue was that when clicking 'New Event Type' from the 'New' menu,
both modals were transitioning simultaneously, causing the create modal
to collapse immediately. The delay ensures proper sequencing.

All 17 modals now work correctly and appear centered on full screen.

* added working date range

* removed all of one-off event code

* nit

* feat(companion): Suggest `Cal.com` links and Embed using Gemini Ai in Gmail (#25406)

Co-authored-by: Peer Richelsen <peeroke@gmail.com>

* add companion build on CI

* feat: oauth companion (#25526)

* first implemenation of oauth

* fix logout for web

* revert app name

* remove isUsingOAuth

* clean up log out button

* remove logs

* clean up authcontext

* simplify code generation

* code clean up

* clean up oauthService.ts

* oauth browser extension

* add redirect uri to .env.example

* remove api key support

* clean up _layout file

* add identity

* fix + clean up

* improvements to oauth flow

* code clean up

* remove comments

* clean up comments

* add log out dialog

* add confirm modal

---------

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

---------

Co-authored-by: Dhairyashil Shinde <93669429+dhairyashiil@users.noreply.github.com>
Co-authored-by: Abhijeet Singh <asingh9829@gmail.com>
Co-authored-by: Dhairyashil <dhairyashil10101010@gmail.com>
Co-authored-by: Volnei Munhoz <volnei.munhoz@gmail.com>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2025-12-03 08:46:04 -03:00
ba3838ec0f chore: docs for BookerEmbed atom (#25541)
* chore: docs for BookerEmbed atom

* chore: PR feedback

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

* Update docs/platform/atoms/booker-embed.mdx

---------

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
Co-authored-by: Volnei Munhoz <volnei@cal.com>
2025-12-03 11:39:09 +00:00
sean-brydon f87d4de546 fix: UI nits in QA verison of onboarding v3 (#25532)
* fix create button

* fix subdomain prefix in orgs details view

* remove redundant text

* fix prefix on link fields

* brand mode colours

* Fix banner positioning

* remove app descrtipion plus add mr to account for button

* fix gaps

* improve free plan welcome modal
2025-12-03 09:57:01 +00:00
Alex van Andel fd6c6e6419 chore: release v5.9.8
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-03 09:25:02 +00:00
Alex van AndelandDevin AI 63d1361a7a fix: simplify credentials provider authorization flow (#25563)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-12-03 09:21:20 +00:00
Udit Takkar cbcd769f88 docs: add automatic no show updated docs (#25507) 2025-12-03 06:35:54 +00:00
Anik Dhabal Babu a2185d9ee0 fix: sms and whatsapp reminder issue with seated event (#25294)
* fix: sms reminder issue with seated event

* fix seated event and workflow issue

* fix whatsapp reminder

* Refactor imports in reminderScheduler.ts

* use repository
2025-12-03 04:12:38 +00:00
Anik Dhabal Babu 7a66add217 Prevent navigation for disabled booking actions (#25543) 2025-12-03 04:10:31 +00:00
Morgan 68b93385eb refactor: platform billing controller auth guards (#25559)
* refactor: platform billing controller auth guards

* fixup! refactor: platform billing controller auth guards
2025-12-02 20:38:29 +00:00
Alex van Andel e2aa6361d5 fix: config is statically analyzed, failing on build (#25558) 2025-12-02 18:38:29 +00:00
d15281160f fix: preserve seatsPerTimeSlot during partial event type updates (#25450)
* fix: preserve seatsPerTimeSlot during partial event type updates

When doing a partial update on the PATCH /v2/organizations/{orgId}/teams/{teamId}/event-types/{eventTypeId} endpoint, providing a partial body would reset seatsPerTimeSlot back to null because the transformSeatsApiToInternal function was always called even when seats was undefined.

This fix ensures that the seat options are only transformed when the seats field is explicitly provided in the update request, preserving existing values during partial updates.

Also adds e2e test to verify partial updates preserve seatsPerTimeSlot.

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

* fix: handle TypeScript union type narrowing for seatsPerTimeSlot

When seats is not provided in a partial update, the transformed body
may not have seatsPerTimeSlot property. This fix uses 'in' operator
to safely check for the property before accessing it.

Also fixes the e2e test to properly narrow the union type for seats
using type guards.

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

* refactor: simplify seatsPerTimeSlot handling per review feedback

Per supalarry's suggestion, instead of using 'in' operator checks in
multiple validation calls, we now return {seatsPerTimeSlot: undefined}
when seats is not provided. This keeps the validation code unchanged
and only requires modifying one line in the transformation.

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-12-02 18:38:01 +00:00
Volnei Munhoz 8b6c6273ee Fix api v1 block paths (#25554) 2025-12-02 16:53:17 +00:00
Rajiv Sahal 957d19740e feat: CalendarView atom v2 (#24896)
* refactor: make individual component for generic and event type specific calendar views

* fixup

* update examples app

* fix: show busy times from calendars in week view

* fix: use calendar busy times not working

* chore: add changesets

* fixup

* chore: implement PR feedback

* fix: merge conflicts

* chore: implement PR feedback

* fixup

* chore: implement PR feedback
2025-12-02 21:44:30 +05:30
Hariom Balhara 150e93dc6e fix: Add /router to isBookingPages to prevent 500 error on customPageMessage redirect (#25522)
* Add /router to isBookingPages

* Remove Dynamic Posthog Provider from AppProvider.tsx because that is only used by the pages router, and the pages router is only in use by the /router endpoint, which is a booking page and we havent implemented corresponding GeoProvider support for it
2025-12-02 13:51:04 +00:00
Anik Dhabal Babu 94c7bfea4d fix: display message in the response (#25102) 2025-12-02 13:46:59 +00:00
Kartik Labhshetwar dbef7644d4 fix: improve availability working hours layout spacing and editable heading icon gap (#25525)
* fix: improve availability working hours layout spacing and editable heading icon gap

* refactor: wrap DateOverride in styled container in AvailabilitySettings
2025-12-02 13:20:14 +00:00
abhix4 a843a38e67 fix: styling override at availability settings (#25521) 2025-12-02 12:41:49 +00:00
Udit Takkar 73ffd9c7b5 chore: update next (#25485) 2025-12-02 12:05:42 +00:00
2c8ff89fac perf: Calendar Cache Improvements (#25502)
* wip

* Filter only selectedCalendars where ff is enabled

* test: fix and add tests for calendar subscription cache feature

Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com>

* test: fix SelectedCalendarRepository tests for new teamIds parameter

Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-12-02 08:11:52 -03:00
Benny JooandDevin AI 3bf2a8fa6c refactor: replace TRPCError with ErrorWithCode in packages/features (#25482)
* refactor: replace TRPCError with ErrorWithCode in packages/features

This refactor moves error handling from throwing TRPCError directly in
packages/features to throwing ErrorWithCode instead. The conversion to
TRPCError now happens at the TRPC layer.

Changes:
- Add generic ErrorCode values (Unauthorized, Forbidden, NotFound,
  BadRequest, InternalServerError) to errorCodes.ts
- Update getServerErrorFromUnknown to map new ErrorCodes to proper
  HTTP status codes
- Create toTRPCError helper in packages/trpc/server/lib
- Create errorMappingMiddleware in packages/trpc/server/middlewares
- Migrate TRPCError throws in packages/features to ErrorWithCode:
  - teamService.ts
  - getEventTypeById.ts
  - eventTypeRepository.ts
  - OrganizationPermissionService.ts
  - OrganizationPaymentService.ts
  - sso.ts
  - handleCreatePhoneCall.ts
  - userCanCreateTeamGroupMapping.ts

This improves separation of concerns by making packages/features
transport-agnostic, allowing the same feature code to be reused from
tRPC, API routes, workers, etc.

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

* fix: remove isTrpcCall parameter and fix lint warning

- Remove isTrpcCall parameter from get.handler.ts call since the
  feature layer no longer needs to know about tRPC
- Fix unsafe optional chaining lint warning in getEventTypesByViewer.ts
  by precomputing usersSource variable
- Complete migration of getEventTypesByViewer.ts to ErrorWithCode

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

* revert

* add eslint rule

* add comment

* fix: add isTrpcCall back to getEventTypeById interface

The user reverted the removal of isTrpcCall parameter from the handler,
so we need to add it back to the interface to fix the type error.

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

* test: update teamService tests to expect ErrorWithCode instead of TRPCError

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

* refactor

* wip

* feat: integrate errorMappingMiddleware into base TRPC procedure

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

* connect middlewares

* revert

* revert

* refactor

* rename

* fix: handle ErrorWithCode in teams server-page error handling

The error handling was checking for TRPCError, but teamService now throws
ErrorWithCode. This caused the 'This invitation is not for your account'
error message to not be displayed when a wrong user tries to use an
invitation link.

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

* fix

* fix

* fix

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-12-02 08:11:17 -03:00
Hariom Balhara 16a4c75e1e Update yarn.lock (#25524) 2025-12-02 09:58:57 +00:00
sean-brydon f9db1d6111 fix: getTeamIdsWithPermission (#25387) 2025-12-02 06:11:56 -03:00
Anik Dhabal Babu 1d9aee7154 fix: Use booker locale for email verification email (#25420) 2025-12-02 08:25:39 +00:00
abhix4andDhairyashil Shinde 56e3a314a8 fix(ui): add missing checked state styling for event type checkbox (#25478)
Co-authored-by: Dhairyashil Shinde <93669429+dhairyashiil@users.noreply.github.com>
2025-12-02 06:47:30 +00:00
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
Amit Sharma 81224f324a feat: google ads conversion tracking (#25198)
Post release / release (push) Canceled after 0s
Release Docker / Release AMD64 (push) Canceled after 0s
Release Docker / Release ARM (push) Canceled after 0s
* feat: google ads conversion tracking

* gaClientid

* store gclid in stripe metadata

* tracking only in the us

* track google campaign id as well

* rename gclid -> google ads

* fix: build

* fix

* refactor

* fix: type check

* fix: type check

* fix: type check

* fix: type check

* fix: store it in cookie

* refactor

* fix

* cleanup

* linked ads tracking

* refactor checkout session tracking
2025-11-25 12:58:10 +00:00
emrysal 700d8e7f30 chore: release v5.9.5 2025-11-25 12:55:45 +00:00
Anik Dhabal Babu 301376e33b fix: set new icalUid when host reassign (#25365)
* fix seated event and workflow issue

* update

* revert

* Update smsReminderNumber assignment in handleSeats
2025-11-25 11:46:35 +00:00
Anik Dhabal Babu 57476ea392 chore: team update handler refactor (#25332)
* chore: team update handler refactor

* add tests

* fix
2025-11-25 11:27:26 +00:00
Anik Dhabal Babu 9c2d7f99c2 fix: google meet link is disappear (#25368) 2025-11-25 16:25:12 +05:30
sean-brydon ef3281f06f Remove square enforcing in resizeBase64Image (#25389) 2025-11-25 16:15:57 +05:30
sean-brydon d03f45dd0f fix: Onboarding v3 - create team then invite. (#25364)
## What does this PR do?
This PR changes the order of creating the team -> paying -> invite users.

The old approach was create team -> invite -> pay. But we are matching the current implementation of how billing works with usage based billing

## How should this be tested?

Enable onboarding-v3 flag on your instance
Ensure you have stripe enabled + stripe:listen running
login as onboarding@example.com, onboarding
Go through the team setup flow
Pay
Invite
Ensure uers were added to team

Create a new account
disable billing
Test again 

## 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
2025-11-25 09:14:59 +00:00
2b2bf36703 fix: Grab booking organizer credentials when team admins request reschedule (#24645)
* Change arg name from `bookingUId` to `bookingUid`

* Lint fix

* Use `BookingRepository` to find booking to reschedule

* Move early return further up if no booking is found

* Use `PermissionCheckService` if request rescheduling a team booking

* Remove redundent check

* Remove redundent eventType query

* Using `BookingRepository` to update the booking to rescheduled

* Update type in `getUsersCredentialsIncludeServiceAccountKey` to only
require params that are required

* Get booking organizer credentials

* Type fixes

* test: Add tests for team admin request reschedule with organizer credentials

- Add test for team admin requesting reschedule with proper permissions
- Add test verifying organizer's credentials are used (not requester's)
- Add test for team member without permissions (should fail)

These tests cover the fix in PR #24645 which ensures that when a team admin
requests a reschedule, the booking organizer's credentials are used to delete
calendar events instead of the requester's credentials.

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

* fix: Address code review comments for request reschedule

- Change user: true to user: { select: { id, email } } to only fetch required fields
- Change eventType include to select with explicit fields including teamId
- Remove sensitive information (user object, cancellationReason) from debug log
- All integration tests passing locally

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

* Type fix

* Remove businesss logic references from repository methods.

* Move business logic to handler

* Type fix

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-11-25 07:27:07 +00:00
Benny Joo 272d97c0a3 fix: prevent 500 errors in round-robin scheduling from OOO calibration for single host (#25369)
* wip

* add unit test
2025-11-25 09:13:04 +02:00
1035 changed files with 72997 additions and 34381 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@calcom/atoms": minor
---
This PR introduces two variants for the calendar view atom, a generic calendar view and an event type specific view containing bookings data and busy times from connected calendars.
+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.
+5
View File
@@ -0,0 +1,5 @@
---
"@calcom/atoms": major
---
feat: use tailwind v4 (breaking change)
+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=
+2
View File
@@ -1,2 +1,4 @@
GET /v2/event-types/{eventTypeId} added to the 'data' response property 'oneOf' list for the response status '200'
GET /v2/event-types/{eventTypeId} added '#/components/schemas/EventTypeOutput_2024_06_14, #/components/schemas/TeamEventTypeOutput_2024_06_14' to the 'data' response property 'oneOf' list for the response status '200'
GET /v2/bookings/{bookingUid}/recordings added the new required 'header' request parameter 'Authorization'
GET /v2/bookings/{bookingUid}/transcripts added the new required 'header' request parameter 'Authorization'
+37
View File
@@ -0,0 +1,37 @@
name: Companion Builds
on:
workflow_call:
permissions:
contents: read
env:
EXPO_PUBLIC_CAL_API_KEY: ${{ secrets.CI_EXPO_PUBLIC_CAL_API_KEY || vars.CI_EXPO_PUBLIC_CAL_API_KEY || vars.EXPO_PUBLIC_CAL_API_KEY }}
jobs:
build:
name: Build Companion App & Extension
runs-on: buildjet-4vcpu-ubuntu-2204
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/dangerous-git-checkout
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: companion/package-lock.json
- name: Install dependencies
working-directory: companion
run: npm ci
- name: Build Expo web app
working-directory: companion
env:
EXPO_NO_TELEMETRY: 1
CI: true
run: npx expo export --platform web
- name: Build browser extension
working-directory: companion
run: npm run ext:build
+8
View File
@@ -146,6 +146,13 @@ jobs:
uses: ./.github/workflows/docs-build.yml
secrets: inherit
build-companion:
name: Companion builds
needs: [changes, check-label, deps]
if: ${{ needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/companion-build.yml
secrets: inherit
build:
name: Production builds
needs: [changes, check-label, deps]
@@ -244,6 +251,7 @@ jobs:
build-api-v2,
build-atoms,
build-docs,
build-companion,
e2e,
e2e-api-v2,
e2e-embed,
+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
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,
+19
View File
@@ -0,0 +1,19 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
export function middleware(_request: NextRequest) {
// Matcher guarantees the last segment is one of the blocked segments, so we can
// immediately return a 403 without further path checks.
return NextResponse.json({ message: "Forbidden" }, { status: 403 });
}
export const config = {
// The blocked segment is always the last part of the path, so we only need this matcher.
matcher: [
"/:path*/_get",
"/:path*/_post",
"/:path*/_patch",
"/:path*/_delete",
"/:path*/_auth-middleware",
],
};
+1 -1
View File
@@ -35,7 +35,7 @@
"@sentry/nextjs": "^9.15.0",
"bcryptjs": "^2.4.3",
"memory-cache": "^0.2.0",
"next": "^15.4.5",
"next": "^15.4.8",
"next-api-middleware": "^1.0.1",
"next-axiom": "^0.17.0",
"next-swagger-doc": "^0.3.6",
@@ -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=
@@ -1,10 +1,12 @@
import { BookingGuestsController_2024_08_13 } from "@/ee/bookings/2024-08-13/controllers/booking-guests.controller";
import { BookingsController_2024_08_13 } from "@/ee/bookings/2024-08-13/controllers/bookings.controller";
import { BookingPbacGuard } from "@/ee/bookings/2024-08-13/guards/booking-pbac.guard";
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/booking-references.repository";
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
import { BookingGuestsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-guests.service";
import { BookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-references.service";
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
import { CalVideoOutputService } from "@/ee/bookings/2024-08-13/services/cal-video.output.service";
import { CalVideoService } from "@/ee/bookings/2024-08-13/services/cal-video.service";
import { ErrorsBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/errors.service";
import { InputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/input.service";
@@ -96,6 +98,8 @@ import { Module } from "@nestjs/common";
BookingReferencesService_2024_08_13,
BookingReferencesRepository_2024_08_13,
CalVideoService,
CalVideoOutputService,
BookingPbacGuard,
],
controllers: [BookingsController_2024_08_13, BookingGuestsController_2024_08_13],
exports: [InputBookingsService_2024_08_13, OutputBookingsService_2024_08_13, BookingsService_2024_08_13],
@@ -1,3 +1,4 @@
import { BookingPbacGuard } from "@/ee/bookings/2024-08-13/guards/booking-pbac.guard";
import { BookingUidGuard } from "@/ee/bookings/2024-08-13/guards/booking-uid.guard";
import { BookingReferencesFilterInput_2024_08_13 } from "@/ee/bookings/2024-08-13/inputs/booking-references-filter.input";
import { BookingReferencesOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/booking-references.output";
@@ -23,6 +24,7 @@ import {
GetOptionalUser,
} from "@/modules/auth/decorators/get-optional-user/get-optional-user.decorator";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Pbac } from "@/modules/auth/decorators/pbac/pbac.decorator";
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { OptionalApiAuthGuard } from "@/modules/auth/guards/optional-api-auth/optional-api-auth.guard";
@@ -66,6 +68,7 @@ import {
RescheduleSeatedBookingInput_2024_08_13,
GetBookingRecordingsOutput,
GetBookingTranscriptsOutput,
GetBookingVideoSessionsOutput,
} from "@calcom/platform-types";
import {
CreateBookingInputPipe,
@@ -209,12 +212,16 @@ export class BookingsController_2024_08_13 {
}
@Get("/:bookingUid/recordings")
// @Pbac(["booking.readRecordings"])
@Permissions([BOOKING_READ])
@UseGuards(BookingUidGuard)
// @UseGuards(ApiAuthGuard, BookingUidGuard, BookingPbacGuard)
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({
summary: "Get all the recordings for the booking",
description: `Fetches all the recordings for the booking \`:bookingUid\`
description: `Fetches all the recordings for the booking \`:bookingUid\`. Requires authentication and proper authorization. Access is granted if you are the booking organizer, team admin or org admin/owner.
<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>cal-api-version: \`2024-08-13\` is required in the request header.</Note>
`,
})
async getBookingRecordings(@Param("bookingUid") bookingUid: string): Promise<GetBookingRecordingsOutput> {
@@ -223,16 +230,26 @@ export class BookingsController_2024_08_13 {
return {
status: SUCCESS_STATUS,
data: recordings,
message:
"This endpoint will require authentication in a future release. Please update your integration to include valid credentials. See https://cal.com/docs/api-reference/v2/introduction#authentication for details.",
};
}
@Get("/:bookingUid/transcripts")
// @Pbac(["booking.readRecordings"])
@Permissions([BOOKING_READ])
@UseGuards(BookingUidGuard)
// @UseGuards(ApiAuthGuard, BookingUidGuard, BookingPbacGuard)
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@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> {
@@ -241,6 +258,8 @@ export class BookingsController_2024_08_13 {
return {
status: SUCCESS_STATUS,
data: transcripts ?? [],
message:
"This endpoint will require authentication in a future release. Please update your integration to include valid credentials. See https://cal.com/docs/api-reference/v2/introduction#authentication for details.",
};
}
@@ -542,4 +561,25 @@ export class BookingsController_2024_08_13 {
data: bookingReferences,
};
}
@Get("/:bookingUid/conferencing-sessions")
@HttpCode(HttpStatus.OK)
@Pbac(["booking.readRecordings"])
@Permissions([BOOKING_READ])
@UseGuards(ApiAuthGuard, BookingUidGuard, BookingPbacGuard)
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({
summary: "Get Video Meeting Sessions. Only supported for Cal Video",
description: `Requires authentication and proper authorization. Access is granted if you are the booking organizer, team admin or org admin/owner.
<Note>cal-api-version: \`2024-08-13\` is required in the request header.</Note>`,
})
async getVideoSessions(@Param("bookingUid") bookingUid: string): Promise<GetBookingVideoSessionsOutput> {
const sessions = await this.calVideoService.getVideoSessions(bookingUid);
return {
status: SUCCESS_STATUS,
data: sessions,
};
}
}
@@ -0,0 +1,245 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { CalVideoService } from "@/ee/bookings/2024-08-13/services/cal-video.service";
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 { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { randomString } from "test/utils/randomString";
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
import type { Booking, User, PlatformOAuthClient, Team } from "@calcom/prisma/client";
describe("Bookings Endpoints 2024-08-13", () => {
describe("Booking access authorization", () => {
let app: INestApplication;
let organization: Team;
let userRepositoryFixture: UserRepositoryFixture;
let bookingsRepositoryFixture: BookingsRepositoryFixture;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let teamRepositoryFixture: TeamRepositoryFixture;
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
let schedulesService: SchedulesService_2024_04_15;
let oAuthClient: PlatformOAuthClient;
const ownerEmail = `booking-access-auth-owner-${randomString()}@api.com`;
const unauthorizedEmail = `booking-access-auth-unauthorized-${randomString()}@api.com`;
let ownerUser: User;
let unauthorizedUser: User;
let ownerApiKey: string;
let unauthorizedApiKey: string;
let eventTypeId: number;
let testBooking: Booking;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
})
.overrideGuard(PermissionsGuard)
.useValue({
canActivate: () => true,
})
.compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
organization = await organizationsRepositoryFixture.create({
name: `booking-access-auth-organization-${randomString()}`,
});
oAuthClient = await createOAuthClient(organization.id);
ownerUser = await userRepositoryFixture.create({
email: ownerEmail,
locale: "en",
name: `booking-access-auth-owner-${randomString()}`,
});
unauthorizedUser = await userRepositoryFixture.create({
email: unauthorizedEmail,
locale: "en",
name: `booking-access-auth-unauthorized-${randomString()}`,
});
const { keyString: ownerKeyString } = await apiKeysRepositoryFixture.createApiKey(ownerUser.id, null);
ownerApiKey = `cal_test_${ownerKeyString}`;
const { keyString: unauthorizedKeyString } = await apiKeysRepositoryFixture.createApiKey(
unauthorizedUser.id,
null
);
unauthorizedApiKey = `cal_test_${unauthorizedKeyString}`;
const userSchedule: CreateScheduleInput_2024_04_15 = {
name: `booking-access-auth-schedule-${randomString()}`,
timeZone: "Europe/Rome",
isDefault: true,
};
await schedulesService.createUserSchedule(ownerUser.id, userSchedule);
const eventType = await eventTypesRepositoryFixture.create(
{
title: `booking-access-auth-event-type-${randomString()}`,
slug: `booking-access-auth-event-type-${randomString()}`,
length: 60,
},
ownerUser.id
);
eventTypeId = eventType.id;
testBooking = await bookingsRepositoryFixture.create({
uid: `booking-access-auth-booking-${randomString()}`,
title: "Test Booking for Access Auth",
description: "",
startTime: new Date(Date.UTC(2030, 0, 8, 10, 0, 0)),
endTime: new Date(Date.UTC(2030, 0, 8, 11, 0, 0)),
eventType: {
connect: {
id: eventTypeId,
},
},
user: {
connect: {
id: ownerUser.id,
},
},
metadata: {},
responses: {
name: "Test Attendee",
email: "attendee@example.com",
},
references: {
create: [
{
type: "daily_video",
uid: `daily-room-${randomString()}`,
meetingId: `daily-room-${randomString()}`,
meetingPassword: "test-password",
meetingUrl: `https://daily.co/test-room-${randomString()}`,
},
],
},
});
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
async function createOAuthClient(organizationId: number) {
const data = {
logo: "logo-url",
name: "name",
redirectUris: ["http://localhost:5555"],
permissions: 32,
};
const secret = "secret";
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
return client;
}
describe("GET /v2/bookings/:bookingUid/conferencing-sessions - Authorization", () => {
it("should allow booking organizer to access conferencing sessions", async () => {
const calVideoService = app.get(CalVideoService);
jest.spyOn(calVideoService, "getVideoSessions").mockResolvedValue([]);
const response = await request(app.getHttpServer())
.get(`/v2/bookings/${testBooking.uid}/conferencing-sessions`)
.set("Authorization", `Bearer ${ownerApiKey}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200);
expect(response.body.status).toEqual(SUCCESS_STATUS);
});
it("should return 403 when unauthorized user tries to access conferencing sessions", async () => {
await request(app.getHttpServer())
.get(`/v2/bookings/${testBooking.uid}/conferencing-sessions`)
.set("Authorization", `Bearer ${unauthorizedApiKey}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(403);
});
});
// describe("GET /v2/bookings/:bookingUid/recordings - Authorization", () => {
// it("should allow booking organizer to access recordings", async () => {
// const calVideoService = app.get(CalVideoService);
// jest.spyOn(calVideoService, "getRecordings").mockResolvedValue([]);
// const response = await request(app.getHttpServer())
// .get(`/v2/bookings/${testBooking.uid}/recordings`)
// .set("Authorization", `Bearer ${ownerApiKey}`)
// .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
// .expect(200);
// expect(response.body.status).toEqual(SUCCESS_STATUS);
// });
// it("should return 403 when unauthorized user tries to access recordings", async () => {
// await request(app.getHttpServer())
// .get(`/v2/bookings/${testBooking.uid}/recordings`)
// .set("Authorization", `Bearer ${unauthorizedApiKey}`)
// .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
// .expect(403);
// });
// });
// describe("GET /v2/bookings/:bookingUid/transcripts - Authorization", () => {
// it("should allow booking organizer to access transcripts", async () => {
// const calVideoService = app.get(CalVideoService);
// jest.spyOn(calVideoService, "getTranscripts").mockResolvedValue([]);
// const response = await request(app.getHttpServer())
// .get(`/v2/bookings/${testBooking.uid}/transcripts`)
// .set("Authorization", `Bearer ${ownerApiKey}`)
// .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
// .expect(200);
// expect(response.body.status).toEqual(SUCCESS_STATUS);
// });
// it("should return 403 when unauthorized user tries to access transcripts", async () => {
// await request(app.getHttpServer())
// .get(`/v2/bookings/${testBooking.uid}/transcripts`)
// .set("Authorization", `Bearer ${unauthorizedApiKey}`)
// .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
// .expect(403);
// });
// });
afterAll(async () => {
await bookingsRepositoryFixture.deleteById(testBooking.id);
await eventTypesRepositoryFixture.delete(eventTypeId);
await userRepositoryFixture.deleteByEmail(ownerEmail);
await userRepositoryFixture.deleteByEmail(unauthorizedEmail);
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
await app.close();
});
});
});
@@ -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;
}
});
@@ -4,6 +4,7 @@ import { CancelBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs
import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output";
import { MarkAbsentBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/mark-absent.output";
import { RescheduleBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reschedule-booking.output";
import { CalVideoService } from "@/ee/bookings/2024-08-13/services/cal-video.service";
import { CreateEventTypeOutput_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/outputs/create-event-type.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";
@@ -79,7 +80,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 +1360,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 +2463,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 +2548,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 +2601,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();
@@ -3180,6 +3079,102 @@ describe("Bookings Endpoints 2024-08-13", () => {
);
}
describe("Meeting sessions", () => {
it("should get cal video sessions for a booking", async () => {
const mockSessions = [
{
id: "session-123",
room: "daily-room-123",
start_time: 1678901234,
duration: 3600,
ongoing: false,
max_participants: 10,
participants: [
{
user_id: "user-1",
participant_id: "participant-1",
user_name: "John Doe",
join_time: 1678901234,
duration: 3600,
},
],
},
];
const booking = await bookingsRepositoryFixture.create({
uid: `test-video-session-${randomString()}`,
title: "Test Video Session Booking",
description: "",
startTime: new Date(Date.UTC(2030, 0, 8, 10, 0, 0)),
endTime: new Date(Date.UTC(2030, 0, 8, 11, 0, 0)),
eventType: {
connect: {
id: eventTypeId,
},
},
user: {
connect: {
id: user.id,
},
},
references: {
create: [
{
type: "daily_video",
uid: `daily-room-123-${randomString()}`,
meetingId: "daily-room-123",
meetingPassword: "test-password",
meetingUrl: "https://daily.co/daily-room-123",
},
],
},
});
const calVideoService = app.get(CalVideoService);
jest
.spyOn(calVideoService, "getVideoSessions")
.mockResolvedValue([
{
id: mockSessions[0].id,
room: mockSessions[0].room,
startTime: mockSessions[0].start_time,
duration: mockSessions[0].duration,
ongoing: mockSessions[0].ongoing,
maxParticipants: mockSessions[0].max_participants,
participants: mockSessions[0].participants.map((p) => ({
userId: p.user_id,
userName: p.user_name,
joinTime: p.join_time,
duration: p.duration,
})),
},
]);
const response = await request(app.getHttpServer())
.get(`/v2/bookings/${booking.uid}/conferencing-sessions`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200);
expect(response.body.status).toEqual(SUCCESS_STATUS);
expect(response.body.data).toBeDefined();
expect(Array.isArray(response.body.data)).toBe(true);
expect(response.body.data.length).toBe(1);
expect(response.body.data[0].id).toEqual(mockSessions[0].id);
expect(response.body.data[0].room).toEqual(mockSessions[0].room);
expect(response.body.data[0].startTime).toEqual(mockSessions[0].start_time);
expect(response.body.data[0].duration).toEqual(mockSessions[0].duration);
expect(response.body.data[0].ongoing).toEqual(mockSessions[0].ongoing);
expect(response.body.data[0].maxParticipants).toEqual(mockSessions[0].max_participants);
expect(response.body.data[0].participants.length).toBe(1);
expect(response.body.data[0].participants[0].userId).toEqual(mockSessions[0].participants[0].user_id);
expect(response.body.data[0].participants[0].userName).toEqual(
mockSessions[0].participants[0].user_name
);
await bookingsRepositoryFixture.deleteById(booking.id);
});
});
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
@@ -0,0 +1,52 @@
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import {
Injectable,
CanActivate,
ExecutionContext,
ForbiddenException,
UnauthorizedException,
BadRequestException,
} from "@nestjs/common";
import { Request } from "express";
import { BookingAccessService } from "@calcom/platform-libraries";
@Injectable()
export class BookingPbacGuard implements CanActivate {
private bookingAccessService: BookingAccessService;
constructor(private readonly prismaReadService: PrismaReadService) {
this.bookingAccessService = new BookingAccessService(this.prismaReadService.prisma);
}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context
.switchToHttp()
.getRequest<Request & { user?: ApiAuthGuardUser; pbacAuthorizedRequest?: boolean }>();
const user = request.user;
const bookingUid = request.params.bookingUid;
if (!user) {
throw new UnauthorizedException();
}
if (!bookingUid) {
throw new BadRequestException("BookingPbacGuard - bookingUid is required");
}
const hasAccess = await this.bookingAccessService.doesUserIdHaveAccessToBooking({
userId: user.id,
bookingUid,
});
if (!hasAccess) {
throw new ForbiddenException(
`BookingPbacGuard - user with id=${user.id} does not have access to booking with uid=${bookingUid}`
);
}
request.pbacAuthorizedRequest = true;
return true;
}
}
@@ -0,0 +1,23 @@
import { Injectable } from "@nestjs/common";
import type { CalMeetingSession } from "@calcom/platform-libraries/conferencing";
@Injectable()
export class CalVideoOutputService {
getOutputVideoSessions(sessions: CalMeetingSession[]) {
return sessions.map((session) => ({
id: session.id,
room: session.room,
startTime: session.start_time,
duration: session.duration,
ongoing: session.ongoing,
maxParticipants: session.max_participants,
participants: session.participants.map((participant) => ({
userId: participant.user_id,
userName: participant.user_name,
joinTime: participant.join_time,
duration: participant.duration,
})),
}));
}
}
@@ -1,11 +1,13 @@
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output.service";
import { CalVideoOutputService } from "@/ee/bookings/2024-08-13/services/cal-video.output.service";
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
import { CAL_VIDEO_TYPE } from "@calcom/platform-constants";
import {
getRecordingsOfCalVideoByRoomName,
getAllTranscriptsAccessLinkFromRoomName,
getDownloadLinkOfCalVideoByRecordingId,
getCalVideoMeetingSessionsByRoomName,
} from "@calcom/platform-libraries/conferencing";
@Injectable()
@@ -13,18 +15,23 @@ export class CalVideoService {
private readonly logger = new Logger("CalVideoService");
constructor(
private readonly bookingsRepository: BookingsRepository_2024_08_13,
private readonly outputService: OutputBookingsService_2024_08_13
private readonly calVideoOutputService: CalVideoOutputService
) {}
private getVideoSessionsRoomName(references?: Array<{ type: string; meetingId?: string | null }>) {
return (
references?.filter((reference) => reference.type === CAL_VIDEO_TYPE)?.pop()?.meetingId ??
undefined
);
}
async getRecordings(bookingUid: string) {
const booking = await this.bookingsRepository.getByUidWithBookingReference(bookingUid);
if (!booking) {
throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`);
}
const roomName =
booking?.references?.filter((reference) => reference.type === "daily_video")?.pop()?.meetingId ??
undefined;
const roomName = this.getVideoSessionsRoomName(booking.references);
if (!roomName) {
throw new NotFoundException(`No Cal Video reference found with booking uid ${bookingUid}`);
}
@@ -68,10 +75,7 @@ export class CalVideoService {
throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`);
}
const roomName =
booking?.references?.filter((reference) => reference.type === "daily_video")?.pop()?.meetingId ??
undefined;
const roomName = this.getVideoSessionsRoomName(booking.references);
if (!roomName) {
throw new NotFoundException(`No Cal Video reference found with booking uid ${bookingUid}`);
}
@@ -80,4 +84,19 @@ export class CalVideoService {
return transcripts;
}
async getVideoSessions(bookingUid: string) {
const booking = await this.bookingsRepository.getByUidWithBookingReference(bookingUid);
if (!booking) {
throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`);
}
const roomName = this.getVideoSessionsRoomName(booking.references);
if (!roomName) {
throw new NotFoundException(`No Cal Video reference found with booking uid ${bookingUid}`);
}
const sessions = await getCalVideoMeetingSessionsByRoomName(roomName);
return this.calVideoOutputService.getOutputVideoSessions(sessions.data);
}
}
@@ -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,
@@ -252,7 +252,7 @@ export class InputEventTypesService_2024_06_14 {
requiresConfirmationWillBlockSlot:
confirmationPolicyTransformed?.requiresConfirmationWillBlockSlot ?? undefined,
eventTypeColor: this.transformInputEventTypeColor(color),
...this.transformInputSeatOptions(seats),
...(seats ? this.transformInputSeatOptions(seats) : { seatsPerTimeSlot: undefined }),
eventName: customName,
useEventTypeDestinationCalendarEmail: useDestinationCalendarEmail,
...maxActiveBookingsPerBooker,
@@ -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,
});
}
}
@@ -2,6 +2,7 @@ import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/reposito
import { BillingProcessor } from "@/modules/billing/billing.processor";
import { BillingRepository } from "@/modules/billing/billing.repository";
import { BillingController } from "@/modules/billing/controllers/billing.controller";
import { IsUserInBillingOrg } from "@/modules/billing/guards/is-user-in-billing-org";
import { BillingServiceCachingProxy } from "@/modules/billing/services/billing-service-caching-proxy";
import { BillingConfigService } from "@/modules/billing/services/billing.config.service";
import { BillingService } from "@/modules/billing/services/billing.service";
@@ -44,6 +45,7 @@ import { Module } from "@nestjs/common";
ManagedOrganizationsBillingService,
OAuthClientRepository,
BookingsRepository_2024_08_13,
IsUserInBillingOrg,
],
exports: [BillingService, BillingRepository, ManagedOrganizationsBillingService],
controllers: [BillingController],
@@ -17,7 +17,7 @@ import { OrganizationRepositoryFixture } from "test/fixtures/repository/organiza
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { randomString } from "test/utils/randomString";
import { withNextAuth } from "test/utils/withNextAuth";
import { withApiAuth } from "test/utils/withApiAuth";
import type { Team, PlatformBilling } from "@calcom/prisma/client";
@@ -32,8 +32,10 @@ describe("Platform Billing Controller (e2e)", () => {
let membershipsRepositoryFixture: MembershipRepositoryFixture;
let platformBillingRepositoryFixture: PlatformBillingRepositoryFixture;
let organization: Team;
let organization2: Team;
beforeAll(async () => {
const moduleRef = await withNextAuth(
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
@@ -49,6 +51,10 @@ describe("Platform Billing Controller (e2e)", () => {
name: `billing-organization-${randomString()}`,
isPlatform: true,
});
organization2 = await organizationsRepositoryFixture.create({
name: `billing-organization-2-${randomString()}`,
isPlatform: true,
});
user = await userRepositoryFixture.create({
email: userEmail,
@@ -133,7 +139,7 @@ describe("Platform Billing Controller (e2e)", () => {
});
});
it("/billing/webhook (GET) should get billing plan for org", () => {
it("/billing/:orgId/check (GET) should check billing plan for org", () => {
return request(app.getHttpServer())
.get(`/v2/billing/${organization.id}/check`)
.expect(200)
@@ -143,6 +149,10 @@ describe("Platform Billing Controller (e2e)", () => {
});
});
it("/billing/:organizationId/check (GET) should not be able to check other org plan", () => {
return request(app.getHttpServer()).get(`/v2/billing/${organization2.id}/check`).expect(403);
});
it("/billing/webhook (POST) failed payment should set billing free plan to overdue", () => {
jest.spyOn(StripeService.prototype, "getStripe").mockImplementation(
() =>
@@ -1,11 +1,13 @@
import { AppConfig } from "@/config/type";
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { ApiAuthGuardOnlyAllow } from "@/modules/auth/decorators/api-auth-guard-only-allow.decorator";
import { MembershipRoles } from "@/modules/auth/decorators/roles/membership-roles.decorator";
import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { OrganizationRolesGuard } from "@/modules/auth/guards/organization-roles/organization-roles.guard";
import { SubscribeToPlanInput } from "@/modules/billing/controllers/inputs/subscribe-to-plan.input";
import { CheckPlatformBillingResponseDto } from "@/modules/billing/controllers/outputs/CheckPlatformBillingResponse.dto";
import { SubscribeTeamToBillingResponseDto } from "@/modules/billing/controllers/outputs/SubscribeTeamToBillingResponse.dto";
import { IsUserInBillingOrg } from "@/modules/billing/guards/is-user-in-billing-org";
import { IBillingService } from "@/modules/billing/interfaces/billing-service.interface";
import { StripeService } from "@/modules/stripe/stripe.service";
import {
@@ -49,8 +51,9 @@ export class BillingController {
}
@Get("/:teamId/check")
@UseGuards(NextAuthGuard, OrganizationRolesGuard)
@UseGuards(ApiAuthGuard, OrganizationRolesGuard, IsUserInBillingOrg)
@MembershipRoles(["OWNER", "ADMIN", "MEMBER"])
@ApiAuthGuardOnlyAllow(["NEXT_AUTH"])
async checkTeamBilling(
@Param("teamId", ParseIntPipe) teamId: number
): Promise<ApiResponse<CheckPlatformBillingResponseDto>> {
@@ -66,8 +69,9 @@ export class BillingController {
}
@Post("/:teamId/subscribe")
@UseGuards(NextAuthGuard, OrganizationRolesGuard)
@UseGuards(ApiAuthGuard, OrganizationRolesGuard, IsUserInBillingOrg)
@MembershipRoles(["OWNER", "ADMIN"])
@ApiAuthGuardOnlyAllow(["NEXT_AUTH"])
async subscribeTeamToStripe(
@Param("teamId") teamId: number,
@Body() input: SubscribeToPlanInput
@@ -84,8 +88,9 @@ export class BillingController {
}
@Post("/:teamId/upgrade")
@UseGuards(NextAuthGuard, OrganizationRolesGuard)
@UseGuards(ApiAuthGuard, OrganizationRolesGuard, IsUserInBillingOrg)
@MembershipRoles(["OWNER", "ADMIN"])
@ApiAuthGuardOnlyAllow(["NEXT_AUTH"])
async upgradeTeamBillingInStripe(
@Param("teamId") teamId: number,
@Body() input: SubscribeToPlanInput
@@ -100,13 +105,12 @@ export class BillingController {
};
}
@Delete("/:organizationId/unsubscribe")
@UseGuards(NextAuthGuard, OrganizationRolesGuard)
@Delete("/:teamId/unsubscribe")
@UseGuards(ApiAuthGuard, OrganizationRolesGuard, IsUserInBillingOrg)
@MembershipRoles(["OWNER", "ADMIN"])
async cancelTeamSubscriptionInStripe(
@Param("organizationId") organizationId: number
): Promise<ApiResponse> {
await this.billingService.cancelTeamSubscription(organizationId);
@ApiAuthGuardOnlyAllow(["NEXT_AUTH"])
async cancelTeamSubscriptionInStripe(@Param("teamId") teamId: number): Promise<ApiResponse> {
await this.billingService.cancelTeamSubscription(teamId);
return {
status: "success",
@@ -0,0 +1,35 @@
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common";
import { Request } from "express";
import type { Team } from "@calcom/prisma/client";
@Injectable()
export class IsUserInBillingOrg implements CanActivate {
constructor(private organizationsRepository: OrganizationsRepository) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request & { team: Team }>();
const orgId: string = request.params.teamId;
const userId = (request.user as ApiAuthGuardUser).id;
if (!userId) {
throw new ForbiddenException("IsUserInBillingOrg - No user id found.");
}
if (!orgId) {
throw new ForbiddenException("IsUserInBillingOrg - No org id found in request params.");
}
const user = await this.organizationsRepository.findOrgUser(Number(orgId), Number(userId));
if (!user) {
throw new ForbiddenException(
`IsUserInBillingOrg - user with id=${userId} is not part of the organization with id=${orgId}.`
);
}
return true;
}
}
@@ -1230,6 +1230,70 @@ describe("Organizations Event Types Endpoints", () => {
});
});
it("should preserve seatsPerTimeSlot when doing partial update", async () => {
const createBody: CreateTeamEventTypeInput_2024_06_14 = {
title: "Coding consultation with seats",
slug: `organizations-event-types-seats-${randomString()}`,
description: "Our team will review your codebase.",
lengthInMinutes: 60,
locations: [
{
type: "integration",
integration: "cal-video",
},
],
schedulingType: "COLLECTIVE",
hosts: [
{
userId: teammate1.id,
},
],
seats: {
seatsPerTimeSlot: 5,
showAttendeeInfo: true,
showAvailabilityCount: true,
},
};
const createResponse = await request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/teams/${team.id}/event-types`)
.send(createBody)
.expect(201);
const createdEventType: TeamEventTypeOutput_2024_06_14 = createResponse.body.data;
const createdSeats = createdEventType.seats;
expect(createdSeats).toBeDefined();
expect(createdSeats && "seatsPerTimeSlot" in createdSeats).toBe(true);
if (createdSeats && "seatsPerTimeSlot" in createdSeats) {
expect(createdSeats.seatsPerTimeSlot).toEqual(5);
expect(createdSeats.showAttendeeInfo).toEqual(true);
expect(createdSeats.showAvailabilityCount).toEqual(true);
}
// Now do a partial update that only changes a different field (not seats)
const updateBody: UpdateTeamEventTypeInput_2024_06_14 = {
bookingRequiresAuthentication: false,
};
const updateResponse = await request(app.getHttpServer())
.patch(`/v2/organizations/${org.id}/teams/${team.id}/event-types/${createdEventType.id}`)
.send(updateBody)
.expect(200);
const updatedEventType: TeamEventTypeOutput_2024_06_14 = updateResponse.body.data;
// Verify that seatsPerTimeSlot is preserved and not reset to null
const updatedSeats = updatedEventType.seats;
expect(updatedSeats).toBeDefined();
expect(updatedSeats && "seatsPerTimeSlot" in updatedSeats).toBe(true);
if (updatedSeats && "seatsPerTimeSlot" in updatedSeats) {
expect(updatedSeats.seatsPerTimeSlot).toEqual(5);
expect(updatedSeats.showAttendeeInfo).toEqual(true);
expect(updatedSeats.showAvailabilityCount).toEqual(true);
}
expect(updatedEventType.bookingRequiresAuthentication).toEqual(false);
});
function evaluateHost(expected: Host, received: Host | undefined) {
expect(expected.userId).toEqual(received?.userId);
expect(expected.mandatory).toEqual(received?.mandatory);
@@ -188,7 +188,8 @@ export class SlotsController_2024_09_04 {
@DocsResponse({
status: 200,
description: `A map of available slots indexed by date, where each date is associated with an array of time slots. If format=range is specified, each slot will be an object with start and end properties denoting start and end of the slot.
For seated slots each object will have attendeesCount and bookingUid properties.`,
For seated slots each object will have attendeesCount and bookingUid properties.
If no slots are available, the data field will be an empty object {}.`,
schema: {
oneOf: [
{
@@ -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>
@@ -6,11 +6,10 @@ import { TeamsListing } from "@calcom/features/ee/teams/components/TeamsListing"
import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
import { TeamService } from "@calcom/features/ee/teams/services/teamService";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { ErrorWithCode } from "@calcom/lib/errors";
import prisma from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { TRPCError } from "@trpc/server";
import { TeamsCTA } from "./CTA";
const getCachedTeams = unstable_cache(
@@ -52,7 +51,7 @@ export const ServerTeamsListing = async ({
}
} catch (e) {
errorMsgFromInvite = "Error while fetching teams";
if (e instanceof TRPCError) errorMsgFromInvite = e.message;
if (e instanceof ErrorWithCode) errorMsgFromInvite = e.message;
}
}
@@ -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")}
@@ -7,6 +7,7 @@ import { APP_NAME } from "@calcom/lib/constants";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import { TeamInviteEmailView } from "~/onboarding/teams/invite/email/team-invite-email-view";
import { TeamInviteView } from "~/onboarding/teams/invite/team-invite-view";
export const generateMetadata = async () => {
@@ -26,13 +27,14 @@ const ServerPage = async () => {
return redirect("/auth/login");
}
if (session.user.role !== "ADMIN") {
return redirect("/onboarding/teams/invite/email");
}
const userEmail = session.user.email || "";
return <TeamInviteView userEmail={userEmail} />;
// If user is not ADMIN, show the email view directly instead of redirecting
if (session.user.role !== "ADMIN") {
return <TeamInviteEmailView userEmail={userEmail} />;
}
return <TeamInviteEmailView userEmail={userEmail} />;
};
export default ServerPage;
@@ -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,47 @@ 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" },
},
{
name: "guest_notifications",
href: "/settings/organizations/guest-notifications",
},
...(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 +146,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 +192,7 @@ export interface SettingsPermissions {
canViewRoles?: boolean;
canViewOrganizationBilling?: boolean;
canUpdateOrganization?: boolean;
canViewAttributes?: boolean;
}
const useTabs = ({
@@ -211,10 +223,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 +236,7 @@ const useTabs = ({
newArray.push({
name: "delegation_credential",
href: "/settings/organizations/delegation-credential",
trackingMetadata: { section: "organization", page: "delegation_credential" },
});
}
@@ -232,6 +246,7 @@ const useTabs = ({
newArray.push({
name: "roles_and_permissions",
href: "/settings/organizations/roles",
trackingMetadata: { section: "organization", page: "roles_and_permissions" },
});
}
@@ -239,6 +254,7 @@ const useTabs = ({
newArray.push({
name: "billing",
href: "/settings/organizations/billing",
trackingMetadata: { section: "organization", page: "billing" },
});
}
} else {
@@ -246,6 +262,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 +270,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 +362,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
/>
@@ -356,22 +375,26 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
const [teamMenuState, setTeamMenuState] =
useState<{ teamId: number | undefined; teamMenuOpen: boolean }[]>();
const searchParams = useCompatSearchParams();
const pathname = usePathname();
const searchParamsId = searchParams?.get("id");
const pathTeamId = pathname?.match(/\/settings\/teams\/(\d+)/)?.[1];
const activeTeamId = pathTeamId || searchParamsId;
useEffect(() => {
if (teams) {
const teamStates = teams?.map((team) => ({
teamId: team.id,
teamMenuOpen: String(team.id) === searchParamsId,
teamMenuOpen: String(team.id) === activeTeamId,
}));
setTeamMenuState(teamStates);
setTimeout(() => {
const tabMembers = Array.from(document.getElementsByTagName("a")).filter(
(bottom) => bottom.dataset.testid === "vertical-tab-Members"
)[1];
tabMembers?.scrollIntoView({ behavior: "smooth" });
}, 100);
if (activeTeamId) {
setTimeout(() => {
const teamTrigger = document.querySelector(`[aria-controls="team-content-${activeTeamId}"]`);
teamTrigger?.scrollIntoView({ behavior: "smooth", block: "center" });
}, 100);
}
}
}, [searchParamsId, teams]);
}, [activeTeamId, teams]);
return (
<>
@@ -419,9 +442,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 +451,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 +469,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 +494,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 +506,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 +517,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 +527,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 +606,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 +620,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 +628,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 +647,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 +697,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 +772,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 +783,7 @@ const SettingsSidebarContainer = ({
<Icon name="chevron-right" className="h-4 w-4" />
)}
</div>
{ }
{!otherTeam.parentId && (
<Image
src={getPlaceholderAvatar(otherTeam.logoUrl, otherTeam.name)}
@@ -766,20 +797,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 +845,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 +914,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 }}
/>
</>
);
@@ -0,0 +1,55 @@
import { _generateMetadata, getTranslate } from "app/_utils";
import { redirect } from "next/navigation";
import GuestNotificationsView from "@calcom/features/ee/organizations/pages/settings/guest-notifications";
import { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { MembershipRole } from "@calcom/prisma/enums";
import { validateUserHasOrg } from "../../actions/validateUserHasOrg";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("guest_notifications"),
(t) => t("guest_notifications_description"),
undefined,
undefined,
"/settings/organizations/guest-notifications"
);
const Page = async () => {
const session = await validateUserHasOrg();
const t = await getTranslate();
if (!session?.user.id || !session?.user.profile?.organizationId || !session?.user.org) {
return redirect("/settings/profile");
}
const { canRead, canEdit } = await getResourcePermissions({
userId: session.user.id,
teamId: session.user.profile.organizationId,
resource: Resource.Organization,
userRole: session.user.org.role,
fallbackRoles: {
read: {
roles: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER],
},
update: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
},
});
if (!canRead) {
return redirect("/settings/profile");
}
return (
<SettingsHeader title={t("guest_notifications")} description={t("guest_notifications_description")}>
<GuestNotificationsView permissions={{ canRead, canEdit }} />
</SettingsHeader>
);
};
export default Page;
@@ -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>
@@ -5,13 +5,11 @@ 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 { getSpecificPermissions } from "@calcom/features/pbac/lib/resource-permissions";
import { getTeamMemberPermissions } from "@calcom/features/pbac/lib/team-member-permissions";
import { RoleManagementFactory } from "@calcom/features/pbac/services/role-management.factory";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { PrismaAttributeRepository } from "@calcom/lib/server/repository/PrismaAttributeRepository";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { viewerTeamsRouter } from "@calcom/trpc/server/routers/viewer/teams/_router";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
@@ -33,7 +31,7 @@ const getCachedTeamRoles = unstable_cache(
try {
const roleManager = await RoleManagementFactory.getInstance().createRoleManager(organizationId);
return await roleManager.getTeamRoles(teamId);
} catch (error) {
} catch {
// PBAC not enabled or error occurred, return empty array
return [];
}
@@ -49,7 +47,7 @@ const getCachedTeamAttributes = unstable_cache(
try {
return await attributeRepo.findAllByOrgIdWithOptions({ orgId: organizationId });
} catch (error) {
} catch {
return [];
}
},
@@ -79,65 +77,15 @@ const Page = async ({ params }: { params: Promise<{ id: string }> }) => {
const organizationId = team.parentId || teamId;
// Load PBAC roles and attributes if available
const [roles, attributes] = await Promise.all([
const [roles, attributes, memberPermissions] = await Promise.all([
getCachedTeamRoles(teamId, organizationId),
getCachedTeamAttributes(organizationId),
getTeamMemberPermissions({
userId: session.user.id,
team,
}),
]);
const fallbackRolesCanListMembers: MembershipRole[] = [MembershipRole.ADMIN, MembershipRole.OWNER];
// If the team is not private we allow members to list other members
if (!team.isPrivate) {
fallbackRolesCanListMembers.push(MembershipRole.MEMBER);
}
// Get specific PBAC permissions for team member actions
const permissions = await getSpecificPermissions({
userId: session.user.id,
teamId: teamId,
resource: Resource.Team,
userRole: team.membership.role,
actions: [
CustomAction.Invite,
CustomAction.ChangeMemberRole,
CustomAction.Remove,
CustomAction.ListMembers,
CustomAction.ListMembersPrivate,
CustomAction.Impersonate,
],
fallbackRoles: {
[CustomAction.Invite]: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
[CustomAction.ChangeMemberRole]: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
[CustomAction.Remove]: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
[CustomAction.ListMembers]: {
roles: fallbackRolesCanListMembers,
},
[CustomAction.Impersonate]: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
[CustomAction.ListMembersPrivate]: {
roles: fallbackRolesCanListMembers,
},
},
});
// Map specific permissions to member actions
const memberPermissions = {
canListMembers: team.isPrivate
? permissions[CustomAction.ListMembersPrivate]
: permissions[CustomAction.ListMembers],
canInvite: permissions[CustomAction.Invite],
canChangeMemberRole: permissions[CustomAction.ChangeMemberRole],
canRemove: permissions[CustomAction.Remove],
canImpersonate: permissions[CustomAction.Impersonate],
};
const facetedTeamValues = {
roles,
teams: [team],
@@ -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);
+4 -2
View File
@@ -113,8 +113,10 @@ async function getHandler(req: NextRequest) {
const isOnboarding = checkoutSessionMetadata.isOnboarding === "true";
if (isOnboarding) {
// Redirect to event-types for onboarding flow
return NextResponse.redirect(new URL("/onboarding/personal/settings", WEBAPP_URL), {
// Redirect to invite flow after payment for onboarding with teamId as query param
const inviteUrl = new URL("/onboarding/teams/invite", WEBAPP_URL);
inviteUrl.searchParams.set("teamId", team.id.toString());
return NextResponse.redirect(inviteUrl, {
status: 302,
});
}
+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",
+17 -15
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,7 +117,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
<head nonce={nonce}>
<style>{`
:root {
--font-inter: ${interFont.style.fontFamily.replace(/\'/g, "")};
--font-sans: ${interFont.style.fontFamily.replace(/\'/g, "")};
--font-cal: ${calFont.style.fontFamily.replace(/\'/g, "")};
}
`}</style>
@@ -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")}
+11 -2
View File
@@ -1,3 +1,12 @@
/**
* PAGES ROUTER ONLY - Used exclusively by Next.js Pages Router
*
* Currently only serves the /router endpoint (routing forms redirect page).
* DO NOT add new features here - this file will be deprecated once we remove apps/web/pages.
*
* For App Router, use PageWrapperAppDir.tsx instead.
*/
"use client";
import { DefaultSeo } from "next-seo";
@@ -24,7 +33,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 +89,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>
@@ -46,7 +46,6 @@ function PageWrapper(props: PageWrapperProps) {
id="page-status"
// It is strictly not necessary to disable, but in a future update of react/no-danger this will error.
// And we don't want it to error here anyways
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: `window.CalComPageStatus = '${pageStatus}'` }}
/>
{props.requiresLicense ? (
+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"

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