Compare commits

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

* fix: adjust FAB positioning

* style: skeleton improvements

* fix: reset password button

* fix: remove left border from remove icon

* style: improve fields

* style: wrap base form styles into layer base

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

* fix bio + team name on invite orgs

* use bg-cal-muted

* Fix error from mass edit

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

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

* fix org labels

* Fix i18n

---------

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

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

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

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

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

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

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

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

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

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

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

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

* wip

* wip

* wip

* revert

* revert

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

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

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

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

* test: update formSubmissionUtils tests to expect creditCheckFn parameter

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

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

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

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

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

* feat: make creditCheckFn required to fully break circular dependency

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

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

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

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

* fix: make creditCheckFn required in WorkflowService.scheduleFormWorkflows

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

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

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

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

* remove

* fix

* refactor

* refactor

* refactor

* wip

* fix

* fix

* rm

---------

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

* fix: Re-export TextFilterOperator

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

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

* Add FilterPopover and further fixups

* Fix further type errors missed earlier

* More hidden type errors

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

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

* implement PKCE

* pass codeChallenge and codeChallengeMethod to handler

* fixes for secure oauth flow

* fix type error

* clean up refresh token endpoint

* only support S256

* fix type error

* remove comment

* add tests

* fix type errors in route.test.ts

* add missing support for refresh token

* add e2e test for public client refresh tokens

* allow pkce for confidential clients

* fix type error

* fix e2e

* fix option pkce for confidential clients

* e2e test improvements

* fix test

* remove only

* add delay

* fix e2e tests

* remove only

* don't skip pkce if codeChallenge is set

* add service functions for token endpoint

* use service function in refreshToken endpoint

* use repository

* remove return types

* e2e test fixes

* fix e2e test

* remove .only in e2e test

* remove pause

* fix error responses in token endpoints

* adjust tests to new error responses

* fix error responses

* e2e improvements

* redirect on error

* adjust tests

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

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

---------

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

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

## Visual Demo (For contributors especially)

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

#### Video Demo (if applicable):

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

#### Image Demo (if applicable):

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

## Mandatory Tasks (DO NOT REMOVE)

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

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

## Checklist

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

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










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

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

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

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

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

* fix: creation

* test: e2e rescheduling

* refactor: remove explicit return type

* fix: e2e

* fix: e2e

* fix: e2e

* fix: e2e tests

* fix: e2e tests

* fix: e2e

* logs: add debug logs for e2e

* debug e2e

* Revert "debug e2e"

This reverts commit 3d92d2bfbc351021ab06492b7279ed95843bf36a.

* Revert "logs: add debug logs for e2e"

This reverts commit f3b321fd96853bc2cf408c7f3667ad38d0931435.

* try to fix e2e

* extract e2e in separate file

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

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

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

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

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

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

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

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

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

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

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

* remove duplicate

* revert

* revert

* revert

* refactor

* add tests

* address

* clean up

* simplify

* simplify

* rename

* rename

