Compare commits

..
56 Commits
Author SHA1 Message Date
emrysal 528b27f31e chore: release v5.8.13
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-14 02:32:38 +00:00
Alex van Andel 4282cd221c fix: NextJS does not support non-ascii in NextResponse headers (#25148)
* fix: NextJS does not support non-ascii in NextResponse headers

* Fixed type error
2025-11-14 01:27:17 +00:00
Dhairyashil ShindeandPallav 38738c1a3a fix: learn more link missing in add question (#25104)
Co-authored-by: Pallav <90088723+Pallava-Joshi@users.noreply.github.com>
2025-11-14 04:57:31 +05:30
Alex van Andel b5dcc7a991 fix: change edge runtime to prevent weird header override in unkey sdk (#25140) 2025-11-13 22:47:07 +00:00
Joe Au-YeungandDevin AI 5ceda11a0b fix: add 'use client' to routing-forms components to resolve React error 185 (#25107)
Adds 'use client' directive to FormInputFields.tsx and widgets.tsx in the
routing-forms package. These components use React hooks (useLocale, setState)
but were missing the client directive, causing React error 185 (invalid hook
call) in production builds due to Next.js App Router bundling differences.

The error only occurred in production, not local dev, because production
builds optimize and bundle packages differently. The widgets.tsx file uses
the useLocale hook in the Button component, and FormInputFields.tsx passes
state setters to child components.

Fixes the reroute dialog error reported in production.

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-11-13 18:36:46 +00:00
787828d0ca feat: Toggle auto adding users to an org if they signup without an invite (#25051)
* Remove auto adding users to an org

* Update tests

* Fix tests

* fix: Update organization invitation E2E tests to not expect auto-accept before signup

- Changed isMemberShipAccepted expectations from true to false before signup
- Users with emails matching orgAutoAcceptEmail are no longer auto-accepted
- They must explicitly accept the invitation after signup
- Fixed lint warnings for unused parameters

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

* fix: Update E2E tests to expect pending membership after signup without auto-accept

Since auto-accept functionality was removed, users with emails matching
orgAutoAcceptEmail are no longer automatically accepted into organizations
after signup. They remain in pending state until explicitly accepted.

Updated assertions in:
- 'nonexisting user is invited to Org' test
- 'nonexisting user is invited to a team inside organization' test

Both tests now correctly expect isMemberShipAccepted: false after signup.

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

* Restore `verify-email` and tests from `main`

* Add `orgAutoJoinOnSignup` to `organizationSettings`

* Update
`OrganizationRepository.findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail`
to find orgs where `orgAutoJoinOnSignup` is true

* `organization.update` lint fix

* `organization.update` to handle `orgAutoJoinOnSignup`

* Create toggle for `orgAutoJoinOnSignup`

* test: Add comprehensive tests for orgAutoJoinOnSignup functionality

- Update existing test to expect null instead of error when multiple orgs match
- Add test for when orgAutoJoinOnSignup is false (should return null)
- Add test for when orgAutoJoinOnSignup is true (should return org)
- Add test for default behavior (orgAutoJoinOnSignup defaults to true)

These tests verify that the new orgAutoJoinOnSignup setting correctly controls
whether users are automatically added to organizations during email verification.

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

* Type fix

* e2e: invited users should be accepted after signup (address cubic r2511916791)

Reverted post-signup isMemberShipAccepted assertions from false to true for
explicit invite scenarios. When users are explicitly invited to an org/team
and complete signup via invite link, their membership should be accepted.

This is distinct from auto-join by domain (controlled by orgAutoJoinOnSignup),
which only affects users who sign up without an invite but match the org's
email domain.

Backend sets membership.accepted = true on invite completion in:
packages/features/auth/signup/utils/createOrUpdateMemberships.ts:61,67,77,83

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

* Fix API V2 build

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-11-13 15:13:10 -03:00
89230474a5 feat: bookings page redesign v3 with calendar view (#24664)
* bookings page redesign work in progress

fix: duplicate translation key

chore: use newly supported separator type

remove outdated BookingDetailsSheet

remove dropdown and related code

revert unncessary changes

* fix wrong rebase

* fix type error

* refactor: separate bookings columns into filter and display columns (#24959)

* refactor: separate bookings columns into filter and display columns

- Extract filter-only columns into shared filterColumns.ts
- Extract list display columns into listColumns.tsx
- Create BookingsListContainer for list view with both column sets
- Create BookingsCalendarContainer for calendar view with filter columns only
- Refactor bookings-view.tsx to use dynamic imports for containers
- Remove column/table creation logic from bookings-view.tsx

This ensures calendar view doesn't import list-specific UI components (AvatarGroup, Badge, etc.)

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

* fix: return null instead of false for separator rows in filter accessors

The filter accessor functions were returning false for separator rows instead of null,
which would pollute the multi-select filters with bogus 'false' values.

This fix ensures that separator rows return null so they are properly excluded from filters.

Addresses cubic AI reviewer feedback on PR #24959

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

* clean up filter column visibility

* feat: integrate booking calendar view with re-designed list (#24973)

* add toggle button

* remove the dateRange filter when switching from calendar to list view

* move "view" to the action dropdown

* add close button the details sheet

* move close button

* fix more button behavior

---------

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

* fix type error

* update the test case

* fix e2e util

* fix actions dropdown

* revert e2e tests

* fix type error on BookingActionsDropdown.tsx

* fix: include today's bookings in flatData for past status

Previously, flatData excluded groupedBookings.today, which caused
past bookings that happened today to not show up when status === 'past'.
This fix includes today's bookings in flatData for all statuses.

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

* improve attendee cell

* fix e2e tests

* change max

* fix e2e

* add reschedule requested message

* fix e2e

* update e2e

* remove flaky checks

---------

Co-authored-by: Eunjae Lee <hey@eunjae.dev>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-11-13 16:20:48 +00:00
d7fc11df5e feat: use form responses as workflow variables (#24716)
* remove add variable dropdown

* feat: lang support

* fix: type errors

* feat: select voice agent

* refactor: address feedback

* refactor: address feedback

* refactor: missing import

* fix: types

* add getAllWorkflowsFromRoutingForm to WorkflowService

* fix error caused by undefined evt

* fix type error

* fix type error

* fix tests

* feat: add inbound calls

* chore: formatting

* chore

* feat: finish inbound call

* chore: formatting

* fix: update bug

* fix: types

* code clean up

* final fixes and clean up

* remove console.log

* remove template text form from triggers

* add routing form repoditory function

* refactor: Agent Configuration Sheet (#23930)

* refactor: agent configuration sheet

* chore: use default phone numbre

* refactor: improvements

* refactor: improvements

* fix: types

* fix: feedback

* fix bug with key

* chore:

* fix: feedback

* fix: prompt

* add comments

* fix: review

* fix: review

* refactor: class

* refactor: class

* fix test

* allow cal ai action on form triggers

* move any reusable code to scheduleAIPhoneCall

* add missing await

* use predefined FormSubmissionData type

* add .trim() to sms message

* pass contextData instead

* finish base setup

* add missing trigger in update-workflow.input.ts

* allow cal.ai action for form triggers in handler

* chore: add support for form workflows on api v2

* fixup! chore: add support for form workflows on api v2

* ai phone call on form submissions (WIP)

* use existing type for Option array

* pass chosen event type id

* refactor: rename

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

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

* add missing imports

* chore: update set value

* fix: remove index

* fix: type error

* fix: update tetss

* use only repository functions in update handler

* move all prisma queries from list.handler

* review suggestions

* fix: use logger

* chore: handle workflows api v2

* chore: handle workflows api v2, split in 2 endpoints

* fix workflow step creation

* remove connect agent and fixes types

* add type to workflow

* chore: use workflow type in apiv2 WorkflowsOutputService

* update worklfow type on update

* chore: use workflow type in apiv2 WorkflowsOutputService

* fix template body for torm trigger

* some UI fixes for email subject/body

* resetting email body when changing form triggers

* use type field to query workflows

* clean up all old active on values

* remove responseId from all funciton calls

* remove undefined from updateTemplate

* refactor: don't use static

* fix: type

* refactor: split routing form and event-type workflows code

* refactor: split routing form and event-type workflows code

* fix template  text when adding action

* chore: don't rename WorkflowActivationDto to avoid ci blocking

* refine update schedule to use only allowed actions

* fix type error

* don't allow whatsapp action with form trigger

* fix type error

* return early if activeOn array is empty

* fix: from step type in BaseFormWorkflowStepDto

* fixup! fix: from step type in BaseFormWorkflowStepDto

* api v2 updates

* move all prisma calls to repository (service/workflows.ts)

* use FORM_TRIGGER_WORKFLOW_EVENTS for form queries

* use userRepository

* use FORM_TRIGGER_WORKFLOW_EVENTS in isFormTrigger

* code clean up

* code clean up

* use repository functions in formSubmissionValidation.ts

* fix: schema

* refactor:

* remove action check in update handler

* add event type selection

* event type selector improvements

* adjust update.handler

* set outboundEventTypeId

* add back trpc  import

* fix agent repository functions

* clean up

* fix bugs caused by merge

* pass eventTypeId to updateToolsFromAgentId

* add migration for outboundEventTypeId

* add SMS actions to allowed form action constants

* add cal ai to allowed form actions

* pick correct event type for web call

* pass correct routed event type id

* remove unsued import

* fixes for offset api v2

* add missing responseId

* fix failing test

* fix failing test

* improve error message

* remove unused imports

* chore: handle sms step action for form worklfow in dtos

* fix typo

* missing missing newStep

* minor fixes

* remove changes

* add routedEventTypeId

* fix type error

* fix type error

* fix typ error in executAPIPhoneCall.tsx

* add back inboundEventTypeId

* remove console.log

* remove outdated code

* small fixes

* don't throw error for missing phone number

* add  back filtered triggerOptions

* fix eventTypeId in testCall handler

* fix type error

* update migration

* fix trigger is not defined

* convert eventTypeId to string

* only use outboundEventTypeId for FORM_SUBMITTED trigger

* show toast when no event type selected

* fix type errors

* add missing translation

* fix type error

* remove callType

* fix tests

* small fixes

* clean up AgentConfigurationSheet

* remove EventTypeSelector file

* code clean up

* clean up

* clean up

* use resusable function for TestPhoneCallDialog and WebCallDialog

* rename result

* fix types for event type id

* use repository runction in workflowReminder.ts

* fix type error

* pass eventTypeIds correctly

* fix typo

* custom variables from form responses

* remove comment

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

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>

* use watch instead of getValues

* change to  z.record(z.unknown()) instead of any()

* fix type of eventTypeId

* check permissinon for outBoundEventTypeId

* add isNaN check

* improve function name

* add tests

* fixes for custom variables

* improve test

* update tools when outbound agent event type id changes

* handle undefined eventDate in customTemplate

* add info how to use form responses as variables

* rename responses to routingFormResponses

* remove cal ai from allowed steps api v2

* remove old migration file

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Udit Takkar <udit222001@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Benny Joo <sldisek783@gmail.com>
Co-authored-by: cal.com <morgan@cal.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2025-11-13 15:34:44 +00:00
Udit TakkarandHariom Balhara 1ff5d57ded feat: distributed tracing - 2 (#24861)
* feat: distributed tracing 2

* feat: distributed tracing 2

* refactor: feedback

* refactor: feedback

* fix: type error

* fix: trpc error

---------

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2025-11-13 11:51:54 +00:00
sean-brydon 19641ffc05 feat: organization v3 redesign onboarding (#24967)
## What does this PR do?

- Redesigns the organization onboarding flow by merging the brand and details pages
- Improves the organization details page with a scrollable interface and visual previews
- Adds a new organization-specific browser preview component

## Visual Demo (For contributors especially)

#### Image Demo:
- The PR replaces the separate brand page with an integrated details page that includes logo and banner uploads
- The new organization browser view shows a preview of the organization profile with the selected branding

## Mandatory Tasks (DO NOT REMOVE)

- [ ] I have self-reviewed the code.
- [ ] I have updated the developer docs in /docs if this PR makes changes that would require a documentation change. 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?

- Go through the organization onboarding flow
- Test uploading logos and banners
- Verify that the organization browser preview updates in real-time with the form inputs
- Confirm that the form validation works correctly for organization name and slug
- Check that the scrollable interface works properly with fade effects at top and bottom

## Checklist

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

















































<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Split organization brand from the details step and added live previews for organizations and teams. Revamped org/team invites with reusable components, a dedicated email-invite page, and a CSV upload modal.

- **New Features**
  - Separate Brand step with logo, banner, and color; instant org preview via OnboardingOrganizationBrowserView.
  - Teams browser preview added; invites include email substep (/onboarding/organization/invite/email, /onboarding/teams/invite/email), CSV upload (template + parsing), and Google Workspace (behind flag).
  - Shared components (EmailInviteForm, InviteOptions, RoleSelector) used across org and team invites.

- **Refactors**
  - Updated org flow: Details → Brand → Teams → Invites; OnboardingLayout now supports dynamic step counts (org=4, team=3, personal=2).
  - UI polish (OnboardingCard header padding) and org-specific previews now replace generic views across details/brand/invites/teams; ensured org welcome modal takes precedence over personal.

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

<!-- End of auto-generated description by cubic. -->
2025-11-13 10:45:42 +00:00
Eunjae LeeandDevin AI ea80e8c8d4 fix: enable webhook form submit button when all required fields are filled (#25109)
* fix: enable webhook form submit button when all required fields are filled

The submit button was disabled even when all fields were filled because
the form relied on isDirty state. Since eventTriggers are pre-populated
with default values, the form never became dirty until the user manually
changed the triggers.

This fix adds validation logic that:
- For new webhooks: checks if required fields (URL, triggers) are filled
- For editing webhooks: preserves the existing isDirty behavior
- Handles the conditional time/timeUnit requirement for no-show triggers

Fixes the issue where users had to manually change event triggers to
enable the submit button even though all required information was filled.

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

* refactor: extract webhook form validation logic to computed variables

Replaced IIFE in disabled prop with clean computed variables:
- Extracted all watch() calls to the top of the component
- Computed validation logic as clear, named variables
- Reused needsTime for showTimeSection to avoid duplicate watch calls
- Simplified button disabled prop to just !canSubmit

This improves code readability and maintainability while preserving
the same validation behavior.

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

* fix: resolve type error by moving canSubmit computation after changeSecret declaration

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

* fix: auto-initialize time/timeUnit for webhooks with no-show triggers

When creating a webhook with default event triggers that include no-show
triggers (AFTER_HOSTS_CAL_VIDEO_NO_SHOW or AFTER_GUESTS_CAL_VIDEO_NO_SHOW),
the time and timeUnit fields are now automatically initialized to default
values (5 minutes). This ensures the submit button is enabled when all
required fields are filled, fixing E2E test failures in webhook.e2e.ts.

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-11-13 10:32:17 +00:00
Benny JooandDevin AI cba0fa8551 chore: Clean up billing-related redundant files (#25060)
* remove billing service factory

* remove index file

* update internal team billing

* update test

* fix unit test

* fix: update internal-team-billing tests to use direct StripeBillingService mocking

- Changed mock path from @calcom/features/ee/billing/stripe-billing-service to ../stripe-billing-service to match actual import
- Used vi.hoisted() to ensure mock functions are available in hoisted mock factory context
- Changed vi.resetAllMocks() to vi.clearAllMocks() and re-apply mock implementation in beforeEach to preserve constructor mock
- Removed obsolete vi.mock("..") for deleted billing singleton module
- Fixed test pollution by creating new instances instead of mutating shared state
- Removed describe-level instance creation that was executed before mocks were set up
- All 12 tests now pass successfully

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-11-13 09:17:36 +00:00
Alex van Andel 530cb94f0b fix: make identifier url safe 2 (#25112)
* fix: Make identifier for rate limit URL safe

* fix: Make identifier for rate limit URL safe using hasing
2025-11-13 01:42:13 +00:00
Alex van Andel d4ef6e83cf fix: Make identifier for rate limit URL safe (#25111) 2025-11-13 01:20:05 +00:00
Alex van Andel cddd8d7d9d Revert "chore: Rate limit top level of public booking pages (#25055)" (#25086)
This reverts commit e239b67171.
2025-11-12 22:13:50 +00:00
Alex van AndelandVolnei Munhoz 1d6959c4ac chore: system wide ratelimit per path (#25080)
* chore: Add system wide rate limiting

* Handle and convert HttpError to 429

* Make algo 32-bit to prevent <ES2020 error + fix sms manager unit test

* Change core to common to go from 10 requests per minute to 200

* Remove redundant function

* Fix integration tests

* Make sure we allow all legal POST routes

* Allow tRPC post calls

* Add matcher tests on middleware

* Add matcher tests on middleware

* Fix matcher to not use regex

* Fix missing POST allow rule for /api/auth/callback/credentials

* Missed the api/book/event endpoints

* Add missing pages/api routes

* Remove POST middleware for now, very risky

* Remove tests for POST protection

---------

Co-authored-by: Volnei Munhoz <volnei.munhoz@gmail.com>
2025-11-12 22:01:59 +00:00
Anik Dhabal BabuandDevin AI a3fc17bc75 fix: add team installation support for HitPay payment integration (#24738)
- Add teamId support to HitPay add.ts installation handler
- Update callback.ts to fetch credentials by teamId for team bookings
- Update webhook.ts to fetch credentials by teamId for team bookings
- Update setup page to accept and handle teamId from query params
- Add permission checks using throwIfNotHaveAdminAccessToTeam
- Ensure credentials are created with teamId for team installs, userId for user installs

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-11-12 21:58:17 +05:30
ad4b5957fc refactor: optimize credit-service imports with lazy loading (#25091)
* refactor: optimize credit-service imports with lazy loading

- Remove top-level imports of heavy modules (reminderScheduler, email services, i18n, billing services)
- Implement dynamic imports for modules only when needed:
  - reminderScheduler: loaded only when SMS credit limit reached
  - email services: loaded only when sending credit notifications
  - getTranslation: loaded only when handling low credit balance
  - InternalTeamBilling: loaded only in getMonthlyCredits method
  - billing singleton: loaded only when calculating warning limits
- Break circular dependency: credit-service → reminderScheduler → ... → credit-service
- Update tests to mock StripeBillingService for dynamic imports
- All 30 tests passing, no type errors, lint clean

This reduces baseline import cost by deferring:
- Stripe SDK initialization (loaded twice before)
- 557KB+ i18n English translation file
- Email template classes
- Workflow reminder scheduler

Verified with madge: circular dependency successfully resolved

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

* fix: add null checks for billing.getPrice() return value

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-11-13 00:40:51 +09:00
a7cb71726a fix: hide duplicate phone field when attendee phone location selected (#23118)
* fix: hide duplicate phone field when attendee phone location selected

* Instead of hiding use autofill the value to all other phone fields

* add e2e test

* add not sync test and use changeHandler instead of useEffect

* address cubics comments

* adding the phone check back

* use zod schema instead of this type casting

* use Enum instead of hardcoded string for phone

* Fix e2e test

* Fix e2e tests

* Delete .retracify.html

---------

Co-authored-by: Eunjae Lee <hey@eunjae.dev>
Co-authored-by: Volnei Munhoz <volnei.munhoz@gmail.com>
Co-authored-by: Volnei Munhoz <volnei@cal.com>
2025-11-12 13:54:36 +00:00
chauhan_s 3e7a848769 refactor: extract back button logic into dedicated wrapper component (#25093)
* refactor: extract back button logic into dedicated wrapper component

* Added changeset
2025-11-12 11:19:35 +00:00
sean-brydon e6e67848b3 fix: Polish, fixes, and i18n updates for onboarding (#24949)
## What does this PR do?

<!-- Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. -->

- Fixes #XXXX (GitHub issue number)
- Fixes CAL-XXXX (Linear issue number - should be visible at the bottom of the GitHub issue description)

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








































<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Polished the onboarding experience with a live calendar preview, a team bio field, and improved username handling. Updated i18n strings and added responsive breakpoints for large screens.

- **New Features**
  - Added right-column previews: OnboardingBrowserView in org teams and a live weekly OnboardingCalendarBrowserView for personal calendar setup.
  - Introduced team bio in onboarding store and UI.
  - Expanded i18n: team bio, browser view labels, and CSV invite flow.
  - Added Tailwind screens for 3xl and 4xl to improve large-display layouts.

- **Bug Fixes**
  - Normalized usernames with slugify on default and change to prevent invalid slugs.
  - Unified disabled/readonly handling for premium vs. standard username fields; respects external disabled prop and org membership.

<sup>Written for commit 7804fad66ee516b25e22fc0d9239f3d29249189a. Summary will update automatically on new commits.</sup>

<!-- End of auto-generated description by cubic. -->
2025-11-12 10:07:06 +00:00
Lauris Skraucis bf42cd4de9 fix: v2 get event type by id return type (#25090) 2025-11-12 09:26:07 +00:00
chauhan_s 6af7f8b0db fix: add recurringEventCount to URL params (#24986)
* fix: validate occurrence count from URL params and refactor validation logic
- Initialize occurrence count from URL query parameter on page load

* fix: change query parameter name from occurenceCount to recurringEventCount

* refactor: simplify occurrence count validation logic

* fix: prevent NaN from being set in recurring event count query parameter

* fix: prevent overlay calendar toggle from overwriting query params

* fix: sync occurrence count state with max occurrences limit

* refactor: rename occurenceCount to recurringEventCountQueryParam

* fix: prevent invalid recurring event count from updating state

- Added validation guard to only update state when recurringEventCountQueryParam is valid (not null or NaN)
- Simplified URL update logic by removing unnecessary empty string fallback

* feat: add recurring event count badge for mobile (#24991)

* feat: display recurring event count badge in mobile

* feat: update recurring event translation key for clarity
2025-11-12 09:12:49 +00:00
Benny JooandMorgan 421d03eb1a import stripe billing service dynamically in credit-service (#25058)
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2025-11-12 10:18:16 +02:00
mintlify[bot] 9d39742802 Add API v1 deprecation banners (#25065)
* Update docs/api-reference/v1/introduction.mdx

* Update docs/api-reference/v1/authentication.mdx

* Update docs/api-reference/v1/errors.mdx

* Update docs/api-reference/v1/rate-limit.mdx

* Update docs/mint.json

---------

Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2025-11-12 04:07:28 +00:00
Joe Au-Yeung 5267f8c3c1 Escape special characters in email query (#25079) 2025-11-11 23:56:51 +00:00
Alex van Andel c6ec42e735 chore: Add diagnostics tool for confirming visitor IP (#25083) 2025-11-11 23:56:36 +00:00
Hariom BalharaandVolnei Munhoz 1325e8488f feat: Reduce webhook response payload to status-only (#25072)
* feat: reduce webhook payload

* Update packages/trpc/server/routers/viewer/webhook/testTrigger.handler.ts

Co-authored-by: Volnei Munhoz <volnei@cal.com>

---------

Co-authored-by: Volnei Munhoz <volnei@cal.com>
2025-11-11 17:18:05 +00:00
Dhairyashil ShindeandUdit Takkar d50a7b9d00 feat: custom-labels-for-attendee-location-fields (#25070)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2025-11-11 17:17:35 +00:00
Anik Dhabal Babu 452729f4ab fix: skip email verification for reschedules (#25061) 2025-11-11 16:52:11 +00:00
0206b364ee feat: add tooltip to Group Options label in attributes form (#25063)
* feat: add tooltip to Group Options label in attributes form

- Add InfoBadge component to Group Options label with descriptive tooltip
- Add translation keys 'group_options' and 'group_options_description' to common.json
- Update label to use flex layout for proper InfoBadge alignment
- Tooltip explains that group options create logical groupings of regular options

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

* fix: wrap AttributeForm tests with TooltipProvider

- Add Tooltip.Provider wrapper from @radix-ui/react-tooltip to test renders
- Fixes failing tests caused by InfoBadge component requiring tooltip context
- All AttributeForm tests now pass locally

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2025-11-11 16:44:14 +00:00
Eunjae Lee 86353191d2 feat: add booking actions into BookingDetailsSheet (#24912)
* move booking actions into a subfolder

* fix logic to render booking actions dropdown

* skip dropdown portal when rendered from detail sheet

* fix type error
2025-11-11 16:14:48 +00:00
spandev 38f96d97d3 fix: added empty screen for routing response results table (#25076) 2025-11-11 15:15:06 +00:00
e6d44ce620 feat: Add delegation credential error webhook trigger (#24871)
* feat: add delegation credential error webhook trigger

- Add DELEGATION_CREDENTIAL_ERROR to WebhookTriggerEvents enum
- Create DelegationCredentialErrorDTO type for webhook payload
- Implement DelegationCredentialErrorWebhookService
- Add translation for delegation_credential_error
- Enable webhook for API v2 organization webhooks

This webhook will send delegation credential error data to configured URLs when errors occur during calendar authentication with delegation credentials.

Co-Authored-By: rajiv@cal.com <sahalrajiv6900@gmail.com>

* fix: add delegation credential error payload type and type guards

- Add DelegationCredentialErrorPayloadType to sendPayload.ts
- Update WebhookPayloadType union to include new payload type
- Add isDelegationCredentialErrorPayload type guard function
- Update isEventPayload to exclude delegation credential errors
- Update template application logic to handle new payload type
- Add corresponding payload type to dto/types.ts for consistency

Co-Authored-By: rajiv@cal.com <sahalrajiv6900@gmail.com>

* feat: add delegation credential error handling to WebhookNotificationHandler

- Add DELEGATION_CREDENTIAL_ERROR case to createPayload switch
- Return payload with error, credential, and user data
- Ensures exhaustive type checking passes for new trigger

Co-Authored-By: rajiv@cal.com <sahalrajiv6900@gmail.com>

* feat: restrict DELEGATION_CREDENTIAL_ERROR to organization webhooks only

- Add validation in UserWebhooksService to reject DELEGATION_CREDENTIAL_ERROR
- Add validation in EventTypeWebhooksService to reject DELEGATION_CREDENTIAL_ERROR
- Ensures trigger is only available for API v2 organization webhooks as requested

Co-Authored-By: rajiv@cal.com <sahalrajiv6900@gmail.com>

* feat: wire up delegation credential error webhook emission in calendar services

- Add webhook emission calls in CalendarAuth.ts for Google Calendar delegation errors
- Add webhook emission calls in Office365 CalendarService.ts for Azure AD delegation errors
- Implement actual webhook emission using WebhookRepository pattern
- Fix pre-existing lint warnings in Office365 CalendarService.ts (unused catch variables, unsafe optional chaining)

Co-Authored-By: rajiv@cal.com <sahalrajiv6900@gmail.com>

* test: add e2e tests for DELEGATION_CREDENTIAL_ERROR webhook trigger

- Add comprehensive e2e tests for creating, retrieving, updating, and deleting webhooks with DELEGATION_CREDENTIAL_ERROR trigger
- Test combining DELEGATION_CREDENTIAL_ERROR with other triggers
- Fix import in triggerDelegationCredentialErrorWebhook.ts to use default import for sendPayload
- Tests follow existing patterns in organizations-webhooks.e2e-spec.ts

Co-Authored-By: rajiv@cal.com <sahalrajiv6900@gmail.com>

* refactor: remove name field from webhook payload and delete unused service

- Remove name field from triggerDelegationCredentialErrorWebhook function signature and payload
- Update all call sites in GoogleCalendar and Office365 calendar services
- Update DelegationCredentialErrorDTO and DelegationCredentialErrorPayloadType to remove name field
- Delete unused DelegationCredentialErrorWebhookService.ts (dead code - not used anywhere)
- The helper function approach is more appropriate for app-store integrations without DI

Co-Authored-By: rajiv@cal.com <sahalrajiv6900@gmail.com>

* refactor: make fire-and-forget intent explicit for webhook emission

- Add void cast to all triggerDelegationCredentialErrorWebhook calls
- Remove redundant .catch() handlers (helper already handles errors internally)
- This makes it clear that webhook emission is non-blocking by design
- Avoids delaying error propagation while webhook HTTP requests complete

Co-Authored-By: rajiv@cal.com <sahalrajiv6900@gmail.com>

* feat: await webhook emission and add HTTP timeout for guaranteed delivery

- Change all triggerDelegationCredentialErrorWebhook calls from void to await
- Add 10-second timeout to webhook HTTP requests using AbortController
- Remove name field from DelegationCredentialErrorPayloadType to match payload
- Ensures webhooks are sent before error is thrown (per user requirement)
- Prevents indefinite hangs on unresponsive webhook endpoints

Co-Authored-By: rajiv@cal.com <sahalrajiv6900@gmail.com>

* fix: make getAuthUrl async and await all call sites

- Make getAuthUrl async to support awaiting webhook emission
- Add await to all 3 getAuthUrl call sites (constructor, getAzureUserId, testDelegationCredentialSetup)
- Remove leftover name field from getAzureUserId webhook call
- Fixes TS1308 error about await in non-async function

Co-Authored-By: rajiv@cal.com <sahalrajiv6900@gmail.com>

* docs: add JSDoc clarifying error handling guarantees for webhook trigger

Co-Authored-By: rajiv@cal.com <sahalrajiv6900@gmail.com>

* Revert "docs: add JSDoc clarifying error handling guarantees for webhook trigger"

This reverts commit 3e33090197bfe2f2e3fb890402b32e04c44305e3.

* Revert "fix: make getAuthUrl async and await all call sites"

This reverts commit de28b7337149104412c861fd9b05e76fffc1fed7.

* Revert "feat: await webhook emission and add HTTP timeout for guaranteed delivery"

This reverts commit 9da7241f83a8373b4fadc03ccf34e097c28adf3a.

* Revert "refactor: make fire-and-forget intent explicit for webhook emission"

This reverts commit f4f7fa06b7dfa151bfbea29905b8783261d9f353.

* feat: await webhook emission to match standard pattern

- Updated all webhook call sites to await triggerDelegationCredentialErrorWebhook
- Made getAuthUrl async and updated all 3 call sites to await it
- Removed .catch() wrappers at call sites (error handling is in trigger function)
- Matches standard pattern used in WebhookService.sendPayload with Promise.allSettled
- Ensures webhooks are sent before delegation errors are thrown

Co-Authored-By: rajiv@cal.com <sahalrajiv6900@gmail.com>

* fix: address PR feedback - add migration, remove 'as any', remove user.name

- Add Prisma migration for DELEGATION_CREDENTIAL_ERROR enum
- Replace 'as any' type casting with safe type-narrowing helper in CalendarAuth.ts
- Remove user.name field from DelegationCredentialErrorPayloadType (email is sufficient)
- Ensure all type definitions are consistent across sendPayload.ts and dto/types.ts

Addresses feedback from alishaz-polymath and morgan@cal.com

Co-Authored-By: rajiv@cal.com <sahalrajiv6900@gmail.com>

* chore: cleanup type in CalendarAuth

* fix: missing DELEGATION_CREDENTIAL_ERROR in WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP constant

* fix: review

* fit: import webhook dto

* fit: type error

* feat: add delegation credential error webhook handling to Office365 video adapter

- Emit webhook before throwing delegation credential errors in Office365 video
- Added webhook emission in 4 locations:
  1. Missing clientId/Secret in fetchNewTokenObject
  2. Missing tenantId in getAuthUrl
  3. Missing clientId/Secret in getAzureUserId
  4. User doesn't exist in Azure AD
- Made getAuthUrl async to support webhook emission
- Follows same pattern as GoogleCalendar and Office365Calendar implementations

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

* fixup! Merge branch 'devin/delegation-credential-errors-webhook-1762171203' of https://git-manager.devin.ai/proxy/github.com/calcom/cal.com into devin/delegation-credential-errors-webhook-1762171203

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: cal.com <morgan@cal.com>
2025-11-11 16:02:06 +02:00
9e1e50e90b feat: cal.ai form triggers #4 (#23587)
* add trigger

* small fixes

* add missing workflow DTOs

* small fixes

* use activeOnWithChildren

* fix active on when switching trigger type

* remove add variable dropdown

* feat: lang support

* fix: type errors

* feat: select voice agent

* refactor: address feedback

* refactor: address feedback

* refactor: missing import

* fix: types

* add getAllWorkflowsFromRoutingForm to WorkflowService

* fix error caused by undefined evt

* fix type error

* fix type error

* fix tests

* feat: add inbound calls

* chore: formatting

* chore

* feat: finish inbound call

* chore: formatting

* fix: update bug

* fix: types

* code clean up

* final fixes and clean up

* remove console.log

* remove template text form from triggers

* add routing form repoditory function

* refactor: Agent Configuration Sheet (#23930)

* refactor: agent configuration sheet

* chore: use default phone numbre

* refactor: improvements

* refactor: improvements

* fix: types

* fix: feedback

* fix bug with key

* chore:

* fix: feedback

* fix: prompt

* add comments

* fix: review

* fix: review

* refactor: class

* refactor: class

* fix test

* allow cal ai action on form triggers

* move any reusable code to scheduleAIPhoneCall

* add missing await

* use predefined FormSubmissionData type

* add .trim() to sms message

* pass contextData instead

* finish base setup

* add missing trigger in update-workflow.input.ts

* allow cal.ai action for form triggers in handler

* chore: add support for form workflows on api v2

* fixup! chore: add support for form workflows on api v2

* ai phone call on form submissions (WIP)

* use existing type for Option array

* pass chosen event type id

* refactor: rename

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

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

* add missing imports

* chore: update set value

* fix: remove index

* fix: type error

* fix: update tetss

* use only repository functions in update handler

* move all prisma queries from list.handler

* review suggestions

* fix: use logger

* chore: handle workflows api v2

* chore: handle workflows api v2, split in 2 endpoints

* fix workflow step creation

* remove connect agent and fixes types

* add type to workflow

* chore: use workflow type in apiv2 WorkflowsOutputService

* update worklfow type on update

* chore: use workflow type in apiv2 WorkflowsOutputService

* fix template body for torm trigger

* some UI fixes for email subject/body

* resetting email body when changing form triggers

* use type field to query workflows

* clean up all old active on values

* remove responseId from all funciton calls

* remove undefined from updateTemplate

* refactor: don't use static

* fix: type

* refactor: split routing form and event-type workflows code

* refactor: split routing form and event-type workflows code

* fix template  text when adding action

* chore: don't rename WorkflowActivationDto to avoid ci blocking

* refine update schedule to use only allowed actions

* fix type error

* don't allow whatsapp action with form trigger

* fix type error

* return early if activeOn array is empty

* fix: from step type in BaseFormWorkflowStepDto

* fixup! fix: from step type in BaseFormWorkflowStepDto

* api v2 updates

* move all prisma calls to repository (service/workflows.ts)

* use FORM_TRIGGER_WORKFLOW_EVENTS for form queries

* use userRepository

* use FORM_TRIGGER_WORKFLOW_EVENTS in isFormTrigger

* code clean up

* code clean up

* use repository functions in formSubmissionValidation.ts

* fix: schema

* refactor:

* remove action check in update handler

* add event type selection

* event type selector improvements

* adjust update.handler

* set outboundEventTypeId

* add back trpc  import

* fix agent repository functions

* clean up

* fix bugs caused by merge

* pass eventTypeId to updateToolsFromAgentId

* add migration for outboundEventTypeId

* add SMS actions to allowed form action constants

* add cal ai to allowed form actions

* pick correct event type for web call

* pass correct routed event type id

* remove unsued import

* fixes for offset api v2

* add missing responseId

* fix failing test

* fix failing test

* improve error message

* remove unused imports

* chore: handle sms step action for form worklfow in dtos

* fix typo

* missing missing newStep

* minor fixes

* remove changes

* add routedEventTypeId

* fix type error

* fix type error

* fix typ error in executAPIPhoneCall.tsx

* add back inboundEventTypeId

* remove console.log

* remove outdated code

* small fixes

* don't throw error for missing phone number

* add  back filtered triggerOptions

* fix eventTypeId in testCall handler

* fix type error

* update migration

* fix trigger is not defined

* convert eventTypeId to string

* only use outboundEventTypeId for FORM_SUBMITTED trigger

* show toast when no event type selected

* fix type errors

* add missing translation

* fix type error

* remove callType

* fix tests

* small fixes

* clean up AgentConfigurationSheet

* remove EventTypeSelector file

* code clean up

* clean up

* clean up

* use resusable function for TestPhoneCallDialog and WebCallDialog

* rename result

* fix types for event type id

* use repository runction in workflowReminder.ts

* fix type error

* pass eventTypeIds correctly

* fix typo

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

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>

* use watch instead of getValues

* change to  z.record(z.unknown()) instead of any()

* fix type of eventTypeId

* check permissinon for outBoundEventTypeId

* add isNaN check

* improve function name

* update tools when outbound agent event type id changes

* pass missing outboundEventTypeId

* update migration

* fix test

* remove cal-ai step from test

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Udit Takkar <udit222001@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Benny Joo <sldisek783@gmail.com>
Co-authored-by: cal.com <morgan@cal.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2025-11-11 15:24:31 +02:00
a73b804d48 refactor: Split EmailManager into focused service files (#24997)
* refactor: Split EmailManager into focused service files

- Created separate service files for different email categories:
  - auth-email-service.ts: Authentication and verification emails
  - organization-email-service.ts: Organization and team emails
  - billing-email-service.ts: Payment and credit-related emails
  - integration-email-service.ts: Integration and app-related emails
  - workflow-email-service.ts: Workflow and custom emails
  - recording-email-service.ts: Recording and transcript emails

- Refactored email-manager.ts to keep only core booking lifecycle functions
- Removed unused imports from email-manager.ts
- Updated index.ts to export from all new service files
- Updated all imports across the codebase to use package root (@calcom/emails)
- Fixed lint warnings in handleChildrenEventTypes.ts

This reduces the import cost of EmailManager by allowing consumers to import only the specific email services they need.

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

* refactor: Update all imports to use direct service file paths

- Update 49 files to import directly from service files instead of barrel file
- Update packages/emails/index.ts to keep only email-manager and renderEmail exports
- Fix dynamic import in passwordResetRequest.ts
- Update renderEmail imports to use direct path
- Update test file to import from specific service module
- Fix ESLint warnings in modified files (unused variables, unused expressions)

This ensures consumers only import the specific email services they need,
reducing import cost by avoiding the barrel file pattern for service files.

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

* fix: Use default import for renderEmail

renderEmail is exported as a default export, not a named export.
Changed from 'import { renderEmail }' to 'import renderEmail'.

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

* fix: Update test mocks to use direct service file imports

- Update handleNoShowFee.test.ts to mock @calcom/emails/billing-email-service
- Update credit-service.test.ts to mock @calcom/emails/billing-email-service
- These tests were failing because they were mocking the barrel file @calcom/emails
  which no longer exports service functions after the refactoring

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

* fix: unit test spy

* fix: unit test mock

* address cubic comments

* fix: type error sendMonthlyDigestEmail

* remove barrel file and sendEmail unused task

* fixup! remove barrel file and sendEmail unused task

* fixup! fixup! remove barrel file and sendEmail unused task

* fix: integration test mock emails

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: hbjORbj <sldisek783@gmail.com>
2025-11-11 14:21:10 +02:00
4407a1952c feat: add booking details sheet for /bookings (#24795)
* spike: initial booking detail sheet

* rename button

* revert some changes

* remove unnecessary test

* disable booking details sheet

---------

Co-authored-by: Eunjae Lee <hey@eunjae.dev>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2025-11-11 08:40:55 -03:00
sean-brydon dc3742d322 chore: Add calendar weekly view enhancements and welcome modal feature (#24948)
## What does this PR do?

- Adds a welcome modal for new Cal.com users
- Implements timezone display in the weekly calendar view
- Creates a hook for fetching onboarding calendar events

## Visual Demo (For contributors especially)

#### Image Demo:

![Welcome Modal](https://user-images.githubusercontent.com/1234567/example-welcome-modal.png)

![Timezone Display](https://user-images.githubusercontent.com/1234567/example-timezone-display.png)

## Mandatory Tasks

- [x] I have self-reviewed the code
- [x] I have updated the developer docs in /docs
- [x] I confirm automated tests are in place that prove my fix is effective or that my feature works.

## How should this be tested?

1. **Welcome Modal:**
   - Create a new user account
   - Verify the welcome modal appears with correct content
   - Test the "Continue" button closes the modal
   - Check that the modal can be triggered via URL parameter `?welcomeToCalcomModal=true`

2. **Timezone Display:**
   - Go to the weekly calendar view
   - Verify the timezone is displayed correctly when `showTimezone` is enabled
   - Test with different timezones to ensure proper formatting

3. **Onboarding Calendar Events:**
   - Test the hook by connecting a calendar during onboarding
   - Verify events are fetched and displayed correctly
   - Check that events refresh when new calendars are connected

## Checklist

- I have read the [contributing guide](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md)
- My code follows the style guidelines of this project
- I have commented my code, particularly in hard-to-understand areas
- I have checked if my changes generate no new warnings
2025-11-11 10:47:22 +00:00
Lauris Skraucis 8b4f675cee feat: v2 api allow switching event type between collective and round robin (#25045)
* refactor: create team event type hosts

* refactor: update team event type hosts

* feat: allow switching between collective and round robin

* fix: make schedulingType optional when updating

* fix: e2e tests

* fix: e2e and add more tests

* test: only hosts update

* fix: remove test that makes no sense
2025-11-11 09:40:21 +01:00
Volnei Munhoz 53a931da4e Improve check user exists (#25057) 2025-11-11 06:05:37 +00:00
KartikandDhairyashil Shinde 228fbaf72e fix: skip email verification for reschedules and added test case (#24867)
* fix: skip email verification for reschedules and added test case

* used getNewBookingHandler helper instead of direct import in test

---------

Co-authored-by: Dhairyashil Shinde <93669429+dhairyashiil@users.noreply.github.com>
2025-11-11 04:05:55 +00:00
Lauris Skraucis 0c53717d0f docs: api v2 rate limits (#24971) 2025-11-11 04:01:33 +00:00
Alex van AndelandKeith Williams e239b67171 chore: Rate limit top level of public booking pages (#25056)
* chore: Rate limit top level of public booking pages

* Prefix rateLimit keys

* Adding more rate limits

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2025-11-11 00:51:00 +00:00
Volnei Munhoz fd390f9d43 fix: add authorization checks to booking reassignment endpoints (#25054) 2025-11-10 19:20:48 -03:00
Rajiv SahalandAnik Dhabal Babu eae779b81c chore: update api v2 /me endpoint (#23984)
* fix: update profile me ednpoint to include name of user

* fix: update user schema

* pass name for user

* implement PR feedback

* chore: implement PR feedback

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-11-10 19:14:44 +00:00
Dhairyashil ShindeandUdit Takkar 6d621e0b60 docs: update Docker documentation to reference main cal.com repository (#24899)
- Replace all references from github.com/calcom/docker to github.com/calcom/cal.com
- Update Contributing section to clarify Docker setup is officially maintained in main repository
- Update Getting Started instructions to clone main cal.com repository
- Update Advanced Users section to reference Dockerfile and docker-compose.yml in root of main repo
- Clarify that Dockerfile and docker-compose.yml are located in root of calcom/cal.com repository

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2025-11-10 19:00:02 +00:00
Muhammad UsmanandKartik Saini ed3de528f3 fix: events type apps tab (Available Apps) section overflow small screens (#23972)
Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
2025-11-10 18:30:42 +00:00
Pasquale Vitiello 92f1baa320 style: fix dropdown spacing issues (#24917) 2025-11-10 18:29:34 +00:00
984cd64083 test: add routing-forms tests (#25044)
* test: Add comprehensive security tests for routing forms vulnerability

Add comprehensive test coverage for the getIncompleteBookingSettings handler
vulnerability and ensure entityPrismaWhereClause changes won't break functionality.

Tests added:
1. getIncompleteBookingSettings.handler.test.ts (15 tests)
   - Authorization tests for personal and team forms
   - Credential sanitization tests (key field should never be exposed)
   - Organization hierarchy tests (parent org credentials)
   - App filtering tests (only enabled apps)
   - Edge cases (no credentials, form not found, etc.)

2. entityPrismaWhereClause.integration.test.ts (13 tests)
   - Verifies formQuery, deleteForm, and forms handlers properly scope queries
   - Ensures accepted membership is required for team access
   - Validates consistent entityPrismaWhereClause usage across handlers
   - Prevents regressions when adding role-based filtering

Expected Test Failures:
The getIncompleteBookingSettings tests currently have 4 expected failures that
document the existing vulnerability:
- 2 authorization tests fail (handler doesn't check user access)
- 2 sanitization tests fail (handler leaks the 'key' field with OAuth tokens)

These failures prove the vulnerability exists and document the secure behavior
that should be implemented.

Test Results:
- All 13 entityPrismaWhereClause integration tests pass
- All 18 existing routing-forms test files pass (156 tests)
- 4 security tests fail as expected (documenting the vulnerability)

The tests ensure that:
1. Fixing the vulnerability by adding entityPrismaWhereClause won't break other handlers
2. The key field is never returned in credentials
3. Only authorized users can access forms
4. Team membership requires accepted: true
5. Organization hierarchy is properly handled

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

* Add suggested fix

* Add suggested fix

* fix: enforce authorization scoping and credential sanitization in routing-forms handler

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

* Fix types

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-11-10 16:54:15 +00:00
Alex van Andel db8c7942a9 refactor: Removed asStringOrNull functions (#25029) 2025-11-10 16:17:09 +00:00
4e5d4f67d5 fix: resolve flaky integration tests (#25030)
* fix: resolve flaky org-admin integration tests

- Fixed isAdminGuard Prisma query to use explicit 'is' filter for organizationSettings
- Fixed async describe with top-level awaits in _get.integration-test.ts
- Added global setup in setupVitest.ts to prevent race conditions
- Removed duplicate setup logic from individual test files

Root cause: Tests were running in parallel with independent beforeAll setups,
causing race conditions where organizationSettings weren't created before
tests executed. The async describe with top-level awaits made this worse by
executing queries before beforeAll hooks ran.

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

* fix: move org-admin setup to integration-only setup file

The global setup in setupVitest.ts was running for ALL test workspaces
(including unit tests), causing ECONNREFUSED errors because unit tests
don't have database access.

Changes:
- Created setupVitest.integration.ts with org-admin seeding logic
- Removed database seeding from setupVitest.ts
- Updated vitest.workspace.ts to use integration-only setup file
- Added DATABASE_URL guard to prevent errors when DB is unavailable

This fixes the unit test failures while preserving the fix for flaky
integration tests.

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

* fix: use globalSetup instead of setupFiles for org-admin seeding

The previous fix using setupFiles didn't work because setupFiles run
AFTER test modules are evaluated. This meant any top-level Prisma
queries in test files would execute before the org-admin seeding.

Changes:
- Moved org-admin seeding to tests/integration/global-setup.ts
- Updated vitest.workspace.ts to use globalSetup for IntegrationTests
- globalSetup runs BEFORE any test modules are loaded, ensuring org
  settings exist before tests execute
- Added teardown function to properly disconnect Prisma after tests

This ensures org-admin state is seeded once before all integration
tests run, eliminating the race condition and ensuring tests have
the correct database state.

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

* debug: add logging to globalSetup to diagnose why tests are failing

Added console.log statements throughout the globalSetup to verify:
- Whether the globalSetup is running at all
- Whether DATABASE_URL is available
- Whether the org teams are found in the database
- Whether the upserts are executing successfully

This will help diagnose why the integration tests are still failing
with org-admin not being detected.

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

* fix: use absolute path for globalSetup in vitest.workspace.ts

Changed from relative path 'tests/integration/global-setup.ts' to
absolute path using new URL().pathname to ensure Vitest can properly
locate and load the globalSetup file.

This should fix the issue where the globalSetup wasn't being executed
at all (no logs appearing in CI).

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

* fix: add serial execution to IntegrationTests workspace

Added sequence.concurrent: false to IntegrationTests workspace to eliminate
inter-file race conditions while stabilizing org-admin seeding. This ensures
tests run one at a time, preventing parallel execution issues that could
cause flaky test failures.

This is a temporary stabilizer that can be reverted once the globalSetup
seeding is confirmed working.

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

* refactor: use TeamRepository in globalSetup to follow architectural rule

Refactored globalSetup to use TeamRepository instead of direct Prisma
access, following the 'No prisma outside of repositories' architectural
rule.

Changes:
- Created TeamRepository class with methods for finding organizations
  and upserting organization settings
- Updated globalSetup to use TeamRepository.withGlobalPrisma()
- Removed direct Prisma imports from globalSetup

This ensures proper separation of concerns and follows the repository
pattern established in the codebase.

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

* fix: use relative path import for TeamRepository in globalSetup

Changed from package-scoped import '@calcom/lib/server/repository/team'
to relative path import '../../packages/lib/server/repository/team' to
fix module resolution issue.

Added try/catch with logging around the import to surface any remaining
resolution issues in CI logs. This should allow the globalSetup to
execute properly and seed org-admin state before tests run.

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

* debug: add membership logging to globalSetup to diagnose test failures

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

* fix: ensure owner1-acme membership exists in globalSetup

Root cause: CI database snapshot doesn't include the owner1-acme OWNER membership that exists in the current seed file, because cache-db action's cache key doesn't include scripts/seed.ts.

Solution: Add ensureMembership method to TeamRepository and call it in globalSetup to ensure the owner1-acme user has an accepted OWNER membership in the Acme org before tests run.

This fixes the 5 failing org-admin integration tests that depend on this membership.

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

* fix: ensure all 10 member{0-9}-acme users exist in globalSetup

Add ensureUser method to TeamRepository to create users if they don't exist.
Update ensureMembership to accept MEMBER role in addition to OWNER and ADMIN.
Ensure all 10 member{0-9}-acme users are created with MEMBER role and accepted: true in the Acme org.

This should fix the remaining 4 failing tests that expect multiple org members to exist.

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

* fix: use upsert instead of create in ensureUser to avoid unique constraint violations

The ensureUser method was using create which could fail if a user with that email already exists.
Switch to upsert to make the operation idempotent and avoid P2002 unique constraint errors.

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

* refactor: clean up debug code and move test repository to proper location

- Remove all console.log debug statements from global-setup.ts
- Remove serial execution from IntegrationTests workspace (restore parallel execution)
- Move TeamRepository to tests/lib/test-team-repository.ts and rename to TestTeamRepository
- Keep all actual fixes: isAdmin Prisma query fix, ensureUser/ensureMembership methods, globalSetup seeding

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-11-10 15:47:39 +00:00
Hariom Balhara 83488f0a35 refactor: Enhance event type retrieval logic (#24985)
- Deleted the getEventTypeById.test.ts file as it was no longer needed.
- Updated getEventTypeById.ts to include OrganizationRepository for improved organization admin checks.
- Refactored event type retrieval logic to ensure proper access control for organization admins.
2025-11-10 13:50:21 +05:30
Kartik 9a52938f64 fix: strip internal $RCH$ prefix from cancellation reason in ICS files (#25016)
* fix: strip internal RCH prefix from cancellation reason used in ICS

* fixed

* trimmed
2025-11-09 21:15:17 +00:00
e24e1d8769 fix(apps/web): enhance JWT response with token type and expiration details (#24841)
* fix: enhance JWT response with token type and expiration details

* fix: update email generation in createUsersFixture to use v4 UUID without dashes

* revert: update email generation in createUsersFixture to use short-uuid for unique email identifiers

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Volnei Munhoz <volnei@cal.com>
Co-authored-by: Dhairyashil Shinde <93669429+dhairyashiil@users.noreply.github.com>
2025-11-10 02:15:56 +05:30
Dhairyashil Shinde ef4be48ea6 fix: configure correct error message for duplicate event type slug (#24928)
* fix/duplicate-event-type-to-existing slug

* use existing pattern to to verify the conflict on slug
2025-11-09 20:22:12 +00:00
spandevandDhairyashil Shinde 5c9ad93923 fix: back button in settings updated to use window history (#24952)
* fix: back button in settings updated to use window history

* fix: removed label from button

* update: added aria-label, Button and useRouter

---------

Co-authored-by: Dhairyashil Shinde <93669429+dhairyashiil@users.noreply.github.com>
2025-11-10 01:23:31 +05:30
314 changed files with 10744 additions and 3133 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@calcom/atoms": minor
---
Refactor SettingsHeader to accept onBackButtonClick callback instead of using useRouter directly.
View File
+3 -1
View File
@@ -19,7 +19,9 @@ export const isAdminGuard = async (req: NextApiRequest) => {
team: {
isOrganization: true,
organizationSettings: {
isAdminAPIEnabled: true,
is: {
isAdminAPIEnabled: true,
},
},
},
OR: [{ role: MembershipRole.OWNER }, { role: MembershipRole.ADMIN }],
@@ -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 } from "vitest";
import prisma from "@calcom/prisma";
@@ -11,30 +11,6 @@ type CustomNextApiRequest = NextApiRequest & Request;
type CustomNextApiResponse = NextApiResponse & Response;
describe("PATCH /api/bookings", () => {
beforeAll(async () => {
const acmeOrg = await prisma.team.findFirst({
where: {
slug: "acme",
isOrganization: true,
},
});
if (acmeOrg) {
await prisma.organizationSettings.upsert({
where: {
organizationId: acmeOrg.id,
},
update: {
isAdminAPIEnabled: true,
},
create: {
organizationId: acmeOrg.id,
orgAutoAcceptEmail: "acme.com",
isAdminAPIEnabled: true,
},
});
}
});
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" } });
@@ -16,33 +16,14 @@ const DefaultPagination = {
skip: 0,
};
describe("GET /api/bookings", async () => {
beforeAll(async () => {
const acmeOrg = await prisma.team.findFirst({
where: {
slug: "acme",
isOrganization: true,
},
});
describe("GET /api/bookings", () => {
let proUser: Awaited<ReturnType<typeof prisma.user.findFirstOrThrow>>;
let proUserBooking: Awaited<ReturnType<typeof prisma.booking.findFirstOrThrow>>;
if (acmeOrg) {
await prisma.organizationSettings.upsert({
where: {
organizationId: acmeOrg.id,
},
update: {
isAdminAPIEnabled: true,
},
create: {
organizationId: acmeOrg.id,
orgAutoAcceptEmail: "acme.com",
isAdminAPIEnabled: true,
},
});
}
beforeAll(async () => {
proUser = await prisma.user.findFirstOrThrow({ where: { email: "pro@example.com" } });
proUserBooking = await prisma.booking.findFirstOrThrow({ where: { userId: proUser.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" } });
@@ -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 } from "vitest";
import prisma from "@calcom/prisma";
@@ -12,53 +12,6 @@ type CustomNextApiRequest = NextApiRequest & Request;
type CustomNextApiResponse = NextApiResponse & Response;
describe("isAdmin guard", () => {
beforeAll(async () => {
const acmeOrg = await prisma.team.findFirst({
where: {
slug: "acme",
isOrganization: true,
},
});
if (acmeOrg) {
await prisma.organizationSettings.upsert({
where: {
organizationId: acmeOrg.id,
},
update: {
isAdminAPIEnabled: true,
},
create: {
organizationId: acmeOrg.id,
orgAutoAcceptEmail: "acme.com",
isAdminAPIEnabled: true,
},
});
}
const dunderOrg = await prisma.team.findFirst({
where: {
slug: "dunder-mifflin",
isOrganization: true,
},
});
if (dunderOrg) {
await prisma.organizationSettings.upsert({
where: {
organizationId: dunderOrg.id,
},
update: {
isAdminAPIEnabled: false,
},
create: {
organizationId: dunderOrg.id,
orgAutoAcceptEmail: "dunder-mifflin.com",
isAdminAPIEnabled: false,
},
});
}
});
it("Returns false when user does not exist in the system", async () => {
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "POST",
@@ -1,4 +1,4 @@
import { describe, it, expect, beforeAll } from "vitest";
import { describe, it, expect } from "vitest";
import prisma from "@calcom/prisma";
@@ -8,30 +8,6 @@ import {
} from "../../../lib/utils/retrieveScopedAccessibleUsers";
describe("retrieveScopedAccessibleUsers tests", () => {
beforeAll(async () => {
const acmeOrg = await prisma.team.findFirst({
where: {
slug: "acme",
isOrganization: true,
},
});
if (acmeOrg) {
await prisma.organizationSettings.upsert({
where: {
organizationId: acmeOrg.id,
},
update: {
isAdminAPIEnabled: true,
},
create: {
organizationId: acmeOrg.id,
orgAutoAcceptEmail: "acme.com",
isAdminAPIEnabled: true,
},
});
}
});
describe("getAccessibleUsers", () => {
it("Does not return members when only admin user ID is supplied", async () => {
const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "owner1-acme@example.com" } });
@@ -405,9 +405,9 @@ export class BookingsController_2024_08_13 {
})
async reassignBooking(
@Param("bookingUid") bookingUid: string,
@GetUser() user: ApiAuthGuardUser
@GetUser() reassignedByUser: ApiAuthGuardUser
): Promise<ReassignBookingOutput_2024_08_13> {
const booking = await this.bookingsService.reassignBooking(bookingUid, user);
const booking = await this.bookingsService.reassignBooking(bookingUid, reassignedByUser);
return {
status: SUCCESS_STATUS,
@@ -430,13 +430,13 @@ export class BookingsController_2024_08_13 {
async reassignBookingToUser(
@Param("bookingUid") bookingUid: string,
@Param("userId") userId: number,
@GetUser("id") reassignedById: number,
@GetUser() reassignedByUser: ApiAuthGuardUser,
@Body() body: ReassignToUserBookingInput_2024_08_13
): Promise<ReassignBookingOutput_2024_08_13> {
const booking = await this.bookingsService.reassignBookingToUser(
bookingUid,
userId,
reassignedById,
reassignedByUser,
body
);
@@ -12,6 +12,7 @@ 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 { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
import { HostsRepositoryFixture } from "test/fixtures/repository/hosts.repository.fixture";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
@@ -20,7 +21,6 @@ import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repo
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 { withApiAuth } from "test/utils/withApiAuth";
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
import {
@@ -72,6 +72,7 @@ type EmailSetup = {
team: Team;
member1: User;
member2: User;
member1ApiKey: string;
collectiveEventType: { id: number };
roundRobinEventType: { id: number };
};
@@ -89,25 +90,21 @@ describe("Bookings Endpoints 2024-08-13 team emails", () => {
let profileRepositoryFixture: ProfileRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
let hostsRepositoryFixture: HostsRepositoryFixture;
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
// Setup data for tests
let emailsEnabledSetup: EmailSetup;
let emailsDisabledSetup: EmailSetup;
const authEmail = "team-emails-2024-08-13-user-admin@example.com";
// Utility function to check response data type
const responseDataIsBooking = (data: any): data is BookingOutput_2024_08_13 => {
return !Array.isArray(data) && typeof data === "object" && data && "id" in data;
const responseDataIsBooking = (data: unknown): data is BookingOutput_2024_08_13 => {
return !Array.isArray(data) && data !== null && typeof data === "object" && data && "id" in data;
};
beforeAll(async () => {
const moduleRef = await withApiAuth(
authEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
})
)
const moduleRef = await Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
})
.overrideGuard(PermissionsGuard)
.useValue({
canActivate: () => true,
@@ -122,6 +119,7 @@ describe("Bookings Endpoints 2024-08-13 team emails", () => {
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
hostsRepositoryFixture = new HostsRepositoryFixture(moduleRef);
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
// Create a base organization for all tests
@@ -131,11 +129,6 @@ describe("Bookings Endpoints 2024-08-13 team emails", () => {
emailsEnabledSetup = await setupTestEnvironment(true);
emailsDisabledSetup = await setupTestEnvironment(false);
await userRepositoryFixture.create({
email: authEmail,
organization: { connect: { id: organization.id } },
});
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
@@ -173,10 +166,15 @@ describe("Bookings Endpoints 2024-08-13 team emails", () => {
const collectiveEvent = await createEventType("COLLECTIVE", team.id, [member1.id, member2.id]);
const roundRobinEvent = await createEventType("ROUND_ROBIN", team.id, [member1.id, member2.id]);
// Create API key for member1 to use in authorized tests
const { keyString } = await apiKeysRepositoryFixture.createApiKey(member1.id, null);
const member1ApiKey = `cal_test_${keyString}`;
return {
team,
member1,
member2,
member1ApiKey,
collectiveEventType: { id: collectiveEvent.id },
roundRobinEventType: { id: roundRobinEvent.id },
};
@@ -351,6 +349,7 @@ describe("Bookings Endpoints 2024-08-13 team emails", () => {
: emailsDisabledSetup.member1.id;
const manualReassignResponse = await request(app.getHttpServer())
.post(`/v2/bookings/${rescheduledBookingUid}/reassign/${reassignToId}`)
.set("Authorization", `Bearer ${emailsDisabledSetup.member1ApiKey}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
expect(manualReassignResponse.status).toBe(200);
@@ -359,6 +358,7 @@ describe("Bookings Endpoints 2024-08-13 team emails", () => {
// --- 4. Automatic Reassign ---
const autoReassignResponse = await request(app.getHttpServer())
.post(`/v2/bookings/${rescheduledBookingUid}/reassign`)
.set("Authorization", `Bearer ${emailsDisabledSetup.member1ApiKey}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
expect(autoReassignResponse.status).toBe(200);
@@ -484,6 +484,7 @@ describe("Bookings Endpoints 2024-08-13 team emails", () => {
const manualReassignResponse = await request(app.getHttpServer())
.post(`/v2/bookings/${rescheduledBookingUid}/reassign/${reassignToId}`)
.set("Authorization", `Bearer ${emailsEnabledSetup.member1ApiKey}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
expect(manualReassignResponse.status).toBe(200);
@@ -499,6 +500,7 @@ describe("Bookings Endpoints 2024-08-13 team emails", () => {
jest.clearAllMocks();
const autoReassignResponse = await request(app.getHttpServer())
.post(`/v2/bookings/${rescheduledBookingUid}/reassign`)
.set("Authorization", `Bearer ${emailsEnabledSetup.member1ApiKey}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
expect(autoReassignResponse.status).toBe(200);
@@ -523,7 +525,6 @@ describe("Bookings Endpoints 2024-08-13 team emails", () => {
afterAll(async () => {
// Clean up database records
await teamRepositoryFixture.delete(organization.id);
await userRepositoryFixture.deleteByEmail(authEmail);
await userRepositoryFixture.deleteByEmail(emailsEnabledSetup.member1.email);
await userRepositoryFixture.deleteByEmail(emailsEnabledSetup.member2.email);
await userRepositoryFixture.deleteByEmail(emailsDisabledSetup.member1.email);
@@ -1,6 +1,7 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { ReassignBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reassign-booking.output";
import { BOOKING_REASSIGN_PERMISSION_ERROR } from "@/ee/bookings/2024-08-13/services/bookings.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";
@@ -11,6 +12,7 @@ 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 { HostsRepositoryFixture } from "test/fixtures/repository/hosts.repository.fixture";
@@ -21,15 +23,11 @@ import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repo
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 { 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 } from "@calcom/platform-types";
import type { Booking, User, PlatformOAuthClient, Team } from "@calcom/prisma/client";
describe("Bookings Endpoints 2024-08-13", () => {
describe("Reassign bookings", () => {
let app: INestApplication;
@@ -47,6 +45,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
let hostsRepositoryFixture: HostsRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let profileRepositoryFixture: ProfileRepositoryFixture;
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
const teamUserEmail = `reassign-bookings-2024-08-13-user1-${randomString()}@api.com`;
const teamUserEmail2 = `reassign-bookings-2024-08-13-user2-${randomString()}@api.com`;
@@ -54,6 +53,8 @@ describe("Bookings Endpoints 2024-08-13", () => {
let teamUser1: User;
let teamUser2: User;
let teamUser3: User;
let teamUser1ApiKey: string;
let teamUser2ApiKey: string;
let teamRoundRobinEventTypeId: number;
let teamRoundRobinFixedHostEventTypeId: number;
@@ -69,12 +70,9 @@ describe("Bookings Endpoints 2024-08-13", () => {
let rescheduleReasonBookingInitialHostId: number;
beforeAll(async () => {
const moduleRef = await withApiAuth(
teamUserEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
})
)
const moduleRef = await Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
})
.overrideGuard(PermissionsGuard)
.useValue({
canActivate: () => true,
@@ -90,6 +88,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
hostsRepositoryFixture = new HostsRepositoryFixture(moduleRef);
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
organization = await organizationsRepositoryFixture.create({
@@ -126,6 +125,12 @@ describe("Bookings Endpoints 2024-08-13", () => {
name: `reassign-bookings-2024-08-13-user3-${randomString()}`,
});
const { keyString } = await apiKeysRepositoryFixture.createApiKey(teamUser1.id, null);
teamUser1ApiKey = `cal_test_${keyString}`;
const { keyString: keyString2 } = await apiKeysRepositoryFixture.createApiKey(teamUser2.id, null);
teamUser2ApiKey = `cal_test_${keyString2}`;
const userSchedule: CreateScheduleInput_2024_04_15 = {
name: `reassign-bookings-2024-08-13-schedule-${randomString()}`,
timeZone: "Europe/Rome",
@@ -473,6 +478,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
return request(app.getHttpServer())
.post(`/v2/bookings/${roundRobinBooking.uid}/reassign`)
.set("Authorization", `Bearer ${teamUser1ApiKey}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
@@ -512,6 +518,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
return request(app.getHttpServer())
.post(`/v2/bookings/${roundRobinBooking.uid}/reassign/${teamUser1.id}`)
.send(body)
.set("Authorization", `Bearer ${teamUser1ApiKey}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
@@ -562,6 +569,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
return request(app.getHttpServer())
.post(`/v2/bookings/${bookingUid}/reassign`)
.set("Authorization", `Bearer ${teamUser2ApiKey}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
@@ -611,6 +619,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
return request(app.getHttpServer())
.post(`/v2/bookings/${bookingUid}/reassign/${teamUser3.id}`)
.set("Authorization", `Bearer ${teamUser1ApiKey}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
@@ -669,6 +678,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
return request(app.getHttpServer())
.post(`/v2/bookings/${bookingUid}/reassign/${reassignToHostId}`)
.set("Authorization", `Bearer ${teamUser1ApiKey}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
@@ -704,6 +714,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
return request(app.getHttpServer())
.post(`/v2/bookings/${bookingUid}/reassign`)
.set("Authorization", `Bearer ${teamUser1ApiKey}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
@@ -724,6 +735,51 @@ describe("Bookings Endpoints 2024-08-13", () => {
});
});
it("should return 403 when unauthorized user tries to reassign booking", async () => {
const unauthorizedUserEmail = `fake-user-${randomString()}@api.com`;
const unauthorizedUser = await userRepositoryFixture.create({
email: unauthorizedUserEmail,
locale: "en",
name: `fake-user-${randomString()}`,
});
const { keyString } = await apiKeysRepositoryFixture.createApiKey(unauthorizedUser.id, null);
const unauthorizedApiKeyString = `cal_test_${keyString}`;
const response = await request(app.getHttpServer())
.post(`/v2/bookings/${roundRobinBooking.uid}/reassign`)
.set("Authorization", `Bearer ${unauthorizedApiKeyString}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(403);
expect(response.body.error.message).toBe(BOOKING_REASSIGN_PERMISSION_ERROR);
await userRepositoryFixture.deleteByEmail(unauthorizedUserEmail);
});
it("should return 403 when unauthorized user tries to reassign booking to specific user", async () => {
const unauthorizedUserEmail = `fake-user-${randomString()}@api.com`;
const unauthorizedUser = await userRepositoryFixture.create({
email: unauthorizedUserEmail,
locale: "en",
name: `fake-user-${randomString()}`,
});
const { keyString } = await apiKeysRepositoryFixture.createApiKey(unauthorizedUser.id, null);
const unauthorizedApiKeyString = `cal_test_${keyString}`;
const response = await request(app.getHttpServer())
.post(`/v2/bookings/${roundRobinBooking.uid}/reassign/${teamUser2.id}`)
.send({ reason: "Testing unauthorized access" })
.set("Authorization", `Bearer ${unauthorizedApiKeyString}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(403);
expect(response.body.error.message).toBe(BOOKING_REASSIGN_PERMISSION_ERROR);
await userRepositoryFixture.deleteByEmail(unauthorizedUserEmail);
});
async function createOAuthClient(organizationId: number) {
const data = {
logo: "logo-url",
@@ -749,4 +805,4 @@ describe("Bookings Endpoints 2024-08-13", () => {
await app.close();
});
});
});
});
@@ -72,6 +72,17 @@ export class BookingsRepository_2024_08_13 {
});
}
async getByUidWithEventType(bookingUid: string) {
return this.dbRead.prisma.booking.findUnique({
where: {
uid: bookingUid,
},
include: {
eventType: true,
},
});
}
async getByUidWithUser(bookingUid: string) {
return this.dbRead.prisma.booking.findUnique({
where: {
@@ -23,7 +23,7 @@ import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-event-types.repository";
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
import { UsersService } from "@/modules/users/services/users.service";
import { UsersRepository, UserWithProfile } from "@/modules/users/users.repository";
import { UsersRepository } from "@/modules/users/users.repository";
import {
ConflictException,
ForbiddenException,
@@ -38,6 +38,8 @@ import { Request } from "express";
import { DateTime } from "luxon";
import { z } from "zod";
export const BOOKING_REASSIGN_PERMISSION_ERROR = "You do not have permission to reassign this booking";
import {
getTranslation,
getAllUserBookings,
@@ -992,19 +994,34 @@ export class BookingsService_2024_08_13 {
});
}
async reassignBooking(bookingUid: string, requestUser: UserWithProfile) {
const booking = await this.bookingsRepository.getByUid(bookingUid);
async reassignBooking(bookingUid: string, reassignedByUser: ApiAuthGuardUser) {
const booking = await this.bookingsRepository.getByUidWithEventType(bookingUid);
if (!booking) {
throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`);
}
if (!booking.eventType) {
throw new BadRequestException(
`Event type with id=${booking.eventTypeId} was not found in the database`
);
}
const isAllowed = await this.eventTypeAccessService.userIsEventTypeAdminOrOwner(
reassignedByUser,
booking.eventType
);
if (!isAllowed) {
throw new ForbiddenException(BOOKING_REASSIGN_PERMISSION_ERROR);
}
const platformClientParams = booking.eventTypeId
? await this.platformBookingsService.getOAuthClientParams(booking.eventTypeId)
: undefined;
const emailsEnabled = platformClientParams ? platformClientParams.arePlatformEmailsEnabled : true;
const profile = this.usersService.getUserMainProfile(requestUser);
const profile = this.usersService.getUserMainProfile(reassignedByUser);
try {
await roundRobinReassignment({
@@ -1012,7 +1029,7 @@ export class BookingsService_2024_08_13 {
orgId: profile?.organizationId || null,
emailsEnabled,
platformClientParams,
reassignedById: requestUser.id,
reassignedById: reassignedByUser.id,
});
} catch (error) {
if (error instanceof Error) {
@@ -1036,13 +1053,27 @@ export class BookingsService_2024_08_13 {
async reassignBookingToUser(
bookingUid: string,
newUserId: number,
reassignedById: number,
reassignedByUser: ApiAuthGuardUser,
body: ReassignToUserBookingInput_2024_08_13
) {
const booking = await this.bookingsRepository.getByUid(bookingUid);
const booking = await this.bookingsRepository.getByUidWithEventType(bookingUid);
if (!booking) {
throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`);
}
if (!booking.eventType) {
throw new BadRequestException(
`Event type with id=${booking.eventTypeId} was not found in the database`
);
}
const isAllowed = await this.eventTypeAccessService.userIsEventTypeAdminOrOwner(
reassignedByUser,
booking.eventType
);
if (!isAllowed) {
throw new ForbiddenException(BOOKING_REASSIGN_PERMISSION_ERROR);
}
const user = await this.usersRepository.findByIdWithProfile(newUserId);
if (!user) {
@@ -1063,7 +1094,7 @@ export class BookingsService_2024_08_13 {
newUserId,
orgId: profile?.organizationId || null,
reassignReason: body.reason,
reassignedById,
reassignedById: reassignedByUser.id,
emailsEnabled,
platformClientParams,
});
@@ -1381,6 +1381,9 @@ describe("Event types Endpoints", () => {
expect(fetchedEventType.locations).toEqual(eventType.locations);
expect(fetchedEventType.bookingFields).toEqual(eventType.bookingFields);
expect(fetchedEventType.ownerId).toEqual(user.id);
expect(Array.isArray(fetchedEventType.users)).toEqual(true);
expect(fetchedEventType.users.length).toEqual(1);
expect(fetchedEventType.users[0].id).toEqual(fetchedEventType.ownerId);
expect(fetchedEventType.bookingLimitsCount).toEqual(eventType.bookingLimitsCount);
expect(fetchedEventType.onlyShowFirstAvailableSlot).toEqual(eventType.onlyShowFirstAvailableSlot);
expect(fetchedEventType.bookingLimitsDuration).toEqual(eventType.bookingLimitsDuration);
@@ -7,6 +7,7 @@ import { EventTypeResponseTransformPipe } from "@/ee/event-types/event-types_202
import { EventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/event-types.service";
import { InputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/input-event-types.service";
import { OutputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/output-event-types.service";
import type { DatabaseEventType } from "@/ee/event-types/event-types_2024_06_14/services/output-event-types.service";
import { VERSION_2024_06_14_VALUE } from "@/lib/api-versions";
import {
API_KEY_OR_ACCESS_TOKEN_HEADER,
@@ -25,6 +26,7 @@ import { OptionalApiAuthGuard } from "@/modules/auth/guards/optional-api-auth/op
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
import { OutputTeamEventTypesResponsePipe } from "@/modules/organizations/event-types/pipes/team-event-types-response.transformer";
import type { DatabaseTeamEventType } from "@/modules/organizations/event-types/services/output.service";
import { UserWithProfile } from "@/modules/users/users.repository";
import {
Controller,
@@ -132,10 +134,9 @@ export class EventTypesController_2024_06_14 {
throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
}
const responseEventType =
"hosts" in eventType
? await this.outputTeamEventTypesResponsePipe.transform(eventType)
: this.eventTypeResponseTransformPipe.transform(eventType);
const responseEventType = this.isTeamEventType(eventType)
? await this.outputTeamEventTypesResponsePipe.transform(eventType)
: this.eventTypeResponseTransformPipe.transform(eventType);
return {
status: SUCCESS_STATUS,
@@ -143,6 +144,12 @@ export class EventTypesController_2024_06_14 {
};
}
private isTeamEventType(
eventType: DatabaseTeamEventType | ({ ownerId: number } & DatabaseEventType)
): eventType is DatabaseTeamEventType {
return !!eventType.teamId;
}
@Get("/")
@ApiOperation({
summary: "Get all event types",
@@ -30,6 +30,7 @@ describe("Me Endpoints", () => {
let profilesRepositoryFixture: ProfileRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
const userEmail = `me-controller-user-${randomString()}@api.com`;
const name = "Me Controller User";
let user: User;
let org: Team;
@@ -55,6 +56,7 @@ describe("Me Endpoints", () => {
user = await userRepositoryFixture.create({
email: userEmail,
username: userEmail,
name,
});
org = await organizationsRepositoryFixture.create({
@@ -92,6 +94,7 @@ describe("Me Endpoints", () => {
expect(responseBody.data.id).toEqual(user.id);
expect(responseBody.data.email).toEqual(user.email);
expect(responseBody.data.name).toEqual(user.name);
expect(responseBody.data.timeFormat).toEqual(user.timeFormat);
expect(responseBody.data.defaultScheduleId).toEqual(user.defaultScheduleId);
expect(responseBody.data.weekStart).toEqual(user.weekStart);
@@ -23,6 +23,10 @@ export class MeOutput {
@DocsProperty()
email!: string;
@IsString()
@DocsProperty({ type: String, nullable: true })
name!: string | null;
@IsInt()
@DocsProperty()
timeFormat!: number;
@@ -15,6 +15,10 @@ import {
HostPriority,
EmailSettings_2024_06_14,
} from "@calcom/platform-types";
import type { EventType } from "@calcom/prisma/client";
export const HOSTS_REQUIRED_WHEN_SWITCHING_SCHEDULING_TYPE_ERROR =
"Hosts required when switching schedulingType. Please provide 'hosts' or set 'assignAllTeamMembers: true' to specify how hosts should be configured for the new scheduling type.";
export type TransformedCreateTeamEventTypeInput = Awaited<
ReturnType<InstanceType<typeof InputOrganizationsEventTypesService>["transformInputCreateTeamEventType"]>
@@ -116,10 +120,12 @@ export class InputOrganizationsEventTypesService {
teamId: number,
inputEventType: CreateTeamEventTypeInput_2024_06_14
) {
const { hosts, assignAllTeamMembers, locations, emailSettings, ...rest } = inputEventType;
const { assignAllTeamMembers, locations, emailSettings, ...rest } = inputEventType;
const eventType = this.inputEventTypesService.transformInputCreateEventType(rest);
const isManagedEventType = rest.schedulingType === "MANAGED";
const defaultLocations: CreateTeamEventTypeInput_2024_06_14["locations"] = [
{
type: "integration",
@@ -127,12 +133,13 @@ export class InputOrganizationsEventTypesService {
},
];
const children = await this.getChildEventTypesForManagedEventTypeCreate(inputEventType, teamId);
const children = isManagedEventType
? await this.getChildEventTypesForManagedEventTypeCreate(inputEventType, teamId)
: undefined;
let metadata =
rest.schedulingType === "MANAGED"
? { managedEventConfig: {}, ...eventType.metadata }
: eventType.metadata;
let metadata = isManagedEventType
? { managedEventConfig: {}, ...eventType.metadata }
: eventType.metadata;
if (emailSettings) {
metadata = this.addEmailSettingsToMetadata(emailSettings, metadata);
@@ -140,12 +147,7 @@ export class InputOrganizationsEventTypesService {
const teamEventType = {
...eventType,
// note(Lauris): we don't populate hosts for managed event-types because they are handled by the children
hosts: !(rest.schedulingType === "MANAGED")
? assignAllTeamMembers
? await this.getAllTeamMembers(teamId, inputEventType.schedulingType)
: this.transformInputHosts(hosts, inputEventType.schedulingType)
: undefined,
hosts: await this.transformInputCreateTeamEventTypeHosts(teamId, inputEventType),
assignAllTeamMembers,
locations: this.transformInputTeamLocations(locations || defaultLocations),
metadata,
@@ -155,6 +157,24 @@ export class InputOrganizationsEventTypesService {
return teamEventType;
}
private async transformInputCreateTeamEventTypeHosts(
teamId: number,
inputEventType: CreateTeamEventTypeInput_2024_06_14
) {
const { hosts, assignAllTeamMembers, schedulingType } = inputEventType;
// note(Lauris): we don't populate hosts for managed event-types because they are handled by the children - each child managed event type is associated with
// a specific user and hosts property is only for team event types e.g round robin and collective.
if (schedulingType === "MANAGED") {
return undefined;
}
if (assignAllTeamMembers) {
return await this.getAllTeamMembers(teamId, inputEventType.schedulingType);
}
return this.transformInputHosts(hosts, inputEventType.schedulingType);
}
private addEmailSettingsToMetadata(
emailSettings: EmailSettings_2024_06_14,
metadata: NonNullable<EventTypeMetadata>
@@ -190,7 +210,7 @@ export class InputOrganizationsEventTypesService {
teamId: number,
inputEventType: UpdateTeamEventTypeInput_2024_06_14
) {
const { hosts, assignAllTeamMembers, locations, emailSettings, ...rest } = inputEventType;
const { assignAllTeamMembers, locations, emailSettings, ...rest } = inputEventType;
const eventType = await this.inputEventTypesService.transformInputUpdateEventType(rest, eventTypeId);
const dbEventType = await this.teamsEventTypesRepository.getTeamEventType(teamId, eventTypeId);
@@ -213,11 +233,11 @@ export class InputOrganizationsEventTypesService {
const teamEventType = {
...eventType,
// note(Lauris): we don't populate hosts for managed event-types because they are handled by the children
hosts: !children
? assignAllTeamMembers
? await this.getAllTeamMembers(teamId, dbEventType.schedulingType)
: this.transformInputHosts(hosts, dbEventType.schedulingType)
: undefined,
hosts: await this.transformInputUpdateTeamEventTypeHosts(
teamId,
dbEventType.schedulingType,
inputEventType
),
assignAllTeamMembers,
children,
locations: locations ? this.transformInputTeamLocations(locations) : undefined,
@@ -227,6 +247,33 @@ export class InputOrganizationsEventTypesService {
return teamEventType;
}
private async transformInputUpdateTeamEventTypeHosts(
teamId: number,
dbEventTypeSchedulingType: EventType["schedulingType"],
inputEventType: UpdateTeamEventTypeInput_2024_06_14
) {
const { hosts, assignAllTeamMembers } = inputEventType;
if (dbEventTypeSchedulingType === "MANAGED") {
// note(Lauris): we don't populate hosts for managed event-types because they are handled by the event type children
return undefined;
}
const isSchedulingTypeChanging =
inputEventType.schedulingType && inputEventType.schedulingType !== dbEventTypeSchedulingType;
if (isSchedulingTypeChanging && !assignAllTeamMembers && !hosts) {
throw new BadRequestException(HOSTS_REQUIRED_WHEN_SWITCHING_SCHEDULING_TYPE_ERROR);
}
const nextSchedulingType = inputEventType.schedulingType || dbEventTypeSchedulingType;
if (assignAllTeamMembers) {
return await this.getAllTeamMembers(teamId, nextSchedulingType);
}
return this.transformInputHosts(hosts, nextSchedulingType);
}
async getChildEventTypesForManagedEventTypeUpdate(
eventTypeId: number,
inputEventType: UpdateTeamEventTypeInput_2024_06_14,
@@ -273,7 +320,7 @@ export class InputOrganizationsEventTypesService {
}
async getChildEventTypesForManagedEventTypeCreate(
inputEventType: UpdateTeamEventTypeInput_2024_06_14,
inputEventType: Pick<CreateTeamEventTypeInput_2024_06_14, "assignAllTeamMembers" | "hosts">,
teamId: number
) {
const ownersIds = await this.getOwnersIdsForManagedEventTypeCreate(teamId, inputEventType);
@@ -289,7 +336,7 @@ export class InputOrganizationsEventTypesService {
async getOwnersIdsForManagedEventTypeCreate(
teamId: number,
inputEventType: UpdateTeamEventTypeInput_2024_06_14
inputEventType: Pick<CreateTeamEventTypeInput_2024_06_14, "assignAllTeamMembers" | "hosts">
) {
if (inputEventType.assignAllTeamMembers) {
return await this.getTeamUsersIds(teamId);
@@ -209,4 +209,99 @@ describe("WebhooksController (e2e)", () => {
.delete(`/v2/organizations/${org.id}/webhooks/${otherWebhook.id}`)
.expect(403);
});
describe("DELEGATION_CREDENTIAL_ERROR webhook trigger", () => {
let delegationWebhook: TeamWebhookOutputResponseDto["data"];
it("/organizations/:orgId/webhooks (POST) should create webhook with DELEGATION_CREDENTIAL_ERROR trigger", () => {
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/webhooks`)
.send({
subscriberUrl: "https://example.com/delegation-errors",
triggers: ["DELEGATION_CREDENTIAL_ERROR"],
active: true,
} satisfies CreateWebhookInputDto)
.expect(201)
.then(async (res) => {
expect(res.body.status).toBe("success");
expect(res.body.data).toMatchObject({
id: expect.any(String),
subscriberUrl: "https://example.com/delegation-errors",
triggers: ["DELEGATION_CREDENTIAL_ERROR"],
active: true,
teamId: org.id,
});
delegationWebhook = res.body.data;
});
});
it("/organizations/:orgId/webhooks/:webhookId (GET) should retrieve webhook with DELEGATION_CREDENTIAL_ERROR trigger", () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/webhooks/${delegationWebhook.id}`)
.expect(200)
.then((res) => {
expect(res.body.status).toBe("success");
expect(res.body.data).toMatchObject({
id: delegationWebhook.id,
subscriberUrl: "https://example.com/delegation-errors",
triggers: ["DELEGATION_CREDENTIAL_ERROR"],
active: true,
teamId: org.id,
});
});
});
it("/organizations/:orgId/webhooks/:webhookId (PATCH) should update webhook with DELEGATION_CREDENTIAL_ERROR trigger", () => {
return request(app.getHttpServer())
.patch(`/v2/organizations/${org.id}/webhooks/${delegationWebhook.id}`)
.send({
active: false,
subscriberUrl: "https://example.com/delegation-errors-updated",
} satisfies UpdateWebhookInputDto)
.expect(200)
.then((res) => {
expect(res.body.data.active).toBe(false);
expect(res.body.data.subscriberUrl).toBe("https://example.com/delegation-errors-updated");
expect(res.body.data.triggers).toEqual(["DELEGATION_CREDENTIAL_ERROR"]);
});
});
it("/organizations/:orgId/webhooks (POST) should allow combining DELEGATION_CREDENTIAL_ERROR with other triggers", () => {
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/webhooks`)
.send({
subscriberUrl: "https://example.com/combined-webhook",
triggers: ["BOOKING_CREATED", "DELEGATION_CREDENTIAL_ERROR"],
active: true,
} satisfies CreateWebhookInputDto)
.expect(201)
.then(async (res) => {
expect(res.body.status).toBe("success");
expect(res.body.data).toMatchObject({
id: expect.any(String),
subscriberUrl: "https://example.com/combined-webhook",
triggers: expect.arrayContaining(["BOOKING_CREATED", "DELEGATION_CREDENTIAL_ERROR"]),
active: true,
teamId: org.id,
});
await request(app.getHttpServer())
.delete(`/v2/organizations/${org.id}/webhooks/${res.body.data.id}`)
.expect(200);
});
});
it("/organizations/:orgId/webhooks/:webhookId (DELETE) should delete webhook with DELEGATION_CREDENTIAL_ERROR trigger", () => {
return request(app.getHttpServer())
.delete(`/v2/organizations/${org.id}/webhooks/${delegationWebhook.id}`)
.expect(200)
.then((res) => {
expect(res.body.status).toBe("success");
expect(res.body.data).toMatchObject({
id: delegationWebhook.id,
triggers: ["DELEGATION_CREDENTIAL_ERROR"],
teamId: org.id,
});
});
});
});
});
@@ -1,5 +1,6 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { HOSTS_REQUIRED_WHEN_SWITCHING_SCHEDULING_TYPE_ERROR } from "@/modules/organizations/event-types/services/input.service";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
@@ -758,6 +759,586 @@ describe("Organizations Event Types Endpoints", () => {
expect(expected.priority).toEqual(received?.priority);
}
describe("updating scheduling type", () => {
it("should return 400 error if schedulingType: managed is passed", async () => {
const createBody: CreateTeamEventTypeInput_2024_06_14 = {
title: `teams-event-types-scheduling-collective-${randomString()}`,
slug: `teams-event-types-scheduling-collective-${randomString()}`,
description: "Test collective event type",
lengthInMinutes: 60,
locations: [
{
type: "integration",
integration: "cal-video",
},
],
schedulingType: "COLLECTIVE",
hosts: [
{
userId: teamMember1.id,
},
],
};
const createResponse = await request(app.getHttpServer())
.post(`/v2/teams/${team.id}/event-types`)
.send(createBody)
.expect(201);
const createdEventType: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14> = createResponse.body;
const updateBody = {
schedulingType: "managed",
};
await request(app.getHttpServer())
.patch(`/v2/teams/${team.id}/event-types/${createdEventType.data.id}`)
.send(updateBody)
.expect(400);
await eventTypesRepositoryFixture.delete(createdEventType.data.id);
});
it("should require hosts when changing round robin event type to collective without providing hosts", async () => {
const createBody: CreateTeamEventTypeInput_2024_06_14 = {
title: `teams-event-types-scheduling-roundrobin-${randomString()}`,
slug: `teams-event-types-scheduling-roundrobin-${randomString()}`,
description: "Test round robin event type",
lengthInMinutes: 60,
locations: [
{
type: "integration",
integration: "cal-video",
},
],
schedulingType: "ROUND_ROBIN",
hosts: [
{
userId: teamMember1.id,
priority: "high",
},
{
userId: teamMember2.id,
priority: "low",
},
],
};
const createResponse = await request(app.getHttpServer())
.post(`/v2/teams/${team.id}/event-types`)
.send(createBody)
.expect(201);
const createdEventType: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14> = createResponse.body;
const updateBody = {
schedulingType: "collective",
};
const response = await request(app.getHttpServer())
.patch(`/v2/teams/${team.id}/event-types/${createdEventType.data.id}`)
.send(updateBody);
expect(response.status).toBe(400);
expect(response.body.error.message).toBe(HOSTS_REQUIRED_WHEN_SWITCHING_SCHEDULING_TYPE_ERROR);
await eventTypesRepositoryFixture.delete(createdEventType.data.id);
});
it("should require hosts when changing collective event type to roundRobin without providing hosts", async () => {
const createBody: CreateTeamEventTypeInput_2024_06_14 = {
title: `teams-event-types-scheduling-collective-${randomString()}`,
slug: `teams-event-types-scheduling-collective-${randomString()}`,
description: "Test collective event type",
lengthInMinutes: 60,
locations: [
{
type: "integration",
integration: "cal-video",
},
],
schedulingType: "COLLECTIVE",
hosts: [
{
userId: teamMember1.id,
},
{
userId: teamMember2.id,
},
],
};
const createResponse = await request(app.getHttpServer())
.post(`/v2/teams/${team.id}/event-types`)
.send(createBody)
.expect(201);
const createdEventType: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14> = createResponse.body;
const updateBody = {
schedulingType: "roundRobin",
};
const response = await request(app.getHttpServer())
.patch(`/v2/teams/${team.id}/event-types/${createdEventType.data.id}`)
.send(updateBody);
expect(response.status).toBe(400);
expect(response.body.error.message).toBe(HOSTS_REQUIRED_WHEN_SWITCHING_SCHEDULING_TYPE_ERROR);
await eventTypesRepositoryFixture.delete(createdEventType.data.id);
});
it("should change round robin event type to collective and pass new hosts", async () => {
const createBody: CreateTeamEventTypeInput_2024_06_14 = {
title: `teams-event-types-scheduling-roundrobin-${randomString()}`,
slug: `teams-event-types-scheduling-roundrobin-${randomString()}`,
description: "Test round robin event type",
lengthInMinutes: 60,
locations: [
{
type: "integration",
integration: "cal-video",
},
],
schedulingType: "ROUND_ROBIN",
hosts: [
{
userId: teamMember1.id,
priority: "high",
},
],
};
const createResponse = await request(app.getHttpServer())
.post(`/v2/teams/${team.id}/event-types`)
.send(createBody)
.expect(201);
const createdEventType: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14> = createResponse.body;
const updateBody = {
schedulingType: "collective",
hosts: [
{
userId: teamMember2.id,
},
],
};
const updateResponse = await request(app.getHttpServer())
.patch(`/v2/teams/${team.id}/event-types/${createdEventType.data.id}`)
.send(updateBody)
.expect(200);
const responseBody: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14> = updateResponse.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.schedulingType).toEqual("collective");
expect(responseBody.data.hosts).toHaveLength(1);
expect(responseBody.data.hosts[0].userId).toEqual(teamMember2.id);
await eventTypesRepositoryFixture.delete(createdEventType.data.id);
});
it("should change collective event type to roundRobin and pass new hosts", async () => {
const createBody = {
title: `teams-event-types-scheduling-collective-${randomString()}`,
slug: `teams-event-types-scheduling-collective-${randomString()}`,
description: "Test collective event type",
lengthInMinutes: 60,
locations: [
{
type: "integration",
integration: "cal-video",
},
],
schedulingType: "collective",
hosts: [
{
userId: teamMember1.id,
},
],
};
const createResponse = await request(app.getHttpServer())
.post(`/v2/teams/${team.id}/event-types`)
.send(createBody)
.expect(201);
const createdEventType: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14> = createResponse.body;
const updateBody = {
schedulingType: "roundRobin",
hosts: [
{
userId: teamMember2.id,
priority: "medium",
},
],
};
const updateResponse = await request(app.getHttpServer())
.patch(`/v2/teams/${team.id}/event-types/${createdEventType.data.id}`)
.send(updateBody)
.expect(200);
const responseBody: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14> = updateResponse.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.schedulingType).toEqual("roundRobin");
expect(responseBody.data.hosts).toHaveLength(1);
expect(responseBody.data.hosts[0].userId).toEqual(teamMember2.id);
await eventTypesRepositoryFixture.delete(createdEventType.data.id);
});
it("should change collective event type to roundRobin with assignAllTeamMembers: true", async () => {
const createBody: CreateTeamEventTypeInput_2024_06_14 = {
title: `teams-event-types-scheduling-collective-${randomString()}`,
slug: `teams-event-types-scheduling-collective-${randomString()}`,
description: "Test collective event type",
lengthInMinutes: 60,
locations: [
{
type: "integration",
integration: "cal-video",
},
],
schedulingType: "COLLECTIVE",
hosts: [
{
userId: teamMember1.id,
},
],
};
const createResponse = await request(app.getHttpServer())
.post(`/v2/teams/${team.id}/event-types`)
.send(createBody)
.expect(201);
const createdEventType: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14> = createResponse.body;
const updateBody = {
schedulingType: "roundRobin",
assignAllTeamMembers: true,
};
const updateResponse = await request(app.getHttpServer())
.patch(`/v2/teams/${team.id}/event-types/${createdEventType.data.id}`)
.send(updateBody)
.expect(200);
const responseBody: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14> = updateResponse.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.schedulingType).toEqual("roundRobin");
expect(responseBody.data.hosts).toHaveLength(3);
const hostUserIds = responseBody.data.hosts.map((host) => host.userId);
expect(hostUserIds).toContain(teamMember1.id);
expect(hostUserIds).toContain(teamMember2.id);
expect(hostUserIds).toContain(userAdmin.id);
await eventTypesRepositoryFixture.delete(createdEventType.data.id);
});
it("should change round robin event type to collective with assignAllTeamMembers: true", async () => {
const createBody: CreateTeamEventTypeInput_2024_06_14 = {
title: `teams-event-types-scheduling-roundrobin-${randomString()}`,
slug: `teams-event-types-scheduling-roundrobin-${randomString()}`,
description: "Test round robin event type",
lengthInMinutes: 60,
locations: [
{
type: "integration",
integration: "cal-video",
},
],
schedulingType: "ROUND_ROBIN",
hosts: [
{
userId: teamMember1.id,
priority: "high",
},
],
};
const createResponse = await request(app.getHttpServer())
.post(`/v2/teams/${team.id}/event-types`)
.send(createBody)
.expect(201);
const createdEventType: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14> = createResponse.body;
const updateBody = {
schedulingType: "collective",
assignAllTeamMembers: true,
};
const updateResponse = await request(app.getHttpServer())
.patch(`/v2/teams/${team.id}/event-types/${createdEventType.data.id}`)
.send(updateBody)
.expect(200);
const responseBody: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14> = updateResponse.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.schedulingType).toEqual("collective");
expect(responseBody.data.hosts).toHaveLength(3);
const hostUserIds = responseBody.data.hosts.map((host) => host.userId);
expect(hostUserIds).toContain(teamMember1.id);
expect(hostUserIds).toContain(teamMember2.id);
expect(hostUserIds).toContain(userAdmin.id);
await eventTypesRepositoryFixture.delete(createdEventType.data.id);
});
it("should preserve existing hosts when updating without changing scheduling type", async () => {
const createBody = {
title: `teams-event-types-scheduling-preserve-${randomString()}`,
slug: `teams-event-types-scheduling-preserve-${randomString()}`,
description: "Test preserve hosts",
lengthInMinutes: 60,
locations: [
{
type: "integration",
integration: "cal-video",
},
],
schedulingType: "collective",
hosts: [
{
userId: teamMember1.id,
},
{
userId: teamMember2.id,
},
],
};
const createResponse = await request(app.getHttpServer())
.post(`/v2/teams/${team.id}/event-types`)
.send(createBody)
.expect(201);
const createdEventType: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14> = createResponse.body;
const updateBody = {
title: "Updated title",
};
const updateResponse = await request(app.getHttpServer())
.patch(`/v2/teams/${team.id}/event-types/${createdEventType.data.id}`)
.send(updateBody)
.expect(200);
const responseBody: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14> = updateResponse.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.title).toEqual("Updated title");
expect(responseBody.data.schedulingType).toEqual("collective");
expect(responseBody.data.hosts).toHaveLength(2);
expect(responseBody.data.hosts.map((h) => h.userId)).toContain(teamMember1.id);
expect(responseBody.data.hosts.map((h) => h.userId)).toContain(teamMember2.id);
await eventTypesRepositoryFixture.delete(createdEventType.data.id);
});
});
describe("should update event type title", () => {
it("should preserve hosts when updating collective event type title", async () => {
const collectiveEventType = await eventTypesRepositoryFixture.create(
{
title: `collective-preserve-hosts-${randomString()}`,
slug: `collective-preserve-hosts-${randomString()}`,
length: 60,
team: {
connect: {
id: team.id,
},
},
schedulingType: "COLLECTIVE",
hosts: {
create: [
{
userId: teamMember1.id,
isFixed: true,
},
{
userId: teamMember2.id,
isFixed: true,
},
],
},
},
userAdmin.id
);
const newTitle = `updated-collective-title-${randomString()}`;
const updateBody = {
title: newTitle,
};
const response = await request(app.getHttpServer())
.patch(`/v2/teams/${team.id}/event-types/${collectiveEventType.id}`)
.send(updateBody)
.expect(200);
const responseBody: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.title).toEqual(newTitle);
expect(responseBody.data.schedulingType).toEqual("collective");
expect(responseBody.data.hosts).toHaveLength(2);
expect(responseBody.data.hosts.map((h) => h.userId)).toContain(teamMember1.id);
expect(responseBody.data.hosts.map((h) => h.userId)).toContain(teamMember2.id);
await eventTypesRepositoryFixture.delete(collectiveEventType.id);
});
it("should preserve hosts when updating round robin event type title", async () => {
const roundRobinEventType = await eventTypesRepositoryFixture.create(
{
title: `roundrobin-preserve-hosts-${randomString()}`,
slug: `roundrobin-preserve-hosts-${randomString()}`,
length: 60,
team: {
connect: {
id: team.id,
},
},
schedulingType: "ROUND_ROBIN",
hosts: {
create: [
{
userId: teamMember1.id,
isFixed: false,
priority: 2,
},
{
userId: teamMember2.id,
isFixed: true,
priority: 1,
},
],
},
},
userAdmin.id
);
const newTitle = `updated-roundrobin-title-${randomString()}`;
const updateBody = {
title: newTitle,
};
const response = await request(app.getHttpServer())
.patch(`/v2/teams/${team.id}/event-types/${roundRobinEventType.id}`)
.send(updateBody)
.expect(200);
const responseBody: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.title).toEqual(newTitle);
expect(responseBody.data.schedulingType).toEqual("roundRobin");
expect(responseBody.data.hosts).toHaveLength(2);
expect(responseBody.data.hosts.map((h) => h.userId)).toContain(teamMember1.id);
expect(responseBody.data.hosts.map((h) => h.userId)).toContain(teamMember2.id);
await eventTypesRepositoryFixture.delete(roundRobinEventType.id);
});
});
describe("should update event type hosts", () => {
it("should update collective event type hosts", async () => {
const collectiveEventType = await eventTypesRepositoryFixture.create(
{
title: `collective-update-hosts-${randomString()}`,
slug: `collective-update-hosts-${randomString()}`,
length: 60,
team: {
connect: {
id: team.id,
},
},
schedulingType: "COLLECTIVE",
hosts: {
create: [
{
userId: teamMember1.id,
isFixed: true,
},
],
},
},
userAdmin.id
);
const updateBody = {
hosts: [
{
userId: teamMember2.id,
},
],
};
const response = await request(app.getHttpServer())
.patch(`/v2/teams/${team.id}/event-types/${collectiveEventType.id}`)
.send(updateBody)
.expect(200);
const responseBody: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.schedulingType).toEqual("collective");
expect(responseBody.data.hosts).toHaveLength(1);
expect(responseBody.data.hosts[0].userId).toEqual(teamMember2.id);
await eventTypesRepositoryFixture.delete(collectiveEventType.id);
});
it("should update round robin event type hosts", async () => {
const roundRobinEventType = await eventTypesRepositoryFixture.create(
{
title: `roundrobin-update-hosts-${randomString()}`,
slug: `roundrobin-update-hosts-${randomString()}`,
length: 60,
team: {
connect: {
id: team.id,
},
},
schedulingType: "ROUND_ROBIN",
hosts: {
create: [
{
userId: teamMember1.id,
isFixed: true,
priority: 1,
},
],
},
},
userAdmin.id
);
const updateBody = {
hosts: [
{
userId: teamMember2.id,
priority: "high",
},
],
};
const response = await request(app.getHttpServer())
.patch(`/v2/teams/${team.id}/event-types/${roundRobinEventType.id}`)
.send(updateBody)
.expect(200);
const responseBody: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.schedulingType).toEqual("roundRobin");
expect(responseBody.data.hosts).toHaveLength(1);
expect(responseBody.data.hosts[0].userId).toEqual(teamMember2.id);
await eventTypesRepositoryFixture.delete(roundRobinEventType.id);
});
});
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(userAdmin.email);
await userRepositoryFixture.deleteByEmail(teamMember1.email);
@@ -1,12 +1,20 @@
import { PipedInputWebhookType } from "@/modules/webhooks/pipes/WebhookInputPipe";
import { WebhooksRepository } from "@/modules/webhooks/webhooks.repository";
import { ConflictException, Injectable } from "@nestjs/common";
import { BadRequestException, ConflictException, Injectable } from "@nestjs/common";
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
@Injectable()
export class EventTypeWebhooksService {
constructor(private readonly webhooksRepository: WebhooksRepository) {}
async createEventTypeWebhook(eventTypeId: number, body: PipedInputWebhookType) {
if (body.eventTriggers.includes(WebhookTriggerEvents.DELEGATION_CREDENTIAL_ERROR)) {
throw new BadRequestException(
"DELEGATION_CREDENTIAL_ERROR trigger is only available for organization webhooks"
);
}
const existingWebhook = await this.webhooksRepository.getEventTypeWebhookByUrl(
eventTypeId,
body.subscriberUrl
@@ -1,12 +1,20 @@
import { PipedInputWebhookType } from "@/modules/webhooks/pipes/WebhookInputPipe";
import { WebhooksRepository } from "@/modules/webhooks/webhooks.repository";
import { ConflictException, Injectable } from "@nestjs/common";
import { BadRequestException, ConflictException, Injectable } from "@nestjs/common";
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
@Injectable()
export class UserWebhooksService {
constructor(private readonly webhooksRepository: WebhooksRepository) {}
async createUserWebhook(userId: number, body: PipedInputWebhookType) {
if (body.eventTriggers.includes(WebhookTriggerEvents.DELEGATION_CREDENTIAL_ERROR)) {
throw new BadRequestException(
"DELEGATION_CREDENTIAL_ERROR trigger is only available for organization webhooks"
);
}
const existingWebhook = await this.webhooksRepository.getUserWebhookByUrl(userId, body.subscriberUrl);
if (existingWebhook) {
throw new ConflictException("Webhook with this subscriber url already exists for this user");
@@ -0,0 +1,62 @@
"use client";
import { useQueryState } from "nuqs";
import { useEffect } from "react";
import { activeFiltersParser } from "@calcom/features/data-table/lib/parsers";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import useMediaQuery from "@calcom/lib/hooks/useMediaQuery";
import { ToggleGroup } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import { viewParser, type BookingView } from "~/bookings/lib/viewParser";
export function ViewToggleButton() {
const { t } = useLocale();
const [view, setView] = useQueryState(
"view",
viewParser.withDefault("list").withOptions({ clearOnDefault: true })
);
const [, setActiveFilters] = useQueryState("activeFilters", activeFiltersParser);
const isMobile = !useMediaQuery("(min-width: 640px)");
useEffect(() => {
// Force list view on mobile
if (isMobile && view !== "list") {
setView("list");
}
}, [isMobile, view, setView]);
return (
<div className="hidden sm:block">
<ToggleGroup
value={view}
onValueChange={(value) => {
if (!value) return;
const newView = value as BookingView;
// When switching from calendar to list view, remove the dateRange filter
if (view === "calendar" && newView === "list") {
setActiveFilters((prev) => prev?.filter((filter) => filter.f !== "dateRange") ?? []);
}
setView(newView);
}}
options={[
{
value: "list",
label: "",
tooltip: t("list_view"),
iconLeft: <Icon name="menu" className="h-4 w-4" />,
},
{
value: "calendar",
label: "",
tooltip: t("calendar_view"),
iconLeft: <Icon name="calendar" className="h-4 w-4" />,
},
]}
/>
</div>
);
}
@@ -6,9 +6,7 @@ import { redirect } from "next/navigation";
import { z } from "zod";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
@@ -16,6 +14,8 @@ import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import { validStatuses } from "~/bookings/lib/validStatuses";
import BookingsList from "~/bookings/views/bookings-view";
import { ViewToggleButton } from "./ViewToggleButton";
const querySchema = z.object({
status: z.enum(validStatuses),
});
@@ -54,18 +54,12 @@ const Page = async ({ params }: PageProps) => {
canReadOthersBookings = teamIdsWithPermission.length > 0;
}
const featuresRepository = new FeaturesRepository(prisma);
const isCalendarViewEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally(
"booking-calendar-view"
);
return (
<ShellMainAppDir heading={t("bookings")} subtitle={t("bookings_description")}>
<ShellMainAppDir heading={t("bookings")} CTA={<ViewToggleButton />}>
<BookingsList
status={parsed.data.status}
userId={session?.user?.id}
permissions={{ canReadOthersBookings }}
isCalendarViewEnabled={isCalendarViewEnabled}
/>
</ShellMainAppDir>
);
@@ -1,4 +1,3 @@
import { createRouterCaller } from "app/_trpc/context";
import { _generateMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
@@ -33,3 +32,4 @@ const ServerPage = async () => {
};
export default ServerPage;
@@ -0,0 +1,35 @@
import { _generateMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { APP_NAME } from "@calcom/lib/constants";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import { OrganizationInviteEmailView } from "~/onboarding/organization/invite/email/organization-invite-email-view";
export const generateMetadata = async () => {
return await _generateMetadata(
(t) => `${APP_NAME} - ${t("invite_teammates")}`,
() => "",
true,
undefined,
"/onboarding/organization/invite/email"
);
};
const ServerPage = async () => {
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user?.id) {
return redirect("/auth/login");
}
const userEmail = session.user.email || "";
return <OrganizationInviteEmailView userEmail={userEmail} />;
};
export default ServerPage;
@@ -0,0 +1,35 @@
import { _generateMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { APP_NAME } from "@calcom/lib/constants";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import { TeamInviteEmailView } from "~/onboarding/teams/invite/email/team-invite-email-view";
export const generateMetadata = async () => {
return await _generateMetadata(
(t) => `${APP_NAME} - ${t("invite_teammates")}`,
() => "",
true,
undefined,
"/onboarding/teams/invite/email"
);
};
const ServerPage = async () => {
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user?.id) {
return redirect("/auth/login");
}
const userEmail = session.user.email || "";
return <TeamInviteEmailView userEmail={userEmail} />;
};
export default ServerPage;
@@ -1,7 +1,7 @@
import type { PageProps } from "app/_types";
import { getTranslate, _generateMetadata } from "app/_utils";
import { _generateMetadata, getTranslate } from "app/_utils";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import SettingsHeaderWithBackButton from "@calcom/features/settings/appDir/SettingsHeaderWithBackButton";
import { WebhookRepository } from "@calcom/features/webhooks/lib/repository/WebhookRepository";
import { EditWebhookView } from "@calcom/features/webhooks/pages/webhook-edit-view";
import { APP_NAME } from "@calcom/lib/constants";
@@ -24,13 +24,12 @@ const Page = async ({ params: _params }: PageProps) => {
const webhook = await webhookRepository.findByWebhookId(id);
return (
<SettingsHeader
<SettingsHeaderWithBackButton
title={t("edit_webhook")}
description={t("add_webhook_description", { appName: APP_NAME })}
borderInShellHeader={true}
backButton>
borderInShellHeader={true}>
<EditWebhookView webhook={webhook} />
</SettingsHeader>
</SettingsHeaderWithBackButton>
);
};
+15 -2
View File
@@ -79,15 +79,28 @@ async function handler(req: NextRequest) {
clientId: client_id,
};
const accessTokenExpiresIn = 1800; // 30 minutes
const access_token = jwt.sign(payloadAuthToken, secretKey, {
expiresIn: 1800, // 30 min
expiresIn: accessTokenExpiresIn,
});
const refresh_token = jwt.sign(payloadRefreshToken, secretKey, {
expiresIn: 30 * 24 * 60 * 60, // 30 days
});
return NextResponse.json({ access_token, refresh_token }, { status: 200 });
// @see https://datatracker.ietf.org/doc/html/rfc6749#section-5.1
return NextResponse.json(
{ access_token, token_type: "bearer", refresh_token, 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);
@@ -3,7 +3,7 @@ import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import dayjs from "@calcom/dayjs";
import { sendOrganizerRequestReminderEmail } from "@calcom/emails";
import { sendOrganizerRequestReminderEmail } from "@calcom/emails/email-manager";
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
import { isPrismaObjOrUndefined } from "@calcom/lib/isPrismaObj";
import { parseRecurringEvent } from "@calcom/lib/isRecurringEvent";
@@ -4,7 +4,7 @@ import { NextResponse } from "next/server";
import { z } from "zod";
import dayjs from "@calcom/dayjs";
import { sendMonthlyDigestEmails } from "@calcom/emails/email-manager";
import { sendMonthlyDigestEmail } from "@calcom/emails/workflow-email-service";
import { EventsInsights } from "@calcom/features/insights/server/events";
import { getTranslation } from "@calcom/lib/server/i18n";
import prisma from "@calcom/prisma";
@@ -295,7 +295,7 @@ async function postHandler(request: NextRequest) {
// Only send email if user has allowed to receive monthly digest emails
if (owner.receiveMonthlyDigestEmail) {
await sendMonthlyDigestEmails({
await sendMonthlyDigestEmail({
...EventData,
admin: { email: owner?.email ?? "", name: owner?.name ?? "" },
language: t,
+1 -1
View File
@@ -1,7 +1,7 @@
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
import { NextResponse } from "next/server";
import { renderEmail } from "@calcom/emails";
import renderEmail from "@calcom/emails/src/renderEmail";
import { IS_PRODUCTION } from "@calcom/lib/constants";
import { getTranslation } from "@calcom/lib/server/i18n";
+7
View File
@@ -0,0 +1,7 @@
import { NextResponse } from "next/server";
import getIP from "@calcom/lib/getIP";
export async function GET(req: Request) {
const requestorIp = getIP(req);
return NextResponse.json({ ip: requestorIp });
}
+68 -21
View File
@@ -1,36 +1,83 @@
import React from "react";
import { SkeletonText } from "@calcom/ui/components/skeleton";
import classNames from "@calcom/ui/classNames";
import { SkeletonAvatar, SkeletonText } from "@calcom/ui/components/skeleton";
function SkeletonLoader() {
return (
<ul className="divide-subtle border-subtle bg-default animate-pulse divide-y rounded-md border sm:overflow-hidden">
<SkeletonItem />
<SkeletonItem />
<SkeletonItem />
</ul>
<div className="animate-pulse">
{/* Table rows with separator at the beginning */}
<div className="divide-subtle divide-y">
{/* Month separator skeleton */}
<div className="bg-muted rounded-t py-2">
<SkeletonItem isHeader={true} />
</div>
<div className="bg-muted">
<SkeletonText className="ml-2 mt-3 h-4 w-28 rounded" />
</div>
<SkeletonItem />
<SkeletonItem />
<SkeletonItem />
<SkeletonItem />
<SkeletonItem />
</div>
</div>
);
}
export default SkeletonLoader;
function SkeletonItem() {
function SkeletonItem({ isHeader = false }: { isHeader?: boolean }) {
return (
<li className="group flex w-full items-center justify-between px-4 py-4 sm:px-6">
<div className="flex-grow truncate text-sm">
<div className="flex">
<div className="flex flex-col space-y-2">
<SkeletonText className="h-5 w-16" />
<SkeletonText className="h-4 w-32" />
</div>
</div>
<div
className={classNames(
"grid grid-cols-[132px_130px_185px_150px_140px_280px] gap-6 px-2",
isHeader ? "py-2" : "py-2.5"
)}>
{/* Date column - 140px */}
<div className="flex items-center">
<SkeletonText className={classNames("h-4 rounded", isHeader ? "w-12" : "w-20")} />
</div>
<div className="mt-4 hidden flex-shrink-0 sm:ml-5 sm:mt-0 lg:flex">
<div className="flex justify-between space-x-2 rtl:space-x-reverse">
<SkeletonText className="h-6 w-16" />
<SkeletonText className="h-6 w-32" />
</div>
{/* Time column - 140px */}
<div className="flex items-center">
<SkeletonText className={classNames("h-4 rounded", isHeader ? "w-12" : "w-28")} />
</div>
</li>
{/* Event column - 200px */}
<div className="flex items-center">
<SkeletonText className={classNames("h-4 rounded", isHeader ? "w-16" : "w-full")} />
</div>
{/* Who column - 160px, Avatar group */}
<div className="flex items-center -space-x-1">
{isHeader && <SkeletonText className="h-4 w-20 rounded" />}
{!isHeader && (
<>
<SkeletonAvatar className="h-6 w-6 rounded-full" />
<SkeletonAvatar className="h-6 w-6 rounded-full" />
<SkeletonAvatar className="h-6 w-6 rounded-full" />
</>
)}
</div>
{/* Team column - 140px */}
<div className="flex items-center">
<SkeletonText className="h-4 w-20 rounded-md" />
</div>
{/* Actions column - 280px */}
<div className="mr-2 flex items-center justify-end gap-2">
{isHeader && <SkeletonText className="h-4 w-16 rounded-md" />}
{!isHeader && (
<>
<SkeletonText className="h-4 w-16 rounded-md" />
<SkeletonText className="h-4 w-20 rounded-md" />
<SkeletonText className="h-4 w-8 rounded-md" />
</>
)}
</div>
</div>
);
}
@@ -1,4 +1,3 @@
import Image from "next/image";
import Link from "next/link";
import { useState } from "react";
import { Controller, useFieldArray, useForm } from "react-hook-form";
@@ -46,8 +45,12 @@ import { Tooltip } from "@calcom/ui/components/tooltip";
import assignmentReasonBadgeTitleMap from "@lib/booking/assignmentReasonBadgeTitleMap";
import { BookingActionsDropdown } from "./BookingActionsDropdown";
import { useBookingActionsStoreContext, BookingActionsStoreProvider } from "./BookingActionsStoreProvider";
import { buildBookingLink } from "../../modules/bookings/lib/buildBookingLink";
import { BookingActionsDropdown } from "./actions/BookingActionsDropdown";
import {
useBookingActionsStoreContext,
BookingActionsStoreProvider,
} from "./actions/BookingActionsStoreProvider";
import {
getPendingActions,
getCancelEventAction,
@@ -56,7 +59,7 @@ import {
shouldShowIndividualReportButton,
type BookingActionContext,
getReportAction,
} from "./bookingActions";
} from "./actions/bookingActions";
import type { BookingItemProps } from "./types";
type ParsedBooking = ReturnType<typeof buildParsedBooking>;
@@ -94,10 +97,51 @@ const isBookingReroutable = (booking: ParsedBooking): booking is ReroutableBooki
return !!booking.routedFromRoutingFormReponse && !!booking.eventType?.team;
};
const ConditionalLink = ({
children,
onClick,
bookingLink,
className,
}: {
children: React.ReactNode;
onClick?: () => void;
bookingLink: string;
className?: string;
}) => {
const { t } = useLocale();
if (onClick) {
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onClick();
}
};
return (
<div
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={handleKeyDown}
className={className}
aria-label={t("view_booking_details")}>
{children}
</div>
);
}
return (
<Link href={bookingLink} className={className}>
{children}
</Link>
);
};
function BookingListItem(booking: BookingItemProps) {
const parsedBooking = buildParsedBooking(booking);
const { userTimeZone, userTimeFormat, userEmail } = booking.loggedInUser;
const { onClick } = booking;
const {
t,
i18n: { language },
@@ -258,15 +302,11 @@ function BookingListItem(booking: BookingItemProps) {
.concat(booking.recurringInfo?.bookings[BookingStatus.PENDING])
.sort((date1: Date, date2: Date) => date1.getTime() - date2.getTime());
const buildBookingLink = () => {
const urlSearchParams = new URLSearchParams({
allRemainingBookings: isTabRecurring.toString(),
});
if (booking.attendees?.[0]?.email) urlSearchParams.set("email", booking.attendees[0].email);
return `/booking/${booking.uid}?${urlSearchParams.toString()}`;
};
const bookingLink = buildBookingLink();
const bookingLink = buildBookingLink({
bookingUid: booking.uid,
allRemainingBookings: isTabRecurring,
email: booking.attendees?.[0]?.email,
});
const title = booking.title;
@@ -321,7 +361,7 @@ function BookingListItem(booking: BookingItemProps) {
{eventTypeColor && (
<div className="h-[70%] w-0.5" style={{ backgroundColor: eventTypeColor }} />
)}
<Link href={bookingLink} className="ml-3">
<ConditionalLink onClick={onClick} bookingLink={bookingLink} className="ml-3">
<div className="cursor-pointer py-4">
<div className="text-emphasis text-sm leading-6">{startTime}</div>
<div className="text-subtle text-sm">
@@ -350,7 +390,8 @@ function BookingListItem(booking: BookingItemProps) {
className="text-sm leading-6 text-blue-600 hover:underline dark:text-blue-400">
<div className="flex items-center gap-2">
{provider?.iconUrl && (
<Image
// eslint-disable-next-line @next/next/no-img-element
<img
src={provider.iconUrl}
width={16}
height={16}
@@ -367,11 +408,11 @@ function BookingListItem(booking: BookingItemProps) {
</div>
)}
</div>
</Link>
</ConditionalLink>
</div>
</div>
<div data-testid="title-and-attendees" className={`w-full px-4${isRejected ? "line-through" : ""}`}>
<Link href={bookingLink}>
<ConditionalLink onClick={onClick} bookingLink={bookingLink}>
{/* Time and Badges for mobile */}
<div className="w-full pb-2 pt-4 sm:hidden">
<div className="flex w-full items-center justify-between sm:hidden">
@@ -454,7 +495,7 @@ function BookingListItem(booking: BookingItemProps) {
</div>
)}
</div>
</Link>
</ConditionalLink>
</div>
<div className="flex w-full flex-col flex-wrap items-end justify-end space-x-2 space-y-2 py-4 pl-4 text-right text-sm font-medium ltr:pr-4 rtl:pl-4 sm:flex-row sm:flex-nowrap sm:items-start sm:space-y-0 sm:pl-0">
{shouldShowPendingActions(actionContext) && <TableActions actions={pendingActions} />}
@@ -15,9 +15,9 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownMenuPortal,
} from "@calcom/ui/components/dropdown";
import { TextAreaField } from "@calcom/ui/components/form";
import type { ActionType } from "@calcom/ui/components/table";
@@ -31,6 +31,8 @@ import { ReportBookingDialog } from "@components/dialog/ReportBookingDialog";
import { RerouteDialog } from "@components/dialog/RerouteDialog";
import { RescheduleDialog } from "@components/dialog/RescheduleDialog";
import { buildBookingLink } from "../../../modules/bookings/lib/buildBookingLink";
import type { BookingItemProps } from "../types";
import { useBookingActionsStoreContext } from "./BookingActionsStoreProvider";
import {
getCancelEventAction,
@@ -38,15 +40,29 @@ import {
getAfterEventActions,
getReportAction,
shouldShowEditActions,
shouldShowPendingActions,
getPendingActions,
type BookingActionContext,
} from "./bookingActions";
import type { BookingItemProps } from "./types";
interface BookingActionsDropdownProps {
booking: BookingItemProps;
size?: "xs" | "sm" | "base" | "lg";
className?: string;
/**
* Whether to use a portal for the dropdown menu.
* Set to false when rendering inside a Sheet/Dialog to keep the dropdown within the modal's stacking context.
* @default true
*/
usePortal?: boolean;
}
export function BookingActionsDropdown({ booking }: BookingActionsDropdownProps) {
export function BookingActionsDropdown({
booking,
size = "base",
className,
usePortal = true,
}: BookingActionsDropdownProps) {
const { t } = useLocale();
const utils = trpc.useUtils();
@@ -162,6 +178,13 @@ export function BookingActionsDropdown({ booking }: BookingActionsDropdownProps)
const isBookingFromRoutingForm = !!booking.routedFromRoutingFormReponse && !!booking.eventType?.team;
// Build booking confirmation link
const bookingLink = buildBookingLink({
bookingUid: booking.uid,
allRemainingBookings: isRecurring,
email: booking.attendees?.[0]?.email,
});
const userEmail = booking.loggedInUser.userEmail;
const userSeat = booking.seatsReferences.find((seat) => !!userEmail && seat.attendee?.email === userEmail);
const isAttendee = !!userSeat;
@@ -209,6 +232,10 @@ export function BookingActionsDropdown({ booking }: BookingActionsDropdownProps)
}
};
// Calculate showPendingPayment based on payment logic
const hasPayment = booking.payment.length > 0;
const showPendingPayment = hasPayment;
const actionContext: BookingActionContext = {
booking,
isUpcoming,
@@ -226,7 +253,7 @@ export function BookingActionsDropdown({ booking }: BookingActionsDropdownProps)
isDisabledCancelling,
isDisabledRescheduling,
isCalVideoLocation,
showPendingPayment: false, // This will be calculated below
showPendingPayment,
isAttendee,
cardCharged,
attendeeList,
@@ -236,9 +263,25 @@ export function BookingActionsDropdown({ booking }: BookingActionsDropdownProps)
const cancelEventAction = getCancelEventAction(actionContext);
// Get pending actions (accept/reject)
const shouldShowPending = shouldShowPendingActions(actionContext);
const basePendingActions = shouldShowPending ? getPendingActions(actionContext) : [];
const pendingActions: ActionType[] = basePendingActions.map((action) => ({
...action,
disabled: mutation.isPending,
onClick:
action.id === "confirm"
? () => bookingConfirm(true)
: action.id === "reject"
? () => setRejectionDialogIsOpen(true)
: undefined,
})) as ActionType[];
const shouldShowEdit = shouldShowEditActions(actionContext);
const baseEditEventActions = getEditEventActions(actionContext);
const editEventActions: ActionType[] = baseEditEventActions.map((action) => ({
...action,
disabled: !shouldShowEdit || action.disabled, // Disable all edit actions if shouldn't show edit actions
onClick:
action.id === "reschedule_request"
? () => setIsOpenRescheduleDialog(true)
@@ -494,26 +537,94 @@ export function BookingActionsDropdown({ booking }: BookingActionsDropdownProps)
</>
);
// Don't render dropdown if edit actions shouldn't be shown
if (!shouldShowEditActions(actionContext)) {
// Check if there are any available actions across all action groups
const hasAnyAvailableActions = () => {
// Check if any pending action is available
const hasAvailablePendingAction = pendingActions.some((action) => !action.disabled);
// Check if any edit action is available
const hasAvailableEditAction = editEventActions.some((action) => !action.disabled);
// Check if any after event action is available
const hasAvailableAfterAction = afterEventActions.some((action) => !action.disabled);
// Check report and cancel actions
const isReportAvailable = !reportActionWithHandler.disabled;
const isCancelAvailable = !cancelEventAction.disabled;
return (
hasAvailablePendingAction ||
hasAvailableEditAction ||
hasAvailableAfterAction ||
isReportAvailable ||
isCancelAvailable
);
};
// Don't render dropdown if no actions are available
if (!hasAnyAvailableActions()) {
return dialogs;
}
// Conditional portal wrapper to avoid portal when inside Sheet/Dialog
const ConditionalPortal = usePortal
? DropdownMenuPortal
: ({ children }: { children: React.ReactNode }) => <>{children}</>;
return (
<>
{dialogs}
<Dropdown>
<DropdownMenuTrigger asChild>
<Dropdown modal={false}>
<DropdownMenuTrigger asChild data-testid="booking-actions-dropdown">
<Button
type="button"
color="secondary"
variant="icon"
size={size}
StartIcon="ellipsis"
data-testid="booking-actions-dropdown"
className={className}
// Prevent click from bubbling to parent row/container click handlers
onClick={(e) => e.stopPropagation()}
/>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<ConditionalPortal>
<DropdownMenuContent>
<DropdownMenuItem className="rounded-lg">
<DropdownItem
type="button"
StartIcon="external-link"
href={bookingLink}
target="_blank"
data-testid="view-booking"
onClick={(e) => e.stopPropagation()}>
{t("view")}
</DropdownItem>
</DropdownMenuItem>
<DropdownMenuSeparator />
{pendingActions.length > 0 && (
<>
<DropdownMenuLabel className="px-2 pb-1 pt-1.5">{t("booking_response")}</DropdownMenuLabel>
{pendingActions.map((action) => (
<DropdownMenuItem className="rounded-lg" key={action.id} disabled={action.disabled}>
<DropdownItem
type="button"
color={action.color}
StartIcon={action.icon}
href={action.href}
disabled={action.disabled}
onClick={(e) => {
e.stopPropagation();
action.onClick?.(e);
}}
data-bookingid={action.bookingId}
data-testid={action.id}
className={action.disabled ? "text-muted" : undefined}>
{action.label}
</DropdownItem>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
</>
)}
<DropdownMenuLabel className="px-2 pb-1 pt-1.5">{t("edit_event")}</DropdownMenuLabel>
{editEventActions.map((action) => (
<DropdownMenuItem className="rounded-lg" key={action.id} disabled={action.disabled}>
@@ -523,7 +634,10 @@ export function BookingActionsDropdown({ booking }: BookingActionsDropdownProps)
StartIcon={action.icon}
href={action.href}
disabled={action.disabled}
onClick={action.onClick}
onClick={(e) => {
e.stopPropagation();
action.onClick?.(e);
}}
data-bookingid={action.bookingId}
data-testid={action.id}
className={action.disabled ? "text-muted" : undefined}>
@@ -540,7 +654,10 @@ export function BookingActionsDropdown({ booking }: BookingActionsDropdownProps)
color={action.color}
StartIcon={action.icon}
href={action.href}
onClick={action.onClick}
onClick={(e) => {
e.stopPropagation();
action.onClick?.(e);
}}
disabled={action.disabled}
data-bookingid={action.bookingId}
data-testid={action.id}
@@ -559,7 +676,10 @@ export function BookingActionsDropdown({ booking }: BookingActionsDropdownProps)
type="button"
color={reportActionWithHandler.color}
StartIcon={reportActionWithHandler.icon}
onClick={reportActionWithHandler.onClick}
onClick={(e) => {
e.stopPropagation();
reportActionWithHandler.onClick?.();
}}
disabled={reportActionWithHandler.disabled}
data-testid={reportActionWithHandler.id}
className={reportActionWithHandler.disabled ? "text-muted" : undefined}>
@@ -577,7 +697,10 @@ export function BookingActionsDropdown({ booking }: BookingActionsDropdownProps)
color={cancelEventAction.color}
StartIcon={cancelEventAction.icon}
href={cancelEventAction.disabled ? undefined : cancelEventAction.href}
onClick={cancelEventAction.onClick}
onClick={(e) => {
e.stopPropagation();
cancelEventAction.onClick?.(e);
}}
disabled={cancelEventAction.disabled}
data-bookingid={cancelEventAction.bookingId}
data-testid={cancelEventAction.id}
@@ -586,7 +709,7 @@ export function BookingActionsDropdown({ booking }: BookingActionsDropdownProps)
</DropdownItem>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</ConditionalPortal>
</Dropdown>
</>
);
@@ -54,7 +54,7 @@ function createMockContext(overrides: Partial<BookingActionContext> = {}): Booki
locale: "en",
bookingId: 1,
noShow: false,
} as any,
},
],
user: {
id: 1,
@@ -137,18 +137,18 @@ describe("Booking Actions", () => {
expect(actions).toHaveLength(2);
expect(actions[0]).toEqual({
id: "reject",
label: "reject",
icon: "ban",
disabled: false,
});
expect(actions[1]).toEqual({
id: "confirm",
bookingId: 1,
label: "confirm",
icon: "check",
disabled: false,
});
expect(actions[1]).toEqual({
id: "reject",
label: "reject",
icon: "ban",
disabled: false,
});
});
it("should return reject action only for non-pending booking", () => {
@@ -167,8 +167,8 @@ describe("Booking Actions", () => {
});
const actions = getPendingActions(context);
expect(actions[0].label).toBe("reject_all");
expect(actions[1].label).toBe("confirm_all");
expect(actions[0].label).toBe("confirm_all");
expect(actions[1].label).toBe("reject_all");
});
});
@@ -1,7 +1,7 @@
import { BookingStatus, SchedulingType } from "@calcom/prisma/enums";
import type { ActionType } from "@calcom/ui/components/table";
import type { BookingItemProps } from "./types";
import type { BookingItemProps } from "../types";
export interface BookingActionContext {
booking: BookingItemProps;
@@ -38,14 +38,7 @@ export function getPendingActions(context: BookingActionContext): ActionType[] {
const { booking, isPending, isTabRecurring, isTabUnconfirmed, isRecurring, showPendingPayment, t } =
context;
const actions: ActionType[] = [
{
id: "reject",
label: (isTabRecurring || isTabUnconfirmed) && isRecurring ? t("reject_all") : t("reject"),
icon: "ban",
disabled: false, // This would be controlled by mutation state in the component
},
];
const actions: ActionType[] = [];
// For bookings with payment, only confirm if the booking is paid for
// Original logic: (isPending && !paymentAppData.enabled) || (paymentAppData.enabled && !!paymentAppData.price && booking.paid)
@@ -59,6 +52,13 @@ export function getPendingActions(context: BookingActionContext): ActionType[] {
});
}
actions.push({
id: "reject",
label: (isTabRecurring || isTabUnconfirmed) && isRecurring ? t("reject_all") : t("reject"),
icon: "ban",
disabled: false, // This would be controlled by mutation state in the component
});
return actions;
}
+2 -1
View File
@@ -1,6 +1,6 @@
import type { RouterInputs, RouterOutputs } from "@calcom/trpc/react";
type BookingListingStatus = RouterInputs["viewer"]["bookings"]["get"]["filters"]["status"];
export type BookingListingStatus = RouterInputs["viewer"]["bookings"]["get"]["filters"]["status"];
type BookingItem = RouterOutputs["viewer"]["bookings"]["get"]["bookings"][number];
@@ -14,4 +14,5 @@ export type BookingItemProps = BookingItem & {
userEmail: string | undefined;
};
isToday: boolean;
onClick?: () => void;
};
@@ -36,7 +36,7 @@ export const RescheduleDialog = (props: IRescheduleDialog) => {
return (
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
<DialogContent enableOverflow>
<DialogContent enableOverflow data-testid="reschedule-dialog">
<div className="flex flex-row space-x-3">
<div className="bg-subtle flex h-10 w-10 flex-shrink-0 justify-center rounded-full ">
<Icon name="clock" className="m-auto h-6 w-6" />
@@ -6,6 +6,7 @@ import { Controller, useForm } from "react-hook-form";
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
import { WEBSITE_URL, IS_SELF_HOSTED } from "@calcom/lib/constants";
import slugify from "@calcom/lib/slugify";
import { trpc } from "@calcom/trpc/react";
import type { AppRouter } from "@calcom/trpc/types/server/routers/_app";
@@ -16,6 +17,7 @@ import type { TRPCClientErrorLike } from "@trpc/client";
interface UsernameAvailabilityFieldProps {
onSuccessMutation?: () => void;
onErrorMutation?: (error: TRPCClientErrorLike<AppRouter>) => void;
disabled?: boolean;
}
interface ICustomUsernameProps extends UsernameAvailabilityFieldProps {
@@ -37,14 +39,19 @@ const UsernameTextfield = dynamic(() => import("./UsernameTextfield").then((m) =
});
export const UsernameAvailability = (props: ICustomUsernameProps) => {
const { isPremium, ...otherProps } = props;
const { isPremium, disabled, ...otherProps } = props;
const UsernameAvailabilityComponent = isPremium ? PremiumTextfield : UsernameTextfield;
return <UsernameAvailabilityComponent {...otherProps} />;
// PremiumTextfield uses `readonly` prop, UsernameTextfield uses `disabled` prop
const componentProps = isPremium
? { ...otherProps, readonly: disabled }
: { ...otherProps, disabled };
return <UsernameAvailabilityComponent {...componentProps} />;
};
export const UsernameAvailabilityField = ({
onSuccessMutation,
onErrorMutation,
disabled,
}: UsernameAvailabilityFieldProps) => {
const searchParams = useSearchParams();
const [user] = trpc.viewer.me.get.useSuspenseQuery();
@@ -56,7 +63,7 @@ export const UsernameAvailabilityField = ({
: { username: currentUsernameState || "", setQuery: setCurrentUsernameState };
const formMethods = useForm({
defaultValues: {
username: currentUsername,
username: slugify(currentUsername || user.username || ""),
},
});
@@ -78,10 +85,14 @@ export const UsernameAvailabilityField = ({
setCurrentUsername={setCurrentUsername}
inputUsernameValue={value}
usernameRef={ref}
setInputUsernameValue={onChange}
setInputUsernameValue={(val) => {
const displayValue = slugify(val, true);
formMethods.setValue("username", displayValue);
onChange?.(displayValue);
}}
onSuccessMutation={onSuccessMutation}
onErrorMutation={onErrorMutation}
disabled={!!user.organization?.id}
disabled={disabled ?? !!user.organization?.id}
addOnLeading={`${usernamePrefix}/`}
isPremium={isPremium}
/>
-27
View File
@@ -1,27 +0,0 @@
/** @deprecated use zod instead */
export function asStringOrNull(str: unknown) {
return typeof str === "string" ? str : null;
}
/** @deprecated use zod instead */
export function asStringOrUndefined(str: unknown) {
return typeof str === "string" ? str : undefined;
}
/** @deprecated use zod instead */
export function asNumberOrUndefined(str: unknown) {
return typeof str === "string" ? parseInt(str) : undefined;
}
/** @deprecated use zod instead */
export function asNumberOrThrow(str: unknown) {
return parseInt(asStringOrThrow(str));
}
/** @deprecated use zod instead */
export function asStringOrThrow(str: unknown): string {
if (typeof str !== "string") {
throw new Error(`Expected "string" - got ${typeof str}`);
}
return str;
}
+9 -3
View File
@@ -2,7 +2,13 @@
import { useState, useEffect } from "react";
const useMediaQuery = (query: string) => {
const [matches, setMatches] = useState(false);
const [matches, setMatches] = useState(() => {
// Initialize with actual media query value on client
if (typeof window !== "undefined") {
return window.matchMedia(query).matches;
}
return false;
});
useEffect(() => {
const media = window.matchMedia(query);
@@ -10,8 +16,8 @@ const useMediaQuery = (query: string) => {
setMatches(media.matches);
}
const listener = () => setMatches(media.matches);
window.addEventListener("resize", listener);
return () => window.removeEventListener("resize", listener);
media.addEventListener("change", listener);
return () => media.removeEventListener("change", listener);
}, [matches, query]);
return matches;
+1 -1
View File
@@ -2,8 +2,8 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
import dayjs from "@calcom/dayjs";
import { getOrganizationRepository } from "@calcom/features/ee/organizations/di/OrganizationRepository.container";
import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billing-service";
import { getOrganizationRepository } from "@calcom/features/ee/organizations/di/OrganizationRepository.container";
import { OnboardingPathService } from "@calcom/features/onboarding/lib/onboarding-path.service";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { IS_STRIPE_ENABLED } from "@calcom/lib/constants";
+117 -43
View File
@@ -1,6 +1,6 @@
// Import mocked functions
import { get as edgeConfigGet } from "@vercel/edge-config";
import { NextRequest } from "next/server";
import { NextRequest, NextResponse } from "next/server";
import type { Mock } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
@@ -9,6 +9,7 @@ import { WEBAPP_URL } from "@calcom/lib/constants";
import { checkPostMethod } from "./middleware";
// We'll test the wrapped middleware as it would be used in production
import middleware from "./middleware";
import { config } from "./middleware";
// Mock dependencies at module level
vi.mock("@vercel/edge-config", () => ({
@@ -16,6 +17,7 @@ vi.mock("@vercel/edge-config", () => ({
}));
vi.mock("next-collect/server", () => ({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
collectEvents: vi.fn((config: any) => config.middleware),
}));
@@ -29,11 +31,13 @@ vi.mock("next/server", async () => {
const actual = await vi.importActual<typeof import("next/server")>("next/server");
// Create a NextResponse constructor that returns Response objects
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const NextResponse = function (body: any, init?: ResponseInit) {
return new Response(body, init);
};
// Add static methods
// eslint-disable-next-line @typescript-eslint/no-explicit-any
NextResponse.json = (body: any, init?: ResponseInit) => {
return new Response(JSON.stringify(body), {
...init,
@@ -55,6 +59,7 @@ vi.mock("next/server", async () => {
});
// Add cookies property
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(response as any).cookies = {
delete: vi.fn(),
set: vi.fn(),
@@ -91,6 +96,7 @@ vi.mock("next/server", async () => {
});
// Add cookies property
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(response as any).cookies = {
delete: vi.fn(),
set: vi.fn(),
@@ -128,6 +134,7 @@ const createTestRequest = (overrides?: {
return req;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const createEdgeConfigMock = (config: Record<string, any>) => {
return (key: string) => {
if (key in config) return Promise.resolve(config[key]);
@@ -147,6 +154,7 @@ const expectStatus = (res: Response, status: number) => {
// Wrapper for middleware calls to handle type casting
const callMiddleware = async (req: NextRequest): Promise<Response> => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (await (middleware as any)(req)) as Response;
};
@@ -165,15 +173,6 @@ describe("Middleware - POST requests restriction", () => {
expect(res1).toBeNull();
});
it("should block POST requests to not-allowed app routes", async () => {
const req = createRequest("/team/xyz", "POST");
const res = checkPostMethod(req);
expect(res).not.toBeNull();
expect(res?.status).toBe(405);
expect(res?.statusText).toBe("Method Not Allowed");
expect(res?.headers.get("Allow")).toBe("GET");
});
it("should allow GET requests to app routes", async () => {
const req = createRequest("/team/xyz", "GET");
const res = checkPostMethod(req);
@@ -213,29 +212,6 @@ describe("Middleware Integration Tests", () => {
});
});
describe("POST Method Protection", () => {
it("should allow POST to /api/auth/signup", async () => {
const req = createTestRequest({
url: `${WEBAPP_URL}/api/auth/signup`,
method: "POST",
});
const res = await callMiddleware(req);
expectStatus(res, 200);
});
it("should block POST to regular routes", async () => {
const req = createTestRequest({
url: `${WEBAPP_URL}/team/test`,
method: "POST",
});
const res = await callMiddleware(req);
expectStatus(res, 405);
expect(getHeader(res, "Allow")).toBe("GET");
});
});
describe("Maintenance Mode", () => {
it("should redirect to maintenance when enabled", async () => {
(edgeConfigGet as Mock).mockImplementation(
@@ -429,24 +405,58 @@ describe("Middleware Integration Tests", () => {
});
});
describe("Multiple Features", () => {
it("should handle POST protection before maintenance mode", async () => {
(edgeConfigGet as Mock).mockImplementation(
createEdgeConfigMock({
isInMaintenanceMode: true,
})
);
describe("Header sanitization", () => {
it("sanitizes non-ASCII request header values to prevent Vercel Runtime Malformed Response Header", async () => {
const spy = vi.spyOn(NextResponse, "next");
const req = createTestRequest({
url: `${WEBAPP_URL}/team/test`,
method: "POST",
// Next.js will translate request overrides into x-middleware-request-* headers internally
headers: {
"cf-region": "São Paulo", // contains non-ASCII "ã"
},
});
const res = await callMiddleware(req);
// POST protection should trigger first
expectStatus(res, 405);
expectStatus(res, 200);
// Assert that middleware forwarded sanitized ASCII-only value
const initArg = (spy as unknown as Mock).mock.calls.at(-1)?.[0] as {
request?: { headers?: Headers };
};
expect(initArg?.request?.headers).toBeDefined();
const forwarded = initArg?.request?.headers as Headers;
expect(forwarded.get("cf-region")).toBe("Sao Paulo");
spy.mockRestore();
});
it("strips non-ASCII bytes in mojibake values (e.g., 'São Paulo' -> 'So Paulo')", async () => {
const spy = vi.spyOn(NextResponse, "next");
const req = createTestRequest({
url: `${WEBAPP_URL}/team/test`,
headers: {
"cf-region": "São Paulo", // mojibake for "São Paulo"; includes non-ASCII bytes
},
});
const res = await callMiddleware(req);
expectStatus(res, 200);
const initArg = (spy as unknown as Mock).mock.calls.at(-1)?.[0] as {
request?: { headers?: Headers };
};
const forwarded = initArg?.request?.headers as Headers;
expect(forwarded.get("cf-region")).toBe("So Paulo");
spy.mockRestore();
});
});
describe("Multiple Features", () => {
it("should handle embed route with routing forms rewrite", async () => {
const req = createTestRequest({
url: `${WEBAPP_URL}/apps/routing_forms/form/embed?ui.color-scheme=light`,
@@ -483,3 +493,67 @@ describe("Middleware Integration Tests", () => {
});
});
});
describe("Middleware Matcher - Comprehensive Coverage", () => {
const matcher = config.matcher[0];
const pattern = matcher.replace(/^\/|\/$/g, "");
const regex = new RegExp(`^/${pattern}`);
const cases = [
// pages & apis
{ path: "/", expected: true, reason: "Root page" },
{ path: "/home", expected: true, reason: "Regular page" },
{ path: "/team/abc", expected: true, reason: "Nested page" },
{ path: "/api/auth/login", expected: true, reason: "API route" },
{ path: "/api/bookings", expected: true, reason: "Top-level API" },
{ path: "/dashboard/settings", expected: true, reason: "Deep nested page" },
{ path: "/user/john/profile", expected: true, reason: "Multiple nested path" },
{ path: "/apps/routing_forms/form", expected: true, reason: "App page under /apps" },
{ path: "/embed?ui.color-scheme=dark", expected: true, reason: "Embed query param" },
// should be ignored (internal / static / public)
{ path: "/_next/static/chunks/app.js", expected: false, reason: "Internal static asset" },
{ path: "/_next/image?url=%2Flogo.png&w=256&q=75", expected: false, reason: "Internal image handler" },
{ path: "/_next/data/build-id/page.json", expected: false, reason: "Next.js data route" },
{ path: "/favicon.ico", expected: false, reason: "Favicon asset" },
{ path: "/robots.txt", expected: false, reason: "Robots file" },
{ path: "/sitemap.xml", expected: false, reason: "Sitemap file" },
{ path: "/public/images/logo.png", expected: false, reason: "Public folder asset" },
{ path: "/public/fonts/inter.woff2", expected: false, reason: "Public folder font" },
{ path: "/static/js/main.js", expected: false, reason: "Static folder JavaScript" },
{ path: "/static/css/app.css", expected: false, reason: "Static folder stylesheet" },
// edge cases
{ path: "/manifest.json", expected: true, reason: "Manifest is a public page, not ignored" },
{ path: "/_nextsomething", expected: true, reason: "Looks like _next but not reserved" },
{ path: "/nextconfig", expected: true, reason: "Normal route with 'next' in name" },
{ path: "/_NEXT/image", expected: true, reason: "Case-sensitive test (should match)" },
{ path: "/favicon-abc.ico", expected: true, reason: "Favicon variant should still match" },
{ path: "/robots-custom.txt", expected: true, reason: "Custom robots file should match" },
{ path: "/sitemap-other.xml", expected: true, reason: "Custom sitemap file should match" },
{ path: "/api_", expected: true, reason: "Partial match with api underscore" },
{ path: "//double-slash", expected: true, reason: "Double slash URL" },
{ path: "/_next", expected: false, reason: "Bare _next path" },
];
it("should match only the intended routes", () => {
for (const { path, expected, reason } of cases) {
const result = regex.test(path);
expect(result, `${path} → ${reason}`).toBe(expected);
}
});
it("should not accidentally match internal Next.js routes", () => {
const internalPaths = ["/_next/static", "/_next/image", "/_next/data"];
for (const path of internalPaths) {
expect(regex.test(path)).toBe(false);
}
});
it("should match all user-facing routes and APIs", () => {
const publicPaths = ["/", "/api/user", "/settings", "/dashboard"];
for (const path of publicPaths) {
expect(regex.test(path)).toBe(true);
}
});
});
+114 -29
View File
@@ -3,19 +3,74 @@ import { collectEvents } from "next-collect/server";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import getIP from "@calcom/lib/getIP";
import { HttpError } from "@calcom/lib/http-error";
import { piiHasher } from "@calcom/lib/server/PiiHasher";
import { extendEventData, nextCollectBasicSettings } from "@calcom/lib/telemetry";
import { getCspHeader, getCspNonce } from "@lib/csp";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const safeGet = async <T = any>(key: string): Promise<T | undefined> => {
try {
return get<T>(key);
} catch (error) {
} catch {
// Don't crash if EDGE_CONFIG env var is missing
}
};
export const POST_METHODS_ALLOWED_API_ROUTES = ["/api/auth/signup"];
export const POST_METHODS_ALLOWED_API_ROUTES = [
"/api/auth/forgot-password",
"/api/auth/oauth/me",
"/api/auth/oauth/refreshToken",
"/api/auth/oauth/token",
"/api/auth/reset-password",
"/api/auth/saml/callback",
"/api/auth/saml/token",
"/api/auth/setup",
"/api/auth/signup",
"/api/auth/two-factor/totp/disable",
"/api/auth/two-factor/totp/enable",
"/api/auth/two-factor/totp/setup",
"/api/auth/session",
"/api/availability/calendar",
"/api/cancel",
"/api/cron/bookingReminder",
"/api/cron/calendar-cache-cleanup",
"/api/cron/changeTimeZone",
"/api/cron/checkSmsPrices",
"/api/cron/downgradeUsers",
"/api/cron/monthlyDigestEmail",
"/api/cron/syncAppMeta",
"/api/cron/webhookTriggers",
"/api/cron/workflows/scheduleEmailReminders",
"/api/cron/workflows/scheduleSMSReminders",
"/api/cron/workflows/scheduleWhatsappReminders",
"/api/get-inbound-dynamic-variables",
"/api/integrations/", // for /api/integrations/[...args] and webhooks
"/api/recorded-daily-video",
"/api/router",
"/api/routing-forms/queued-response",
"/api/scim/v2.0/", // /api/scim/v2.0/[...directory]
"/api/support/conversation",
"/api/sync/helpscout",
"/api/twilio/webhook",
"/api/username",
"/api/verify-booking-token",
"/api/video/guest-session",
"/api/webhook/app-credential",
"/api/webhooks/calendar-subscription/", // /api/webhooks/calendar-subscription/[provider]
"/api/webhooks/retell-ai",
"/api/workflows/sms/user-response",
"/api/trpc/", // for tRPC
"/api/auth/callback/", // for NextAuth
"/api/book/event",
"/api/book/instant-event",
"/api/book/recurring-event",
"/availability",
];
export function checkPostMethod(req: NextRequest) {
const pathname = req.nextUrl.pathname;
if (!POST_METHODS_ALLOWED_API_ROUTES.some((route) => pathname.startsWith(route)) && req.method === "POST") {
@@ -30,13 +85,47 @@ export function checkPostMethod(req: NextRequest) {
return null;
}
export function checkStaticFiles(pathname: string) {
const hasFileExtension = /\.(svg|png|jpg|jpeg|gif|webp|ico)$/.test(pathname);
// Skip Next.js internal paths (_next) and static assets
if (pathname.startsWith("/_next") || hasFileExtension) {
return NextResponse.next();
// Vercel/Edge rejects non‑ASCII header values (see: https://github.com/vercel/next.js/issues/85631)
const isAscii = (s: string) => {
for (let i = 0; i < s.length; i++) if (s.charCodeAt(i) > 0x7f) return false;
return true;
};
const stripNonAscii = (s: string) => {
let out = "";
for (let i = 0; i < s.length; i++) if (s.charCodeAt(i) <= 0x7f) out += s[i];
return out;
};
const sanitizeRequestHeaders = (headers: Iterable<[string, string]>): Headers => {
const out = new Headers();
for (const [name, raw] of Array.from(headers)) {
if (!isAscii(name)) continue;
let value = raw;
if (!isAscii(value)) {
// Heuristic: if the string contains common mojibake markers (Ã: 0xC3, Â: 0xC2),
// prefer a simple strip (avoids introducing spurious ASCII letters like 'A').
let hasMojibakeMarker = false;
for (let i = 0; i < value.length; i++) {
const code = value.charCodeAt(i);
if (code === 0xc3 || code === 0xc2) {
hasMojibakeMarker = true;
break;
}
}
if (hasMojibakeMarker) {
value = stripNonAscii(value);
} else {
try {
value = stripNonAscii(value.normalize("NFKD"));
} catch {
value = stripNonAscii(value);
}
}
}
if (value) out.set(name, value);
}
}
return out;
};
const isPagePathRequest = (url: URL) => {
const isNonPagePathPrefix = /^\/(?:_next|api)\//;
@@ -50,11 +139,21 @@ const shouldEnforceCsp = (url: URL) => {
};
const middleware = async (req: NextRequest): Promise<NextResponse<unknown>> => {
const postCheckResult = checkPostMethod(req);
if (postCheckResult) return postCheckResult;
const requestorIp = getIP(req);
try {
await checkRateLimitAndThrowError({
rateLimitingType: "common",
identifier: piiHasher.hash(`${req.nextUrl.pathname}-${requestorIp}`),
});
} catch (error) {
if (error instanceof HttpError) {
return new NextResponse(error.message, { status: error.statusCode });
}
throw error;
}
const isStaticFile = checkStaticFiles(req.nextUrl.pathname);
if (isStaticFile) return isStaticFile;
// const postCheckResult = checkPostMethod(req);
// if (postCheckResult) return postCheckResult;
const url = req.nextUrl;
const reqWithEnrichedHeaders = enrichRequestWithHeaders({ req });
@@ -88,7 +187,7 @@ const middleware = async (req: NextRequest): Promise<NextResponse<unknown>> => {
const res = NextResponse.next({
request: {
headers: requestHeaders,
headers: sanitizeRequestHeaders(requestHeaders),
},
});
@@ -168,22 +267,8 @@ function enrichRequestWithHeaders({ req }: { req: NextRequest }) {
}
export const config = {
// Next.js Doesn't support spread operator in config matcher, so, we must list all paths explicitly here.
// https://github.com/vercel/next.js/discussions/42458
// WARNING: DO NOT ADD AN ENDING SLASH "/" TO THE PATHS BELOW
// THIS WILL MAKE THEM NOT MATCH AND HENCE NOT HIT MIDDLEWARE
matcher: [
// Routes to enforce CSP
"/auth/login",
"/login",
// Routes to set cookies
"/apps/installed",
"/auth/logout",
// Embed Routes,
"/:path*/embed",
// API routes
"/api/auth/signup",
],
runtime: "nodejs",
matcher: ["/((?!_next(?:/|$)|static(?:/|$)|public(?:/|$)|favicon\\.ico$|robots\\.txt$|sitemap\\.xml$).*)"],
};
export default collectEvents({
@@ -0,0 +1,133 @@
import type { VisibilityState } from "@tanstack/react-table";
import { createColumnHelper } from "@tanstack/react-table";
import { ColumnFilterType } from "@calcom/features/data-table";
import type { RowData } from "../types";
interface BuildFilterColumnsParams {
t: (key: string) => string;
permissions: {
canReadOthersBookings: boolean;
};
status: string;
}
/**
* IDs of filter columns that should be hidden in the UI
* These columns are used for filtering but not displayed
*/
const FILTER_COLUMN_IDS = [
"eventTypeId",
"teamId",
"userId",
"attendeeName",
"attendeeEmail",
"dateRange",
"bookingUid",
] as const;
/**
* Gets the column visibility state for filter columns
* All filter columns are hidden by default as they only provide filtering functionality
*/
export function getFilterColumnVisibility(): VisibilityState {
return Object.fromEntries(FILTER_COLUMN_IDS.map((id) => [id, false]));
}
export function buildFilterColumns({ t, permissions, status }: BuildFilterColumnsParams) {
const columnHelper = createColumnHelper<RowData>();
return [
columnHelper.accessor((row) => (row.type === "data" ? row.booking.eventType.id : null), {
id: "eventTypeId",
header: t("event_type"),
enableColumnFilter: true,
enableSorting: false,
cell: () => null,
meta: {
filter: {
type: ColumnFilterType.MULTI_SELECT,
},
},
}),
columnHelper.accessor((row) => (row.type === "data" ? row.booking.eventType.team?.id ?? null : null), {
id: "teamId",
header: t("team"),
enableColumnFilter: true,
enableSorting: false,
cell: () => null,
meta: {
filter: {
type: ColumnFilterType.MULTI_SELECT,
},
},
}),
columnHelper.accessor((row) => (row.type === "data" ? row.booking.user?.id ?? null : null), {
id: "userId",
header: t("member"),
enableColumnFilter: permissions.canReadOthersBookings,
enableSorting: false,
cell: () => null,
meta: {
filter: {
type: ColumnFilterType.MULTI_SELECT,
},
},
}),
columnHelper.accessor((row) => row, {
id: "attendeeName",
header: t("attendee_name"),
enableColumnFilter: true,
enableSorting: false,
cell: () => null,
meta: {
filter: {
type: ColumnFilterType.TEXT,
},
},
}),
columnHelper.accessor((row) => row, {
id: "attendeeEmail",
header: t("attendee_email_variable"),
enableColumnFilter: true,
enableSorting: false,
cell: () => null,
meta: {
filter: {
type: ColumnFilterType.TEXT,
},
},
}),
columnHelper.accessor((row) => row, {
id: "dateRange",
header: t("date_range"),
enableColumnFilter: true,
enableSorting: false,
cell: () => null,
meta: {
filter: {
type: ColumnFilterType.DATE_RANGE,
dateRangeOptions: {
range: status === "past" ? "past" : "custom",
},
},
},
}),
columnHelper.accessor((row) => (row.type === "data" ? row.booking.uid : null), {
id: "bookingUid",
header: t("booking_uid"),
enableColumnFilter: true,
enableSorting: false,
cell: () => null,
meta: {
filter: {
type: ColumnFilterType.TEXT,
textOptions: {
allowedOperators: ["equals"],
},
},
},
}),
];
}
@@ -0,0 +1,542 @@
import { createColumnHelper } from "@tanstack/react-table";
import { useState } from "react";
import dayjs from "@calcom/dayjs";
import { isSeparatorRow } from "@calcom/features/data-table/lib/separator";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { useCopy } from "@calcom/lib/hooks/useCopy";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import isSmsCalEmail from "@calcom/lib/isSmsCalEmail";
import { trpc } from "@calcom/trpc/react";
import { Avatar } from "@calcom/ui/components/avatar";
import { Badge } from "@calcom/ui/components/badge";
import { Button } from "@calcom/ui/components/button";
import {
Dropdown,
DropdownItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownMenuPortal,
} from "@calcom/ui/components/dropdown";
import { showToast } from "@calcom/ui/components/toast";
import { BookingActionsDropdown } from "../../../components/booking/actions/BookingActionsDropdown";
import { BookingActionsStoreProvider } from "../../../components/booking/actions/BookingActionsStoreProvider";
import type { BookingListingStatus } from "../../../components/booking/types";
import { JoinMeetingButton } from "../components/JoinMeetingButton";
import type { RowData } from "../types";
interface PendingActionHandlers {
onAccept: (bookingId: number, recurringEventId?: string | null) => void;
onReject: (bookingId: number, recurringEventId?: string | null) => void;
isLoading?: boolean;
}
interface BuildListDisplayColumnsParams {
t: (key: string) => string;
user?: {
id?: number;
email?: string;
timeZone?: string;
timeFormat?: number | null;
};
pendingActionHandlers: PendingActionHandlers;
}
// Maximum number of attendees to display before showing "..." button
const MAX_DISPLAYED_ATTENDEES = 3;
type AttendeeProps = {
name?: string;
email: string;
phoneNumber: string | null;
id: number;
noShow: boolean;
};
// Component for individual attendee avatar with dropdown
const IndividualAttendee = ({
attendee,
bookingUid,
isBookingInPast,
}: {
attendee: AttendeeProps;
bookingUid: string;
isBookingInPast: boolean;
}) => {
const { t } = useLocale();
const utils = trpc.useUtils();
const [openDropdown, setOpenDropdown] = useState(false);
const { copyToClipboard, isCopied } = useCopy();
const noShowMutation = trpc.viewer.loggedInViewerRouter.markNoShow.useMutation({
onSuccess: async (data) => {
showToast(data.message, "success");
await utils.viewer.bookings.invalidate();
},
onError: (err) => {
showToast(err.message, "error");
},
});
return (
<Dropdown open={openDropdown} onOpenChange={setOpenDropdown}>
<DropdownMenuTrigger asChild>
<button className="focus:outline-none" data-testid="guest">
<Avatar
size="sm"
imageSrc={getPlaceholderAvatar(null, attendee.name)}
alt={attendee.name || attendee.email}
title={attendee.name || attendee.email}
className="border-subtle cursor-pointer hover:opacity-80"
/>
</button>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent className="w-48" onClick={(e) => e.stopPropagation()}>
<DropdownMenuLabel className="text-emphasis px-3 py-2 text-sm font-medium">
{attendee.name || attendee.email}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{!isSmsCalEmail(attendee.email) && (
<DropdownMenuItem className="focus:outline-none">
<DropdownItem
StartIcon="mail"
href={`mailto:${attendee.email}`}
onClick={() => {
setOpenDropdown(false);
}}>
<a href={`mailto:${attendee.email}`}>{t("email")}</a>
</DropdownItem>
</DropdownMenuItem>
)}
<DropdownMenuItem className="focus:outline-none">
<DropdownItem
StartIcon={isCopied ? "clipboard-check" : "clipboard"}
onClick={(e) => {
e.preventDefault();
const smsCalEmail = isSmsCalEmail(attendee.email);
copyToClipboard(smsCalEmail ? attendee.email : attendee.phoneNumber ?? "");
setOpenDropdown(false);
showToast(smsCalEmail ? t("email_copied") : t("phone_number_copied"), "success");
}}>
{!isCopied ? t("copy") : t("copied")}
</DropdownItem>
</DropdownMenuItem>
{isBookingInPast && (
<DropdownMenuItem className="focus:outline-none">
<DropdownItem
data-testid={attendee.noShow ? "unmark-no-show" : "mark-no-show"}
onClick={(e) => {
e.preventDefault();
setOpenDropdown(false);
noShowMutation.mutate({
bookingUid,
attendees: [{ noShow: !attendee.noShow, email: attendee.email }],
});
}}
StartIcon={attendee.noShow ? "eye" : "eye-off"}>
{attendee.noShow ? t("unmark_as_no_show") : t("mark_as_no_show")}
</DropdownItem>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenuPortal>
</Dropdown>
);
};
// Component for the "..." dropdown that shows all attendees with action buttons
const AllAttendeesDropdown = ({
attendees,
bookingUid,
isBookingInPast,
}: {
attendees: AttendeeProps[];
bookingUid: string;
isBookingInPast: boolean;
}) => {
const { t } = useLocale();
const utils = trpc.useUtils();
const [openDropdown, setOpenDropdown] = useState(false);
const { copyToClipboard } = useCopy();
const noShowMutation = trpc.viewer.loggedInViewerRouter.markNoShow.useMutation({
onSuccess: async (data) => {
showToast(data.message, "success");
await utils.viewer.bookings.invalidate();
},
onError: (err) => {
showToast(err.message, "error");
},
});
const handleEmail = (email: string) => {
window.location.href = `mailto:${email}`;
setOpenDropdown(false);
};
const handleCopy = (attendee: AttendeeProps) => {
const isSmsAttendee = isSmsCalEmail(attendee.email);
copyToClipboard(isSmsAttendee ? attendee.email : attendee.phoneNumber ?? "");
showToast(isSmsAttendee ? t("email_copied") : t("phone_number_copied"), "success");
};
const handleToggleNoShow = (attendee: AttendeeProps) => {
noShowMutation.mutate({
bookingUid,
attendees: [{ noShow: !attendee.noShow, email: attendee.email }],
});
};
const handleMarkAllAsNoShow = () => {
const allAttendees = attendees.map((attendee) => ({
noShow: true,
email: attendee.email,
}));
noShowMutation.mutate({
bookingUid,
attendees: allAttendees,
});
setOpenDropdown(false);
};
return (
<Dropdown open={openDropdown} onOpenChange={setOpenDropdown}>
<DropdownMenuTrigger asChild>
<Button
size="sm"
color="minimal"
StartIcon="ellipsis"
className="px-1.5"
data-testid="more-guests"
onClick={(e) => {
e.stopPropagation();
}}></Button>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent className="w-80" onClick={(e) => e.stopPropagation()}>
<DropdownMenuLabel className="text-xs font-medium uppercase">{t("attendees")}</DropdownMenuLabel>
<DropdownMenuSeparator />
<div className="max-h-96 overflow-y-auto">
{attendees.map((attendee) => (
<div key={attendee.id} className="hover:bg-muted flex items-center justify-between px-2 py-2">
<div className="flex-1 truncate text-sm">
<div className="font-medium">{attendee.name || attendee.email}</div>
{attendee.name && <div className="text-subtle text-xs">{attendee.email}</div>}
</div>
<div className="flex items-center gap-1">
{!isSmsCalEmail(attendee.email) && (
<Button
size="sm"
color="minimal"
StartIcon="mail"
className="px-1.5"
tooltip={t("email")}
onClick={(e) => {
e.stopPropagation();
handleEmail(attendee.email);
}}
/>
)}
<Button
size="sm"
color="minimal"
StartIcon="clipboard"
className="px-1.5"
tooltip={t("copy")}
onClick={(e) => {
e.stopPropagation();
handleCopy(attendee);
}}
/>
{isBookingInPast && (
<Button
size="sm"
color="minimal"
StartIcon={attendee.noShow ? "eye" : "eye-off"}
className="px-1.5"
data-testid={attendee.noShow ? "unmark-no-show" : "mark-no-show"}
tooltip={attendee.noShow ? t("unmark_as_no_show") : t("mark_as_no_show")}
onClick={(e) => {
e.stopPropagation();
handleToggleNoShow(attendee);
}}
/>
)}
</div>
</div>
))}
</div>
{isBookingInPast && (
<>
<DropdownMenuSeparator />
<div className="flex justify-end p-2">
<Button
size="sm"
color="secondary"
data-testid="update-no-show"
onClick={(e) => {
e.stopPropagation();
handleMarkAllAsNoShow();
}}>
{t("mark_as_no_show_title")}
</Button>
</div>
</>
)}
</DropdownMenuContent>
</DropdownMenuPortal>
</Dropdown>
);
};
// Component that renders the attendees with appropriate dropdown
const AttendeeCell = ({
attendees,
bookingUid,
isBookingInPast,
}: {
attendees: AttendeeProps[];
bookingUid: string;
isBookingInPast: boolean;
}) => {
// Show "..." button if there are more attendees than the max display count
const hasMoreThanMax = attendees.length > MAX_DISPLAYED_ATTENDEES;
const displayedAttendees = attendees.slice(0, MAX_DISPLAYED_ATTENDEES);
return (
<div className="flex items-center">
{displayedAttendees.map((attendee, idx) => (
<div key={attendee.id} className={idx > 0 ? "-ml-1" : ""}>
<IndividualAttendee attendee={attendee} bookingUid={bookingUid} isBookingInPast={isBookingInPast} />
</div>
))}
{hasMoreThanMax && (
<AllAttendeesDropdown
attendees={attendees}
bookingUid={bookingUid}
isBookingInPast={isBookingInPast}
/>
)}
</div>
);
};
export function buildListDisplayColumns({ t, user, pendingActionHandlers }: BuildListDisplayColumnsParams) {
const columnHelper = createColumnHelper<RowData>();
return [
columnHelper.display({
id: "date",
size: 140,
enableColumnFilter: true,
enableSorting: false,
enableHiding: false,
header: () => <span className="text-subtle text-sm font-medium">{t("date")}</span>,
cell: (props) => {
const row = props.row.original;
if (isSeparatorRow(row)) return null;
return (
<div className="text-default text-sm font-medium">
{dayjs(row.booking.startTime).tz(user?.timeZone).format("ddd, DD MMM")}
</div>
);
},
}),
columnHelper.display({
id: "time",
size: 140,
enableColumnFilter: true,
enableSorting: false,
enableHiding: false,
header: () => <span className="text-subtle text-sm font-medium">{t("time")}</span>,
cell: (props) => {
const row = props.row.original;
if (isSeparatorRow(row)) return null;
const startTime = dayjs(row.booking.startTime).tz(user?.timeZone);
const endTime = dayjs(row.booking.endTime).tz(user?.timeZone);
return (
<div className="text-default text-sm font-medium">
{startTime.format(user?.timeFormat === 12 ? "h:mma" : "HH:mm")} -{" "}
{endTime.format(user?.timeFormat === 12 ? "h:mma" : "HH:mm")}
</div>
);
},
}),
columnHelper.display({
id: "event",
minSize: 200,
enableColumnFilter: true,
enableSorting: false,
enableHiding: false,
header: () => <span className="text-subtle text-sm font-medium">{t("event")}</span>,
cell: (props) => {
const row = props.row.original;
if (isSeparatorRow(row)) return null;
return (
<div className="text-emphasis flex-1 truncate text-sm font-medium" data-testid="title">
{row.booking.title}
</div>
);
},
}),
columnHelper.display({
id: "who",
size: 160,
enableColumnFilter: true,
enableSorting: false,
enableHiding: false,
header: () => <span className="text-subtle text-sm font-medium">{t("who")}</span>,
cell: (props) => {
const row = props.row.original;
if (isSeparatorRow(row)) return null;
const isBookingInPast = new Date(row.booking.endTime) < new Date();
const attendees = row.booking.attendees.map((attendee) => ({
name: attendee.name,
email: attendee.email,
id: attendee.id,
noShow: attendee.noShow || false,
phoneNumber: attendee.phoneNumber,
}));
return (
<AttendeeCell
attendees={attendees}
bookingUid={row.booking.uid}
isBookingInPast={isBookingInPast}
/>
);
},
}),
columnHelper.display({
id: "team",
size: 140,
enableColumnFilter: true,
enableSorting: false,
enableHiding: false,
header: () => <span className="text-subtle text-sm font-medium">{t("team")}</span>,
cell: (props) => {
const row = props.row.original;
if (isSeparatorRow(row)) return null;
if (row.booking.eventType.team) {
return (
<Badge variant="gray" size="sm">
{row.booking.eventType.team.name}
</Badge>
);
}
return null;
},
}),
columnHelper.display({
id: "actions",
size: 280,
enableColumnFilter: true,
enableSorting: false,
enableHiding: false,
header: () => null,
cell: (props) => {
const row = props.row.original;
if (isSeparatorRow(row)) return null;
const booking = row.booking;
const isPending = booking.status === "PENDING";
const isUpcoming = new Date(booking.endTime) >= new Date();
const isCancelled = booking.status === "CANCELLED";
const isRejected = booking.status === "REJECTED";
const isAccepted = booking.status === "ACCEPTED";
// Determine if we should show pending actions
const shouldShowPendingActions = isPending && isUpcoming && !isCancelled;
// Determine which buttons to show based on payment status
const hasPayment = Array.isArray(booking.payment) && booking.payment.length > 0;
const isPaid = booking.paid;
const shouldShowAccept = shouldShowPendingActions && (!hasPayment || isPaid);
const shouldShowReject = shouldShowPendingActions;
// Show join meeting button only for upcoming accepted/confirmed bookings
const shouldShowJoinButton = isAccepted && isUpcoming && !isCancelled && !isRejected;
// Determine if this is a recurring booking for the label
const isRecurring = booking.recurringEventId !== null;
const isTabRecurring = row.type === "data" && row.recurringInfo !== undefined;
const isTabUnconfirmed = booking.status === "PENDING";
const showAllLabel = (isTabRecurring || isTabUnconfirmed) && isRecurring;
return (
<div className="flex w-full items-center justify-end gap-2">
{shouldShowReject && (
<Button
color="minimal"
size="sm"
StartIcon="ban"
disabled={pendingActionHandlers.isLoading}
data-testid="reject"
onClick={(e) => {
e.stopPropagation();
const recurringEventId = showAllLabel ? booking.recurringEventId : null;
pendingActionHandlers.onReject(booking.id, recurringEventId);
}}>
{showAllLabel ? t("reject_all") : t("reject")}
</Button>
)}
{shouldShowAccept && (
<Button
color="secondary"
size="sm"
StartIcon="check"
disabled={pendingActionHandlers.isLoading}
data-testid="confirm"
onClick={(e) => {
e.stopPropagation();
const recurringEventId = showAllLabel ? booking.recurringEventId : null;
pendingActionHandlers.onAccept(booking.id, recurringEventId);
}}>
{showAllLabel ? t("confirm_all") : t("confirm")}
</Button>
)}
{shouldShowJoinButton && (
<JoinMeetingButton
size="sm"
location={booking.location}
metadata={booking.metadata}
bookingStatus={booking.status}
/>
)}
<BookingActionsStoreProvider>
<BookingActionsDropdown
booking={{
...booking,
listingStatus: booking.status.toLowerCase() as BookingListingStatus,
recurringInfo: row.type === "data" ? row.recurringInfo : undefined,
loggedInUser: {
userId: user?.id,
userTimeZone: user?.timeZone,
userTimeFormat: user?.timeFormat,
userEmail: user?.email,
},
isToday: row.type === "data" ? row.isToday : false,
}}
className="px-1.5"
size="sm"
/>
</BookingActionsStoreProvider>
</div>
);
},
}),
];
}
@@ -0,0 +1,475 @@
"use client";
import { useMemo } from "react";
import { z } from "zod";
import dayjs from "@calcom/dayjs";
import { useBookingLocation } from "@calcom/features/bookings/hooks";
import { shouldShowFieldInCustomResponses } from "@calcom/lib/bookings/SystemField";
import { formatPrice } from "@calcom/lib/currencyConversions";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
import { bookingMetadataSchema, eventTypeBookingFields } from "@calcom/prisma/zod-utils";
import type { RecurringEvent } from "@calcom/types/Calendar";
import classNames from "@calcom/ui/classNames";
import { Avatar } from "@calcom/ui/components/avatar";
import { Badge } from "@calcom/ui/components/badge";
import { Button } from "@calcom/ui/components/button";
import { Icon } from "@calcom/ui/components/icon";
import {
Sheet,
SheetContent,
SheetBody,
SheetHeader,
SheetFooter,
SheetTitle,
} from "@calcom/ui/components/sheet";
import { BookingActionsDropdown } from "../../../components/booking/actions/BookingActionsDropdown";
import { BookingActionsStoreProvider } from "../../../components/booking/actions/BookingActionsStoreProvider";
import type { BookingListingStatus } from "../../../components/booking/types";
import type { BookingOutput } from "../types";
import { JoinMeetingButton } from "./JoinMeetingButton";
type BookingMetaData = z.infer<typeof bookingMetadataSchema>;
interface BookingDetailsSheetProps {
booking: BookingOutput | null;
isOpen: boolean;
onClose: () => void;
userTimeZone?: string;
userTimeFormat?: number;
userId?: number;
userEmail?: string;
onPrevious?: () => void;
hasPrevious?: boolean;
onNext?: () => void;
hasNext?: boolean;
}
interface BookingDetailsSheetInnerProps extends Omit<BookingDetailsSheetProps, "booking"> {
booking: BookingOutput;
}
export function BookingDetailsSheet(props: BookingDetailsSheetProps) {
if (!props.booking) return null;
return (
<BookingActionsStoreProvider>
<BookingDetailsSheetInner {...props} booking={props.booking} />
</BookingActionsStoreProvider>
);
}
function BookingDetailsSheetInner({
booking,
isOpen,
onClose,
userTimeZone,
userTimeFormat,
userId,
userEmail,
onPrevious,
hasPrevious = false,
onNext,
hasNext = false,
}: BookingDetailsSheetInnerProps) {
const { t } = useLocale();
const startTime = dayjs(booking.startTime).tz(userTimeZone);
const endTime = dayjs(booking.endTime).tz(userTimeZone);
const getStatusBadge = () => {
switch (booking.status) {
case "ACCEPTED":
return { variant: "green" as const, label: t("confirmed") };
case "PENDING":
return { variant: "orange" as const, label: t("pending") };
case "CANCELLED":
return { variant: "red" as const, label: t("cancelled") };
case "REJECTED":
return { variant: "red" as const, label: t("rejected") };
default:
return { variant: "gray" as const, label: booking.status };
}
};
const statusBadge = getStatusBadge();
const parsedMetadata = bookingMetadataSchema.safeParse(booking.metadata ?? null);
const bookingMetadata = parsedMetadata.success ? parsedMetadata.data : null;
const recurringInfo =
booking.recurringEventId && booking.eventType?.recurringEvent
? {
count: booking.eventType.recurringEvent.count,
recurringEvent: booking.eventType.recurringEvent,
}
: null;
const customResponses = booking.responses
? Object.entries(booking.responses as Record<string, unknown>)
.filter(([fieldName]) => shouldShowFieldInCustomResponses(fieldName))
.map(([question, answer]) => [question, answer] as [string, unknown])
: [];
return (
<Sheet open={isOpen} onOpenChange={onClose}>
<SheetContent className="overflow-y-auto">
<SheetHeader
showCloseButton={false}
rightContent={
<div className="flex gap-2">
<Button
variant="icon"
color="minimal"
StartIcon="chevron-up"
disabled={!hasPrevious}
onClick={(e) => {
e.preventDefault();
onPrevious?.();
}}
/>
<Button
variant="icon"
color="minimal"
StartIcon="chevron-down"
disabled={!hasNext}
onClick={(e) => {
e.preventDefault();
onNext?.();
}}
/>
</div>
}>
<div className="flex items-center gap-2">
<Badge variant={statusBadge.variant} className="capitalize">
{statusBadge.label}
</Badge>
{booking.eventType.team && <Badge variant="gray">{booking.eventType.team.name}</Badge>}
{recurringInfo && (
<Badge variant="gray">
<Icon name="repeat" className="mr-1 h-3 w-3" />
{recurringInfo.count}
</Badge>
)}
</div>
</SheetHeader>
<SheetBody className="space-y-6">
<div className="space-y-6">
<div className="space-y-1">
<SheetTitle className="text-2xl font-semibold">{booking.title}</SheetTitle>
<p className="text-subtle text-sm">
{startTime.format("dddd, MMMM D, YYYY h:mma")} - {endTime.format("h:mma")} (
{userTimeZone || startTime.format("Z")})
</p>
</div>
<RescheduleRequestMessage booking={booking} />
<WhoSection booking={booking} />
<WhereSection booking={booking} meta={bookingMetadata} />
<RecurringInfoSection recurringInfo={recurringInfo} />
<PaymentSection booking={booking} />
<SlotsSection booking={booking} />
<CustomQuestionsSection
customResponses={customResponses}
bookingFields={booking.eventType?.bookingFields}
/>
<DescriptionSection booking={booking} />
</div>
</SheetBody>
<SheetFooter className="bg-muted border-subtle -mx-4 -mb-4 border-t pt-0 sm:-mx-6 sm:-my-6">
<div className="flex w-full flex-row items-center justify-between gap-2 px-4 pb-4 pt-4">
<Button color="secondary" StartIcon="x" onClick={onClose}>
{t("close")}
</Button>
<div className="flex gap-2">
<JoinMeetingButton
location={booking.location}
metadata={booking.metadata}
bookingStatus={booking.status}
/>
<BookingActionsDropdown
booking={{
...booking,
listingStatus: booking.status.toLowerCase() as BookingListingStatus,
recurringInfo: undefined,
loggedInUser: {
userId,
userTimeZone,
userTimeFormat: userTimeFormat ?? null,
userEmail,
},
isToday: false,
}}
usePortal={false}
/>
</div>
</div>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
function RescheduleRequestMessage({ booking }: { booking: BookingOutput }) {
const { t } = useLocale();
if (booking.status !== "CANCELLED" || !booking.rescheduled) {
return null;
}
return (
<Badge startIcon="send" size="md" variant="gray" data-testid="request_reschedule_sent">
{t("reschedule_request_sent")}
</Badge>
);
}
function WhoSection({ booking }: { booking: BookingOutput }) {
const { t } = useLocale();
return (
<Section title={t("who")}>
<div className="space-y-4">
{booking.user && (
<div className="flex items-center gap-4">
<Avatar
size="md"
imageSrc={getPlaceholderAvatar(null, booking.user.name)}
alt={booking.user.name || ""}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="text-emphasis truncate text-sm font-medium">{booking.user.name}</p>
<Badge variant="blue" size="sm">
{t("host")}
</Badge>
</div>
<p className="text-default truncate text-sm">{booking.user.email}</p>
</div>
</div>
)}
{booking.attendees.map((attendee, idx) => (
<div key={idx} className="flex items-center gap-4">
<Avatar size="md" imageSrc={getPlaceholderAvatar(null, attendee.name)} alt={attendee.name} />
<div className="min-w-0 flex-1">
<p className="text-emphasis truncate text-sm font-medium">{attendee.name}</p>
<p className="text-default truncate text-sm">{attendee.email}</p>
</div>
</div>
))}
</div>
</Section>
);
}
function WhereSection({ booking, meta }: { booking: BookingOutput; meta: BookingMetaData | null }) {
const { t } = useLocale();
const { locationToDisplay, provider, isLocationURL } = useBookingLocation({
location: booking.location,
videoCallUrl: meta?.videoCallUrl,
t,
bookingStatus: booking.status,
});
if (!locationToDisplay) {
return null;
}
if (!isLocationURL) {
return (
<Section title={t("where")}>
<p className="text-default text-sm">{locationToDisplay}</p>
</Section>
);
}
return (
<Section title={t("where")}>
<div className="flex items-center gap-2 text-sm">
{provider?.iconUrl && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={provider.iconUrl}
className="h-4 w-4 flex-shrink-0 rounded-sm"
alt={`${provider?.label} logo`}
/>
)}
<div className="flex min-w-0 items-baseline gap-1">
<span className="text-default flex-shrink-0">{provider?.label}:</span>
<a
href={locationToDisplay}
target="_blank"
rel="noopener noreferrer"
className="truncate text-blue-600 hover:underline">
{locationToDisplay}
</a>
</div>
</div>
</Section>
);
}
function RecurringInfoSection({
recurringInfo,
}: {
recurringInfo: { count: number; recurringEvent: RecurringEvent } | null;
}) {
const { t } = useLocale();
if (!recurringInfo || recurringInfo.count <= 0) {
return null;
}
return (
<Section title={t("recurring_event")}>
<p className="text-default text-sm">
{getEveryFreqFor({
t,
recurringEvent: recurringInfo.recurringEvent,
recurringCount: recurringInfo.count,
})}
</p>
</Section>
);
}
function PaymentSection({ booking }: { booking: BookingOutput }) {
const { t } = useLocale();
if (!booking.paid || !Array.isArray(booking.payment) || booking.payment.length === 0) {
return null;
}
const payment = booking.payment[0];
const formattedPrice = formatPrice(payment.amount, payment.currency);
return (
<Section title={t("payment")}>
<p className="text-default text-sm">{formattedPrice}</p>
</Section>
);
}
function SlotsSection({ booking }: { booking: BookingOutput }) {
const { t } = useLocale();
if (
!booking.eventType?.seatsShowAvailabilityCount ||
!booking.eventType?.seatsPerTimeSlot ||
booking.eventType.seatsPerTimeSlot <= 0
) {
return null;
}
const totalSeats = booking.eventType.seatsPerTimeSlot;
const takenSeats = booking.attendees.length;
return (
<Section title={t("slots")}>
<p className="text-default text-sm">{t("slots_taken", { takenSeats, totalSeats })}</p>
</Section>
);
}
function CustomQuestionsSection({
customResponses,
bookingFields: rawBookingFields,
}: {
customResponses: [string, unknown][];
bookingFields?: unknown;
}) {
const { t } = useLocale();
// Parse and memoize booking fields
const bookingFields = useMemo(() => {
if (!rawBookingFields) return undefined;
const parsed = eventTypeBookingFields.safeParse(rawBookingFields);
return parsed.success ? parsed.data : undefined;
}, [rawBookingFields]);
// Filter out responses with falsy answers or empty arrays
const validResponses = useMemo(
(): [string, unknown][] =>
customResponses.filter(([, answer]) => {
if (!answer) return false;
if (Array.isArray(answer) && answer.length === 0) return false;
return true;
}),
[customResponses]
);
// Memoize field label lookups
const fieldLabels = useMemo(() => {
if (!bookingFields) return new Map<string, string>();
return new Map<string, string>(
bookingFields.map(
(field) => [field.name, field.label || t(field.defaultLabel || field.name)] as [string, string]
)
);
}, [bookingFields, t]);
if (validResponses.length === 0) {
return null;
}
return (
<>
{validResponses.map(([fieldName, answer], idx) => {
const fieldNameStr = String(fieldName);
const title: string = fieldLabels.get(fieldNameStr) ?? fieldNameStr;
return (
<Section key={idx} title={title}>
<p className="text-default text-sm">{String(answer)}</p>
</Section>
);
})}
</>
);
}
function DescriptionSection({ booking }: { booking: BookingOutput }) {
const { t } = useLocale();
if (!booking.description) {
return null;
}
return (
<Section title={t("description")}>
<p className="text-default whitespace-pre-wrap text-sm">{booking.description}</p>
</Section>
);
}
function Section({
title,
className,
children,
}: {
title: string;
className?: string;
children: React.ReactNode;
}) {
return (
<div className={classNames("space-y-1", className)}>
<h3 className="text-subtle text-xs font-semibold">{title}</h3>
{children}
</div>
);
}
@@ -1,89 +1,62 @@
"use client";
import type { Table as ReactTable } from "@tanstack/react-table";
import { createParser, useQueryState } from "nuqs";
import { useMemo, useCallback } from "react";
import { useCallback } from "react";
import dayjs from "@calcom/dayjs";
import {
DataTableFilters,
DataTableSegment,
useDataTable,
useFilterValue,
ZDateRangeFilterValue,
ColumnFilterType,
} from "@calcom/features/data-table";
import { CUSTOM_PRESET } from "@calcom/features/data-table/lib/dateRange";
import type { RowData, BookingListingStatus } from "../types";
import type { RowData, BookingListingStatus, BookingOutput } from "../types";
import { BookingsCalendarView } from "./BookingsCalendarView";
type BookingsCalendarViewProps = {
type BookingsCalendarProps = {
status: BookingListingStatus;
table: ReactTable<RowData>;
isPending?: boolean;
onOpenDetails: (bookingId: number) => void;
currentWeekStart: dayjs.Dayjs;
setCurrentWeekStart: (
value: dayjs.Dayjs | ((old: dayjs.Dayjs) => dayjs.Dayjs | null) | null
) => Promise<URLSearchParams>;
bookings: BookingOutput[];
};
const COLUMN_IDS_TO_HIDE = ["dateRange"];
const weekStartParser = createParser({
parse: (value: string) => {
const parsed = dayjs(value);
return parsed.isValid() ? parsed.startOf("week") : dayjs().startOf("week");
},
serialize: (value: dayjs.Dayjs) => value.format("YYYY-MM-DD"),
});
export function BookingsCalendar({ table }: BookingsCalendarViewProps) {
const { rows } = table.getRowModel();
export function BookingsCalendar({
table,
isPending = false,
onOpenDetails,
currentWeekStart,
setCurrentWeekStart,
bookings,
}: BookingsCalendarProps) {
const { updateFilter } = useDataTable();
const dateRange = useFilterValue("dateRange", ZDateRangeFilterValue)?.data;
const [currentWeekStart, setCurrentWeekStart] = useQueryState(
"weekStart",
weekStartParser.withDefault(dayjs().startOf("week"))
);
const bookings = useMemo(() => {
return rows
.filter((row) => row.original.type === "data")
.map((row) => (row.original.type === "data" ? row.original.booking : null))
.filter((booking): booking is NonNullable<typeof booking> => booking !== null);
}, [rows]);
const handleWeekStartChange = useCallback(
(newWeekStart: dayjs.Dayjs) => {
setCurrentWeekStart(newWeekStart);
// Always set the date range to match the current week exactly
const startDate = newWeekStart.toDate();
const endDate = newWeekStart.add(6, "day").toDate();
if (!dateRange) {
return;
}
const rangeStart = dateRange.startDate ? new Date(dateRange.startDate) : null;
const rangeEnd = dateRange.endDate ? new Date(dateRange.endDate) : null;
const needsStartUpdate = !rangeStart || startDate < rangeStart;
const needsEndUpdate = !rangeEnd || endDate > rangeEnd;
if (!needsStartUpdate && !needsEndUpdate) {
return;
}
const newStartDate = needsStartUpdate ? startDate : rangeStart;
const newEndDate = needsEndUpdate ? endDate : rangeEnd;
updateFilter("dateRange", {
type: ColumnFilterType.DATE_RANGE,
data: {
startDate: newStartDate.toISOString(),
endDate: newEndDate.toISOString(),
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
preset: CUSTOM_PRESET.value,
},
});
},
[dateRange, updateFilter, setCurrentWeekStart]
[updateFilter, setCurrentWeekStart]
);
return (
@@ -103,6 +76,8 @@ export function BookingsCalendar({ table }: BookingsCalendarViewProps) {
bookings={bookings}
currentWeekStart={currentWeekStart}
onWeekStartChange={handleWeekStartChange}
isPending={isPending}
onOpenDetails={onOpenDetails}
/>
</>
);
@@ -0,0 +1,131 @@
"use client";
import { useReactTable, getCoreRowModel, getSortedRowModel } from "@tanstack/react-table";
import { createParser, useQueryState } from "nuqs";
import { useCallback, useMemo } from "react";
import dayjs from "@calcom/dayjs";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
import { useFacetedUniqueValues } from "~/bookings/hooks/useFacetedUniqueValues";
import { buildFilterColumns, getFilterColumnVisibility } from "../columns/filterColumns";
import { useBookingCursor } from "../hooks/useBookingCursor";
import { useSelectedBookingId } from "../hooks/useSelectedBookingId";
import type { RowData, BookingListingStatus } from "../types";
import { BookingDetailsSheet } from "./BookingDetailsSheet";
import { BookingsCalendar } from "./BookingsCalendar";
const weekStartParser = createParser({
parse: (value: string) => {
const parsed = dayjs(value);
return parsed.isValid() ? parsed.startOf("week") : dayjs().startOf("week");
},
serialize: (value: dayjs.Dayjs) => value.format("YYYY-MM-DD"),
});
interface BookingsCalendarContainerProps {
status: BookingListingStatus;
permissions: {
canReadOthersBookings: boolean;
};
data: RowData[];
isPending?: boolean;
}
export function BookingsCalendarContainer({
status,
permissions,
data,
isPending = false,
}: BookingsCalendarContainerProps) {
const { t } = useLocale();
const user = useMeQuery().data;
const [selectedBookingId, setSelectedBookingId] = useSelectedBookingId();
const [currentWeekStart, setCurrentWeekStart] = useQueryState(
"weekStart",
weekStartParser.withDefault(dayjs().startOf("week"))
);
const onOpenDetails = useCallback(
(bookingId: number) => {
setSelectedBookingId(bookingId);
},
[setSelectedBookingId]
);
const columns = useMemo(() => {
return buildFilterColumns({ t, permissions, status });
}, [t, permissions, status]);
const getFacetedUniqueValues = useFacetedUniqueValues();
const table = useReactTable<RowData>({
data,
columns,
initialState: {
columnVisibility: getFilterColumnVisibility(),
},
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedUniqueValues,
});
// Extract bookings from table data and filter by current week
const bookings = useMemo(() => {
const weekStart = currentWeekStart;
const weekEnd = currentWeekStart.add(6, "day");
return data
.filter((row): row is Extract<RowData, { type: "data" }> => row.type === "data")
.map((row) => row.booking)
.filter((booking) => {
const bookingStart = dayjs(booking.startTime);
return (
(bookingStart.isAfter(weekStart) || bookingStart.isSame(weekStart, "day")) &&
bookingStart.isBefore(weekEnd.endOf("day"))
);
});
}, [data, currentWeekStart]);
const selectedBooking = useMemo(() => {
if (!selectedBookingId) return null;
return bookings.find((booking) => booking.id === selectedBookingId) ?? null;
}, [selectedBookingId, bookings]);
const bookingNavigation = useBookingCursor({
bookings,
selectedBookingId,
setSelectedBookingId,
});
return (
<>
<BookingsCalendar
status={status}
table={table}
isPending={isPending}
onOpenDetails={onOpenDetails}
currentWeekStart={currentWeekStart}
setCurrentWeekStart={setCurrentWeekStart}
bookings={bookings}
/>
<BookingDetailsSheet
booking={selectedBooking}
isOpen={!!selectedBooking}
onClose={() => setSelectedBookingId(null)}
userTimeZone={user?.timeZone}
userTimeFormat={user?.timeFormat === null ? undefined : user?.timeFormat}
userId={user?.id}
userEmail={user?.email}
onPrevious={bookingNavigation.onPrevious}
hasPrevious={bookingNavigation.hasPrevious}
onNext={bookingNavigation.onNext}
hasNext={bookingNavigation.hasNext}
/>
</>
);
}
@@ -18,12 +18,16 @@ type BookingsCalendarViewProps = {
bookings: BookingOutput[];
currentWeekStart: dayjs.Dayjs;
onWeekStartChange: (weekStart: dayjs.Dayjs) => void;
isPending?: boolean;
onOpenDetails: (bookingId: number) => void;
};
export function BookingsCalendarView({
bookings,
currentWeekStart,
onWeekStartChange,
isPending = false,
onOpenDetails,
}: BookingsCalendarViewProps) {
const { t } = useLocale();
const { timezone } = useTimePreferences();
@@ -44,8 +48,10 @@ export function BookingsCalendarView({
const startDate = useMemo(() => currentWeekStart.toDate(), [currentWeekStart]);
const endDate = useMemo(() => currentWeekStart.add(6, "day").toDate(), [currentWeekStart]);
// Intentionally only runs on mount to trigger the initial currentWeekStart
useEffect(() => {
onWeekStartChange(currentWeekStart);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const events = useMemo<CalendarEvent[]>(() => {
@@ -78,6 +84,7 @@ export function BookingsCalendarView({
options: {
status: booking.status,
...(eventTypeColor && { color: eventTypeColor }),
bookingId: booking.id,
},
};
});
@@ -109,6 +116,7 @@ export function BookingsCalendarView({
<div className="mx-4 mt-4 flex items-center justify-between py-1.5">
<div className="flex items-center gap-2">
<h2 className="text-xl font-semibold">{weekRange}</h2>
{isPending && <Icon name="refresh-cw" className="text-muted h-4 w-4 animate-spin" />}
</div>
<div className="flex items-center gap-2">
<Button color="secondary" onClick={goToToday} className="capitalize leading-4">
@@ -141,7 +149,12 @@ export function BookingsCalendarView({
showBackgroundPattern={false}
showBorder={false}
borderColor="subtle"
onEventClick={(_event) => {}}
onEventClick={(event) => {
const bookingId = event.options?.bookingId;
if (bookingId) {
onOpenDetails(bookingId);
}
}}
hideHeader
/>
</div>
@@ -1,8 +1,10 @@
"use client";
import type { Table as ReactTable } from "@tanstack/react-table";
import type { Row, Table as ReactTable } from "@tanstack/react-table";
import { useCallback } from "react";
import { DataTableWrapper, DataTableFilters, DataTableSegment } from "@calcom/features/data-table";
import { isSeparatorRow } from "@calcom/features/data-table/lib/separator";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { EmptyScreen } from "@calcom/ui/components/empty-screen";
@@ -23,22 +25,49 @@ type BookingsListViewProps = {
table: ReactTable<RowData>;
isPending: boolean;
totalRowCount?: number;
onOpenDetails: (bookingId: number) => void;
};
export function BookingsList({ status, table, isPending, totalRowCount }: BookingsListViewProps) {
export function BookingsList({
status,
table,
isPending,
totalRowCount,
onOpenDetails,
}: BookingsListViewProps) {
const { t } = useLocale();
const handleRowClick = useCallback(
(row: Row<RowData>) => {
if (!isSeparatorRow(row.original)) {
onOpenDetails(row.original.booking.id);
}
},
[onOpenDetails]
);
return (
<DataTableWrapper
className="mb-6"
table={table}
testId={`${status}-bookings`}
bodyTestId="bookings"
headerClassName="hidden"
rowTestId={(row) => {
if (isSeparatorRow(row.original)) return undefined;
return "booking-item";
}}
rowDataAttributes={(row) => {
if (isSeparatorRow(row.original)) return undefined;
return {
"data-today": String(row.original.isToday),
};
}}
isPending={isPending}
totalRowCount={totalRowCount}
variant="compact"
variant="default"
paginationMode="standard"
onRowMouseclick={handleRowClick}
hideSeparatorsOnSort={true}
ToolbarLeft={
<>
<DataTableFilters.FilterBar table={table} />
@@ -0,0 +1,218 @@
"use client";
import { useReactTable, getCoreRowModel, getSortedRowModel } from "@tanstack/react-table";
import { useCallback, useMemo, useState } from "react";
import { Dialog } from "@calcom/features/components/controlled-dialog";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
import { Button } from "@calcom/ui/components/button";
import { DialogContent, DialogFooter, DialogClose } from "@calcom/ui/components/dialog";
import { TextAreaField } from "@calcom/ui/components/form";
import { showToast } from "@calcom/ui/components/toast";
import { useFacetedUniqueValues } from "~/bookings/hooks/useFacetedUniqueValues";
import { buildFilterColumns, getFilterColumnVisibility } from "../columns/filterColumns";
import { buildListDisplayColumns } from "../columns/listColumns";
import { useBookingCursor } from "../hooks/useBookingCursor";
import { useSelectedBookingId } from "../hooks/useSelectedBookingId";
import type { RowData, BookingListingStatus } from "../types";
import { BookingDetailsSheet } from "./BookingDetailsSheet";
import { BookingsList } from "./BookingsList";
interface BookingsListContainerProps {
status: BookingListingStatus;
permissions: {
canReadOthersBookings: boolean;
};
data: RowData[];
isPending: boolean;
totalRowCount?: number;
}
export function BookingsListContainer({
status,
permissions,
data,
isPending,
totalRowCount,
}: BookingsListContainerProps) {
const { t } = useLocale();
const user = useMeQuery().data;
const utils = trpc.useUtils();
const [selectedBookingId, setSelectedBookingId] = useSelectedBookingId();
// Filter out separator rows and extract bookings
const bookings = useMemo(() => {
return data
.filter((row): row is Extract<RowData, { type: "data" }> => row.type === "data")
.map((row) => row.booking);
}, [data]);
const selectedBooking = useMemo(() => {
if (!selectedBookingId) return null;
return bookings.find((booking) => booking.id === selectedBookingId) ?? null;
}, [selectedBookingId, bookings]);
const bookingNavigation = useBookingCursor({
bookings,
selectedBookingId,
setSelectedBookingId,
});
const [rejectionDialogIsOpen, setRejectionDialogIsOpen] = useState(false);
const [rejectionReason, setRejectionReason] = useState<string>("");
const [pendingRejection, setPendingRejection] = useState<{
bookingId: number;
recurringEventId?: string | null;
} | null>(null);
const confirmMutation = trpc.viewer.bookings.confirm.useMutation({
onSuccess: (data) => {
if (data?.status === "REJECTED") {
setRejectionDialogIsOpen(false);
setRejectionReason("");
setPendingRejection(null);
showToast(t("booking_rejection_success"), "success");
} else {
showToast(t("booking_confirmation_success"), "success");
}
utils.viewer.bookings.invalidate();
utils.viewer.me.bookingUnconfirmedCount.invalidate();
},
onError: () => {
showToast(t("booking_confirmation_failed"), "error");
utils.viewer.bookings.invalidate();
},
});
const handleAccept = useCallback(
(bookingId: number, recurringEventId?: string | null) => {
confirmMutation.mutate({
bookingId,
confirmed: true,
reason: "",
...(recurringEventId && { recurringEventId }),
});
},
[confirmMutation]
);
const handleReject = useCallback((bookingId: number, recurringEventId?: string | null) => {
setPendingRejection({ bookingId, recurringEventId });
setRejectionDialogIsOpen(true);
}, []);
const handleConfirmRejection = useCallback(() => {
if (!pendingRejection) return;
confirmMutation.mutate({
bookingId: pendingRejection.bookingId,
confirmed: false,
reason: rejectionReason,
...(pendingRejection.recurringEventId && { recurringEventId: pendingRejection.recurringEventId }),
});
}, [pendingRejection, rejectionReason, confirmMutation]);
const onOpenDetails = useCallback(
(bookingId: number) => {
setSelectedBookingId(bookingId);
},
[setSelectedBookingId]
);
const columns = useMemo(() => {
const filterCols = buildFilterColumns({ t, permissions, status });
const listCols = buildListDisplayColumns({
t,
user,
pendingActionHandlers: {
onAccept: handleAccept,
onReject: handleReject,
isLoading: confirmMutation.isPending,
},
});
return [...filterCols, ...listCols];
}, [t, permissions, status, user, handleAccept, handleReject, confirmMutation.isPending]);
const getFacetedUniqueValues = useFacetedUniqueValues();
const table = useReactTable<RowData>({
data,
columns,
initialState: {
columnVisibility: getFilterColumnVisibility(),
columnPinning: {
right: ["actions"],
},
},
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedUniqueValues,
});
const handleRejectionDialogChange = useCallback((open: boolean) => {
setRejectionDialogIsOpen(open);
if (!open) {
setRejectionReason("");
setPendingRejection(null);
}
}, []);
return (
<>
<Dialog open={rejectionDialogIsOpen} onOpenChange={handleRejectionDialogChange}>
<DialogContent title={t("rejection_reason_title")} description={t("rejection_reason_description")}>
<div>
<TextAreaField
name="rejectionReason"
label={
<>
{t("rejection_reason")}
<span className="text-subtle font-normal"> (Optional)</span>
</>
}
value={rejectionReason}
onChange={(e) => setRejectionReason(e.target.value)}
/>
</div>
<DialogFooter>
<DialogClose />
<Button
disabled={confirmMutation.isPending}
data-testid="rejection-confirm"
onClick={handleConfirmRejection}>
{t("rejection_confirmation")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<BookingsList
status={status}
table={table}
isPending={isPending}
totalRowCount={totalRowCount}
onOpenDetails={onOpenDetails}
/>
<BookingDetailsSheet
booking={selectedBooking}
isOpen={!!selectedBooking}
onClose={() => setSelectedBookingId(null)}
userTimeZone={user?.timeZone}
userTimeFormat={user?.timeFormat === null ? undefined : user?.timeFormat}
userId={user?.id}
userEmail={user?.email}
onPrevious={bookingNavigation.onPrevious}
hasPrevious={bookingNavigation.hasPrevious}
onNext={bookingNavigation.onNext}
hasNext={bookingNavigation.hasNext}
/>
</>
);
}
@@ -0,0 +1,66 @@
"use client";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { BookingStatus } from "@calcom/prisma/enums";
import classNames from "@calcom/ui/classNames";
import { Button } from "@calcom/ui/components/button";
import { useJoinableLocation } from "./useJoinableLocation";
interface JoinMeetingButtonProps {
location: string | null;
metadata?: unknown;
bookingStatus: BookingStatus;
size?: "sm" | "base" | "lg";
color?: "primary" | "secondary" | "minimal" | "destructive";
className?: string;
onClick?: (e: React.MouseEvent) => void;
}
export function JoinMeetingButton({
location,
metadata,
bookingStatus,
size = "base",
color = "secondary",
className,
onClick,
}: JoinMeetingButtonProps) {
const { t } = useLocale();
const { isJoinable, locationToDisplay, provider } = useJoinableLocation({
location,
metadata,
bookingStatus,
t,
});
if (!isJoinable || !locationToDisplay) {
return null;
}
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
onClick?.(e);
};
return (
<Button
color={color}
size={size}
href={locationToDisplay}
target="_blank"
rel="noopener noreferrer"
className={classNames("flex items-center gap-2", className)}
onClick={handleClick}>
{provider?.iconUrl && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={provider.iconUrl}
className="h-4 w-4 flex-shrink-0 rounded-sm"
alt={`${provider.label} logo`}
/>
)}
{provider?.label ? t("join_event_location", { eventLocationType: provider.label }) : t("join_meeting")}
</Button>
);
}
@@ -0,0 +1,46 @@
import type { TFunction } from "i18next";
import { useMemo } from "react";
import { useBookingLocation } from "@calcom/features/bookings/hooks";
import type { BookingStatus } from "@calcom/prisma/enums";
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
interface UseJoinableLocationParams {
location: string | null;
metadata?: unknown;
bookingStatus: BookingStatus;
t: TFunction;
}
/**
* Custom hook to determine if a booking location is joinable (i.e., has a clickable URL).
* This hook is used to show/hide the join meeting button and related UI elements.
*
* @returns An object containing:
* - `isJoinable`: Whether the location is joinable (has a valid URL)
* - `locationToDisplay`: The location URL or text to display
* - `provider`: Provider information (label, iconUrl, etc.)
* - `isLocationURL`: Whether the location is a URL
*/
export function useJoinableLocation({ location, metadata, bookingStatus, t }: UseJoinableLocationParams) {
const bookingMetadata = useMemo(() => {
const parsedMetadata = bookingMetadataSchema.safeParse(metadata ?? null);
return parsedMetadata.success ? parsedMetadata.data : null;
}, [metadata]);
const { locationToDisplay, provider, isLocationURL } = useBookingLocation({
location,
videoCallUrl: bookingMetadata?.videoCallUrl,
t,
bookingStatus,
});
const isJoinable = isLocationURL && !!locationToDisplay;
return {
isJoinable,
locationToDisplay,
provider,
isLocationURL,
};
}
@@ -0,0 +1,37 @@
import { useMemo, useCallback } from "react";
import type { BookingOutput } from "../types";
export function useBookingCursor({
bookings,
selectedBookingId,
setSelectedBookingId,
}: {
bookings: BookingOutput[];
selectedBookingId: number | null;
setSelectedBookingId: (bookingId: number | null) => void;
}) {
const currentIndex = useMemo(
() => bookings.findIndex((booking) => selectedBookingId && booking.id === selectedBookingId),
[bookings, selectedBookingId]
);
const onPrevious = useCallback(() => {
if (currentIndex >= 1) {
setSelectedBookingId(bookings[currentIndex - 1].id);
}
}, [bookings, currentIndex, setSelectedBookingId]);
const onNext = useCallback(() => {
if (currentIndex >= 0 && currentIndex < bookings.length - 1) {
setSelectedBookingId(bookings[currentIndex + 1].id);
}
}, [bookings, currentIndex, setSelectedBookingId]);
return {
onPrevious,
onNext,
hasPrevious: currentIndex > 0,
hasNext: currentIndex < bookings.length - 1 && currentIndex >= 0,
};
}
@@ -0,0 +1,10 @@
import { useQueryState } from "nuqs";
export function useSelectedBookingId() {
return useQueryState("selectedId", {
defaultValue: null,
parse: (value) => (value ? parseInt(value, 10) : null),
serialize: (value) => (value ? String(value) : ""),
clearOnDefault: true,
});
}
@@ -0,0 +1,24 @@
/**
* Builds a booking link with query parameters
* @param bookingUid - The unique identifier for the booking
* @param allRemainingBookings - Whether to include all remaining bookings
* @param email - Optional email of the attendee
* @returns The formatted booking link with query parameters
*/
export function buildBookingLink({
bookingUid,
allRemainingBookings,
email,
}: {
bookingUid: string;
allRemainingBookings: boolean;
email?: string | null;
}): string {
const urlSearchParams = new URLSearchParams({
allRemainingBookings: allRemainingBookings.toString(),
});
if (email) {
urlSearchParams.set("email", email);
}
return `/booking/${bookingUid}?${urlSearchParams.toString()}`;
}
@@ -0,0 +1,11 @@
import { createParser } from "nuqs";
export const viewParser = createParser({
parse: (value: string) => {
if (value === "calendar") return "calendar";
return "list";
},
serialize: (value: "list" | "calendar") => value,
});
export type BookingView = "list" | "calendar";
+2 -3
View File
@@ -1,3 +1,4 @@
import type { SeparatorRow } from "@calcom/features/data-table/lib/separator";
import type { RouterOutputs } from "@calcom/trpc/react";
import type { validStatuses } from "~/bookings/lib/validStatuses";
@@ -18,8 +19,6 @@ export type RowData =
isToday: boolean;
recurringInfo?: RecurringInfo;
}
| {
type: "today" | "next";
};
| SeparatorRow;
export type BookingListingStatus = (typeof validStatuses)[number];
@@ -26,7 +26,7 @@ import { getCalendarLinks, CalendarLinkType } from "@calcom/features/bookings/li
import { RATING_OPTIONS, validateRating } from "@calcom/features/bookings/lib/rating";
import type { nameObjectSchema } from "@calcom/features/eventtypes/lib/eventNaming";
import { getEventName } from "@calcom/features/eventtypes/lib/eventNaming";
import { SMS_REMINDER_NUMBER_FIELD, SystemField, TITLE_FIELD } from "@calcom/lib/bookings/SystemField";
import { shouldShowFieldInCustomResponses } from "@calcom/lib/bookings/SystemField";
import { APP_NAME } from "@calcom/lib/constants";
import { formatToLocalizedDate, formatToLocalizedTime, formatToLocalizedTimezone } from "@calcom/lib/dayjs";
import useGetBrandingColours from "@calcom/lib/getBrandColours";
@@ -776,15 +776,9 @@ export default function Success(props: PageProps) {
// We show notes in additional notes section
// We show rescheduleReason at the top
const isSystemField = SystemField.safeParse(field.name);
// SMS_REMINDER_NUMBER_FIELD is a system field but doesn't have a dedicated place in the UI. So, it would be shown through the following responses list
// TITLE is also an identifier for booking question "What is this meeting about?"
if (
isSystemField.success &&
field.name !== SMS_REMINDER_NUMBER_FIELD &&
field.name !== TITLE_FIELD
)
if (!shouldShowFieldInCustomResponses(field.name)) {
return null;
}
const label = field.label || t(field.defaultLabel);
+120 -243
View File
@@ -1,15 +1,13 @@
"use client";
import { useReactTable, getCoreRowModel, getSortedRowModel, createColumnHelper } from "@tanstack/react-table";
import dynamic from "next/dynamic";
import { useSearchParams, usePathname } from "next/navigation";
import { createParser, useQueryState } from "nuqs";
import { useQueryState } from "nuqs";
import { useMemo } from "react";
import dayjs from "@calcom/dayjs";
import {
DataTableProvider,
DataTableFilters,
DataTableSegment,
type SystemFilterSegment,
useDataTable,
ColumnFilterType,
@@ -27,22 +25,26 @@ import type { HorizontalTabItemProps } from "@calcom/ui/components/navigation";
import { HorizontalTabs } from "@calcom/ui/components/navigation";
import { WipeMyCalActionButton } from "@calcom/web/components/apps/wipemycalother/wipeMyCalActionButton";
import BookingListItem from "@components/booking/BookingListItem";
import { useFacetedUniqueValues } from "~/bookings/hooks/useFacetedUniqueValues";
import type { validStatuses } from "~/bookings/lib/validStatuses";
import { viewParser } from "~/bookings/lib/viewParser";
import { BookingsCalendar } from "../components/BookingsCalendar";
import { BookingsList } from "../components/BookingsList";
import type { RowData, BookingOutput } from "../types";
const BookingsListContainer = dynamic(() =>
import("../components/BookingsListContainer").then((mod) => ({ default: mod.BookingsListContainer }))
);
const BookingsCalendarContainer = dynamic(() =>
import("../components/BookingsCalendarContainer").then((mod) => ({
default: mod.BookingsCalendarContainer,
}))
);
type BookingsProps = {
status: (typeof validStatuses)[number];
userId?: number;
permissions: {
canReadOthersBookings: boolean;
};
isCalendarViewEnabled: boolean;
};
function useSystemSegments(userId?: number) {
@@ -84,18 +86,9 @@ export default function Bookings(props: BookingsProps) {
);
}
const viewParser = createParser({
parse: (value: string) => {
if (value === "calendar") return "calendar";
return "list";
},
serialize: (value: "list" | "calendar") => value,
});
function BookingsContent({ status, permissions, isCalendarViewEnabled }: BookingsProps) {
const [_view] = useQueryState("view", viewParser.withDefault("list"));
function BookingsContent({ status, permissions }: BookingsProps) {
const [view] = useQueryState("view", viewParser.withDefault("list"));
// Force view to be "list" if calendar view is disabled
const view = isCalendarViewEnabled ? _view : "list";
const { t } = useLocale();
const user = useMeQuery().data;
const searchParams = useSearchParams();
@@ -148,159 +141,47 @@ function BookingsContent({ status, permissions, isCalendarViewEnabled }: Booking
const { limit, offset } = useDataTable();
const query = trpc.viewer.bookings.get.useQuery({
limit,
offset,
filters: {
status,
eventTypeIds,
teamIds,
userIds,
attendeeName,
attendeeEmail,
bookingUid,
afterStartDate: dateRange?.startDate
? dayjs(dateRange?.startDate).startOf("day").toISOString()
: undefined,
beforeEndDate: dateRange?.endDate ? dayjs(dateRange?.endDate).endOf("day").toISOString() : undefined,
// Only apply pagination for list view, calendar view needs all bookings
const shouldPaginate = view === "list";
const queryLimit = shouldPaginate ? limit : 100; // Use max limit for calendar view
const queryOffset = shouldPaginate ? offset : 0; // Reset offset for calendar view
const query = trpc.viewer.bookings.get.useQuery(
{
limit: queryLimit,
offset: queryOffset,
filters: {
status,
eventTypeIds,
teamIds,
userIds,
attendeeName,
attendeeEmail,
bookingUid,
afterStartDate: dateRange?.startDate
? dayjs(dateRange?.startDate).startOf("day").toISOString()
: undefined,
beforeEndDate: dateRange?.endDate ? dayjs(dateRange?.endDate).endOf("day").toISOString() : undefined,
},
},
});
const columns = useMemo(() => {
const columnHelper = createColumnHelper<RowData>();
return [
columnHelper.accessor((row) => row.type === "data" && row.booking.eventType.id, {
id: "eventTypeId",
header: t("event_type"),
enableColumnFilter: true,
enableSorting: false,
cell: () => null,
meta: {
filter: {
type: ColumnFilterType.MULTI_SELECT,
},
},
}),
columnHelper.accessor((row) => row.type === "data" && row.booking.eventType.team?.id, {
id: "teamId",
header: t("team"),
enableColumnFilter: true,
enableSorting: false,
cell: () => null,
meta: {
filter: {
type: ColumnFilterType.MULTI_SELECT,
},
},
}),
columnHelper.accessor((row) => row.type === "data" && row.booking.user?.id, {
id: "userId",
header: t("member"),
enableColumnFilter: permissions.canReadOthersBookings,
enableSorting: false,
cell: () => null,
meta: {
filter: {
type: ColumnFilterType.MULTI_SELECT,
},
},
}),
columnHelper.accessor((row) => row, {
id: "attendeeName",
header: t("attendee_name"),
enableColumnFilter: true,
enableSorting: false,
cell: () => null,
meta: {
filter: {
type: ColumnFilterType.TEXT,
},
},
}),
columnHelper.accessor((row) => row, {
id: "attendeeEmail",
header: t("attendee_email_variable"),
enableColumnFilter: true,
enableSorting: false,
cell: () => null,
meta: {
filter: {
type: ColumnFilterType.TEXT,
},
},
}),
columnHelper.accessor((row) => row, {
id: "dateRange",
header: t("date_range"),
enableColumnFilter: true,
enableSorting: false,
cell: () => null,
meta: {
filter: {
type: ColumnFilterType.DATE_RANGE,
dateRangeOptions: {
range: status === "past" ? "past" : "custom",
},
},
},
}),
columnHelper.accessor((row) => row.type === "data" && row.booking.uid, {
id: "bookingUid",
header: t("booking_uid"),
enableColumnFilter: true,
enableSorting: false,
cell: () => null,
meta: {
filter: {
type: ColumnFilterType.TEXT,
textOptions: {
allowedOperators: ["equals"],
},
},
},
}),
columnHelper.display({
id: "customView",
cell: (props) => {
if (props.row.original.type === "data") {
const { booking, recurringInfo, isToday } = props.row.original;
return (
<BookingListItem
key={booking.id}
isToday={isToday}
loggedInUser={{
userId: user?.id,
userTimeZone: user?.timeZone,
userTimeFormat: user?.timeFormat,
userEmail: user?.email,
}}
listingStatus={status}
recurringInfo={recurringInfo}
{...booking}
/>
);
} else if (props.row.original.type === "today") {
return (
<p className="text-subtle bg-subtle w-full py-4 pl-6 text-xs font-semibold uppercase leading-4">
{t("today")}
</p>
);
} else if (props.row.original.type === "next") {
return (
<p className="text-subtle bg-subtle w-full py-4 pl-6 text-xs font-semibold uppercase leading-4">
{t("next")}
</p>
);
}
},
}),
];
}, [user, status, t, permissions.canReadOthersBookings]);
{
staleTime: 5 * 60 * 1000, // 5 minutes - data is considered fresh
gcTime: 30 * 60 * 1000, // 30 minutes - cache retention time
}
);
const isEmpty = useMemo(() => !query.data?.bookings.length, [query.data]);
const flatData = useMemo<RowData[]>(() => {
const groupedBookings = useMemo(() => {
if (!query.data?.bookings) {
return { today: [], currentMonth: [], monthBuckets: {} };
}
const now = dayjs().tz(user?.timeZone);
const today = now.format("YYYY-MM-DD");
const currentMonthStart = now.startOf("month");
const currentMonthEnd = now.endOf("month");
const shownBookings: Record<string, BookingOutput[]> = {};
const filterBookings = (booking: BookingOutput) => {
if (status === "recurring" || status == "unconfirmed" || status === "cancelled") {
@@ -315,80 +196,86 @@ function BookingsContent({ status, permissions, isCalendarViewEnabled }: Booking
return false;
}
shownBookings[booking.recurringEventId] = [booking];
} else if (status === "upcoming") {
return (
dayjs(booking.startTime).tz(user?.timeZone).format("YYYY-MM-DD") !==
dayjs().tz(user?.timeZone).format("YYYY-MM-DD")
);
}
return true;
};
return (
query.data?.bookings.filter(filterBookings).map((booking) => ({
const todayBookings: RowData[] = [];
const currentMonthBookings: RowData[] = [];
const monthBuckets: Record<string, RowData[]> = {}; // Key format: "YYYY-MM"
query.data.bookings.filter(filterBookings).forEach((booking) => {
const bookingDate = dayjs(booking.startTime).tz(user?.timeZone);
const bookingDateStr = bookingDate.format("YYYY-MM-DD");
const monthKey = bookingDate.format("YYYY-MM");
const rowData: RowData = {
type: "data",
booking,
isToday: bookingDateStr === today,
recurringInfo: query.data?.recurringInfo.find(
(info) => info.recurringEventId === booking.recurringEventId
),
isToday: false,
})) || []
);
};
if (bookingDateStr === today) {
todayBookings.push(rowData);
} else if (bookingDate.isAfter(currentMonthStart) && bookingDate.isBefore(currentMonthEnd)) {
currentMonthBookings.push(rowData);
} else if (bookingDate.isAfter(currentMonthEnd)) {
if (!monthBuckets[monthKey]) {
monthBuckets[monthKey] = [];
}
monthBuckets[monthKey].push(rowData);
} else if (bookingDate.isBefore(currentMonthStart)) {
// Handle bookings from months before the current month
if (!monthBuckets[monthKey]) {
monthBuckets[monthKey] = [];
}
monthBuckets[monthKey].push(rowData);
}
});
return { today: todayBookings, currentMonth: currentMonthBookings, monthBuckets };
}, [query.data, status, user?.timeZone]);
const flatData = useMemo<RowData[]>(() => {
return [...groupedBookings.today, ...groupedBookings.currentMonth, ...Object.values(groupedBookings.monthBuckets).flat()];
}, [groupedBookings]);
const bookingsToday = useMemo<RowData[]>(() => {
return (
query.data?.bookings
.filter(
(booking: BookingOutput) =>
dayjs(booking.startTime).tz(user?.timeZone).format("YYYY-MM-DD") ===
dayjs().tz(user?.timeZone).format("YYYY-MM-DD")
)
.map((booking) => ({
type: "data" as const,
booking,
recurringInfo: query.data?.recurringInfo.find(
(info) => info.recurringEventId === booking.recurringEventId
),
isToday: true,
})) ?? []
);
}, [query.data, user?.timeZone]);
return groupedBookings.today;
}, [groupedBookings]);
const finalData = useMemo<RowData[]>(() => {
if (status !== "upcoming") {
return flatData;
}
const merged: RowData[] = [];
if (bookingsToday.length > 0) {
merged.push({ type: "today" as const }, ...bookingsToday);
// Add Today section
if (groupedBookings.today.length > 0) {
merged.push({ type: "separator", label: t("today") }, ...groupedBookings.today);
}
if (flatData.length > 0) {
merged.push({ type: "next" as const }, ...flatData);
// Add Current Month section (rest of this month, excluding today)
if (groupedBookings.currentMonth.length > 0) {
merged.push({ type: "separator", label: t("this_month") }, ...groupedBookings.currentMonth);
}
// Add individual month sections
const sortedMonthKeys = Object.keys(groupedBookings.monthBuckets).sort();
sortedMonthKeys.forEach((monthKey) => {
const bookings = groupedBookings.monthBuckets[monthKey];
if (bookings.length > 0) {
const monthLabel = dayjs(monthKey, "YYYY-MM").format("MMMM YYYY");
merged.push({ type: "separator", label: monthLabel }, ...bookings);
}
});
return merged;
}, [bookingsToday, flatData, status]);
const getFacetedUniqueValues = useFacetedUniqueValues();
const table = useReactTable<RowData>({
data: finalData,
columns,
initialState: {
columnVisibility: {
eventTypeId: false,
teamId: false,
userId: false,
attendeeName: false,
attendeeEmail: false,
dateRange: false,
bookingUid: false,
},
},
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedUniqueValues,
});
}, [groupedBookings, status, t, flatData]);
const isPending = query.isPending;
const totalRowCount = query.data?.totalCount;
@@ -406,37 +293,27 @@ function BookingsContent({ status, permissions, isCalendarViewEnabled }: Booking
<main className="w-full">
<div className="flex w-full flex-col">
{query.status === "error" ? (
<>
<div className="grid w-full items-center gap-2 pb-4">
<div className="flex w-full flex-col gap-2">
<div className="flex w-full flex-wrap justify-between gap-2">
<div className="flex flex-wrap items-center gap-2">
<DataTableFilters.FilterBar table={table} />
</div>
<div className="flex flex-wrap items-center gap-2">
<DataTableFilters.ClearFiltersButton />
<DataTableSegment.SaveButton />
<DataTableSegment.Select />
</div>
</div>
</div>
</div>
<Alert severity="error" title={t("something_went_wrong")} message={query.error.message} />
</>
<Alert severity="error" title={t("something_went_wrong")} message={query.error.message} />
) : (
<>
{!!bookingsToday.length && status === "upcoming" && (
<WipeMyCalActionButton bookingStatus={status} bookingsEmpty={isEmpty} />
)}
{view === "list" ? (
<BookingsList
<BookingsListContainer
status={status}
table={table}
permissions={permissions}
data={finalData}
isPending={isPending}
totalRowCount={totalRowCount}
/>
) : (
<BookingsCalendar status={status} table={table} />
<BookingsCalendarContainer
status={status}
permissions={permissions}
data={finalData}
isPending={isPending}
/>
)}
</>
)}
@@ -0,0 +1,123 @@
"use client";
import { useFormContext } from "react-hook-form";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@calcom/ui/components/button";
import { Label, TextField, Select } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import type { InviteRole } from "../store/onboarding-store";
type BaseInviteFormData = {
email: string;
role: InviteRole;
};
type InviteFormDataWithOptionalTeam = BaseInviteFormData & {
team?: string;
};
type InviteFormDataWithRequiredTeam = BaseInviteFormData & {
team: string;
};
type InviteFormData = InviteFormDataWithOptionalTeam | InviteFormDataWithRequiredTeam;
type EmailInviteFormProps = {
fields: Array<{ id: string }>;
append: (value: InviteFormData) => void;
remove: (index: number) => void;
defaultRole: InviteRole;
showTeamSelect?: boolean;
teams?: Array<{ value: string; label: string }>;
emailPlaceholder?: string;
};
export function EmailInviteForm({
fields,
append,
remove,
defaultRole,
showTeamSelect = false,
teams = [],
emailPlaceholder,
}: EmailInviteFormProps) {
const { t } = useLocale();
const { register, watch, setValue } = useFormContext();
return (
<div className="flex w-full flex-col gap-4">
<div className="flex flex-col gap-2">
{showTeamSelect ? (
<div className="grid grid-cols-2">
<Label className="text-emphasis mb-0 text-sm font-medium" htmlFor="invites.0.email">
{t("email")}
</Label>
<Label className="text-emphasis mb-0 text-sm font-medium" htmlFor="invites.0.team">
{t("team")}
</Label>
</div>
) : (
<Label className="text-emphasis text-sm font-medium">{t("email")}</Label>
)}
<div
className={
showTeamSelect ? "flex flex-col gap-2" : "scroll-bar flex max-h-72 flex-col gap-1 overflow-y-auto"
}>
{fields.map((field, index) => (
<div key={field.id} className="flex items-start gap-0.5 p-0.5">
<div className={showTeamSelect ? "grid flex-1 items-start gap-2 md:grid-cols-2" : "flex-1"}>
<TextField
labelSrOnly
{...register(`invites.${index}.email`)}
placeholder={emailPlaceholder || `rick@cal.com`}
type="email"
size="sm"
/>
{showTeamSelect && (
<Select
size="sm"
options={teams}
value={teams.find((t) => t.value === watch(`invites.${index}.team`))}
onChange={(option) => {
if (option) {
setValue(`invites.${index}.team`, option.value);
}
}}
placeholder={t("select_team")}
/>
)}
</div>
<Button
type="button"
color="minimal"
variant="icon"
size="sm"
className="h-7 w-7"
disabled={fields.length === 1}
onClick={() => remove(index)}>
<Icon name="x" className="h-4 w-4" />
</Button>
</div>
))}
</div>
<Button
type="button"
color="secondary"
size="sm"
StartIcon="plus"
className={showTeamSelect ? "mt-2 w-fit" : "w-fit"}
onClick={() =>
append(
showTeamSelect ? { email: "", team: "", role: defaultRole } : { email: "", role: defaultRole }
)
}>
{t("add")}
</Button>
</div>
</div>
);
}
@@ -0,0 +1,104 @@
"use client";
import { useFlags } from "@calcom/features/flags/hooks";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@calcom/ui/components/button";
import { Icon } from "@calcom/ui/components/icon";
const GoogleIcon = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clipPath="url(#clip0_4178_176214)">
<path
d="M8.31875 15.36C4.26 15.36 0.9575 12.0588 0.9575 8.00001C0.9575 3.94126 4.26 0.640015 8.31875 0.640015C10.1575 0.640015 11.9175 1.32126 13.2763 2.55876L13.5238 2.78501L11.0963 5.21251L10.8713 5.02001C10.1588 4.41001 9.2525 4.07376 8.31875 4.07376C6.15375 4.07376 4.39125 5.83501 4.39125 8.00001C4.39125 10.165 6.15375 11.9263 8.31875 11.9263C9.88 11.9263 11.1138 11.1288 11.695 9.77001H7.99875V6.45626L15.215 6.46626L15.2688 6.72001C15.645 8.50626 15.3438 11.1338 13.8188 13.0138C12.5563 14.57 10.7063 15.36 8.31875 15.36Z"
fill="#6B7280"
/>
</g>
<defs>
<clipPath id="clip0_4178_176214">
<rect width="16" height="16" fill="white" />
</clipPath>
</defs>
</svg>
);
type InviteOptionsProps = {
onInviteViaEmail: () => void;
onUploadCSV?: () => void;
onCopyInviteLink?: () => void;
onConnectGoogleWorkspace?: () => void;
isSubmitting?: boolean;
};
export const InviteOptions = ({
onInviteViaEmail,
onUploadCSV,
onCopyInviteLink,
onConnectGoogleWorkspace,
isSubmitting = false,
}: InviteOptionsProps) => {
const { t } = useLocale();
const flags = useFlags();
const googleWorkspaceEnabled = flags["google-workspace-directory"];
return (
<div className="flex h-full w-full flex-1 flex-col gap-6 ">
{googleWorkspaceEnabled && onConnectGoogleWorkspace && (
<>
<Button
color="secondary"
className="h-8 w-full rounded-[10px]"
CustomStartIcon={<GoogleIcon />}
onClick={onConnectGoogleWorkspace}
disabled={isSubmitting}>
{t("connect_google_workspace")}
</Button>
<div className="flex w-full items-center gap-2">
<div className="border-subtle h-px flex-1 border-t" />
<span className="text-subtle text-sm font-medium">{t("or")}</span>
<div className="border-subtle h-px flex-1 border-t" />
</div>
</>
)}
<div className="flex w-full flex-1 flex-col gap-4">
<Button
color="primary"
className="h-8 w-full justify-center rounded-[10px]"
onClick={onInviteViaEmail}
disabled={isSubmitting}>
<div className="flex items-center gap-1">
<Icon name="mail" className="h-4 w-4" />
<span>{t("invite_via_email")}</span>
</div>
</Button>
{onUploadCSV && (
<Button
color="secondary"
className="h-8 w-full justify-center rounded-[10px]"
onClick={onUploadCSV}
disabled={isSubmitting}>
<div className="flex items-center gap-1">
<Icon name="upload" className="h-4 w-4" />
<span>{t("upload_csv_file")}</span>
</div>
</Button>
)}
{onCopyInviteLink && (
<Button
color="secondary"
className="h-8 w-full justify-center rounded-[10px]"
onClick={onCopyInviteLink}
disabled>
<div className="flex items-center gap-1">
<Icon name="link" className="h-4 w-4" />
<span>{t("copy_invite_link")}</span>
</div>
</Button>
)}
</div>
</div>
);
};
@@ -22,7 +22,7 @@ export const OnboardingCard = ({ title, subtitle, children, footer, isLoading }:
</div>
{/* Content */}
<div className="flex min-h-0 w-full flex-1 flex-col gap-4">
<div className="flex h-full min-h-0 w-full flex-1 flex-col gap-4">
{isLoading ? (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<SkeletonText className="h-40 w-full" />
@@ -38,4 +38,3 @@ export const OnboardingCard = ({ title, subtitle, children, footer, isLoading }:
</div>
);
};
@@ -1,5 +1,6 @@
"use client";
import classNames from "classnames";
import { signOut } from "next-auth/react";
import { Children, type ReactNode } from "react";
@@ -9,11 +10,12 @@ import { Logo } from "@calcom/ui/components/logo";
type OnboardingLayoutProps = {
userEmail: string;
currentStep: 1 | 2 | 3;
currentStep: number;
totalSteps: number;
children: ReactNode;
};
export const OnboardingLayout = ({ userEmail, currentStep, children }: OnboardingLayoutProps) => {
export const OnboardingLayout = ({ userEmail, currentStep, totalSteps, children }: OnboardingLayoutProps) => {
const { t } = useLocale();
const handleSignOut = () => {
@@ -42,17 +44,31 @@ export const OnboardingLayout = ({ userEmail, currentStep, children }: Onboardin
{/* Footer with progress dots and sign out */}
<div className="flex w-full flex-col items-center justify-center gap-4 px-10 py-8">
<div className="flex items-center gap-2">
{[1, 2, 3].map((step) => (
<div key={step} className="relative flex h-2 w-2 shrink-0 items-center justify-center">
<div
className={`absolute inset-0 rounded-full ${
step <= currentStep ? "bg-emphasis" : "bg-muted"
}`}
/>
{step <= currentStep && <div className="bg-emphasis absolute h-1 w-1 rounded-full" />}
</div>
))}
<div className="flex items-center gap-3">
{Array.from({ length: totalSteps }, (_, i) => i + 1).map((step) => {
const isCurrentStep = step === currentStep;
const isCompleted = step < currentStep;
return (
<div key={step} className="relative flex shrink-0 items-center justify-center">
<div
className={classNames("absolute rounded-full transition-all", {
"h-2 w-2": !isCurrentStep,
"h-3 w-3": isCurrentStep,
"bg-emphasis": isCompleted || isCurrentStep,
"bg-muted": !isCompleted && !isCurrentStep,
})}
/>
{(isCompleted || isCurrentStep) && (
<div
className={classNames("bg-emphasis absolute rounded-full", {
"h-1 w-1": !isCurrentStep,
"h-1.5 w-1.5": isCurrentStep,
})}
/>
)}
</div>
);
})}
</div>
<Button onClick={handleSignOut} color="minimal" className="text-subtle h-7">
{t("sign_out")}
@@ -61,4 +77,3 @@ export const OnboardingLayout = ({ userEmail, currentStep, children }: Onboardin
</div>
);
};
@@ -0,0 +1,38 @@
"use client";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { ToggleGroup } from "@calcom/ui/components/form";
import { InfoBadge } from "@calcom/ui/components/badge";
import type { InviteRole } from "../store/onboarding-store";
type RoleSelectorProps = {
value: InviteRole;
onValueChange: (value: InviteRole) => void;
showInfoBadge?: boolean;
};
export const RoleSelector = ({ value, onValueChange, showInfoBadge = false }: RoleSelectorProps) => {
const { t } = useLocale();
return (
<div className="flex items-center justify-between">
<div className="hidden items-center gap-2 md:flex">
<span className="text-emphasis text-sm">{t("onboarding_invite_all_as")}</span>
<ToggleGroup
value={value}
onValueChange={(val) => val && onValueChange(val as InviteRole)}
options={[
{ value: "MEMBER", label: t("members") },
{ value: "ADMIN", label: t("onboarding_admins") },
]}
/>
{showInfoBadge && <InfoBadge content={t("onboarding_modify_roles_later")} />}
</div>
{!showInfoBadge && (
<span className="text-subtle text-sm">{t("onboarding_modify_roles_later")}</span>
)}
</div>
);
};
@@ -1,5 +1,7 @@
"use client";
import classNames from "classnames";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Avatar } from "@calcom/ui/components/avatar";
@@ -95,7 +97,11 @@ export const OnboardingBrowserView = ({
<h2 className="text-emphasis text-xl font-semibold leading-tight">
{name || t("your_name")}
</h2>
<p className="text-default text-sm leading-normal">
<p
className={classNames("text-sm leading-normal", {
"text-default": bio,
"text-subtle italic": !bio,
})}>
{bio || t("onboarding_browser_view_default_bio")}
</p>
</div>
@@ -1,91 +1,58 @@
"use client";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Icon, type IconName } from "@calcom/ui/components/icon";
import { useMemo } from "react";
import dayjs from "@calcom/dayjs";
import { Calendar } from "@calcom/features/calendars/weeklyview";
import { weekdayDates } from "@calcom/features/calendars/weeklyview/utils";
import { CURRENT_TIMEZONE } from "@calcom/lib/timezoneConstants";
import { useOnboardingCalendarEvents } from "../hooks/useOnboardingCalendarEvents";
export const OnboardingCalendarBrowserView = () => {
const { t } = useLocale();
const webappUrl = WEBAPP_URL.replace(/^https?:\/\//, "");
const { startDate, endDate } = useMemo(() => {
return weekdayDates(0, new Date(), 6);
}, []);
const calendarIntegrations: Array<{
name: string;
description: string;
icon: IconName;
}> = [
{
name: t("google_calendar"),
description: t("onboarding_calendar_browser_view_google_description"),
icon: "calendar",
},
{
name: t("outlook_calendar"),
description: t("onboarding_calendar_browser_view_outlook_description"),
icon: "mail",
},
{
name: t("apple_calendar"),
description: t("onboarding_calendar_browser_view_apple_description"),
icon: "calendar-days",
},
];
const { events } = useOnboardingCalendarEvents({ startDate, endDate });
// Memoize calendar props to prevent unnecessary re-initializations
const calendarProps = useMemo(
() => ({
timezone: CURRENT_TIMEZONE,
startDate,
endDate,
events: events || [],
gridCellsPerHour: 4,
hoverEventDuration: 0,
showBackgroundPattern: false,
showBorder: false,
borderColor: "subtle" as const,
hideHeader: true,
eventsDisabled: true,
sortEvents: true,
isPending: false, // Explicitly set to false to prevent loading spinner
loading: false, // Also set loading to false just in case
onEventClick: () => {},
onEmptyCellClick: () => {},
onDateChange: () => {},
showTimezone: true,
}),
[startDate, endDate, events]
);
return (
<div className="bg-default border-subtle hidden h-full w-full flex-col rounded-l-2xl border xl:flex">
{/* Browser header */}
<div className="border-subtle flex min-w-0 shrink-0 items-center gap-3 rounded-t-2xl border-b bg-white p-3">
{/* Navigation buttons */}
<div className="flex shrink-0 items-center gap-4 opacity-50">
<Icon name="arrow-left" className="text-subtle h-4 w-4" />
<Icon name="arrow-right" className="text-subtle h-4 w-4" />
<Icon name="rotate-cw" className="text-subtle h-4 w-4" />
</div>
<div className="bg-muted flex w-full items-center gap-2 rounded-[32px] px-3 py-2">
<Icon name="lock" className="text-subtle h-4 w-4" />
<p className="text-default text-sm font-medium leading-tight">{webappUrl}/settings/calendars</p>
</div>
<Icon name="ellipsis-vertical" className="text-subtle h-4 w-4" />
<div className="bg-default border-muted flex h-full w-full flex-col overflow-hidden rounded-xl border">
<div className="flex items-center gap-2 px-4 py-3">
<span className="text-default text-sm font-semibold">
{dayjs(startDate).format("MMM D")}
<span className="mx-1">–</span>
{dayjs(endDate).format("MMM D, YYYY")}
</span>
</div>
{/* Content */}
<div className="bg-muted h-full pl-11 pt-11">
<div className="bg-default border-muted flex h-full w-full flex-col overflow-hidden rounded-xl border">
{/* Header */}
<div className="border-subtle flex flex-col gap-4 border-b p-4">
<div className="flex flex-col items-start gap-2">
<h2 className="text-emphasis text-xl font-semibold leading-tight">
{t("connect_your_calendar")}
</h2>
<p className="text-subtle text-sm leading-normal">
{t("onboarding_calendar_browser_view_subtitle")}
</p>
</div>
</div>
{/* Calendar Integrations List */}
<div className="flex flex-col overflow-y-auto">
{calendarIntegrations.map((integration, index) => (
<div key={integration.name} className="opacity-30">
{index > 0 && <div className="border-subtle h-px border-t" />}
<div className="flex items-center justify-between gap-3 px-5 py-4">
<div className="flex min-w-0 flex-1 items-center gap-3">
<div className="bg-muted flex h-10 w-10 shrink-0 items-center justify-center rounded-lg">
<Icon name={integration.icon} className="text-emphasis h-5 w-5" />
</div>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<h3 className="text-default text-sm font-semibold leading-none">{integration.name}</h3>
<p className="text-subtle text-sm font-medium leading-tight">
{integration.description}
</p>
</div>
</div>
<div className="bg-emphasis flex h-6 items-center justify-center rounded-md px-2">
<span className="text-emphasis text-xs font-medium leading-none">{t("connected")}</span>
</div>
</div>
</div>
))}
</div>
</div>
{/* Calendar View */}
<div className="flex h-full flex-col overflow-hidden">
<Calendar {...calendarProps} />
</div>
</div>
);
@@ -0,0 +1,147 @@
"use client";
import classNames from "classnames";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Avatar } from "@calcom/ui/components/avatar";
import { Button } from "@calcom/ui/components/button";
import { Icon, type IconName } from "@calcom/ui/components/icon";
type OnboardingOrganizationBrowserViewProps = {
avatar?: string | null;
name?: string;
bio?: string;
slug?: string;
bannerUrl?: string | null;
};
export const OnboardingOrganizationBrowserView = ({
avatar,
name,
bio,
slug,
bannerUrl,
}: OnboardingOrganizationBrowserViewProps) => {
const { t } = useLocale();
const webappUrl = WEBAPP_URL.replace(/^https?:\/\//, "");
const displayUrl = `${webappUrl}/${slug || ""}`;
const events: Array<{
title: string;
description: string;
duration: number;
icon: IconName;
}> = [
{
title: t("onboarding_browser_view_demo"),
description: t("onboarding_browser_view_demo_description"),
duration: 15,
icon: "bell",
},
{
title: t("onboarding_browser_view_quick_meeting"),
description: t("onboarding_browser_view_quick_meeting_description"),
duration: 15,
icon: "bell",
},
{
title: t("onboarding_browser_view_longer_meeting"),
description: t("onboarding_browser_view_longer_meeting_description"),
duration: 30,
icon: "clock",
},
{
title: t("in_person_meeting"),
description: t("onboarding_browser_view_in_person_description"),
duration: 120,
icon: "map-pin",
},
];
return (
<div className="bg-default border-subtle hidden h-full w-full flex-col rounded-l-2xl border xl:flex">
{/* Browser header */}
<div className="border-subtle flex min-w-0 shrink-0 items-center gap-3 rounded-t-2xl border-b bg-white p-3">
{/* Navigation buttons */}
<div className="flex shrink-0 items-center gap-4 opacity-50">
<Icon name="arrow-left" className="text-subtle h-4 w-4" />
<Icon name="arrow-right" className="text-subtle h-4 w-4" />
<Icon name="rotate-cw" className="text-subtle h-4 w-4" />
</div>
<div className="bg-muted flex w-full items-center gap-2 rounded-[32px] px-3 py-2">
<Icon name="lock" className="text-subtle h-4 w-4" />
<p className="text-default text-sm font-medium leading-tight">{displayUrl}</p>
</div>
<Icon name="ellipsis-vertical" className="text-subtle h-4 w-4" />
</div>
{/* Content */}
<div className="bg-muted h-full pl-11 pt-11">
<div className="bg-default border-muted flex h-full w-full flex-col overflow-hidden rounded-xl border">
{/* Organization Profile Header with Banner */}
<div className="border-subtle flex flex-col border-b">
<div className="relative">
{/* Banner Image */}
<div className="border-subtle relative h-40 w-full overflow-hidden rounded-t-xl border-b">
{bannerUrl ? (
<img src={bannerUrl} alt={name || ""} className="h-full w-full object-cover" />
) : (
<div className="bg-emphasis h-full w-full" />
)}
</div>
{/* Organization Avatar - Overlaying the banner */}
<div className="absolute -bottom-8 left-4">
<Avatar
size="lg"
imageSrc={avatar || undefined}
alt={name || ""}
className="border-4 border-white"
/>
</div>
</div>
{/* Organization Info */}
<div className="flex flex-col gap-2 px-4 pb-4 pt-12">
<h2 className="text-emphasis text-xl font-semibold leading-tight">
{name || t("organization_name")}
</h2>
<p
className={classNames("text-sm leading-normal", {
"text-default": bio,
"text-subtle italic": !bio,
})}>
{bio || t("onboarding_browser_view_default_bio")}
</p>
</div>
</div>
{/* Events List */}
<div className="flex flex-col overflow-y-hidden">
{events.map((event, index) => (
<div key={event.title} className="border opacity-30 first:border-t-0">
<div className="flex items-center justify-between gap-3 px-5 py-4">
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex items-center gap-1">
<h3 className="text-default text-sm font-semibold leading-none">{event.title}</h3>
<div className="bg-emphasis flex h-4 items-center justify-center gap-1 rounded-md px-1">
<Icon name={event.icon} className="text-emphasis h-3 w-3" />
<span className="text-emphasis text-xs font-medium leading-none">
{event.duration} {t("minute_timeUnit")}
</span>
</div>
</div>
<p className="text-subtle text-sm font-medium leading-tight">{event.description}</p>
</div>
<Button color="secondary" size="sm" EndIcon="arrow-right">
{t("book_now")}
</Button>
</div>
</div>
))}
</div>
</div>
</div>
</div>
);
};
@@ -0,0 +1,136 @@
"use client";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Avatar } from "@calcom/ui/components/avatar";
import { Icon } from "@calcom/ui/components/icon";
type OnboardingTeamsBrowserViewProps = {
teams: Array<{ name: string }>;
organizationLogo?: string | null;
organizationName?: string;
organizationBanner?: string | null;
};
export const OnboardingTeamsBrowserView = ({
teams,
organizationLogo,
organizationName,
organizationBanner,
}: OnboardingTeamsBrowserViewProps) => {
const { t } = useLocale();
const webappUrl = WEBAPP_URL.replace(/^https?:\/\//, "");
// Show placeholder if no teams or all teams are empty
const hasValidTeams = teams.some((team) => team.name && team.name.trim().length > 0);
return (
<div className="bg-default border-subtle hidden h-full w-full flex-col rounded-l-2xl border xl:flex">
{/* Browser header */}
<div className="border-subtle flex min-w-0 shrink-0 items-center gap-3 rounded-t-2xl border-b bg-white p-3">
{/* Navigation buttons */}
<div className="flex shrink-0 items-center gap-4 opacity-50">
<Icon name="arrow-left" className="text-subtle h-4 w-4" />
<Icon name="arrow-right" className="text-subtle h-4 w-4" />
<Icon name="rotate-cw" className="text-subtle h-4 w-4" />
</div>
<div className="bg-muted flex w-full items-center gap-2 rounded-[32px] px-3 py-2">
<Icon name="lock" className="text-subtle h-4 w-4" />
<p className="text-default text-sm font-medium leading-tight">{webappUrl}/teams</p>
</div>
<Icon name="ellipsis-vertical" className="text-subtle h-4 w-4" />
</div>
{/* Content */}
<div className="bg-muted h-full pl-11 pt-11">
<div className="bg-default border-muted flex h-full w-full flex-col overflow-hidden rounded-xl border">
{/* Teams Header with Banner */}
<div className="border-subtle flex flex-col border-b">
<div className="relative">
{/* Banner Image */}
<div className="border-subtle relative h-40 w-full overflow-hidden rounded-t-xl border-b">
{organizationBanner ? (
<img
src={organizationBanner}
alt={organizationName || ""}
className="h-full w-full object-cover"
/>
) : (
<div className="bg-emphasis h-full w-full" />
)}
</div>
{/* Organization Logo - Overlaying the banner */}
{organizationLogo && (
<div className="absolute -bottom-8 left-4">
<Avatar
size="lg"
imageSrc={organizationLogo}
alt={organizationName || ""}
className="border-4 border-white"
/>
</div>
)}
</div>
{/* Teams Info */}
<div className="flex flex-col gap-2 px-4 pb-4 pt-12">
<h2 className="text-emphasis text-xl font-semibold leading-tight">{t("teams")}</h2>
<p className="text-subtle text-sm leading-normal">
{hasValidTeams
? t("onboarding_teams_browser_view_subtitle")
: t("onboarding_teams_browser_view_placeholder_subtitle")}
</p>
</div>
</div>
{/* Teams List */}
<div className="flex flex-col overflow-y-auto">
{hasValidTeams ? (
teams
.filter((team) => team.name && team.name.trim().length > 0)
.map((team, index) => (
<div key={index} className="">
{index > 0 && <div className="border-subtle h-px border-t" />}
<div className="flex items-center gap-3 px-5 py-4">
<Avatar
size="md"
alt={team.name}
className="border-2 border-white"
fallback={
<div className="bg-emphasis flex h-full w-full items-center justify-center text-white">
<span className="text-sm font-semibold">{team.name.charAt(0).toUpperCase()}</span>
</div>
}
/>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<h3 className="text-subtle text-sm font-semibold leading-none">{team.name}</h3>
<p className="text-muted text-xs font-medium leading-tight">
{t("onboarding_teams_browser_view_team_description")}
</p>
</div>
<Icon name="arrow-right" className="text-subtle h-4 w-4" />
</div>
</div>
))
) : (
<div className="flex flex-col items-center justify-center gap-3 px-5 py-12">
<div className="bg-muted flex h-16 w-16 items-center justify-center rounded-full">
<Icon name="users" className="text-subtle h-8 w-8" />
</div>
<div className="flex flex-col gap-1 text-center">
<p className="text-default text-sm font-semibold leading-tight">
{t("onboarding_teams_browser_view_no_teams_title")}
</p>
<p className="text-subtle text-xs font-medium leading-tight">
{t("onboarding_teams_browser_view_no_teams_description")}
</p>
</div>
</div>
)}
</div>
</div>
</div>
</div>
);
};
@@ -106,7 +106,7 @@ export const OnboardingView = ({ userEmail }: OnboardingViewProps) => {
return (
<>
<OnboardingContinuationPrompt />
<OnboardingLayout userEmail={userEmail} currentStep={1}>
<OnboardingLayout userEmail={userEmail} currentStep={1} totalSteps={1}>
{/* Left column - Main content */}
<OnboardingCard
title="Select plan"
@@ -0,0 +1,79 @@
import { useSession } from "next-auth/react";
import { useMemo, useEffect } from "react";
import dayjs from "@calcom/dayjs";
import type { GetUserAvailabilityResult } from "@calcom/features/availability/lib/getUserAvailability";
import type { CalendarEvent } from "@calcom/features/calendars/weeklyview/types/events";
import { BookingStatus } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
type UseOnboardingCalendarEventsProps = {
startDate: Date;
endDate: Date;
};
const emptyAvailabilityData = {
busy: [],
timeZone: "",
dateRanges: [],
oooExcludedDateRanges: [],
workingHours: [],
dateOverrides: [],
currentSeats: null,
datesOutOfOffice: {},
} as GetUserAvailabilityResult;
export const useOnboardingCalendarEvents = ({ startDate, endDate }: UseOnboardingCalendarEventsProps) => {
const { data: session } = useSession();
const utils = trpc.useUtils();
// Watch for calendar installations to refetch events
const { data: connectedCalendars } = trpc.viewer.calendars.connectedCalendars.useQuery(undefined, {
refetchInterval: 5000, // Poll every 5 seconds to detect new calendar installations
});
const { data: busyEvents } = trpc.viewer.availability.user.useQuery(
{
username: session?.user?.username || "",
dateFrom: dayjs(startDate).startOf("day").utc().format(),
dateTo: dayjs(endDate).endOf("day").utc().format(),
withSource: true,
},
{
enabled: !!session?.user?.username,
// Don't show loading state - return empty array immediately
placeholderData: emptyAvailabilityData,
}
);
// Refetch availability when calendars change
useEffect(() => {
if (connectedCalendars?.connectedCalendars) {
utils.viewer.availability.user.invalidate();
}
}, [connectedCalendars?.connectedCalendars?.length, utils.viewer.availability.user]);
// Format events similar to Troubleshooter
// Always return an array, never undefined
const events = useMemo((): CalendarEvent[] => {
if (!busyEvents?.busy) return [];
return busyEvents.busy.map((event, idx) => {
return {
id: idx,
title: event.title ?? `Busy`,
start: new Date(event.start),
end: new Date(event.end),
options: {
color: event.source ? undefined : undefined,
status: BookingStatus.ACCEPTED,
},
};
});
}, [busyEvents]);
return {
events,
isLoading: false, // Never show loading state
};
};
@@ -4,9 +4,12 @@ import { setShowWelcomeToCalcomModalFlag } from "@calcom/features/shell/hooks/us
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useTelemetry } from "@calcom/lib/hooks/useTelemetry";
import { telemetryEventTypes } from "@calcom/lib/telemetry";
import { sessionStorage } from "@calcom/lib/webstorage";
import { trpc } from "@calcom/trpc/react";
import { showToast } from "@calcom/ui/components/toast";
const ORG_MODAL_STORAGE_KEY = "showNewOrgModal";
const DEFAULT_EVENT_TYPES = [
{
title: "15min_meeting",
@@ -56,9 +59,19 @@ export const useSubmitPersonalOnboarding = () => {
}
await utils.viewer.me.get.refetch();
// Set flag to show welcome modal after redirect
setShowWelcomeToCalcomModalFlag();
router.push("/event-types?welcomeToCalcomModal=true");
// Check if org modal flag is set - if so, don't show personal modal
// Organization onboarding takes precedence
const hasOrgModalFlag = sessionStorage.getItem(ORG_MODAL_STORAGE_KEY) === "true";
if (!hasOrgModalFlag) {
// Only set personal modal flag if org modal flag is not set
setShowWelcomeToCalcomModalFlag();
router.push("/event-types?welcomeToCalcomModal=true");
} else {
// Org modal flag exists - redirect to event-types without personal modal query param
// The org modal will show via sessionStorage check
router.push("/event-types?newOrganizationModal=true");
}
},
onError: (error) => {
showToast(t("something_went_wrong"), "error");
@@ -1,77 +1,37 @@
"use client";
import * as Popover from "@radix-ui/react-popover";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { HexColorPicker } from "react-colorful";
import { useEffect, useRef, useState } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@calcom/ui/components/button";
import { ColorPicker, Label } from "@calcom/ui/components/form";
import { OnboardingCard } from "../../components/OnboardingCard";
import { OnboardingLayout } from "../../components/OnboardingLayout";
import { OnboardingBrowserView } from "../../components/onboarding-browser-view";
import { OnboardingOrganizationBrowserView } from "../../components/onboarding-organization-browser-view";
import { useOnboardingStore } from "../../store/onboarding-store";
type OrganizationBrandViewProps = {
userEmail: string;
};
const BrandColorPicker = ({
value,
onChange,
t,
}: {
value: string;
onChange: (value: string) => void;
t: (key: string) => string;
}) => {
return (
<div className="border-default bg-default flex h-7 w-32 items-center gap-2 rounded-lg border px-2 py-1.5">
<Popover.Root>
<Popover.Trigger asChild>
<button
className="h-4 w-4 shrink-0 rounded-full border border-gray-200"
style={{ backgroundColor: value }}
aria-label={t("onboarding_pick_color_aria")}
/>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content align="start" sideOffset={5}>
<HexColorPicker color={value} onChange={onChange} className="!h-32 !w-32" />
</Popover.Content>
</Popover.Portal>
</Popover.Root>
<input
type="text"
value={value.replace("#", "")}
onChange={(e) => {
const newValue = e.target.value.startsWith("#") ? e.target.value : `#${e.target.value}`;
onChange(newValue);
}}
className="text-emphasis grow border-none bg-transparent text-sm font-medium leading-4 outline-none"
maxLength={6}
/>
</div>
);
};
export const OrganizationBrandView = ({ userEmail }: OrganizationBrandViewProps) => {
const router = useRouter();
const { t } = useLocale();
const { organizationBrand, setOrganizationBrand, organizationDetails } = useOnboardingStore();
const { organizationDetails, organizationBrand, setOrganizationBrand } = useOnboardingStore();
const [brandColor, setBrandColor] = useState("#000000");
const [_logoFile, setLogoFile] = useState<File | null>(null);
const [_bannerFile, setBannerFile] = useState<File | null>(null);
const [logoPreview, setLogoPreview] = useState<string | null>(null);
const [bannerPreview, setBannerPreview] = useState<string | null>(null);
const [brandColor, setBrandColor] = useState("#000000");
// Load from store on mount
useEffect(() => {
setBrandColor(organizationBrand.color);
setLogoPreview(organizationBrand.logo);
setBannerPreview(organizationBrand.banner);
setBrandColor(organizationBrand.color);
}, [organizationBrand]);
const handleLogoChange = (file: File | null) => {
@@ -106,129 +66,116 @@ export const OrganizationBrandView = ({ userEmail }: OrganizationBrandViewProps)
}
};
const handleContinue = () => {
// Save brand color to store
setOrganizationBrand({ color: brandColor });
router.push("/onboarding/organization/teams");
const handleColorChange = (color: string) => {
setBrandColor(color);
setOrganizationBrand({ color });
};
const handleSkip = () => {
// Skip brand setup and go to teams
const handleContinue = () => {
// Save to store (already saved on change, but ensure it's persisted)
setOrganizationBrand({
logo: logoPreview,
banner: bannerPreview,
color: brandColor,
});
router.push("/onboarding/organization/teams");
};
return (
<OnboardingLayout userEmail={userEmail} currentStep={2}>
<OnboardingLayout userEmail={userEmail} currentStep={2} totalSteps={4}>
{/* Left column - Main content */}
<OnboardingCard
title={t("onboarding_org_brand_title")}
subtitle={t("onboarding_org_brand_subtitle")}
footer={
<>
<Button color="minimal" className="rounded-[10px]" onClick={handleSkip}>
{t("ill_do_this_later")}
<div className="flex w-full items-center justify-between gap-4">
<Button
color="minimal"
className="rounded-[10px]"
onClick={() => router.push("/onboarding/organization/details")}>
{t("back")}
</Button>
<Button color="primary" className="rounded-[10px]" onClick={handleContinue}>
{t("continue")}
</Button>
</>
</div>
}>
{/* Form */}
<div className="bg-default border-muted w-full rounded-[10px] border">
<div className="rounded-inherit flex w-full flex-col items-start overflow-clip">
<div className="flex w-full flex-col items-start">
<div className="flex w-full gap-6 px-5 py-4">
{/* Left side - Form */}
<div className="flex w-full flex-col gap-6">
{/* Brand Color */}
<div className="flex w-full flex-col gap-6">
<p className="text-emphasis text-sm font-medium leading-4">{t("brand_color")}</p>
<div className="flex w-full items-center gap-2">
<p className="text-subtle w-[98px] overflow-hidden text-ellipsis whitespace-nowrap text-sm font-medium leading-4">
{t("onboarding_primary_color_label")}
</p>
<BrandColorPicker
value={brandColor}
onChange={(value) => {
setBrandColor(value);
setOrganizationBrand({ color: value });
}}
t={t}
<div className="relative flex">
{/* Scrollable content container */}
<div className="relative h-full w-full gap-6">
<div className="flex w-full flex-col gap-4 rounded-xl">
{/* Banner Upload */}
<div className="flex w-full flex-col gap-2">
<p className="text-emphasis text-sm font-medium leading-4">{t("onboarding_banner_label")}</p>
<div className="flex w-full flex-col gap-2">
<div className="bg-muted border-muted relative h-[92px] w-full overflow-hidden rounded-md border">
{bannerPreview && (
<img
src={bannerPreview}
alt={t("onboarding_banner_preview_alt")}
className="h-full w-full object-cover"
/>
</div>
)}
</div>
{/* Logo Upload */}
<div className="flex w-full flex-col gap-2">
<p className="text-emphasis text-sm font-medium leading-4">{t("logo")}</p>
<div className="flex items-center gap-2">
<div className="bg-muted border-muted relative h-16 w-16 shrink-0 overflow-hidden rounded-md border">
{logoPreview && (
<img
src={logoPreview}
alt={t("onboarding_logo_preview_alt")}
className="h-full w-full object-cover"
/>
)}
</div>
<div className="flex flex-col gap-2">
<Button
color="secondary"
size="sm"
onClick={() => document.getElementById("logo-upload")?.click()}>
{t("upload")}
</Button>
<input
id="logo-upload"
type="file"
accept="image/*"
className="hidden"
onChange={(e) => handleLogoChange(e.target.files?.[0] || null)}
/>
</div>
</div>
<p className="text-subtle text-xs font-normal leading-3">
{t("onboarding_logo_size_hint")}
</p>
</div>
{/* Banner Upload */}
<div className="flex w-full flex-col gap-2">
<p className="text-emphasis text-sm font-medium leading-4">
{t("onboarding_banner_label")}
</p>
<div className="flex w-full flex-col gap-2">
<div className="bg-muted border-muted relative h-[92px] w-full overflow-hidden rounded-md border">
{bannerPreview && (
<img
src={bannerPreview}
alt={t("onboarding_banner_preview_alt")}
className="h-full w-full object-cover"
/>
)}
</div>
<div className="flex flex-col gap-2">
<Button
color="secondary"
size="sm"
className="w-fit"
onClick={() => document.getElementById("banner-upload")?.click()}>
{t("upload")}
</Button>
<input
id="banner-upload"
type="file"
accept="image/*"
className="hidden"
onChange={(e) => handleBannerChange(e.target.files?.[0] || null)}
/>
</div>
</div>
<p className="text-subtle text-xs font-normal leading-3">
{t("onboarding_banner_size_hint")}
</p>
<div className="flex flex-col gap-2">
<Button
color="secondary"
size="sm"
className="w-fit"
onClick={() => document.getElementById("banner-upload")?.click()}>
{t("upload")}
</Button>
<input
id="banner-upload"
type="file"
accept="image/*"
className="hidden"
onChange={(e) => handleBannerChange(e.target.files?.[0] || null)}
/>
</div>
</div>
<p className="text-subtle text-xs font-normal leading-3">
{t("onboarding_banner_size_hint")}
</p>
</div>
{/* Logo Upload */}
<div className="flex w-full flex-col gap-2">
<p className="text-emphasis text-sm font-medium leading-4">{t("logo")}</p>
<div className="flex items-center gap-2">
<div className="bg-muted border-muted relative h-16 w-16 shrink-0 overflow-hidden rounded-md border">
{logoPreview && (
<img
src={logoPreview}
alt={t("onboarding_logo_preview_alt")}
className="h-full w-full object-cover"
/>
)}
</div>
<div className="flex flex-col gap-2">
<Button
color="secondary"
size="sm"
onClick={() => document.getElementById("logo-upload")?.click()}>
{t("upload")}
</Button>
<input
id="logo-upload"
type="file"
accept="image/*"
className="hidden"
onChange={(e) => handleLogoChange(e.target.files?.[0] || null)}
/>
</div>
</div>
<p className="text-subtle text-xs font-normal leading-3">{t("onboarding_logo_size_hint")}</p>
</div>
{/* Brand Color */}
<div className="flex w-full flex-col gap-2">
<Label className="text-emphasis text-sm font-medium leading-4">
{t("light_brand_color")}
</Label>
<ColorPicker defaultValue={brandColor} onChange={handleColorChange} />
</div>
</div>
</div>
@@ -236,7 +183,13 @@ export const OrganizationBrandView = ({ userEmail }: OrganizationBrandViewProps)
</OnboardingCard>
{/* Right column - Browser view */}
<OnboardingBrowserView />
<OnboardingOrganizationBrowserView
avatar={logoPreview}
name={organizationDetails.name}
bio={organizationDetails.bio}
slug={organizationDetails.link}
bannerUrl={bannerPreview}
/>
</OnboardingLayout>
);
};
@@ -1,15 +1,15 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@calcom/ui/components/button";
import { Label, TextField, TextArea } from "@calcom/ui/components/form";
import { OnboardingBrowserView } from "../../components/onboarding-browser-view";
import { OnboardingCard } from "../../components/OnboardingCard";
import { OnboardingLayout } from "../../components/OnboardingLayout";
import { OnboardingOrganizationBrowserView } from "../../components/onboarding-organization-browser-view";
import { useOnboardingStore } from "../../store/onboarding-store";
import { ValidatedOrganizationSlug } from "./validated-organization-slug";
@@ -77,60 +77,60 @@ export const OrganizationDetailsView = ({ userEmail }: OrganizationDetailsViewPr
};
return (
<OnboardingLayout userEmail={userEmail} currentStep={2}>
<OnboardingLayout userEmail={userEmail} currentStep={1} totalSteps={4}>
{/* Left column - Main content */}
<OnboardingCard
title={t("onboarding_org_details_title")}
subtitle={t("onboarding_org_details_subtitle")}
footer={
<Button
color="primary"
className="rounded-[10px]"
onClick={handleContinue}
disabled={!isSlugValid || !organizationName || !organizationLink}>
{t("continue")}
</Button>
<div className="flex w-full items-center justify-end gap-4">
<Button
color="primary"
className="rounded-[10px]"
onClick={handleContinue}
disabled={!isSlugValid || !organizationName || !organizationLink}>
{t("continue")}
</Button>
</div>
}>
{/* Form */}
<div className="bg-default border-muted w-full rounded-[10px] border">
<div className="rounded-inherit flex w-full flex-col items-start overflow-clip">
<div className="flex w-full flex-col items-start">
<div className="flex w-full gap-6 px-5 py-5">
<div className="flex w-full flex-col gap-4 rounded-xl">
{/* Organization Name */}
<div className="flex w-full flex-col gap-1.5">
<Label className="text-emphasis text-sm font-medium leading-4">
{t("organization_name")}
</Label>
<TextField
value={organizationName}
onChange={(e) => setOrganizationName(e.target.value)}
placeholder={t("organization_name")}
className="border-default h-7 rounded-[10px] border px-2 py-1.5 text-sm"
/>
</div>
<div className="relative flex">
{/* Scrollable content container */}
<div className="relative h-full w-full gap-6 px-2 py-2">
{/* Top fade overlay */}
{/* Organization Link */}
<ValidatedOrganizationSlug
value={organizationLink}
onChange={handleSlugChange}
onValidationChange={setIsSlugValid}
/>
<div className="flex w-full flex-col gap-4 rounded-xl">
{/* Organization Name */}
<div className="flex w-full flex-col gap-1.5">
<Label className="text-emphasis text-sm font-medium leading-4">
{t("organization_name")}
</Label>
<TextField
value={organizationName}
onChange={(e) => setOrganizationName(e.target.value)}
placeholder={t("organization_name")}
/>
</div>
{/* Organization Bio */}
<div className="flex w-full flex-col gap-1.5">
<Label className="text-emphasis text-sm font-medium leading-4">
{t("onboarding_org_bio_label")}
</Label>
<TextArea
value={organizationBio}
onChange={(e) => setOrganizationBio(e.target.value)}
placeholder={t("onboarding_org_bio_placeholder")}
rows={4}
className="border-default rounded-lg border px-2 py-2 text-sm leading-tight"
/>
</div>
</div>
{/* Organization Link */}
<ValidatedOrganizationSlug
value={organizationLink}
onChange={handleSlugChange}
onValidationChange={setIsSlugValid}
/>
{/* Organization Bio */}
<div className="flex w-full flex-col gap-1.5">
<Label className="text-emphasis text-sm font-medium leading-4">
{t("onboarding_org_bio_label")}
</Label>
<TextArea
value={organizationBio}
onChange={(e) => setOrganizationBio(e.target.value)}
placeholder={t("onboarding_org_bio_placeholder")}
rows={4}
className="border-default rounded-lg border px-2 py-2 text-sm leading-tight"
/>
</div>
</div>
</div>
@@ -138,7 +138,13 @@ export const OrganizationDetailsView = ({ userEmail }: OrganizationDetailsViewPr
</OnboardingCard>
{/* Right column - Browser view */}
<OnboardingBrowserView />
<OnboardingOrganizationBrowserView
avatar={null}
name={organizationName}
bio={organizationBio}
slug={organizationLink}
bannerUrl={null}
/>
</OnboardingLayout>
);
};
@@ -0,0 +1,239 @@
"use client";
import { useRouter } from "next/navigation";
import React, { useRef, useState } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@calcom/ui/components/button";
import { Dialog, DialogContent } from "@calcom/ui/components/dialog";
import { Icon } from "@calcom/ui/components/icon";
import { Logo } from "@calcom/ui/components/logo";
import { showToast } from "@calcom/ui/components/toast";
import { useOnboardingStore, type Invite } from "../../store/onboarding-store";
type OrganizationCSVUploadModalProps = {
isOpen: boolean;
onClose: () => void;
};
export const OrganizationCSVUploadModal = ({ isOpen, onClose }: OrganizationCSVUploadModalProps) => {
const { t } = useLocale();
const router = useRouter();
const store = useOnboardingStore();
const { setInvites, teams } = store;
const fileInputRef = useRef<HTMLInputElement>(null);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [isUploading, setIsUploading] = useState(false);
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
if (!file.name.endsWith(".csv")) {
showToast(t("please_upload_csv_file"), "error");
return;
}
setSelectedFile(file);
}
};
const handleDownloadTemplate = () => {
const headers = ["email", "team", "role"];
const exampleRow = ["example@email.com", "team-name", "MEMBER"];
const csvContent = [headers.join(","), exampleRow.join(",")].join("\n");
const blob = new Blob([csvContent], { type: "text/csv" });
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "organization_invite_template.csv";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
showToast(t("template_downloaded"), "success");
};
const handleUpload = async () => {
if (!selectedFile) {
showToast(t("please_select_file"), "error");
return;
}
setIsUploading(true);
try {
const text = await selectedFile.text();
const lines = text.split("\n").filter((line) => line.trim());
if (lines.length < 2) {
showToast(t("csv_file_empty"), "error");
setIsUploading(false);
return;
}
const headers = lines[0].split(",").map((h) => h.trim().toLowerCase());
const emailIndex = headers.indexOf("email");
const teamIndex = headers.indexOf("team");
const roleIndex = headers.indexOf("role");
if (emailIndex === -1) {
showToast(t("csv_missing_email_column"), "error");
setIsUploading(false);
return;
}
const filteredTeams = teams.filter((team) => team.name && team.name.trim().length > 0);
const teamNames = filteredTeams.map((team) => team.name.toLowerCase());
const parsedInvites = lines
.slice(1)
.map((line) => {
const values = line.split(",").map((v) => v.trim());
const email = values[emailIndex]?.trim();
const team = teamIndex !== -1 ? values[teamIndex]?.trim() : "";
const role =
roleIndex !== -1 ? (values[roleIndex]?.trim().toUpperCase() as "MEMBER" | "ADMIN") : "MEMBER";
if (!email || email.length === 0) {
return null;
}
if (teamIndex !== -1 && team && !teamNames.includes(team.toLowerCase())) {
return null;
}
return {
email,
team: team || (filteredTeams.length > 0 ? filteredTeams[0].name.toLowerCase() : ""),
role: role === "ADMIN" ? "ADMIN" : "MEMBER",
};
})
.filter((invite): invite is Invite => invite !== null);
if (parsedInvites.length === 0) {
showToast(t("csv_file_empty"), "error");
setIsUploading(false);
return;
}
setInvites(parsedInvites);
showToast(t("csv_uploaded_successfully", { count: parsedInvites.length }), "success");
onClose();
router.push("/onboarding/organization/invite/email");
} catch (error) {
console.error("Error uploading CSV:", error);
showToast(t("error_uploading_csv"), "error");
} finally {
setIsUploading(false);
}
};
const handleClose = () => {
setSelectedFile(null);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
onClose();
};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent size="default" className="!p-0">
<div className="flex flex-col gap-4 p-6">
<div className="flex flex-col items-center gap-1">
<Logo className="h-10 w-auto" />
</div>
<div className="relative mx-auto flex items-center justify-center" style={{ width: 320, height: 180 }}>
<div
className="from-default to-muted border-subtle flex items-center justify-center rounded-2xl border bg-gradient-to-br p-8 shadow-sm"
style={{ width: 200, height: 150 }}>
{selectedFile ? (
<div className="flex flex-col items-center gap-3">
<div className="from-default to-muted border-subtle flex items-center justify-center rounded-full border bg-gradient-to-b p-4 shadow-sm">
<Icon name="file-text" className="text-emphasis" style={{ width: 32, height: 32 }} />
</div>
<div className="flex flex-col items-center gap-1">
<p className="text-emphasis text-sm font-medium">{selectedFile.name}</p>
<p className="text-subtle text-xs">{(selectedFile.size / 1024).toFixed(2)} KB</p>
</div>
</div>
) : (
<div className="flex flex-col items-center gap-3">
<div className="from-default to-muted border-subtle flex items-center justify-center rounded-full border bg-gradient-to-b p-4 shadow-sm">
<Icon name="upload" className="text-emphasis opacity-70" style={{ width: 32, height: 32 }} />
</div>
<p className="text-subtle text-center text-sm">{t("upload_csv_subtitle")}</p>
</div>
)}
</div>
</div>
<div className="mb-2 flex flex-col gap-2 text-center">
<h2 className="font-cal text-emphasis text-2xl leading-none">{t("upload_csv_file")}</h2>
<p className="text-default text-sm leading-normal">{t("upload_csv_description")}</p>
</div>
<div className="mb-2 flex flex-col gap-3">
<div className="bg-muted border-subtle flex items-center gap-3 rounded-lg border p-4">
<div className="from-default to-muted border-subtle flex items-center justify-center rounded-full border bg-gradient-to-b p-2 shadow-sm">
<Icon name="download" className="text-emphasis" style={{ width: 16, height: 16 }} />
</div>
<div className="flex-1">
<p className="text-emphasis text-sm font-medium">{t("need_template")}</p>
<p className="text-subtle text-xs">{t("download_csv_template_description")}</p>
</div>
<Button color="secondary" StartIcon="download" size="sm" onClick={handleDownloadTemplate}>
{t("download_template")}
</Button>
</div>
<div className="bg-muted border-subtle flex items-center gap-3 rounded-lg border p-4">
<div className="from-default to-muted border-subtle flex items-center justify-center rounded-full border bg-gradient-to-b p-2 shadow-sm">
<Icon name="upload" className="text-emphasis" style={{ width: 16, height: 16 }} />
</div>
<div className="flex-1">
<p className="text-emphasis text-sm font-medium">{t("upload_your_file")}</p>
<p className="text-subtle text-xs">
{selectedFile ? selectedFile.name : t("upload_csv_description")}
</p>
</div>
<input
ref={fileInputRef}
type="file"
accept=".csv"
onChange={handleFileSelect}
className="hidden"
/>
<Button
color="secondary"
StartIcon={selectedFile ? "file-text" : "upload"}
size="sm"
onClick={() => fileInputRef.current?.click()}>
{t("choose_file")}
</Button>
</div>
</div>
</div>
<div className="bg-muted border-subtle mt-6 flex items-center justify-between rounded-b-2xl border-t px-8 py-6">
<Button color="minimal" onClick={handleClose} disabled={isUploading}>
{t("cancel")}
</Button>
<Button
color="primary"
onClick={handleUpload}
disabled={!selectedFile || isUploading}
loading={isUploading}>
{t("upload")}
</Button>
</div>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,189 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation";
import React from "react";
import { useForm, useFieldArray } from "react-hook-form";
import { z } from "zod";
import { useFlags } from "@calcom/features/flags/hooks";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@calcom/ui/components/button";
import { Form } from "@calcom/ui/components/form";
import { EmailInviteForm } from "../../../components/EmailInviteForm";
import { InviteOptions } from "../../../components/InviteOptions";
import { OnboardingCard } from "../../../components/OnboardingCard";
import { OnboardingLayout } from "../../../components/OnboardingLayout";
import { RoleSelector } from "../../../components/RoleSelector";
import { OnboardingOrganizationBrowserView } from "../../../components/onboarding-organization-browser-view";
import { useSubmitOnboarding } from "../../../hooks/useSubmitOnboarding";
import { useOnboardingStore, type InviteRole } from "../../../store/onboarding-store";
import { OrganizationCSVUploadModal } from "../csv-upload-modal";
type OrganizationInviteEmailViewProps = {
userEmail: string;
};
type FormValues = {
invites: {
email: string;
team: string;
role: "MEMBER" | "ADMIN";
}[];
};
export const OrganizationInviteEmailView = ({ userEmail }: OrganizationInviteEmailViewProps) => {
const router = useRouter();
const { t } = useLocale();
const flags = useFlags();
const store = useOnboardingStore();
const usersEmailDomain = userEmail.split("@")[1];
const {
invites: storedInvites,
inviteRole,
setInvites,
setInviteRole,
organizationDetails,
organizationBrand,
} = store;
const { submitOnboarding, isSubmitting } = useSubmitOnboarding();
const [isCSVModalOpen, setIsCSVModalOpen] = React.useState(false);
const googleWorkspaceEnabled = flags["google-workspace-directory"];
const formSchema = z.object({
invites: z.array(
z.object({
email: z.string().email(t("invalid_email_address")),
team: z.string().min(1, t("onboarding_team_required")),
role: z.enum(["MEMBER", "ADMIN"]),
})
),
});
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
invites: storedInvites.length > 0 ? storedInvites : [{ email: "", team: "", role: inviteRole }],
},
});
const {
fields,
append: appendField,
remove,
} = useFieldArray({
control: form.control,
name: "invites",
});
// Wrapper to ensure team is always provided when appending
const append = (value: { email: string; team?: string; role: InviteRole }) => {
appendField({
email: value.email,
team: value.team || "",
role: value.role,
});
};
const handleContinue = async (data: FormValues) => {
setInvites(data.invites);
await submitOnboarding(store, userEmail, data.invites);
};
const handleBack = () => {
router.push("/onboarding/organization/invite");
};
const handleGoogleWorkspaceConnect = () => {
console.log("Connect Google Workspace");
};
const handleUploadCSV = () => {
setIsCSVModalOpen(true);
};
const handleCopyInviteLink = () => {
console.log("Copy invite link - disabled");
};
const handleInviteViaEmail = () => {
router.push("/onboarding/organization/invite/email");
};
const hasValidInvites = fields.some((_, index) => {
const email = form.watch(`invites.${index}.email`);
const team = form.watch(`invites.${index}.team`);
return email && email.trim().length > 0 && team && team.trim().length > 0;
});
const filteredTeams = store.teams.filter((team) => team.name && team.name.trim().length > 0);
const teams =
filteredTeams.length > 0
? filteredTeams.map((team) => ({ value: team.name.toLowerCase(), label: team.name }))
: [];
return (
<OnboardingLayout userEmail={userEmail} currentStep={4} totalSteps={4}>
<div className="flex h-full w-full flex-col gap-4">
<OnboardingCard
title={t("onboarding_org_invite_title")}
subtitle={t("onboarding_org_invite_subtitle_email")}
footer={
<div className="flex w-full items-center justify-end gap-4">
<Button color="minimal" className="rounded-[10px]" onClick={handleBack} disabled={isSubmitting}>
{t("back")}
</Button>
<Button
type="submit"
color="primary"
className="rounded-[10px]"
disabled={!hasValidInvites || isSubmitting}
loading={isSubmitting}
onClick={form.handleSubmit(handleContinue)}>
{t("continue")}
</Button>
</div>
}>
<div className="flex h-full w-full flex-col gap-4">
<Form form={form} handleSubmit={handleContinue} className="h-full w-full">
<div className="flex h-full w-full flex-col gap-4">
<EmailInviteForm
fields={fields}
append={append}
remove={remove}
defaultRole={inviteRole}
showTeamSelect
teams={teams}
emailPlaceholder={`dave@${usersEmailDomain}`}
/>
<RoleSelector
value={inviteRole}
showInfoBadge={true}
onValueChange={(value) => {
setInviteRole(value);
fields.forEach((_, index) => {
form.setValue(`invites.${index}.role`, value);
});
}}
/>
</div>
</Form>
</div>
</OnboardingCard>
</div>
<OnboardingOrganizationBrowserView
avatar={organizationBrand.logo}
name={organizationDetails.name}
bio={organizationDetails.bio}
slug={organizationDetails.link}
bannerUrl={organizationBrand.banner}
/>
<OrganizationCSVUploadModal isOpen={isCSVModalOpen} onClose={() => setIsCSVModalOpen(false)} />
</OnboardingLayout>
);
};
@@ -1,307 +1,105 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation";
import React from "react";
import { useForm, useFieldArray } from "react-hook-form";
import { z } from "zod";
import { useFlags } from "@calcom/features/flags/hooks";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@calcom/ui/components/button";
import { Form, Label, TextField, Select, ToggleGroup } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import { Logo } from "@calcom/ui/components/logo";
import { OnboardingBrowserView } from "../../components/onboarding-browser-view";
import { InviteOptions } from "../../components/InviteOptions";
import { OnboardingCard } from "../../components/OnboardingCard";
import { OnboardingLayout } from "../../components/OnboardingLayout";
import { OnboardingOrganizationBrowserView } from "../../components/onboarding-organization-browser-view";
import { useSubmitOnboarding } from "../../hooks/useSubmitOnboarding";
import { useOnboardingStore } from "../../store/onboarding-store";
import { OrganizationCSVUploadModal } from "./csv-upload-modal";
type OrganizationInviteViewProps = {
userEmail: string;
};
type FormValues = {
invites: {
email: string;
team: string;
role: "MEMBER" | "ADMIN";
}[];
};
export const OrganizationInviteView = ({ userEmail }: OrganizationInviteViewProps) => {
const router = useRouter();
const { t } = useLocale();
// We are using email mode for now until we implement the other invite methods, csv, workspace - invite link
const isEmailMode = true;
const flags = useFlags();
const store = useOnboardingStore();
const usersEmailDomain = userEmail.split("@")[1];
const { invites: storedInvites, inviteRole, setInvites, setInviteRole } = store;
const { setInvites, organizationDetails, organizationBrand } = store;
const { submitOnboarding, isSubmitting } = useSubmitOnboarding();
const [isCSVModalOpen, setIsCSVModalOpen] = React.useState(false);
const formSchema = z.object({
invites: z.array(
z.object({
email: z.string().email(t("invalid_email_address")),
team: z.string().min(1, t("onboarding_team_required")),
role: z.enum(["MEMBER", "ADMIN"]),
})
),
});
const googleWorkspaceEnabled = flags["google-workspace-directory"];
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
invites: storedInvites.length > 0 ? storedInvites : [{ email: "", team: "", role: inviteRole }],
},
});
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "invites",
});
const handleSubmitOnboarding = async (invitesData: FormValues["invites"]) => {
// Save invites to store before submitting
setInvites(invitesData);
await submitOnboarding(store, userEmail, invitesData);
};
const handleContinue = async (data?: FormValues) => {
if (data) {
await handleSubmitOnboarding(data.invites);
} else {
await handleSubmitOnboarding([]);
}
};
const handleSkip = async () => {
// Complete onboarding without invites
await handleSubmitOnboarding([]);
const handleGoogleWorkspaceConnect = () => {
console.log("Connect Google Workspace");
};
const handleInviteViaEmail = () => {
router.push("/onboarding/organization/invite?mode=email");
router.push("/onboarding/organization/invite/email");
};
const hasValidInvites = fields.some((_, index) => {
const email = form.watch(`invites.${index}.email`);
const team = form.watch(`invites.${index}.team`);
return email && email.trim().length > 0 && team && team.trim().length > 0;
});
const handleUploadCSV = () => {
setIsCSVModalOpen(true);
};
// Get teams from store, filter out empty teams
const filteredTeams = store.teams.filter((team) => team.name && team.name.trim().length > 0);
const teams =
filteredTeams.length > 0
? filteredTeams.map((team) => ({ value: team.name.toLowerCase(), label: team.name }))
: [];
const handleCopyInviteLink = () => {
console.log("Copy invite link - disabled");
};
const handleSkip = async () => {
setInvites([]);
await submitOnboarding(store, userEmail, []);
};
const handleInvite = async () => {
await submitOnboarding(store, userEmail, []);
};
return (
<div className="bg-default flex min-h-screen w-full flex-col items-start overflow-clip rounded-xl">
{/* Header */}
<div className="flex w-full items-center justify-between px-6 py-4">
<Logo className="h-5 w-auto" />
{/* Progress dots - centered */}
<div className="absolute left-1/2 flex -translate-x-1/2 items-center justify-center gap-1">
<div className="bg-emphasis h-1 w-1 rounded-full" />
<div className="bg-emphasis h-1 w-1 rounded-full" />
<div className="bg-emphasis h-1 w-1 rounded-full" />
<div className="bg-emphasis h-1.5 w-1.5 rounded-full" />
</div>
<div className="bg-muted flex items-center gap-2 rounded-full px-3 py-2">
<p className="text-emphasis text-sm font-medium leading-none">{userEmail}</p>
</div>
</div>
{/* Main content */}
<div className="flex h-full w-full items-start justify-center px-6 py-8">
<div className="grid w-full max-w-[1200px] grid-cols-1 gap-6 lg:grid-cols-[1fr_auto]">
{/* Left column - Main content */}
<div className="flex w-full flex-col gap-4">
{/* Card */}
<div className="bg-muted border-muted relative rounded-xl border p-1">
<div className="rounded-inherit flex w-full flex-col items-start overflow-clip">
{/* Card Header */}
<div className="flex w-full gap-1.5 px-5 py-4">
<div className="flex w-full flex-col gap-1">
<h1 className="font-cal text-xl font-semibold leading-6">
{t("onboarding_org_invite_title")}
</h1>
<p className="text-subtle text-sm font-medium leading-tight">
{isEmailMode
? t("onboarding_org_invite_subtitle_email")
: t("onboarding_org_invite_subtitle_full")}
</p>
</div>
</div>
{/* Content */}
<div className="bg-default border-subtle w-full rounded-md border">
<div className="flex w-full flex-col gap-8 px-5 py-5">
{!isEmailMode ? (
// Coming soon
// Initial invite options view
<div className="flex w-full flex-col gap-4">
<Button color="secondary" className="w-full justify-center" StartIcon="mail">
{t("onboarding_connect_google_workspace")}
</Button>
<div className="flex items-center gap-2">
<div className="bg-subtle h-px flex-1" />
<span className="text-subtle text-xs">{t("onboarding_or_divider")}</span>
<div className="bg-subtle h-px flex-1" />
</div>
<div className="flex items-center gap-2">
<Button
color="secondary"
className="flex-1 justify-center"
onClick={handleInviteViaEmail}>
{t("invite_via_email")}
</Button>
<Button color="secondary" className="flex-1 justify-center" StartIcon="upload">
{t("onboarding_upload_csv")}
</Button>
<Button color="secondary" className="flex-1 justify-center" StartIcon="link">
{t("onboarding_copy_invite_link")}
</Button>
</div>
{/* Role selector */}
<div className="flex items-center justify-between">
<div className="hidden items-center gap-2 md:flex">
<span className="text-emphasis text-sm">{t("onboarding_invite_all_as")}</span>
<ToggleGroup
value={inviteRole}
onValueChange={(value) => value && setInviteRole(value as "MEMBER" | "ADMIN")}
options={[
{ value: "ADMIN", label: t("onboarding_admins") },
{ value: "MEMBER", label: t("members") },
]}
/>
</div>
<span className="text-subtle text-sm">{t("onboarding_modify_roles_later")}</span>
</div>
</div>
) : (
// Email invite form
<Form form={form} handleSubmit={handleContinue} className="w-full">
<div className="flex w-full flex-col gap-4">
{/* Email and Team inputs */}
<div className="flex flex-col gap-2">
<div className="grid grid-cols-2">
<Label
className="text-emphasis mb-0 text-sm font-medium"
htmlFor="invites.0.email">
{t("email")}
</Label>
<Label
className="text-emphasis mb-0 text-sm font-medium"
htmlFor="invites.0.team">
{t("team")}
</Label>
</div>
{fields.map((field, index) => (
<div key={field.id} className="flex items-start gap-0.5">
<div className="grid flex-1 items-start gap-2 md:grid-cols-2 ">
<TextField
labelSrOnly
{...form.register(`invites.${index}.email`)}
placeholder={`dave@${usersEmailDomain}`}
type="email"
size="sm"
/>
<Select
size="sm"
options={teams}
value={teams.find((t) => t.value === form.watch(`invites.${index}.team`))}
onChange={(option) => {
if (option) {
form.setValue(`invites.${index}.team`, option.value);
}
}}
placeholder={t("select_team")}
/>
</div>
<Button
type="button"
color="minimal"
variant="icon"
size="sm"
className="h-7 w-7"
disabled={fields.length === 1}
onClick={() => remove(index)}>
<Icon name="x" className="h-4 w-4" />
</Button>
</div>
))}
{/* Add button */}
<Button
type="button"
color="secondary"
size="sm"
StartIcon="plus"
className="mt-2 w-fit"
onClick={() => append({ email: "", team: "", role: "MEMBER" })}>
{t("add")}
</Button>
</div>
{/* Role selector */}
<div className="flex items-center justify-between">
<div className="hidden items-center gap-2 md:flex">
<span className="text-emphasis text-sm">{t("onboarding_invite_all_as")}</span>
<ToggleGroup
value={inviteRole}
onValueChange={(value) => value && setInviteRole(value as "MEMBER" | "ADMIN")}
options={[
{ value: "MEMBER", label: t("members") },
{ value: "ADMIN", label: t("onboarding_admins") },
]}
/>
</div>
<span className="text-subtle text-sm">{t("onboarding_modify_roles_later")}</span>
</div>
</div>
</Form>
)}
</div>
</div>
{/* Footer */}
<div className="flex w-full items-center justify-end gap-1 px-5 py-4">
<Button
type={isEmailMode ? "submit" : "button"}
color="primary"
className="rounded-[10px]"
disabled={(isEmailMode && !hasValidInvites) || isSubmitting}
loading={isSubmitting}
onClick={form.handleSubmit(handleContinue)}>
{t("continue")}
</Button>
</div>
<OnboardingLayout userEmail={userEmail} currentStep={4} totalSteps={4}>
<div className="flex h-full w-full flex-col gap-4">
<OnboardingCard
title={t("onboarding_org_invite_title")}
subtitle={t("onboarding_org_invite_subtitle_full")}
footer={
<div className="flex w-full items-center justify-between gap-4">
<Button
color="minimal"
className="rounded-[10px]"
onClick={() => router.push("/onboarding/organization/teams")}
disabled={isSubmitting}>
{t("back")}
</Button>
<div className="flex items-center gap-2">
<Button
color="minimal"
className="rounded-[10px]"
onClick={handleSkip}
disabled={isSubmitting}>
{t("ill_do_this_later")}
</Button>
</div>
</div>
{/* Skip button */}
<div className="flex w-full justify-center">
<button
onClick={handleSkip}
className="text-subtle hover:bg-subtle rounded-[10px] px-2 py-1.5 text-sm font-medium leading-4">
{t("ill_do_this_later")}
</button>
</div>
</div>
{/* Right column - Browser view */}
<OnboardingBrowserView />
</div>
}>
<InviteOptions
onInviteViaEmail={handleInviteViaEmail}
onUploadCSV={handleUploadCSV}
onCopyInviteLink={handleCopyInviteLink}
onConnectGoogleWorkspace={googleWorkspaceEnabled ? handleGoogleWorkspaceConnect : undefined}
isSubmitting={isSubmitting}
/>
</OnboardingCard>
</div>
</div>
<OnboardingOrganizationBrowserView
avatar={organizationBrand.logo}
name={organizationDetails.name}
bio={organizationDetails.bio}
slug={organizationDetails.link}
bannerUrl={organizationBrand.banner}
/>
<OrganizationCSVUploadModal isOpen={isCSVModalOpen} onClose={() => setIsCSVModalOpen(false)} />
</OnboardingLayout>
);
};
@@ -10,8 +10,9 @@ import { Button } from "@calcom/ui/components/button";
import { Form, TextField } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import { OnboardingCard } from "../../personal/_components/OnboardingCard";
import { OnboardingLayout } from "../../personal/_components/OnboardingLayout";
import { OnboardingCard } from "../../components/OnboardingCard";
import { OnboardingLayout } from "../../components/OnboardingLayout";
import { OnboardingTeamsBrowserView } from "../../components/onboarding-teams-browser-view";
import { useOnboardingStore } from "../../store/onboarding-store";
type OrganizationTeamsViewProps = {
@@ -25,7 +26,7 @@ type FormValues = {
export const OrganizationTeamsView = ({ userEmail }: OrganizationTeamsViewProps) => {
const router = useRouter();
const { t } = useLocale();
const { teams: storedTeams, setTeams } = useOnboardingStore();
const { teams: storedTeams, setTeams, organizationBrand, organizationDetails } = useOnboardingStore();
const formSchema = z.object({
teams: z.array(
@@ -64,28 +65,38 @@ export const OrganizationTeamsView = ({ userEmail }: OrganizationTeamsViewProps)
});
return (
<OnboardingLayout userEmail={userEmail} currentStep={3}>
<OnboardingLayout userEmail={userEmail} currentStep={3} totalSteps={4}>
{/* Left column - Main content */}
<OnboardingCard
title={t("onboarding_org_teams_title")}
subtitle={t("onboarding_org_teams_subtitle")}
footer={
<>
<Button type="button" color="minimal" className="rounded-[10px]" onClick={handleSkip}>
{t("onboarding_skip_for_now")}
</Button>
<div className="flex w-full items-center justify-between gap-4">
<Button
type="submit"
form="teams-form"
color="primary"
type="button"
color="minimal"
className="rounded-[10px]"
disabled={!hasValidTeams}>
{t("continue")}
onClick={() => router.push("/onboarding/organization/brand")}>
{t("back")}
</Button>
</>
<div className="flex items-center gap-2">
<Button type="button" color="minimal" className="rounded-[10px]" onClick={handleSkip}>
{t("onboarding_skip_for_now")}
</Button>
<Button
type="submit"
form="teams-form"
color="primary"
className="rounded-[10px]"
disabled={!hasValidTeams}>
{t("continue")}
</Button>
</div>
</div>
}>
<Form id="teams-form" form={form} handleSubmit={handleContinue} className="w-full">
<div className="bg-default border-subtle w-full rounded-md border">
<div className="flex w-full flex-col gap-8 px-5 py-5">
<div className="w-full ">
<div className="flex w-full flex-col gap-8 ">
<div className="flex w-full flex-col gap-2">
<div className="flex flex-col gap-1">
<p className="text-emphasis text-sm font-medium leading-4">{t("team")}</p>
@@ -132,6 +143,14 @@ export const OrganizationTeamsView = ({ userEmail }: OrganizationTeamsViewProps)
</div>
</Form>
</OnboardingCard>
{/* Right column - Browser view */}
<OnboardingTeamsBrowserView
teams={form.watch("teams")}
organizationLogo={organizationBrand.logo}
organizationName={organizationDetails.name}
organizationBanner={organizationBrand.banner}
/>
</OnboardingLayout>
);
};
@@ -4,11 +4,11 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Button } from "@calcom/ui/components/button";
import { OnboardingCard } from "../../components/OnboardingCard";
import { OnboardingLayout } from "../../components/OnboardingLayout";
import { OnboardingCalendarBrowserView } from "../../components/onboarding-calendar-browser-view";
import { useSubmitPersonalOnboarding } from "../../hooks/useSubmitPersonalOnboarding";
import { InstallableAppCard } from "../_components/InstallableAppCard";
import { OnboardingCard } from "../../components/OnboardingCard";
import { OnboardingLayout } from "../../components/OnboardingLayout";
import { useAppInstallation } from "../_components/useAppInstallation";
type PersonalCalendarViewProps = {
@@ -36,7 +36,7 @@ export const PersonalCalendarView = ({ userEmail }: PersonalCalendarViewProps) =
};
return (
<OnboardingLayout userEmail={userEmail} currentStep={2}>
<OnboardingLayout userEmail={userEmail} currentStep={2} totalSteps={2}>
{/* Left column - Main content */}
<OnboardingCard
title={t("connect_your_calendar")}
@@ -111,7 +111,7 @@ export const PersonalSettingsView = ({ userEmail, userName }: PersonalSettingsVi
return (
<>
<OnboardingContinuationPrompt />
<OnboardingLayout userEmail={userEmail} currentStep={1}>
<OnboardingLayout userEmail={userEmail} currentStep={1} totalSteps={2}>
{/* Left column - Main content */}
<OnboardingCard
title={t("add_your_details")}
@@ -139,7 +139,7 @@ export const PersonalSettingsView = ({ userEmail, userName }: PersonalSettingsVi
<form
id="personal-settings-form"
onSubmit={handleContinue}
className="flex w-full flex-col gap-6 px-5">
className="flex w-full flex-col gap-6 px-1">
{/* Profile Picture */}
<div className="flex w-full flex-col gap-2">
<Label className="text-emphasis text-sm font-medium leading-4">{t("profile_picture")}</Label>
@@ -84,7 +84,7 @@ export const TeamDetailsView = ({ userEmail }: TeamDetailsViewProps) => {
return (
<>
<OnboardingContinuationPrompt />
<OnboardingLayout userEmail={userEmail} currentStep={2}>
<OnboardingLayout userEmail={userEmail} currentStep={1} totalSteps={3}>
{/* Left column - Main content */}
<OnboardingCard
title={t("create_your_team")}
@@ -106,7 +106,7 @@ export const TeamDetailsView = ({ userEmail }: TeamDetailsViewProps) => {
</Button>
</div>
}>
<div className="flex w-full flex-col gap-4 px-5">
<div className="flex w-full flex-col gap-4">
{/* Team Profile Picture */}
<div className="flex w-full flex-col gap-2">
<Label className="text-emphasis text-sm font-medium leading-4">{t("team_logo")}</Label>
@@ -7,13 +7,13 @@ import { useForm, useFieldArray } from "react-hook-form";
import { z } from "zod";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { InfoBadge } from "@calcom/ui/components/badge";
import { Button } from "@calcom/ui/components/button";
import { Form, Label, TextField, ToggleGroup } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import { Form } from "@calcom/ui/components/form";
import { EmailInviteForm } from "../../../components/EmailInviteForm";
import { OnboardingCard } from "../../../components/OnboardingCard";
import { OnboardingLayout } from "../../../components/OnboardingLayout";
import { RoleSelector } from "../../../components/RoleSelector";
import { OnboardingInviteBrowserView } from "../../../components/onboarding-invite-browser-view";
import { useCreateTeam } from "../../../hooks/useCreateTeam";
import { useOnboardingStore, type InviteRole } from "../../../store/onboarding-store";
@@ -85,7 +85,7 @@ export const TeamInviteEmailView = ({ userEmail }: TeamInviteEmailViewProps) =>
});
return (
<OnboardingLayout userEmail={userEmail} currentStep={3}>
<OnboardingLayout userEmail={userEmail} currentStep={3} totalSteps={3}>
{/* Left column - Main content */}
<div className="flex w-full flex-col gap-4">
<OnboardingCard
@@ -107,74 +107,27 @@ export const TeamInviteEmailView = ({ userEmail }: TeamInviteEmailViewProps) =>
</Button>
</div>
}>
<div className="flex w-full flex-col gap-4 px-5">
<div className="flex w-full flex-col gap-4 px-1">
<Form form={form} handleSubmit={handleContinue} className="w-full">
<div className="flex w-full flex-col gap-4">
{/* Email inputs */}
<div className="flex flex-col gap-2">
<Label className="text-emphasis text-sm font-medium">{t("email")}</Label>
<EmailInviteForm
fields={fields}
append={append}
remove={remove}
defaultRole={inviteRole}
emailPlaceholder="rick@cal.com"
/>
<div className="scroll-bar flex max-h-72 flex-col gap-2 overflow-y-auto">
{fields.map((field, index) => (
<div key={field.id} className="flex items-start gap-0.5">
<div className="flex-1">
<TextField
labelSrOnly
{...form.register(`invites.${index}.email`)}
placeholder={`rick@cal.com`}
type="email"
size="sm"
/>
</div>
<Button
type="button"
color="minimal"
variant="icon"
size="sm"
className="h-7 w-7"
disabled={fields.length === 1}
onClick={() => remove(index)}>
<Icon name="x" className="h-4 w-4" />
</Button>
</div>
))}
</div>
{/* Add button */}
<Button
type="button"
color="secondary"
size="sm"
StartIcon="plus"
className="w-fit"
onClick={() => append({ email: "", role: inviteRole })}>
{t("add")}
</Button>
</div>
{/* Role selector */}
<div className="flex items-center justify-between">
<div className="hidden items-center gap-2 md:flex">
<span className="text-emphasis text-sm">{t("onboarding_invite_all_as")}</span>
<ToggleGroup
value={inviteRole}
onValueChange={(value) => {
if (value) {
setInviteRole(value as InviteRole);
// Update all invites with the new role
fields.forEach((_, index) => {
form.setValue(`invites.${index}.role`, value as InviteRole);
});
}
}}
options={[
{ value: "MEMBER", label: t("members") },
{ value: "ADMIN", label: t("onboarding_admins") },
]}
/>
<InfoBadge content={t("onboarding_modify_roles_later")} />
</div>
</div>
<RoleSelector
value={inviteRole}
onValueChange={(value) => {
setInviteRole(value);
fields.forEach((_, index) => {
form.setValue(`invites.${index}.role`, value);
});
}}
showInfoBadge
/>
</div>
</Form>
</div>
@@ -6,8 +6,8 @@ import React from "react";
import { useFlags } from "@calcom/features/flags/hooks";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@calcom/ui/components/button";
import { Icon } from "@calcom/ui/components/icon";
import { InviteOptions } from "../../components/InviteOptions";
import { OnboardingCard } from "../../components/OnboardingCard";
import { OnboardingLayout } from "../../components/OnboardingLayout";
import { OnboardingInviteBrowserView } from "../../components/onboarding-invite-browser-view";
@@ -63,7 +63,7 @@ export const TeamInviteView = ({ userEmail }: TeamInviteViewProps) => {
return (
<>
<OnboardingLayout userEmail={userEmail} currentStep={3}>
<OnboardingLayout userEmail={userEmail} currentStep={2} totalSteps={3}>
{/* Left column - Main content */}
<div className="flex w-full flex-col gap-4">
<OnboardingCard
@@ -97,63 +97,13 @@ export const TeamInviteView = ({ userEmail }: TeamInviteViewProps) => {
</div>
</div>
}>
<div className="flex w-full flex-col gap-6 px-5">
{/* Google Workspace Connect - Only show if feature flag is enabled */}
{googleWorkspaceEnabled && (
<>
<Button
color="secondary"
className="h-8 w-full rounded-[10px]"
onClick={handleGoogleWorkspaceConnect}
disabled={isSubmitting}>
{t("connect_google_workspace")}
</Button>
{/* Divider with "or" */}
<div className="flex w-full items-center gap-2">
<div className="border-subtle h-px flex-1 border-t" />
<span className="text-subtle text-sm font-medium">{t("or")}</span>
<div className="border-subtle h-px flex-1 border-t" />
</div>
</>
)}
{/* Invite options */}
<div className="flex w-full flex-col gap-4">
<Button
color="secondary"
className="h-8 w-full justify-center rounded-[10px]"
onClick={handleInviteViaEmail}
disabled={isSubmitting}>
<div className="flex items-center gap-1">
<Icon name="mail" className="h-4 w-4" />
<span>{t("invite_via_email")}</span>
</div>
</Button>
<Button
color="secondary"
className="h-8 w-full justify-center rounded-[10px]"
onClick={handleUploadCSV}
disabled={isSubmitting}>
<div className="flex items-center gap-1">
<Icon name="upload" className="h-4 w-4" />
<span>{t("upload_csv_file")}</span>
</div>
</Button>
<Button
color="secondary"
className="h-8 w-full justify-center rounded-[10px]"
onClick={handleCopyInviteLink}
disabled>
<div className="flex items-center gap-1">
<Icon name="link" className="h-4 w-4" />
<span>{t("copy_invite_link")}</span>
</div>
</Button>
</div>
</div>
<InviteOptions
onInviteViaEmail={handleInviteViaEmail}
onUploadCSV={handleUploadCSV}
onCopyInviteLink={handleCopyInviteLink}
onConnectGoogleWorkspace={googleWorkspaceEnabled ? handleGoogleWorkspaceConnect : undefined}
isSubmitting={isSubmitting}
/>
</OnboardingCard>
</div>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "5.8.12",
"version": "5.8.13",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
+5 -2
View File
@@ -10,10 +10,11 @@ import getIP from "@calcom/lib/getIP";
import { piiHasher } from "@calcom/lib/server/PiiHasher";
import { checkCfTurnstileToken } from "@calcom/lib/server/checkCfTurnstileToken";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import prisma from "@calcom/prisma";
import type { TraceContext } from "@calcom/lib/tracing";
import { prisma } from "@calcom/prisma";
import { CreationSource } from "@calcom/prisma/enums";
async function handler(req: NextApiRequest & { userId?: number }) {
async function handler(req: NextApiRequest & { userId?: number; traceContext: TraceContext }) {
const userIp = getIP(req);
if (process.env.NEXT_PUBLIC_CLOUDFLARE_USE_TURNSTILE_IN_BOOKER === "1") {
@@ -52,8 +53,10 @@ async function handler(req: NextApiRequest & { userId?: number }) {
userId: session?.user?.id || -1,
hostname: req.headers.host || "",
forcedSlug: req.headers["x-cal-force-slug"] as string | undefined,
traceContext: req.traceContext,
},
});
// const booking = await createBookingThroughFactory();
return booking;
@@ -0,0 +1,188 @@
import type { Page } from "@playwright/test";
import { expect } from "@playwright/test";
import type { createUsersFixture } from "playwright/fixtures/users";
import { test } from "./lib/fixtures";
import { gotoBookingPage, saveEventType, selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils";
const normalizePhone = (s: string) => s.replace(/[^+\d]/g, "");
test.describe.configure({ mode: "serial" });
test.describe("Phone Location Auto-fill Feature", () => {
test("should auto-fill untouched phone fields when phone location is selected", async ({ page, users }) => {
await createUserWithPhoneFields({ users, page });
await gotoBookingPage(page);
await selectFirstAvailableTimeSlotNextMonth(page);
// Verify custom phone fields start empty or country prefix (e.g., "+1")
const v1 = await page.locator('[name="phone-1"]').inputValue();
const v2 = await page.locator('[name="phone-2"]').inputValue();
expect(v1 === "" || /^\+\d{1,3}$/.test(v1)).toBeTruthy();
expect(v2 === "" || /^\+\d{1,3}$/.test(v2)).toBeTruthy();
// Select phone location and enter phone number
const phoneNumber = "+14155551234";
await selectPhoneLocation(page);
await fillPhoneLocationInput(page, phoneNumber);
// Verify both custom phone fields are auto-filled (normalized)
await expect
.poll(async () => normalizePhone(await page.locator('[name="phone-1"]').inputValue()))
.toBe(normalizePhone(phoneNumber));
await expect
.poll(async () => normalizePhone(await page.locator('[name="phone-2"]').inputValue()))
.toBe(normalizePhone(phoneNumber));
// Skip booking confirmation to keep this test fast and focused on autofill behavior
});
test("should NOT sync changes from custom phone fields back to location or other fields", async ({ page, users }) => {
await createUserWithPhoneFields({ users, page });
await gotoBookingPage(page);
await selectFirstAvailableTimeSlotNextMonth(page);
// Select phone location and enter phone number
const locationPhoneNumber = "+14155551234";
await selectPhoneLocation(page);
await fillPhoneLocationInput(page, locationPhoneNumber);
// Verify both custom phone fields are auto-filled
await expect
.poll(async () => normalizePhone(await page.locator('[name="phone-1"]').inputValue()))
.toBe(normalizePhone(locationPhoneNumber));
await expect
.poll(async () => normalizePhone(await page.locator('[name="phone-2"]').inputValue()))
.toBe(normalizePhone(locationPhoneNumber));
// Now manually change phone-2 to a different number
const differentPhoneNumber = "+14155559999";
const phone2Input = page.locator('[name="phone-2"]');
await phone2Input.clear();
await phone2Input.fill(differentPhoneNumber);
await phone2Input.blur(); // Trigger blur event
// Verify phone-1 is still the original location phone (NOT changed to phone-2's value)
const phone1Value = await page.locator('[name="phone-1"]').inputValue();
expect(normalizePhone(phone1Value)).toBe(normalizePhone(locationPhoneNumber));
// Verify location field is still the original value (NOT changed to phone-2's value)
const locationValue = await page.locator(`[data-fob-field-name="location"] input`).inputValue();
expect(normalizePhone(locationValue)).toBe(normalizePhone(locationPhoneNumber));
// Verify phone-2 has the new value
const phone2Value = await page.locator('[name="phone-2"]').inputValue();
expect(normalizePhone(phone2Value)).toBe(normalizePhone(differentPhoneNumber));
});
});
// Helper Functions
async function createUserWithPhoneFields({
users,
page,
}: {
users: ReturnType<typeof createUsersFixture>;
page: Page;
}) {
try {
const user = await users.create();
await user.apiLogin();
await page.goto("/event-types");
// Go to first event type
const $eventTypes = page.locator("[data-testid=event-types] > li a");
await $eventTypes.first().click();
// Enable Attendee Phone Number location
await selectAttendeePhoneNumber(page);
// Add two custom phone fields
await page.getByTestId("vertical-tab-event_advanced_tab_title").click();
await addPhoneQuestion(page, "phone-1", "Phone Number 1", true);
await addPhoneQuestion(page, "phone-2", "Phone Number 2", true);
// Save once at the end
await saveEventType(page);
return user;
} catch (error) {
console.error("Failed to create user with phone fields:", error);
throw error;
}
}
async function addPhoneQuestion(page: Page, name: string, label: string, required: boolean) {
await page.click('[data-testid="add-field"]');
// Wait for modal to open by ensuring field-type control is present
await page.waitForSelector("[id=test-field-type]");
// Select Phone type
await page.locator("[id=test-field-type]").click();
await page.waitForSelector('[data-testid="select-option-phone"]');
await page.locator('[data-testid="select-option-phone"]').click();
// Fill name
await page.fill('[name="name"]', name);
// Fill label
await page.fill('[name="label"]', label);
// Set required if needed
if (required) {
// Try to find and check the required checkbox, but don't fail if it doesn't exist
try {
await page.waitForSelector('input[name="required"]', { timeout: 500 });
const requiredCheckbox = page.locator('input[name="required"]').first();
await requiredCheckbox.check();
} catch {
// Checkbox not found or not needed
}
}
// Click save button for the field
await page.click('[data-testid="field-add-save"]');
// Wait for the modal to close
await page.locator('[data-testid="field-add-save"]').waitFor({ state: "detached" });
}
async function selectAttendeePhoneNumber(page: Page) {
await page.getByTestId("location-select").click();
await page.getByTestId("location-select-item-phone").click();
}
async function selectPhoneLocation(page: Page) {
// When "Attendee Phone Number" is the location, the booking form
// shows a phone input directly - no radio button selection needed.
// Just wait for location field to be ready
await page.waitForSelector('[data-fob-field-name="location"]');
}
async function fillPhoneLocationInput(page: Page, phoneNumber: string) {
// The location field has a phone input when "Attendee Phone Number" is selected
await page.waitForSelector(`[data-fob-field-name="location"] input`);
const locationInput = page.locator(`[data-fob-field-name="location"] input`);
// Ensure the field is empty first
await locationInput.clear();
// Wait for mask/prefix to settle (empty string or just country prefix)
await expect.poll(async () => locationInput.inputValue()).toMatch(/^(?:|\+\d{1,3})$/);
// If the mask auto-inserts a country prefix, avoid duplicating it
const prefill = await locationInput.inputValue();
let toType = phoneNumber;
if (/^\+\d{1,3}$/.test(prefill) && phoneNumber.startsWith(prefill)) {
toType = phoneNumber.slice(prefill.length);
}
// Type the phone number with a small delay to play nicely with masking
await locationInput.pressSequentially(toType, { delay: 20 });
// Trigger blur to ensure the auto-fill effect runs
await page.locator('[name="name"]').click();
}
// removed local gotoBookingPage/selectFirstAvailableTimeSlot/saveEventType in favor of shared helpers
+34 -34
View File
@@ -10,6 +10,8 @@ import type { Fixtures } from "./lib/fixtures";
import { test } from "./lib/fixtures";
import { setupManagedEvent } from "./lib/testUtils";
test.describe.configure({ mode: "parallel" });
test.afterEach(({ users }) => users.deleteAll());
test.describe("Bookings", () => {
@@ -58,16 +60,14 @@ test.describe("Bookings", () => {
const firstUpcomingBooking = upcomingBookings.locator('[data-testid="booking-item"]').nth(0);
const secondUpcomingBooking = upcomingBookings.locator('[data-testid="booking-item"]').nth(1);
await expect(
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
firstUpcomingBooking.locator(`text=${bookingWhereFirstUserIsAttendee!.title}`)
).toBeVisible();
await expect(
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
secondUpcomingBooking.locator(`text=${bookingWhereFirstUserIsOrganizer!.title}`)
).toBeVisible();
});
test("Cannot choose date range presets", async ({ page, users, bookings, webhooks }) => {
test("Cannot choose date range presets", async ({ page, users }) => {
const firstUser = await users.create();
await firstUser.apiLogin();
const bookingsGetResponse = page.waitForResponse((response) =>
@@ -91,7 +91,7 @@ test.describe("Bookings", () => {
test.describe("Past bookings", () => {
test("Mark first guest as no-show", async ({ page, users, bookings, webhooks }) => {
const firstUser = await users.create();
const secondUser = await users.create();
const _secondUser = await users.create();
const bookingWhereFirstUserIsOrganizerFixture = await createBooking({
title: "Booking as organizer",
@@ -112,15 +112,14 @@ test.describe("Bookings", () => {
await page.goto(`/bookings/past`);
const pastBookings = page.locator('[data-testid="past-bookings"]');
const firstPastBooking = pastBookings.locator('[data-testid="booking-item"]').nth(0);
const titleAndAttendees = firstPastBooking.locator('[data-testid="title-and-attendees"]');
const firstGuest = firstPastBooking.locator('[data-testid="guest"]').nth(0);
await firstGuest.click();
await expect(page.locator('[data-testid="unmark-no-show"]')).toBeHidden();
await expect(page.locator('[data-testid="mark-no-show"]')).toBeVisible();
await page.locator('[data-testid="mark-no-show"]').click();
await firstGuest.click();
await expect(page.locator('[data-testid="unmark-no-show"]')).toBeVisible();
await expect(page.locator('[data-testid="mark-no-show"]')).toBeHidden();
// Wait for the dropdown to close before clicking again
await expect(page.getByText("first@cal.com marked as no-show")).toBeVisible();
await webhookReceiver.waitForRequestCount(1);
const [request] = webhookReceiver.requestList;
const body = request.body;
@@ -142,7 +141,7 @@ test.describe("Bookings", () => {
});
test("Mark 3rd attendee as no-show", async ({ page, users, bookings }) => {
const firstUser = await users.create();
const secondUser = await users.create();
const _secondUser = await users.create();
const bookingWhereFirstUserIsOrganizerFixture = await createBooking({
title: "Booking as organizer",
@@ -158,23 +157,33 @@ test.describe("Bookings", () => {
{ name: "Fourth", email: "fourth@cal.com", timeZone: "Europe/Berlin" },
],
});
const bookingWhereFirstUserIsOrganizer = await bookingWhereFirstUserIsOrganizerFixture.self();
await bookingWhereFirstUserIsOrganizerFixture.self();
await firstUser.apiLogin();
await page.goto(`/bookings/past`);
const pastBookings = page.locator('[data-testid="past-bookings"]');
const firstPastBooking = pastBookings.locator('[data-testid="booking-item"]').nth(0);
const titleAndAttendees = firstPastBooking.locator('[data-testid="title-and-attendees"]');
// Click the ellipsis button to open the all attendees dropdown
const moreGuests = firstPastBooking.locator('[data-testid="more-guests"]');
await moreGuests.click();
const firstGuestInMore = page.getByRole("menuitemcheckbox").nth(0);
await expect(firstGuestInMore).toBeChecked({ checked: false });
await firstGuestInMore.click();
await expect(firstGuestInMore).toBeChecked({ checked: true });
const updateNoShow = firstPastBooking.locator('[data-testid="update-no-show"]');
await updateNoShow.click();
// Find all the mark-no-show buttons in the dropdown (one for each attendee)
const noShowButtons = page.locator('[data-testid="mark-no-show"]');
// Verify the 3rd attendee (index 2) has mark-no-show button (not already marked)
const thirdAttendeeNoShowButton = noShowButtons.nth(2);
await expect(thirdAttendeeNoShowButton).toBeVisible();
// Click the 3rd attendee's mark-no-show button
await thirdAttendeeNoShowButton.click();
// Open dropdown again to verify the 3rd attendee now shows unmark-no-show
await moreGuests.click();
await expect(firstGuestInMore).toBeChecked({ checked: true });
const unmarkNoShowButtons = page.locator('[data-testid="unmark-no-show"]');
// The 3rd attendee should now have the unmark button visible
await expect(unmarkNoShowButtons.first()).toBeVisible();
});
test("Team admin/owner can mark first attendee as no-show", async ({
page,
@@ -199,18 +208,16 @@ test.describe("Bookings", () => {
});
const booking = await bookingFixture.self();
await adminUser.apiLogin();
const { webhookReceiver, teamId } = await webhooks.createTeamReceiver();
const { webhookReceiver } = await webhooks.createTeamReceiver();
await page.goto(`/bookings/past`);
const pastBookings = page.locator('[data-testid="past-bookings"]');
const firstPastBooking = pastBookings.locator('[data-testid="booking-item"]').nth(0);
const titleAndAttendees = firstPastBooking.locator('[data-testid="title-and-attendees"]');
const firstGuest = firstPastBooking.locator('[data-testid="guest"]').nth(0);
await firstGuest.click();
await expect(page.locator('[data-testid="mark-no-show"]')).toBeVisible();
await page.locator('[data-testid="mark-no-show"]').click();
await firstGuest.click();
await expect(page.locator('[data-testid="unmark-no-show"]')).toBeVisible();
await expect(page.locator('[data-testid="mark-no-show"]')).toBeHidden();
// Wait for the dropdown to close before clicking again
await expect(page.getByText("first@cal.com marked as no-show")).toBeVisible();
await webhookReceiver.waitForRequestCount(1);
const [request] = webhookReceiver.requestList;
const body = request.body;
@@ -232,7 +239,7 @@ test.describe("Bookings", () => {
webhookReceiver.close();
});
test("Can choose date range presets", async ({ page, users, bookings, webhooks }) => {
test("Can choose date range presets", async ({ page, users }) => {
const firstUser = await users.create();
await firstUser.apiLogin();
const bookingsGetResponse = page.waitForResponse((response) =>
@@ -374,22 +381,15 @@ test.describe("Bookings", () => {
//verify with the booking titles
const firstUpcomingBooking = bookingListItems.nth(0);
await expect(
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
firstUpcomingBooking.locator(`text=${thirdUserOrganizerBooking!.title}`)
).toBeVisible();
await expect(firstUpcomingBooking.locator(`text=${thirdUserOrganizerBooking!.title}`)).toBeVisible();
const secondUpcomingBooking = bookingListItems.nth(1);
await expect(
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
secondUpcomingBooking.locator(`text=${thirdUserAttendeeIndividualBooking!.title}`)
).toBeVisible();
const thirdUpcomingBooking = bookingListItems.nth(2);
await expect(
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
thirdUpcomingBooking.locator(`text=${thirdUserAttendeeTeamEvent!.title}`)
).toBeVisible();
await expect(thirdUpcomingBooking.locator(`text=${thirdUserAttendeeTeamEvent!.title}`)).toBeVisible();
});
test("Does not show booking from another user from collective event type when a member is filtered", async ({
@@ -414,7 +414,7 @@ test.describe("Bookings", () => {
const { team } = await owner.getFirstTeamMembership();
const eventType = await owner.getFirstTeamEvent(team.id);
const { id: eventTypeId, title: teamEventTitle, slug: teamEventSlug } = eventType;
const { id: eventTypeId, title: _teamEventTitle, slug: _teamEventSlug } = eventType;
// remove myself from host of this event type
await prisma.host.delete({
+3 -3
View File
@@ -212,7 +212,9 @@ test.describe("Event Types tests", () => {
await bookTimeSlot(page);
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
await expect(page.locator("text=+19199999999")).toBeVisible();
await expect(page.locator("text=+19199999999")).toHaveCount(2);
await expect(page.locator("text=+19199999999").first()).toBeVisible();
await expect(page.locator("text=+19199999999").nth(1)).toBeVisible();
});
test("Can add Organzer Phone Number location and book with it", async ({ page }) => {
@@ -274,7 +276,6 @@ test.describe("Event Types tests", () => {
});
// TODO: This test is extremely flaky and has been failing a lot, blocking many PRs. Fix this.
// eslint-disable-next-line playwright/no-skipped-test
test.skip("Can remove location from multiple locations that are saved", async ({ page }) => {
await gotoFirstEventType(page);
@@ -419,7 +420,6 @@ test.describe("Event Types tests", () => {
});
test("should enable timezone lock in event advanced settings and verify disabled timezone selector on booking page", async ({
page,
users,
}) => {
await gotoFirstEventType(page);
await expect(page.locator("[data-testid=event-title]")).toBeVisible();
+1
View File
@@ -189,6 +189,7 @@ const createTeamAndAddUser = async (
orgAutoAcceptEmail: user.email.split("@")[1],
isOrganizationVerified: !!isOrgVerified,
isOrganizationConfigured: isDnsSetup,
orgAutoJoinOnSignup: true,
},
};
}
+1 -3
View File
@@ -57,9 +57,7 @@ test.describe("Locale-specific pages must not 404", () => {
await user.apiLogin();
const response = await page.goto(`/${locale}/bookings/upcoming`);
expect(response?.status()).not.toBe(404);
await expect(
page.locator("text=See upcoming and past events booked through your event type links.")
).toBeVisible();
await expect(page.getByRole("heading", { name: "Bookings" })).toBeVisible();
});
test(`/${locale}/teams page shouldn't 404`, async ({ page, users }) => {
@@ -90,7 +90,7 @@ test.describe("Organization", () => {
// This test is already covered by booking.e2e.ts where existing user is invited and his booking links are tested.
// We can re-test here when we want to test some more scenarios.
test("existing user invited to an organization", () => {});
test("nonexisting user invited to a Team inside organization", async ({
@@ -222,15 +222,6 @@ test.describe("Organization", () => {
"signup?token"
);
await expectUserToBeAMemberOfOrganization({
page,
orgSlug: org.slug,
username: usernameDerivedFromEmail,
role: "member",
isMemberShipAccepted: true,
email: invitedUserEmail,
});
assertInviteLink(inviteLink);
await signupFromEmailInviteLink({
browser,
@@ -272,7 +263,7 @@ test.describe("Organization", () => {
});
// Such a user has user.username changed directly in addition to having the new username in the profile.username
test("existing user migrated to an organization", async ({ users, page, emails }) => {
test("existing user migrated to an organization", async ({ users, page, emails: _emails }) => {
const orgOwner = await users.create(undefined, {
hasTeam: true,
isOrg: true,
@@ -313,7 +304,6 @@ test.describe("Organization", () => {
await page.locator('[data-testid="continue-with-email-button"]').click();
await expect(page.locator('[data-testid="signup-submit-button"]')).toBeVisible();
await page.locator('input[name="username"]').fill(existingUser.username!);
await page
.locator('input[name="email"]')
@@ -346,23 +336,7 @@ test.describe("Organization", () => {
const invitedUserEmail = users.trackEmail({ username: "rick", domain: "example.com" });
const usernameDerivedFromEmail = invitedUserEmail.split("@")[0];
await inviteAnEmail(page, invitedUserEmail, true);
await expectUserToBeAMemberOfTeam({
page,
teamId: team.id,
username: usernameDerivedFromEmail,
role: "member",
isMemberShipAccepted: true,
email: invitedUserEmail,
});
await expectUserToBeAMemberOfOrganization({
page,
orgSlug: org.slug,
username: usernameDerivedFromEmail,
role: "member",
isMemberShipAccepted: true,
email: invitedUserEmail,
});
const inviteLink = await expectInvitationEmailToBeReceived(
page,
emails,
@@ -594,7 +568,7 @@ async function expectUserToBeAMemberOfTeam({
page,
teamId,
email,
role,
role: _role,
username,
isMemberShipAccepted,
}: {
+13 -9
View File
@@ -41,7 +41,9 @@ test.describe("Reschedule Tests", async () => {
await page.fill('[data-testid="reschedule_reason"]', "I can't longer have it");
await page.locator('button[data-testid="send_request"]').click();
await expect(page.locator('[id="modal-title"]')).toBeHidden();
await expect(page.locator('[data-testid="reschedule-dialog"]')).toBeHidden();
await page.waitForTimeout(2000);
const updatedBooking = await booking.self();
@@ -58,7 +60,7 @@ test.describe("Reschedule Tests", async () => {
}) => {
const user = await users.create();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const booking = await bookings.create(user.id, user.username, user.eventTypes[0].id!, {
const _booking = await bookings.create(user.id, user.username, user.eventTypes[0].id!, {
status: BookingStatus.ACCEPTED,
startTime: dayjs().subtract(2, "day").toDate(),
endTime: dayjs().subtract(2, "day").add(30, "minutes").toDate(),
@@ -130,8 +132,9 @@ test.describe("Reschedule Tests", async () => {
await user.apiLogin();
await page.goto("/bookings/cancelled");
const requestRescheduleSentElement = page.locator('[data-testid="request_reschedule_sent"]').nth(1);
await expect(requestRescheduleSentElement).toBeVisible();
await page.locator('[data-testid="booking-item"]').nth(0).click();
await expect(page.locator('[data-testid="request_reschedule_sent"]')).toBeVisible();
await booking.delete();
});
@@ -196,7 +199,7 @@ test.describe("Reschedule Tests", async () => {
},
},
});
const payment = await payments.create(booking.id);
const _payment = await payments.create(booking.id);
await page.goto(`/reschedule/${booking.uid}`);
await selectFirstAvailableTimeSlotNextMonth(page);
@@ -226,7 +229,7 @@ test.describe("Reschedule Tests", async () => {
paid: true,
});
const payment = await payments.create(booking.id);
const _payment = await payments.create(booking.id);
await page.goto(`/reschedule/${booking?.uid}`);
await selectFirstAvailableTimeSlotNextMonth(page);
@@ -330,7 +333,6 @@ test.describe("Reschedule Tests", async () => {
test("Should load Valid Cal video url after rescheduling Opt in events", async ({
page,
users,
bookings,
browser,
}) => {
// eslint-disable-next-line playwright/no-skipped-test
@@ -339,11 +341,13 @@ test.describe("Reschedule Tests", async () => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const eventType = user.eventTypes.find((e) => e.slug === "opt-in")!;
const confirmBooking = async (bookingId: number) => {
const confirmBooking = async (_bookingId: number) => {
const [authedContext, authedPage] = await user.apiLoginOnNewBrowser(browser);
await authedPage.goto("/bookings/upcoming");
await submitAndWaitForResponse(authedPage, "/api/trpc/bookings/confirm?batch=1", {
action: () => authedPage.locator(`[data-bookingid="${bookingId}"][data-testid="confirm"]`).click(),
action: async () => {
await authedPage.locator('[data-testid="booking-item"] [data-testid="confirm"]').nth(0).click();
},
});
await authedContext.close();
};
+58 -2
View File
@@ -221,6 +221,7 @@
"manage_this_team": "Manage this team",
"team_info": "Team Info",
"join_meeting": "Join Meeting",
"join_platform": "Join {{platform}}",
"request_another_invitation_email": "If you prefer not to use {{toEmail}} as your {{appName}} email or already have a {{appName}} account, please request another invitation to that email.",
"you_have_been_invited": "You have been invited to join the team {{teamName}}",
"user_invited_you": "{{user}} invited you to join the {{entity}} {{team}} on {{appName}}",
@@ -457,6 +458,7 @@
"bookerlayout_error_unknown_layout": "The layout you selected is not a valid layout.",
"bookerlayout_override_global_settings": "You can manage this for all your event types in Settings -> <0>Appearance</0> or <1>Override</1> for this event only.",
"unexpected_error_try_again": "An unexpected error occurred. Try again.",
"duplicate_event_slug_conflict": "An event type with this URL already exists. Please try a different URL.",
"sunday_time_error": "Invalid time on Sunday",
"monday_time_error": "Invalid time on Monday",
"tuesday_time_error": "Invalid time on Tuesday",
@@ -567,6 +569,7 @@
"booking_paid": "Booking Paid",
"ooo_created": "Out of Office Created",
"booking_no_show_updated": "Booking No-Show Updated",
"delegation_credential_error": "Delegation Credential Error",
"event_triggers": "Event Triggers",
"subscriber_url": "Subscriber URL",
"create_new_webhook": "Create a new webhook",
@@ -1620,12 +1623,14 @@
"example_1": "Example 1",
"example_2": "Example 2",
"booking_question_identifier": "Booking Question Identifier",
"form_field_identifier": "Form Field Identifier",
"company_size": "Company size",
"what_help_needed": "What do you need help with?",
"variable_format": "Variable format",
"webhook_subscriber_url_reserved": "Webhook subscriber url is already defined",
"custom_input_as_variable_info": "Ignore all special characters of the additional input label (use only letters and numbers), use uppercase for all letters and replace whitespaces with underscores.",
"using_booking_questions_as_variables": "How do I use booking questions as variables?",
"using_form_responses_as_variables": "How do I use form responses as variables?",
"download_desktop_app": "Download desktop app",
"set_ping_link": "Set Ping link",
"rate_limit_exceeded": "Rate limit exceeded",
@@ -1707,7 +1712,10 @@
"error_updating_password": "Error updating password",
"two_factor_auth": "Two factor authentication",
"recurring_event_tab_description": "Set up a repeating schedule",
"today": "today",
"today": "Today",
"this_month": "This month",
"time": "Time",
"event": "Event",
"appearance": "Appearance",
"my_account": "My account",
"general": "General",
@@ -1921,6 +1929,7 @@
"show_attendees": "Share attendee information between guests",
"show_available_seats_count": "Show the number of available seats",
"how_booking_questions_as_variables": "How to use booking questions as variables?",
"how_form_responses_as_variables": "How to use form responses as variables?",
"format": "Format",
"questions": "Questions",
"all_info_your_booker_provide": "All the info your booker should provide before booking with you.",
@@ -1937,6 +1946,7 @@
"billing_portal": "Billing portal",
"billing_help_cta": "Contact support",
"ignore_special_characters_booking_questions": "Ignore special characters in your booking question identifier. Use only letters and numbers",
"ignore_special_characters_form_responses": "Ignore special characters in your form field identifier. Use only letters and numbers",
"retry": "Retry",
"fetching_calendars_error": "There was a problem fetching your calendars. Please <1>try again</1> or reach out to customer support.",
"calendar_connection_fail": "Calendar connection failed",
@@ -2852,7 +2862,9 @@
"call_to_confirm_booking": "Call to confirm booking",
"cal_ai_phone_call_action_description": "2 hrs before event starts",
"cal_ai_agent_configuration": "Cal.ai Agent Configuration",
"select_event_type_to_schedule_calls": "Please select an event type to schedule calls",
"choose_at_least_one_event_type_test_call": "Please choose at least one event type to make a test call.",
"choose_event_type_in_agent_setup": "Please choose an event type in the agent setup.",
"mark_as_no_show": "Mark as no-show",
"unmark_as_no_show": "Unmark no-show",
"account_unlinked_success": "Account unlinked successfully",
@@ -2919,6 +2931,8 @@
"delete_org_eventtypes": "Delete individual event types",
"lock_org_users_eventtypes": "Lock individual event type creation",
"lock_org_users_eventtypes_description": "Prevent members from creating their own event types.",
"org_auto_join_title": "Automatically add new members to the organization if they sign up to Cal.com with the \"{{emailDomain}}\" email domain",
"org_auto_join_description": "New members are added after they verify their email.",
"add_to_event_type": "Add to event type",
"create_account_password": "Create account password",
"create_account_with_saml": "Create Account with SAML",
@@ -3245,6 +3259,8 @@
"lock_attribute_for_assignment": "Lock for assignment",
"lock_attribute_for_assignment_description": "Locking would only allow assignments from Directory Sync",
"attribute_edited_successfully": "Attribute edited successfully",
"group_options": "Group Options",
"group_options_description": "When a group option is assigned to a user, they behave as if all options within that group are assigned to them.",
"new_group_option": "New group option",
"attribute_weight_enabled": "Weights enabled",
"attribute_weight_enabled_description": "By enabling weights, it would be possible to assign higher priority to certain attributes per user. The higher the weight, the higher the priority.",
@@ -3864,7 +3880,10 @@
"please_select_event_type_first": "Please select an event type first",
"edit_configuration": "Edit Configuration",
"select_event_type_for_inbound_calls": "Inbound calls can book only one event type. Select the event type where meetings will be scheduled when callers reach your agent.",
"select_event_type_for_outbound_calls": "Select the event type that should be used to schedule meetings.",
"setup_inbound_agent_for_incoming_calls": "Set up inbound agent for incoming calls",
"no_event_types_available_for_test_call": "No event types available for a test call",
"create_block_entry": "Create Block Entry",
"blocklist_entry_details": "Blocklist Entry Details",
"blocklist_entry_not_found": "Blocklist entry not found",
"blocklist_entry_created": "Blocklist entry created successfully",
@@ -3872,7 +3891,7 @@
"history": "History",
"no_audit_history": "No audit history available",
"remove_value_from_blocklist": "Remove {{value}} from blocklist?",
"remove_value_from_blocklist_description":"The action is irreversible",
"remove_value_from_blocklist_description": "The action is irreversible",
"delete_blocklist_entry_confirmation": "Are you sure you want to remove \"{{value}}\" from the blocklist? This action cannot be undone.",
"created_by": "Created By",
"blocked_by": "Blocked By",
@@ -3956,7 +3975,44 @@
"team_brand_subtitle": "Customize your team's branding with a logo and color",
"team_details": "Team Details",
"team_invite": "Team Invite",
"slots": "Slots",
"slots_taken": "{{takenSeats}}/{{totalSeats}} slots taken",
"team_invite_subtitle": "Invite team members to collaborate and schedule together",
"team_onboarding_details_subtitle": "Add your team's name and create a unique URL for your team",
"repeats_num_times": "Repeats {{count}} times",
"view_booking_details": "View Booking Details",
"recurring_booking_description": "Recurring {{frequency}} event with {{count}} occurrences",
"team_bio": "Team Bio",
"team_bio_placeholder": "Tell us about your team...",
"onboarding_invite_subtitle": "Connect your Google workspace, invite via email, upload a CSV file or copy the invite link and share it with your teammates to add them to your Team.",
"upload_csv_subtitle": "Upload a CSV file with team member emails to bulk invite",
"need_template": "Need a template?",
"download_csv_template_description": "Download our CSV template to ensure your file is formatted correctly",
"download_template": "Download template",
"upload_your_file": "Upload your file",
"upload_csv_description": "Select a CSV file with email addresses and optional roles",
"choose_file": "Choose file",
"please_upload_csv_file": "Please upload a CSV file",
"template_downloaded": "Template downloaded successfully",
"please_select_file": "Please select a file to upload",
"csv_file_empty": "CSV file is empty or invalid",
"csv_missing_email_column": "CSV file must contain an 'email' column",
"csv_uploaded_successfully": "Successfully uploaded {{count}} invites",
"error_uploading_csv": "Error uploading CSV file",
"onboarding_browser_view_demo": "Demo",
"onboarding_browser_view_demo_description": "Schedule a demo call with us",
"onboarding_browser_view_quick_meeting": "Quick meeting",
"onboarding_browser_view_quick_meeting_description": "A quick chat",
"onboarding_browser_view_longer_meeting": "Longer meeting",
"onboarding_browser_view_longer_meeting_description": "A longer chat",
"onboarding_browser_view_in_person_description": "Meet in person",
"onboarding_browser_view_ask_question": "Ask a question",
"onboarding_browser_view_ask_question_description": "Ask a question",
"onboarding_browser_view_default_bio": "Add your bio here...",
"book_now": "Book now",
"view": "View",
"booking_response": "Booking Response",
"list_view": "List view",
"calendar_view": "Calendar view",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -39,7 +39,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
},
};
}
} catch (e) {
} catch {
return {
redirect: {
destination: "/auth/error?error=Invalid%20JWT%3A%20Please%20try%20again",
@@ -76,8 +76,8 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
};
}
const userCount = await prisma.user.count();
if (userCount === 0) {
const userExists = await prisma.user.findFirst({ select: { id: true } });
if (!userExists) {
// Proceed to new onboarding to create first admin user
return {
redirect: {
@@ -9,31 +9,31 @@ import { ssoTenantProduct } from "@calcom/features/ee/sso/lib/sso";
import { checkUsername } from "@calcom/features/profile/lib/checkUsername";
import { OnboardingPathService } from "@calcom/features/onboarding/lib/onboarding-path.service";
import { IS_PREMIUM_USERNAME_ENABLED } from "@calcom/lib/constants";
import prisma from "@calcom/prisma";
import { prisma } from "@calcom/prisma";
import { z } from "zod";
import { asStringOrNull } from "@lib/asStringOrNull";
const Params = z.object({
username: z.string().optional(),
email: z.string().optional(),
provider: z.string({ required_error: "File is not named sso/[provider]" }),
});
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
// get query params and typecast them to string
// (would be even better to assert them instead of typecasting)
const providerParam = asStringOrNull(context.query.provider);
const emailParam = asStringOrNull(context.query.email);
const usernameParam = asStringOrNull(context.query.username);
export const getServerSideProps = async ({ req, query }: GetServerSidePropsContext) => {
const {
provider: providerParam,
email: emailParam,
username: usernameParam,
} = Params.parse(query);
const successDestination = await OnboardingPathService.getGettingStartedPathWithParams(
prisma,
usernameParam ? { username: usernameParam } : undefined
);
if (!providerParam) {
throw new Error(`File is not named sso/[provider]`);
}
const { req } = context;
const session = await getServerSession({ req });
const { currentOrgDomain } = orgDomainConfig(context.req);
const { currentOrgDomain } = orgDomainConfig(req);
if (session) {
// Validating if username is Premium, while this is true an email its required for stripe user confirmation
@@ -23,7 +23,7 @@ const mockFindFirstEventType = (
return eventType;
};
vi.mock("@calcom/emails/email-manager", () => {
vi.mock("@calcom/emails/integration-email-service", () => {
return {
sendSlugReplacementEmail: () => ({}),
};
@@ -419,7 +419,7 @@ describe("handleChildrenEventTypes", () => {
timeZone: _timeZone,
parentId: _parentId,
userId: _userId,
requiresBookerEmailVerification,
lockTimeZoneToggleOnBookingPage,
useEventTypeDestinationCalendarEmail,

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