* rename

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-11-26 00:04:21 +00:00
d6546c3107 feat: upgrade tailwind v4 (#24598)
* chore: refactor config files to prevent migration tool errors

* refactor: upgrade with the tailwind migration tool

* chore: restore pre-commit command + mc

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

* chore: resolve Tailwind v4 migration conflicts from merging main

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

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

* fix: resolve token conflicts between calcom and coss ui

* fix: textarea scrollbar

* Fix CUI-16

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

* fix: selects and inputs of different sizes

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

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

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

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

* fix: calendar

* fix: update KBarSearch

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

* Fix focus state email input

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

* fix: line-height issues

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

* fix: sidebar spacing

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

* fix embed

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

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

* suggestion for coss-ui

* refactor: update CSS variable usage and clean up styles

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

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

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

* fix: examples app breaking

* fix: modal not resizing correctly

* feat: upgrade atoms to tailwind v4

* fix: atoms build breaking

* fix: atoms build breaking

* chore: upgrate examples/base to tailwind 4

* chore: update globals.css

* fix: add missing scheduler css variables

* fix: PlatformAdditionalCalendarSelector

* chore: update global styles

* chore: update tailwindcss and postcss dependencies to stable versions

* chore: remove unneeded class

* fix: dialog and toast animation

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

* fix: dialog modal for Apple connect

* add margin in SaveFilterSegmentButton

* Fix radix button nested states

* add cursor pointer to buttons but keep dsabled state

* Fix commandK selectors and adds cursor pointer

* Fix teams filter

* fix - round checkboxes

* fix filter checkbox

* fix select indicator's margin

* command group font size

* style: fix badge and tooltip radius

* chore: remove unneeded files

* Delete PR_REVIEW_MANAGED_EVENT_REASSIGNMENT.md

* remove ui-playground leftover

* fix: add missing react phone input styles in atoms

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

* fix: inter font not loading

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

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

* fix: typo

* fix: atoms stack class + remove unused css file

* fix default radius valiue

* fix space-y in embed

* fix skeleton background

* Hardcode radius values to match production

* fix border in embed

* add missing externalThemeClass

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

* fix: add stack utility to atom global css

* fix: Skeleton loader class modalbox

* Add stack-y utility in embed

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

* update yarn.lock

* add popover portla

* update

---------

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

* added testcase

---------

Co-authored-by: chauhan_s <somaychauhan98@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2025-11-25 20:14:05 +00:00
544 changed files with 18434 additions and 29857 deletions
-1
View File
@@ -4,7 +4,6 @@
"yzhang.markdown-all-in-one", // nicer markdown support
"esbenp.prettier-vscode", // prettier plugin
"dbaeumer.vscode-eslint", // eslint plugin
"bradlc.vscode-tailwindcss", // hinting / autocompletion for tailwind
"ban.spellright", // Spell check for docs
"stripe.vscode-stripe", // stripe VSCode extension
"Prisma.prisma", // syntax|format|completion for prisma
+1 -1
View File
@@ -8,5 +8,5 @@
"typescript.preferences.importModuleSpecifier": "non-relative",
"spellright.language": ["en"],
"spellright.documentTypes": ["markdown", "typescript", "typescriptreact"],
"tailwindCSS.experimental.classRegex": [["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"]]
"tailwindCSS.classFunctions": ["cva"]
}
View File
@@ -0,0 +1,230 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output";
import { RescheduleBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reschedule-booking.output";
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import * as request from "supertest";
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { randomString } from "test/utils/randomString";
import { withApiAuth } from "test/utils/withApiAuth";
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
import type {
CreateBookingInput_2024_08_13,
BookingOutput_2024_08_13,
RescheduleBookingInput_2024_08_13,
GetBookingOutput_2024_08_13,
} from "@calcom/platform-types";
import type { EventType, User } from "@calcom/prisma/client";
describe("Reschedule bookings 2024-08-13", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let schedulesService: SchedulesService_2024_04_15;
let normalBookingUser: User;
let normalBookingEventType: EventType;
let normalBookingEventTypeId: number;
const normalBookingUserEmail = `user-bookings-normal-user-${randomString(5)}@api.com`;
const normalBookingEventTypeSlug = `user-bookings-normal-event-type-${randomString(5)}`;
let oldNormalBooking: BookingOutput_2024_08_13;
let newNormalBooking: BookingOutput_2024_08_13;
const RESCHEDULE_REASON = "Flying to venus that day";
const RESCHEDULED_BY = `user-venus-bookings-rescheduler-${randomString(5)}@api.com`;
beforeAll(async () => {
const moduleRef = await withApiAuth(
normalBookingUserEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
})
)
.overrideGuard(PermissionsGuard)
.useValue({
canActivate: () => true,
})
.compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
// initialize normal booking user, schedule and event type
normalBookingUser = await userRepositoryFixture.create({
email: normalBookingUserEmail,
username: normalBookingUserEmail,
});
const userSchedule: CreateScheduleInput_2024_04_15 = {
name: `user-bookings-normal-schedule-${randomString()}`,
timeZone: "Europe/Rome",
isDefault: true,
};
await schedulesService.createUserSchedule(normalBookingUser.id, userSchedule);
normalBookingEventType = await eventTypesRepositoryFixture.create(
{
title: `user-bookings-normal-event-type-${randomString()}`,
slug: normalBookingEventTypeSlug,
length: 60,
},
normalBookingUser.id
);
normalBookingEventTypeId = normalBookingEventType.id;
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
describe("reschedule normal booking", () => {
it("should create a booking to be rescheduled", async () => {
const bookingBody: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2040, 1, 9, 10, 0, 0)).toISOString(),
eventTypeId: normalBookingEventTypeId,
attendee: {
name: `user-venus-bookings-attendee-${randomString(10)}`,
email: `user-venus-bookings-attendee-${randomString(10)}@gmail.com`,
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/oj12u83128u9312ou3",
};
const bookingResponse = await request(app.getHttpServer())
.post("/v2/bookings")
.send(bookingBody)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201);
const bookingResponseBody: CreateBookingOutput_2024_08_13 = bookingResponse.body;
expect(bookingResponseBody.data).toBeDefined();
expect(responseDataIsBooking(bookingResponseBody.data)).toBe(true);
if (responseDataIsBooking(bookingResponseBody.data)) {
const created: BookingOutput_2024_08_13 = bookingResponseBody.data;
oldNormalBooking = created;
expect(oldNormalBooking).toBeDefined();
expect(oldNormalBooking.uid).toBeDefined();
} else {
throw new Error("should create a booking to be rescheduled - Invalid response data");
}
});
it("should reschedule", async () => {
const body: RescheduleBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2040, 1, 9, 12, 0, 0)).toISOString(),
reschedulingReason: RESCHEDULE_REASON,
rescheduledBy: RESCHEDULED_BY,
};
const beforeCreate = new Date();
return request(app.getHttpServer())
.post(`/v2/bookings/${oldNormalBooking.uid}/reschedule`)
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const afterCreate = new Date();
const responseBody: RescheduleBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const data: BookingOutput_2024_08_13 = responseBody.data as BookingOutput_2024_08_13;
expect(data.reschedulingReason).toEqual(RESCHEDULE_REASON);
expect(data.rescheduledFromUid).toEqual(oldNormalBooking.uid);
expect(data.rescheduledByEmail).toEqual(RESCHEDULED_BY);
expect(data.start).toEqual(body.start);
expect(data.end).toEqual(new Date(Date.UTC(2040, 1, 9, 13, 0, 0)).toISOString());
expect(data.id).toBeDefined();
expect(data.uid).toBeDefined();
expect(data.hosts[0].id).toEqual(normalBookingUser.id);
expect(data.hosts[0].username).toEqual(normalBookingUser.username);
expect(data.hosts[0].email).toEqual(normalBookingUser.email);
expect(data.status).toEqual(oldNormalBooking.status);
expect(data.duration).toEqual(oldNormalBooking.duration);
expect(data.eventTypeId).toEqual(oldNormalBooking.eventTypeId);
expect(data.attendees[0]).toEqual(oldNormalBooking.attendees[0]);
expect(data.location).toEqual(oldNormalBooking.location);
expect(data.absentHost).toEqual(oldNormalBooking.absentHost);
expect(data.metadata).toEqual(oldNormalBooking.metadata);
newNormalBooking = data;
expect(newNormalBooking).toBeDefined();
const createdAtDate = new Date(data.createdAt);
expect(createdAtDate.getTime()).toBeGreaterThanOrEqual(beforeCreate.getTime());
expect(createdAtDate.getTime()).toBeLessThanOrEqual(afterCreate.getTime());
});
});
it("should fetch old booking and verify rescheduledToUid and rescheduledByEmail and status cancelled", async () => {
return request(app.getHttpServer())
.get(`/v2/bookings/${oldNormalBooking.uid}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsBooking(responseBody.data)).toBe(true);
if (responseDataIsBooking(responseBody.data)) {
const data: BookingOutput_2024_08_13 = responseBody.data;
expect(data.rescheduledToUid).toEqual(newNormalBooking.uid);
expect(data.rescheduledByEmail).toEqual(RESCHEDULED_BY);
expect(data.status).toEqual("cancelled");
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibly recurring bookings"
);
}
});
});
it("should fetch new booking and verify rescheduledFromUid, rescheduledByEmail and reschedulingReason", async () => {
return request(app.getHttpServer())
.get(`/v2/bookings/${newNormalBooking.uid}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsBooking(responseBody.data)).toBe(true);
if (responseDataIsBooking(responseBody.data)) {
const data: BookingOutput_2024_08_13 = responseBody.data;
expect(data.rescheduledFromUid).toEqual(oldNormalBooking.uid);
expect(data.rescheduledByEmail).toEqual(RESCHEDULED_BY);
expect(data.reschedulingReason).toEqual(RESCHEDULE_REASON);
expect(data.status).toEqual("accepted");
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibly recurring bookings"
);
}
});
});
});
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(normalBookingUser.email);
});
function responseDataIsBooking(data: unknown): data is BookingOutput_2024_08_13 {
return !Array.isArray(data) && typeof data === "object" && data !== null && data && "id" in data;
}
});
@@ -79,7 +79,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
const eventTypeRequiresConfirmationSlug = `user-bookings-event-type-requires-confirmation-${randomString()}`;
let createdBooking: BookingOutput_2024_08_13;
let rescheduledBooking: BookingOutput_2024_08_13;
let createdRecurringBooking: RecurringBookingOutput_2024_08_13[];
let bookingInThePast: Booking;
@@ -1360,139 +1359,217 @@ describe("Bookings Endpoints 2024-08-13", () => {
});
});
describe("reschedule bookings", () => {
it("should reschedule normal booking", async () => {
const body: RescheduleBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2035, 0, 8, 14, 0, 0)).toISOString(),
reschedulingReason: "Flying to mars that day",
};
const beforeCreate = new Date();
return request(app.getHttpServer())
.post(`/v2/bookings/${createdBooking.uid}/reschedule`)
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const afterCreate = new Date();
const responseBody: RescheduleBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const data: BookingOutput_2024_08_13 = responseBody.data;
expect(data.reschedulingReason).toEqual(body.reschedulingReason);
expect(data.start).toEqual(body.start);
expect(data.end).toEqual(new Date(Date.UTC(2035, 0, 8, 15, 0, 0)).toISOString());
expect(data.rescheduledFromUid).toEqual(createdBooking.uid);
expect(data.id).toBeDefined();
expect(data.uid).toBeDefined();
expect(data.hosts[0].id).toEqual(user.id);
expect(data.hosts[0].username).toEqual(user.username);
expect(data.hosts[0].email).toEqual(user.email);
expect(data.status).toEqual(createdBooking.status);
expect(data.duration).toEqual(createdBooking.duration);
expect(data.eventTypeId).toEqual(createdBooking.eventTypeId);
expect(data.attendees[0]).toEqual(createdBooking.attendees[0]);
expect(data.location).toEqual(createdBooking.location);
expect(data.absentHost).toEqual(createdBooking.absentHost);
expect(data.metadata).toEqual(createdBooking.metadata);
// When a booking is rescheduled, a new booking is created and the old booking is cancelled.
// We want to make sure the createdAt date of the new booking is between the beforeCreate and afterCreate dates.
const createdAtDate = new Date(data.createdAt);
expect(createdAtDate.getTime()).toBeGreaterThanOrEqual(beforeCreate.getTime());
expect(createdAtDate.getTime()).toBeLessThanOrEqual(afterCreate.getTime());
rescheduledBooking = data;
describe("rescheduling bookings", () => {
describe("cant't reschedule cancelled or rescheduled booking booking", () => {
it("should not be able to reschedule cancelled booking", async () => {
const cancelledBooking = await bookingsRepositoryFixture.create({
status: "CANCELLED",
user: {
connect: {
id: user.id,
},
},
startTime: new Date(Date.UTC(2050, 0, 8, 13, 0, 0)),
endTime: new Date(Date.UTC(2050, 0, 8, 14, 0, 0)),
title: "peer coding lets goo",
uid: `cancelled-booking-${randomString()}`,
eventType: {
connect: {
id: eventTypeId,
},
},
location: "integrations:daily",
customInputs: {},
metadata: {},
responses: {
name: "Oldie",
email: "oldie@gmail.com",
},
attendees: {
create: {
email: "oldie@gmail.com",
name: "Oldie",
locale: "lv",
timeZone: "Europe/Rome",
},
},
});
const body: RescheduleBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2040, 0, 9, 14, 0, 0)).toISOString(),
reschedulingReason: "Flying to mars that day",
};
const response = await request(app.getHttpServer())
.post(`/v2/bookings/${cancelledBooking.uid}/reschedule`)
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(400);
expect(response.body.error.message).toEqual(
`Can't reschedule booking with uid=${cancelledBooking.uid} because it has been cancelled. Please provide uid of a booking that is not cancelled.`
);
await bookingsRepositoryFixture.deleteById(cancelledBooking.id);
});
it("should not be able to reschedule rescheduled booking", async () => {
const rescheduledBooking = await bookingsRepositoryFixture.create({
status: "CANCELLED",
rescheduled: true,
user: {
connect: {
id: user.id,
},
},
startTime: new Date(Date.UTC(2050, 0, 8, 13, 0, 0)),
endTime: new Date(Date.UTC(2050, 0, 8, 14, 0, 0)),
title: "peer coding lets goo",
uid: `cancelled-booking-${randomString()}`,
eventType: {
connect: {
id: eventTypeId,
},
},
location: "integrations:daily",
customInputs: {},
metadata: {},
responses: {
name: "Oldie",
email: "oldie@gmail.com",
},
attendees: {
create: {
email: "oldie@gmail.com",
name: "Oldie",
locale: "lv",
timeZone: "Europe/Rome",
},
},
});
const newBooking = await bookingsRepositoryFixture.create({
status: "ACCEPTED",
user: {
connect: {
id: user.id,
},
},
fromReschedule: rescheduledBooking.uid,
startTime: new Date(Date.UTC(2050, 0, 8, 13, 0, 0)),
endTime: new Date(Date.UTC(2050, 0, 8, 14, 0, 0)),
title: "peer coding lets goo",
uid: `new-booking-${randomString()}`,
eventType: {
connect: {
id: eventTypeId,
},
},
location: "integrations:daily",
customInputs: {},
metadata: {},
responses: {
name: "Oldie",
email: "oldie@gmail.com",
},
attendees: {
create: {
email: "oldie@gmail.com",
name: "Oldie",
locale: "lv",
timeZone: "Europe/Rome",
},
},
});
const body: RescheduleBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2040, 0, 9, 14, 0, 0)).toISOString(),
reschedulingReason: "Flying to mars that day",
};
const response = await request(app.getHttpServer())
.post(`/v2/bookings/${rescheduledBooking.uid}/reschedule`)
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(400);
expect(response.body.error.message).toEqual(
`Can't reschedule booking with uid=${rescheduledBooking.uid} because it has been cancelled and rescheduled already to booking with uid=${newBooking.uid}. You probably want to reschedule ${newBooking.uid} instead by passing it within the request URL.`
);
await bookingsRepositoryFixture.deleteById(rescheduledBooking.id);
await bookingsRepositoryFixture.deleteById(newBooking.id);
});
});
it("should set rescheduled booking status to cancelled", async () => {
return request(app.getHttpServer())
.get(`/v2/bookings/${createdBooking.uid}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: RescheduleBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const data: BookingOutput_2024_08_13 = responseBody.data;
expect(data.status).toEqual("cancelled");
expect(data.rescheduledToUid).toEqual(rescheduledBooking.uid);
});
});
describe("recurring booking", () => {
it("should reschedule recurrence of a recurring booking", async () => {
const body: RescheduleBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2035, 0, 9, 14, 0, 0)).toISOString(),
reschedulingReason: "Flying to mars again",
};
it("should reschedule recurrence of a recurring booking", async () => {
const body: RescheduleBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2035, 0, 9, 14, 0, 0)).toISOString(),
reschedulingReason: "Flying to mars again",
};
return request(app.getHttpServer())
.post(`/v2/bookings/${createdRecurringBooking[0].uid}/reschedule`)
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const responseBody: RescheduleBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const data: RecurringBookingOutput_2024_08_13 = responseBody.data;
expect(data.id).toBeDefined();
expect(data.uid).toBeDefined();
expect(data.hosts[0].id).toEqual(user.id);
expect(data.hosts[0].username).toEqual(user.username);
expect(data.hosts[0].email).toEqual(user.email);
expect(data.status).toEqual(createdRecurringBooking[0].status);
expect(data.start).toEqual(body.start);
expect(data.end).toEqual(new Date(Date.UTC(2035, 0, 9, 15, 0, 0)).toISOString());
expect(data.duration).toEqual(createdRecurringBooking[0].duration);
expect(data.recurringBookingUid).toEqual(createdRecurringBooking[0].recurringBookingUid);
expect(data.eventTypeId).toEqual(createdRecurringBooking[0].eventTypeId);
expect(data.attendees[0]).toEqual(createdRecurringBooking[0].attendees[0]);
expect(data.location).toEqual(createdRecurringBooking[0].location);
expect(data.absentHost).toEqual(createdRecurringBooking[0].absentHost);
expect(data.metadata).toEqual(createdRecurringBooking[0].metadata);
return request(app.getHttpServer())
.post(`/v2/bookings/${createdRecurringBooking[0].uid}/reschedule`)
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const responseBody: RescheduleBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const data: RecurringBookingOutput_2024_08_13 = responseBody.data;
expect(data.id).toBeDefined();
expect(data.uid).toBeDefined();
expect(data.hosts[0].id).toEqual(user.id);
expect(data.hosts[0].username).toEqual(user.username);
expect(data.hosts[0].email).toEqual(user.email);
expect(data.status).toEqual(createdRecurringBooking[0].status);
expect(data.start).toEqual(body.start);
expect(data.end).toEqual(new Date(Date.UTC(2035, 0, 9, 15, 0, 0)).toISOString());
expect(data.duration).toEqual(createdRecurringBooking[0].duration);
expect(data.recurringBookingUid).toEqual(createdRecurringBooking[0].recurringBookingUid);
expect(data.eventTypeId).toEqual(createdRecurringBooking[0].eventTypeId);
expect(data.attendees[0]).toEqual(createdRecurringBooking[0].attendees[0]);
expect(data.location).toEqual(createdRecurringBooking[0].location);
expect(data.absentHost).toEqual(createdRecurringBooking[0].absentHost);
expect(data.metadata).toEqual(createdRecurringBooking[0].metadata);
const oldBooking = await bookingsRepositoryFixture.getByUid(createdRecurringBooking[0].uid);
expect(oldBooking).toBeDefined();
expect(oldBooking?.status).toEqual("CANCELLED");
});
});
const oldBooking = await bookingsRepositoryFixture.getByUid(createdRecurringBooking[0].uid);
expect(oldBooking).toBeDefined();
expect(oldBooking?.status).toEqual("CANCELLED");
});
});
it("should get recurring booking recurrences after rescheduling one", async () => {
const recurringBookingUid = createdRecurringBooking[0].recurringBookingUid;
return request(app.getHttpServer())
.get(`/v2/bookings/${recurringBookingUid}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsRecurringBooking(responseBody.data)).toBe(true);
it("should get recurring booking recurrences after rescheduling one", async () => {
const recurringBookingUid = createdRecurringBooking[0].recurringBookingUid;
return request(app.getHttpServer())
.get(`/v2/bookings/${recurringBookingUid}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsRecurringBooking(responseBody.data)).toBe(true);
if (responseDataIsRecurringBooking(responseBody.data)) {
const data: RecurringBookingOutput_2024_08_13[] = responseBody.data;
expect(data.length).toEqual(4);
const cancelled = data.find((booking) => booking.status === "cancelled");
expect(cancelled).toBeDefined();
const rescheduledNew = data.find(
(booking) => booking.start === new Date(Date.UTC(2035, 0, 9, 14, 0, 0)).toISOString()
);
expect(rescheduledNew).toBeDefined();
createdRecurringBooking = data;
} else {
throw new Error(
"Invalid response data - expected recurring booking but received non array response"
);
}
});
if (responseDataIsRecurringBooking(responseBody.data)) {
const data: RecurringBookingOutput_2024_08_13[] = responseBody.data;
expect(data.length).toEqual(4);
const cancelled = data.find((booking) => booking.status === "cancelled");
expect(cancelled).toBeDefined();
const rescheduledNew = data.find(
(booking) => booking.start === new Date(Date.UTC(2035, 0, 9, 14, 0, 0)).toISOString()
);
expect(rescheduledNew).toBeDefined();
createdRecurringBooking = data;
} else {
throw new Error(
"Invalid response data - expected recurring booking but received non array response"
);
}
});
});
});
});
@@ -2385,95 +2462,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
});
});
describe("rescheduling booking with invalid booking responses", () => {
it("should reschedule", async () => {
const email = `user-bookings-attendee-${randomString(10)}@gmail.com`;
const username = `user-bookings-attendee-${randomString(10)}`;
const locale = "it";
const timeZone = "Europe/Rome";
const attendee = await userRepositoryFixture.create({
email,
username,
locale,
timeZone,
});
const booking = await bookingsRepositoryFixture.create({
uid: `booking-uid-${randomString(10)}`,
title: "booking title",
startTime: "2060-09-05T11:00:00.000Z",
endTime: "2060-09-05T12:00:00.000Z",
eventType: {
connect: {
id: eventTypeId,
},
},
status: "ACCEPTED",
metadata: {},
responses: "null",
user: {
connect: {
id: user.id,
},
},
attendees: {
create: {
email: email,
name: username,
timeZone,
locale,
},
},
});
const body: RescheduleBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2060, 0, 9, 14, 0, 0)).toISOString(),
reschedulingReason: "Flying to mars that day",
};
return request(app.getHttpServer())
.post(`/v2/bookings/${booking.uid}/reschedule`)
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const responseBody: RescheduleBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const data: BookingOutput_2024_08_13 = responseBody.data;
expect(data.reschedulingReason).toEqual(body.reschedulingReason);
expect(data.start).toEqual(body.start);
expect(data.end).toEqual(new Date(Date.UTC(2060, 0, 9, 15, 0, 0)).toISOString());
expect(data.rescheduledFromUid).toEqual(booking.uid);
expect(data.id).toBeDefined();
expect(data.uid).toBeDefined();
expect(data.hosts[0].id).toEqual(user.id);
expect(data.hosts[0].username).toEqual(user.username);
expect(data.hosts[0].email).toEqual(user.email);
expect(data.status).toEqual("accepted");
expect(data.eventTypeId).toEqual(booking.eventTypeId);
expect(data.attendees.length).toEqual(1);
expect(data.attendees[0]).toEqual({
name: attendee.username,
email: attendee.email,
timeZone: attendee.timeZone,
language: attendee.locale,
absent: false,
});
expect(data.bookingFieldsResponses).toEqual({
name: attendee.username,
email: attendee.email,
rescheduledReason: body.reschedulingReason,
guests: [],
});
await bookingsRepositoryFixture.deleteById(data.id);
await userRepositoryFixture.delete(attendee.id);
});
});
});
describe("create booking with attendee sms reminder", () => {
it("should not be able create a booking if attendee sms reminder is missing", async () => {
const body: CreateBookingInput_2024_08_13 = {
@@ -2559,146 +2547,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
});
});
describe("cant't reschedule cancelled booking", () => {
it("should not be able to reschedule cancelled booking", async () => {
const cancelledBooking = await bookingsRepositoryFixture.create({
status: "CANCELLED",
user: {
connect: {
id: user.id,
},
},
startTime: new Date(Date.UTC(2050, 0, 8, 13, 0, 0)),
endTime: new Date(Date.UTC(2050, 0, 8, 14, 0, 0)),
title: "peer coding lets goo",
uid: `cancelled-booking-${randomString()}`,
eventType: {
connect: {
id: eventTypeId,
},
},
location: "integrations:daily",
customInputs: {},
metadata: {},
responses: {
name: "Oldie",
email: "oldie@gmail.com",
},
attendees: {
create: {
email: "oldie@gmail.com",
name: "Oldie",
locale: "lv",
timeZone: "Europe/Rome",
},
},
});
const body: RescheduleBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2040, 0, 9, 14, 0, 0)).toISOString(),
reschedulingReason: "Flying to mars that day",
};
const response = await request(app.getHttpServer())
.post(`/v2/bookings/${cancelledBooking.uid}/reschedule`)
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(400);
expect(response.body.error.message).toEqual(
`Can't reschedule booking with uid=${cancelledBooking.uid} because it has been cancelled. Please provide uid of a booking that is not cancelled.`
);
await bookingsRepositoryFixture.deleteById(cancelledBooking.id);
});
it("should not be able to reschedule rescheduled booking", async () => {
const rescheduledBooking = await bookingsRepositoryFixture.create({
status: "CANCELLED",
rescheduled: true,
user: {
connect: {
id: user.id,
},
},
startTime: new Date(Date.UTC(2050, 0, 8, 13, 0, 0)),
endTime: new Date(Date.UTC(2050, 0, 8, 14, 0, 0)),
title: "peer coding lets goo",
uid: `cancelled-booking-${randomString()}`,
eventType: {
connect: {
id: eventTypeId,
},
},
location: "integrations:daily",
customInputs: {},
metadata: {},
responses: {
name: "Oldie",
email: "oldie@gmail.com",
},
attendees: {
create: {
email: "oldie@gmail.com",
name: "Oldie",
locale: "lv",
timeZone: "Europe/Rome",
},
},
});
const newBooking = await bookingsRepositoryFixture.create({
status: "ACCEPTED",
user: {
connect: {
id: user.id,
},
},
fromReschedule: rescheduledBooking.uid,
startTime: new Date(Date.UTC(2050, 0, 8, 13, 0, 0)),
endTime: new Date(Date.UTC(2050, 0, 8, 14, 0, 0)),
title: "peer coding lets goo",
uid: `new-booking-${randomString()}`,
eventType: {
connect: {
id: eventTypeId,
},
},
location: "integrations:daily",
customInputs: {},
metadata: {},
responses: {
name: "Oldie",
email: "oldie@gmail.com",
},
attendees: {
create: {
email: "oldie@gmail.com",
name: "Oldie",
locale: "lv",
timeZone: "Europe/Rome",
},
},
});
const body: RescheduleBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2040, 0, 9, 14, 0, 0)).toISOString(),
reschedulingReason: "Flying to mars that day",
};
const response = await request(app.getHttpServer())
.post(`/v2/bookings/${rescheduledBooking.uid}/reschedule`)
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(400);
expect(response.body.error.message).toEqual(
`Can't reschedule booking with uid=${rescheduledBooking.uid} because it has been cancelled and rescheduled already to booking with uid=${newBooking.uid}. You probably want to reschedule ${newBooking.uid} instead by passing it within the request URL.`
);
await bookingsRepositoryFixture.deleteById(rescheduledBooking.id);
await bookingsRepositoryFixture.deleteById(newBooking.id);
});
});
describe("cant't cancel already cancelled booking", () => {
it("should not be able to cancel alraedy cancelled booking", async () => {
const cancelledBooking = await bookingsRepositoryFixture.create({
@@ -2752,6 +2600,56 @@ describe("Bookings Endpoints 2024-08-13", () => {
});
});
describe("cancellation reason and cancelled by returned if null in database", () => {
it("should return cancellation reason and cancelled by if null in database", async () => {
const dbBooking = await bookingsRepositoryFixture.create({
uid: randomString(10),
title: "test",
startTime: new Date(Date.UTC(2040, 0, 13, 16, 0, 0)),
endTime: new Date(Date.UTC(2040, 0, 13, 17, 0, 0)),
eventType: {
connect: {
id: eventTypeId,
},
},
user: {
connect: {
id: user.id,
},
},
metadata: {},
responses: {
name: "tester",
email: "tester@example.com",
guests: [],
},
cancellationReason: null,
cancelledBy: null,
});
const response = await request(app.getHttpServer())
.get(`/v2/bookings/${dbBooking.uid}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200);
const responseBody: GetBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsBooking(responseBody.data)).toBe(true);
if (responseDataIsBooking(responseBody.data)) {
const data: BookingOutput_2024_08_13 = responseBody.data;
expect(data.id).toBeDefined();
expect(data.cancellationReason).toBe("");
expect(data.cancelledByEmail).toBe("");
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibly recurring bookings"
);
}
});
});
describe("calendar events", () => {
beforeEach(() => {
jest.restoreAllMocks();
@@ -544,12 +544,18 @@ export class InputBookingsService_2024_08_13 {
Object.assign(newRequest, { userId, ...oAuthClientParams, platformBookingLocation: location });
newRequest.body = {
...bodyTransformed,
rescheduledBy: request.body.rescheduledBy,
noEmail: !oAuthClientParams.arePlatformEmailsEnabled,
creationSource: CreationSource.API_V2,
};
} else {
Object.assign(newRequest, { userId, platformBookingLocation: location });
newRequest.body = { ...bodyTransformed, noEmail: false, creationSource: CreationSource.API_V2 };
newRequest.body = {
...bodyTransformed,
noEmail: false,
creationSource: CreationSource.API_V2,
rescheduledBy: request.body.rescheduledBy,
};
}
return newRequest as unknown as BookingRequest;
@@ -25,7 +25,6 @@ import type {
ReassignBookingOutput_2024_08_13,
} from "@calcom/platform-types";
import type { Booking, BookingSeat } from "@calcom/prisma/client";
import { BookingStatus } from "@calcom/prisma/enums";
export const bookingResponsesSchema = z
.object({
@@ -105,14 +104,8 @@ export class OutputBookingsService_2024_08_13 {
);
const metadata = safeParse(bookingMetadataSchema, databaseBooking.metadata, defaultBookingMetadata);
const location = metadata?.videoCallUrl || databaseBooking.location;
const rescheduledToInfo = databaseBooking.rescheduled
? await this.getRescheduledToInfo(databaseBooking.uid)
: undefined;
const rescheduledToUid = rescheduledToInfo?.uid;
const rescheduledByEmail = databaseBooking.rescheduled
? rescheduledToInfo?.rescheduledBy
: databaseBooking.rescheduledBy;
const rescheduledToUid = await this.getRescheduledToUid(databaseBooking);
const rescheduledByEmail = await this.getRescheduledByEmail(databaseBooking);
const booking = {
id: databaseBooking.id,
@@ -121,9 +114,8 @@ export class OutputBookingsService_2024_08_13 {
description: databaseBooking.description,
hosts: [this.getHost(databaseBooking.user)],
status: databaseBooking.status.toLowerCase(),
cancellationReason:
databaseBooking.status === "CANCELLED" ? databaseBooking.cancellationReason : undefined,
cancelledByEmail: databaseBooking.status === "CANCELLED" ? databaseBooking.cancelledBy : undefined,
cancellationReason: databaseBooking.cancellationReason || "",
cancelledByEmail: databaseBooking.cancelledBy || "",
reschedulingReason: bookingResponses?.rescheduledReason,
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
start: databaseBooking.startTime,
@@ -168,6 +160,23 @@ export class OutputBookingsService_2024_08_13 {
};
}
private async getRescheduledToUid(databaseBooking: DatabaseBooking) {
if (!databaseBooking.rescheduled) return undefined;
const rescheduledTo = await this.bookingsRepository.getByFromReschedule(databaseBooking.uid);
return rescheduledTo?.uid;
}
private async getRescheduledByEmail(databaseBooking: DatabaseBooking) {
if (databaseBooking.rescheduled) {
return databaseBooking.rescheduledBy;
}
if (databaseBooking.fromReschedule) {
const previousBooking = await this.bookingsRepository.getByUid(databaseBooking.fromReschedule);
return previousBooking?.rescheduledBy ?? databaseBooking.rescheduledBy;
}
return databaseBooking.rescheduledBy;
}
getUserDefinedMetadata(databaseMetadata: DatabaseMetadata) {
if (databaseMetadata === null) return {};
@@ -227,10 +236,8 @@ export class OutputBookingsService_2024_08_13 {
description: databaseBooking.description,
hosts: [this.getHost(databaseBooking.user)],
status: databaseBooking.status.toLowerCase(),
cancellationReason:
databaseBooking.status === BookingStatus.CANCELLED ? databaseBooking.cancellationReason : undefined,
cancelledByEmail:
databaseBooking.status === BookingStatus.CANCELLED ? databaseBooking.cancelledBy : undefined,
cancellationReason: databaseBooking.cancellationReason || "",
cancelledByEmail: databaseBooking.cancelledBy || "",
reschedulingReason: bookingResponses?.rescheduledReason,
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
start: databaseBooking.startTime,
@@ -284,14 +291,8 @@ export class OutputBookingsService_2024_08_13 {
const duration = dateEnd.diff(dateStart, "minutes").minutes;
const metadata = safeParse(bookingMetadataSchema, databaseBooking.metadata, defaultBookingMetadata);
const location = metadata?.videoCallUrl || databaseBooking.location;
const rescheduledToInfo = databaseBooking.rescheduled
? await this.getRescheduledToInfo(databaseBooking.uid)
: undefined;
const rescheduledToUid = rescheduledToInfo?.uid;
const rescheduledByEmail = databaseBooking.rescheduled
? rescheduledToInfo?.rescheduledBy
: databaseBooking.rescheduledBy;
const rescheduledToUid = await this.getRescheduledToUid(databaseBooking);
const rescheduledByEmail = await this.getRescheduledByEmail(databaseBooking);
const booking = {
id: databaseBooking.id,
@@ -300,8 +301,7 @@ export class OutputBookingsService_2024_08_13 {
description: databaseBooking.description,
hosts: [this.getHost(databaseBooking.user)],
status: databaseBooking.status.toLowerCase(),
cancellationReason:
databaseBooking.status === BookingStatus.CANCELLED ? databaseBooking.cancellationReason : undefined,
cancellationReason: databaseBooking.cancellationReason || "",
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
start: databaseBooking.startTime,
end: databaseBooking.endTime,
@@ -428,8 +428,7 @@ export class OutputBookingsService_2024_08_13 {
description: databaseBooking.description,
hosts: [this.getHost(databaseBooking.user)],
status: databaseBooking.status.toLowerCase(),
cancellationReason:
databaseBooking.status === BookingStatus.CANCELLED ? databaseBooking.cancellationReason : undefined,
cancellationReason: databaseBooking.cancellationReason || "",
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
start: databaseBooking.startTime,
end: databaseBooking.endTime,
@@ -45,8 +45,8 @@ export function ShellMainAppDir(props: LayoutProps) {
className={classNames(
props.backPath
? "relative"
: "pwa:bottom-[max(7rem,_calc(5rem_+_env(safe-area-inset-bottom)))] fixed bottom-32 z-40 ltr:right-4 rtl:left-4 md:z-auto md:ltr:right-0 md:rtl:left-0",
"flex-shrink-0 [-webkit-app-region:no-drag] md:relative md:bottom-auto md:right-auto"
: "pwa:bottom-[max(7rem,_calc(5rem_+_env(safe-area-inset-bottom)))] fixed bottom-20 z-40 ltr:right-4 rtl:left-4 md:z-auto md:ltr:right-0 md:rtl:left-0",
"shrink-0 [-webkit-app-region:no-drag] md:relative md:bottom-auto md:right-auto"
)}>
{props.CTA}
</div>
@@ -97,7 +97,7 @@ function Field({
data-testid={`${hookFieldNamespace}.label`}
disabled={!!router}
label="Label"
className="flex-grow"
className="grow"
placeholder={t("this_is_what_your_users_would_see")}
defaultValue={label || routerField?.label || "Field"}
required
@@ -189,7 +189,7 @@ function Field({
/>
</div>
{["select", "multiselect"].includes(fieldType) ? (
<div className="bg-muted w-full rounded-[10px] p-2">
<div className="bg-cal-muted w-full rounded-[10px] p-2">
<Label className="text-subtle">{t("options")}</Label>
<MultiOptionInput
fieldArrayName={`${hookFieldNamespace}.options`}
@@ -319,7 +319,7 @@ const FormEdit = ({
) : (
<div className="w-full py-4 lg:py-8">
{/* TODO: remake empty screen for V3 */}
<div className="border-sublte bg-muted flex flex-col items-center gap-6 rounded-xl border p-11">
<div className="border-sublte bg-cal-muted flex flex-col items-center gap-6 rounded-xl border p-11">
<div className="mb-3 grid">
{/* Icon card - Top */}
<div className="bg-default border-subtle z-30 col-start-1 col-end-1 row-start-1 row-end-1 h-10 w-10 transform rounded-md border shadow-sm">
@@ -122,7 +122,7 @@ function Page({ form }: { form: RoutingFormWithResponseCount }) {
/>
</div>
{salesforceActionEnabled ? (
<div className="bg-muted mt-1 rounded-xl p-2">
<div className="bg-cal-muted mt-1 rounded-xl p-2">
{form.team && (
<>
<div className="bg-default mt-2 rounded-xl px-2 py-2">
@@ -307,7 +307,7 @@ const WeightedAttributesSelector = ({
}}
/>
</div>
<div className="bg-muted mt-1 rounded-xl p-2">
<div className="bg-cal-muted mt-1 rounded-xl p-2">
{attributeIdForWeights ? (
<SelectField
size="sm"
@@ -652,21 +652,21 @@ const Route = ({
<div
className={classNames(
"cal-query-builder-card w-full gap-2 p-2",
route.isFallback && "bg-muted border-subtle rounded-xl border"
route.isFallback && "bg-cal-muted border-subtle rounded-xl border"
)}>
<div className="cal-query-builder w-full ">
{formFieldsQueryBuilder}
<div>
{route.isFallback ? (
<div className="flex w-full flex-col gap-2 text-sm lg:flex-row">
<div className="flex flex-grow items-center gap-2">
{/* <div className="flex flex-grow-0 whitespace-nowrap">
<div className="flex grow items-center gap-2">
{/* <div className="flex grow-0 whitespace-nowrap">
<span>{t("send_booker_to")}</span>
</div> */}
<Select
size="sm"
isDisabled={disabled}
className="data-testid-select-routing-action block w-full flex-grow"
className="data-testid-select-routing-action block w-full grow"
required
value={RoutingPages.find((page) => page.value === route.action?.type)}
onChange={(item) => {
@@ -695,7 +695,7 @@ const Route = ({
required
disabled={disabled}
name="customPageMessage"
className="border-default flex flex-grow lg:w-fit"
className="border-default flex grow lg:w-fit"
style={{
minHeight: "38px",
}}
@@ -708,8 +708,8 @@ const Route = ({
<TextField
disabled={disabled}
name="externalRedirectUrl"
className="border-default flex flex-grow text-sm"
containerClassName="flex-grow"
className="border-default flex grow text-sm"
containerClassName="grow"
type="url"
required
labelSrOnly
@@ -720,7 +720,7 @@ const Route = ({
placeholder="https://example.com"
/>
) : (
<div className="flex-grow">
<div className="grow">
<Select
size="sm"
required
@@ -758,8 +758,8 @@ const Route = ({
<>
<TextField
disabled={disabled}
className="border-default flex w-full flex-grow text-sm"
containerClassName="flex-grow mt-2"
className="border-default flex w-full grow text-sm"
containerClassName="grow mt-2"
addOnLeading={eventTypePrefix}
required
value={customEventTypeSlug}
@@ -796,12 +796,12 @@ const Route = ({
</div>
<span className="text-emphasis ml-2 text-sm font-medium">Send booker to</span>
</div>
<div className="bg-muted flex w-full flex-col gap-2 rounded-xl p-2 text-sm lg:flex-row">
<div className="flex flex-grow items-center gap-2">
<div className="bg-cal-muted flex w-full flex-col gap-2 rounded-xl p-2 text-sm lg:flex-row">
<div className="flex grow items-center gap-2">
<Select
size="sm"
isDisabled={disabled}
className="data-testid-select-routing-action block w-full flex-grow"
className="data-testid-select-routing-action block w-full grow"
required
value={RoutingPages.find((page) => page.value === route.action?.type)}
onChange={(item) => {
@@ -830,7 +830,7 @@ const Route = ({
required
disabled={disabled}
name="customPageMessage"
className="border-default flex flex-grow lg:w-fit"
className="border-default flex grow lg:w-fit"
style={{
minHeight: "38px",
}}
@@ -844,8 +844,8 @@ const Route = ({
size="sm"
disabled={disabled}
name="externalRedirectUrl"
className="border-default flex flex-grow text-sm"
containerClassName="flex-grow"
className="border-default flex grow text-sm"
containerClassName="grow"
type="url"
required
labelSrOnly
@@ -856,7 +856,7 @@ const Route = ({
placeholder="https://example.com"
/>
) : (
<div className="flex-grow">
<div className="grow">
<Select
size="sm"
required
@@ -894,8 +894,8 @@ const Route = ({
<>
<TextField
disabled={disabled}
className="border-default flex w-full flex-grow text-sm"
containerClassName="flex-grow mt-2"
className="border-default flex w-full grow text-sm"
containerClassName="grow mt-2"
addOnLeading={eventTypePrefix}
required
value={customEventTypeSlug}
@@ -177,7 +177,7 @@ function RoutingForm({ form, profile, ...restProps }: Props) {
<>
<div className={classNames("mx-auto my-0 max-w-3xl", isEmbed ? "" : "md:my-24")}>
<div className="w-full max-w-4xl ltr:mr-2 rtl:ml-2">
<div className="main border-booker md:border-booker-width dark:bg-muted bg-default mx-0 rounded-md p-4 py-6 sm:-mx-4 sm:px-8 ">
<div className="main border-booker md:border-booker-width dark:bg-cal-muted bg-default mx-0 rounded-md p-4 py-6 sm:-mx-4 sm:px-8 ">
<Toaster position="bottom-right" />
<form onSubmit={handleOnSubmit}>
@@ -160,7 +160,7 @@ export default function RoutingForms({ appUrl }: { appUrl: string }) {
background="/tips/routing-forms"
isParentLoading={<SkeletonLoaderTeamList />}
buttons={
<div className="space-y-2 rtl:space-x-reverse sm:space-x-2">
<div className="stack-y-2 rtl:space-x-reverse sm:space-x-2">
<ButtonGroup>
<Button color="primary" href={`${WEBAPP_URL}/settings/teams/new`}>
{t("upgrade")}
@@ -278,7 +278,7 @@ export default function RoutingForms({ appUrl }: { appUrl: string }) {
action="edit"
routingForm={form}
color="minimal"
className="!flex"
className="flex!"
StartIcon="pencil">
{t("edit")}
</FormAction>
@@ -24,7 +24,7 @@ export function AvailabilityScheduleSkeleton() {
</div>
{/* Action buttons */}
<div className="relative flex-shrink-0 md:relative md:bottom-auto md:right-auto">
<div className="relative shrink-0 md:relative md:bottom-auto md:right-auto">
{/* Desktop buttons - Set to Default, Delete, and Save */}
<div className="flex items-center justify-end">
{/* Set to Default - desktop only */}
@@ -99,14 +99,14 @@ export function AvailabilityScheduleSkeleton() {
<SkeletonText className="ml-2 h-4 w-4 rounded-full" /> {/* Info icon */}
</div>
<SkeletonText className="mb-4 mt-1 h-4 w-full max-w-md text-sm" /> {/* Description */}
<div className="space-y-2">
<div className="stack-y-2">
<SkeletonButton className="h-9 w-36 rounded-md" /> {/* Add an override button */}
</div>
</div>
</div>
{/* Sidebar - only visible on desktop */}
<div className="min-w-40 col-span-3 hidden space-y-2 md:block lg:col-span-1 xl:w-[25%]">
<div className="min-w-40 col-span-3 hidden stack-y-2 md:block lg:col-span-1 xl:w-[25%]">
<div className="xl:max-w-80 w-full pr-4 sm:ml-0 sm:mr-36 sm:p-0">
<div>
<SkeletonText className="mb-1 h-4 w-24 text-sm" /> {/* Timezone */}
@@ -8,7 +8,7 @@ import { Icon } from "@calcom/ui/components/icon";
export default function TroubleshooterLayout({ children }: { children: React.ReactNode }) {
return (
<>
<div className="flex flex-1 [&>*]:flex-1">
<div className="flex flex-1 *:flex-1">
<ErrorBoundary>
<Suspense fallback={<Icon name="loader" />}>{children}</Suspense>
</ErrorBoundary>
@@ -32,8 +32,8 @@ export const EventTypeEditPageSkeleton = () => {
<Shell subtitle={<SkeletonText className="hidden h-4 w-24" />} afterHeading={backButtonSkeleton}>
<div className="flex flex-col xl:flex-row xl:space-x-6">
<div className="hidden xl:block">
<div className="primary-navigation w-64 flex-shrink-0">
<div className="flex flex-col space-y-1">
<div className="primary-navigation w-64 shrink-0">
<div className="flex flex-col stack-y-1">
{/* Tab navigation items */}
{Array.from({ length: 8 }).map((_, index) => (
<div
@@ -58,14 +58,14 @@ export const EventTypeEditPageSkeleton = () => {
<SkeletonText className="h-8 w-full max-w-md" />
</div>
<div className="mb-6 space-y-6">
<div className="mb-6 stack-y-6">
{/* Form fields */}
<div className="flex flex-col space-y-2">
<div className="flex flex-col stack-y-2">
<SkeletonText className="h-4 w-32" />
<SkeletonText className="h-10 w-full" />
</div>
<div className="flex flex-col space-y-2">
<div className="flex flex-col stack-y-2">
<SkeletonText className="h-4 w-40" />
<div className="flex items-center">
<SkeletonText className="h-10 w-full" />
@@ -73,12 +73,12 @@ export const EventTypeEditPageSkeleton = () => {
</div>
</div>
<div className="flex flex-col space-y-2">
<div className="flex flex-col stack-y-2">
<SkeletonText className="h-4 w-36" />
<SkeletonText className="h-24 w-full" />
</div>
<div className="flex flex-col space-y-2">
<div className="flex flex-col stack-y-2">
<SkeletonText className="h-4 w-28" />
<div className="flex items-center">
<SkeletonText className="h-10 w-full" />
@@ -86,7 +86,7 @@ export const EventTypeEditPageSkeleton = () => {
</div>
</div>
<div className="flex flex-col space-y-2">
<div className="flex flex-col stack-y-2">
<SkeletonText className="h-4 w-32" />
<SkeletonText className="h-10 w-full" />
</div>
@@ -38,7 +38,7 @@ export default function UpgradeTipWrapper({ children }: { children: React.ReactN
features={features}
background="/tips/insights"
buttons={
<div className="space-y-2 rtl:space-x-reverse sm:space-x-2">
<div className="stack-y-2 rtl:space-x-reverse sm:space-x-2">
<ButtonGroup>
<Button color="primary" href={`${WEBAPP_URL}/settings/teams/new`}>
{t("create_team")}
@@ -109,7 +109,7 @@ const PaymentPage: FC<PaymentPageProps> = (props) => {
aria-modal="true"
aria-labelledby="modal-headline">
<div>
<div className="bg-success mx-auto flex h-12 w-12 items-center justify-center rounded-full">
<div className="bg-cal-success mx-auto flex h-12 w-12 items-center justify-center rounded-full">
<PayIcon currency={paymentAppData.currency} className="h-8 w-8 text-green-600" />
</div>
@@ -15,7 +15,7 @@ export default function Loading() {
<SkeletonText className="h-5 w-32" />
</div>
<div className="mb-6 space-y-2">
<div className="mb-6 stack-y-2">
<SkeletonText className="h-7 w-3/4" />
<SkeletonText className="h-7 w-1/2" />
</div>
@@ -18,7 +18,7 @@ export default function AdminLayoutAppDirClient({ children }: AdminLayoutProps)
const isAppsPage = pathname?.startsWith("/settings/admin/apps");
return (
<div className="divide-subtle bg-default mx-auto flex max-w-4xl flex-row divide-y">
<div className={isAppsPage ? "min-w-0" : "flex flex-1 [&>*]:flex-1"}>
<div className={isAppsPage ? "min-w-0" : "flex flex-1 *:flex-1"}>
<ErrorBoundary>{children}</ErrorBoundary>
</div>
</div>
@@ -35,7 +35,7 @@ const sampleBookingsByHourStats = [
export default function BookingsByHourPlayground() {
const { t } = useLocale();
return (
<div className="space-y-6 p-6">
<div className="stack-y-6 p-6">
<div className="mb-6">
<h1 className="text-3xl font-bold">Bookings by Hour Playground</h1>
<p className="mt-2 text-gray-600">
@@ -56,7 +56,7 @@ const sampleRoutingFunnelData = [
export default function RoutingFunnelPlayground() {
const { t } = useLocale();
return (
<div className="space-y-6 p-6">
<div className="stack-y-6 p-6">
<div className="mb-6">
<h1 className="text-3xl font-bold">Routing Funnel Playground</h1>
<p className="mt-2 text-gray-600">
@@ -512,7 +512,7 @@ function ScenarioCard({ scenario }: { scenario: Scenario }) {
export default function WeeklyCalendarPlayground() {
return (
<div className="space-y-8 p-6">
<div className="stack-y-8 p-6">
<div>
<h1 className="text-3xl font-bold">Weekly Calendar Playground</h1>
<p className="text-subtle mt-2">
@@ -522,7 +522,7 @@ export default function WeeklyCalendarPlayground() {
</div>
{/* Grid View of All Scenarios */}
<div className="space-y-8">
<div className="stack-y-8">
<h2 className="mb-4 text-2xl font-bold">All Scenarios (Side-by-Side)</h2>
{scenarios.map((scenario, index) => (
<ScenarioCard key={index} scenario={scenario} />
@@ -532,7 +532,7 @@ export default function WeeklyCalendarPlayground() {
{/* Testing Checklist */}
<div className="border-subtle rounded-lg border p-6">
<h2 className="mb-4 text-xl font-bold">Testing Checklist</h2>
<ul className="text-subtle space-y-2 text-sm">
<ul className="text-subtle stack-y-2 text-sm">
<li>✓ Visual appearance matches expectations (80% width with 8% cascading offsets)</li>
<li>✓ Hover behavior works smoothly - hovered event appears topmost</li>
<li>✓ No visual glitches with 3+ overlapping events</li>
@@ -181,6 +181,7 @@ export interface SettingsPermissions {
canViewRoles?: boolean;
canViewOrganizationBilling?: boolean;
canUpdateOrganization?: boolean;
canViewAttributes?: boolean;
}
const useTabs = ({
@@ -211,7 +212,7 @@ const useTabs = ({
(child) => permissions?.canUpdateOrganization || !organizationAdminKeys.includes(child.name)
);
if (permissions?.canUpdateOrganization) {
if (permissions?.canViewAttributes) {
newArray.splice(4, 0, {
name: "attributes",
href: "/settings/organizations/attributes",
@@ -446,13 +447,13 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
)}
</button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-0.5" id={`team-content-${team.id}`}>
<CollapsibleContent className="stack-y-0.5" id={`team-content-${team.id}`}>
{team.accepted && (
<VerticalTabItem
name={t("profile")}
href={`/settings/teams/${team.id}/profile`}
textClassNames="px-3 text-emphasis font-medium text-sm"
className="me-5 h-7 w-auto !px-2"
className="px-2! me-5 h-7 w-auto"
disableChevron
/>
)}
@@ -460,7 +461,7 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
name={t("members")}
href={`/settings/teams/${team.id}/members`}
textClassNames="px-3 text-emphasis font-medium text-sm"
className="me-5 h-7 w-auto !px-2"
className="px-2! me-5 h-7 w-auto"
disableChevron
/>
{/* Show roles only for sub-teams with PBAC-enabled parent */}
@@ -481,7 +482,7 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
name={t("appearance")}
href={`/settings/teams/${team.id}/appearance`}
textClassNames="px-3 text-emphasis font-medium text-sm"
className="me-5 h-7 w-auto !px-2"
className="px-2! me-5 h-7 w-auto"
disableChevron
/>
{/* Hide if there is a parent ID */}
@@ -491,7 +492,7 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
name={t("billing")}
href={`/settings/teams/${team.id}/billing`}
textClassNames="px-3 text-emphasis font-medium text-sm"
className="me-5 h-7 w-auto !px-2"
className="px-2! me-5 h-7 w-auto"
disableChevron
/>
</>
@@ -500,7 +501,7 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
name={t("settings")}
href={`/settings/teams/${team.id}/settings`}
textClassNames="px-3 text-emphasis font-medium text-sm"
className="me-5 h-7 w-auto !px-2"
className="px-2! me-5 h-7 w-auto"
disableChevron
/>
</>
@@ -578,7 +579,7 @@ const SettingsSidebarContainer = ({
<nav
style={{ maxHeight: `calc(100vh - ${bannersHeight}px)`, top: `${bannersHeight}px` }}
className={classNames(
"no-scrollbar bg-muted fixed bottom-0 left-0 top-0 z-20 flex max-h-screen w-56 flex-col space-y-1 overflow-x-hidden overflow-y-scroll px-2 pb-3 transition-transform max-lg:z-10 lg:sticky lg:flex",
"no-scrollbar bg-cal-muted stack-y-1 fixed bottom-0 left-0 top-0 z-20 flex max-h-screen w-56 flex-col overflow-x-hidden overflow-y-scroll px-2 pb-3 transition-transform max-lg:z-10 lg:sticky lg:flex",
className,
navigationIsOpenedOnMobile
? "translate-x-0 opacity-100"
@@ -592,7 +593,7 @@ const SettingsSidebarContainer = ({
<React.Fragment key={tab.href}>
{!["teams", "other_teams"].includes(tab.name) && (
<React.Fragment key={tab.href}>
<div className={`${!tab.children?.length ? "!mb-3" : ""}`}>
<div className={`${!tab.children?.length ? "mb-3!" : ""}`}>
<div className="[&[aria-current='page']]:bg-emphasis [&[aria-current='page']]:text-emphasis text-default group flex h-7 w-full flex-row items-center rounded-md px-2 text-sm font-medium leading-none">
{tab && tab.icon && (
<Icon
@@ -618,7 +619,7 @@ const SettingsSidebarContainer = ({
</Skeleton>
</div>
</div>
<div className="my-3 space-y-px">
<div className="stack-y-px">
{tab.children?.map((child, index) => (
<div key={child.href} className="flex items-start gap-2">
<VerticalTabItem
@@ -626,8 +627,8 @@ const SettingsSidebarContainer = ({
isExternalLink={child.isExternalLink}
href={child.href || "/"}
textClassNames="text-emphasis font-medium text-sm"
className={`h-7 w-fit !px-2 ${
tab.children && index === tab.children?.length - 1 && "!mb-3"
className={`px-2! h-7 w-fit ${
tab.children && index === tab.children?.length - 1 && "mb-3!"
}`}
disableChevron
/>
@@ -668,7 +669,7 @@ const SettingsSidebarContainer = ({
name={t("add_a_team")}
href={`${WEBAPP_URL}/settings/teams/new`}
textClassNames="px-3 items-center mt-2 text-emphasis font-medium text-sm"
className="me-5 h-7 w-auto !px-2"
className="px-2! me-5 h-7 w-auto"
icon="plus"
disableChevron
/>
@@ -766,20 +767,20 @@ const SettingsSidebarContainer = ({
</button>
</CollapsibleTrigger>
<CollapsibleContent
className="space-y-0.5"
className="stack-y-0.5"
id={`other-team-content-${otherTeam.id}`}>
<VerticalTabItem
name={t("profile")}
href={`/settings/organizations/teams/other/${otherTeam.id}/profile`}
textClassNames="px-3 text-emphasis font-medium text-sm"
className="me-5 h-7 w-auto !px-2"
className="px-2! me-5 h-7 w-auto"
disableChevron
/>
<VerticalTabItem
name={t("members")}
href={`/settings/organizations/teams/other/${otherTeam.id}/members`}
textClassNames="px-3 text-emphasis font-medium text-sm"
className="me-5 h-7 w-auto !px-2"
className="px-2! me-5 h-7 w-auto"
disableChevron
/>
<>
@@ -812,7 +813,7 @@ const MobileSettingsContainer = (props: { onSideContainerOpen?: () => void }) =>
return (
<>
<nav className="bg-muted border-muted sticky top-0 z-20 flex w-full items-center justify-between border-b px-2 py-2 sm:relative lg:hidden">
<nav className="bg-cal-muted border-muted sticky top-0 z-20 flex w-full items-center justify-between border-b px-2 py-2 sm:relative lg:hidden">
<div className="flex items-center space-x-3">
<Button StartIcon="menu" color="minimal" variant="icon" onClick={props.onSideContainerOpen}>
<span className="sr-only">{t("show_navigation")}</span>
@@ -881,7 +882,7 @@ export default function SettingsLayoutAppDirClient({
TopNavContainer={
<MobileSettingsContainer onSideContainerOpen={() => setSideContainerOpen(!sideContainerOpen)} />
}>
<div className="flex flex-1 [&>*]:flex-1">
<div className="*:flex-1 flex flex-1">
<div
className={classNames("mx-auto max-w-full justify-center lg:max-w-3xl", rest.containerClassName)}>
<ErrorBoundary>{children}</ErrorBoundary>
@@ -48,6 +48,7 @@ export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
let canViewRoles = false;
let canViewOrganizationBilling = false;
let canUpdateOrganization = false;
let canViewAttributes = false;
const orgId = session?.user?.profile?.organizationId ?? session?.user.org?.id;
// For now we only grab organization features but it would be nice to fetch these on the server side for specific team feature flags
@@ -66,9 +67,10 @@ export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
if (isPbacEnabled) {
// Only fetch and apply PBAC permissions if the feature is enabled
const [rolePermissions, organizationPermissions] = await Promise.all([
const [rolePermissions, organizationPermissions, attributesPermissions] = await Promise.all([
getCachedResourcePermissions(userId, orgId, Resource.Role),
getCachedResourcePermissions(userId, orgId, Resource.Organization),
getCachedResourcePermissions(userId, orgId, Resource.Attributes),
]);
// Check if user has permission to read roles
@@ -77,11 +79,14 @@ export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
const orgActions = PermissionMapper.toActionMap(organizationPermissions, Resource.Organization);
canViewOrganizationBilling = orgActions[CustomAction.ManageBilling] ?? isOrgAdminOrOwner;
canUpdateOrganization = orgActions[CrudAction.Update] ?? isOrgAdminOrOwner;
const attributesActions = PermissionMapper.toActionMap(attributesPermissions, Resource.Attributes);
canViewAttributes = attributesActions[CrudAction.Read] ?? isOrgAdminOrOwner;
} else {
// Fall back to legacy permissions when PBAC is not enabled or features not loaded
canViewRoles = features ? isOrgAdminOrOwner : false;
canViewOrganizationBilling = isOrgAdminOrOwner;
canUpdateOrganization = isOrgAdminOrOwner;
canViewAttributes = isOrgAdminOrOwner;
}
}
@@ -90,7 +95,7 @@ export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
<SettingsLayoutAppDirClient
{...props}
teamFeatures={teamFeatures ?? {}}
permissions={{ canViewRoles, canViewOrganizationBilling, canUpdateOrganization }}
permissions={{ canViewRoles, canViewOrganizationBilling, canUpdateOrganization, canViewAttributes }}
/>
</>
);
@@ -87,7 +87,7 @@ export function AdvancedPermissionGroup({
};
return (
<div className="bg-muted border-subtle mb-2 rounded-xl border">
<div className="bg-cal-muted border-subtle mb-2 rounded-xl border">
<button
type="button"
className="flex cursor-pointer items-center justify-between gap-1.5 p-4"
@@ -56,7 +56,7 @@ export function PbacOptInModal({ open, onOpenChange, revalidateRolesPath }: Pbac
</div>
</div>
<div className="bg-subtle w-full space-y-px overflow-hidden rounded-xl border border-gray-200">
<div className="bg-subtle border-subtle w-full stack-y-px overflow-hidden rounded-xl border">
<div className="bg-default flex gap-3 p-3">
<div className="bg-subtle flex h-8 w-8 shrink-0 items-center justify-center rounded-md">
<Icon name="shield-check" className="text-subtle h-5 w-5" />
@@ -33,7 +33,7 @@ export default function RoleColorPicker({ value, onChange, disabled }: RoleColor
type="button"
onClick={handleColorSquareClick}
disabled={disabled}
className="bg-default text-default border-default hover:bg-muted hover:text-emphasis focus:bg-subtle focus-visible:shadow-outline-gray-focused shadow-outline-gray-rested enabled:hover:shadow-outline-gray-hover enabled:active:shadow-outline-gray-active flex h-8 w-8 items-center justify-center gap-2 rounded-[10px] border text-sm transition-shadow duration-200 focus-visible:outline-none focus-visible:ring-0 disabled:cursor-not-allowed disabled:opacity-50">
className="bg-default text-default border-default hover:bg-cal-muted hover:text-emphasis focus:bg-subtle focus-visible:shadow-outline-gray-focused shadow-outline-gray-rested enabled:hover:shadow-outline-gray-hover enabled:active:shadow-outline-gray-active flex h-8 w-8 items-center justify-center gap-2 rounded-[10px] border text-sm transition-shadow duration-200 focus-visible:outline-none focus-visible:ring-0 disabled:cursor-not-allowed disabled:opacity-50">
<div
className="h-5 w-5 rounded border shadow-sm"
style={{ backgroundColor: value }}
@@ -183,7 +183,7 @@ export function RoleSheet({
</SheetTitle>
</SheetHeader>
<Form form={form} handleSubmit={onSubmit}>
<div className="space-y-4 py-5">
<div className="stack-y-4 py-5">
<div className="flex items-end justify-end gap-2">
<div className="flex-1">
<TextField
@@ -203,7 +203,7 @@ export function RoleSheet({
<div className="">
{isAdvancedMode ? (
<div className="space-y-4">
<div className="stack-y-4">
<div>
<div className="mb-2 flex items-center justify-between">
<Label className="mb-0">{t("permissions")}</Label>
@@ -239,7 +239,7 @@ export function RoleSheet({
))}{" "}
</div>
) : (
<div className="bg-muted rounded-xl p-1">
<div className="bg-cal-muted rounded-xl p-1">
<div className="flex items-center justify-between px-3 py-2">
<Label>{t("permissions")}</Label>
<div className="flex items-center gap-2">
@@ -71,7 +71,7 @@ export function RolesList({
return (
<>
<div className="mt-4">
<div className="bg-muted border-muted flex flex-col rounded-xl border p-[1px]">
<div className="bg-cal-muted border-muted flex flex-col rounded-xl border p-px">
{/* Roles list header */}
<div className="px-5 py-4">
<h2 className="text-default text-sm font-semibold leading-none">{t("role")}</h2>
@@ -7,7 +7,7 @@ export const SkeletonLoader = () => {
const { t } = useLocale();
return (
<div className="mt-4">
<div className="bg-muted border-muted flex flex-col rounded-xl border p-[1px]">
<div className="bg-cal-muted border-muted flex flex-col rounded-xl border p-px">
{/* Roles list header */}
<div className="px-5 py-4">
<h2 className="text-default text-sm font-semibold leading-none">{t("role")}</h2>
@@ -8,7 +8,7 @@ export default async function Layout({ children }: { children: React.ReactNode }
const t = await getTranslate();
return (
<SettingsLayoutAppDirClient containerClassName="lg:max-w-screen-2xl">
<SettingsLayoutAppDirClient containerClassName="lg:max-w-(--breakpoint-2xl)">
<SettingsHeader
title={t("organization_members")}
description={t("organization_description")}
@@ -5,7 +5,7 @@ import { headers, cookies } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { Resource, CustomAction } from "@calcom/features/pbac/domain/types/permission-registry";
import { Resource, CustomAction, CrudAction } from "@calcom/features/pbac/domain/types/permission-registry";
import { getSpecificPermissions } from "@calcom/features/pbac/lib/resource-permissions";
import { RoleManagementFactory } from "@calcom/features/pbac/services/role-management.factory";
import { PrismaAttributeRepository } from "@calcom/lib/server/repository/PrismaAttributeRepository";
@@ -63,50 +63,69 @@ const Page = async () => {
}
// Get specific PBAC permissions for organization member actions
const permissions = await getSpecificPermissions({
userId: session.user.id,
teamId: session.user.profile.organizationId,
resource: Resource.Organization,
userRole: session.user.org.role,
actions: [
CustomAction.ListMembers,
CustomAction.ListMembersPrivate,
CustomAction.Invite,
CustomAction.ChangeMemberRole,
CustomAction.Remove,
CustomAction.Impersonate,
],
fallbackRoles: {
[CustomAction.ListMembers]: {
roles: fallbackRolesThatCanSeeMembers,
const [orgPermissions, attributesPermissions] = await Promise.all([
getSpecificPermissions({
userId: session.user.id,
teamId: session.user.profile.organizationId,
resource: Resource.Organization,
userRole: session.user.org.role,
actions: [
CustomAction.ListMembers,
CustomAction.ListMembersPrivate,
CustomAction.Invite,
CustomAction.ChangeMemberRole,
CustomAction.Remove,
CustomAction.Impersonate,
],
fallbackRoles: {
[CustomAction.ListMembers]: {
roles: fallbackRolesThatCanSeeMembers,
},
[CustomAction.ListMembersPrivate]: {
roles: fallbackRolesThatCanSeeMembers,
},
[CustomAction.Invite]: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
[CustomAction.ChangeMemberRole]: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
[CustomAction.Remove]: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
[CustomAction.Impersonate]: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
},
[CustomAction.ListMembersPrivate]: {
roles: fallbackRolesThatCanSeeMembers,
}),
getSpecificPermissions({
userId: session.user.id,
teamId: session.user.profile.organizationId,
resource: Resource.Attributes,
userRole: session.user.org.role,
actions: [CrudAction.Read, CustomAction.EditUsers],
fallbackRoles: {
[CrudAction.Read]: {
roles: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER],
},
[CustomAction.EditUsers]: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
},
[CustomAction.Invite]: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
[CustomAction.ChangeMemberRole]: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
[CustomAction.Remove]: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
[CustomAction.Impersonate]: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
},
});
}),
]);
// Map specific permissions to member actions
const memberPermissions = {
canListMembers: org.isPrivate
? permissions[CustomAction.ListMembersPrivate]
: permissions[CustomAction.ListMembers],
canInvite: permissions[CustomAction.Invite],
canChangeMemberRole: permissions[CustomAction.ChangeMemberRole],
canRemove: permissions[CustomAction.Remove],
canImpersonate: permissions[CustomAction.Impersonate],
? orgPermissions[CustomAction.ListMembersPrivate]
: orgPermissions[CustomAction.ListMembers],
canInvite: orgPermissions[CustomAction.Invite],
canChangeMemberRole: orgPermissions[CustomAction.ChangeMemberRole],
canRemove: orgPermissions[CustomAction.Remove],
canImpersonate: orgPermissions[CustomAction.Impersonate],
canViewAttributes: attributesPermissions[CrudAction.Read],
canEditAttributesForUser: attributesPermissions[CustomAction.EditUsers],
};
const facetedTeamValues = {
@@ -4,52 +4,71 @@ import jwt from "jsonwebtoken";
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { OAuthClientRepository } from "@calcom/features/oauth/repositories/OAuthClientRepository";
import { OAuthService } from "@calcom/features/oauth/services/OAuthService";
import prisma from "@calcom/prisma";
import { generateSecret } from "@calcom/trpc/server/routers/viewer/oAuth/addClient.handler";
import type { OAuthTokenPayload } from "@calcom/types/oauth";
async function handler(req: NextRequest) {
const { client_id, client_secret, grant_type } = await parseUrlFormData(req);
const { client_id, client_secret, grant_type, refresh_token, code_verifier } = await parseUrlFormData(req);
if (!client_id || !client_secret) {
return NextResponse.json({ message: "Missing client id or secret" }, { status: 400 });
if (!process.env.CALENDSO_ENCRYPTION_KEY) {
return NextResponse.json({ message: "CALENDSO_ENCRYPTION_KEY is not set" }, { status: 500 });
}
if (!client_id) {
return NextResponse.json({ error: "invalid_request" }, { status: 400 });
}
if (grant_type !== "refresh_token") {
return NextResponse.json({ message: "grant type invalid" }, { status: 400 });
return NextResponse.json({ error: "invalid_request" }, { status: 400 });
}
const [hashedSecret] = generateSecret(client_secret);
const oAuthClientRepository = new OAuthClientRepository(prisma);
const client = await prisma.oAuthClient.findFirst({
where: {
clientId: client_id,
clientSecret: hashedSecret,
},
select: {
redirectUri: true,
},
});
const client = await oAuthClientRepository.findByClientId(client_id);
if (!client) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
return NextResponse.json({ error: "invalid_client" }, { status: 401 });
}
const secretKey = process.env.CALENDSO_ENCRYPTION_KEY || "";
const isValidClient = OAuthService.validateClient(client, client_secret);
if (!isValidClient) {
return NextResponse.json({ error: "invalid_client" }, { status: 401 });
}
const secretKey = process.env.CALENDSO_ENCRYPTION_KEY;
let decodedRefreshToken: OAuthTokenPayload;
try {
const refreshToken = req.headers.get("authorization")?.split(" ")[1] || "";
decodedRefreshToken = jwt.verify(refreshToken, secretKey) as OAuthTokenPayload;
const refreshTokenValue = refresh_token || req.headers.get("authorization")?.split(" ")[1] || "";
if (!refreshTokenValue) {
return NextResponse.json({ error: "invalid_request" }, { status: 400 });
}
decodedRefreshToken = jwt.verify(refreshTokenValue, secretKey) as OAuthTokenPayload;
} catch {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
return NextResponse.json({ error: "invalid_grant" }, { status: 400 });
}
if (!decodedRefreshToken || decodedRefreshToken.token_type !== "Refresh Token") {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
return NextResponse.json({ error: "invalid_grant" }, { status: 400 });
}
if (decodedRefreshToken.clientId !== client_id) {
return NextResponse.json({ error: "invalid_grant" }, { status: 400 });
}
const pkceError = OAuthService.verifyPKCE(client, decodedRefreshToken, code_verifier);
if (pkceError) {
return NextResponse.json({ error: pkceError.error }, { status: pkceError.status });
}
const accessTokenExpiresIn = 1800; // 30 minutes
const payloadAuthToken: OAuthTokenPayload = {
userId: decodedRefreshToken.userId,
teamId: decodedRefreshToken.teamId,
@@ -64,17 +83,37 @@ async function handler(req: NextRequest) {
scope: decodedRefreshToken.scope,
token_type: "Refresh Token",
clientId: client_id,
// Preserve PKCE information for any client that used PKCE originally
...(decodedRefreshToken.codeChallenge && {
codeChallenge: decodedRefreshToken.codeChallenge,
codeChallengeMethod: decodedRefreshToken.codeChallengeMethod,
}),
};
const access_token = jwt.sign(payloadAuthToken, secretKey, {
expiresIn: 1800, // 30 min
expiresIn: accessTokenExpiresIn,
});
const refresh_token = jwt.sign(payloadRefreshToken, secretKey, {
const refresh_token_new = jwt.sign(payloadRefreshToken, secretKey, {
expiresIn: 30 * 24 * 60 * 60, // 30 days
});
return NextResponse.json({ access_token, refresh_token }, { status: 200 });
return NextResponse.json(
{
access_token,
token_type: "bearer",
refresh_token: refresh_token_new,
expires_in: accessTokenExpiresIn,
},
{
status: 200,
headers: {
"Content-Type": "application/json;charset=UTF-8",
"Cache-Control": "no-store",
Pragma: "no-cache",
},
}
);
}
export const POST = defaultResponderForAppDir(handler);
@@ -0,0 +1,540 @@
import prismaMock from "../../../../../../../../tests/libs/__mocks__/prismaMock";
import jwt from "jsonwebtoken";
// Import mocked dependencies after mocks are set up
import { NextRequest } from "next/server";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { verifyCodeChallenge } from "@calcom/lib/pkce";
import { generateSecret } from "@calcom/trpc/server/routers/viewer/oAuth/addClient.handler";
import { POST } from "../route";
// Create mock NextResponse.json function using hoisted
const mockNextResponseJson = vi.hoisted(() => {
return vi.fn((data: unknown, options?: { status?: number; headers?: Record<string, string> }) => ({
json: vi.fn().mockResolvedValue(data),
status: options?.status || 200,
headers: new Headers(options?.headers || {}),
}));
});
// Mock next/server before importing anything that uses it
vi.mock("next/server", () => ({
NextRequest: class MockNextRequest {
url: string;
method: string;
nextUrl: { pathname: string; searchParams: URLSearchParams };
private _body: string;
constructor(
url: string,
options: { method?: string; body?: string; headers?: Record<string, string> } = {}
) {
this.url = url;
this.method = options.method || "POST";
this._body = options.body || "";
const urlObj = new URL(url);
this.nextUrl = {
pathname: urlObj.pathname,
searchParams: urlObj.searchParams,
};
}
async text(): Promise<string> {
return this._body;
}
},
NextResponse: {
json: mockNextResponseJson,
},
}));
// Mock dependencies
vi.mock("@calcom/lib/pkce", () => ({
verifyCodeChallenge: vi.fn(),
}));
vi.mock("@calcom/trpc/server/routers/viewer/oAuth/addClient.handler", () => ({
generateSecret: vi.fn(),
}));
vi.mock("jsonwebtoken", () => ({
default: {
sign: vi.fn(() => "mock_jwt_token"),
},
}));
vi.mock("@calcom/prisma", () => ({
default: prismaMock,
}));
vi.mock("app/api/defaultResponderForAppDir", async () => {
const { NextResponse } = await import("next/server");
return {
defaultResponderForAppDir:
(
handler: (req: NextRequest, context: { params: Promise<Record<string, string>> }) => Promise<Response>
) =>
async (req: NextRequest, context?: { params: Promise<Record<string, string>> }) => {
try {
const result = await handler(req, context || { params: Promise.resolve({}) });
if (result) {
return result;
}
return NextResponse.json({});
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : "Internal server error";
return NextResponse.json(
{
message: errorMessage,
},
{ status: 500 }
);
}
},
};
});
vi.mock("app/api/parseRequestData", () => ({
parseUrlFormData: async (req: NextRequest): Promise<Record<string, string>> => {
const text = await req.text();
const params = new URLSearchParams(text);
return Object.fromEntries(params);
},
}));
const mockVerifyCodeChallenge = vi.mocked(verifyCodeChallenge);
const mockGenerateSecret = vi.mocked(generateSecret);
const mockJwt = vi.mocked(jwt);
// Set up environment
vi.stubEnv("CALENDSO_ENCRYPTION_KEY", "test_encryption_key");
// Helper to create token exchange request
function createTokenRequest(data: Record<string, string>) {
const formData = new URLSearchParams(data);
const request = new NextRequest("http://localhost:3000/api/auth/oauth/token", {
method: "POST",
body: formData.toString(),
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
return request;
}
describe("POST /api/auth/oauth/token", () => {
beforeEach(() => {
vi.clearAllMocks();
mockJwt.sign.mockReturnValue("mock_jwt_token" as never);
mockVerifyCodeChallenge.mockReturnValue(false);
mockGenerateSecret.mockReturnValue(["hashed_secret", "plain_secret"]);
// Reset the mock for NextResponse.json
mockNextResponseJson.mockImplementation(
(data: unknown, options?: { status?: number; headers?: Record<string, string> }) => ({
json: vi.fn().mockResolvedValue(data),
status: options?.status || 200,
headers: new Headers(options?.headers || {}),
})
);
});
afterEach(() => {
vi.resetAllMocks();
});
describe("PUBLIC clients", () => {
const mockPublicClient = {
redirectUri: "https://app.example.com/callback",
clientSecret: null,
clientType: "PUBLIC" as const,
} as const;
const mockAccessCode = {
userId: 1,
teamId: null,
scopes: [] as const,
codeChallenge: "test_challenge",
codeChallengeMethod: "S256",
} as const;
it("should exchange authorization code for tokens with valid PKCE", async () => {
prismaMock.oAuthClient.findFirst.mockResolvedValue(
mockPublicClient as Awaited<ReturnType<typeof prismaMock.oAuthClient.findFirst>>
);
prismaMock.accessCode.findFirst.mockResolvedValue(
mockAccessCode as unknown as Awaited<ReturnType<typeof prismaMock.accessCode.findFirst>>
);
prismaMock.accessCode.deleteMany.mockResolvedValue({ count: 1 });
mockVerifyCodeChallenge.mockReturnValue(true);
const tokenRequest = createTokenRequest({
grant_type: "authorization_code",
code: "test_auth_code",
client_id: "public_client_123",
redirect_uri: "https://app.example.com/callback",
code_verifier: "test_verifier",
});
const response = await POST(tokenRequest, { params: Promise.resolve({}) });
expect(response).toBeDefined();
if (!response) throw new Error("Response is undefined");
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toEqual({
access_token: "mock_jwt_token",
token_type: "bearer",
refresh_token: "mock_jwt_token",
expires_in: 1800,
});
expect(mockVerifyCodeChallenge).toHaveBeenCalledWith("test_verifier", "test_challenge", "S256");
expect(prismaMock.accessCode.deleteMany).toHaveBeenCalled();
});
it("should reject PUBLIC client without code_verifier", async () => {
prismaMock.oAuthClient.findFirst.mockResolvedValue(
mockPublicClient as Awaited<ReturnType<typeof prismaMock.oAuthClient.findFirst>>
);
prismaMock.accessCode.findFirst.mockResolvedValue(
mockAccessCode as unknown as Awaited<ReturnType<typeof prismaMock.accessCode.findFirst>>
);
const tokenRequest = createTokenRequest({
grant_type: "authorization_code",
code: "test_auth_code",
client_id: "public_client_123",
redirect_uri: "https://app.example.com/callback",
});
const response = await POST(tokenRequest, { params: Promise.resolve({}) });
expect(response).toBeDefined();
if (!response) throw new Error("Response is undefined");
const data = await response.json();
expect(response.status).toBe(400);
expect(data.error).toBe("invalid_request");
});
it("should reject PUBLIC client with missing code challenge in access code", async () => {
prismaMock.oAuthClient.findFirst.mockResolvedValue(
mockPublicClient as Awaited<ReturnType<typeof prismaMock.oAuthClient.findFirst>>
);
prismaMock.accessCode.findFirst.mockResolvedValue({
...mockAccessCode,
codeChallenge: null,
} as unknown as Awaited<ReturnType<typeof prismaMock.accessCode.findFirst>>);
prismaMock.accessCode.deleteMany.mockResolvedValue({ count: 1 });
const tokenRequest = createTokenRequest({
grant_type: "authorization_code",
code: "test_auth_code",
client_id: "public_client_123",
redirect_uri: "https://app.example.com/callback",
code_verifier: "test_verifier",
});
const response = await POST(tokenRequest, { params: Promise.resolve({}) });
expect(response).toBeDefined();
if (!response) throw new Error("Response is undefined");
const data = await response.json();
expect(response.status).toBe(400);
expect(data.error).toBe("invalid_request");
});
it("should reject PUBLIC client with invalid code_verifier", async () => {
prismaMock.oAuthClient.findFirst.mockResolvedValue(
mockPublicClient as Awaited<ReturnType<typeof prismaMock.oAuthClient.findFirst>>
);
prismaMock.accessCode.findFirst.mockResolvedValue(
mockAccessCode as unknown as Awaited<ReturnType<typeof prismaMock.accessCode.findFirst>>
);
prismaMock.accessCode.deleteMany.mockResolvedValue({ count: 1 });
mockVerifyCodeChallenge.mockReturnValue(false);
const tokenRequest = createTokenRequest({
grant_type: "authorization_code",
code: "test_auth_code",
client_id: "public_client_123",
redirect_uri: "https://app.example.com/callback",
code_verifier: "wrong_verifier",
});
const response = await POST(tokenRequest, { params: Promise.resolve({}) });
expect(response).toBeDefined();
if (!response) throw new Error("Response is undefined");
const data = await response.json();
expect(response.status).toBe(400);
expect(data.error).toBe("invalid_grant");
expect(mockVerifyCodeChallenge).toHaveBeenCalledWith("wrong_verifier", "test_challenge", "S256");
});
});
describe("CONFIDENTIAL clients", () => {
const mockConfidentialClient = {
redirectUri: "https://app.example.com/callback",
clientSecret: "hashed_secret",
clientType: "CONFIDENTIAL" as const,
} as const;
const mockAccessCode = {
userId: 1,
teamId: null,
scopes: [] as const,
codeChallenge: null,
codeChallengeMethod: null,
} as const;
it("should exchange authorization code for tokens with valid client secret", async () => {
prismaMock.oAuthClient.findFirst.mockResolvedValue(
mockConfidentialClient as Awaited<ReturnType<typeof prismaMock.oAuthClient.findFirst>>
);
prismaMock.accessCode.findFirst.mockResolvedValue(
mockAccessCode as unknown as Awaited<ReturnType<typeof prismaMock.accessCode.findFirst>>
);
prismaMock.accessCode.deleteMany.mockResolvedValue({ count: 1 });
mockGenerateSecret.mockReturnValue(["hashed_secret", "plain_secret"]);
const tokenRequest = createTokenRequest({
grant_type: "authorization_code",
code: "test_auth_code",
client_id: "confidential_client_456",
client_secret: "plain_secret",
redirect_uri: "https://app.example.com/callback",
});
const response = await POST(tokenRequest, { params: Promise.resolve({}) });
expect(response).toBeDefined();
if (!response) throw new Error("Response is undefined");
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toEqual({
access_token: "mock_jwt_token",
token_type: "bearer",
refresh_token: "mock_jwt_token",
expires_in: 1800,
});
expect(mockGenerateSecret).toHaveBeenCalledWith("plain_secret");
expect(prismaMock.accessCode.deleteMany).toHaveBeenCalled();
});
it("should reject CONFIDENTIAL client without client_secret", async () => {
prismaMock.oAuthClient.findFirst.mockResolvedValue(
mockConfidentialClient as Awaited<ReturnType<typeof prismaMock.oAuthClient.findFirst>>
);
const tokenRequest = createTokenRequest({
grant_type: "authorization_code",
code: "test_auth_code",
client_id: "confidential_client_456",
redirect_uri: "https://app.example.com/callback",
});
const response = await POST(tokenRequest, { params: Promise.resolve({}) });
expect(response).toBeDefined();
if (!response) throw new Error("Response is undefined");
const data = await response.json();
expect(response.status).toBe(401);
expect(data.error).toBe("invalid_client");
});
it("should reject CONFIDENTIAL client with invalid client_secret", async () => {
prismaMock.oAuthClient.findFirst.mockResolvedValue(
mockConfidentialClient as Awaited<ReturnType<typeof prismaMock.oAuthClient.findFirst>>
);
mockGenerateSecret.mockReturnValue(["different_hashed_secret", "wrong_secret"]);
const tokenRequest = createTokenRequest({
grant_type: "authorization_code",
code: "test_auth_code",
client_id: "confidential_client_456",
client_secret: "wrong_secret",
redirect_uri: "https://app.example.com/callback",
});
const response = await POST(tokenRequest, { params: Promise.resolve({}) });
expect(response).toBeDefined();
if (!response) throw new Error("Response is undefined");
const data = await response.json();
expect(response.status).toBe(401);
expect(data.error).toBe("invalid_client");
});
it("should accept CONFIDENTIAL client with code_verifier for enhanced security", async () => {
const mockAccessCodeWithPKCE = {
userId: 1,
teamId: null,
scopes: [] as const,
codeChallenge: "test_challenge",
codeChallengeMethod: "S256",
} as const;
prismaMock.oAuthClient.findFirst.mockResolvedValue(
mockConfidentialClient as Awaited<ReturnType<typeof prismaMock.oAuthClient.findFirst>>
);
prismaMock.accessCode.findFirst.mockResolvedValue(
mockAccessCodeWithPKCE as unknown as Awaited<ReturnType<typeof prismaMock.accessCode.findFirst>>
);
prismaMock.accessCode.deleteMany.mockResolvedValue({ count: 1 });
mockGenerateSecret.mockReturnValue(["hashed_secret", "plain_secret"]);
mockVerifyCodeChallenge.mockReturnValue(true);
const tokenRequest = createTokenRequest({
grant_type: "authorization_code",
code: "test_auth_code",
client_id: "confidential_client_456",
client_secret: "plain_secret",
redirect_uri: "https://app.example.com/callback",
code_verifier: "test_verifier",
});
const response = await POST(tokenRequest, { params: Promise.resolve({}) });
expect(response).toBeDefined();
if (!response) throw new Error("Response is undefined");
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toEqual({
access_token: "mock_jwt_token",
token_type: "bearer",
refresh_token: "mock_jwt_token",
expires_in: 1800,
});
expect(mockGenerateSecret).toHaveBeenCalledWith("plain_secret");
expect(mockVerifyCodeChallenge).toHaveBeenCalledWith("test_verifier", "test_challenge", "S256");
});
it("should reject CONFIDENTIAL client with invalid code_verifier", async () => {
const mockAccessCodeWithPKCE = {
userId: 1,
teamId: null,
scopes: [] as const,
codeChallenge: "test_challenge",
codeChallengeMethod: "S256",
} as const;
prismaMock.oAuthClient.findFirst.mockResolvedValue(
mockConfidentialClient as Awaited<ReturnType<typeof prismaMock.oAuthClient.findFirst>>
);
prismaMock.accessCode.findFirst.mockResolvedValue(
mockAccessCodeWithPKCE as unknown as Awaited<ReturnType<typeof prismaMock.accessCode.findFirst>>
);
prismaMock.accessCode.deleteMany.mockResolvedValue({ count: 1 });
mockGenerateSecret.mockReturnValue(["hashed_secret", "plain_secret"]);
mockVerifyCodeChallenge.mockReturnValue(false);
const tokenRequest = createTokenRequest({
grant_type: "authorization_code",
code: "test_auth_code",
client_id: "confidential_client_456",
client_secret: "plain_secret",
redirect_uri: "https://app.example.com/callback",
code_verifier: "wrong_verifier",
});
const response = await POST(tokenRequest, { params: Promise.resolve({}) });
expect(response).toBeDefined();
if (!response) throw new Error("Response is undefined");
const data = await response.json();
expect(response.status).toBe(400);
expect(data.error).toBe("invalid_grant");
expect(mockVerifyCodeChallenge).toHaveBeenCalledWith("wrong_verifier", "test_challenge", "S256");
});
});
describe("General validation", () => {
it("should reject invalid grant_type", async () => {
const tokenRequest = createTokenRequest({
grant_type: "implicit",
code: "test_auth_code",
client_id: "test_client",
redirect_uri: "https://app.example.com/callback",
});
const response = await POST(tokenRequest, { params: Promise.resolve({}) });
expect(response).toBeDefined();
if (!response) throw new Error("Response is undefined");
const data = await response.json();
expect(response.status).toBe(400);
expect(data.error).toBe("invalid_request");
});
it("should reject invalid client_id", async () => {
prismaMock.oAuthClient.findFirst.mockResolvedValue(null);
const tokenRequest = createTokenRequest({
grant_type: "authorization_code",
code: "test_auth_code",
client_id: "invalid_client",
redirect_uri: "https://app.example.com/callback",
});
const response = await POST(tokenRequest, { params: Promise.resolve({}) });
expect(response).toBeDefined();
if (!response) throw new Error("Response is undefined");
const data = await response.json();
expect(response.status).toBe(401);
expect(data.error).toBe("invalid_client");
});
it("should reject mismatched redirect_uri", async () => {
prismaMock.oAuthClient.findFirst.mockResolvedValue({
redirectUri: "https://app.example.com/callback",
clientSecret: null,
clientType: "PUBLIC" as const,
} as Awaited<ReturnType<typeof prismaMock.oAuthClient.findFirst>>);
const tokenRequest = createTokenRequest({
grant_type: "authorization_code",
code: "test_auth_code",
client_id: "test_client",
redirect_uri: "https://malicious.com/callback",
});
const response = await POST(tokenRequest, { params: Promise.resolve({}) });
expect(response).toBeDefined();
if (!response) throw new Error("Response is undefined");
const data = await response.json();
expect(response.status).toBe(400);
expect(data.error).toBe("invalid_grant");
});
it("should reject expired authorization code", async () => {
prismaMock.oAuthClient.findFirst.mockResolvedValue({
redirectUri: "https://app.example.com/callback",
clientSecret: null,
clientType: "PUBLIC" as const,
} as Awaited<ReturnType<typeof prismaMock.oAuthClient.findFirst>>);
prismaMock.accessCode.findFirst.mockResolvedValue(null);
const tokenRequest = createTokenRequest({
grant_type: "authorization_code",
code: "expired_auth_code",
client_id: "test_client",
redirect_uri: "https://app.example.com/callback",
code_verifier: "test_verifier",
});
const response = await POST(tokenRequest, { params: Promise.resolve({}) });
expect(response).toBeDefined();
if (!response) throw new Error("Response is undefined");
const data = await response.json();
expect(response.status).toBe(400);
expect(data.error).toBe("invalid_grant");
});
});
});
+45 -42
View File
@@ -4,64 +4,63 @@ import jwt from "jsonwebtoken";
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { AccessCodeRepository } from "@calcom/features/oauth/repositories/AccessCodeRepository";
import { OAuthClientRepository } from "@calcom/features/oauth/repositories/OAuthClientRepository";
import { OAuthService } from "@calcom/features/oauth/services/OAuthService";
import prisma from "@calcom/prisma";
import { generateSecret } from "@calcom/trpc/server/routers/viewer/oAuth/addClient.handler";
import type { OAuthTokenPayload } from "@calcom/types/oauth";
async function handler(req: NextRequest) {
const { code, client_id, client_secret, grant_type, redirect_uri } = await parseUrlFormData(req);
const { code, client_id, client_secret, grant_type, redirect_uri, code_verifier } = await parseUrlFormData(
req
);
if (!process.env.CALENDSO_ENCRYPTION_KEY) {
return NextResponse.json({ message: "CALENDSO_ENCRYPTION_KEY is not set" }, { status: 500 });
}
if (!client_id || !code || !redirect_uri) {
return NextResponse.json({ error: "invalid_request" }, { status: 400 });
}
if (grant_type !== "authorization_code") {
return NextResponse.json({ message: "grant_type invalid" }, { status: 400 });
return NextResponse.json({ error: "invalid_request" }, { status: 400 });
}
const [hashedSecret] = generateSecret(client_secret);
const oAuthClientRepository = new OAuthClientRepository(prisma);
const accessCodeRepository = new AccessCodeRepository(prisma);
const client = await prisma.oAuthClient.findFirst({
where: {
clientId: client_id,
clientSecret: hashedSecret,
},
select: {
redirectUri: true,
},
});
const client = await oAuthClientRepository.findByClientId(client_id);
if (!client || client.redirectUri !== redirect_uri) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
if (!client) {
return NextResponse.json({ error: "invalid_client" }, { status: 401 });
}
const accessCode = await prisma.accessCode.findFirst({
where: {
code: code,
clientId: client_id,
expiresAt: {
gt: new Date(),
},
},
});
if (client.redirectUri !== redirect_uri) {
return NextResponse.json({ error: "invalid_grant" }, { status: 400 });
}
const isValidClient = OAuthService.validateClient(client, client_secret);
if (!isValidClient) {
return NextResponse.json({ error: "invalid_client" }, { status: 401 });
}
const accessCode = await accessCodeRepository.findValidCode(code, client_id);
// Delete all expired accessCodes + the one that is used here
await prisma.accessCode.deleteMany({
where: {
OR: [
{
expiresAt: {
lt: new Date(),
},
},
{
code: code,
clientId: client_id,
},
],
},
});
await accessCodeRepository.deleteExpiredAndUsedCodes(code, client_id);
if (!accessCode) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
return NextResponse.json({ error: "invalid_grant" }, { status: 400 });
}
const secretKey = process.env.CALENDSO_ENCRYPTION_KEY || "";
const pkceError = OAuthService.verifyPKCE(client, accessCode, code_verifier);
if (pkceError) {
return NextResponse.json({ error: pkceError.error }, { status: pkceError.status });
}
const secretKey = process.env.CALENDSO_ENCRYPTION_KEY;
const payloadAuthToken: OAuthTokenPayload = {
userId: accessCode.userId,
@@ -77,6 +76,11 @@ async function handler(req: NextRequest) {
scope: accessCode.scopes,
token_type: "Refresh Token",
clientId: client_id,
// Include PKCE information for clients that used PKCE (PUBLIC mandatory, CONFIDENTIAL optional)
...(accessCode.codeChallenge && {
codeChallenge: accessCode.codeChallenge,
codeChallengeMethod: accessCode.codeChallengeMethod,
}),
};
const accessTokenExpiresIn = 1800; // 30 minutes
@@ -89,7 +93,6 @@ async function handler(req: NextRequest) {
expiresIn: 30 * 24 * 60 * 60, // 30 days
});
// @see https://datatracker.ietf.org/doc/html/rfc6749#section-5.1
return NextResponse.json(
{ access_token, token_type: "bearer", refresh_token, expires_in: accessTokenExpiresIn },
{
@@ -0,0 +1,84 @@
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { z } from "zod";
import stripe from "@calcom/features/ee/payments/server/stripe";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { HttpError } from "@calcom/lib/http-error";
import { prisma } from "@calcom/prisma";
const querySchema = z.object({
session_id: z.string().min(1),
paymentStatus: z.enum(["success", "failed"]),
});
async function getHandler(req: NextRequest) {
try {
const searchParams = req.nextUrl.searchParams;
const { session_id, paymentStatus } = querySchema.parse({
session_id: searchParams.get("session_id"),
paymentStatus: searchParams.get("paymentStatus"),
});
// Retrieve checkout session from Stripe to get metadata
const checkoutSession = await stripe.checkout.sessions.retrieve(session_id);
if (!checkoutSession) {
throw new HttpError({ statusCode: 404, message: "Checkout session not found" });
}
// Extract organizationOnboardingId from metadata
const organizationOnboardingId = checkoutSession.metadata?.organizationOnboardingId;
// Check if onboarding-v3 feature flag is enabled
const featuresRepository = new FeaturesRepository(prisma);
const isOnboardingV3Enabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("onboarding-v3");
// Build query params to preserve
const params = new URLSearchParams({
session_id,
paymentStatus,
});
// If onboarding-v3 is enabled AND organizationOnboardingId exists, redirect to onboarding flow
if (isOnboardingV3Enabled && organizationOnboardingId) {
const redirectUrl = new URL(
`/onboarding/personal/settings?${params.toString()}`,
WEBAPP_URL
).toString();
return NextResponse.redirect(redirectUrl);
}
// Otherwise, redirect to the current flow
// Preserve any additional query params that were passed
searchParams.forEach((value, key) => {
if (key !== "session_id" && key !== "paymentStatus") {
params.append(key, value);
}
});
const redirectUrl = new URL(
`/settings/organizations/new/status?${params.toString()}`,
WEBAPP_URL
).toString();
return NextResponse.redirect(redirectUrl);
} catch (error) {
if (error instanceof HttpError) {
throw error;
}
// If there's an error, fall back to the default status page
const searchParams = req.nextUrl.searchParams;
const fallbackParams = new URLSearchParams({
session_id: searchParams.get("session_id") || "",
paymentStatus: searchParams.get("paymentStatus") || "failed",
});
const fallbackUrl = new URL(
`/settings/organizations/new/status?${fallbackParams.toString()}`,
WEBAPP_URL
).toString();
return NextResponse.redirect(fallbackUrl);
}
}
export const GET = defaultResponderForAppDir(getHandler);
+1 -1
View File
@@ -15,7 +15,7 @@ export async function generateMetadata(): Promise<Metadata> {
return await _generateMetadataForStaticPage("Icons Showcase", "", undefined, undefined, "/icons");
}
const interFont = Inter({ subsets: ["latin"], variable: "--font-inter", preload: true, display: "swap" });
const interFont = Inter({ subsets: ["latin"], variable: "--font-sans", preload: true, display: "swap" });
const calFont = localFont({
src: "../../fonts/CalSans-SemiBold.woff2",
variable: "--font-cal",
+15 -15
View File
@@ -15,7 +15,7 @@ import { AppRouterI18nProvider } from "./AppRouterI18nProvider";
import { SpeculationRules } from "./SpeculationRules";
import { Providers } from "./providers";
const interFont = Inter({ subsets: ["latin"], variable: "--font-inter", preload: true, display: "swap" });
const interFont = Inter({ subsets: ["latin"], variable: "--font-sans", preload: true, display: "swap" });
const calFont = localFont({
src: "../fonts/CalSans-SemiBold.woff2",
variable: "--font-cal",
@@ -115,8 +115,8 @@ export default async function RootLayout({ children }: { children: React.ReactNo
<head nonce={nonce}>
<style>{`
:root {
--font-inter: ${interFont.style.fontFamily.replace(/'/g, "")};
--font-cal: ${calFont.style.fontFamily.replace(/'/g, "")};
--font-sans: ${interFont.style.fontFamily.replace(/\'/g, "")};
--font-cal: ${calFont.style.fontFamily.replace(/\'/g, "")};
}
`}</style>
</head>
@@ -125,19 +125,19 @@ export default async function RootLayout({ children }: { children: React.ReactNo
style={
isEmbed
? {
background: "transparent",
// Keep the embed hidden till parent initializes and
// - gives it the appropriate styles if UI instruction is there.
// - gives iframe the appropriate height(equal to document height) which can only be known after loading the page once in browser.
// - Tells iframe which mode it should be in (dark/light) - if there is a a UI instruction for that
visibility: "hidden",
// This in addition to visibility: hidden is to ensure that elements with specific opacity set are not visible
opacity: 0,
}
background: "transparent",
// Keep the embed hidden till parent initializes and
// - gives it the appropriate styles if UI instruction is there.
// - gives iframe the appropriate height(equal to document height) which can only be known after loading the page once in browser.
// - Tells iframe which mode it should be in (dark/light) - if there is a a UI instruction for that
visibility: "hidden",
// This in addition to visibility: hidden is to ensure that elements with specific opacity set are not visible
opacity: 0,
}
: {
visibility: "visible",
opacity: 1,
}
visibility: "visible",
opacity: 1,
}
}>
<IconSprites />
<SpeculationRules
+5 -5
View File
@@ -145,7 +145,7 @@ export function NotFound({ host }: { host: string }) {
target="_blank"
className="relative flex items-start space-x-4 py-6 rtl:space-x-reverse"
rel="noreferrer">
<div className="flex-shrink-0">
<div className="shrink-0">
<span className="flex h-12 w-12 items-center justify-center rounded-lg bg-green-50">
<Icon name="check" className="h-6 w-6 text-green-500" aria-hidden="true" />
</span>
@@ -164,7 +164,7 @@ export function NotFound({ host }: { host: string }) {
</h3>
<p className="text-subtle text-base">{t(`404_claim_entity_${pageType.toLowerCase()}`)}</p>
</div>
<div className="flex-shrink-0 self-center">
<div className="shrink-0 self-center">
<Icon name="chevron-right" className="text-muted h-5 w-5" aria-hidden="true" />
</div>
</a>
@@ -180,8 +180,8 @@ export function NotFound({ host }: { host: string }) {
<a
href={link.href}
className="relative flex items-start space-x-4 py-6 rtl:space-x-reverse">
<div className="flex-shrink-0">
<span className="bg-muted flex h-12 w-12 items-center justify-center rounded-lg">
<div className="shrink-0">
<span className="bg-cal-muted flex h-12 w-12 items-center justify-center rounded-lg">
<Icon name={link.icon} className="text-default h-6 w-6" aria-hidden="true" />
</span>
</div>
@@ -194,7 +194,7 @@ export function NotFound({ host }: { host: string }) {
</h3>
<p className="text-subtle text-base">{link.description}</p>
</div>
<div className="flex-shrink-0 self-center">
<div className="shrink-0 self-center">
<Icon name="chevron-right" className="text-muted h-5 w-5" aria-hidden="true" />
</div>
</a>
+2 -2
View File
@@ -18,7 +18,7 @@ export default function AddToHomescreen() {
<div className="rounded-lg p-2 shadow-lg sm:p-3" style={{ background: "#2F333D" }}>
<div className="flex flex-wrap items-center justify-between">
<div className="flex w-0 flex-1 items-center">
<span className="bg-brand text-brandcontrast dark:bg-darkmodebrand dark:text-darkmodebrandcontrast flex rounded-lg bg-opacity-30 p-2">
<span className="text-brandcontrast dark:bg-darkmodebrand/30 dark:text-darkmodebrandcontrast flex rounded-lg p-2">
<svg
className="h-7 w-7 fill-current text-[#5B93F9]"
xmlns="http://www.w3.org/2000/svg"
@@ -34,7 +34,7 @@ export default function AddToHomescreen() {
</p>
</div>
<div className="order-2 flex-shrink-0 sm:order-3">
<div className="order-2 shrink-0 sm:order-3">
<button
onClick={() => setCloseBanner(true)}
type="button"
+1 -1
View File
@@ -52,7 +52,7 @@ export default function EnterprisePage() {
features={features}
background="/tips/enterprise"
buttons={
<div className="space-y-2 rtl:space-x-reverse sm:space-x-2">
<div className="stack-y-2 rtl:space-x-reverse sm:space-x-2">
<ButtonGroup>
<Button color="primary" href="https://go.cal.com/quote" target="_blank">
{t("contact_sales")}
+2 -2
View File
@@ -24,7 +24,7 @@ export interface CalPageWrapper {
PageWrapper?: AppProps["Component"]["PageWrapper"];
}
const interFont = Inter({ subsets: ["latin"], variable: "--font-inter", preload: true, display: "swap" });
const interFont = Inter({ subsets: ["latin"], variable: "--font-sans", preload: true, display: "swap" });
const calFont = localFont({
src: "../fonts/CalSans-SemiBold.woff2",
variable: "--font-cal",
@@ -80,7 +80,7 @@ function PageWrapper(props: AppProps) {
<style jsx global>{`
:root {
--font-inter: ${interFont.style.fontFamily};
--font-sans: ${interFont.style.fontFamily};
--font-cal: ${calFont.style.fontFamily};
}
`}</style>
+1 -1
View File
@@ -497,7 +497,7 @@ export const AppPage = ({
)}
</ul>
<hr className="border-subtle my-8 border" />
<span className="leading-1 text-subtle block text-xs">
<span className="text-subtle block text-xs">
{t("every_app_published", { appName: APP_NAME, companyName: COMPANY_NAME })}
</span>
<a className="mt-2 block text-xs text-red-500" href={`mailto:${SUPPORT_MAIL_ADDRESS}`}>
@@ -25,8 +25,8 @@ export default function AppleCalendarSetup() {
return (
<div className="bg-emphasis flex h-screen dark:bg-inherit">
<div className="bg-default dark:bg-muted border-subtle m-auto rounded p-5 dark:border md:w-[560px] md:p-10">
<div className="flex flex-col space-y-5 md:flex-row md:space-x-5 md:space-y-0">
<div className="bg-default dark:bg-cal-muted border-subtle m-auto rounded p-5 dark:border md:w-[560px] md:p-10">
<div className="flex flex-col stack-y-5 md:flex-row md:space-x-5 md:stack-y-0">
<div>
{/* eslint-disable @next/next/no-img-element */}
<img
@@ -75,7 +75,7 @@ export default function AppleCalendarSetup() {
}
}}>
<fieldset
className="space-y-4"
className="stack-y-4"
disabled={form.formState.isSubmitting}
data-testid="apple-calendar-form">
<TextField
@@ -249,13 +249,13 @@ function BTCPaySetupPage(props: IBTCPaySetupProps) {
<>
<div className="bg-default flex h-screen items-center justify-center">
{showContent ? (
<div className="flex w-full w-full max-w-[43em] flex-col items-center justify-center space-y-4 p-4 lg:space-y-5">
<form className="w-full space-y-4" onSubmit={onSubmit}>
<div className="flex w-full w-full max-w-[43em] flex-col items-center justify-center stack-y-4 p-4 lg:stack-y-5">
<form className="w-full stack-y-4" onSubmit={onSubmit}>
<div className="bg-default border-subtle overflow-auto rounded border">
<div className="border-subtle flex items-center justify-between border-b-[1px] p-4 md:p-5">
<div className="border-subtle flex items-center justify-between border-b p-4 md:p-5">
<h2 className="text-2xl font-semibold">BTCPay Server Information</h2>
</div>
<div className="w-full space-y-4 p-4 md:p-5">
<div className="w-full stack-y-4 p-4 md:p-5">
<div className="w-full">
<KeyField
{...register("serverUrl", { required: true })}
@@ -26,7 +26,7 @@ export default function CalDavCalendarSetup() {
return (
<div className="bg-emphasis flex h-screen">
<div className="bg-default m-auto rounded p-5 md:w-[560px] md:p-10">
<div className="flex flex-col space-y-5 md:flex-row md:space-x-5 md:space-y-0">
<div className="flex flex-col stack-y-5 md:flex-row md:space-x-5 md:stack-y-0">
<div>
{/* eslint-disable @next/next/no-img-element */}
<img
@@ -60,7 +60,7 @@ export default function CalDavCalendarSetup() {
router.push(json.url);
}
}}>
<fieldset className="space-y-2" disabled={form.formState.isSubmitting}>
<fieldset className="stack-y-2" disabled={form.formState.isSubmitting}>
<TextField
required
type="text"
@@ -95,7 +95,7 @@ export default function CalDavCalendarSetup() {
href={errorActionUrl}
color="secondary"
target="_blank"
className="ml-5 w-32 !p-5">
className="ml-5 w-32 p-5!">
Go to Admin
</Button>
) : undefined
@@ -54,7 +54,7 @@ export default function Exchange2013CalendarSetup() {
router.push(json.url);
}
}}>
<fieldset className="space-y-2" disabled={form.formState.isSubmitting}>
<fieldset className="stack-y-2" disabled={form.formState.isSubmitting}>
<TextField
required
type="text"
@@ -54,7 +54,7 @@ export default function Exchange2016CalendarSetup() {
router.push(json.url);
}
}}>
<fieldset className="space-y-2" disabled={form.formState.isSubmitting}>
<fieldset className="stack-y-2" disabled={form.formState.isSubmitting}>
<TextField
required
type="text"
@@ -67,7 +67,7 @@ export default function ExchangeSetup() {
<>
<div className="bg-emphasis flex h-screen">
<div className="bg-default m-auto rounded p-5 md:w-[560px] md:p-10">
<div className="flex flex-col space-y-5 md:flex-row md:space-x-5 md:space-y-0">
<div className="flex flex-col stack-y-5 md:flex-row md:space-x-5 md:stack-y-0">
<div>
{/* eslint-disable @next/next/no-img-element */}
<img
@@ -98,7 +98,7 @@ export default function ExchangeSetup() {
router.push(json.url);
}
}}>
<fieldset className="space-y-4" disabled={form.formState.isSubmitting}>
<fieldset className="stack-y-4" disabled={form.formState.isSubmitting}>
<TextField
required
type="url"
+4 -4
View File
@@ -203,15 +203,15 @@ function HitPaySetupPage(props: IHitPaySetupProps) {
<>
<div className="bg-default flex h-screen items-center justify-center">
{showContent ? (
<div className="flex w-full w-full max-w-[43em] flex-col items-center justify-center space-y-4 p-4 lg:space-y-5">
<div className="flex w-full w-full max-w-[43em] flex-col items-center justify-center stack-y-4 p-4 lg:stack-y-5">
<div className="rounded bg-blue-50 p-3 text-sm text-blue-700 dark:bg-blue-950 dark:text-blue-300">
<Icon name="info" className="mb-0.5 inline-flex h-4 w-4" /> Create or connect to an existing
HitPay account to receive payments for your paid bookings.
</div>
<form className="w-full space-y-4" onSubmit={onSubmit}>
<form className="w-full stack-y-4" onSubmit={onSubmit}>
<div className="bg-default border-subtle overflow-auto rounded border">
<div className="border-subtle flex items-center justify-between border-b-[1px] p-4 md:p-5">
<div className="border-subtle flex items-center justify-between border-b p-4 md:p-5">
<h2 className="text-2xl font-semibold">Account Information</h2>
<div className="ml-auto flex items-center">
<Switch
@@ -221,7 +221,7 @@ function HitPaySetupPage(props: IHitPaySetupProps) {
/>
</div>
</div>
<div className="w-full space-y-4 p-4 md:p-5">
<div className="w-full stack-y-4 p-4 md:p-5">
<div className="w-full">
<KeyField
{...register("apiKey", {
@@ -24,7 +24,7 @@ export default function ICSFeedSetup() {
return (
<div className="bg-emphasis flex h-screen">
<div className="bg-default m-auto rounded p-5 md:w-[560px] md:p-10">
<div className="flex flex-col space-y-5 md:flex-row md:space-x-5 md:space-y-0">
<div className="flex flex-col stack-y-5 md:flex-row md:space-x-5 md:stack-y-0">
<div>
{/* eslint-disable @next/next/no-img-element */}
<img
@@ -58,7 +58,7 @@ export default function ICSFeedSetup() {
router.push(json.url);
}
}}>
<fieldset className="space-y-2" disabled={form.formState.isSubmitting}>
<fieldset className="stack-y-2" disabled={form.formState.isSubmitting}>
{urls.map((url, i) => (
<div key={i} className="flex w-full items-center gap-2">
<TextField
@@ -104,7 +104,7 @@ export default function ICSFeedSetup() {
href={errorActionUrl}
color="secondary"
target="_blank"
className="ml-5 w-32 !p-5">
className="ml-5 w-32 p-5!">
Go to Admin
</Button>
) : undefined
@@ -42,9 +42,9 @@ const AccountSelector: FC<AccountSelectorProps> = ({
return (
<div
className={classNames(
"hover:bg-muted flex cursor-pointer flex-row items-center gap-2 p-1",
"hover:bg-cal-muted flex cursor-pointer flex-row items-center gap-2 p-1",
(alreadyInstalled || loading) && "cursor-not-allowed",
selected && loading && "bg-muted animate-pulse"
selected && loading && "bg-cal-muted animate-pulse"
)}
data-testid={testId}
onClick={() => {
@@ -139,7 +139,7 @@ const EventTypeGroup = ({
keyName: "fieldId",
});
return (
<div className="ml-2 flex flex-col space-y-6">
<div className="ml-2 flex flex-col stack-y-6">
{fields.map(
(field, index) =>
field.selected && (
@@ -45,7 +45,7 @@ const EventTypeCard: FC<EventTypeCardProps> = ({
return (
<div
data-testid={`select-event-type-${id}`}
className="hover:bg-muted min-h-20 box-border flex w-full cursor-pointer select-none items-center space-x-4 px-4 py-3"
className="hover:bg-cal-muted min-h-20 box-border flex w-full cursor-pointer select-none items-center space-x-4 px-4 py-3"
onClick={() => handleSelect()}>
<input
id={`${id}`}
@@ -63,7 +63,7 @@ const EventTypeCard: FC<EventTypeCardProps> = ({
</div>
{Boolean(description) && (
<div
className="text-subtle line-clamp-4 break-words text-sm sm:max-w-[650px] [&>*:not(:first-child)]:hidden [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600"
className="text-subtle line-clamp-4 wrap-break-word text-sm sm:max-w-[650px] [&>*:not(:first-child)]:hidden [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600"
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{
__html: markdownToSafeHTML(description),
@@ -105,7 +105,7 @@ const EventTypeGroup: FC<EventTypeGroupProps> = ({ groupIndex, userName, ...prop
<div className="sm:border-subtle bg-default border dark:bg-black sm:rounded-md">
<ScrollableArea className="rounded-md">
<ul className="border-subtle max-h-97 !static w-full divide-y">
<ul className="border-subtle max-h-97 static! w-full divide-y">
{fields.length > 0 ? (
fields.map((field, index) => (
<EventTypeCard
@@ -118,7 +118,7 @@ const EventTypeGroup: FC<EventTypeGroupProps> = ({ groupIndex, userName, ...prop
/>
))
) : (
<div className="text-subtle bg-muted w-full p-2 text-center text-sm">Team has no Events</div>
<div className="text-subtle bg-cal-muted w-full p-2 text-center text-sm">Team has no Events</div>
)}
</ul>
</ScrollableArea>
@@ -95,9 +95,7 @@ function NewFormDialog({
<Dialog open={newFormDialogState !== null} onOpenChange={(open) => !open && setNewFormDialogState(null)}>
<DialogContent className="overflow-y-auto">
<div className="mb-1">
<h3
className="text-emphasis !font-cal text-semibold leading-20 text-xl font-medium"
id="modal-title">
<h3 className="text-emphasis !font-cal text-semibold text-xl font-medium" id="modal-title">
{teamId ? t("add_new_team_form") : t("add_new_form")}
</h3>
<div>
@@ -117,7 +115,7 @@ function NewFormDialog({
duplicateFrom: formToDuplicate,
});
}}>
<div className="mt-3 space-y-5">
<div className="mt-3 stack-y-5">
<TextField label={t("title")} required placeholder={t("a_routing_form")} {...register("name")} />
<div className="mb-5">
<TextAreaField
@@ -175,7 +173,10 @@ export const FormActionsDropdown = ({
type="button"
variant="icon"
color="secondary"
className={classNames(disabled && "opacity-30")}
className={classNames(
disabled && "opacity-30",
"ltr:radix-state-open:rounded-r-(--btn-group-radius) rtl:radix-state-open:rounded-l-(--btn-group-radius)"
)}
StartIcon="ellipsis"
/>
</DropdownMenuTrigger>
@@ -272,16 +273,14 @@ function Dialogs({
const actionsCtx = createContext({
appUrl: "",
// eslint-disable-next-line @typescript-eslint/no-empty-function
setNewFormDialogState: null as SetNewFormDialogState | null,
newFormDialogState: null as NewFormDialogState,
_delete: {
// eslint-disable-next-line @typescript-eslint/no-empty-function, @typescript-eslint/no-unused-vars
onAction: (_arg: { routingForm: RoutingForm | null }) => {},
isPending: false,
},
toggle: {
// eslint-disable-next-line @typescript-eslint/no-empty-function, @typescript-eslint/no-unused-vars
onAction: (_arg: { routingForm: RoutingForm | null; checked: boolean }) => {},
isPending: false,
},
@@ -191,7 +191,7 @@ export const FormSettingsSlideover = ({
)}
</div>
</div>
<SheetFooter className="flex-shrink-0 ">
<SheetFooter className="shrink-0 ">
<Button color="minimal" onClick={handleCancel} data-testid="settings-slider-over-cancel">
{t("cancel")}
</Button>
@@ -289,7 +289,7 @@ export function Header({
) : (
<div className="group flex items-center gap-1">
<span
className="text-default hover:bg-muted min-w-[100px] cursor-pointer truncate whitespace-nowrap rounded px-1 text-sm font-semibold leading-none"
className="text-default hover:bg-cal-muted min-w-[100px] cursor-pointer truncate whitespace-nowrap rounded px-1 text-sm font-semibold leading-none"
onClick={() => setIsEditing(true)}>
{watchedName || "Loading..."}
</span>
@@ -183,7 +183,7 @@ function SingleForm({
className={classNames(
"bg-default flex-1",
isDesktop && "grid gap-8",
isDesktop && isTestPreviewOpen && "grid-cols-[1fr,400px]",
isDesktop && isTestPreviewOpen && "grid-cols-[1fr_400px]",
isDesktop && !isTestPreviewOpen && "grid-cols-1",
!isDesktop && "flex flex-col"
)}>
@@ -335,7 +335,7 @@ export const TestFormRenderer = ({
if (!isMobile) {
if (isTestPreviewOpen) {
return (
<div className="border-muted bg-muted h-full border-l p-6">
<div className="border-muted bg-cal-muted h-full border-l p-6">
<TestForm
form={testForm}
supportsTeamMembersMatchingLogic={isSubTeamForm}
+4 -4
View File
@@ -39,7 +39,7 @@ export default function SendgridSetup() {
return (
<div className="bg-emphasis flex h-screen">
<div className="bg-default m-auto rounded p-5 md:w-[520px] md:p-10">
<div className="flex flex-col space-y-5 md:flex-row md:space-x-5 md:space-y-0">
<div className="flex flex-col stack-y-5 md:flex-row md:space-x-5 md:stack-y-0">
<div>
<img src="/api/app-store/sendgrid/logo.png" alt="Sendgrid" className="h-12 w-12 max-w-2xl" />
</div>
@@ -76,7 +76,7 @@ export default function SendgridSetup() {
showToast(json.message, "error");
}
}}>
<fieldset className="space-y-2" disabled={form.formState.isSubmitting}>
<fieldset className="stack-y-2" disabled={form.formState.isSubmitting}>
<Controller
name="api_key"
control={form.control}
@@ -108,8 +108,8 @@ export default function SendgridSetup() {
className={
testPassed !== undefined
? testPassed
? " !bg-success hover:bg-success !text-green-700"
: "bg-error hover:bg-error !border-red-700 !text-red-700"
? " bg-cal-success! hover:bg-cal-success text-green-700!"
: "bg-error hover:bg-error border-red-700! text-red-700!"
: "secondary"
}
color={testPassed === true ? "minimal" : "secondary"}
@@ -82,7 +82,7 @@ export const ConfirmDialog = (props: IConfirmDialogWipe) => {
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
<DialogContent onOpenAutoFocus={(e) => e.preventDefault()}>
<div className="flex flex-row space-x-3">
<div className="flex h-10 w-10 flex-shrink-0 justify-center rounded-full bg-[#FAFAFA]">
<div className="flex h-10 w-10 shrink-0 justify-center rounded-full bg-[#FAFAFA]">
<Icon name="clock" className="m-auto h-5 w-5" />
</div>
<div className="pt-1">
+1 -1
View File
@@ -9,7 +9,7 @@ export default function TwoFactor({ center = true }) {
const methods = useFormContext();
return (
<div className={center ? "mx-auto !mt-0 max-w-sm" : "!mt-0 max-w-sm"}>
<div className={center ? "mx-auto mt-0! max-w-sm" : "mt-0! max-w-sm"}>
<Label className="mt-4">{t("backup_code")}</Label>
<p className="text-subtle mb-4 text-sm">{t("backup_code_instructions")}</p>
+2 -2
View File
@@ -22,10 +22,10 @@ export default function TwoFactor({ center = true, autoFocus = true }) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]);
const className = "h-12 w-12 !text-xl text-center";
const className = "h-12 w-12 text-xl! text-center";
return (
<div className={center ? "mx-auto !mt-0 max-w-sm" : "!mt-0 max-w-sm"}>
<div className={center ? "mx-auto mt-0! max-w-sm" : "mt-0! max-w-sm"}>
<Label className="mt-4">{t("2fa_code")}</Label>
<p className="text-subtle mb-4 text-sm">{t("2fa_enabled_instructions")}</p>
@@ -354,9 +354,9 @@ function BookingListItem(booking: BookingItemProps) {
<div
data-testid="booking-item"
data-today={String(booking.isToday)}
className="hover:bg-muted group w-full">
className="hover:bg-cal-muted group w-full">
<div className="flex flex-col sm:flex-row">
<div className="hidden align-top ltr:pl-3 rtl:pr-6 sm:table-cell sm:min-w-[12rem]">
<div className="hidden align-top ltr:pl-3 rtl:pr-6 sm:table-cell sm:min-w-48">
<div className="flex h-full items-center">
{eventTypeColor && (
<div className="h-[70%] w-0.5" style={{ backgroundColor: eventTypeColor }} />
@@ -497,7 +497,7 @@ function BookingListItem(booking: BookingItemProps) {
</div>
</ConditionalLink>
</div>
<div className="flex w-full flex-col flex-wrap items-end justify-end space-x-2 space-y-2 py-4 pl-4 text-right text-sm font-medium ltr:pr-4 rtl:pl-4 sm:flex-row sm:flex-nowrap sm:items-start sm:space-y-0 sm:pl-0">
<div className="flex w-full flex-col flex-wrap items-end justify-end space-x-2 stack-y-2 py-4 pl-4 text-right text-sm font-medium ltr:pr-4 rtl:pl-4 sm:flex-row sm:flex-nowrap sm:items-start sm:stack-y-0 sm:pl-0">
{shouldShowPendingActions(actionContext) && <TableActions actions={pendingActions} />}
<BookingActionsDropdown booking={booking} context="list" />
{shouldShowRecurringCancelAction(actionContext) && <TableActions actions={[cancelEventAction]} />}
+5 -10
View File
@@ -29,7 +29,7 @@ function SkeletonItem() {
return (
<li className="group flex w-full items-start justify-between px-4 py-4 sm:px-6">
{/* Left side - Date and time info */}
<div className="flex min-w-0 flex-shrink-0 flex-col gap-2 pr-4">
<div className="flex min-w-0 shrink-0 flex-col gap-2 pr-4">
<SkeletonText className="h-4 w-24" />
<SkeletonText className="h-4 w-28" />
<div className="mt-1 flex items-center gap-2">
@@ -43,26 +43,21 @@ function SkeletonItem() {
</div>
{/* Right side - Event details */}
<div className="flex min-w-0 flex-1 flex-col space-y-2">
<div className="flex min-w-0 flex-1 flex-col stack-y-2">
{/* Event title */}
<SkeletonText className="h-5 w-36" />
{/* Event description */}
<SkeletonText className="h-4 w-48" />
<SkeletonText className="h-4 w-full max-w-48" />
{/* Attendees */}
<SkeletonText className="h-4 w-56" />
<SkeletonText className="h-4 w-full max-w-56" />
</div>
{/* Action buttons - only visible on larger screens */}
<div className="ml-4 hidden flex-shrink-0 gap-2 sm:flex">
<div className="ml-4 hidden shrink-0 gap-2 sm:flex">
<SkeletonText className="h-9 w-20 rounded-md" />
<SkeletonText className="h-9 w-20 rounded-md" />
<SkeletonText className="h-9 w-9 rounded-md" />
</div>
{/* Mobile menu button */}
<div className="ml-4 flex flex-shrink-0 sm:hidden">
<SkeletonText className="h-9 w-9 rounded-md" />
</div>
</li>
);
}
@@ -369,7 +369,7 @@ export function BookingActionsDropdown({
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent title={t("mark_as_no_show")} enableOverflow>
<div className="space-y-2">
<div className="stack-y-2">
{attendees.map((attendee, index) => (
<label key={attendee.email} className="flex items-center space-x-2">
<input
@@ -58,7 +58,7 @@ export const AddGuestsDialog = (props: IAddGuestsDialog) => {
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
<DialogContent enableOverflow>
<div className="flex flex-row md:space-x-3">
<div className="bg-subtle hidden h-10 w-10 flex-shrink-0 justify-center rounded-full md:flex">
<div className="bg-subtle hidden h-10 w-10 shrink-0 justify-center rounded-full md:flex">
<Icon name="user-plus" className="m-auto h-6 w-6" />
</div>
<div className="w-full md:pt-1">
@@ -74,7 +74,7 @@ export const AddGuestsDialog = (props: IAddGuestsDialog) => {
{isInvalidEmail && (
<div className="my-4 flex text-sm text-red-700">
<div className="flex-shrink-0">
<div className="shrink-0">
<Icon name="triangle-alert" className="h-5 w-5" />
</div>
<div className="ml-3">
@@ -44,7 +44,7 @@ export const ChargeCardDialog = (props: IRescheduleDialog) => {
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
<DialogContent>
<div className="flex flex-row space-x-3">
<div className=" bg-subtle flex h-10 w-10 flex-shrink-0 justify-center rounded-full">
<div className=" bg-subtle flex h-10 w-10 shrink-0 justify-center rounded-full">
<Icon name="credit-card" className="m-auto h-6 w-6" />
</div>
<div className="pt-1">
@@ -257,7 +257,7 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
}
}}>
<div className="flex flex-row md:space-x-3">
<div className="bg-subtle mx-auto hidden h-12 w-12 flex-shrink-0 items-center justify-center rounded-full sm:mx-0 sm:h-10 sm:w-10 md:flex">
<div className="bg-subtle mx-auto hidden h-12 w-12 shrink-0 items-center justify-center rounded-full sm:mx-0 sm:h-10 sm:w-10 md:flex">
<Icon name="map-pin" className="text-emphasis h-6 w-6" />
</div>
<div className="w-full md:pt-1">
@@ -244,7 +244,7 @@ export const ReassignDialog = ({
/>
<div
className={classNames(
"h-3 w-3 flex-shrink-0 rounded-full",
"h-3 w-3 shrink-0 rounded-full",
member.status === "unavailable" ? "bg-red-500" : "bg-green-500"
)}
/>
+5 -5
View File
@@ -373,9 +373,9 @@ const NewRoutingManager = ({
reroutingState.value?.type === "reschedule_to_different_event_new_tab";
return (
<div className="bg-muted flex flex-col space-y-3 rounded-md p-4 text-sm">
<div className="bg-cal-muted flex flex-col stack-y-3 rounded-md p-4 text-sm">
<h2 className="text-emphasis font-medium">{t("new_routing_status")}</h2>
<div className="flex flex-col space-y-2">
<div className="flex flex-col stack-y-2">
{reroutingState.status === ReroutingStatusEnum.REROUTING_NOT_INITIATED && reroutingPreview()}
{(reroutingState.status === ReroutingStatusEnum.REROUTING_NOT_INITIATED || !isReroutingInNewTab) &&
reroutingCTAs()}
@@ -567,7 +567,7 @@ const NewRoutingManager = ({
throw new Error(t("something_went_wrong"));
}
return (
<div className="flex flex-col space-y-2">
<div className="flex flex-col stack-y-2">
<span className="text-default" data-testid="reroute-preview-event-type">
<span className="font-semibold">{t("event_type")}:</span>{" "}
<a
@@ -666,9 +666,9 @@ const CurrentRoutingStatus = ({
const bookerUrl = useBookerUrl();
if (!fullSlug) return null;
return (
<div className="bg-muted flex flex-col space-y-3 rounded-md p-4 text-sm">
<div className="bg-cal-muted flex flex-col stack-y-3 rounded-md p-4 text-sm">
<h2 className="text-emphasis font-medium">{t("current_routing_status")}</h2>
<div className="flex flex-col space-y-2">
<div className="flex flex-col stack-y-2">
<span className="text-default" data-testid="current-routing-status-event-type">
<span className="font-semibold">{t("event_type")}:</span>{" "}
<a
@@ -38,7 +38,7 @@ export const RescheduleDialog = (props: IRescheduleDialog) => {
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
<DialogContent enableOverflow data-testid="reschedule-dialog">
<div className="flex flex-row md:space-x-3">
<div className="bg-subtle hidden h-10 w-10 flex-shrink-0 justify-center rounded-full md:flex">
<div className="bg-subtle hidden h-10 w-10 shrink-0 justify-center rounded-full md:flex">
<Icon name="clock" className="m-auto h-6 w-6" />
</div>
<div className="w-full md:pt-1">
+1 -1
View File
@@ -76,7 +76,7 @@ export const ErrorPage: React.FC<Props> = (props) => {
<p className="text-default mb-4 max-w-2xl text-sm">
Please provide the following text when contacting support to better help you:
</p>
<pre className="bg-emphasis text-emphasis w-full max-w-2xl whitespace-normal break-words rounded-md p-4">
<pre className="bg-emphasis text-emphasis w-full max-w-2xl whitespace-normal wrap-break-word rounded-md p-4">
{message}
</pre>
</div>
@@ -29,7 +29,7 @@ const ConnectedCalendarItem = (prop: IConnectedCalendarItem) => {
<p className="font-sans text-sm font-bold leading-5">
{name}
{/* Temporarily removed till we use it on another place */}
{/* <span className="mx-1 rounded-[4px] bg-success py-[2px] px-[6px] font-sans text-xs font-medium text-green-600">
{/* <span className="mx-1 rounded-[4px] bg-cal-success py-[2px] px-[6px] font-sans text-xs font-medium text-green-600">
{t("default")}
</span> */}
</p>
@@ -49,7 +49,7 @@ const ConnectedCalendarItem = (prop: IConnectedCalendarItem) => {
{t("edit")}
</Button> */}
</div>
<div className="border-subtle h-[1px] w-full border-b" />
<div className="border-subtle h-px w-full border-b" />
<div>
<ul className="p-4">
{calendars?.map((calendar, i) => (
@@ -59,7 +59,7 @@ const ConnectedVideoStepInner = ({
const { data: metadata } = result;
const defaultConferencingApp = metadata?.defaultConferencingApp?.appSlug;
return (
<List className="bg-default border-subtle divide-subtle scroll-bar mx-1 max-h-[45vh] divide-y !overflow-y-scroll rounded-md border p-0 sm:mx-0">
<List className="bg-default border-subtle divide-subtle scroll-bar mx-1 max-h-[45vh] divide-y overflow-y-scroll! rounded-md border p-0 sm:mx-0">
{queryConnectedVideoApps?.items &&
queryConnectedVideoApps?.items.map((item) => {
if (item.slug === "daily-video") return null; // we dont want to show daily here as it is installed by default
@@ -69,7 +69,7 @@ const SetupAvailability = (props: ISetupAvailabilityProps) => {
}
}
}}>
<div className="bg-default dark:text-inverted text-emphasis border-subtle w-full rounded-md border dark:bg-opacity-5">
<div className="bg-default dark:text-inverted text-emphasis border-subtle w-full rounded-md border">
<Schedule control={availabilityForm.control} name="schedule" weekStart={1} />
</div>
@@ -68,7 +68,7 @@ const UserSettings = (props: IUserSettingsProps) => {
return (
<form onSubmit={onSubmit}>
<div className="space-y-6">
<div className="stack-y-6">
{/* Username textfield: when not coming from signup */}
{!props.hideUsername && <UsernameAvailabilityField />}
@@ -3,7 +3,7 @@ import { Icon } from "@calcom/ui/components/icon";
const TwoFactorModalHeader = ({ title, description }: { title: string; description: string }) => {
return (
<div className="mb-4 sm:flex sm:items-start">
<div className="bg-brand text-brandcontrast dark:bg-darkmodebrand dark:text-darkmodebrandcontrast mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-opacity-5 sm:mx-0 sm:h-10 sm:w-10">
<div className="bg-brand/5 text-brandcontrast dark:bg-darkmodebrand/5 dark:text-darkmodebrandcontrast mx-auto flex h-12 w-12 shrink-0 items-center justify-center rounded-full sm:mx-0 sm:h-10 sm:w-10">
<Icon name="shield" className="text-inverted h-6 w-6" />
</div>
<div className="mt-3 text-center sm:ml-4 sm:mt-0 sm:text-left">
@@ -11,7 +11,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@calcom/ui/components/dropdown";
import { InputError } from "@calcom/ui/components/form";
import { InputError, Input } from "@calcom/ui/components/form";
import type { FormValues } from "~/settings/my-account/profile-view";
@@ -47,9 +47,9 @@ const CustomEmailTextField = ({
className={`border-default mt-2 flex w-full items-center rounded-[10px] border ${
inputFocus ? "ring-brand-default border-neutral-300 ring-2" : ""
}`}>
<input
<Input
{...formMethods.register(formMethodFieldName)}
className="flex-1 bg-transparent px-3 py-1.5 text-sm outline-none"
className="focus:ring-none! focus:shadow-none! flex-1 border-none bg-transparent px-3 py-1.5 text-sm outline-none"
data-testid={dataTestId}
onFocus={() => setInputFocus(true)}
onBlur={() => setInputFocus(false)}
@@ -124,7 +124,7 @@ export const OAuthClientForm = ({
<TextField
type="url"
required={index === 0}
className="w-[100%]"
className="w-full"
label=""
disabled={isFormDisabled}
{...register(`redirectUris.${index}.uri` as const)}
@@ -197,7 +197,7 @@ export const OAuthClientForm = ({
<TextField
type="url"
label="Booking redirect uri"
className="w-[100%]"
className="w-full"
{...register("bookingRedirectUri")}
disabled={isFormDisabled}
/>
@@ -208,7 +208,7 @@ export const OAuthClientForm = ({
<TextField
type="url"
label="Booking cancel redirect uri"
className="w-[100%]"
className="w-full"
{...register("bookingCancelRedirectUri")}
disabled={isFormDisabled}
/>
@@ -219,7 +219,7 @@ export const OAuthClientForm = ({
<TextField
type="url"
label="Booking reschedule redirect uri"
className="w-[100%]"
className="w-full"
{...register("bookingRescheduleRedirectUri")}
disabled={isFormDisabled}
/>
@@ -24,7 +24,7 @@ export const PlatformBillingCard = ({
return (
<div className="border-subtle max-w-[450px] rounded-2xl border p-5 md:mx-4">
<div className="pb-5">
<h1 className="border-b-[1px] pb-2 pt-1 text-center text-2xl font-bold">
<h1 className="border-b pb-2 pt-1 text-center text-2xl font-bold">
{plan}
{currentPlan && (
<>
@@ -51,7 +51,7 @@ export const PlatformBillingCard = ({
<Button
loading={isLoading}
onClick={handleSubscribe}
className="flex w-[100%] items-center justify-center">
className="flex w-full items-center justify-center">
{pricing !== undefined ? t("subscribe") : t("schedule_a_time")}
</Button>
</div>
@@ -63,7 +63,7 @@ export const PlatformPricing = ({ teamId, teamPlan, heading }: PlatformPricingPr
}
return (
<div className="flex h-auto flex-col items-center justify-center px-5 py-10 md:px-10 lg:h-[100%]">
<div className="flex h-auto flex-col items-center justify-center px-5 py-10 md:px-10 lg:h-full">
{heading}
<div>
<div className="grid grid-cols-1 gap-5 md:grid-cols-2 lg:grid-cols-4">
+2 -2
View File
@@ -20,7 +20,7 @@ export const AdminUserContainer = (props: React.ComponentProps<typeof AdminUser>
<form
id="wizard-step-1"
name="wizard-step-1"
className="space-y-4"
className="stack-y-4"
onSubmit={(e) => {
e.preventDefault();
props.onSuccess();
@@ -106,7 +106,7 @@ export const AdminUser = (props: {
return (
<FormProvider {...formMethods}>
<form id="wizard-step-1" name="wizard-step-1" className="space-y-4" onSubmit={onSubmit}>
<form id="wizard-step-1" name="wizard-step-1" className="stack-y-4" onSubmit={onSubmit}>
<div>
<Controller
name="username"
+3 -3
View File
@@ -19,7 +19,7 @@ const ChooseLicense = (
return (
<form
{...rest}
className="space-y-4"
className="stack-y-4"
onSubmit={(e) => {
e.preventDefault();
onSubmit(value);
@@ -36,7 +36,7 @@ const ChooseLicense = (
<RadioGroup.Item value="FREE">
<div
className={classNames(
"bg-default cursor-pointer space-y-2 rounded-md border p-4 hover:border-black",
"bg-default cursor-pointer stack-y-2 rounded-md border p-4 hover:border-black",
value === "FREE" && "ring-2 ring-black"
)}>
<h2 className="font-cal text-emphasis text-xl">{t("agplv3_license")}</h2>
@@ -51,7 +51,7 @@ const ChooseLicense = (
</RadioGroup.Item>
<RadioGroup.Item value="EE" disabled>
<Link href="https://cal.com/sales" target="_blank">
<div className={classNames("bg-default h-full cursor-pointer space-y-2 rounded-md border p-4")}>
<div className={classNames("bg-default h-full cursor-pointer stack-y-2 rounded-md border p-4")}>
<h2 className="font-cal text-emphasis text-xl">{t("custom_plan")}</h2>
<p className="font-medium text-green-800">{t("contact_sales")}</p>
<p className="text-subtle">Build on top of Cal.com</p>
@@ -89,7 +89,7 @@ const EnterpriseLicense = (
return (
<FormProvider {...formMethods}>
<form {...rest} className="bg-default space-y-4 rounded-md px-8 py-10" onSubmit={handleSubmit}>
<form {...rest} className="bg-default stack-y-4 rounded-md px-8 py-10" onSubmit={handleSubmit}>
<div>
<Button
className="w-full justify-center text-lg"
@@ -101,7 +101,7 @@ const LicenseSelection = (
return (
<form
{...rest}
className="space-y-6"
className="stack-y-6"
onSubmit={(e) => {
e.preventDefault();
if (value === "FREE") {
@@ -119,7 +119,7 @@ const LicenseSelection = (
<RadioGroup.Item value="FREE" className="h-full">
<div
className={classNames(
"bg-default h-full cursor-pointer space-y-2 rounded-md border p-4 hover:border-black",
"bg-default h-full cursor-pointer stack-y-2 rounded-md border p-4 hover:border-black",
value === "FREE" && "ring-2 ring-black"
)}>
<h2 className="font-cal text-emphasis text-xl">{t("agplv3_license")}</h2>
@@ -136,7 +136,7 @@ const LicenseSelection = (
<RadioGroup.Item value="EXISTING" className="h-full">
<div
className={classNames(
"bg-default h-full cursor-pointer space-y-2 rounded-md border p-4 hover:border-black",
"bg-default h-full cursor-pointer stack-y-2 rounded-md border p-4 hover:border-black",
value === "EXISTING" && "ring-2 ring-black"
)}>
<h2 className="font-cal text-emphasis text-xl">{t("enter_license_key")}</h2>
@@ -158,8 +158,8 @@ const LicenseSelection = (
{value === "EXISTING" && (
<FormProvider {...formMethods}>
<div className="bg-muted space-y-4 rounded-md px-4 py-3">
<div className="space-y-4">
<div className="bg-cal-muted stack-y-4 rounded-md px-4 py-3">
<div className="stack-y-4">
<div>
<Controller
name="licenseKey"
+1 -1
View File
@@ -16,7 +16,7 @@ const StepDone = (props: {
<form
id={`wizard-step-${props.currentStep}`}
name={`wizard-step-${props.currentStep}`}
className="flex justify-center space-y-4"
className="flex justify-center stack-y-4"
onSubmit={(e) => {
props.setIsPending(true);
e.preventDefault();
+4 -4
View File
@@ -30,15 +30,15 @@ const Member = ({ member, teamName }: { member: MemberType; teamName: string | n
<Link
key={member.id}
href={{ pathname: `${member.bookerUrl}/${member.username}`, query: queryParamsToForward }}>
<div className="bg-default hover:bg-muted border-subtle group flex min-h-full flex-col space-y-2 rounded-md border p-4 transition hover:cursor-pointer sm:w-80">
<div className="bg-default hover:bg-cal-muted border-subtle group flex min-h-full flex-col stack-y-2 rounded-md border p-4 transition hover:cursor-pointer sm:w-80">
<UserAvatar noOrganizationIndicator size="md" user={member} />
<section className="mt-2 line-clamp-4 w-full space-y-1">
<section className="mt-2 line-clamp-4 w-full stack-y-1">
<p className="text-default font-medium">{member.name}</p>
<div className="text-subtle line-clamp-3 overflow-ellipsis text-sm font-normal">
<div className="text-subtle line-clamp-3 text-ellipsis text-sm font-normal">
{!isBioEmpty ? (
<>
<div
className=" text-subtle break-words text-sm [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600"
className=" text-subtle wrap-break-word text-sm [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600"
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: markdownToSafeHTML(member.bio) }}
/>
+2 -2
View File
@@ -20,12 +20,12 @@ export default function AuthContainer(props: React.PropsWithChildren<Props>) {
{props.heading && <h2 className="font-cal text-emphasis text-center text-3xl">{props.heading}</h2>}
</div>
{props.loading && (
<div className="bg-muted absolute z-50 flex h-screen w-full items-center">
<div className="bg-cal-muted absolute z-50 flex h-screen w-full items-center">
<Loader />
</div>
)}
<div className="mb-auto mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="bg-default dark:bg-muted border-subtle mx-2 rounded-md border px-4 py-10 sm:px-10">
<div className="bg-default dark:bg-cal-muted border-subtle mx-2 rounded-md border px-4 py-10 sm:px-10">
{props.children}
</div>
<div className="text-default mt-8 text-center text-sm">{props.footerText}</div>
+1 -1
View File
@@ -25,7 +25,7 @@ export default function ModalContainer(
"sm:w-full sm:max-w-lg ": !props.wide,
"sm:w-4xl sm:max-w-4xl": props.wide,
"overflow-auto": props.scroll,
"!p-0": props.noPadding,
"p-0!": props.noPadding,
}
)}>
{props.children}
@@ -10,7 +10,7 @@ export default function SettingInputContainer({
htmlFor?: string;
}) {
return (
<div className="space-y-3">
<div className="stack-y-3">
<div className="block sm:flex">
<div className="min-w-48 mb-4 sm:mb-0">
<label htmlFor={htmlFor} className="text-default mt-1 flex text-sm font-medium">
@@ -18,7 +18,7 @@ export default function SettingInputContainer({
{label}
</label>
</div>
<div className="w-full flex-grow">{Input}</div>
<div className="w-full grow">{Input}</div>
</div>
</div>
);
@@ -202,7 +202,7 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
<span
className={classNames(
isInputUsernamePremium ? "border border-orange-400 " : "",
"border-default bg-muted text-subtle hidden h-8 items-center rounded-l-md border border-r-0 px-3 text-sm md:inline-flex"
"border-default bg-cal-muted text-subtle hidden h-8 items-center rounded-l-md border border-r-0 px-3 text-sm md:inline-flex"
)}>
{process.env.NEXT_PUBLIC_WEBSITE_URL.replace("https://", "").replace("http://", "")}/
</span>
@@ -216,7 +216,7 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
autoCorrect="none"
disabled={disabled}
className={classNames(
"border-l-1 my-0 rounded-md font-sans text-sm leading-4 focus:!ring-0 sm:rounded-l-none",
"border-l my-0 rounded-md font-sans text-sm leading-4 focus:ring-0! sm:rounded-l-none",
isInputUsernamePremium
? "border border-orange-400 focus:border focus:border-orange-400"
: "border focus:border",
+1 -1
View File
@@ -41,7 +41,7 @@ function AppsSearch({
<TextField
addOnLeading={<Icon name="search" className="text-subtle h-4 w-4" />}
addOnClassname="!border-muted"
containerClassName={classNames("focus:!ring-offset-0 m-1", className)}
containerClassName={classNames("focus:ring-offset-0! m-1", className)}
type="search"
autoComplete="false"
onChange={onChange}
@@ -233,7 +233,7 @@ const OnboardingPage = ({
return (
<div
key={pathname}
className="dark:bg-brand dark:text-brand-contrast text-emphasis min-h-screen px-4"
className="text-emphasis min-h-screen px-4"
data-testid="onboarding">
<div className="mx-auto py-6 sm:px-4 md:py-24">
<div className="relative">
@@ -21,7 +21,7 @@ function Success() {
const { t } = useLocale();
return (
<>
<div className="space-y-6">
<div className="stack-y-6">
<div>
<h2 className="font-cal text-emphasis mt-6 text-center text-3xl font-extrabold">
{t("password_updated")}
@@ -39,7 +39,7 @@ function Expired() {
const { t } = useLocale();
return (
<>
<div className="space-y-6">
<div className="stack-y-6">
<div>
<h2 className="font-cal text-emphasis mt-6 text-center text-3xl font-extrabold">{t("whoops")}</h2>
<h2 className="text-emphasis text-center text-3xl font-extrabold">{t("request_is_expired")}</h2>
@@ -114,7 +114,7 @@ function PasswordResetForm({
return (
<Form
className="space-y-6"
className="stack-y-6"
form={formMethods}
style={
{
@@ -76,7 +76,7 @@ export default function ForgotPassword(props: PageProps) {
const Success = () => {
return (
<div className="space-y-6 text-sm leading-normal ">
<div className="stack-y-6 text-sm leading-normal ">
<p className="">{t("password_reset_email", { email })}</p>
<p className="">{t("password_reset_leading")}</p>
{error && <p className="text-center text-red-600">{error.message}</p>}
@@ -103,9 +103,9 @@ export default function ForgotPassword(props: PageProps) {
{success && <Success />}
{!success && (
<>
<div className="space-y-6">{error && <p className="text-red-600">{error.message}</p>}</div>
<div className="stack-y-6">{error && <p className="text-red-600">{error.message}</p>}</div>
<form
className="space-y-6"
className="stack-y-6"
onSubmit={handleSubmit}
action="#"
style={
@@ -125,11 +125,11 @@ export default function ForgotPassword(props: PageProps) {
placeholder="john.doe@example.com"
required
/>
<div className="space-y-2">
<div className="stack-y-2">
<Button
className="w-full justify-center bg-white hover:bg-black hover:text-white"
className="w-full justify-center"
type="submit"
color="primary"
color="secondary"
disabled={loading}
aria-label={t("request_password_reset")}
loading={loading}>
+7 -7
View File
@@ -177,7 +177,7 @@ export default function Login({
: isSAMLLoginEnabled && !isPending && data?.connectionExists;
return (
<div className="dark:bg-brand dark:text-brand-contrast text-emphasis min-h-screen [--cal-brand-emphasis:#101010] [--cal-brand-subtle:#9CA3AF] [--cal-brand-text:white] [--cal-brand:#111827] dark:[--cal-brand-emphasis:#e1e1e1] dark:[--cal-brand-text:black] dark:[--cal-brand:white]">
<div className="text-emphasis min-h-screen [--cal-brand-emphasis:#101010] [--cal-brand-subtle:#9CA3AF] [--cal-brand-text:white] [--cal-brand:#111827] dark:[--cal-brand-emphasis:#e1e1e1] dark:[--cal-brand-text:black] dark:[--cal-brand:white]">
<AuthContainer
showLogo
heading={twoFactorRequired ? t("2fa_code") : t("welcome_back")}
@@ -193,7 +193,7 @@ export default function Login({
<FormProvider {...methods}>
{!twoFactorRequired && (
<>
<div className="space-y-3">
<div className="stack-y-3">
{isGoogleLoginEnabled && (
<Button
color="primary"
@@ -224,11 +224,11 @@ export default function Login({
{(isGoogleLoginEnabled || displaySSOLogin) && (
<div className="my-8">
<div className="relative flex items-center">
<div className="border-subtle flex-grow border-t" />
<span className="text-subtle mx-2 flex-shrink text-sm font-normal leading-none">
<div className="border-subtle grow border-t" />
<span className="text-subtle mx-2 shrink text-sm font-normal leading-none">
{t("or").toLocaleLowerCase()}
</span>
<div className="border-subtle flex-grow border-t" />
<div className="border-subtle grow border-t" />
</div>
</div>
)}
@@ -239,8 +239,8 @@ export default function Login({
<div>
<input defaultValue={csrfToken || undefined} type="hidden" hidden {...register("csrfToken")} />
</div>
<div className="space-y-6">
<div className={classNames("space-y-6", { hidden: twoFactorRequired })}>
<div className="stack-y-6">
<div className={classNames("stack-y-6", { hidden: twoFactorRequired })}>
<EmailField
id="email"
label={t("email_address")}
+1 -1
View File
@@ -43,7 +43,7 @@ export function Logout(props: PageProps) {
return (
<AuthContainer showLogo>
<div className="mb-4">
<div className="bg-success mx-auto flex h-12 w-12 items-center justify-center rounded-full">
<div className="bg-cal-success mx-auto flex h-12 w-12 items-center justify-center rounded-full">
<Icon name="check" className="h-6 w-6 text-green-600" />
</div>
<div className="mt-3 text-center sm:mt-5">
@@ -24,6 +24,8 @@ export default function Authorize() {
const client_id = (searchParams?.get("client_id") as string) || "";
const state = searchParams?.get("state") as string;
const scope = searchParams?.get("scope") as string;
const code_challenge = searchParams?.get("code_challenge") as string;
const code_challenge_method = searchParams?.get("code_challenge_method") as string;
const queryString = searchParams?.toString();
@@ -46,6 +48,30 @@ export default function Authorize() {
onSuccess: (data) => {
window.location.href = `${client?.redirectUri}?code=${data.authorizationCode}&state=${state}`;
},
onError: (error) => {
let oauthError = "server_error";
if (error.data?.code === "BAD_REQUEST") {
oauthError = "invalid_request";
} else if (error.data?.code === "UNAUTHORIZED") {
oauthError = "unauthorized_client";
}
const errorParams = new URLSearchParams({
error: oauthError,
});
if (error.message) {
errorParams.append("error_description", error.message);
}
if (state) {
errorParams.append("state", state);
}
if (client?.redirectUri) {
window.location.href = `${client.redirectUri}${client.redirectUri.includes("?") ? "&" : "?"}${errorParams.toString()}`;
}
},
});
const mappedProfiles = data
@@ -116,7 +142,7 @@ export default function Authorize() {
options={mappedProfiles}
/>
<div className="mb-4 mt-5 font-medium">{t("allow_client_to", { clientName: client.name })}</div>
<ul className="space-y-4 text-sm">
<ul className="stack-y-4 text-sm">
<li className="relative pl-5">
<span className="absolute left-0">&#10003;</span>{" "}
{t("associate_with_cal_account", { clientName: client.name })}
@@ -144,7 +170,7 @@ export default function Authorize() {
<div>
<Icon name="info" className="mr-1 mt-0.5 h-4 w-4" />
</div>
<div className="ml-1 ">
<div className="ml-1">
<div className="mb-1 text-sm font-medium">
{t("allow_client_to_do", { clientName: client.name })}
</div>
@@ -157,7 +183,13 @@ export default function Authorize() {
className="mr-2"
color="minimal"
onClick={() => {
window.location.href = `${client.redirectUri}`;
const separator = client.redirectUri.includes("?") ? "&" : "?";
const params = new URLSearchParams();
params.set("error", "access_denied");
if (state) {
params.set("state", state);
}
window.location.href = `${client.redirectUri}${separator}${params.toString()}`;
}}>
{t("go_back")}
</Button>
@@ -169,6 +201,8 @@ export default function Authorize() {
teamSlug: selectedAccount?.value.startsWith("team/")
? selectedAccount?.value.substring(5)
: undefined, // team account starts with /team/<slug>
codeChallenge: code_challenge || undefined,
codeChallengeMethod: (code_challenge_method as "S256") || undefined,
});
}}
data-testid="allow-button">

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