Compare commits

...
197 Commits
Author SHA1 Message Date
Omar López 9bc40a3eb6 v3.3.0
release.yml / release (push) Canceled after 0s
2023-09-14 12:50:43 -07:00
Keith Williamsandzomars bd21b2dfe1 fix: Import prisma (#11369)
Co-authored-by: zomars <zomars@me.com>
2023-09-14 19:47:25 +00:00
Crowdin Bot d60fb081c9 New Crowdin translations by Github Action 2023-09-14 16:56:47 +00:00
197b435b6f fix: Duplicate calendar events (#11327)
Co-authored-by: Shivam Kalra <shivamkalra98@gmail.com>
Co-authored-by: zomars <zomars@me.com>
Co-authored-by: Alan <alannnc@gmail.com>
2023-09-14 09:53:58 -07:00
dihedral add297b09a fix: use selected duration when checking for conflicts for new bookings (#10713) 2023-09-14 10:40:49 -04:00
Ujwal Kumar d65f0ee0e8 fix: Allow attendee first and last name from attendee name when name … (#11287) 2023-09-14 10:24:58 -04:00
Hariom Balhara 59c0a71db7 fix: Routing Forms - Forward Query Params and show the error message in case of validation error in headless router (#11338) 2023-09-14 10:05:22 -04:00
Hariom Balhara d50151fe08 Fix yarn.lock (#11364) 2023-09-14 13:41:56 +00:00
Kiran K d2b0dd8eab fix: SAML SSO - Stop relying on invitedTo column and use membership table instead (#11331) 2023-09-14 14:57:45 +02:00
Joe Au-Yeung bb0574dff8 chore: Outlook processBusyTimes iterate busy times once (#11350) 2023-09-14 14:56:31 +02:00
sean-brydon 49364eee9a Import prisma (#11358) 2023-09-14 09:18:19 -03:00
Peer Richelsen adb0530d69 feat: getalby.com app (#11347) 2023-09-14 08:35:13 -03:00
+18 a2d1dbebba feat: editmode availbility slider (#11197)
* Add loading data and banner

* [WIP] hasEditPerms middleware

* fix: type error in booker (#11011)

* New Crowdin translations by Github Action

* refactor: removed redundant test (#10785)

Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
Co-authored-by: Shivam Kalra <shivamkalra98@gmail.com>

* feat: 2fa backup codes (#10600)

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

* Delete add-PRs-to-project-reviewing-PRs.yml (#11008)

Co-authored-by: alannnc <alannnc@gmail.com>

* New Crowdin translations by Github Action

* fix: multiple duration when booking (#11032)

* fix: other reported issues (#11015)

* fix: weird margin top in avatar

* fix: pending users are shown on booking page

* fix: avatar and naming issues

* fix: toast alignment and removing unneeded titles

* missing changes from toast improvements

* feat: empty state for teams without event types

* Removing console.log

* feat: cal ai (#10992)

Co-authored-by: nicktrn <55853254+nicktrn@users.noreply.github.com>
Co-authored-by: tedspare <ted.spare@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>

* New Crowdin translations by Github Action

* fix: meeting ended trigger for webhooks and zapier sometimes not working (#10946)

Co-authored-by: mohammed gehad <mohammed.gehad.1998@gmail.com>
Co-authored-by: Monto <138862352+monto7926@users.noreply.github.com>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>

* feat: team admin: see connected apps of team members (#11036)

* added feature:team admin can see connected apps of members

* fixed the type error

* Update packages/lib/server/queries/teams/index.ts

* Minor fixes

---------

Co-authored-by: alannnc <alannnc@gmail.com>

* fix: lower case slugs in teams (#11026)

* fix: lower case slugs in teams

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* fix: use slugify

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

---------

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* refactor: improvements on german translation (#10898)

* fix: fix-tablet-menu-not-centered-sidebar (#11020)

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

* chore: add Popover in storybook (#11021)

Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
Co-authored-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* fix: Set mobile availability (#11027)

* chore: add ColorPicker in storybook (CALCOM-10760) (#10866)

Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>

* feat: adds next cold start profiler (#11014)

* Handle disabling of ORGANIZATIONS_ENABLED flag (#11041)

* New Crowdin translations by Github Action

* styles:dark mode color fix (#11004)

* chore: sheet darkmode and improve responsive (#11047)

* fix: handle collective multiple host on destinationCalendar (#10967)

* fix: include app data and credentials from DB (#11048)

* include app data and credentials from DB

* Improve performance

* fix: Error when running storybook (#11037)

Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>

* fix: broken company profile link on team booking page (#10978)

* chore: add ErrorBoundary in storybook (CALCOM-10760) (#10872)

Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>

* feat: sorting for workflow and routing forms (#10780)

Co-authored-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>

* resolve zod versions across child packages (#11052)

* fix: List storybook file is empty (fix-list) (#10965)

Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>

* fix: admin org list without members (#11051)

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

* chore: add ToggleGroup in storybook (#10802)

Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>

* fix: add metadata to stripe payment intent (#11053)

* fix: Logo storybook file with invalid icons (fix-logo) (#11018)

Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>

* fix: Add controls for Select Field storybook file (#10936)

Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>

* fix: Fix tooltip control on ButtonPlayground storybook file (fix-ButtonTooltip) (#10937)

Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>

* fix: email embed – remove collapsible and permanently show times (#10996)

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: Mehul <mehulzr@gmail.com>

* chore: add Timezone Select in storybook (CALCOM-10760) (#10966)

Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>

* chore: add Switch in storybook (CALCOM-10760) (#10804)

Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>

* Sync packages

* fix: zod utils due to upgrade

* easy fix (#11054)

* fix sub quantity update stripe (#11057)

* v3.2.7

* Revert "feat: adds next cold start profiler (#11014)" (#11072)

This reverts commit 05631d093f.

* fix: Fixes username invite issue (#10998)

* Fixes username invite issue

* Ensure we only suggest email invites in org members

---------

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

* Fix sheet layout

* WIP permissions when userschedule doesnt match

* WIP get handler weird behaviour

* Update toast

* add disabled - handle membership overlap

* Handle permissions + perf improvments

* use input uid

* Remove Console.log

* Clean up

* Revert changes accidental

* Fix merge artifacts

* Remove dead code

* Remove code after return

* Update read permission check

* Revert avatar changes as fixed elsewhere

* Handle if user has not completed onboarding

* Disable button

* Update packages/lib/hasEditPermissionForUser.ts

* Correct Error throwing

* Update packages/features/timezone-buddy/components/AvailabilityEditSheet.tsx

* Fix type erro

* Add i18n

* Improve Spacing

* Update yarn.lock

---------

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
Co-authored-by: GitStart-Cal.com <121884634+gitstart-calcom@users.noreply.github.com>
Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
Co-authored-by: Shivam Kalra <shivamkalra98@gmail.com>
Co-authored-by: nicktrn <55853254+nicktrn@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: alannnc <alannnc@gmail.com>
Co-authored-by: Leo Giovanetti <hello@leog.me>
Co-authored-by: DexterStorey <36115192+DexterStorey@users.noreply.github.com>
Co-authored-by: tedspare <ted.spare@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Monto <138862352+montocoder@users.noreply.github.com>
Co-authored-by: mohammed gehad <mohammed.gehad.1998@gmail.com>
Co-authored-by: Monto <138862352+monto7926@users.noreply.github.com>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
Co-authored-by: Abhijeet Singh <asingh9829@gmail.com>
Co-authored-by: Kamil B. Demirci <kamil.demirci@indyaner.ch>
Co-authored-by: Denzil Samuel <71846487+samueldenzil@users.noreply.github.com>
Co-authored-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Omar López <zomars@me.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: Sahil Padvi <71093044+Sahil25061999@users.noreply.github.com>
Co-authored-by: Patel Divyesh <pateldivyesh1323@gmail.com>
Co-authored-by: neo773 <62795688+neo773@users.noreply.github.com>
Co-authored-by: Mehul <mehulzr@gmail.com>
2023-09-14 09:45:13 +01:00
alannnc 605e744a58 fix: remove avatar form user select when loading insights (#11353) 2023-09-14 09:37:41 +02:00
alannnc aab0d6b18a fix: paypal save event type and payment (#11181)
* fix-paypal-save

* change type to use appId instead

* Remove duplicated code
2023-09-14 00:29:03 -07:00
WesleyandHariom Balhara dfde17111c test: verify embed helpers (#11318)
* test: add test cases to `window.getEmbedNamespace`

* test: add test cases when window.name doesn't have cal-embed=

* test: add test case to `getembedTheme` returning the theme from embedStore

* test: add test to `getEmbedTheme` returning the theme from query params

* Small refactor to reduce the time taken by tests and fix missing typings for embed functions on window

* Fix weird test failure

* Fix type import error caught by api.test.test

---------

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2023-09-14 07:14:54 +00:00
Hariom Balhara 2591ad12df chore: Enable prettier on vitest files without causing test failures (#11190) 2023-09-14 08:58:08 +02:00
Kevin Crimiandalannnc 3a05ae6f74 feat: Add more metadata to Stripe transaction (#11221)
* Add booker info, event type title, and organizer info into Stripe metadata

* Add back bookingTitle in addition to eventTitle

* Remove .tool-versions

* Fix typo

---------

Co-authored-by: alannnc <alannnc@gmail.com>
2023-09-14 04:10:42 +00:00
efcfd7bb9b fix: when creating new user, set email handle as username (#11238)
* When creating new user, set email handle as username

* Accounting for external invited users

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Leo Giovanetti <hello@leog.me>
2023-09-13 20:46:41 -03:00
Leo Giovanetti bce62829b7 fix: org shell and org user settings avatars + org owner username (#11342) 2023-09-13 18:04:36 +00:00
Carina WollendorferandCarinaWolli f320e486a2 fix issue with duplicate children event types (#11337)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2023-09-13 17:04:14 +00:00
MeenuyD 8c96e04b51 fix: insights upgrade screen has no title and subtitle for upgrade screen (#11328) 2023-09-13 15:41:53 +02:00
9eb3698ea5 fix: multiple organizer address bug (#11299)
* fix: multiple organizer address bug

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* fix: add inPerson condition

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* fix: change options

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* Send _type along with the response to be able to determine what input was it. We can use that later

* Revert "Send _type along with the response to be able to determine what input was it. We can use that later"

This reverts commit c75ba57ad14b07d629b218d6e3eaede93b99ebaa.

* Update multiple organizer address test to verify the test fix

* Add a test to ensure that sending label would correctly add that label as location

---------

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2023-09-13 04:19:01 +00:00
sean-brydonandPeer Richelsen 8ff07807f9 Fix overflow (#11320)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-09-12 21:46:18 +00:00
Crowdin Bot 9c7f655329 New Crowdin translations by Github Action 2023-09-12 21:29:30 +00:00
Alex van Andel 5c72fca816 Revert "fix: improve UX of team invite by copy link (#11009)"
This reverts commit 0150041496.
2023-09-12 22:26:22 +01:00
Alex van Andel aeafa3ef0a v3.2.10 2023-09-12 18:17:53 +01:00
Joe Au-YeungandAlex van Andel 3d4a3c7a93 fix: Connect selected calendar to credential (#11283)
release.yml / release (push) Canceled after 0s
* Reduce the unhandled promise

* Connect selected calendar to credential

* Write credential to selected calendar

* Type fix

* Handle catching promises in separate threads

* Only call to list calendars if there are no credentialIds

* Changed type of IntegrationCalendar from undef to null

* Adding missing property to getting started

* Also add calendars.tsx

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2023-09-12 18:09:05 +01:00
alannnc 1c1625d3fc fix: org admin delete other teams (#11247)
* fix org-admin delete other teams

* Fix delete from teams to delete from orgs endpoint
2023-09-12 16:19:17 +00:00
alannnc 75f9f045b8 fix: payments on round-robin events (#11248)
* Fix condition when no users are found for event type

* fix for types
2023-09-12 08:59:32 -07:00
Leo Giovanetti 86af383f6c feat: autolink org on provider signup (#11267)
* feat: autolink org on provider signup

* Adding env var in example

* slugifying inferred org username

* Updated org README

* Optional env var for autolinking added to readme

* Typo

* md syntax correction

* Verifying user when autolinked

* Also verifying users invited to org

* Applying feedback

* Scoping to Google for now

* Updating comment

* Updating readme with Google scoping
2023-09-12 15:17:34 +00:00
Keith WilliamsandAlex van Andel 84408025ed perf: pre-load booking data for all users (#11243)
* perf: pre-load booking data for all users

* Fixing property issues

* Fixes

* Removed unwanted console.log

* Made comment for intention of refactor more clear

* fix: types

* _count is optional

* Assign to const bookings

* Added mock for prisma booking

* Fixed unit tests

* Added lint ignore for prisma field

* Update bookingScenario.ts

* Fix linting

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2023-09-12 14:12:25 +01:00
Hariom Balhara 9fba8c548b fix: Prefill not working if firstAndLastName variant enabled in name field and name doesn't have any space (#11289) 2023-09-12 13:00:14 +02:00
sean-brydon 56f3647ec2 Fix max w (#11294) 2023-09-12 09:59:40 +00:00
GitStart-Cal.comandgitstart-calcom a5cb3ce573 feat: Create VerifyCodeDialog storybook file (#11286)
Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
2023-09-12 00:09:13 +02:00
Udit Takkar 7581f4d641 fix: add inline color in og image (#11280) 2023-09-12 00:08:27 +02:00
Crowdin Bot a1d67b256e New Crowdin translations by Github Action 2023-09-11 18:41:56 +00:00
Joe Au-Yeung 75f9dad16e fix: Selected calendar unhandled promise when removing calendars (#11281) 2023-09-11 14:39:27 -04:00
sean-brydonandLeo Giovanetti 0a0d212373 fix: Handle removing user from org (#11227)
* Handle removing user from org

* Address no password or username being set

---------

Co-authored-by: Leo Giovanetti <hello@leog.me>
2023-09-11 14:46:38 +00:00
b8794ba807 fix: Fixed team image not showing on event-types page (#11231)
Co-authored-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-09-11 11:37:42 +00:00
Ahsan AhmadandPeer Richelsen 9b8e4ed385 fix: README typo uppercase ID (#11254)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-09-11 13:19:13 +02:00
Somay Chauhan 997b5d5290 fix: "no teams" text not visibl in organization's public url (#11257) 2023-09-11 13:10:36 +02:00
Yash d7080b41cb fix: text-emphasis removed (#11228) 2023-09-11 13:05:03 +02:00
Ujwal Kumar 32754bd120 fix: add hardcoded strings to locales so they can be translated (#11266) 2023-09-11 13:00:38 +02:00
Meet Mota 13fd02d174 fix: smaller date overwrite when no date is selected (#11250) 2023-09-11 12:49:27 +02:00
Crowdin Bot d6cbac54e0 New Crowdin translations by Github Action 2023-09-11 10:36:13 +00:00
Udit Takkar 489ffdf49a fix: hide book a team member (#11271) 2023-09-11 10:33:30 +00:00
Hariom BalharaandJoe Au-Yeung 37b22977ee fix: Booking and Cancellation related text changes (#10744)
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
2023-09-10 17:00:51 +00:00
Crowdin Bot 848a040162 New Crowdin translations by Github Action 2023-09-08 21:31:37 +00:00
Leo Giovanetti 350d84a047 fix: showing profile settings of other teams (#11245)
* fix: showing profile settings of other teams

* Updating URL
2023-09-08 18:29:06 -03:00
Shubham SinghandPeer Richelsen 9c3cbee363 feat: preference to show/hide available seats count in events (#11109)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-09-08 11:37:26 -04:00
Peer RichelsenandUdit Takkar 7e2ad3cea9 fix: inverted location logo (#11233)
Co-authored-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
2023-09-08 15:59:05 +01:00
Alex van Andel 4ef52aa2c6 perf: Move query to get just the user email to the main get query (#11230) 2023-09-08 08:39:29 -03:00
Alex van Andel 3132c64205 perf: Improve getAggregatedAvailability by reducing reliance on Day.js (#11224) 2023-09-08 07:19:27 -03:00
Crowdin Bot f25f8e0909 New Crowdin translations by Github Action 2023-09-08 08:05:45 +00:00
Leo Giovanetti d8961f8eb9 fix: subteam invitation without teamId associated (#11222) 2023-09-08 09:02:12 +01:00
Crowdin Bot 9ff7cf650b New Crowdin translations by Github Action 2023-09-08 00:23:57 +00:00
ba4e717b59 chore: unify org data access (#11158)
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Omar López <zomars@me.com>
2023-09-07 21:21:04 -03:00
Udit Takkar f326a9a620 fix: eventName variables (#10924) 2023-09-07 16:52:07 -04:00
Omar López 95be16c358 v3.2.9
release.yml / release (push) Canceled after 0s
2023-09-07 13:14:59 -07:00
Peer Richelsen 5621507f7e chore: fixed an i18n string (#11200) 2023-09-07 17:27:22 +00:00
Mehul bc6d58fd47 fix: bad setState call at datePicker component (#11175) 2023-09-07 17:11:59 +00:00
WesleyandCarinaWolli db29cc1251 feat: send new booking uid on reschedule webhook (#10654)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2023-09-07 16:27:46 +00:00
Patel Divyesh 69888c57b0 fix: Fixed SVG icons on light and dark mode (#11173) 2023-09-07 17:34:11 +02:00
5c0da23b97 feat: User Avatar and Org Logo (#10700)
* Pass organization name & logo

* Overflow hidden

* Show org icon on public page

* Add org logo to large user avatars

* Clean up

* Add org name and logo to context

* Get org logo from /avatar.png endpoint

* Do not query for logo

* Remove name and logo from session middleware

* Type fix

* Set user onboarding org logo

* feat: organization avatar component (#10788)

Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>

* Type fixes

* Type fix

* Transition to org slug for organization avatar

* Address feedback

* Clean up

* Clean up

* Type fix

* fix: set avatar cache control (#11163)

* test: Integration tests for handleNewBooking (#11044)

Co-authored-by: Shivam Kalra <shivamkalra98@gmail.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>

* fix: booking_paid webhook and added new payment metadata (#11093)

* app store improvements, logos, dark mode, added screenshots, fixed author names (#11164)

* fix: mobile event types and avatars (#11184)

* New Crowdin translations by Github Action

* fix: updateProfile metadata overwrite (#11188)

Co-authored-by: alannnc <alannnc@gmail.com>

* New Crowdin translations by Github Action

---------

Co-authored-by: Sean Brydon <sean@cal.com>
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
Co-authored-by: Omar López <zomars@me.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: Shivam Kalra <shivamkalra98@gmail.com>
Co-authored-by: alannnc <alannnc@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Leo Giovanetti <hello@leog.me>
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2023-09-07 17:26:40 +02:00
Hariom BalharaandCarina Wollendorfer 5324ec8051 fix: Routing Form - Show responses in the order of fields (#11143)
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
2023-09-07 15:22:39 +00:00
Crowdin Bot b946f89ffb New Crowdin translations by Github Action 2023-09-07 12:33:00 +00:00
Crowdin Bot 3e3581fabc New Crowdin translations by Github Action 2023-09-07 12:29:44 +00:00
Crowdin Bot 05ac339233 New Crowdin translations by Github Action 2023-09-07 12:26:19 +00:00
Crowdin Bot ef4675ff44 New Crowdin translations by Github Action 2023-09-07 12:22:42 +00:00
sean-brydon 47dc85ee1b chore: fixing darkmode data table (#11195) 2023-09-07 14:18:52 +02:00
Crowdin Bot 50342e33ed New Crowdin translations by Github Action 2023-09-06 23:43:28 +00:00
Omar Lópezandalannnc eebcb636f1 fix: updateProfile metadata overwrite (#11188)
Co-authored-by: alannnc <alannnc@gmail.com>
2023-09-06 16:40:50 -07:00
Crowdin Bot ed2ced899a New Crowdin translations by Github Action 2023-09-06 21:43:37 +00:00
Leo Giovanetti b7ae8c9594 fix: mobile event types and avatars (#11184) 2023-09-06 23:41:03 +02:00
Peer Richelsen fce98ebb13 app store improvements, logos, dark mode, added screenshots, fixed author names (#11164) 2023-09-07 01:17:04 +05:30
alannnc b81b221bf5 fix: booking_paid webhook and added new payment metadata (#11093) 2023-09-06 12:40:27 -07:00
f9eb335d0b test: Integration tests for handleNewBooking (#11044)
Co-authored-by: Shivam Kalra <shivamkalra98@gmail.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
2023-09-06 12:23:53 -07:00
Omar López d7d7bcd651 fix: set avatar cache control (#11163) 2023-09-06 12:22:13 -07:00
Crowdin Bot 4c861b9e67 New Crowdin translations by Github Action 2023-09-06 18:32:09 +00:00
Carina WollendorferandCarinaWolli 2fa704c39b fix: add make invite link to setup page (#11177)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2023-09-06 14:29:13 -04:00
Crowdin Bot 9ccdf21d75 New Crowdin translations by Github Action 2023-09-06 15:25:19 +00:00
GitStart-Cal.comandgitstart-calcom 9d6148da42 chore: add Wizard in storybook (CALCOM-10760) (#11166)
Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
2023-09-06 17:22:02 +02:00
MehulandUdit Takkar bf437b7d6b fix: event duration can be 0 (#11145)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2023-09-06 17:21:24 +02:00
Udit Takkar 3701fcd68b fix: teams page error (#11171) 2023-09-06 11:38:21 +02:00
GitStart-Cal.comandgitstart-calcom 2f90dc7cb6 fix: DataTable storybook file is not rendering (#10984)
Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
2023-09-06 09:18:06 +01:00
alannnc 829f90b82f chore: remove unused queries on user event types (#11159)
* remove unused queries on user event types

* Fix types
2023-09-06 05:00:06 -03:00
Crowdin Bot 3539693729 New Crowdin translations by Github Action 2023-09-06 05:11:51 +00:00
Joe Au-Yeung 868ba4396d fix: Installing Zoho CRM (#11161)
* Fix installing Zoho CRM

* Clean up
2023-09-06 05:08:59 +00:00
zomars d06e36a5cd Update yarn.lock 2023-09-05 14:46:14 -07:00
Crowdin Bot f61c9d9bbf New Crowdin translations by Github Action 2023-09-05 21:39:15 +00:00
Leo Giovanetti a2d521e001 fix: i18n issues reported by crowdin (#11160) 2023-09-05 18:36:17 -03:00
Omar López bf9c881d16 v3.2.8
release.yml / release (push) Canceled after 0s
2023-09-05 13:41:18 -07:00
alannnc 6dd0f9ce29 fix: selected organizer when creating event (#11157) 2023-09-05 13:38:59 -07:00
d34db7aef6 fix: Fix wrong footer Button in some languages (#10873)
Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Shivam Kalra <shivamkalra98@gmail.com>
2023-09-05 18:44:46 +00:00
Keith WilliamsandAlan b4b90f2d27 perf: Removed unused queries for user event types (#10568)
* perf: Removed unused queries for user event types

* Added empty children array

* Refactor to clean up

* Fixing type issue

* Removed extra call to get raw event types

* Temporarily putting children back

* Removing include from children

* Added check for children

* Put children back for testing

---------

Co-authored-by: Alan <alannnc@gmail.com>
2023-09-05 11:35:02 -07:00
Peer Richelsen 3f0380617a chore: removed gravatar (#11153) 2023-09-05 19:56:02 +02:00
Udit Takkar 7143923ea4 fix: rescheduling in require confirmation (#11152)
## What does this PR do?

Fixes https://github.com/calcom/cal.com/issues/11075

BEFORE:

Meeting has been rescheduled email and calendar event after submitting booking without confirming the meeting (require confirmation event type).

<img width="1086" alt="Screenshot 2023-09-05 at 7 45 44 PM" src="https://github.com/calcom/cal.com/assets/53316345/574c8d2f-3698-44ea-9466-aef162e09dd5">

AFTER:-

event scheduled email and calendar event created only after "awaiting approval" meeting has been confirmed.

<img width="1086" alt="Screenshot 2023-09-05 at 7 47 22 PM" src="https://github.com/calcom/cal.com/assets/53316345/af19ccea-d418-4a40-a9c6-88782522df53">


## Type of change

<!-- Please delete bullets that are not relevant. -->

- [x] Bug fix (non-breaking change which fixes an issue)

## How should this be tested?

1) Create a requires confirmation event type
2) open incognito tab or another browser and book a meeting (Attendee)
3) Confirm the booking (Organizer) and attendee would get meeting schedule email
4) Get the reschedule link from attendee's email and reschedule the booking

Both attendee and organiser shouldn't get booking email or calendar event  before organiser has accepted the meeting
2023-09-05 10:52:47 -07:00
DexterStorey dfccf4604f fix: better timezones and error handling in ai tools (#11085)
* current time in user timezone, let ai handle 401

* update agent.ts

* simple onboarding for non cal.com users

* reverting this line - unecessary

* v1.0.1
2023-09-05 13:56:26 +01:00
VijayandHariom Balhara c12abd75a8 fix: #10973 back button not working as expected in embed (#11073)
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2023-09-05 15:40:09 +05:30
Crowdin Bot 2e12f09f05 New Crowdin translations by Github Action 2023-09-04 23:12:36 +00:00
alannnc 4258da1df5 remove double call on useSchedule() (#11141) 2023-09-04 23:10:19 +00:00
Hariom Balhara 41ef354c7b fix: FORM_SUBMITTED webhook payload change and support for Team Webhooks with it (#10986) 2023-09-04 17:04:57 -04:00
alannncandzomars 8891641953 hotfix: missing gCal calendarId (#11139)
Co-authored-by: zomars <zomars@me.com>
2023-09-04 20:25:39 +00:00
Udit Takkar 0150041496 fix: improve UX of team invite by copy link (#11009) 2023-09-04 12:43:37 -07:00
Carina WollendorferandCarinaWolli 13f9e97015 fix: variables for custom webhook payload (#11135)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2023-09-04 11:01:12 -07:00
Peer Richelsen 3c5901edb8 chore: cal.ai redirect (#11130) 2023-09-04 16:21:37 +00:00
Madhan KumarandAlan d26bab5880 fix: Require an 'Email' placeholder in the 'Add guest' field. (#11123)
* Adding_Email_PlaceHolder_Text

Signed-off-by: Madhan Kumar M <madhankumar26291@gmail.com>

* Adding_Email_PlaceHolder_Text

Signed-off-by: Madhan Kumar M <madhankumar26291@gmail.com>

* Adding_Email_PlaceHolder_Text

Signed-off-by: Madhan Kumar M <madhankumar26291@gmail.com>

* fixes

* fixes

---------

Signed-off-by: Madhan Kumar M <madhankumar26291@gmail.com>
Co-authored-by: Alan <alannnc@gmail.com>
2023-09-04 09:13:12 -07:00
Udit Takkar 1c451a8d51 fix: installed badge (#11131) 2023-09-04 17:51:59 +02:00
Crowdin Bot 576261bf88 New Crowdin translations by Github Action 2023-09-04 15:50:49 +00:00
alannnc 7d944af7f6 fix: teams are listed on insights but then you cant find them on settings page 11087 cal 2401 (#11092)
* fix listing of other teams

* add more invalidate trpc queries

* Remove unused code

* Removed unnecesary flags
2023-09-04 08:47:53 -07:00
Crowdin Bot dfe7056c93 New Crowdin translations by Github Action 2023-09-04 14:55:48 +00:00
b27f01449a feat: make.com/integromat app (#8897)
* start make app integration

* setup integration

* add relevant env vars

* update app metadata

* import setup route in app setups

* fix typo

* add app store imports

* fix module import error

* update make readme

* move scheduler to app-store utils

* move add subscription to node scheduler

* move delete subscription to scheduler

* subscribe unsubscribe in zapier using common nodeScheduler

* fix lint errors

* revert settings.json

* update icon

* add app screenshots

* fix app description

* fix type errors

* update app code

* Delete .gitkeep

* delete unused template files

* get app invite link from env vars

* chore: handle error, cleanup readme, address review comments

* fix: update link in readme

* revert yarn.lock

* fix type errors

* Update packages/prisma/seed-app-store.ts

* Update .env.appStore.example

* Update .env.appStore.example

* update app readme

* fix param name in deleteSubcription

* fix listBookings handler

* Update turbo.json

* use default installation handler to install app

* use logger for console logs

* Fix inviteLink reading

* fix app setup handler

* Fix type issue

* always show app invite link

* fix type error

* add make invite link

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2023-09-04 14:52:34 +00:00
Udit Takkar 6dd77d0494 fix: create event type (#11046) 2023-09-04 15:02:30 +02:00
f73d38b632 fix: color tokens are not used in payment form (#10806)
Co-authored-by: VIneet Pradhan <138296762+vineetpradhan-dev@users.noreply.github.com>
Co-authored-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
2023-09-04 15:02:21 +02:00
Gaurav SutharandUdit Takkar a7d8f0c84e fix: add loading to delete event dialog (#11102)
* add loading to delete event dialog

* chore: use mutation isLoading

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

---------

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
Co-authored-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
2023-09-04 14:59:50 +02:00
Heath Henley b0164990c0 Conformation --> Confirmation (#11058) 2023-09-04 14:56:46 +02:00
Ikko Eltociear Ashimine bad72ba7bf chore: fix typo in embed-iframe.ts (#10858)
currenty -> currently
2023-09-04 17:48:42 +05:30
Crowdin Bot 1b317a98a4 New Crowdin translations by Github Action 2023-09-04 12:17:54 +00:00
Omar López 6a22d75caa fix: no-scrollbar for firefox (#10964) 2023-09-04 17:43:32 +05:30
a030861423 feat: recently added apps (#11118)
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-09-04 10:30:21 +00:00
omahs bd9cd00c04 fix: typos (#11125) 2023-09-04 12:18:02 +02:00
Kushagra MathurandPeer Richelsen 6fd34af6f6 fix: dark mode icons for Riverside and Sirius Video (#11107)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-09-04 09:55:16 +00:00
1a85f336c5 fix: Booking Question Dialog: Dropdown covered by DialogFooter (CALCOM-10987) (#10989)
Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-09-04 11:42:10 +02:00
Peer Richelsen cc5096a54a chore: stop using telemetry for website (#11050) 2023-09-01 16:53:08 -07:00
alannnc eea7059226 Add test for different layouts (#11091) 2023-09-01 16:26:45 -07:00
Omar López 63f3247b1b fix: Vercel preview rewrites 404s (#11090) 2023-09-01 23:36:59 +02:00
Hariom BalharaandJoe Au-Yeung 7a769c38fb Remove duplicate fields (#11003)
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
2023-09-02 00:58:21 +05:30
alannnc 34c2565c70 fix paypal minor issues (#11069) 2023-09-01 11:03:27 -07:00
Crowdin Bot a130804f18 New Crowdin translations by Github Action 2023-09-01 17:55:12 +00:00
Leo Giovanetti 446e851065 fix: support subteam in org redirection (#11080) 2023-09-01 19:52:27 +02:00
Abhilash Sampath f06b4bfb9c fix: Add send sms description (#11063) 2023-09-01 19:12:10 +02:00
Alex van Andel 1896efa3a0 v3.2.7.1
release.yml / release (push) Canceled after 0s
2023-09-01 17:45:59 +01:00
Alex van Andel 62c8603863 fix: Revert #10366: Undoing then redo fix differently (#11078)
* Revert #10366: Undoing then redo fix differently

* Re-implement defaulting to first day in the month

* Update Embed.tsx and DateOverrideInputDialog
2023-09-01 17:12:01 +01:00
7d8559b0b0 chore: add Dropdown in storybook (#10874)
Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-09-01 18:08:34 +02:00
sean-brydon 7a48bbe553 Remove background colour (#11082) 2023-09-01 15:55:38 +00:00
nidhi sharma 817e95c246 fix #11077 space added on team events (#11081) 2023-09-01 15:30:37 +00:00
Udit Takkar 4ac6225a35 fix: unauthorized error in organization (#11039)
* fix: unauthorized error in organization

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* chore

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* fix: allow instance admin to update

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

---------

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
2023-09-01 14:02:58 +00:00
sean-brydon 38d9b93dee Fix avatar (#11076) 2023-09-01 14:23:28 +01:00
sean-brydonandUdit Takkar 376b38d4ed fix: Fixes username invite issue (#10998)
* Fixes username invite issue

* Ensure we only suggest email invites in org members

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2023-09-01 17:31:45 +05:30
Hariom Balhara 0e181f6d9f Revert "feat: adds next cold start profiler (#11014)" (#11072)
This reverts commit 05631d093f.
2023-09-01 10:18:17 +01:00
Omar López 4dbb692156 v3.2.7
release.yml / release (push) Canceled after 0s
2023-08-31 15:50:53 -07:00
alannnc 0068718d24 fix sub quantity update stripe (#11057) 2023-08-31 22:46:48 +00:00
alannnc ac817b3542 easy fix (#11054) 2023-08-31 22:44:35 +00:00
zomars 97f2cffd1f fix: zod utils due to upgrade 2023-08-31 15:27:51 -07:00
zomars b6dea9c161 Sync packages 2023-08-31 12:57:59 -07:00
57eb4746ad chore: add Switch in storybook (CALCOM-10760) (#10804)
Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-08-31 21:36:30 +02:00
eff9c87964 chore: add Timezone Select in storybook (CALCOM-10760) (#10966)
Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-08-31 21:34:22 +02:00
e06c07bfec fix: email embed – remove collapsible and permanently show times (#10996)
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: Mehul <mehulzr@gmail.com>
2023-08-31 19:32:52 +00:00
GitStart-Cal.comandgitstart-calcom 332c00dac2 fix: Fix tooltip control on ButtonPlayground storybook file (fix-ButtonTooltip) (#10937)
Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
2023-08-31 21:17:59 +02:00
GitStart-Cal.comandgitstart-calcom e3523db6bb fix: Add controls for Select Field storybook file (#10936)
Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
2023-08-31 21:17:09 +02:00
GitStart-Cal.comandgitstart-calcom 9c0f046b2a fix: Logo storybook file with invalid icons (fix-logo) (#11018)
Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
2023-08-31 21:16:21 +02:00
alannnc 6e1a717ecf fix: add metadata to stripe payment intent (#11053) 2023-08-31 19:15:45 +00:00
b2e098c781 chore: add ToggleGroup in storybook (#10802)
Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2023-08-31 21:12:13 +02:00
Leo GiovanettiandPeer Richelsen 3010900358 fix: admin org list without members (#11051)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-08-31 19:11:28 +00:00
GitStart-Cal.comandgitstart-calcom 9b043f44a0 fix: List storybook file is empty (fix-list) (#10965)
Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
2023-08-31 21:11:01 +02:00
DexterStorey 814674fe40 resolve zod versions across child packages (#11052) 2023-08-31 20:10:59 +01:00
acaa79b0b8 feat: sorting for workflow and routing forms (#10780)
Co-authored-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-08-31 18:57:33 +00:00
GitStart-Cal.comandgitstart-calcom 781a0f4a7c chore: add ErrorBoundary in storybook (CALCOM-10760) (#10872)
Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
2023-08-31 20:56:50 +02:00
Patel Divyesh b2028870d0 fix: broken company profile link on team booking page (#10978) 2023-08-31 20:45:26 +02:00
8f70be8861 fix: Error when running storybook (#11037)
Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-08-31 20:35:43 +02:00
alannnc 53c8f8c04a fix: include app data and credentials from DB (#11048)
* include app data and credentials from DB

* Improve performance
2023-08-31 11:10:58 -07:00
alannnc c4e5d168a5 fix: handle collective multiple host on destinationCalendar (#10967) 2023-08-31 10:47:02 -07:00
sean-brydon 500bfb4d82 chore: sheet darkmode and improve responsive (#11047) 2023-08-31 19:09:06 +02:00
Sahil Padvi 024b92b9ce styles:dark mode color fix (#11004) 2023-08-31 17:18:27 +01:00
Crowdin Bot f23b63cc98 New Crowdin translations by Github Action 2023-08-31 14:24:31 +00:00
Hariom Balhara edc90e3f27 Handle disabling of ORGANIZATIONS_ENABLED flag (#11041) 2023-08-31 19:48:20 +05:30
Omar López 05631d093f feat: adds next cold start profiler (#11014) 2023-08-31 12:25:52 +02:00
GitStart-Cal.comandgitstart-calcom 6000ff4b48 chore: add ColorPicker in storybook (CALCOM-10760) (#10866)
Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
2023-08-31 12:16:37 +02:00
Anik Dhabal Babu cd7de98e3f fix: Set mobile availability (#11027) 2023-08-31 12:08:34 +02:00
3f51858d27 chore: add Popover in storybook (#11021)
Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
Co-authored-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
2023-08-31 12:04:09 +02:00
Denzil SamuelandUdit Takkar 2c498ace5c fix: fix-tablet-menu-not-centered-sidebar (#11020)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2023-08-31 12:03:32 +02:00
Kamil B. Demirci 595d781d51 refactor: improvements on german translation (#10898) 2023-08-31 12:03:11 +02:00
Udit Takkar 3c508568da fix: lower case slugs in teams (#11026)
* fix: lower case slugs in teams

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* fix: use slugify

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

---------

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
2023-08-30 23:34:19 -07:00
Abhijeet Singhandalannnc 3204d0af34 feat: team admin: see connected apps of team members (#11036)
* added feature:team admin can see connected apps of members

* fixed the type error

* Update packages/lib/server/queries/teams/index.ts

* Minor fixes

---------

Co-authored-by: alannnc <alannnc@gmail.com>
2023-08-31 01:20:31 +00:00
25684f9040 fix: meeting ended trigger for webhooks and zapier sometimes not working (#10946)
Co-authored-by: mohammed gehad <mohammed.gehad.1998@gmail.com>
Co-authored-by: Monto <138862352+monto7926@users.noreply.github.com>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
2023-08-30 23:17:42 +00:00
Crowdin Bot bc9aeef710 New Crowdin translations by Github Action 2023-08-30 23:13:45 +00:00
393411a47a feat: cal ai (#10992)
Co-authored-by: nicktrn <55853254+nicktrn@users.noreply.github.com>
Co-authored-by: tedspare <ted.spare@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2023-08-30 16:10:59 -07:00
Leo Giovanetti 356117feaf fix: other reported issues (#11015)
* fix: weird margin top in avatar

* fix: pending users are shown on booking page

* fix: avatar and naming issues

* fix: toast alignment and removing unneeded titles

* missing changes from toast improvements

* feat: empty state for teams without event types

* Removing console.log
2023-08-30 13:42:34 -07:00
Leo Giovanetti 1bf68d50e0 fix: multiple duration when booking (#11032) 2023-08-30 12:19:00 -07:00
Crowdin Bot 4a35e85756 New Crowdin translations by Github Action 2023-08-30 10:15:41 +00:00
Peer Richelsenandalannnc a4f56bf680 Delete add-PRs-to-project-reviewing-PRs.yml (#11008)
Co-authored-by: alannnc <alannnc@gmail.com>
2023-08-30 10:12:54 +00:00
nicktrnandPeer Richelsen a308075bc3 feat: 2fa backup codes (#10600)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-08-30 07:33:48 +00:00
efa6d464a3 refactor: removed redundant test (#10785)
Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
Co-authored-by: Shivam Kalra <shivamkalra98@gmail.com>
2023-08-29 18:14:30 +00:00
Crowdin Bot 811262d7ee New Crowdin translations by Github Action 2023-08-29 17:29:54 +00:00
Udit Takkar 99a5dcf96a fix: type error in booker (#11011) 2023-08-29 10:27:06 -07:00
MehulandUdit Takkar 79f092673a fix: signup password hint validation (#10454)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2023-08-29 13:56:45 +02:00
b285f27d00 feat: option for adding ics events to workflow reminders (#10856)
Co-authored-by: SMLukwiya <sundaymorganl@gmail.com>
Co-authored-by: Monto <138862352+monto7926@users.noreply.github.com>
Co-authored-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
2023-08-29 13:56:26 +02:00
1fa87ae179 fix: Modal popup overflow issue in reschedule booking (#10976)
Co-authored-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-08-29 12:36:15 +02:00
Patel DivyeshandPeer Richelsen f6f4b67bf9 fix: Team Logo not Showing up in Team Event Create Modal (#10959)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-08-29 12:35:50 +02:00
MehulandHariom Balhara 8c8550e6f8 fix: switching between months leads to incorrect autoselect first available date (#10366)
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2023-08-29 12:32:56 +02:00
Joe Au-YeungandHariom Balhara c6246c9408 refactor: Eventtype app context after orgs (#10781)
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2023-08-29 11:32:20 +05:30
Omar López ccc814eb54 v3.2.6
release.yml / release (push) Canceled after 0s
2023-08-28 17:04:09 -07:00
Joe Au-Yeung 2ee0ec11d6 Install Stripe on team from team event type (#10920) 2023-08-28 16:39:57 -07:00
Syed Ali Shahbaz 3223beb9ac omit id as it is autocreated, should not be req (#10982) 2023-08-28 06:57:01 +00:00
nicktrnandzomars c1bcfcfa3d fix: replace all async foreach callbacks (#10157)
Co-authored-by: zomars <zomars@me.com>
2023-08-26 00:43:10 +00:00
83aea7d28c fix: webhooks for managed event types (#10958)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Omar López <zomars@me.com>
2023-08-26 00:27:05 +00:00
501 changed files with 14713 additions and 6531 deletions
+4
View File
@@ -213,6 +213,10 @@ NEXT_PUBLIC_MINUTES_TO_BOOK=5 # Minutes
# use organizations # use organizations
ORGANIZATIONS_ENABLED= ORGANIZATIONS_ENABLED=
# This variable should only be set to 1 or true if you want to autolink external provider sing-ups with
# existing organizations based on email domain address
ORGANIZATIONS_AUTOLINK=
# Vercel Config to create subdomains for organizations # Vercel Config to create subdomains for organizations
# Get it from https://vercel.com/<TEAM_OR_USER_NAME>/<PROJECT_SLUG>/settings # Get it from https://vercel.com/<TEAM_OR_USER_NAME>/<PROJECT_SLUG>/settings
PROJECT_ID_VERCEL= PROJECT_ID_VERCEL=
@@ -1,16 +0,0 @@
name: Add PRs to project Reviewing PRs
on:
pull_request:
types:
- opened
jobs:
add-PR-to-project:
name: Add PRs to project Reviewing PRs
runs-on: ubuntu-latest
steps:
- uses: actions/add-to-project@v0.1.0
with:
project-url: https://github.com/orgs/calcom/projects/11
github-token: ${{ secrets.GH_ACCESS_TOKEN }}
@@ -0,0 +1,23 @@
name: Cron - webhookTriggers
on:
# "Scheduled workflows run on the latest commit on the default or base branch."
# — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule
schedule:
# Runs “every 5 minutes” (see https://crontab.guru)
- cron: "*/5 * * * *"
jobs:
cron-webhookTriggers:
env:
APP_URL: ${{ secrets.APP_URL }}
CRON_API_KEY: ${{ secrets.CRON_API_KEY }}
runs-on: ubuntu-latest
steps:
- name: cURL request
if: ${{ env.APP_URL && env.CRON_API_KEY }}
run: |
curl ${{ secrets.APP_URL }}/api/cron/webhookTriggers \
-X POST \
-H 'content-type: application/json' \
-H 'authorization: ${{ secrets.CRON_API_KEY }}' \
--fail
-1
View File
@@ -7,7 +7,6 @@ public
*.lock *.lock
*.log *.log
*.test.ts
.gitignore .gitignore
.npmignore .npmignore
+4 -5
View File
@@ -1,6 +1,6 @@
# Contributing to Cal.com # Contributing to Cal.com
Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**. Contributions are what makes the open source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**.
- Before jumping into a PR be sure to search [existing PRs](https://github.com/calcom/cal.com/pulls) or [issues](https://github.com/calcom/cal.com/issues) for an open or closed item that relates to your submission. - Before jumping into a PR be sure to search [existing PRs](https://github.com/calcom/cal.com/pulls) or [issues](https://github.com/calcom/cal.com/issues) for an open or closed item that relates to your submission.
@@ -37,7 +37,7 @@ Contributions are what make the open source community such an amazing place to l
</tr> </tr>
<tr> <tr>
<td> <td>
Core Features (Booking page, availabilty, timezone calculation) Core Features (Booking page, availability, timezone calculation)
</td> </td>
<td> <td>
<a href="https://github.com/calcom/cal.com/issues?q=is:issue+is:open+sort:updated-desc+label:%22High+priority%22"> <a href="https://github.com/calcom/cal.com/issues?q=is:issue+is:open+sort:updated-desc+label:%22High+priority%22">
@@ -132,7 +132,6 @@ If you get errors, be sure to fix them before committing.
## Making a Pull Request ## Making a Pull Request
- Be sure to [check the "Allow edits from maintainers" option](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/allowing-changes-to-a-pull-request-branch-created-from-a-fork) while creating you PR. - Be sure to [check the "Allow edits from maintainers" option](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/allowing-changes-to-a-pull-request-branch-created-from-a-fork) while creating your PR.
- If your PR refers to or fixes an issue, be sure to add `refs #XXX` or `fixes #XXX` to the PR description. Replacing `XXX` with the respective issue number. See more about [Linking a pull request to an issue - If your PR refers to or fixes an issue, be sure to add `refs #XXX` or `fixes #XXX` to the PR description. Replacing `XXX` with the respective issue number. See more about [Linking a pull request to an issue](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue).
](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue).
- Be sure to fill the PR Template accordingly. - Be sure to fill the PR Template accordingly.
+4 -4
View File
@@ -423,7 +423,7 @@ yarn seed-app-store
``` ```
You will need to complete a few more steps to activate Google Calendar App. You will need to complete a few more steps to activate Google Calendar App.
Make sure to complete section "Obtaining the Google API Credentials". After the do the Make sure to complete section "Obtaining the Google API Credentials". After that do the
following following
1. Add extra redirect URL `<Cal.com URL>/api/auth/callback/google` 1. Add extra redirect URL `<Cal.com URL>/api/auth/callback/google`
@@ -449,8 +449,8 @@ following
7. Click "Create". 7. Click "Create".
8. Now copy the Client ID and Client Secret to your `.env` file into the `ZOOM_CLIENT_ID` and `ZOOM_CLIENT_SECRET` fields. 8. Now copy the Client ID and Client Secret to your `.env` file into the `ZOOM_CLIENT_ID` and `ZOOM_CLIENT_SECRET` fields.
9. Set the Redirect URL for OAuth `<Cal.com URL>/api/integrations/zoomvideo/callback` replacing Cal.com URL with the URI at which your application runs. 9. Set the Redirect URL for OAuth `<Cal.com URL>/api/integrations/zoomvideo/callback` replacing Cal.com URL with the URI at which your application runs.
10. Also add the redirect URL given above as a allow list URL and enable "Subdomain check". Make sure, it says "saved" below the form. 10. Also add the redirect URL given above as an allow list URL and enable "Subdomain check". Make sure, it says "saved" below the form.
11. You don't need to provide basic information about your app. Instead click at "Scopes" and then at "+ Add Scopes". On the left, click the category "Meeting" and check the scope `meeting:write`. 11. You don't need to provide basic information about your app. Instead click on "Scopes" and then on "+ Add Scopes". On the left, click the category "Meeting" and check the scope `meeting:write`.
12. Click "Done". 12. Click "Done".
13. You're good to go. Now you can easily add your Zoom integration in the Cal.com settings. 13. You're good to go. Now you can easily add your Zoom integration in the Cal.com settings.
@@ -526,7 +526,7 @@ For example, `Cal.com (support@cal.com)`.
3. Copy Account SID to your `.env` file into the `TWILIO_SID` field 3. Copy Account SID to your `.env` file into the `TWILIO_SID` field
4. Copy Auth Token to your `.env` file into the `TWILIO_TOKEN` field 4. Copy Auth Token to your `.env` file into the `TWILIO_TOKEN` field
5. Copy your Twilio phone number to your `.env` file into the `TWILIO_PHONE_NUMBER` field 5. Copy your Twilio phone number to your `.env` file into the `TWILIO_PHONE_NUMBER` field
6. Add your own sender id to the `.env` file into the `NEXT_PUBLIC_SENDER_ID` field (fallback is Cal.com) 6. Add your own sender ID to the `.env` file into the `NEXT_PUBLIC_SENDER_ID` field (fallback is Cal.com)
7. Create a messaging service (Develop -> Messaging -> Services) 7. Create a messaging service (Develop -> Messaging -> Services)
8. Choose any name for the messaging service 8. Choose any name for the messaging service
9. Click 'Add Senders' 9. Click 'Add Senders'
+15
View File
@@ -0,0 +1,15 @@
BACKEND_URL=http://localhost:3002/api
APP_ID=cal-ai
APP_URL=http://localhost:3000/apps/cal-ai
# Used to verify requests from sendgrid. You can generate a new one with: `openssl rand -hex 32`
PARSE_KEY=
OPENAI_API_KEY=
# Optionally trace completions at https://smith.langchain.com
# LANGCHAIN_TRACING_V2=true
# LANGCHAIN_ENDPOINT=
# LANGCHAIN_API_KEY=
# LANGCHAIN_PROJECT=
+48
View File
@@ -0,0 +1,48 @@
# Cal.com Email Assistant
Welcome to the first stage of Cal AI!
This app lets you chat with your calendar via email:
- Turn informal emails into bookings eg. forward "wanna meet tmrw at 2pm?"
- List and rearrange your bookings eg. "Cancel my next meeting"
- Answer basic questions about your busiest times eg. "How does my Tuesday look?"
The core logic is contained in [agent/route.ts](/apps/ai/src/app/api/agent/route.ts). Here, a [LangChain Agent Executor](https://docs.langchain.com/docs/components/agents/agent-executor) is tasked with following your instructions. Given your last-known timezone, working hours, and busy times, it attempts to CRUD your bookings.
_The AI agent can only choose from a set of tools, without ever seeing your API key._
Emails are cleaned and routed in [receive/route.ts](/apps/ai/src/app/api/receive/route.ts) using [MailParser](https://nodemailer.com/extras/mailparser/).
Incoming emails are routed by email address. Addresses are verified by [DKIM record](https://support.google.com/a/answer/174124?hl=en), making it hard to spoof them.
## Getting Started
### Development
If you haven't yet, please run the [root setup](/README.md) steps.
Before running the app, please see [env.mjs](./src/env.mjs) for all required environment variables. You'll need:
- An [OpenAI API key](https://platform.openai.com/account/api-keys) with access to GPT-4
- A [SendGrid API key](https://app.sendgrid.com/settings/api_keys)
- A default sender email (for example, `ai@cal.dev`)
- The Cal AI's app ID and URL (see [add.ts](/packages/app-store/cal-ai/api/index.ts))
To stand up the API and AI apps simultaneously, simply run `yarn dev:ai`.
### Email Router
To expose the AI app, run `ngrok http 3000` (or the AI app's port number) in a new terminal. You may need to install [nGrok](https://ngrok.com/).
To forward incoming emails to the Node.js server, one option is to use [SendGrid's Inbound Parse Webhook](https://docs.sendgrid.com/for-developers/parsing-email/setting-up-the-inbound-parse-webhook).
1. [Sign up for an account](https://signup.sendgrid.com/)
2. Go to Settings > [Inbound Parse](https://app.sendgrid.com/settings/parse) > Add Host & URL.
3. For subdomain, use `<sub>.<domain>.com` for now, where `sub` can be any subdomain but `domain.com` will need to be verified via MX records in your environment variables, eg. on [Vercel](https://vercel.com/guides/how-to-add-vercel-environment-variables).
4. Use the nGrok URL from above as the **Destination URL**.
5. Activate "POST the raw, full MIME message".
6. Send an email to `<anyone>@ai.example.com`. You should see a ping on the nGrok listener and Node.js server.
7. Adjust the logic in [receive/route.ts](/apps/ai/src/app/api/receive/route.ts), save to hot-reload, and send another email to test the behaviour.
Please feel free to improve any part of this architecture.
+5
View File
@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.
+24
View File
@@ -0,0 +1,24 @@
const withBundleAnalyzer = require("@next/bundle-analyzer");
const plugins = [];
plugins.push(withBundleAnalyzer({ enabled: process.env.ANALYZE === "true" }));
/** @type {import("next").NextConfig} */
const nextConfig = {
async redirects() {
return [
{
source: "/",
destination: "https://cal.com/ai",
permanent: true,
},
];
},
i18n: {
defaultLocale: "en",
locales: ["en"],
},
reactStrictMode: true,
};
module.exports = () => plugins.reduce((acc, next) => next(acc), nextConfig);
+26
View File
@@ -0,0 +1,26 @@
{
"name": "@calcom/ai",
"version": "1.0.1",
"private": true,
"author": "Cal.com Inc.",
"dependencies": {
"@calcom/prisma": "*",
"@t3-oss/env-nextjs": "^0.6.1",
"langchain": "^0.0.131",
"mailparser": "^3.6.5",
"next": "^13.4.6",
"supports-color": "8.1.1",
"zod": "^3.22.2"
},
"devDependencies": {
"@types/mailparser": "^3.4.0"
},
"scripts": {
"build": "next build",
"dev": "next dev -p 3005",
"format": "npx prettier . --write",
"lint": "eslint . --ext .ts,.js,.tsx,.jsx",
"lint:fix": "eslint . --ext .ts,.js,.tsx,.jsx --fix",
"start": "next start"
}
}
+45
View File
@@ -0,0 +1,45 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import agent from "../../../utils/agent";
import sendEmail from "../../../utils/sendEmail";
import { verifyParseKey } from "../../../utils/verifyParseKey";
/**
* Launches a LangChain agent to process an incoming email,
* then sends the response to the user.
*/
export const POST = async (request: NextRequest) => {
const verified = verifyParseKey(request.url);
if (!verified) {
return new NextResponse("Unauthorized", { status: 401 });
}
const json = await request.json();
const { apiKey, userId, message, subject, user, replyTo } = json;
if ((!message && !subject) || !user) {
return new NextResponse("Missing fields", { status: 400 });
}
try {
const response = await agent(`${subject}\n\n${message}`, user, apiKey, userId);
// Send response to user
await sendEmail({
subject: `Re: ${subject}`,
text: response.replace(/(?:\r\n|\r|\n)/g, "\n"),
to: user.email,
from: replyTo,
});
return new NextResponse("ok");
} catch (error) {
return new NextResponse(
(error as Error).message || "Something went wrong. Please try again or reach out for help.",
{ status: 500 }
);
}
};
+154
View File
@@ -0,0 +1,154 @@
import type { ParsedMail, Source } from "mailparser";
import { simpleParser } from "mailparser";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import prisma from "@calcom/prisma";
import { env } from "../../../env.mjs";
import { fetchAvailability } from "../../../tools/getAvailability";
import { fetchEventTypes } from "../../../tools/getEventTypes";
import getHostFromHeaders from "../../../utils/host";
import now from "../../../utils/now";
import sendEmail from "../../../utils/sendEmail";
import { verifyParseKey } from "../../../utils/verifyParseKey";
/**
* Verifies email signature and app authorization,
* then hands off to booking agent.
*/
export const POST = async (request: NextRequest) => {
const verified = verifyParseKey(request.url);
if (!verified) {
return new NextResponse("Unauthorized", { status: 401 });
}
const formData = await request.formData();
const body = Object.fromEntries(formData);
// body.dkim looks like {@domain-com.22222222.gappssmtp.com : pass}
const signature = (body.dkim as string).includes(" : pass");
const envelope = JSON.parse(body.envelope as string);
const aiEmail = envelope.to[0];
// Parse email from mixed MIME type
const parsed: ParsedMail = await simpleParser(body.email as Source);
if (!parsed.text && !parsed.subject) {
return new NextResponse("Email missing text and subject", { status: 400 });
}
const user = await prisma.user.findUnique({
select: {
email: true,
id: true,
timeZone: true,
credentials: {
select: {
appId: true,
key: true,
},
},
},
where: { email: envelope.from },
});
// User is not a cal.com user or is using an unverified email.
if (!signature || !user) {
await sendEmail({
html: `Thanks for your interest in Cal AI! To get started, Make sure you have a <a href="https://cal.com/signup" target="_blank">cal.com</a> account with this email address.`,
subject: `Re: ${body.subject}`,
text: `Thanks for your interest in Cal AI! To get started, Make sure you have a cal.com account with this email address. You can sign up for an account at: https://cal.com/signup`,
to: envelope.from,
from: aiEmail,
});
return new NextResponse("ok");
}
const credential = user.credentials.find((c) => c.appId === env.APP_ID)?.key;
// User has not installed the app from the app store. Direct them to install it.
if (!(credential as { apiKey: string })?.apiKey) {
const url = env.APP_URL;
await sendEmail({
html: `Thanks for using Cal AI! To get started, the app must be installed. <a href=${url} target="_blank">Click this link</a> to install it.`,
subject: `Re: ${body.subject}`,
text: `Thanks for using Cal AI! To get started, the app must be installed. Click this link to install the Cal AI app: ${url}`,
to: envelope.from,
from: aiEmail,
});
return new NextResponse("ok");
}
const { apiKey } = credential as { apiKey: string };
// Pre-fetch data relevant to most bookings.
const [eventTypes, availability] = await Promise.all([
fetchEventTypes({
apiKey,
}),
fetchAvailability({
apiKey,
userId: user.id,
dateFrom: now(user.timeZone),
dateTo: now(user.timeZone),
}),
]);
if ("error" in availability) {
await sendEmail({
subject: `Re: ${body.subject}`,
text: "Sorry, there was an error fetching your availability. Please try again.",
to: user.email,
from: aiEmail,
});
console.error(availability.error);
return new NextResponse("Error fetching availability. Please try again.", { status: 400 });
}
if ("error" in eventTypes) {
await sendEmail({
subject: `Re: ${body.subject}`,
text: "Sorry, there was an error fetching your event types. Please try again.",
to: user.email,
from: aiEmail,
});
console.error(eventTypes.error);
return new NextResponse("Error fetching event types. Please try again.", { status: 400 });
}
const { workingHours } = availability;
const appHost = getHostFromHeaders(request.headers);
// Hand off to long-running agent endpoint to handle the email. (don't await)
fetch(`${appHost}/api/agent?parseKey=${env.PARSE_KEY}`, {
body: JSON.stringify({
apiKey,
userId: user.id,
message: parsed.text,
subject: parsed.subject,
replyTo: aiEmail,
user: {
email: user.email,
eventTypes,
timeZone: user.timeZone,
workingHours,
},
}),
headers: {
"Content-Type": "application/json",
},
method: "POST",
});
await new Promise((r) => setTimeout(r, 1000));
return new NextResponse("ok");
};
+43
View File
@@ -0,0 +1,43 @@
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";
export const env = createEnv({
/**
* Specify your client-side environment variables schema here. This way you can ensure the app
* isn't built with invalid env vars. To expose them to the client, prefix them with
* `NEXT_PUBLIC_`.
*/
client: {
// NEXT_PUBLIC_CLIENTVAR: z.string().min(1),
},
/**
* You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g.
* middlewares) or client-side so we need to destruct manually.
*/
runtimeEnv: {
BACKEND_URL: process.env.BACKEND_URL,
APP_ID: process.env.APP_ID,
APP_URL: process.env.APP_URL,
PARSE_KEY: process.env.PARSE_KEY,
NODE_ENV: process.env.NODE_ENV,
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
SENDGRID_API_KEY: process.env.SENDGRID_API_KEY,
DATABASE_URL: process.env.DATABASE_URL,
},
/**
* Specify your server-side environment variables schema here. This way you can ensure the app
* isn't built with invalid env vars.
*/
server: {
BACKEND_URL: z.string().url(),
APP_ID: z.string().min(1),
APP_URL: z.string().url(),
PARSE_KEY: z.string().min(1),
NODE_ENV: z.enum(["development", "test", "production"]),
OPENAI_API_KEY: z.string().min(1),
SENDGRID_API_KEY: z.string().min(1),
DATABASE_URL: z.string().url(),
},
});
@@ -0,0 +1,110 @@
import { DynamicStructuredTool } from "langchain/tools";
import { z } from "zod";
import { env } from "../env.mjs";
/**
* Creates a booking for a user by event type, times, and timezone.
*/
const createBooking = async ({
apiKey,
userId,
eventTypeId,
start,
end,
timeZone,
language,
responses,
}: {
apiKey: string;
userId: number;
eventTypeId: number;
start: string;
end: string;
timeZone: string;
language: string;
responses: { name?: string; email?: string; location?: string };
title?: string;
status?: string;
}): Promise<string | Error | { error: string }> => {
const params = {
apiKey,
userId: userId.toString(),
};
const urlParams = new URLSearchParams(params);
const url = `${env.BACKEND_URL}/bookings?${urlParams.toString()}`;
const response = await fetch(url, {
body: JSON.stringify({
end,
eventTypeId,
language,
metadata: {},
responses,
start,
timeZone,
}),
headers: {
"Content-Type": "application/json",
},
method: "POST",
});
// Let GPT handle this. This will happen when wrong event type id is used.
// if (response.status === 401) throw new Error("Unauthorized");
const data = await response.json();
if (response.status !== 200) {
return {
error: data.message,
};
}
return "Booking created";
};
const createBookingTool = (apiKey: string, userId: number) => {
return new DynamicStructuredTool({
description:
"Tries to create a booking. If the user is unavailable, it will return availability that day, allowing you to avoid the getAvailability step in many cases.",
func: async ({ eventTypeId, start, end, timeZone, language, responses, title, status }) => {
return JSON.stringify(
await createBooking({
apiKey,
userId,
end,
eventTypeId,
language,
responses,
start,
status,
timeZone,
title,
})
);
},
name: "createBookingIfAvailable",
schema: z.object({
end: z
.string()
.describe("This should correspond to the event type's length, unless otherwise specified."),
eventTypeId: z.number(),
language: z.string(),
responses: z
.object({
email: z.string().optional(),
name: z.string().optional(),
})
.describe("External invited user. Not the user making the request."),
start: z.string(),
status: z.string().optional().describe("ACCEPTED, PENDING, CANCELLED or REJECTED"),
timeZone: z.string(),
title: z.string().optional(),
}),
});
};
export default createBookingTool;
+66
View File
@@ -0,0 +1,66 @@
import { DynamicStructuredTool } from "langchain/tools";
import { z } from "zod";
import { env } from "../env.mjs";
/**
* Cancels a booking for a user by ID with reason.
*/
const cancelBooking = async ({
apiKey,
id,
reason,
}: {
apiKey: string;
id: string;
reason: string;
}): Promise<string | { error: string }> => {
const params = {
apiKey,
};
const urlParams = new URLSearchParams(params);
const url = `${env.BACKEND_URL}/bookings/${id}/cancel?${urlParams.toString()}`;
const response = await fetch(url, {
body: JSON.stringify({ reason }),
headers: {
"Content-Type": "application/json",
},
method: "DELETE",
});
// Let GPT handle this. This will happen when wrong booking id is used.
// if (response.status === 401) throw new Error("Unauthorized");
const data = await response.json();
if (response.status !== 200) {
return { error: data.message };
}
return "Booking cancelled";
};
const cancelBookingTool = (apiKey: string) => {
return new DynamicStructuredTool({
description: "Cancel a booking",
func: async ({ id, reason }) => {
return JSON.stringify(
await cancelBooking({
apiKey,
id,
reason,
})
);
},
name: "cancelBooking",
schema: z.object({
id: z.string(),
reason: z.string(),
}),
});
};
export default cancelBookingTool;
+82
View File
@@ -0,0 +1,82 @@
import { DynamicStructuredTool } from "langchain/tools";
import { z } from "zod";
import { env } from "../env.mjs";
import type { Availability } from "../types/availability";
/**
* Fetches availability for a user by date range and event type.
*/
export const fetchAvailability = async ({
apiKey,
userId,
dateFrom,
dateTo,
eventTypeId,
}: {
apiKey: string;
userId: number;
dateFrom: string;
dateTo: string;
eventTypeId?: number;
}): Promise<Partial<Availability> | { error: string }> => {
const params: { [k: string]: string } = {
apiKey,
userId: userId.toString(),
dateFrom,
dateTo,
};
if (eventTypeId) params["eventTypeId"] = eventTypeId.toString();
const urlParams = new URLSearchParams(params);
const url = `${env.BACKEND_URL}/availability?${urlParams.toString()}`;
const response = await fetch(url);
if (response.status === 401) throw new Error("Unauthorized");
const data = await response.json();
if (response.status !== 200) {
return { error: data.message };
}
return {
busy: data.busy,
dateRanges: data.dateRanges,
timeZone: data.timeZone,
workingHours: data.workingHours,
};
};
const getAvailabilityTool = (apiKey: string, userId: number) => {
return new DynamicStructuredTool({
description: "Get availability within range.",
func: async ({ dateFrom, dateTo, eventTypeId }) => {
return JSON.stringify(
await fetchAvailability({
apiKey,
userId,
dateFrom,
dateTo,
eventTypeId,
})
);
},
name: "getAvailability",
schema: z.object({
dateFrom: z.string(),
dateTo: z.string(),
eventTypeId: z
.number()
.optional()
.describe(
"The ID of the event type to filter availability for if you've called getEventTypes, otherwise do not include."
),
}),
});
};
export default getAvailabilityTool;
+75
View File
@@ -0,0 +1,75 @@
import { DynamicStructuredTool } from "langchain/tools";
import { z } from "zod";
import { env } from "../env.mjs";
import type { Booking } from "../types/booking";
import { BOOKING_STATUS } from "../types/booking";
/**
* Fetches bookings for a user by date range.
*/
const fetchBookings = async ({
apiKey,
userId,
from,
to,
}: {
apiKey: string;
userId: number;
from: string;
to: string;
}): Promise<Booking[] | { error: string }> => {
const params = {
apiKey,
userId: userId.toString(),
};
const urlParams = new URLSearchParams(params);
const url = `${env.BACKEND_URL}/bookings?${urlParams.toString()}`;
const response = await fetch(url);
if (response.status === 401) throw new Error("Unauthorized");
const data = await response.json();
if (response.status !== 200) {
return { error: data.message };
}
const bookings: Booking[] = data.bookings
.filter((booking: Booking) => {
const afterFrom = new Date(booking.startTime).getTime() > new Date(from).getTime();
const beforeTo = new Date(booking.endTime).getTime() < new Date(to).getTime();
const notCancelled = booking.status !== BOOKING_STATUS.CANCELLED;
return afterFrom && beforeTo && notCancelled;
})
.map(({ endTime, eventTypeId, id, startTime, status, title }: Booking) => ({
endTime,
eventTypeId,
id,
startTime,
status,
title,
}));
return bookings;
};
const getBookingsTool = (apiKey: string, userId: number) => {
return new DynamicStructuredTool({
description: "Get bookings for a user between two dates.",
func: async ({ from, to }) => {
return JSON.stringify(await fetchBookings({ apiKey, userId, from, to }));
},
name: "getBookings",
schema: z.object({
from: z.string().describe("ISO 8601 datetime string"),
to: z.string().describe("ISO 8601 datetime string"),
}),
});
};
export default getBookingsTool;
+51
View File
@@ -0,0 +1,51 @@
import { DynamicStructuredTool } from "langchain/tools";
import { z } from "zod";
import { env } from "../env.mjs";
import type { EventType } from "../types/eventType";
/**
* Fetches event types by user ID.
*/
export const fetchEventTypes = async ({ apiKey }: { apiKey: string }) => {
const params = {
apiKey,
};
const urlParams = new URLSearchParams(params);
const url = `${env.BACKEND_URL}/event-types?${urlParams.toString()}`;
const response = await fetch(url);
if (response.status === 401) throw new Error("Unauthorized");
const data = await response.json();
if (response.status !== 200) {
return { error: data.message };
}
return data.event_types.map((eventType: EventType) => ({
id: eventType.id,
length: eventType.length,
title: eventType.title,
}));
};
const getEventTypesTool = (apiKey: string) => {
return new DynamicStructuredTool({
description: "Get the user's event type IDs. Usually necessary to book a meeting.",
func: async () => {
return JSON.stringify(
await fetchEventTypes({
apiKey,
})
);
},
name: "getEventTypes",
schema: z.object({}),
});
};
export default getEventTypesTool;
+85
View File
@@ -0,0 +1,85 @@
import { DynamicStructuredTool } from "langchain/tools";
import { z } from "zod";
import { env } from "../env.mjs";
/**
* Edits a booking for a user by booking ID with new times, title, description, or status.
*/
const editBooking = async ({
apiKey,
userId,
id,
startTime, // In the docs it says start, but it's startTime: https://cal.com/docs/enterprise-features/api/api-reference/bookings#edit-an-existing-booking.
endTime, // Same here: it says end but it's endTime.
title,
description,
status,
}: {
apiKey: string;
userId: number;
id: string;
startTime?: string;
endTime?: string;
title?: string;
description?: string;
status?: string;
}): Promise<string | { error: string }> => {
const params = {
apiKey,
userId: userId.toString(),
};
const urlParams = new URLSearchParams(params);
const url = `${env.BACKEND_URL}/bookings/${id}?${urlParams.toString()}`;
const response = await fetch(url, {
body: JSON.stringify({ description, endTime, startTime, status, title }),
headers: {
"Content-Type": "application/json",
},
method: "PATCH",
});
// Let GPT handle this. This will happen when wrong booking id is used.
// if (response.status === 401) throw new Error("Unauthorized");
const data = await response.json();
if (response.status !== 200) {
return { error: data.message };
}
return "Booking edited";
};
const editBookingTool = (apiKey: string, userId: number) => {
return new DynamicStructuredTool({
description: "Edit a booking",
func: async ({ description, endTime, id, startTime, status, title }) => {
return JSON.stringify(
await editBooking({
apiKey,
userId,
description,
endTime,
id,
startTime,
status,
title,
})
);
},
name: "editBooking",
schema: z.object({
description: z.string().optional(),
endTime: z.string().optional(),
id: z.string(),
startTime: z.string().optional(),
status: z.string().optional(),
title: z.string().optional(),
}),
});
};
export default editBookingTool;
+25
View File
@@ -0,0 +1,25 @@
export type Availability = {
busy: {
start: string;
end: string;
title?: string;
}[];
timeZone: string;
dateRanges: {
start: string;
end: string;
}[];
workingHours: {
days: number[];
startTime: number;
endTime: number;
userId: number;
}[];
dateOverrides: {
date: string;
startTime: number;
endTime: number;
userId: number;
};
currentSeats: number;
};
+23
View File
@@ -0,0 +1,23 @@
export enum BOOKING_STATUS {
ACCEPTED = "ACCEPTED",
PENDING = "PENDING",
CANCELLED = "CANCELLED",
REJECTED = "REJECTED",
}
export type Booking = {
id: number;
userId: number;
description: string | null;
eventTypeId: number;
uid: string;
title: string;
startTime: string;
endTime: string;
attendees: { email: string; name: string; timeZone: string; locale: string }[] | null;
user: { email: string; name: string; timeZone: string; locale: string }[] | null;
payment: { id: number; success: boolean; paymentOption: string }[];
metadata: object | null;
status: BOOKING_STATUS;
responses: { email: string; name: string; location: string } | null;
};
+13
View File
@@ -0,0 +1,13 @@
export type EventType = {
id: number;
title: string;
length: number;
metadata: object;
slug: string;
hosts: {
userId: number;
isFixed: boolean;
}[];
hidden: boolean;
// ...
};
+9
View File
@@ -0,0 +1,9 @@
import type { EventType } from "./eventType";
import type { WorkingHours } from "./workingHours";
export type User = {
email: string;
timeZone: string;
eventTypes: EventType[];
workingHours: WorkingHours[];
};
+5
View File
@@ -0,0 +1,5 @@
export type WorkingHours = {
days: number[];
startTime: number;
endTime: number;
};
+73
View File
@@ -0,0 +1,73 @@
import { initializeAgentExecutorWithOptions } from "langchain/agents";
import { ChatOpenAI } from "langchain/chat_models/openai";
import { env } from "../env.mjs";
import createBookingIfAvailable from "../tools/createBookingIfAvailable";
import deleteBooking from "../tools/deleteBooking";
import getAvailability from "../tools/getAvailability";
import getBookings from "../tools/getBookings";
import updateBooking from "../tools/updateBooking";
import type { EventType } from "../types/eventType";
import type { User } from "../types/user";
import type { WorkingHours } from "../types/workingHours";
import now from "./now";
const gptModel = "gpt-4";
/**
* Core of the Cal AI booking agent: a LangChain Agent Executor.
* Uses a toolchain to book meetings, list available slots, etc.
* Uses OpenAI functions to better enforce JSON-parsable output from the LLM.
*/
const agent = async (input: string, user: User, apiKey: string, userId: number) => {
const tools = [
createBookingIfAvailable(apiKey, userId),
getAvailability(apiKey, userId),
getBookings(apiKey, userId),
updateBooking(apiKey, userId),
deleteBooking(apiKey),
];
const model = new ChatOpenAI({
modelName: gptModel,
openAIApiKey: env.OPENAI_API_KEY,
temperature: 0,
});
/**
* Initialize the agent executor with arguments.
*/
const executor = await initializeAgentExecutorWithOptions(tools, model, {
agentArgs: {
prefix: `You are Cal AI - a bleeding edge scheduling assistant that interfaces via email.
Make sure your final answers are definitive, complete and well formatted.
Sometimes, tools return errors. In this case, try to handle the error intelligently or ask the user for more information.
Tools will always handle times in UTC, but times sent to the user should be formatted per that user's timezone.
The current time in the user's timezone is: ${now(user.timeZone)}
The user's time zone is: ${user.timeZone}
The user's event types are: ${user.eventTypes
.map((e: EventType) => `ID: ${e.id}, Title: ${e.title}, Length: ${e.length}`)
.join("\n")}
The user's working hours are: ${user.workingHours
.map(
(w: WorkingHours) =>
`Days: ${w.days.join(", ")}, Start Time (minutes in UTC): ${
w.startTime
}, End Time (minutes in UTC): ${w.endTime}`
)
.join("\n")}
`,
},
agentType: "openai-functions",
returnIntermediateSteps: env.NODE_ENV === "development",
verbose: env.NODE_ENV === "development",
});
const result = await executor.call({ input });
const { output } = result;
return output;
};
export default agent;
+7
View File
@@ -0,0 +1,7 @@
import type { NextRequest } from "next/server";
const getHostFromHeaders = (headers: NextRequest["headers"]): string => {
return `https://${headers.get("host")}`;
};
export default getHostFromHeaders;
+5
View File
@@ -0,0 +1,5 @@
export default function now(timeZone: string) {
return new Date().toLocaleString("en-US", {
timeZone,
});
}
+40
View File
@@ -0,0 +1,40 @@
import mail from "@sendgrid/mail";
const sendgridAPIKey = process.env.SENDGRID_API_KEY as string;
/**
* Simply send an email by address, subject, and body.
*/
const send = async ({
subject,
to,
from,
text,
html,
}: {
subject: string;
to: string;
from: string;
text: string;
html?: string;
}): Promise<boolean> => {
mail.setApiKey(sendgridAPIKey);
const msg = {
to,
from: {
email: from,
name: "Cal AI",
},
text,
html,
subject,
};
const res = await mail.send(msg);
const success = !!res;
return success;
};
export default send;
+13
View File
@@ -0,0 +1,13 @@
import type { NextRequest } from "next/server";
import { env } from "../env.mjs";
/**
* Verifies that the request contains the correct parse key.
* env.PARSE_KEY must be configured as a query param in the sendgrid inbound parse settings.
*/
export const verifyParseKey = (url: NextRequest["url"]) => {
const verified = new URL(url).searchParams.get("parseKey") === env.PARSE_KEY;
return verified;
};
+18
View File
@@ -0,0 +1,18 @@
{
"extends": "@calcom/tsconfig/nextjs.json",
"compilerOptions": {
"strict": true,
"jsx": "preserve",
"baseUrl": ".",
"paths": {
"~/*": ["*"]
},
"plugins": [
{
"name": "next"
}
]
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
+3
View File
@@ -70,6 +70,7 @@ const schemaEventTypeCreateParams = z
recurringEvent: recurringEventInputSchema.optional(), recurringEvent: recurringEventInputSchema.optional(),
seatsPerTimeSlot: z.number().optional(), seatsPerTimeSlot: z.number().optional(),
seatsShowAttendees: z.boolean().optional(), seatsShowAttendees: z.boolean().optional(),
seatsShowAvailabilityCount: z.boolean().optional(),
bookingFields: eventTypeBookingFields.optional(), bookingFields: eventTypeBookingFields.optional(),
scheduleId: z.number().optional(), scheduleId: z.number().optional(),
}) })
@@ -89,6 +90,7 @@ const schemaEventTypeEditParams = z
length: z.number().int().optional(), length: z.number().int().optional(),
seatsPerTimeSlot: z.number().optional(), seatsPerTimeSlot: z.number().optional(),
seatsShowAttendees: z.boolean().optional(), seatsShowAttendees: z.boolean().optional(),
seatsShowAvailabilityCount: z.boolean().optional(),
bookingFields: eventTypeBookingFields.optional(), bookingFields: eventTypeBookingFields.optional(),
scheduleId: z.number().optional(), scheduleId: z.number().optional(),
}) })
@@ -129,6 +131,7 @@ export const schemaEventTypeReadPublic = EventType.pick({
metadata: true, metadata: true,
seatsPerTimeSlot: true, seatsPerTimeSlot: true,
seatsShowAttendees: true, seatsShowAttendees: true,
seatsShowAvailabilityCount: true,
bookingFields: true, bookingFields: true,
bookingLimits: true, bookingLimits: true,
durationLimits: true, durationLimits: true,
+12 -10
View File
@@ -13,16 +13,18 @@ const schemaMembershipRequiredParams = z.object({
teamId: z.number(), teamId: z.number(),
}); });
export const membershipCreateBodySchema = Membership.partial({ export const membershipCreateBodySchema = Membership.omit({ id: true })
accepted: true, .partial({
role: true, accepted: true,
disableImpersonation: true, role: true,
}).transform((v) => ({ disableImpersonation: true,
accepted: false, })
role: MembershipRole.MEMBER, .transform((v) => ({
disableImpersonation: false, accepted: false,
...v, role: MembershipRole.MEMBER,
})); disableImpersonation: false,
...v,
}));
export const membershipEditBodySchema = Membership.omit({ export const membershipEditBodySchema = Membership.omit({
/** To avoid complication, let's avoid updating these, instead you can delete and create a new invite */ /** To avoid complication, let's avoid updating these, instead you can delete and create a new invite */
+1 -1
View File
@@ -40,6 +40,6 @@
"typescript": "^4.9.4", "typescript": "^4.9.4",
"tzdata": "^1.0.30", "tzdata": "^1.0.30",
"uuid": "^8.3.2", "uuid": "^8.3.2",
"zod": "^3.20.2" "zod": "^3.22.2"
} }
} }
+3
View File
@@ -94,6 +94,9 @@ import { defaultResponder } from "@calcom/lib/server";
* seatsShowAttendees: * seatsShowAttendees:
* type: boolean * type: boolean
* description: 'Share Attendee information in seats' * description: 'Share Attendee information in seats'
* seatsShowAvailabilityCount:
* type: boolean
* description: 'Show the number of available seats'
* smsReminderNumber: * smsReminderNumber:
* type: number * type: number
* description: 'SMS reminder number' * description: 'SMS reminder number'
@@ -146,6 +146,9 @@ import checkTeamEventEditPermission from "../_utils/checkTeamEventEditPermission
* seatsShowAttendees: * seatsShowAttendees:
* type: boolean * type: boolean
* description: 'Share Attendee information in seats' * description: 'Share Attendee information in seats'
* seatsShowAvailabilityCount:
* type: boolean
* description: 'Show the number of available seats'
* locations: * locations:
* type: array * type: array
* description: A list of all available locations for the event type * description: A list of all available locations for the event type
+1 -1
View File
@@ -13,7 +13,7 @@ import { Meta } from "@storybook/addon-docs";
<a href="https://www.figma.com/file/9MOufQNLtdkpnDucmNX10R/%E2%9D%96-Cal-DS" target="_blank"> <a href="https://www.figma.com/file/9MOufQNLtdkpnDucmNX10R/%E2%9D%96-Cal-DS" target="_blank">
Figma Figma
</a>{" "} </a>{" "}
library is avalible for anyone to view and use. If you have any questions or concerns, please reach out to library is available for anyone to view and use. If you have any questions or concerns, please reach out to
the design team. the design team.
</p> </p>
</div> </div>
+8 -8
View File
@@ -248,10 +248,10 @@
--cal-bg-inverted: #f3f4f6; --cal-bg-inverted: #f3f4f6;
/* background -> components*/ /* background -> components*/
--cal-bg-info: #dee9fc; --cal-bg-info: #263fa9;
--cal-bg-success: #e2fbe8; --cal-bg-success: #306339;
--cal-bg-attention: #fceed8; --cal-bg-attention: #8e3b1f;
--cal-bg-error: #f9e3e2; --cal-bg-error: #8c2822;
--cal-bg-dark-error: #752522; --cal-bg-dark-error: #752522;
/* Borders */ /* Borders */
@@ -269,10 +269,10 @@
--cal-text-inverted: #101010; --cal-text-inverted: #101010;
/* Content/Text -> components */ /* Content/Text -> components */
--cal-text-info: #253985; --cal-text-info: #dee9fc;
--cal-text-success: #285231; --cal-text-success: #e2fbe8;
--cal-text-attention: #73321b; --cal-text-attention: #fceed8;
--cal-text-error: #752522; --cal-text-error: #f9e3e2;
/* Brand shenanigans /* Brand shenanigans
-> These will be computed for the users theme at runtime. -> These will be computed for the users theme at runtime.
+1 -2
View File
@@ -1,2 +1 @@
public/embed public/embed
*.test.ts
+8 -1
View File
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react";
import { z } from "zod"; import { z } from "zod";
import type { CredentialOwner } from "@calcom/app-store/types"; import type { CredentialOwner } from "@calcom/app-store/types";
import classNames from "@calcom/lib/classNames";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage"; import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery"; import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
@@ -80,7 +81,13 @@ export default function AppListCard(props: AppListCardProps) {
return ( return (
<div className={`${highlight ? "dark:bg-muted bg-yellow-100" : ""}`}> <div className={`${highlight ? "dark:bg-muted bg-yellow-100" : ""}`}>
<div className="flex items-center gap-x-3 px-5 py-4"> <div className="flex items-center gap-x-3 px-5 py-4">
{logo ? <img className="h-10 w-10" src={logo} alt={`${title} logo`} /> : null} {logo ? (
<img
className={classNames(logo.includes("-dark") && "dark:invert", "h-10 w-10")}
src={logo}
alt={`${title} logo`}
/>
) : null}
<div className="flex grow flex-col gap-y-1 truncate"> <div className="flex grow flex-col gap-y-1 truncate">
<div className="flex items-center gap-x-2"> <div className="flex items-center gap-x-2">
<h3 className="text-emphasis truncate text-sm font-semibold">{title}</h3> <h3 className="text-emphasis truncate text-sm font-semibold">{title}</h3>
@@ -116,6 +116,7 @@ function ConnectedCalendarsList(props: Props) {
type={item.integration.type} type={item.integration.type}
isChecked={cal.isSelected} isChecked={cal.isSelected}
destination={cal.externalId === props.destinationCalendarId} destination={cal.externalId === props.destinationCalendarId}
credentialId={cal.credentialId}
/> />
))} ))}
</ul> </ul>
+29
View File
@@ -0,0 +1,29 @@
import React from "react";
import { useFormContext } from "react-hook-form";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Label, TextField } from "@calcom/ui";
export default function TwoFactor({ center = true }) {
const { t } = useLocale();
const methods = useFormContext();
return (
<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>
<TextField
id="backup-code"
label=""
defaultValue=""
placeholder="XXXXX-XXXXX"
minLength={10} // without dash
maxLength={11} // with dash
required
{...methods.register("backupCode")}
/>
</div>
);
}
+2 -2
View File
@@ -5,7 +5,7 @@ import { useFormContext } from "react-hook-form";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Label, Input } from "@calcom/ui"; import { Label, Input } from "@calcom/ui";
export default function TwoFactor({ center = true }) { export default function TwoFactor({ center = true, autoFocus = true }) {
const [value, onChange] = useState(""); const [value, onChange] = useState("");
const { t } = useLocale(); const { t } = useLocale();
const methods = useFormContext(); const methods = useFormContext();
@@ -40,7 +40,7 @@ export default function TwoFactor({ center = true }) {
name={`2fa${index + 1}`} name={`2fa${index + 1}`}
inputMode="decimal" inputMode="decimal"
{...digit} {...digit}
autoFocus={index === 0} autoFocus={autoFocus && index === 0}
autoComplete="one-time-code" autoComplete="one-time-code"
/> />
))} ))}
@@ -382,24 +382,22 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
}} }}
/> />
{selectedLocation && LocationOptions} {selectedLocation && LocationOptions}
<DialogFooter> <DialogFooter className="mt-4">
<div className="mt-4 flex justify-end space-x-2 rtl:space-x-reverse"> <Button
<Button onClick={() => {
onClick={() => { setShowLocationModal(false);
setShowLocationModal(false); setSelectedLocation?.(undefined);
setSelectedLocation?.(undefined); setEditingLocationType?.("");
setEditingLocationType?.(""); locationFormMethods.unregister(["locationType", "locationLink"]);
locationFormMethods.unregister(["locationType", "locationLink"]); }}
}} type="button"
type="button" color="secondary">
color="secondary"> {t("cancel")}
{t("cancel")} </Button>
</Button>
<Button data-testid="update-location" type="submit"> <Button data-testid="update-location" type="submit">
{t("update")} {t("update")}
</Button> </Button>
</div>
</DialogFooter> </DialogFooter>
</Form> </Form>
</div> </div>
@@ -41,7 +41,7 @@ export const RescheduleDialog = (props: IRescheduleDialog) => {
return ( return (
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}> <Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
<DialogContent> <DialogContent enableOverflow>
<div className="flex flex-row space-x-3"> <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 flex-shrink-0 justify-center rounded-full bg-[#FAFAFA]">
<Clock className="m-auto h-6 w-6" /> <Clock className="m-auto h-6 w-6" />
@@ -377,6 +377,14 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
defaultChecked={!!eventType.seatsShowAttendees} defaultChecked={!!eventType.seatsShowAttendees}
/> />
</div> </div>
<div className="mt-2">
<CheckboxField
description={t("show_available_seats_count")}
disabled={seatsLocked.disabled}
onChange={(e) => formMethods.setValue("seatsShowAvailabilityCount", e.target.checked)}
defaultChecked={!!eventType.seatsShowAvailabilityCount}
/>
</div>
</div> </div>
)} )}
/> />
@@ -13,7 +13,6 @@ import type { EventLocationType } from "@calcom/app-store/locations";
import { getEventLocationType, MeetLocationType, LocationType } from "@calcom/app-store/locations"; import { getEventLocationType, MeetLocationType, LocationType } from "@calcom/app-store/locations";
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager"; import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider"; import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
import cx from "@calcom/lib/classNames";
import { CAL_URL } from "@calcom/lib/constants"; import { CAL_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import { md } from "@calcom/lib/markdownIt"; import { md } from "@calcom/lib/markdownIt";
@@ -302,13 +301,7 @@ export const EventSetupTab = (
<div className="flex items-center"> <div className="flex items-center">
<img <img
src={eventLocationType.iconUrl} src={eventLocationType.iconUrl}
className={cx( className="h-4 w-4 dark:invert-[.65]"
"h-4 w-4",
// invert all the icons except app icons
eventLocationType.iconUrl &&
!eventLocationType.iconUrl.startsWith("/app-store") &&
"dark:invert"
)}
alt={`${eventLocationType.label} logo`} alt={`${eventLocationType.label} logo`}
/> />
<span className="ms-1 line-clamp-1 text-sm">{`${eventLabel} ${ <span className="ms-1 line-clamp-1 text-sm">{`${eventLabel} ${
@@ -9,7 +9,6 @@ import type { Options } from "react-select";
import type { CheckedSelectOption } from "@calcom/features/eventtypes/components/CheckedTeamSelect"; import type { CheckedSelectOption } from "@calcom/features/eventtypes/components/CheckedTeamSelect";
import CheckedTeamSelect from "@calcom/features/eventtypes/components/CheckedTeamSelect"; import CheckedTeamSelect from "@calcom/features/eventtypes/components/CheckedTeamSelect";
import ChildrenEventTypeSelect from "@calcom/features/eventtypes/components/ChildrenEventTypeSelect"; import ChildrenEventTypeSelect from "@calcom/features/eventtypes/components/ChildrenEventTypeSelect";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import { SchedulingType } from "@calcom/prisma/enums"; import { SchedulingType } from "@calcom/prisma/enums";
import { Label, Select } from "@calcom/ui"; import { Label, Select } from "@calcom/ui";
@@ -18,13 +17,14 @@ interface IUserToValue {
id: number | null; id: number | null;
name: string | null; name: string | null;
username: string | null; username: string | null;
avatar: string;
email: string; email: string;
} }
const mapUserToValue = ({ id, name, username, email }: IUserToValue, pendingString: string) => ({ const mapUserToValue = ({ id, name, username, avatar, email }: IUserToValue, pendingString: string) => ({
value: `${id || ""}`, value: `${id || ""}`,
label: `${name || email || ""}${!username ? ` (${pendingString})` : ""}`, label: `${name || email || ""}${!username ? ` (${pendingString})` : ""}`,
avatar: `${WEBAPP_URL}/${username}/avatar.png`, avatar,
email, email,
}); });
@@ -66,6 +66,7 @@ type Props = {
formMethods: UseFormReturn<FormValues>; formMethods: UseFormReturn<FormValues>;
isUpdateMutationLoading?: boolean; isUpdateMutationLoading?: boolean;
availability?: AvailabilityOption; availability?: AvailabilityOption;
isUserOrganizationAdmin: boolean;
}; };
function getNavigation(props: { function getNavigation(props: {
@@ -133,6 +134,7 @@ function EventTypeSingleLayout({
isUpdateMutationLoading, isUpdateMutationLoading,
formMethods, formMethods,
availability, availability,
isUserOrganizationAdmin,
}: Props) { }: Props) {
const utils = trpc.useContext(); const utils = trpc.useContext();
const { t } = useLocale(); const { t } = useLocale();
@@ -142,7 +144,8 @@ function EventTypeSingleLayout({
const hasPermsToDelete = const hasPermsToDelete =
currentUserMembership?.role !== "MEMBER" || currentUserMembership?.role !== "MEMBER" ||
!currentUserMembership || !currentUserMembership ||
eventType.schedulingType === SchedulingType.MANAGED; eventType.schedulingType === SchedulingType.MANAGED ||
isUserOrganizationAdmin;
const deleteMutation = trpc.viewer.eventTypes.delete.useMutation({ const deleteMutation = trpc.viewer.eventTypes.delete.useMutation({
onSuccess: async () => { onSuccess: async () => {
@@ -53,6 +53,7 @@ const ConnectedCalendarItem = (prop: IConnectedCalendarItem) => {
<ul className="p-4"> <ul className="p-4">
{calendars?.map((calendar, i) => ( {calendars?.map((calendar, i) => (
<CalendarSwitch <CalendarSwitch
credentialId={calendar.credentialId}
key={calendar.externalId} key={calendar.externalId}
externalId={calendar.externalId} externalId={calendar.externalId}
title={calendar.name || "Nameless Calendar"} title={calendar.name || "Nameless Calendar"}
@@ -3,12 +3,13 @@ import type { FormEvent } from "react";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import OrganizationAvatar from "@calcom/features/ee/organizations/components/OrganizationAvatar";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import { md } from "@calcom/lib/markdownIt"; import { md } from "@calcom/lib/markdownIt";
import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry"; import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import turndown from "@calcom/lib/turndownService"; import turndown from "@calcom/lib/turndownService";
import { trpc } from "@calcom/trpc/react"; import { trpc } from "@calcom/trpc/react";
import { Avatar, Button, Editor, ImageUploader, Label, showToast } from "@calcom/ui"; import { Button, Editor, ImageUploader, Label, showToast } from "@calcom/ui";
import { ArrowRight } from "@calcom/ui/components/icon"; import { ArrowRight } from "@calcom/ui/components/icon";
type FormData = { type FormData = {
@@ -99,11 +100,11 @@ const UserProfile = () => {
<form onSubmit={onSubmit}> <form onSubmit={onSubmit}>
<div className="flex flex-row items-center justify-start rtl:justify-end"> <div className="flex flex-row items-center justify-start rtl:justify-end">
{user && ( {user && (
<Avatar <OrganizationAvatar
alt={user.username || "user avatar"} alt={user.username || "user avatar"}
gravatarFallbackMd5={user.emailMd5}
size="lg" size="lg"
imageSrc={imageSrc} imageSrc={imageSrc}
organizationSlug={user.organization?.slug}
/> />
)} )}
<input <input
@@ -5,6 +5,7 @@ import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button, Dialog, DialogContent, DialogFooter, Form, PasswordField } from "@calcom/ui"; import { Button, Dialog, DialogContent, DialogFooter, Form, PasswordField } from "@calcom/ui";
import BackupCode from "@components/auth/BackupCode";
import TwoFactor from "@components/auth/TwoFactor"; import TwoFactor from "@components/auth/TwoFactor";
import TwoFactorAuthAPI from "./TwoFactorAuthAPI"; import TwoFactorAuthAPI from "./TwoFactorAuthAPI";
@@ -20,6 +21,7 @@ interface DisableTwoFactorAuthModalProps {
} }
interface DisableTwoFactorValues { interface DisableTwoFactorValues {
backupCode: string;
totpCode: string; totpCode: string;
password: string; password: string;
} }
@@ -33,11 +35,19 @@ const DisableTwoFactorAuthModal = ({
}: DisableTwoFactorAuthModalProps) => { }: DisableTwoFactorAuthModalProps) => {
const [isDisabling, setIsDisabling] = useState(false); const [isDisabling, setIsDisabling] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [twoFactorLostAccess, setTwoFactorLostAccess] = useState(false);
const { t } = useLocale(); const { t } = useLocale();
const form = useForm<DisableTwoFactorValues>(); const form = useForm<DisableTwoFactorValues>();
async function handleDisable({ totpCode, password }: DisableTwoFactorValues) { const resetForm = (clearPassword = true) => {
if (clearPassword) form.setValue("password", "");
form.setValue("backupCode", "");
form.setValue("totpCode", "");
setErrorMessage(null);
};
async function handleDisable({ password, totpCode, backupCode }: DisableTwoFactorValues) {
if (isDisabling) { if (isDisabling) {
return; return;
} }
@@ -45,8 +55,10 @@ const DisableTwoFactorAuthModal = ({
setErrorMessage(null); setErrorMessage(null);
try { try {
const response = await TwoFactorAuthAPI.disable(password, totpCode); const response = await TwoFactorAuthAPI.disable(password, totpCode, backupCode);
if (response.status === 200) { if (response.status === 200) {
setTwoFactorLostAccess(false);
resetForm();
onDisable(); onDisable();
return; return;
} }
@@ -54,12 +66,14 @@ const DisableTwoFactorAuthModal = ({
const body = await response.json(); const body = await response.json();
if (body.error === ErrorCode.IncorrectPassword) { if (body.error === ErrorCode.IncorrectPassword) {
setErrorMessage(t("incorrect_password")); setErrorMessage(t("incorrect_password"));
} } else if (body.error === ErrorCode.SecondFactorRequired) {
if (body.error === ErrorCode.SecondFactorRequired) {
setErrorMessage(t("2fa_required")); setErrorMessage(t("2fa_required"));
} } else if (body.error === ErrorCode.IncorrectTwoFactorCode) {
if (body.error === ErrorCode.IncorrectTwoFactorCode) {
setErrorMessage(t("incorrect_2fa")); setErrorMessage(t("incorrect_2fa"));
} else if (body.error === ErrorCode.IncorrectBackupCode) {
setErrorMessage(t("incorrect_backup_code"));
} else if (body.error === ErrorCode.MissingBackupCodes) {
setErrorMessage(t("missing_backup_codes"));
} else { } else {
setErrorMessage(t("something_went_wrong")); setErrorMessage(t("something_went_wrong"));
} }
@@ -78,6 +92,7 @@ const DisableTwoFactorAuthModal = ({
<div className="mb-8"> <div className="mb-8">
{!disablePassword && ( {!disablePassword && (
<PasswordField <PasswordField
required
labelProps={{ labelProps={{
className: "block text-sm font-medium text-default", className: "block text-sm font-medium text-default",
}} }}
@@ -85,12 +100,25 @@ const DisableTwoFactorAuthModal = ({
className="border-default mt-1 block w-full rounded-md border px-3 py-2 text-sm focus:border-black focus:outline-none focus:ring-black" className="border-default mt-1 block w-full rounded-md border px-3 py-2 text-sm focus:border-black focus:outline-none focus:ring-black"
/> />
)} )}
<TwoFactor center={false} /> {twoFactorLostAccess ? (
<BackupCode center={false} />
) : (
<TwoFactor center={false} autoFocus={false} />
)}
{errorMessage && <p className="mt-1 text-sm text-red-700">{errorMessage}</p>} {errorMessage && <p className="mt-1 text-sm text-red-700">{errorMessage}</p>}
</div> </div>
<DialogFooter showDivider className="relative mt-5"> <DialogFooter showDivider className="relative mt-5">
<Button
color="minimal"
className="mr-auto"
onClick={() => {
setTwoFactorLostAccess(!twoFactorLostAccess);
resetForm(false);
}}>
{twoFactorLostAccess ? t("go_back") : t("lost_access")}
</Button>
<Button color="secondary" onClick={onCancel}> <Button color="secondary" onClick={onCancel}>
{t("cancel")} {t("cancel")}
</Button> </Button>
@@ -5,7 +5,7 @@ import { useForm } from "react-hook-form";
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode"; import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
import { useCallbackRef } from "@calcom/lib/hooks/useCallbackRef"; import { useCallbackRef } from "@calcom/lib/hooks/useCallbackRef";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button, Dialog, DialogContent, DialogFooter, Form, TextField } from "@calcom/ui"; import { Button, Dialog, DialogContent, DialogFooter, Form, PasswordField, showToast } from "@calcom/ui";
import TwoFactor from "@components/auth/TwoFactor"; import TwoFactor from "@components/auth/TwoFactor";
@@ -28,6 +28,7 @@ interface EnableTwoFactorModalProps {
enum SetupStep { enum SetupStep {
ConfirmPassword, ConfirmPassword,
DisplayBackupCodes,
DisplayQrCode, DisplayQrCode,
EnterTotpCode, EnterTotpCode,
} }
@@ -54,16 +55,25 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
const setupDescriptions = { const setupDescriptions = {
[SetupStep.ConfirmPassword]: t("2fa_confirm_current_password"), [SetupStep.ConfirmPassword]: t("2fa_confirm_current_password"),
[SetupStep.DisplayBackupCodes]: t("backup_code_instructions"),
[SetupStep.DisplayQrCode]: t("2fa_scan_image_or_use_code"), [SetupStep.DisplayQrCode]: t("2fa_scan_image_or_use_code"),
[SetupStep.EnterTotpCode]: t("2fa_enter_six_digit_code"), [SetupStep.EnterTotpCode]: t("2fa_enter_six_digit_code"),
}; };
const [step, setStep] = useState(SetupStep.ConfirmPassword); const [step, setStep] = useState(SetupStep.ConfirmPassword);
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [backupCodes, setBackupCodes] = useState([]);
const [backupCodesUrl, setBackupCodesUrl] = useState("");
const [dataUri, setDataUri] = useState(""); const [dataUri, setDataUri] = useState("");
const [secret, setSecret] = useState(""); const [secret, setSecret] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(null);
const resetState = () => {
setPassword("");
setErrorMessage(null);
setStep(SetupStep.ConfirmPassword);
};
async function handleSetup(e: React.FormEvent) { async function handleSetup(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
@@ -79,6 +89,15 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
const body = await response.json(); const body = await response.json();
if (response.status === 200) { if (response.status === 200) {
setBackupCodes(body.backupCodes);
// create backup codes download url
const textBlob = new Blob([body.backupCodes.map(formatBackupCode).join("\n")], {
type: "text/plain",
});
if (backupCodesUrl) URL.revokeObjectURL(backupCodesUrl);
setBackupCodesUrl(URL.createObjectURL(textBlob));
setDataUri(body.dataUri); setDataUri(body.dataUri);
setSecret(body.secret); setSecret(body.secret);
setStep(SetupStep.DisplayQrCode); setStep(SetupStep.DisplayQrCode);
@@ -113,7 +132,7 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
const body = await response.json(); const body = await response.json();
if (response.status === 200) { if (response.status === 200) {
onEnable(); setStep(SetupStep.DisplayBackupCodes);
return; return;
} }
@@ -141,13 +160,18 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
} }
}, [form, handleEnableRef, totpCode]); }, [form, handleEnableRef, totpCode]);
const formatBackupCode = (code: string) => `${code.slice(0, 5)}-${code.slice(5, 10)}`;
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent title={t("enable_2fa")} description={setupDescriptions[step]} type="creation"> <DialogContent
title={step === SetupStep.DisplayBackupCodes ? t("backup_codes") : t("enable_2fa")}
description={setupDescriptions[step]}
type="creation">
<WithStep step={SetupStep.ConfirmPassword} current={step}> <WithStep step={SetupStep.ConfirmPassword} current={step}>
<form onSubmit={handleSetup}> <form onSubmit={handleSetup}>
<div className="mb-4"> <div className="mb-4">
<TextField <PasswordField
label={t("password")} label={t("password")}
type="password" type="password"
name="password" name="password"
@@ -173,6 +197,15 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
</p> </p>
</> </>
</WithStep> </WithStep>
<WithStep step={SetupStep.DisplayBackupCodes} current={step}>
<>
<div className="mt-5 grid grid-cols-2 gap-1 text-center font-mono md:pl-10 md:pr-10">
{backupCodes.map((code) => (
<div key={code}>{formatBackupCode(code)}</div>
))}
</div>
</>
</WithStep>
<Form handleSubmit={handleEnable} form={form}> <Form handleSubmit={handleEnable} form={form}>
<WithStep step={SetupStep.EnterTotpCode} current={step}> <WithStep step={SetupStep.EnterTotpCode} current={step}>
<div className="-mt-4 pb-2"> <div className="-mt-4 pb-2">
@@ -186,9 +219,16 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
</div> </div>
</WithStep> </WithStep>
<DialogFooter className="mt-8" showDivider> <DialogFooter className="mt-8" showDivider>
<Button color="secondary" onClick={onCancel}> {step !== SetupStep.DisplayBackupCodes ? (
{t("cancel")} <Button
</Button> color="secondary"
onClick={() => {
onCancel();
resetState();
}}>
{t("cancel")}
</Button>
) : null}
<WithStep step={SetupStep.ConfirmPassword} current={step}> <WithStep step={SetupStep.ConfirmPassword} current={step}>
<Button <Button
type="submit" type="submit"
@@ -218,6 +258,35 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
{t("enable")} {t("enable")}
</Button> </Button>
</WithStep> </WithStep>
<WithStep step={SetupStep.DisplayBackupCodes} current={step}>
<>
<Button
color="secondary"
data-testid="backup-codes-close"
onClick={(e) => {
e.preventDefault();
resetState();
onEnable();
}}>
{t("close")}
</Button>
<Button
color="secondary"
data-testid="backup-codes-copy"
onClick={(e) => {
e.preventDefault();
navigator.clipboard.writeText(backupCodes.map(formatBackupCode).join("\n"));
showToast(t("backup_codes_copied"), "success");
}}>
{t("copy")}
</Button>
<a download="cal-backup-codes.txt" href={backupCodesUrl}>
<Button color="primary" data-testid="backup-codes-download">
{t("download")}
</Button>
</a>
</>
</WithStep>
</DialogFooter> </DialogFooter>
</Form> </Form>
</DialogContent> </DialogContent>
@@ -19,10 +19,10 @@ const TwoFactorAuthAPI = {
}); });
}, },
async disable(password: string, code: string) { async disable(password: string, code: string, backupCode: string) {
return fetch("/api/auth/two-factor/totp/disable", { return fetch("/api/auth/two-factor/totp/disable", {
method: "POST", method: "POST",
body: JSON.stringify({ password, code }), body: JSON.stringify({ password, code, backupCode }),
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
+9 -27
View File
@@ -57,12 +57,10 @@ export const AdminUser = (props: { onSubmit: () => void; onError: () => void; on
}), }),
}); });
const formMethods = useForm<{ type formSchemaType = z.infer<typeof formSchema>;
username: string;
email_address: string; const formMethods = useForm<formSchemaType>({
full_name: string; mode: "onChange",
password: string;
}>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
}); });
@@ -70,7 +68,7 @@ export const AdminUser = (props: { onSubmit: () => void; onError: () => void; on
props.onError(); props.onError();
}; };
const onSubmit = formMethods.handleSubmit(async (data: z.infer<typeof formSchema>) => { const onSubmit = formMethods.handleSubmit(async (data) => {
props.onSubmit(); props.onSubmit();
const response = await fetch("/api/auth/setup", { const response = await fetch("/api/auth/setup", {
method: "POST", method: "POST",
@@ -130,11 +128,7 @@ export const AdminUser = (props: { onSubmit: () => void; onError: () => void; on
className={classNames("my-0", longWebsiteUrl && "rounded-t-none")} className={classNames("my-0", longWebsiteUrl && "rounded-t-none")}
onBlur={onBlur} onBlur={onBlur}
name="username" name="username"
onChange={async (e) => { onChange={(e) => onChange(e.target.value)}
onChange(e.target.value);
formMethods.setValue("username", e.target.value);
await formMethods.trigger("username");
}}
/> />
</> </>
)} )}
@@ -148,11 +142,7 @@ export const AdminUser = (props: { onSubmit: () => void; onError: () => void; on
<TextField <TextField
value={value || ""} value={value || ""}
onBlur={onBlur} onBlur={onBlur}
onChange={async (e) => { onChange={(e) => onChange(e.target.value)}
onChange(e.target.value);
formMethods.setValue("full_name", e.target.value);
await formMethods.trigger("full_name");
}}
color={formMethods.formState.errors.full_name ? "warn" : ""} color={formMethods.formState.errors.full_name ? "warn" : ""}
type="text" type="text"
name="full_name" name="full_name"
@@ -172,11 +162,7 @@ export const AdminUser = (props: { onSubmit: () => void; onError: () => void; on
<EmailField <EmailField
value={value || ""} value={value || ""}
onBlur={onBlur} onBlur={onBlur}
onChange={async (e) => { onChange={(e) => onChange(e.target.value)}
onChange(e.target.value);
formMethods.setValue("email_address", e.target.value);
await formMethods.trigger("email_address");
}}
className="my-0" className="my-0"
name="email_address" name="email_address"
/> />
@@ -191,11 +177,7 @@ export const AdminUser = (props: { onSubmit: () => void; onError: () => void; on
<PasswordField <PasswordField
value={value || ""} value={value || ""}
onBlur={onBlur} onBlur={onBlur}
onChange={async (e) => { onChange={(e) => onChange(e.target.value)}
onChange(e.target.value);
formMethods.setValue("password", e.target.value);
await formMethods.trigger("password");
}}
hintErrors={["caplow", "admin_min", "num"]} hintErrors={["caplow", "admin_min", "num"]}
name="password" name="password"
className="my-0" className="my-0"
+8 -10
View File
@@ -4,7 +4,6 @@ import { components } from "react-select";
import type { EventLocationType } from "@calcom/app-store/locations"; import type { EventLocationType } from "@calcom/app-store/locations";
import type { CredentialDataWithTeamName } from "@calcom/app-store/utils"; import type { CredentialDataWithTeamName } from "@calcom/app-store/utils";
import { classNames } from "@calcom/lib"; import { classNames } from "@calcom/lib";
import cx from "@calcom/lib/classNames";
import { Select } from "@calcom/ui"; import { Select } from "@calcom/ui";
export type LocationOption = { export type LocationOption = {
@@ -23,14 +22,7 @@ export type GroupOptionType = GroupBase<LocationOption>;
const OptionWithIcon = ({ icon, label }: { icon?: string; label: string }) => { const OptionWithIcon = ({ icon, label }: { icon?: string; label: string }) => {
return ( return (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{icon && ( {icon && <img src={icon} alt="cover" className="h-3.5 w-3.5 dark:invert-[.65]" />}
<img
src={icon}
alt="cover"
// invert all the icons except app icons
className={cx("h-3.5 w-3.5", icon && !icon.startsWith("/app-store") && "dark:invert")}
/>
)}
<span className={classNames("text-sm font-medium")}>{label}</span> <span className={classNames("text-sm font-medium")}>{label}</span>
</div> </div>
); );
@@ -57,7 +49,13 @@ export default function LocationSelect(props: Props<LocationOption, false, Group
}} }}
formatOptionLabel={(e) => ( formatOptionLabel={(e) => (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{e.icon && <img src={e.icon} alt="app-icon" className="h-5 w-5" />} {e.icon && (
<img
src={e.icon}
alt="app-icon"
className={classNames(e.icon.includes("-dark") && "dark:invert", "h-5 w-5")}
/>
)}
<span>{e.label}</span> <span>{e.label}</span>
</div> </div>
)} )}
+1 -14
View File
@@ -14,7 +14,6 @@ import DynamicHelpscoutProvider from "@calcom/features/ee/support/lib/helpscout/
import DynamicIntercomProvider from "@calcom/features/ee/support/lib/intercom/providerDynamic"; import DynamicIntercomProvider from "@calcom/features/ee/support/lib/intercom/providerDynamic";
import { FeatureProvider } from "@calcom/features/flags/context/provider"; import { FeatureProvider } from "@calcom/features/flags/context/provider";
import { useFlags } from "@calcom/features/flags/hooks"; import { useFlags } from "@calcom/features/flags/hooks";
import { trpc } from "@calcom/trpc/react";
import { MetaProvider } from "@calcom/ui"; import { MetaProvider } from "@calcom/ui";
import useIsBookingPage from "@lib/hooks/useIsBookingPage"; import useIsBookingPage from "@lib/hooks/useIsBookingPage";
@@ -222,19 +221,7 @@ function FeatureFlagsProvider({ children }: { children: React.ReactNode }) {
function useOrgBrandingValues() { function useOrgBrandingValues() {
const session = useSession(); const session = useSession();
return session?.data?.user.org;
const res = trpc.viewer.organizations.getBrand.useQuery(undefined, {
// Only fetch if we have a session to avoid flooding logs with errors
enabled: session.status === "authenticated",
});
if (res.status === "loading") {
return undefined;
}
if (res.status === "error") return null;
return res.data;
} }
function OrgBrandProvider({ children }: { children: React.ReactNode }) { function OrgBrandProvider({ children }: { children: React.ReactNode }) {
+1 -1
View File
@@ -32,7 +32,7 @@ function getCspPolicy(nonce: string) {
IS_PRODUCTION ? (useNonStrictPolicy ? "'unsafe-inline'" : "") : "'unsafe-inline'" IS_PRODUCTION ? (useNonStrictPolicy ? "'unsafe-inline'" : "") : "'unsafe-inline'"
} app.cal.com; } app.cal.com;
font-src 'self'; font-src 'self';
img-src 'self' ${WEBAPP_URL} https://www.gravatar.com https://img.youtube.com https://eu.ui-avatars.com/api/ data:; img-src 'self' ${WEBAPP_URL} https://img.youtube.com https://eu.ui-avatars.com/api/ data:;
connect-src 'self' connect-src 'self'
`; `;
} }
-11
View File
@@ -1,11 +0,0 @@
import crypto from "crypto";
export const defaultAvatarSrc = function ({ email, md5 }: { md5?: string; email?: string }) {
if (!email && !md5) return "";
if (email && !md5) {
md5 = crypto.createHash("md5").update(email).digest("hex");
}
return `https://www.gravatar.com/avatar/${md5}?s=160&d=mp&r=PG`;
};
+47 -33
View File
@@ -14,7 +14,8 @@ const {
if (!process.env.NEXTAUTH_SECRET) throw new Error("Please set NEXTAUTH_SECRET"); if (!process.env.NEXTAUTH_SECRET) throw new Error("Please set NEXTAUTH_SECRET");
if (!process.env.CALENDSO_ENCRYPTION_KEY) throw new Error("Please set CALENDSO_ENCRYPTION_KEY"); if (!process.env.CALENDSO_ENCRYPTION_KEY) throw new Error("Please set CALENDSO_ENCRYPTION_KEY");
const isOrganizationsEnabled =
process.env.ORGANIZATIONS_ENABLED === "1" || process.env.ORGANIZATIONS_ENABLED === "true";
// To be able to use the version in the app without having to import package.json // To be able to use the version in the app without having to import package.json
process.env.NEXT_PUBLIC_CALCOM_VERSION = version; process.env.NEXT_PUBLIC_CALCOM_VERSION = version;
@@ -226,7 +227,7 @@ const nextConfig = {
async rewrites() { async rewrites() {
const beforeFiles = [ const beforeFiles = [
// These rewrites are other than booking pages rewrites and so that they aren't redirected to org pages ensure that they happen in beforeFiles // These rewrites are other than booking pages rewrites and so that they aren't redirected to org pages ensure that they happen in beforeFiles
...(process.env.ORGANIZATIONS_ENABLED ...(isOrganizationsEnabled
? [ ? [
{ {
...matcherConfigRootPath, ...matcherConfigRootPath,
@@ -253,6 +254,10 @@ const nextConfig = {
source: "/org/:slug", source: "/org/:slug",
destination: "/team/:slug", destination: "/team/:slug",
}, },
{
source: "/org/:orgSlug/avatar.png",
destination: "/api/user/avatar?orgSlug=:orgSlug",
},
{ {
source: "/team/:teamname/avatar.png", source: "/team/:teamname/avatar.png",
destination: "/api/user/avatar?teamname=:teamname", destination: "/api/user/avatar?teamname=:teamname",
@@ -333,44 +338,46 @@ const nextConfig = {
}, },
], ],
}, },
...[ ...(isOrganizationsEnabled
{ ? [
...matcherConfigRootPath,
headers: [
{ {
key: "X-Cal-Org-path", ...matcherConfigRootPath,
value: "/team/:orgSlug", headers: [
{
key: "X-Cal-Org-path",
value: "/team/:orgSlug",
},
],
}, },
],
},
{
...matcherConfigUserRoute,
headers: [
{ {
key: "X-Cal-Org-path", ...matcherConfigUserRoute,
value: "/org/:orgSlug/:user", headers: [
{
key: "X-Cal-Org-path",
value: "/org/:orgSlug/:user",
},
],
}, },
],
},
{
...matcherConfigUserTypeRoute,
headers: [
{ {
key: "X-Cal-Org-path", ...matcherConfigUserTypeRoute,
value: "/org/:orgSlug/:user/:type", headers: [
{
key: "X-Cal-Org-path",
value: "/org/:orgSlug/:user/:type",
},
],
}, },
],
},
{
...matcherConfigUserTypeEmbedRoute,
headers: [
{ {
key: "X-Cal-Org-path", ...matcherConfigUserTypeEmbedRoute,
value: "/org/:orgSlug/:user/:type/embed", headers: [
{
key: "X-Cal-Org-path",
value: "/org/:orgSlug/:user/:type/embed",
},
],
}, },
], ]
}, : []),
],
]; ];
}, },
async redirects() { async redirects() {
@@ -447,6 +454,13 @@ const nextConfig = {
}, },
{ {
source: "/support", source: "/support",
missing: [
{
type: "header",
key: "host",
value: orgHostPath,
},
],
destination: "/event-types?openIntercom=true", destination: "/event-types?openIntercom=true",
permanent: true, permanent: true,
}, },
@@ -463,7 +477,7 @@ const nextConfig = {
// OAuth callbacks when sent to localhost:3000(w would be expected) should be redirected to corresponding to WEBAPP_URL // OAuth callbacks when sent to localhost:3000(w would be expected) should be redirected to corresponding to WEBAPP_URL
...(process.env.NODE_ENV === "development" && ...(process.env.NODE_ENV === "development" &&
// Safer to enable the redirect only when the user is opting to test out organizations // Safer to enable the redirect only when the user is opting to test out organizations
process.env.ORGANIZATIONS_ENABLED && isOrganizationsEnabled &&
// Prevent infinite redirect by checking that we aren't already on localhost // Prevent infinite redirect by checking that we aren't already on localhost
process.env.NEXT_PUBLIC_WEBAPP_URL !== "http://localhost:3000" process.env.NEXT_PUBLIC_WEBAPP_URL !== "http://localhost:3000"
? [ ? [
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@calcom/web", "name": "@calcom/web",
"version": "3.2.5", "version": "3.3.0",
"private": true, "private": true,
"scripts": { "scripts": {
"analyze": "ANALYZE=true next build", "analyze": "ANALYZE=true next build",
@@ -129,7 +129,7 @@
"turndown": "^7.1.1", "turndown": "^7.1.1",
"uuid": "^8.3.2", "uuid": "^8.3.2",
"web3": "^1.7.5", "web3": "^1.7.5",
"zod": "^3.20.2" "zod": "^3.22.2"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.19.6", "@babel/core": "^7.19.6",
+10 -2
View File
@@ -11,6 +11,7 @@ import {
useEmbedStyles, useEmbedStyles,
useIsEmbed, useIsEmbed,
} from "@calcom/embed-core/embed-iframe"; } from "@calcom/embed-core/embed-iframe";
import OrganizationAvatar from "@calcom/features/ee/organizations/components/OrganizationAvatar";
import { getSlugOrRequestedSlug } from "@calcom/features/ee/organizations/lib/orgDomains"; import { getSlugOrRequestedSlug } from "@calcom/features/ee/organizations/lib/orgDomains";
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains"; import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import { EventTypeDescriptionLazy as EventTypeDescription } from "@calcom/features/eventtypes/components"; import { EventTypeDescriptionLazy as EventTypeDescription } from "@calcom/features/eventtypes/components";
@@ -25,7 +26,7 @@ import prisma from "@calcom/prisma";
import type { EventType, User } from "@calcom/prisma/client"; import type { EventType, User } from "@calcom/prisma/client";
import { baseEventTypeSelect } from "@calcom/prisma/selects"; import { baseEventTypeSelect } from "@calcom/prisma/selects";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { Avatar, HeadSeo, UnpublishedEntity } from "@calcom/ui"; import { HeadSeo, UnpublishedEntity } from "@calcom/ui";
import { Verified, ArrowRight } from "@calcom/ui/components/icon"; import { Verified, ArrowRight } from "@calcom/ui/components/icon";
import type { EmbedProps } from "@lib/withEmbedSsr"; import type { EmbedProps } from "@lib/withEmbedSsr";
@@ -96,7 +97,12 @@ export function UserPage(props: InferGetServerSidePropsType<typeof getServerSide
"max-w-3xl px-4 py-24" "max-w-3xl px-4 py-24"
)}> )}>
<div className="mb-8 text-center"> <div className="mb-8 text-center">
<Avatar imageSrc={profile.image} size="xl" alt={profile.name} /> <OrganizationAvatar
imageSrc={profile.image}
size="xl"
alt={profile.name}
organizationSlug={profile.organizationSlug}
/>
<h1 className="font-cal text-emphasis mb-1 text-3xl" data-testid="name-title"> <h1 className="font-cal text-emphasis mb-1 text-3xl" data-testid="name-title">
{profile.name} {profile.name}
{user.verified && ( {user.verified && (
@@ -218,6 +224,7 @@ export type UserPageProps = {
theme: string | null; theme: string | null;
brandColor: string; brandColor: string;
darkBrandColor: string; darkBrandColor: string;
organizationSlug: string | null;
allowSEOIndexing: boolean; allowSEOIndexing: boolean;
}; };
users: Pick<User, "away" | "name" | "username" | "bio" | "verified">[]; users: Pick<User, "away" | "name" | "username" | "bio" | "verified">[];
@@ -321,6 +328,7 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
theme: user.theme, theme: user.theme,
brandColor: user.brandColor, brandColor: user.brandColor,
darkBrandColor: user.darkBrandColor, darkBrandColor: user.darkBrandColor,
organizationSlug: user.organization?.slug ?? null,
allowSEOIndexing: user.allowSEOIndexing ?? true, allowSEOIndexing: user.allowSEOIndexing ?? true,
}; };
+1 -11
View File
@@ -1,5 +1,4 @@
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
import dayjs from "@calcom/dayjs"; import dayjs from "@calcom/dayjs";
import { checkPremiumUsername } from "@calcom/ee/common/lib/checkPremiumUsername"; import { checkPremiumUsername } from "@calcom/ee/common/lib/checkPremiumUsername";
@@ -11,18 +10,9 @@ import { closeComUpsertTeamUser } from "@calcom/lib/sync/SyncServiceManager";
import { validateUsernameInTeam, validateUsername } from "@calcom/lib/validateUsername"; import { validateUsernameInTeam, validateUsername } from "@calcom/lib/validateUsername";
import prisma from "@calcom/prisma"; import prisma from "@calcom/prisma";
import { IdentityProvider } from "@calcom/prisma/enums"; import { IdentityProvider } from "@calcom/prisma/enums";
import { signupSchema } from "@calcom/prisma/zod-utils";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
const signupSchema = z.object({
username: z.string().refine((value) => !value.includes("+"), {
message: "String should not contain a plus symbol (+).",
}),
email: z.string().email(),
password: z.string().min(7),
language: z.string().optional(),
token: z.string().optional(),
});
export default async function handler(req: NextApiRequest, res: NextApiResponse) { export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== "POST") { if (req.method !== "POST") {
return res.status(405).end(); return res.status(405).end();
@@ -43,8 +43,30 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return res.status(400).json({ error: ErrorCode.IncorrectPassword }); return res.status(400).json({ error: ErrorCode.IncorrectPassword });
} }
} }
// if user has 2fa
if (user.twoFactorEnabled) { // if user has 2fa and using backup code
if (user.twoFactorEnabled && req.body.backupCode) {
if (!process.env.CALENDSO_ENCRYPTION_KEY) {
console.error("Missing encryption key; cannot proceed with backup code login.");
throw new Error(ErrorCode.InternalServerError);
}
if (!user.backupCodes) {
return res.status(400).json({ error: ErrorCode.MissingBackupCodes });
}
const backupCodes = JSON.parse(symmetricDecrypt(user.backupCodes, process.env.CALENDSO_ENCRYPTION_KEY));
// check if user-supplied code matches one
const index = backupCodes.indexOf(req.body.backupCode.replaceAll("-", ""));
if (index === -1) {
return res.status(400).json({ error: ErrorCode.IncorrectBackupCode });
}
// we delete all stored backup codes at the end, no need to do this here
// if user has 2fa and NOT using backup code, try totp
} else if (user.twoFactorEnabled) {
if (!req.body.code) { if (!req.body.code) {
return res.status(400).json({ error: ErrorCode.SecondFactorRequired }); return res.status(400).json({ error: ErrorCode.SecondFactorRequired });
// throw new Error(ErrorCode.SecondFactorRequired); // throw new Error(ErrorCode.SecondFactorRequired);
@@ -82,6 +104,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
id: session.user.id, id: session.user.id,
}, },
data: { data: {
backupCodes: null,
twoFactorEnabled: false, twoFactorEnabled: false,
twoFactorSecret: null, twoFactorSecret: null,
}, },
@@ -1,3 +1,4 @@
import crypto from "crypto";
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import { authenticator } from "otplib"; import { authenticator } from "otplib";
import qrcode from "qrcode"; import qrcode from "qrcode";
@@ -56,11 +57,15 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
// bytes without updating the sanity checks in the enable and login endpoints. // bytes without updating the sanity checks in the enable and login endpoints.
const secret = authenticator.generateSecret(20); const secret = authenticator.generateSecret(20);
// generate backup codes with 10 character length
const backupCodes = Array.from(Array(10), () => crypto.randomBytes(5).toString("hex"));
await prisma.user.update({ await prisma.user.update({
where: { where: {
id: session.user.id, id: session.user.id,
}, },
data: { data: {
backupCodes: symmetricEncrypt(JSON.stringify(backupCodes), process.env.CALENDSO_ENCRYPTION_KEY),
twoFactorEnabled: false, twoFactorEnabled: false,
twoFactorSecret: symmetricEncrypt(secret, process.env.CALENDSO_ENCRYPTION_KEY), twoFactorSecret: symmetricEncrypt(secret, process.env.CALENDSO_ENCRYPTION_KEY),
}, },
@@ -70,5 +75,5 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const keyUri = authenticator.keyuri(name, "Cal", secret); const keyUri = authenticator.keyuri(name, "Cal", secret);
const dataUri = await qrcode.toDataURL(keyUri); const dataUri = await qrcode.toDataURL(keyUri);
return res.json({ secret, keyUri, dataUri }); return res.json({ secret, keyUri, dataUri, backupCodes });
} }
+7 -2
View File
@@ -5,10 +5,12 @@ import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/Cale
import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import notEmpty from "@calcom/lib/notEmpty"; import notEmpty from "@calcom/lib/notEmpty";
import prisma from "@calcom/prisma"; import prisma from "@calcom/prisma";
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
const selectedCalendarSelectSchema = z.object({ const selectedCalendarSelectSchema = z.object({
integration: z.string(), integration: z.string(),
externalId: z.string(), externalId: z.string(),
credentialId: z.number().optional(),
}); });
export default async function handler(req: NextApiRequest, res: NextApiResponse) { export default async function handler(req: NextApiRequest, res: NextApiResponse) {
@@ -24,7 +26,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
id: session.user.id, id: session.user.id,
}, },
select: { select: {
credentials: true, credentials: {
select: credentialForCalendarServiceSelect,
},
timeZone: true, timeZone: true,
id: true, id: true,
selectedCalendars: true, selectedCalendars: true,
@@ -37,7 +41,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const { credentials, ...user } = userWithCredentials; const { credentials, ...user } = userWithCredentials;
if (req.method === "POST") { if (req.method === "POST") {
const { integration, externalId } = selectedCalendarSelectSchema.parse(req.body); const { integration, externalId, credentialId } = selectedCalendarSelectSchema.parse(req.body);
await prisma.selectedCalendar.upsert({ await prisma.selectedCalendar.upsert({
where: { where: {
userId_integration_externalId: { userId_integration_externalId: {
@@ -50,6 +54,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
userId: user.id, userId: user.id,
integration, integration,
externalId, externalId,
credentialId,
}, },
// already exists // already exists
update: {}, update: {},
+2 -2
View File
@@ -104,7 +104,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}); });
const attendeesList = await Promise.all(attendeesListPromises); const attendeesList = await Promise.all(attendeesListPromises);
const selectedDestinationCalendar = booking.destinationCalendar || user.destinationCalendar;
const evt: CalendarEvent = { const evt: CalendarEvent = {
type: booking.title, type: booking.title,
title: booking.title, title: booking.title,
@@ -127,7 +127,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
attendees: attendeesList, attendees: attendeesList,
uid: booking.uid, uid: booking.uid,
recurringEvent: parseRecurringEvent(booking.eventType?.recurringEvent), recurringEvent: parseRecurringEvent(booking.eventType?.recurringEvent),
destinationCalendar: booking.destinationCalendar || user.destinationCalendar, destinationCalendar: selectedDestinationCalendar ? [selectedDestinationCalendar] : [],
}; };
await sendOrganizerRequestReminderEmail(evt); await sendOrganizerRequestReminderEmail(evt);
@@ -0,0 +1 @@
export { default } from "@calcom/features/webhooks/lib/cron";
+1 -1
View File
@@ -187,7 +187,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
} }
res.setHeader("Content-Type", response.headers.get("content-type") as string); res.setHeader("Content-Type", response.headers.get("content-type") as string);
res.setHeader("Cache-Control", "s-maxage=86400"); res.setHeader("Cache-Control", "s-maxage=86400, stale-while-revalidate=60");
res.send(buffer); res.send(buffer);
} catch (error) { } catch (error) {
res.statusCode = 404; res.statusCode = 404;
+6 -1
View File
@@ -34,13 +34,17 @@ const triggerWebhook = async ({
booking: { booking: {
userId: number | undefined; userId: number | undefined;
eventTypeId: number | null; eventTypeId: number | null;
eventTypeParentId: number | null | undefined;
teamId?: number | null; teamId?: number | null;
}; };
}) => { }) => {
const eventTrigger: WebhookTriggerEvents = "RECORDING_READY"; const eventTrigger: WebhookTriggerEvents = "RECORDING_READY";
// Send Webhook call if hooked to BOOKING.RECORDING_READY // Send Webhook call if hooked to BOOKING.RECORDING_READY
const triggerForUser = !booking.teamId || (booking.teamId && booking.eventTypeParentId);
const subscriberOptions = { const subscriberOptions = {
userId: booking.userId, userId: triggerForUser ? booking.userId : null,
eventTypeId: booking.eventTypeId, eventTypeId: booking.eventTypeId,
triggerEvent: eventTrigger, triggerEvent: eventTrigger,
teamId: booking.teamId, teamId: booking.teamId,
@@ -183,6 +187,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
booking: { booking: {
userId: booking?.user?.id, userId: booking?.user?.id,
eventTypeId: booking.eventTypeId, eventTypeId: booking.eventTypeId,
eventTypeParentId: booking.eventType?.parentId,
teamId, teamId,
}, },
}); });
+23 -11
View File
@@ -1,17 +1,16 @@
import crypto from "crypto";
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod"; import { z } from "zod";
import { getSlugOrRequestedSlug, orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains"; import { getSlugOrRequestedSlug, orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import { AVATAR_FALLBACK } from "@calcom/lib/constants";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage"; import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import prisma from "@calcom/prisma"; import prisma from "@calcom/prisma";
import { defaultAvatarSrc } from "@lib/profile";
const querySchema = z const querySchema = z
.object({ .object({
username: z.string(), username: z.string(),
teamname: z.string(), teamname: z.string(),
orgSlug: z.string(),
/** /**
* Allow fetching avatar of a particular organization * Allow fetching avatar of a particular organization
* Avatars being public, we need not worry about others accessing it. * Avatars being public, we need not worry about others accessing it.
@@ -21,7 +20,7 @@ const querySchema = z
.partial(); .partial();
async function getIdentityData(req: NextApiRequest) { async function getIdentityData(req: NextApiRequest) {
const { username, teamname, orgId } = querySchema.parse(req.query); const { username, teamname, orgId, orgSlug } = querySchema.parse(req.query);
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(req.headers.host ?? ""); const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(req.headers.host ?? "");
const org = isValidOrgDomain ? currentOrgDomain : null; const org = isValidOrgDomain ? currentOrgDomain : null;
@@ -61,7 +60,23 @@ async function getIdentityData(req: NextApiRequest) {
org, org,
name: teamname, name: teamname,
email: null, email: null,
avatar: team?.logo || getPlaceholderAvatar(null, teamname), avatar: getPlaceholderAvatar(team?.logo, teamname),
};
}
if (orgSlug) {
const org = await prisma.team.findFirst({
where: getSlugOrRequestedSlug(orgSlug),
select: {
slug: true,
logo: true,
name: true,
},
});
return {
org: org?.slug,
name: org?.name,
email: null,
avatar: getPlaceholderAvatar(org?.logo, org?.name),
}; };
} }
} }
@@ -69,18 +84,15 @@ async function getIdentityData(req: NextApiRequest) {
export default async function handler(req: NextApiRequest, res: NextApiResponse) { export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const identity = await getIdentityData(req); const identity = await getIdentityData(req);
const img = identity?.avatar; const img = identity?.avatar;
// We cache for one day
res.setHeader("Cache-Control", "s-maxage=86400, stale-while-revalidate=60");
// If image isn't set or links to this route itself, use default avatar // If image isn't set or links to this route itself, use default avatar
if (!img) { if (!img) {
if (identity?.org) { if (identity?.org) {
res.setHeader("x-cal-org", identity.org); res.setHeader("x-cal-org", identity.org);
} }
res.writeHead(302, { res.writeHead(302, {
Location: defaultAvatarSrc({ Location: AVATAR_FALLBACK,
md5: crypto
.createHash("md5")
.update(identity?.email || "guest@example.com")
.digest("hex"),
}),
}); });
return res.end(); return res.end();
+9 -1
View File
@@ -12,7 +12,14 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { AppCategories } from "@calcom/prisma/enums"; import type { AppCategories } from "@calcom/prisma/enums";
import type { inferSSRProps } from "@calcom/types/inferSSRProps"; import type { inferSSRProps } from "@calcom/types/inferSSRProps";
import type { HorizontalTabItemProps } from "@calcom/ui"; import type { HorizontalTabItemProps } from "@calcom/ui";
import { AllApps, AppStoreCategories, HorizontalTabs, TextField, PopularAppsSlider } from "@calcom/ui"; import {
AllApps,
AppStoreCategories,
HorizontalTabs,
TextField,
PopularAppsSlider,
RecentAppsSlider,
} from "@calcom/ui";
import { Search } from "@calcom/ui/components/icon"; import { Search } from "@calcom/ui/components/icon";
import PageWrapper from "@components/PageWrapper"; import PageWrapper from "@components/PageWrapper";
@@ -81,6 +88,7 @@ export default function Apps({
<> <>
<AppStoreCategories categories={categories} /> <AppStoreCategories categories={categories} />
<PopularAppsSlider items={appStore} /> <PopularAppsSlider items={appStore} />
<RecentAppsSlider items={appStore} />
</> </>
)} )}
<AllApps <AllApps
+36 -12
View File
@@ -21,7 +21,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry"; import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import prisma from "@calcom/prisma"; import prisma from "@calcom/prisma";
import { Alert, Button, EmailField, PasswordField } from "@calcom/ui"; import { Alert, Button, EmailField, PasswordField } from "@calcom/ui";
import { ArrowLeft } from "@calcom/ui/components/icon"; import { ArrowLeft, Lock } from "@calcom/ui/components/icon";
import type { inferSSRProps } from "@lib/types/inferSSRProps"; import type { inferSSRProps } from "@lib/types/inferSSRProps";
import type { WithNonceProps } from "@lib/withNonce"; import type { WithNonceProps } from "@lib/withNonce";
@@ -29,6 +29,7 @@ import withNonce from "@lib/withNonce";
import AddToHomescreen from "@components/AddToHomescreen"; import AddToHomescreen from "@components/AddToHomescreen";
import PageWrapper from "@components/PageWrapper"; import PageWrapper from "@components/PageWrapper";
import BackupCode from "@components/auth/BackupCode";
import TwoFactor from "@components/auth/TwoFactor"; import TwoFactor from "@components/auth/TwoFactor";
import AuthContainer from "@components/ui/AuthContainer"; import AuthContainer from "@components/ui/AuthContainer";
@@ -39,6 +40,7 @@ interface LoginValues {
email: string; email: string;
password: string; password: string;
totpCode: string; totpCode: string;
backupCode: string;
csrfToken: string; csrfToken: string;
} }
export default function Login({ export default function Login({
@@ -65,6 +67,7 @@ export default function Login({
const methods = useForm<LoginValues>({ resolver: zodResolver(formSchema) }); const methods = useForm<LoginValues>({ resolver: zodResolver(formSchema) });
const { register, formState } = methods; const { register, formState } = methods;
const [twoFactorRequired, setTwoFactorRequired] = useState(!!totpEmail || false); const [twoFactorRequired, setTwoFactorRequired] = useState(!!totpEmail || false);
const [twoFactorLostAccess, setTwoFactorLostAccess] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(null);
const errorMessages: { [key: string]: string } = { const errorMessages: { [key: string]: string } = {
@@ -98,15 +101,35 @@ export default function Login({
); );
const TwoFactorFooter = ( const TwoFactorFooter = (
<Button <>
onClick={() => { <Button
setTwoFactorRequired(false); onClick={() => {
methods.setValue("totpCode", ""); if (twoFactorLostAccess) {
}} setTwoFactorLostAccess(false);
StartIcon={ArrowLeft} methods.setValue("backupCode", "");
color="minimal"> } else {
{t("go_back")} setTwoFactorRequired(false);
</Button> methods.setValue("totpCode", "");
}
setErrorMessage(null);
}}
StartIcon={ArrowLeft}
color="minimal">
{t("go_back")}
</Button>
{!twoFactorLostAccess ? (
<Button
onClick={() => {
setTwoFactorLostAccess(true);
setErrorMessage(null);
methods.setValue("totpCode", "");
}}
StartIcon={Lock}
color="minimal">
{t("lost_access")}
</Button>
) : null}
</>
); );
const ExternalTotpFooter = ( const ExternalTotpFooter = (
@@ -130,8 +153,9 @@ export default function Login({
if (!res) setErrorMessage(errorMessages[ErrorCode.InternalServerError]); if (!res) setErrorMessage(errorMessages[ErrorCode.InternalServerError]);
// we're logged in! let's do a hard refresh to the desired url // we're logged in! let's do a hard refresh to the desired url
else if (!res.error) router.push(callbackUrl); else if (!res.error) router.push(callbackUrl);
// reveal two factor input if required
else if (res.error === ErrorCode.SecondFactorRequired) setTwoFactorRequired(true); else if (res.error === ErrorCode.SecondFactorRequired) setTwoFactorRequired(true);
else if (res.error === ErrorCode.IncorrectBackupCode) setErrorMessage(t("incorrect_backup_code"));
else if (res.error === ErrorCode.MissingBackupCodes) setErrorMessage(t("missing_backup_codes"));
// fallback if error not found // fallback if error not found
else setErrorMessage(errorMessages[res.error] || t("something_went_wrong")); else setErrorMessage(errorMessages[res.error] || t("something_went_wrong"));
}; };
@@ -194,7 +218,7 @@ export default function Login({
</div> </div>
</div> </div>
{twoFactorRequired && <TwoFactor center />} {twoFactorRequired ? !twoFactorLostAccess ? <TwoFactor center /> : <BackupCode center /> : null}
{errorMessage && <Alert severity="error" title={errorMessage} />} {errorMessage && <Alert severity="error" title={errorMessage} />}
<Button <Button
+3 -8
View File
@@ -7,13 +7,8 @@ import { getPremiumMonthlyPlanPriceId } from "@calcom/app-store/stripepayment/li
import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains"; import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import stripe from "@calcom/features/ee/payments/server/stripe"; import stripe from "@calcom/features/ee/payments/server/stripe";
import { import { hostedCal, isSAMLLoginEnabled, samlProductID, samlTenantID } from "@calcom/features/ee/sso/lib/saml";
hostedCal, import { ssoTenantProduct } from "@calcom/features/ee/sso/lib/sso";
isSAMLLoginEnabled,
samlProductID,
samlTenantID,
samlTenantProduct,
} from "@calcom/features/ee/sso/lib/saml";
import { checkUsername } from "@calcom/lib/server/checkUsername"; import { checkUsername } from "@calcom/lib/server/checkUsername";
import prisma from "@calcom/prisma"; import prisma from "@calcom/prisma";
@@ -111,7 +106,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
error = "Email not provided"; error = "Email not provided";
} else { } else {
try { try {
const ret = await samlTenantProduct(prisma, emailParam); const ret = await ssoTenantProduct(prisma, emailParam);
tenant = ret.tenant; tenant = ret.tenant;
product = ret.product; product = ret.product;
} catch (e) { } catch (e) {
+103 -38
View File
@@ -6,6 +6,7 @@ import dayjs from "@calcom/dayjs";
import { DateOverrideInputDialog, DateOverrideList } from "@calcom/features/schedules"; import { DateOverrideInputDialog, DateOverrideList } from "@calcom/features/schedules";
import Schedule from "@calcom/features/schedules/components/Schedule"; import Schedule from "@calcom/features/schedules/components/Schedule";
import Shell from "@calcom/features/shell/Shell"; import Shell from "@calcom/features/shell/Shell";
import { classNames } from "@calcom/lib";
import { availabilityAsString } from "@calcom/lib/availability"; import { availabilityAsString } from "@calcom/lib/availability";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import { HttpError } from "@calcom/lib/http-error"; import { HttpError } from "@calcom/lib/http-error";
@@ -17,11 +18,6 @@ import {
ConfirmationDialogContent, ConfirmationDialogContent,
Dialog, Dialog,
DialogTrigger, DialogTrigger,
Dropdown,
DropdownItem,
DropdownMenuContent,
DropdownMenuSeparator,
DropdownMenuTrigger,
Form, Form,
Label, Label,
showToast, showToast,
@@ -32,7 +28,7 @@ import {
Tooltip, Tooltip,
VerticalDivider, VerticalDivider,
} from "@calcom/ui"; } from "@calcom/ui";
import { Info, MoreHorizontal, Plus, Trash } from "@calcom/ui/components/icon"; import { Info, MoreVertical, ArrowLeft, Plus, Trash } from "@calcom/ui/components/icon";
import PageWrapper from "@components/PageWrapper"; import PageWrapper from "@components/PageWrapper";
import { SelectSkeletonLoader } from "@components/availability/SkeletonLoader"; import { SelectSkeletonLoader } from "@components/availability/SkeletonLoader";
@@ -95,7 +91,7 @@ export default function Availability() {
const scheduleId = searchParams?.get("schedule") ? Number(searchParams.get("schedule")) : -1; const scheduleId = searchParams?.get("schedule") ? Number(searchParams.get("schedule")) : -1;
const fromEventType = searchParams?.get("fromEventType"); const fromEventType = searchParams?.get("fromEventType");
const { timeFormat } = me.data || { timeFormat: null }; const { timeFormat } = me.data || { timeFormat: null };
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [openSidebar, setOpenSidebar] = useState(false);
const { data: schedule, isLoading } = trpc.viewer.availability.schedule.get.useQuery( const { data: schedule, isLoading } = trpc.viewer.availability.schedule.get.useQuery(
{ scheduleId }, { scheduleId },
{ {
@@ -225,33 +221,60 @@ export default function Availability() {
</ConfirmationDialogContent> </ConfirmationDialogContent>
</Dialog> </Dialog>
<VerticalDivider className="hidden sm:inline" /> <VerticalDivider className="hidden sm:inline" />
<Dropdown> <div
<DropdownMenuTrigger asChild> className={classNames(
<Button className="sm:hidden" StartIcon={MoreHorizontal} variant="icon" color="secondary" /> openSidebar
</DropdownMenuTrigger> ? "fadeIn fixed inset-0 z-50 bg-neutral-800 bg-opacity-70 transition-opacity dark:bg-opacity-70 sm:hidden"
<DropdownMenuContent style={{ minWidth: "200px" }}> : ""
<DropdownItem )}>
type="button" <div
color="destructive" className={classNames(
StartIcon={Trash} "bg-default fixed right-0 z-20 flex h-screen w-80 flex-col space-y-2 overflow-x-hidden rounded-md px-2 pb-3 transition-transform",
onClick={() => setDeleteDialogOpen(true)}> openSidebar ? "translate-x-0 opacity-100" : "translate-x-full opacity-0"
{t("delete")} )}>
</DropdownItem> <div className="flex flex-row items-center pt-5">
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}> <Button StartIcon={ArrowLeft} color="minimal" onClick={() => setOpenSidebar(false)} />
<ConfirmationDialogContent <p className="-ml-2">{t("availability_settings")}</p>
isLoading={deleteMutation.isLoading} <Dialog>
variety="danger" <DialogTrigger asChild>
title={t("delete_schedule")} <Button
confirmBtnText={t("delete")} StartIcon={Trash}
loadingText={t("delete")} variant="icon"
onConfirm={() => { color="destructive"
schedule !== undefined && deleteMutation.mutate({ scheduleId: schedule.id }); aria-label={t("delete")}
}}> className="ml-16 inline"
{t("delete_schedule_description")} disabled={schedule?.isLastSchedule}
</ConfirmationDialogContent> tooltip={schedule?.isLastSchedule ? t("requires_at_least_one_schedule") : t("delete")}
</Dialog> />
<DropdownMenuSeparator /> </DialogTrigger>
<div className="flex h-9 flex-row items-center justify-between px-4 py-2 hover:bg-gray-100"> <ConfirmationDialogContent
isLoading={deleteMutation.isLoading}
variety="danger"
title={t("delete_schedule")}
confirmBtnText={t("delete")}
loadingText={t("delete")}
onConfirm={() => {
scheduleId && deleteMutation.mutate({ scheduleId });
setOpenSidebar(false);
}}>
{t("delete_schedule_description")}
</ConfirmationDialogContent>
</Dialog>
</div>
<div className="flex flex-col px-2 py-2">
<Skeleton as={Label}>{t("name")}</Skeleton>
<Controller
control={form.control}
name="name"
render={({ field }) => (
<input
className="hover:border-emphasis dark:focus:border-emphasis border-default bg-default placeholder:text-muted text-emphasis focus:ring-brand-default disabled:bg-subtle disabled:hover:border-subtle mb-2 block h-9 w-full rounded-md border px-3 py-2 text-sm leading-4 focus:border-neutral-300 focus:outline-none focus:ring-2 disabled:cursor-not-allowed"
{...field}
/>
)}
/>
</div>
<div className="flex h-9 flex-row-reverse items-center justify-end gap-3 px-2">
<Skeleton <Skeleton
as={Label} as={Label}
htmlFor="hiddenSwitch" htmlFor="hiddenSwitch"
@@ -267,9 +290,44 @@ export default function Availability() {
}} }}
/> />
</div> </div>
</DropdownMenuContent>
</Dropdown>
<div className="min-w-40 col-span-3 space-y-2 px-2 py-4 lg:col-span-1">
<div className="xl:max-w-80 w-full pr-4 sm:ml-0 sm:mr-36 sm:p-0">
<div>
<Skeleton as={Label} htmlFor="timeZone" className="mb-0 inline-block leading-none">
{t("timezone")}
</Skeleton>
<Controller
control={form.control}
name="timeZone"
render={({ field: { onChange, value } }) =>
value ? (
<TimezoneSelect
value={value}
className="focus:border-brand-default border-default mt-1 block w-72 rounded-md text-sm"
onChange={(timezone) => onChange(timezone.value)}
/>
) : (
<SelectSkeletonLoader className="mt-1 w-72" />
)
}
/>
</div>
<hr className="border-subtle my-7" />
<div className="rounded-md md:block">
<Skeleton as="h3" className="mb-0 inline-block text-sm font-medium">
{t("something_doesnt_look_right")}
</Skeleton>
<div className="mt-3 flex">
<Skeleton as={Button} href="/availability/troubleshoot" color="secondary">
{t("launch_troubleshooter")}
</Skeleton>
</div>
</div>
</div>
</div>
</div>
</div>
<div className="border-default border-l-2" /> <div className="border-default border-l-2" />
<Button <Button
className="ml-4 lg:ml-0" className="ml-4 lg:ml-0"
@@ -278,6 +336,13 @@ export default function Availability() {
loading={updateMutation.isLoading}> loading={updateMutation.isLoading}>
{t("save")} {t("save")}
</Button> </Button>
<Button
className="ml-3 sm:hidden"
StartIcon={MoreVertical}
variant="icon"
color="secondary"
onClick={() => setOpenSidebar(true)}
/>
</div> </div>
}> }>
<div className="mt-4 w-full md:mt-0"> <div className="mt-4 w-full md:mt-0">
@@ -313,7 +378,7 @@ export default function Availability() {
{schedule?.workingHours && <DateOverride workingHours={schedule.workingHours} />} {schedule?.workingHours && <DateOverride workingHours={schedule.workingHours} />}
</div> </div>
</div> </div>
<div className="min-w-40 col-span-3 space-y-2 lg:col-span-1"> <div className="min-w-40 col-span-3 hidden space-y-2 md:block lg:col-span-1">
<div className="xl:max-w-80 w-full pr-4 sm:ml-0 sm:mr-36 sm:p-0"> <div className="xl:max-w-80 w-full pr-4 sm:ml-0 sm:mr-36 sm:p-0">
<div> <div>
<Skeleton as={Label} htmlFor="timeZone" className="mb-0 inline-block leading-none"> <Skeleton as={Label} htmlFor="timeZone" className="mb-0 inline-block leading-none">
@@ -335,7 +400,7 @@ export default function Availability() {
/> />
</div> </div>
<hr className="border-subtle my-6 mr-8" /> <hr className="border-subtle my-6 mr-8" />
<div className="hidden rounded-md md:block"> <div className="rounded-md">
<Skeleton as="h3" className="mb-0 inline-block text-sm font-medium"> <Skeleton as="h3" className="mb-0 inline-block text-sm font-medium">
{t("something_doesnt_look_right")} {t("something_doesnt_look_right")}
</Skeleton> </Skeleton>
+3 -1
View File
@@ -461,7 +461,7 @@ export default function Success(props: SuccessProps) {
{locationToDisplay && !isCancelled && ( {locationToDisplay && !isCancelled && (
<> <>
<div className="mt-3 font-medium">{t("where")}</div> <div className="mt-3 font-medium">{t("where")}</div>
<div className="col-span-2 mt-3"> <div className="col-span-2 mt-3" data-testid="where">
{locationToDisplay.startsWith("http") ? ( {locationToDisplay.startsWith("http") ? (
<a <a
href={locationToDisplay} href={locationToDisplay}
@@ -917,6 +917,7 @@ const getEventTypesFromDB = async (id: number) => {
metadata: true, metadata: true,
seatsPerTimeSlot: true, seatsPerTimeSlot: true,
seatsShowAttendees: true, seatsShowAttendees: true,
seatsShowAvailabilityCount: true,
periodStartDate: true, periodStartDate: true,
periodEndDate: true, periodEndDate: true,
}, },
@@ -940,6 +941,7 @@ const handleSeatsEventTypeOnBooking = async (
eventType: { eventType: {
seatsPerTimeSlot?: number | null; seatsPerTimeSlot?: number | null;
seatsShowAttendees: boolean | null; seatsShowAttendees: boolean | null;
seatsShowAvailabilityCount: boolean | null;
[x: string | number | symbol]: unknown; [x: string | number | symbol]: unknown;
}, },
bookingInfo: Partial< bookingInfo: Partial<
+18 -9
View File
@@ -113,6 +113,7 @@ export type FormValues = {
periodDates: { startDate: Date; endDate: Date }; periodDates: { startDate: Date; endDate: Date };
seatsPerTimeSlot: number | null; seatsPerTimeSlot: number | null;
seatsShowAttendees: boolean | null; seatsShowAttendees: boolean | null;
seatsShowAvailabilityCount: boolean | null;
seatsPerTimeSlotEnabled: boolean; seatsPerTimeSlotEnabled: boolean;
minimumBookingNotice: number; minimumBookingNotice: number;
minimumBookingNoticeInDurationType: number; minimumBookingNoticeInDurationType: number;
@@ -183,12 +184,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
created: true, created: true,
})) }))
); );
showToast( showToast(t("event_type_updated_successfully"), "success");
t("event_type_updated_successfully", {
eventTypeTitle: eventType.title,
}),
"success"
);
}, },
async onSettled() { async onSettled() {
await utils.viewer.eventTypes.get.invalidate(); await utils.viewer.eventTypes.get.invalidate();
@@ -365,6 +361,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
afterBufferTime, afterBufferTime,
seatsPerTimeSlot, seatsPerTimeSlot,
seatsShowAttendees, seatsShowAttendees,
seatsShowAvailabilityCount,
bookingLimits, bookingLimits,
durationLimits, durationLimits,
recurringEvent, recurringEvent,
@@ -381,9 +378,12 @@ const EventTypePage = (props: EventTypeSetupProps) => {
bookerLayouts, bookerLayouts,
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
multipleDurationEnabled, multipleDurationEnabled,
length,
...input ...input
} = values; } = values;
if (!Number(length)) throw new Error(t("event_setup_length_error"));
if (bookingLimits) { if (bookingLimits) {
const isValid = validateIntervalLimitOrder(bookingLimits); const isValid = validateIntervalLimitOrder(bookingLimits);
if (!isValid) throw new Error(t("event_setup_booking_limits_error")); if (!isValid) throw new Error(t("event_setup_booking_limits_error"));
@@ -401,7 +401,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
if (metadata?.multipleDuration.length < 1) { if (metadata?.multipleDuration.length < 1) {
throw new Error(t("event_setup_multiple_duration_error")); throw new Error(t("event_setup_multiple_duration_error"));
} else { } else {
if (!input.length && !metadata?.multipleDuration?.includes(input.length)) { if (!length && !metadata?.multipleDuration?.includes(length)) {
throw new Error(t("event_setup_multiple_duration_default_error")); throw new Error(t("event_setup_multiple_duration_default_error"));
} }
} }
@@ -415,6 +415,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
const { availability, ...rest } = input; const { availability, ...rest } = input;
updateMutation.mutate({ updateMutation.mutate({
...rest, ...rest,
length,
locations, locations,
recurringEvent, recurringEvent,
periodStartDate: periodDates.startDate, periodStartDate: periodDates.startDate,
@@ -427,6 +428,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
durationLimits, durationLimits,
seatsPerTimeSlot, seatsPerTimeSlot,
seatsShowAttendees, seatsShowAttendees,
seatsShowAvailabilityCount,
metadata, metadata,
customInputs, customInputs,
children, children,
@@ -448,7 +450,8 @@ const EventTypePage = (props: EventTypeSetupProps) => {
isUpdateMutationLoading={updateMutation.isLoading} isUpdateMutationLoading={updateMutation.isLoading}
formMethods={formMethods} formMethods={formMethods}
disableBorder={tabName === "apps" || tabName === "workflows" || tabName === "webhooks"} disableBorder={tabName === "apps" || tabName === "workflows" || tabName === "webhooks"}
currentUserMembership={currentUserMembership}> currentUserMembership={currentUserMembership}
isUserOrganizationAdmin={props.isUserOrganizationAdmin}>
<Form <Form
form={formMethods} form={formMethods}
id="event-type-form" id="event-type-form"
@@ -460,6 +463,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
afterBufferTime, afterBufferTime,
seatsPerTimeSlot, seatsPerTimeSlot,
seatsShowAttendees, seatsShowAttendees,
seatsShowAvailabilityCount,
bookingLimits, bookingLimits,
durationLimits, durationLimits,
recurringEvent, recurringEvent,
@@ -471,9 +475,12 @@ const EventTypePage = (props: EventTypeSetupProps) => {
seatsPerTimeSlotEnabled, seatsPerTimeSlotEnabled,
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
multipleDurationEnabled, multipleDurationEnabled,
length,
...input ...input
} = values; } = values;
if (!Number(length)) throw new Error(t("event_setup_length_error"));
if (bookingLimits) { if (bookingLimits) {
const isValid = validateIntervalLimitOrder(bookingLimits); const isValid = validateIntervalLimitOrder(bookingLimits);
if (!isValid) throw new Error(t("event_setup_booking_limits_error")); if (!isValid) throw new Error(t("event_setup_booking_limits_error"));
@@ -491,7 +498,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
if (metadata?.multipleDuration.length < 1) { if (metadata?.multipleDuration.length < 1) {
throw new Error(t("event_setup_multiple_duration_error")); throw new Error(t("event_setup_multiple_duration_error"));
} else { } else {
if (!input.length && !metadata?.multipleDuration?.includes(input.length)) { if (!length && !metadata?.multipleDuration?.includes(length)) {
throw new Error(t("event_setup_multiple_duration_default_error")); throw new Error(t("event_setup_multiple_duration_default_error"));
} }
} }
@@ -500,6 +507,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
const { availability, ...rest } = input; const { availability, ...rest } = input;
updateMutation.mutate({ updateMutation.mutate({
...rest, ...rest,
length,
locations, locations,
recurringEvent, recurringEvent,
periodStartDate: periodDates.startDate, periodStartDate: periodDates.startDate,
@@ -512,6 +520,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
durationLimits, durationLimits,
seatsPerTimeSlot, seatsPerTimeSlot,
seatsShowAttendees, seatsShowAttendees,
seatsShowAvailabilityCount,
metadata, metadata,
customInputs, customInputs,
}); });
+74 -34
View File
@@ -52,10 +52,9 @@ import {
Skeleton, Skeleton,
Switch, Switch,
Tooltip, Tooltip,
ArrowButton,
} from "@calcom/ui"; } from "@calcom/ui";
import { import {
ArrowDown,
ArrowUp,
Clipboard, Clipboard,
Code, Code,
Copy, Copy,
@@ -109,8 +108,10 @@ const MobileTeamsTab: FC<MobileTeamsTabProps> = (props) => {
const orgBranding = useOrgBranding(); const orgBranding = useOrgBranding();
const tabs = eventTypeGroups.map((item) => ({ const tabs = eventTypeGroups.map((item) => ({
name: item.profile.name ?? "", name: item.profile.name ?? "",
href: item.teamId ? `/event-types?teamId=${item.teamId}` : "/event-types", href: item.teamId ? `/event-types?teamId=${item.teamId}` : "/event-types?noTeam",
avatar: item.profile.image ?? `${orgBranding?.fullDomain ?? WEBAPP_URL}/${item.profile.slug}/avatar.png`, avatar: orgBranding
? `${orgBranding.fullDomain}${item.teamId ? "/team" : ""}/${item.profile.slug}/avatar.png`
: item.profile.image ?? `${WEBAPP_URL + (item.teamId && "/team")}/${item.profile.slug}/avatar.png`,
})); }));
const { data } = useTypedQuery(querySchema); const { data } = useTypedQuery(querySchema);
const events = eventTypeGroups.filter((item) => item.teamId === data.teamId); const events = eventTypeGroups.filter((item) => item.teamId === data.teamId);
@@ -118,13 +119,15 @@ const MobileTeamsTab: FC<MobileTeamsTabProps> = (props) => {
return ( return (
<div> <div>
<HorizontalTabs tabs={tabs} /> <HorizontalTabs tabs={tabs} />
{events.length && ( {events.length > 0 ? (
<EventTypeList <EventTypeList
types={events[0].eventTypes} types={events[0].eventTypes}
group={events[0]} group={events[0]}
groupIndex={0} groupIndex={0}
readOnly={events[0].metadata.readOnly} readOnly={events[0].metadata.readOnly}
/> />
) : (
<CreateFirstEventTypeView />
)} )}
</div> </div>
); );
@@ -359,6 +362,10 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
} }
}, []); }, []);
if (!types.length) {
return group.teamId ? <EmptyEventTypeList group={group} /> : <CreateFirstEventTypeView />;
}
const firstItem = types[0]; const firstItem = types[0];
const lastItem = types[types.length - 1]; const lastItem = types[types.length - 1];
const isManagedEventPrefix = () => { const isManagedEventPrefix = () => {
@@ -378,19 +385,11 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
<div className="hover:bg-muted flex w-full items-center justify-between"> <div className="hover:bg-muted flex w-full items-center justify-between">
<div className="group flex w-full max-w-full items-center justify-between overflow-hidden px-4 py-4 sm:px-6"> <div className="group flex w-full max-w-full items-center justify-between overflow-hidden px-4 py-4 sm:px-6">
{!(firstItem && firstItem.id === type.id) && ( {!(firstItem && firstItem.id === type.id) && (
<button <ArrowButton onClick={() => moveEventType(index, -1)} arrowDirection="up" />
className="bg-default text-muted hover:text-emphasis border-default hover:border-emphasis invisible absolute left-[5px] -ml-4 -mt-4 mb-4 hidden h-6 w-6 scale-0 items-center justify-center rounded-md border p-1 transition-all group-hover:visible group-hover:scale-100 sm:ml-0 sm:flex lg:left-[36px]"
onClick={() => moveEventType(index, -1)}>
<ArrowUp className="h-5 w-5" />
</button>
)} )}
{!(lastItem && lastItem.id === type.id) && ( {!(lastItem && lastItem.id === type.id) && (
<button <ArrowButton onClick={() => moveEventType(index, 1)} arrowDirection="down" />
className="bg-default text-muted border-default hover:text-emphasis hover:border-emphasis invisible absolute left-[5px] -ml-4 mt-8 hidden h-6 w-6 scale-0 items-center justify-center rounded-md border p-1 transition-all group-hover:visible group-hover:scale-100 sm:ml-0 sm:flex lg:left-[36px]"
onClick={() => moveEventType(index, 1)}>
<ArrowDown className="h-5 w-5" />
</button>
)} )}
<MemoizedItem type={type} group={group} readOnly={readOnly} /> <MemoizedItem type={type} group={group} readOnly={readOnly} />
<div className="mt-4 hidden sm:mt-0 sm:flex"> <div className="mt-4 hidden sm:mt-0 sm:flex">
@@ -400,23 +399,27 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
className="relative right-3 top-1" className="relative right-3 top-1"
size="sm" size="sm"
truncateAfter={4} truncateAfter={4}
items={type.users.map( items={
(organizer: { name: string | null; username: string | null }) => ({ type?.users
alt: organizer.name || "", ? type.users.map(
image: `${orgBranding?.fullDomain ?? WEBAPP_URL}/${ (organizer: { name: string | null; username: string | null }) => ({
organizer.username alt: organizer.name || "",
}/avatar.png`, image: `${orgBranding?.fullDomain ?? WEBAPP_URL}/${
title: organizer.name || "", organizer.username
}) }/avatar.png`,
)} title: organizer.name || "",
})
)
: []
}
/> />
)} )}
{isManagedEventType && ( {isManagedEventType && type?.children && type.children?.length > 0 && (
<AvatarGroup <AvatarGroup
className="relative right-3 top-1" className="relative right-3 top-1"
size="sm" size="sm"
truncateAfter={4} truncateAfter={4}
items={type.children items={type?.children
.flatMap((ch) => ch.users) .flatMap((ch) => ch.users)
.map((user: Pick<User, "name" | "username">) => ({ .map((user: Pick<User, "name" | "username">) => ({
alt: user.name || "", alt: user.name || "",
@@ -671,6 +674,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
title={t(`delete${isManagedEventPrefix()}_event_type`)} title={t(`delete${isManagedEventPrefix()}_event_type`)}
confirmBtnText={t(`confirm_delete_event_type`)} confirmBtnText={t(`confirm_delete_event_type`)}
loadingText={t(`confirm_delete_event_type`)} loadingText={t(`confirm_delete_event_type`)}
isLoading={deleteMutation.isLoading}
onConfirm={(e) => { onConfirm={(e) => {
e.preventDefault(); e.preventDefault();
deleteEventTypeHandler(deleteDialogTypeId); deleteEventTypeHandler(deleteDialogTypeId);
@@ -711,12 +715,17 @@ const EventTypeListHeading = ({
}, },
}); });
const bookerUrl = useBookerUrl(); const bookerUrl = useBookerUrl();
return ( return (
<div className="mb-4 flex items-center space-x-2"> <div className="mb-4 flex items-center space-x-2">
<Avatar <Avatar
alt={profile?.name || ""} alt={profile?.name || ""}
href={teamId ? `/settings/teams/${teamId}/profile` : "/settings/my-account/profile"} href={teamId ? `/settings/teams/${teamId}/profile` : "/settings/my-account/profile"}
imageSrc={`${orgBranding?.fullDomain ?? WEBAPP_URL}/${profile.slug}/avatar.png` || undefined} imageSrc={
orgBranding?.fullDomain
? `${orgBranding.fullDomain}${teamId ? "/team" : ""}/${profile.slug}/avatar.png`
: profile.image
}
size="md" size="md"
className="mt-1 inline-flex justify-center" className="mt-1 inline-flex justify-center"
/> />
@@ -763,6 +772,12 @@ const CreateFirstEventTypeView = () => {
Icon={LinkIcon} Icon={LinkIcon}
headline={t("new_event_type_heading")} headline={t("new_event_type_heading")}
description={t("new_event_type_description")} description={t("new_event_type_description")}
className="mb-16"
buttonRaw={
<Button href="?dialog=new" variant="button">
{t("create")}
</Button>
}
/> />
); );
}; };
@@ -830,6 +845,25 @@ const SetupProfileBanner = ({ closeAction }: { closeAction: () => void }) => {
); );
}; };
const EmptyEventTypeList = ({ group }: { group: EventTypeGroup }) => {
const { t } = useLocale();
return (
<>
<EmptyScreen
headline={t("team_no_event_types")}
buttonRaw={
<Button
href={`?dialog=new&eventPage=${group.profile.slug}&teamId=${group.teamId}`}
variant="button"
className="mt-5">
{t("create")}
</Button>
}
/>
</>
);
};
const Main = ({ const Main = ({
status, status,
errorMessage, errorMessage,
@@ -863,7 +897,7 @@ const Main = ({
<MobileTeamsTab eventTypeGroups={data.eventTypeGroups} /> <MobileTeamsTab eventTypeGroups={data.eventTypeGroups} />
) : ( ) : (
data.eventTypeGroups.map((group: EventTypeGroup, index: number) => ( data.eventTypeGroups.map((group: EventTypeGroup, index: number) => (
<div className="flex flex-col" key={group.profile.slug}> <div className="mt-4 flex flex-col" key={group.profile.slug}>
<EventTypeListHeading <EventTypeListHeading
profile={group.profile} profile={group.profile}
membershipCount={group.metadata.membershipCount} membershipCount={group.metadata.membershipCount}
@@ -871,12 +905,18 @@ const Main = ({
orgSlug={orgBranding?.slug} orgSlug={orgBranding?.slug}
/> />
<EventTypeList {group.eventTypes.length ? (
types={group.eventTypes} <EventTypeList
group={group} types={group.eventTypes}
groupIndex={index} group={group}
readOnly={group.metadata.readOnly} groupIndex={index}
/> readOnly={group.metadata.readOnly}
/>
) : group.teamId ? (
<EmptyEventTypeList group={group} />
) : (
<CreateFirstEventTypeView />
)}
</div> </div>
)) ))
)} )}
+1 -1
View File
@@ -57,7 +57,7 @@ export default function InsightsPage() {
return ( return (
<div> <div>
<ShellMain hideHeadingOnMobile> <ShellMain heading="Insights" subtitle={t("insights_subtitle")}>
<UpgradeTip <UpgradeTip
title={t("make_informed_decisions")} title={t("make_informed_decisions")}
description={t("make_informed_decisions_description")} description={t("make_informed_decisions_description")}
@@ -182,6 +182,7 @@ const CalendarsView = () => {
{item.calendars.map((cal) => ( {item.calendars.map((cal) => (
<CalendarSwitch <CalendarSwitch
key={cal.externalId} key={cal.externalId}
credentialId={cal.credentialId}
externalId={cal.externalId} externalId={cal.externalId}
title={cal.name || "Nameless calendar"} title={cal.name || "Nameless calendar"}
name={cal.name || "Nameless calendar"} name={cal.name || "Nameless calendar"}
+11 -2
View File
@@ -6,6 +6,7 @@ import { Controller, useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode"; import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
import OrganizationAvatar from "@calcom/features/ee/organizations/components/OrganizationAvatar";
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout"; import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
import { APP_NAME, FULL_NAME_LENGTH_MAX_LIMIT } from "@calcom/lib/constants"; import { APP_NAME, FULL_NAME_LENGTH_MAX_LIMIT } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
@@ -14,10 +15,10 @@ import turndown from "@calcom/lib/turndownService";
import { IdentityProvider } from "@calcom/prisma/enums"; import { IdentityProvider } from "@calcom/prisma/enums";
import type { TRPCClientErrorLike } from "@calcom/trpc/client"; import type { TRPCClientErrorLike } from "@calcom/trpc/client";
import { trpc } from "@calcom/trpc/react"; import { trpc } from "@calcom/trpc/react";
import type { RouterOutputs } from "@calcom/trpc/react";
import type { AppRouter } from "@calcom/trpc/server/routers/_app"; import type { AppRouter } from "@calcom/trpc/server/routers/_app";
import { import {
Alert, Alert,
Avatar,
Button, Button,
Dialog, Dialog,
DialogClose, DialogClose,
@@ -223,6 +224,7 @@ const ProfileView = () => {
key={JSON.stringify(defaultValues)} key={JSON.stringify(defaultValues)}
defaultValues={defaultValues} defaultValues={defaultValues}
isLoading={updateProfileMutation.isLoading} isLoading={updateProfileMutation.isLoading}
userOrganization={user.organization}
onSubmit={(values) => { onSubmit={(values) => {
if (values.email !== user.email && isCALIdentityProvider) { if (values.email !== user.email && isCALIdentityProvider) {
setTempFormValues(values); setTempFormValues(values);
@@ -364,11 +366,13 @@ const ProfileForm = ({
onSubmit, onSubmit,
extraField, extraField,
isLoading = false, isLoading = false,
userOrganization,
}: { }: {
defaultValues: FormValues; defaultValues: FormValues;
onSubmit: (values: FormValues) => void; onSubmit: (values: FormValues) => void;
extraField?: React.ReactNode; extraField?: React.ReactNode;
isLoading: boolean; isLoading: boolean;
userOrganization: RouterOutputs["viewer"]["me"]["organization"];
}) => { }) => {
const { t } = useLocale(); const { t } = useLocale();
const [firstRender, setFirstRender] = useState(true); const [firstRender, setFirstRender] = useState(true);
@@ -406,7 +410,12 @@ const ProfileForm = ({
name="avatar" name="avatar"
render={({ field: { value } }) => ( render={({ field: { value } }) => (
<> <>
<Avatar alt="" imageSrc={value} gravatarFallbackMd5="fallback" size="lg" /> <OrganizationAvatar
alt={formMethods.getValues("username")}
imageSrc={value}
size="lg"
organizationSlug={userOrganization.slug}
/>
<div className="ms-4"> <div className="ms-4">
<ImageUploader <ImageUploader
target="avatar" target="avatar"
+4 -9
View File
@@ -17,6 +17,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import slugify from "@calcom/lib/slugify"; import slugify from "@calcom/lib/slugify";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry"; import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { signupSchema as apiSignupSchema } from "@calcom/prisma/zod-utils";
import type { inferSSRProps } from "@calcom/types/inferSSRProps"; import type { inferSSRProps } from "@calcom/types/inferSSRProps";
import { Alert, Button, EmailField, HeadSeo, PasswordField, TextField } from "@calcom/ui"; import { Alert, Button, EmailField, HeadSeo, PasswordField, TextField } from "@calcom/ui";
@@ -25,14 +26,7 @@ import PageWrapper from "@components/PageWrapper";
import { IS_GOOGLE_LOGIN_ENABLED } from "../server/lib/constants"; import { IS_GOOGLE_LOGIN_ENABLED } from "../server/lib/constants";
import { ssrInit } from "../server/lib/ssr"; import { ssrInit } from "../server/lib/ssr";
const signupSchema = z.object({ const signupSchema = apiSignupSchema.extend({
username: z.string().refine((value) => !value.includes("+"), {
message: "String should not contain a plus symbol (+).",
}),
email: z.string().email(),
password: z.string().min(7),
language: z.string().optional(),
token: z.string().optional(),
apiError: z.string().optional(), // Needed to display API errors doesnt get passed to the API apiError: z.string().optional(), // Needed to display API errors doesnt get passed to the API
}); });
@@ -46,6 +40,7 @@ export default function Signup({ prepopulateFormValues, token, orgSlug }: Signup
const { t, i18n } = useLocale(); const { t, i18n } = useLocale();
const flags = useFlagMap(); const flags = useFlagMap();
const methods = useForm<FormValues>({ const methods = useForm<FormValues>({
mode: "onChange",
resolver: zodResolver(signupSchema), resolver: zodResolver(signupSchema),
defaultValues: prepopulateFormValues, defaultValues: prepopulateFormValues,
}); });
@@ -131,7 +126,7 @@ export default function Signup({ prepopulateFormValues, token, orgSlug }: Signup
<TextField <TextField
addOnLeading={ addOnLeading={
orgSlug orgSlug
? getOrgFullDomain(orgSlug, { protocol: true }) ? `${getOrgFullDomain(orgSlug, { protocol: true })}/`
: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/` : `${process.env.NEXT_PUBLIC_WEBSITE_URL}/`
} }
{...register("username")} {...register("username")}
+4 -6
View File
@@ -153,11 +153,9 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain }
) : ( ) : (
<div className="space-y-6" data-testid="event-types"> <div className="space-y-6" data-testid="event-types">
<div className="overflow-hidden rounded-sm border dark:border-gray-900"> <div className="overflow-hidden rounded-sm border dark:border-gray-900">
<div className="text-muted dark:text-inverted p-8 text-center"> <div className="text-muted p-8 text-center">
<h2 className="font-cal dark:text-inverted text-emphasis600 mb-2 text-3xl"> <h2 className="font-cal text-emphasis mb-2 text-3xl">{" " + t("org_no_teams_yet")}</h2>
{" " + t("org_no_teams_yet")} <p className="text-emphasis mx-auto max-w-md">{t("org_no_teams_yet_description")}</p>
</h2>
<p className="mx-auto max-w-md">{t("org_no_teams_yet_description")}</p>
</div> </div>
</div> </div>
</div> </div>
@@ -212,7 +210,7 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain }
<EventTypes /> <EventTypes />
{/* Hide "Book a team member button when team is private or hideBookATeamMember is true" */} {/* Hide "Book a team member button when team is private or hideBookATeamMember is true" */}
{(!team.hideBookATeamMember || team.isPrivate) && ( {!team.hideBookATeamMember && !team.isPrivate && (
<div> <div>
<div className="relative mt-12"> <div className="relative mt-12">
<div className="absolute inset-0 flex items-center" aria-hidden="true"> <div className="absolute inset-0 flex items-center" aria-hidden="true">
+1 -1
View File
@@ -25,7 +25,7 @@ const otherNonExistingRoutePrefixes = ["forms", "router", "success", "cancel"];
let subdomainRegExp = (exports.subdomainRegExp = getSubdomainRegExp( let subdomainRegExp = (exports.subdomainRegExp = getSubdomainRegExp(
process.env.NEXT_PUBLIC_WEBAPP_URL || "https://" + process.env.VERCEL_URL process.env.NEXT_PUBLIC_WEBAPP_URL || "https://" + process.env.VERCEL_URL
)); ));
exports.orgHostPath = `^(?<orgSlug>${subdomainRegExp})\\..*`; exports.orgHostPath = `^(?<orgSlug>${subdomainRegExp})\\.(?!vercel\.app).*`;
let beforeRewriteExcludePages = pages.concat(otherNonExistingRoutePrefixes); let beforeRewriteExcludePages = pages.concat(otherNonExistingRoutePrefixes);
exports.orgUserRoutePath = `/:user((?!${beforeRewriteExcludePages.join("|")}|_next|public)[a-zA-Z0-9\-_]+)`; exports.orgUserRoutePath = `/:user((?!${beforeRewriteExcludePages.join("|")}|_next|public)[a-zA-Z0-9\-_]+)`;
+59 -1
View File
@@ -158,9 +158,10 @@ test.describe("pro user", () => {
await expect(page.locator("[data-testid=success-page]")).toBeVisible(); await expect(page.locator("[data-testid=success-page]")).toBeVisible();
additionalGuests.forEach(async (email) => { const promises = additionalGuests.map(async (email) => {
await expect(page.locator(`[data-testid="attendee-email-${email}"]`)).toHaveText(email); await expect(page.locator(`[data-testid="attendee-email-${email}"]`)).toHaveText(email);
}); });
await Promise.all(promises);
}); });
test("Time slots should be reserved when selected", async ({ context, page }) => { test("Time slots should be reserved when selected", async ({ context, page }) => {
@@ -267,3 +268,60 @@ test.describe("prefill", () => {
}); });
}); });
}); });
test.describe("Booking on different layouts", () => {
test.beforeEach(async ({ page, users }) => {
const user = await users.create();
await page.goto(`/${user.username}`);
});
test("Book on week layout", async ({ page }) => {
// Click first event type
await page.click('[data-testid="event-type-link"]');
await page.click('[data-testid="toggle-group-item-week_view"]');
await page.click('[data-testid="incrementMonth"]');
await page.locator('[data-testid="calendar-empty-cell"]').nth(0).click();
// Fill what is this meeting about? name email and notes
await page.locator('[name="name"]').fill("Test name");
await page.locator('[name="email"]').fill(`${randomString(4)}@example.com`);
await page.locator('[name="notes"]').fill("Test notes");
await page.click('[data-testid="confirm-book-button"]');
await page.waitForURL((url) => {
return url.pathname.startsWith("/booking");
});
// expect page to be booking page
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
});
test("Book on column layout", async ({ page }) => {
// Click first event type
await page.click('[data-testid="event-type-link"]');
await page.click('[data-testid="toggle-group-item-column_view"]');
await page.click('[data-testid="incrementMonth"]');
await page.locator('[data-testid="time"]').nth(0).click();
// Fill what is this meeting about? name email and notes
await page.locator('[name="name"]').fill("Test name");
await page.locator('[name="email"]').fill(`${randomString(4)}@example.com`);
await page.locator('[name="notes"]').fill("Test notes");
await page.click('[data-testid="confirm-book-button"]');
await page.waitForURL((url) => {
return url.pathname.startsWith("/booking");
});
// expect page to be booking page
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
});
});
+2 -41
View File
@@ -1,60 +1,21 @@
import { expect } from "@playwright/test"; import { expect } from "@playwright/test";
import type { Prisma } from "@prisma/client";
import { uuid } from "short-uuid"; import { uuid } from "short-uuid";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import prisma from "@calcom/prisma"; import prisma from "@calcom/prisma";
import { BookingStatus } from "@calcom/prisma/enums"; import { BookingStatus } from "@calcom/prisma/enums";
import type { Fixtures } from "./lib/fixtures";
import { test } from "./lib/fixtures"; import { test } from "./lib/fixtures";
import { import {
bookTimeSlot, bookTimeSlot,
createNewSeatedEventType, createNewSeatedEventType,
selectFirstAvailableTimeSlotNextMonth, selectFirstAvailableTimeSlotNextMonth,
createUserWithSeatedEventAndAttendees,
} from "./lib/testUtils"; } from "./lib/testUtils";
test.describe.configure({ mode: "parallel" }); test.describe.configure({ mode: "parallel" });
test.afterEach(({ users }) => users.deleteAll()); test.afterEach(({ users }) => users.deleteAll());
async function createUserWithSeatedEvent(users: Fixtures["users"]) {
const slug = "seats";
const user = await users.create({
eventTypes: [
{
title: "Seated event",
slug,
seatsPerTimeSlot: 10,
requiresConfirmation: true,
length: 30,
disableGuests: true, // should always be true for seated events
},
],
});
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const eventType = user.eventTypes.find((e) => e.slug === slug)!;
return { user, eventType };
}
async function createUserWithSeatedEventAndAttendees(
fixtures: Pick<Fixtures, "users" | "bookings">,
attendees: Prisma.AttendeeCreateManyBookingInput[]
) {
const { user, eventType } = await createUserWithSeatedEvent(fixtures.users);
const booking = await fixtures.bookings.create(user.id, user.username, eventType.id, {
status: BookingStatus.ACCEPTED,
// startTime with 1 day from now and endTime half hour after
startTime: new Date(Date.now() + 24 * 60 * 60 * 1000),
endTime: new Date(Date.now() + 24 * 60 * 60 * 1000 + 30 * 60 * 1000),
attendees: {
createMany: {
data: attendees,
},
},
});
return { user, eventType, booking };
}
test.describe("Booking with Seats", () => { test.describe("Booking with Seats", () => {
test("User can create a seated event (2 seats as example)", async ({ users, page }) => { test("User can create a seated event (2 seats as example)", async ({ users, page }) => {
const user = await users.create({ name: "Seated event" }); const user = await users.create({ name: "Seated event" });
@@ -64,7 +25,7 @@ test.describe("Booking with Seats", () => {
await page.waitForSelector('[data-testid="event-types"]'); await page.waitForSelector('[data-testid="event-types"]');
const eventTitle = "My 2-seated event"; const eventTitle = "My 2-seated event";
await createNewSeatedEventType(page, { eventTitle }); await createNewSeatedEventType(page, { eventTitle });
await expect(page.locator(`text=${eventTitle} event type updated successfully`)).toBeVisible(); await expect(page.locator(`text=Event type updated successfully`)).toBeVisible();
}); });
test("Multiple Attendees can book a seated event time slot", async ({ users, page }) => { test("Multiple Attendees can book a seated event time slot", async ({ users, page }) => {
+15 -7
View File
@@ -142,17 +142,25 @@ test.describe("Event Types tests", () => {
.first() .first()
.getAttribute("href"); .getAttribute("href");
/**
* Verify first organizer address
*/
await page.goto(previewLink ?? ""); await page.goto(previewLink ?? "");
await selectFirstAvailableTimeSlotNextMonth(page); await selectFirstAvailableTimeSlotNextMonth(page);
await page.locator(`span:has-text("${locationData[0]}")`).click();
for (const location of locationData) {
await page.locator(`span:has-text("${location}")`).click();
}
await bookTimeSlot(page); await bookTimeSlot(page);
await expect(page.locator("[data-testid=success-page]")).toBeVisible(); await expect(page.locator("[data-testid=success-page]")).toBeVisible();
await expect(page.locator(`[data-testid="where"]`)).toHaveText(locationData[0]);
/**
* Verify second organizer address
*/
await page.goto(previewLink ?? "");
await selectFirstAvailableTimeSlotNextMonth(page);
await page.locator(`span:has-text("${locationData[1]}")`).click();
await bookTimeSlot(page);
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
await expect(page.locator(`[data-testid="where"]`)).toHaveText(locationData[1]);
}); });
test.describe("Different Locations Tests", () => { test.describe("Different Locations Tests", () => {
@@ -0,0 +1,61 @@
import { v4 as uuidv4 } from "uuid";
import { prisma } from "@calcom/prisma";
type Route = {
id: string;
action: {
type: string;
value: string;
};
isFallback: boolean;
queryValue: {
id: string;
type: string;
};
};
export const createRoutingFormsFixture = () => {
return {
async create({
userId,
teamId,
name,
fields,
routes = [],
}: {
name: string;
userId: number;
teamId: number | null;
routes?: Route[];
fields: {
type: string;
label: string;
identifier?: string;
required: boolean;
}[];
}) {
return await prisma.app_RoutingForms_Form.create({
data: {
name,
userId,
teamId,
routes: [
...routes,
// Add a fallback route always, this is taken care of tRPC route normally but do it manually while running the query directly.
{
id: "898899aa-4567-489a-bcde-f1823f708646",
action: { type: "customPageMessage", value: "Fallback Message" },
isFallback: true,
queryValue: { id: "898899aa-4567-489a-bcde-f1823f708646", type: "group" },
},
],
fields: fields.map((f) => ({
id: uuidv4(),
...f,
})),
},
});
},
};
};
+5
View File
@@ -10,6 +10,7 @@ import type { ExpectedUrlDetails } from "../../../../playwright.config";
import { createBookingsFixture } from "../fixtures/bookings"; import { createBookingsFixture } from "../fixtures/bookings";
import { createEmbedsFixture, createGetActionFiredDetails } from "../fixtures/embeds"; import { createEmbedsFixture, createGetActionFiredDetails } from "../fixtures/embeds";
import { createPaymentsFixture } from "../fixtures/payments"; import { createPaymentsFixture } from "../fixtures/payments";
import { createRoutingFormsFixture } from "../fixtures/routingForms";
import { createServersFixture } from "../fixtures/servers"; import { createServersFixture } from "../fixtures/servers";
import { createUsersFixture } from "../fixtures/users"; import { createUsersFixture } from "../fixtures/users";
@@ -23,6 +24,7 @@ export interface Fixtures {
servers: ReturnType<typeof createServersFixture>; servers: ReturnType<typeof createServersFixture>;
prisma: typeof prisma; prisma: typeof prisma;
emails?: API; emails?: API;
routingForms: ReturnType<typeof createRoutingFormsFixture>;
} }
declare global { declare global {
@@ -71,6 +73,9 @@ export const test = base.extend<Fixtures>({
prisma: async ({}, use) => { prisma: async ({}, use) => {
await use(prisma); await use(prisma);
}, },
routingForms: async ({}, use) => {
await use(createRoutingFormsFixture());
},
emails: async ({}, use) => { emails: async ({}, use) => {
if (IS_MAILHOG_ENABLED) { if (IS_MAILHOG_ENABLED) {
const mailhogAPI = mailhog(); const mailhogAPI = mailhog();
+46
View File
@@ -6,6 +6,10 @@ import { createServer } from "http";
import { noop } from "lodash"; import { noop } from "lodash";
import type { API, Messages } from "mailhog"; import type { API, Messages } from "mailhog";
import type { Prisma } from "@calcom/prisma/client";
import { BookingStatus } from "@calcom/prisma/enums";
import type { Fixtures } from "./fixtures";
import { test } from "./fixtures"; import { test } from "./fixtures";
export function todo(title: string) { export function todo(title: string) {
@@ -192,6 +196,7 @@ export async function installAppleCalendar(page: Page) {
await page.waitForURL("/apps/apple-calendar"); await page.waitForURL("/apps/apple-calendar");
await page.click('[data-testid="install-app-button"]'); await page.click('[data-testid="install-app-button"]');
} }
export async function getEmailsReceivedByUser({ export async function getEmailsReceivedByUser({
emails, emails,
userEmail, userEmail,
@@ -228,3 +233,44 @@ export async function expectEmailsToHaveSubject({
expect(organizerFirstEmail.subject).toBe(emailSubject); expect(organizerFirstEmail.subject).toBe(emailSubject);
expect(bookerFirstEmail.subject).toBe(emailSubject); expect(bookerFirstEmail.subject).toBe(emailSubject);
} }
// this method is not used anywhere else
// but I'm keeping it here in case we need in the future
async function createUserWithSeatedEvent(users: Fixtures["users"]) {
const slug = "seats";
const user = await users.create({
eventTypes: [
{
title: "Seated event",
slug,
seatsPerTimeSlot: 10,
requiresConfirmation: true,
length: 30,
disableGuests: true, // should always be true for seated events
},
],
});
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const eventType = user.eventTypes.find((e) => e.slug === slug)!;
return { user, eventType };
}
export async function createUserWithSeatedEventAndAttendees(
fixtures: Pick<Fixtures, "users" | "bookings">,
attendees: Prisma.AttendeeCreateManyBookingInput[]
) {
const { user, eventType } = await createUserWithSeatedEvent(fixtures.users);
const booking = await fixtures.bookings.create(user.id, user.username, eventType.id, {
status: BookingStatus.ACCEPTED,
// startTime with 1 day from now and endTime half hour after
startTime: new Date(Date.now() + 24 * 60 * 60 * 1000),
endTime: new Date(Date.now() + 24 * 60 * 60 * 1000 + 30 * 60 * 1000),
attendees: {
createMany: {
data: attendees,
},
},
});
return { user, eventType, booking };
}
+24 -2
View File
@@ -4,11 +4,14 @@ import { authenticator } from "otplib";
import { symmetricDecrypt } from "@calcom/lib/crypto"; import { symmetricDecrypt } from "@calcom/lib/crypto";
import { totpAuthenticatorCheck } from "@calcom/lib/totp"; import { totpAuthenticatorCheck } from "@calcom/lib/totp";
import { prisma } from "@calcom/prisma";
import { test } from "./lib/fixtures"; import { test } from "./lib/fixtures";
test.describe.configure({ mode: "parallel" }); test.describe.configure({ mode: "parallel" });
// TODO: add more backup code tests, e.g. login + disabling 2fa with backup
// a test to logout requires both a succesfull login as logout, to prevent // a test to logout requires both a succesfull login as logout, to prevent
// a doubling of tests failing on logout & logout, we can group them. // a doubling of tests failing on logout & logout, we can group them.
test.describe("2FA Tests", async () => { test.describe("2FA Tests", async () => {
@@ -45,6 +48,8 @@ test.describe("2FA Tests", async () => {
secret: secret!, secret: secret!,
}); });
// FIXME: this passes even when switch is not checked, compare to test
// below which checks for data-state="checked" and works as expected
await page.waitForSelector(`[data-testid=two-factor-switch]`); await page.waitForSelector(`[data-testid=two-factor-switch]`);
await expect(page.locator(`[data-testid=two-factor-switch]`).isChecked()).toBeTruthy(); await expect(page.locator(`[data-testid=two-factor-switch]`).isChecked()).toBeTruthy();
@@ -57,7 +62,7 @@ test.describe("2FA Tests", async () => {
await test.step("Login with 2FA enabled", async () => { await test.step("Login with 2FA enabled", async () => {
await user.login(); await user.login();
const userWith2FaSecret = await prisma?.user.findFirst({ const userWith2FaSecret = await prisma.user.findFirst({
where: { where: {
id: user.id, id: user.id,
}, },
@@ -103,6 +108,23 @@ test.describe("2FA Tests", async () => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
await fillOtp({ page, secret: secret! }); await fillOtp({ page, secret: secret! });
// backup codes are now showing, so run a few tests
// click download button
const promise = page.waitForEvent("download");
await page.getByTestId("backup-codes-download").click();
const download = await promise;
expect(download.suggestedFilename()).toBe("cal-backup-codes.txt");
// TODO: check file content
// click copy button
await page.getByTestId("backup-codes-copy").click();
await page.getByTestId("toast-success").waitFor();
// TODO: check clipboard content
// close backup code dialog
await page.getByTestId("backup-codes-close").click();
await expect(page.locator(`[data-testid=two-factor-switch][data-state="checked"]`)).toBeVisible(); await expect(page.locator(`[data-testid=two-factor-switch][data-state="checked"]`)).toBeVisible();
return user; return user;
@@ -117,7 +139,7 @@ test.describe("2FA Tests", async () => {
await page.click(`[data-testid=two-factor-switch][data-state="checked"]`); await page.click(`[data-testid=two-factor-switch][data-state="checked"]`);
await page.fill('input[name="password"]', userPassword); await page.fill('input[name="password"]', userPassword);
const userWith2FaSecret = await prisma?.user.findFirst({ const userWith2FaSecret = await prisma.user.findFirst({
where: { where: {
id: user.id, id: user.id,
}, },
@@ -135,6 +135,24 @@ test.describe("Manage Booking Questions", () => {
}); });
}); });
}); });
await test.step("Verify that we can prefill name field with no lastname", async () => {
const searchParams = new URLSearchParams();
searchParams.append("name", "FirstName");
await doOnFreshPreviewWithSearchParams(searchParams, page, context, async (page) => {
await selectFirstAvailableTimeSlotNextMonth(page);
await expectSystemFieldsToBeThereOnBookingPage({
page,
isFirstAndLastNameVariant: true,
values: {
name: {
firstName: "FirstName",
lastName: "",
},
},
});
});
});
}); });
}); });
@@ -506,6 +524,27 @@ async function doOnFreshPreview(
return previewTabPage; return previewTabPage;
} }
async function doOnFreshPreviewWithSearchParams(
searchParams: URLSearchParams,
page: Page,
context: PlaywrightTestArgs["context"],
callback: (page: Page) => Promise<void>,
persistTab = false
) {
const previewUrl = (await page.locator('[data-testid="preview-button"]').getAttribute("href")) || "";
const previewUrlObj = new URL(previewUrl);
searchParams.forEach((value, key) => {
previewUrlObj.searchParams.append(key, value);
});
const previewTabPage = await context.newPage();
await previewTabPage.goto(previewUrlObj.toString());
await callback(previewTabPage);
if (!persistTab) {
await previewTabPage.close();
}
return previewTabPage;
}
async function toggleQuestionAndSave({ name, page }: { name: string; page: Page }) { async function toggleQuestionAndSave({ name, page }: { name: string; page: Page }) {
await page.locator(`[data-testid="field-${name}"]`).locator('[data-testid="toggle-field"]').click(); await page.locator(`[data-testid="field-${name}"]`).locator('[data-testid="toggle-field"]').click();
await saveEventType(page); await saveEventType(page);
+281 -32
View File
@@ -1,4 +1,9 @@
import type { Page } from "@playwright/test";
import { expect } from "@playwright/test"; import { expect } from "@playwright/test";
import { v4 as uuidv4 } from "uuid";
import prisma from "@calcom/prisma";
import { BookingStatus } from "@calcom/prisma/client";
import { test } from "./lib/fixtures"; import { test } from "./lib/fixtures";
import { import {
@@ -7,10 +12,37 @@ import {
selectFirstAvailableTimeSlotNextMonth, selectFirstAvailableTimeSlotNextMonth,
waitFor, waitFor,
gotoRoutingLink, gotoRoutingLink,
createUserWithSeatedEventAndAttendees,
} from "./lib/testUtils"; } from "./lib/testUtils";
// remove dynamic properties that differs depending on where you run the tests
const dynamic = "[redacted/dynamic]";
test.afterEach(({ users }) => users.deleteAll()); test.afterEach(({ users }) => users.deleteAll());
async function createWebhookReceiver(page: Page) {
const webhookReceiver = createHttpServer();
await page.goto(`/settings/developer/webhooks`);
// --- add webhook
await page.click('[data-testid="new_webhook"]');
await page.fill('[name="subscriberUrl"]', webhookReceiver.url);
await page.fill('[name="secret"]', "secret");
await Promise.all([
page.click("[type=submit]"),
page.waitForURL((url) => url.pathname.endsWith("/settings/developer/webhooks")),
]);
// page contains the url
expect(page.locator(`text='${webhookReceiver.url}'`)).toBeDefined();
return webhookReceiver;
}
test.describe("BOOKING_CREATED", async () => { test.describe("BOOKING_CREATED", async () => {
test("add webhook & test that creating an event triggers a webhook call", async ({ test("add webhook & test that creating an event triggers a webhook call", async ({
page, page,
@@ -55,8 +87,6 @@ test.describe("BOOKING_CREATED", async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const body: any = request.body; const body: any = request.body;
// remove dynamic properties that differs depending on where you run the tests
const dynamic = "[redacted/dynamic]";
body.createdAt = dynamic; body.createdAt = dynamic;
body.payload.startTime = dynamic; body.payload.startTime = dynamic;
body.payload.endTime = dynamic; body.payload.endTime = dynamic;
@@ -187,8 +217,6 @@ test.describe("BOOKING_REJECTED", async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const body = request.body as any; const body = request.body as any;
// remove dynamic properties that differs depending on where you run the tests
const dynamic = "[redacted/dynamic]";
body.createdAt = dynamic; body.createdAt = dynamic;
body.payload.startTime = dynamic; body.payload.startTime = dynamic;
body.payload.endTime = dynamic; body.payload.endTime = dynamic;
@@ -246,7 +274,7 @@ test.describe("BOOKING_REJECTED", async () => {
}, },
], ],
location: "[redacted/dynamic]", location: "[redacted/dynamic]",
destinationCalendar: null, destinationCalendar: [],
// hideCalendarNotes: false, // hideCalendarNotes: false,
requiresConfirmation: "[redacted/dynamic]", requiresConfirmation: "[redacted/dynamic]",
eventTypeId: "[redacted/dynamic]", eventTypeId: "[redacted/dynamic]",
@@ -311,8 +339,6 @@ test.describe("BOOKING_REQUESTED", async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const body = request.body as any; const body = request.body as any;
// remove dynamic properties that differs depending on where you run the tests
const dynamic = "[redacted/dynamic]";
body.createdAt = dynamic; body.createdAt = dynamic;
body.payload.startTime = dynamic; body.payload.startTime = dynamic;
body.payload.endTime = dynamic; body.payload.endTime = dynamic;
@@ -390,55 +416,278 @@ test.describe("BOOKING_REQUESTED", async () => {
}); });
}); });
test.describe("FORM_SUBMITTED", async () => { test.describe("BOOKING_RESCHEDULED", async () => {
test("can submit a form and get a submission event", async ({ page, users }) => { test("can reschedule a booking and get a booking rescheduled event", async ({ page, users, bookings }) => {
const webhookReceiver = createHttpServer();
const user = await users.create(); const user = await users.create();
const [eventType] = user.eventTypes;
await user.apiLogin(); await user.apiLogin();
await page.goto("/settings/teams/new"); const webhookReceiver = await createWebhookReceiver(page);
await page.waitForLoadState("networkidle");
const teamName = `${user.username}'s Team`;
// Create a new team
await page.locator('input[name="name"]').fill(teamName);
await page.locator('input[name="slug"]').fill(teamName);
await page.locator('button[type="submit"]').click();
await page.locator("text=Publish team").click(); const booking = await bookings.create(user.id, user.username, eventType.id, {
await page.waitForURL(/\/settings\/teams\/(\d+)\/profile$/i); status: BookingStatus.ACCEPTED,
});
await page.waitForLoadState("networkidle"); await page.goto(`/${user.username}/${eventType.slug}?rescheduleUid=${booking.uid}`);
await selectFirstAvailableTimeSlotNextMonth(page);
await page.locator('[data-testid="confirm-reschedule-button"]').click();
await expect(page).toHaveURL(/.*booking/);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const newBooking = await prisma.booking.findFirst({ where: { fromReschedule: booking?.uid } })!;
expect(newBooking).not.toBeNull();
// --- check that webhook was called
await waitFor(() => {
expect(webhookReceiver.requestList.length).toBe(1);
});
const [request] = webhookReceiver.requestList;
expect(request.body).toMatchObject({
triggerEvent: "BOOKING_RESCHEDULED",
payload: {
uid: newBooking?.uid,
},
});
});
test("when rescheduling to a booking that already exists, should send a booking rescheduled event with the existant booking uid", async ({
page,
users,
bookings,
}) => {
const { user, eventType, booking } = await createUserWithSeatedEventAndAttendees({ users, bookings }, [
{ name: "John First", email: "first+seats@cal.com", timeZone: "Europe/Berlin" },
{ name: "Jane Second", email: "second+seats@cal.com", timeZone: "Europe/Berlin" },
]);
await prisma.eventType.update({
where: { id: eventType.id },
data: { requiresConfirmation: false },
});
await user.apiLogin();
const webhookReceiver = await createWebhookReceiver(page);
const bookingAttendees = await prisma.attendee.findMany({
where: { bookingId: booking.id },
select: {
id: true,
email: true,
},
});
const bookingSeats = bookingAttendees.map((attendee) => ({
bookingId: booking.id,
attendeeId: attendee.id,
referenceUid: uuidv4(),
}));
await prisma.bookingSeat.createMany({
data: bookingSeats,
});
const references = await prisma.bookingSeat.findMany({
where: { bookingId: booking.id },
include: { attendee: true },
});
await page.goto(`/reschedule/${references[0].referenceUid}`);
await selectFirstAvailableTimeSlotNextMonth(page);
await page.locator('[data-testid="confirm-reschedule-button"]').click();
await expect(page).toHaveURL(/.*booking/);
const newBooking = await prisma.booking.findFirst({
where: {
attendees: {
some: {
email: bookingAttendees[0].email,
},
},
},
});
// --- ensuring that new booking was created
expect(newBooking).not.toBeNull();
// --- check that webhook was called
await waitFor(() => {
expect(webhookReceiver.requestList.length).toBe(1);
});
const [firstRequest] = webhookReceiver.requestList;
expect(firstRequest?.body).toMatchObject({
triggerEvent: "BOOKING_RESCHEDULED",
payload: {
uid: newBooking?.uid,
},
});
await page.goto(`/reschedule/${references[1].referenceUid}`);
await selectFirstAvailableTimeSlotNextMonth(page);
await page.locator('[data-testid="confirm-reschedule-button"]').click();
await expect(page).toHaveURL(/.*booking/);
await waitFor(() => {
expect(webhookReceiver.requestList.length).toBe(2);
});
const [_, secondRequest] = webhookReceiver.requestList;
expect(secondRequest?.body).toMatchObject({
triggerEvent: "BOOKING_RESCHEDULED",
payload: {
// in the current implementation, it is the same as the first booking
uid: newBooking?.uid,
},
});
});
});
test.describe("FORM_SUBMITTED", async () => {
test("on submitting user form, triggers user webhook", async ({ page, users, routingForms }) => {
const webhookReceiver = createHttpServer();
const user = await users.create(null, {
hasTeam: true,
});
await user.apiLogin();
await page.waitForLoadState("networkidle");
await page.goto(`/settings/developer/webhooks/new`); await page.goto(`/settings/developer/webhooks/new`);
// Add webhook // Add webhook
await page.fill('[name="subscriberUrl"]', webhookReceiver.url); await page.fill('[name="subscriberUrl"]', webhookReceiver.url);
await page.fill('[name="secret"]', "secret"); await page.fill('[name="secret"]', "secret");
await Promise.all([page.click("[type=submit]"), page.goForward()]); await page.click("[type=submit]");
// Page contains the url // Page contains the url
expect(page.locator(`text='${webhookReceiver.url}'`)).toBeDefined(); expect(page.locator(`text='${webhookReceiver.url}'`)).toBeDefined();
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
await page.goto("/routing-forms/forms");
await page.click('[data-testid="new-routing-form"]');
// Choose to create the Form for the user(which is the first option) and not the team
await page.click('[data-testid="option-0"]');
await page.fill("input[name]", "TEST FORM");
await page.click('[data-testid="add-form"]');
await page.waitForSelector('[data-testid="add-field"]');
const url = page.url(); const form = await routingForms.create({
const formId = new URL(url).pathname.split("/").at(-1); name: "Test Form",
userId: user.id,
teamId: null,
fields: [
{
type: "text",
label: "Name",
identifier: "name",
required: true,
},
],
});
await gotoRoutingLink({ page, formId: formId }); await gotoRoutingLink({ page, formId: form.id });
const fieldName = "name";
await page.fill(`[data-testid="form-field-${fieldName}"]`, "John Doe");
page.click('button[type="submit"]'); page.click('button[type="submit"]');
await waitFor(() => { await waitFor(() => {
expect(webhookReceiver.requestList.length).toBe(1); expect(webhookReceiver.requestList.length).toBe(1);
}); });
const [request] = webhookReceiver.requestList;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const body = request.body as any;
body.createdAt = dynamic;
expect(body).toEqual({
triggerEvent: "FORM_SUBMITTED",
createdAt: dynamic,
payload: {
formId: form.id,
formName: form.name,
teamId: null,
responses: {
name: {
value: "John Doe",
},
},
},
name: "John Doe",
});
webhookReceiver.close();
});
test("on submitting team form, triggers team webhook", async ({ page, users, routingForms }) => {
const webhookReceiver = createHttpServer();
const user = await users.create(null, {
hasTeam: true,
});
await user.apiLogin();
await page.goto(`/settings/developer/webhooks`);
const teamId = await clickFirstTeamWebhookCta(page);
// Add webhook
await page.fill('[name="subscriberUrl"]', webhookReceiver.url);
await page.fill('[name="secret"]', "secret");
await page.click("[type=submit]");
const form = await routingForms.create({
name: "Test Form",
userId: user.id,
teamId: teamId,
fields: [
{
type: "text",
label: "Name",
identifier: "name",
required: true,
},
],
});
await gotoRoutingLink({ page, formId: form.id });
const fieldName = "name";
await page.fill(`[data-testid="form-field-${fieldName}"]`, "John Doe");
page.click('button[type="submit"]');
await waitFor(() => {
expect(webhookReceiver.requestList.length).toBe(1);
});
const [request] = webhookReceiver.requestList;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const body = request.body as any;
body.createdAt = dynamic;
expect(body).toEqual({
triggerEvent: "FORM_SUBMITTED",
createdAt: dynamic,
payload: {
formId: form.id,
formName: form.name,
teamId,
responses: {
name: {
value: "John Doe",
},
},
},
name: "John Doe",
});
webhookReceiver.close(); webhookReceiver.close();
}); });
}); });
async function clickFirstTeamWebhookCta(page: Page) {
await page.click('[data-testid="new_webhook"]');
await page.click('[data-testid="option-team-1"]');
await page.waitForURL((u) => u.pathname === "/settings/developer/webhooks/new");
const url = page.url();
const teamId = Number(new URL(url).searchParams.get("teamId")) as number;
return teamId;
}
+1
View File
@@ -0,0 +1 @@
<svg width="32" height="32" fill="none" xmlns="http://www.w3.org/2000/svg"><g clip-path="url(#a)"><rect width="32" height="32" rx="16" fill="#E5E7EB"/><path d="M16 21.333A6.667 6.667 0 1 0 16 8a6.667 6.667 0 0 0 0 13.333Z" fill="#1F2937"/><path d="M26.667 32a10.667 10.667 0 1 0-21.334 0" fill="#1F2937"/><path d="M16 21.333A6.667 6.667 0 1 0 16 8a6.667 6.667 0 0 0 0 13.333Zm0 0A10.667 10.667 0 0 1 26.667 32M16 21.333A10.666 10.666 0 0 0 5.333 32" stroke="#1F2937" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></g><defs><clipPath id="a"><rect width="32" height="32" rx="16" fill="#fff"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 635 B

+9 -11
View File
@@ -13,7 +13,7 @@
"reset_password_subject": "{{appName}}: إرشادات إعادة تعيين كلمة المرور", "reset_password_subject": "{{appName}}: إرشادات إعادة تعيين كلمة المرور",
"verify_email_subject": "{{appName}}: تأكيد حسابك", "verify_email_subject": "{{appName}}: تأكيد حسابك",
"check_your_email": "تحقق من بريدك الإلكتروني", "check_your_email": "تحقق من بريدك الإلكتروني",
"verify_email_page_body": "أرسلنا رسالة إلكترونية إلى {{email}}. من المهم تأكيد عنوان بريدك الإلكتروني لضمان أفضل تسليم للبريد الإلكتروني والتقويم من {{appName}}.", "verify_email_page_body": "أرسلنا رسالة إلكترونية إلى {{email}}. من المهم تأكيد عنوان بريدك الإلكتروني لضمان تسليم البريد الإلكتروني والتقويم من {{appName}}.",
"verify_email_banner_body": "قم بتأكيد عنوان بريدك الإلكتروني لضمان أفضل تسليم للبريد الإلكتروني والتقويم", "verify_email_banner_body": "قم بتأكيد عنوان بريدك الإلكتروني لضمان أفضل تسليم للبريد الإلكتروني والتقويم",
"verify_email_email_header": "تأكيد عنوان بريدك الإلكتروني", "verify_email_email_header": "تأكيد عنوان بريدك الإلكتروني",
"verify_email_email_button": "تأكيد البريد الإلكتروني", "verify_email_email_button": "تأكيد البريد الإلكتروني",
@@ -130,7 +130,7 @@
"upgrade_banner_action": "قم بالترقية هنا", "upgrade_banner_action": "قم بالترقية هنا",
"team_upgraded_successfully": "تمت ترقية فريقك بنجاح!", "team_upgraded_successfully": "تمت ترقية فريقك بنجاح!",
"org_upgrade_banner_description": "شكرًا لك على تجربة خطة Organization الجديدة التي نقدمها. لقد لاحظنا أن منظمتك \"{{teamName}}\" بحاجة إلى الترقية.", "org_upgrade_banner_description": "شكرًا لك على تجربة خطة Organization الجديدة التي نقدمها. لقد لاحظنا أن منظمتك \"{{teamName}}\" بحاجة إلى الترقية.",
"org_upgraded_successfully": "تمت ترقية منظمتك بنجاح!", "org_upgraded_successfully": "تمت ترقية خطة Organization بنجاح!",
"use_link_to_reset_password": "استخدم الرابط أدناه لإعادة تعيين كلمة المرور", "use_link_to_reset_password": "استخدم الرابط أدناه لإعادة تعيين كلمة المرور",
"hey_there": "مرحبًا،", "hey_there": "مرحبًا،",
"forgot_your_password_calcom": "نسيت كلمة المرور الخاصة بك؟ - {{appName}}", "forgot_your_password_calcom": "نسيت كلمة المرور الخاصة بك؟ - {{appName}}",
@@ -550,7 +550,7 @@
"team_description": "بضع جمل عن فريقك. ستظهر على صفحة رابط فريقك.", "team_description": "بضع جمل عن فريقك. ستظهر على صفحة رابط فريقك.",
"org_description": "بضع جمل عن منظمتك. ستظهر على صفحة رابط منظمتك.", "org_description": "بضع جمل عن منظمتك. ستظهر على صفحة رابط منظمتك.",
"members": "الأعضاء", "members": "الأعضاء",
"organization_members": "أعضاء المنظمة", "organization_members": "أعضاء Organization",
"member": "العضو", "member": "العضو",
"number_member_one": "{{count}} عضو", "number_member_one": "{{count}} عضو",
"number_member_other": "{{count}} من الأعضاء", "number_member_other": "{{count}} من الأعضاء",
@@ -1074,11 +1074,11 @@
"event_cancelled": "تم إلغاء هذا الحدث", "event_cancelled": "تم إلغاء هذا الحدث",
"emailed_information_about_cancelled_event": "لقد أرسلنا إليك وإلى الحاضرين الآخرين بريداً إلكترونياً لإعلامهم.", "emailed_information_about_cancelled_event": "لقد أرسلنا إليك وإلى الحاضرين الآخرين بريداً إلكترونياً لإعلامهم.",
"this_input_will_shown_booking_this_event": "سيُعرَض هذا المُدخَل عند حجز هذا الحدث", "this_input_will_shown_booking_this_event": "سيُعرَض هذا المُدخَل عند حجز هذا الحدث",
"meeting_url_in_conformation_email": "عنوان رابط الاجتماع موجود في رسالة التأكيد الإلكترونية", "meeting_url_in_confirmation_email": "عنوان رابط الاجتماع موجود في رسالة التأكيد الإلكترونية",
"url_start_with_https": "يجب أن يبدأ العنوان بـ http:// أو https://", "url_start_with_https": "يجب أن يبدأ العنوان بـ http:// أو https://",
"number_provided": "سيتم توفير رقم الهاتف", "number_provided": "سيتم توفير رقم الهاتف",
"before_event_trigger": "قبل بدء الحدث", "before_event_trigger": "قبل بدء الحدث",
"event_cancelled_trigger": "متى تم إلغاء هذا الحدث", "event_cancelled_trigger": "عند إلغاء هذا الحدث",
"new_event_trigger": "متى تم حجز الحدث الجديد", "new_event_trigger": "متى تم حجز الحدث الجديد",
"email_host_action": "إرسال رسالة إلكترونية إلى المضيف", "email_host_action": "إرسال رسالة إلكترونية إلى المضيف",
"email_attendee_action": "إرسال رسالة إلكترونية إلى الحضور", "email_attendee_action": "إرسال رسالة إلكترونية إلى الحضور",
@@ -1652,9 +1652,8 @@
"delete_sso_configuration_confirmation_description": "هل تريد بالتأكيد حذف تكوين {{connectionType}}؟ لن يتمكن أعضاء فريقك الذين يستخدمون معلومات تسجيل الدخول إلى {{connectionType}} من الوصول إلى Cal.com بعد الآن.", "delete_sso_configuration_confirmation_description": "هل تريد بالتأكيد حذف تكوين {{connectionType}}؟ لن يتمكن أعضاء فريقك الذين يستخدمون معلومات تسجيل الدخول إلى {{connectionType}} من الوصول إلى Cal.com بعد الآن.",
"organizer_timezone": "منظم المناطق الزمنية", "organizer_timezone": "منظم المناطق الزمنية",
"email_user_cta": "عرض الدعوة", "email_user_cta": "عرض الدعوة",
"email_no_user_invite_heading": "تمت دعوتك للانضمام إلى {{appName}} {{entity}}",
"email_no_user_invite_subheading": "دعاك {{invitedBy}} للانضمام إلى فريقه على {{appName}}. إن {{appName}} هو برنامج جدولة الأحداث الذي يمكّنك أنت وفريقك من جدولة الاجتماعات دون الحاجة إلى المراسلة عبر البريد الإلكتروني.", "email_no_user_invite_subheading": "دعاك {{invitedBy}} للانضمام إلى فريقه على {{appName}}. إن {{appName}} هو برنامج جدولة الأحداث الذي يمكّنك أنت وفريقك من جدولة الاجتماعات دون الحاجة إلى المراسلة عبر البريد الإلكتروني.",
"email_user_invite_subheading": "لقد دعاك {{invitedBy}} للانضمام إلى {{entity}} `{{teamName}}` على {{appName}}. إن {{appName}} هو برنامج جدولة الأحداث الذي يمكّنك أنت و{{entity}} من جدولة الاجتماعات دون الحاجة إلى المراسلة عبر البريد الإلكتروني.", "email_user_invite_subheading_team": "دعاك {{invitedBy}} للانضمام إلى فريقه {{teamName}} على {{appName}}. إن {{appName}} هو برنامج جدولة الأحداث الذي يمكّنك أنت وفريقك من جدولة الاجتماعات دون الحاجة إلى المراسلة عبر البريد الإلكتروني.",
"email_no_user_invite_steps_intro": "سنرشدك خلال بضع خطوات قصيرة وستستمتع بجدول زمني خالٍ من التوتر مع {{entity}} في لمح البصر.", "email_no_user_invite_steps_intro": "سنرشدك خلال بضع خطوات قصيرة وستستمتع بجدول زمني خالٍ من التوتر مع {{entity}} في لمح البصر.",
"email_no_user_step_one": "اختر اسم المستخدم الخاص بك", "email_no_user_step_one": "اختر اسم المستخدم الخاص بك",
"email_no_user_step_two": "ربط حساب التقويم الخاص بك", "email_no_user_step_two": "ربط حساب التقويم الخاص بك",
@@ -1834,7 +1833,7 @@
"invite_link_copied": "تم نسخ رابط الدعوة", "invite_link_copied": "تم نسخ رابط الدعوة",
"invite_link_deleted": "تم حذف رابط الدعوة", "invite_link_deleted": "تم حذف رابط الدعوة",
"invite_link_updated": "تم حفظ إعدادات رابط الدعوة", "invite_link_updated": "تم حفظ إعدادات رابط الدعوة",
"link_expires_after": "تم تعيين الروابط للانتهاء بعد...", "link_expires_after": "تم تعيين انتهاء صلاحية الروابط بعد...",
"one_day": "1 يوم", "one_day": "1 يوم",
"seven_days": "7 أيام", "seven_days": "7 أيام",
"thirty_days": "30 يومًا", "thirty_days": "30 يومًا",
@@ -1871,7 +1870,6 @@
"first_event_type_webhook_description": "قم بإنشاء أول شبكة ويب هوك لهذا النوع من الأحداث", "first_event_type_webhook_description": "قم بإنشاء أول شبكة ويب هوك لهذا النوع من الأحداث",
"install_app_on": "تثبيت التطبيق على", "install_app_on": "تثبيت التطبيق على",
"create_for": "إنشاء من أجل", "create_for": "إنشاء من أجل",
"setup_organization": "إعداد منظمة",
"organization_banner_description": "إنشاء بيئات حيث يمكن لفرقك إنشاء تطبيقات مشتركة ومهام سير العمل وأنواع الأحداث باستخدام الجدولة الدوارة والجماعية.", "organization_banner_description": "إنشاء بيئات حيث يمكن لفرقك إنشاء تطبيقات مشتركة ومهام سير العمل وأنواع الأحداث باستخدام الجدولة الدوارة والجماعية.",
"organization_banner_title": "إدارة المنظمات ذات الفرق المتعددة", "organization_banner_title": "إدارة المنظمات ذات الفرق المتعددة",
"set_up_your_organization": "إعداد منظمتك", "set_up_your_organization": "إعداد منظمتك",
@@ -1886,7 +1884,7 @@
"organization_verify_email_body": "الرجاء استخدام الرمز أدناه لتأكيد عنوان بريدك الإلكتروني لمواصلة إعداد منظمتك.", "organization_verify_email_body": "الرجاء استخدام الرمز أدناه لتأكيد عنوان بريدك الإلكتروني لمواصلة إعداد منظمتك.",
"additional_url_parameters": "معلمات الرابط الإضافية", "additional_url_parameters": "معلمات الرابط الإضافية",
"about_your_organization": "حول منظمتك", "about_your_organization": "حول منظمتك",
"about_your_organization_description": "المنظمات هي بيئات مشتركة حيث يمكنك إنشاء فرق متعددة مع أعضاء مشتركين وأنواع الأحداث والتطبيقات ومهام سير العمل والمزيد.", "about_your_organization_description": "المنظمات هي بيئات مشتركة حيث يمكنك إنشاء فرق متعددة مع أعضاء مشتركين وأنواع الأحداث والتطبيقات ومهام سير العمل المشتركة والمزيد.",
"create_your_teams": "إنشاء فرقك", "create_your_teams": "إنشاء فرقك",
"create_your_teams_description": "ابدأوا الجدولة معًا عن طريق إضافة أعضاء فريقك إلى منظمتك", "create_your_teams_description": "ابدأوا الجدولة معًا عن طريق إضافة أعضاء فريقك إلى منظمتك",
"invite_organization_admins": "دعوة مشرفي منظمتك", "invite_organization_admins": "دعوة مشرفي منظمتك",
@@ -1927,7 +1925,7 @@
"404_the_org": "المنظمة", "404_the_org": "المنظمة",
"404_the_team": "الفريق", "404_the_team": "الفريق",
"404_claim_entity_org": "المطالبة بنطاقك الفرعي لمنظمتك", "404_claim_entity_org": "المطالبة بنطاقك الفرعي لمنظمتك",
"404_claim_entity_team": "المطالبة بهذا الفريق والبدء في إدارة الجداول الزمنية بشكل جماعي", "404_claim_entity_team": "انضم لهذا الفريق وابدأ في إدارة الجداول الزمنية بشكل جماعي",
"insights_all_org_filter": "الكل", "insights_all_org_filter": "الكل",
"insights_team_filter": "الفريق: {{teamName}}", "insights_team_filter": "الفريق: {{teamName}}",
"insights_user_filter": "المستخدم: {{userName}}", "insights_user_filter": "المستخدم: {{userName}}",
+10 -12
View File
@@ -118,7 +118,7 @@
"team_info": "Informace o týmu", "team_info": "Informace o týmu",
"request_another_invitation_email": "Pokud si nepřejete použít e-mail {{toEmail}} pro {{appName}} nebo už účet na {{appName}} máte, požádejte prosím o další pozvánku na jiný e-mail.", "request_another_invitation_email": "Pokud si nepřejete použít e-mail {{toEmail}} pro {{appName}} nebo už účet na {{appName}} máte, požádejte prosím o další pozvánku na jiný e-mail.",
"you_have_been_invited": "Byli jste pozváni do týmu {{teamName}}", "you_have_been_invited": "Byli jste pozváni do týmu {{teamName}}",
"user_invited_you": "Uživatel {{user}} vás pozval, abyste se připojili k subjektu {{entity}} {{team}} v aplikaci {{appName}}", "user_invited_you": "Uživatel {{user}} vás pozval, abyste se připojili k {{entity}} {{team}} v aplikaci {{appName}}",
"hidden_team_member_title": "V tomto týmu nejste viditelní", "hidden_team_member_title": "V tomto týmu nejste viditelní",
"hidden_team_member_message": "Vaše místo není zaplacené. Buď přejděte na tarif Pro, nebo informujte vlastníka týmu, že za vaše místo může zaplatit.", "hidden_team_member_message": "Vaše místo není zaplacené. Buď přejděte na tarif Pro, nebo informujte vlastníka týmu, že za vaše místo může zaplatit.",
"hidden_team_owner_message": "Pro využívání týmů potřebujete tarif Pro, budete skrytí, než přejdete na vyšší verzi.", "hidden_team_owner_message": "Pro využívání týmů potřebujete tarif Pro, budete skrytí, než přejdete na vyšší verzi.",
@@ -130,7 +130,7 @@
"upgrade_banner_action": "Upgradovat zde", "upgrade_banner_action": "Upgradovat zde",
"team_upgraded_successfully": "Váš tým byl úspěšně přešel na vyšší verzi!", "team_upgraded_successfully": "Váš tým byl úspěšně přešel na vyšší verzi!",
"org_upgrade_banner_description": "Děkujeme, že jste vyzkoušeli náš tarif Organizace. Všimli jsme si, že vaše organizace „{{teamName}}“ vyžaduje upgrade.", "org_upgrade_banner_description": "Děkujeme, že jste vyzkoušeli náš tarif Organizace. Všimli jsme si, že vaše organizace „{{teamName}}“ vyžaduje upgrade.",
"org_upgraded_successfully": "Vaše organizace byla upgradována!", "org_upgraded_successfully": "Váš upgrade na tarif Organization byl úspěšně dokončen!",
"use_link_to_reset_password": "Pro obnovení hesla použijte odkaz níž", "use_link_to_reset_password": "Pro obnovení hesla použijte odkaz níž",
"hey_there": "Zdravíme,", "hey_there": "Zdravíme,",
"forgot_your_password_calcom": "Zapomněli jste heslo? - {{appName}}", "forgot_your_password_calcom": "Zapomněli jste heslo? - {{appName}}",
@@ -307,7 +307,7 @@
"layout": "Rozvržení", "layout": "Rozvržení",
"bookerlayout_default_title": "Výchozí zobrazení", "bookerlayout_default_title": "Výchozí zobrazení",
"bookerlayout_description": "Můžete si jich vybrat více a vaši rezervující si mohou zobrazení přepínat.", "bookerlayout_description": "Můžete si jich vybrat více a vaši rezervující si mohou zobrazení přepínat.",
"bookerlayout_user_settings_title": "Rozvržení rezervace", "bookerlayout_user_settings_title": "Rozvržení rezervací",
"bookerlayout_user_settings_description": "Můžete si jich vybrat více a vaši rezervující si mohou zobrazení přepínat. Lze nadefinovat pro každou událost zvlášť.", "bookerlayout_user_settings_description": "Můžete si jich vybrat více a vaši rezervující si mohou zobrazení přepínat. Lze nadefinovat pro každou událost zvlášť.",
"bookerlayout_month_view": "Měsíční", "bookerlayout_month_view": "Měsíční",
"bookerlayout_week_view": "Týdenní", "bookerlayout_week_view": "Týdenní",
@@ -403,7 +403,7 @@
"recording_ready": "Odkaz ke stažení záznamu je připraven", "recording_ready": "Odkaz ke stažení záznamu je připraven",
"booking_created": "Rezervace vytvořena", "booking_created": "Rezervace vytvořena",
"booking_rejected": "Rezervace byla zamítnuta", "booking_rejected": "Rezervace byla zamítnuta",
"booking_requested": "Byla vyžádána rezervace", "booking_requested": "Váš požadavek na rezervaci byl úspěšně odeslán",
"meeting_ended": "Schůzka skončila", "meeting_ended": "Schůzka skončila",
"form_submitted": "Formulář byl odeslán", "form_submitted": "Formulář byl odeslán",
"event_triggers": "Eventy na základě akce", "event_triggers": "Eventy na základě akce",
@@ -1075,7 +1075,7 @@
"event_cancelled": "Tato událost je zrušena", "event_cancelled": "Tato událost je zrušena",
"emailed_information_about_cancelled_event": "Poslali jsme vám a ostatním účastníkům informační e-mail.", "emailed_information_about_cancelled_event": "Poslali jsme vám a ostatním účastníkům informační e-mail.",
"this_input_will_shown_booking_this_event": "Toto pole se zobrazí při rezervaci této události", "this_input_will_shown_booking_this_event": "Toto pole se zobrazí při rezervaci této události",
"meeting_url_in_conformation_email": "Adresa URL schůzky je v potvrzovacím e-mailu", "meeting_url_in_confirmation_email": "Adresa URL schůzky je v potvrzovacím e-mailu",
"url_start_with_https": "Adresa URL musí začínat http:// nebo https://", "url_start_with_https": "Adresa URL musí začínat http:// nebo https://",
"number_provided": "Bude uvedeno telefonní číslo", "number_provided": "Bude uvedeno telefonní číslo",
"before_event_trigger": "před zahájením události", "before_event_trigger": "před zahájením události",
@@ -1659,9 +1659,8 @@
"delete_sso_configuration_confirmation_description": "Opravdu chcete odstranit konfiguraci {{connectionType}}? Členové vašeho týmu, kteří používají přihlášení {{connectionType}}, ztratí přístup k webu Cal.com.", "delete_sso_configuration_confirmation_description": "Opravdu chcete odstranit konfiguraci {{connectionType}}? Členové vašeho týmu, kteří používají přihlášení {{connectionType}}, ztratí přístup k webu Cal.com.",
"organizer_timezone": "Časové pásmo organizátora", "organizer_timezone": "Časové pásmo organizátora",
"email_user_cta": "Zobrazit pozvánku", "email_user_cta": "Zobrazit pozvánku",
"email_no_user_invite_heading": "Byli jste pozváni, abyste se připojili k subjektu {{entity}} v aplikaci {{appName}}",
"email_no_user_invite_subheading": "Uživatel {{invitedBy}} vás pozval, abyste se připojili k jeho týmu v aplikaci {{appName}}. {{appName}} je plánovač událostí, který vám a vašemu týmu umožňuje plánovat schůzky bez e-mailového ping-pongu.", "email_no_user_invite_subheading": "Uživatel {{invitedBy}} vás pozval, abyste se připojili k jeho týmu v aplikaci {{appName}}. {{appName}} je plánovač událostí, který vám a vašemu týmu umožňuje plánovat schůzky bez e-mailového ping-pongu.",
"email_user_invite_subheading": "Uživatel {{invitedBy}} vás pozval, abyste se připojili k týmu {{teamName}} subjektu {{entity}} v aplikaci {{appName}}. {{appName}} je plánovač událostí, který vám a vašemu subjektu {{entity}} umožňuje plánovat schůzky bez e-mailového pingpongu.", "email_user_invite_subheading_team": "Uživatel {{invitedBy}} vás pozval, abyste se připojili k jeho týmu „{{teamName}}“ v aplikaci {{appName}}. {{appName}} je plánovač událostí, který vám a vašemu týmu umožňuje plánovat schůzky bez e-mailového pingpongu.",
"email_no_user_invite_steps_intro": "Provedeme vás několika krátkými kroky a za malou chvíli si budete se svým subjektem {{entity}} užívat plánování bez stresu.", "email_no_user_invite_steps_intro": "Provedeme vás několika krátkými kroky a za malou chvíli si budete se svým subjektem {{entity}} užívat plánování bez stresu.",
"email_no_user_step_one": "Vyberte si uživatelské jméno", "email_no_user_step_one": "Vyberte si uživatelské jméno",
"email_no_user_step_two": "Připojte svůj účet kalendáře", "email_no_user_step_two": "Připojte svůj účet kalendáře",
@@ -1878,7 +1877,6 @@
"first_event_type_webhook_description": "Vytvořte svůj první webhook pro tento typ události", "first_event_type_webhook_description": "Vytvořte svůj první webhook pro tento typ události",
"install_app_on": "Nainstalovat aplikaci v", "install_app_on": "Nainstalovat aplikaci v",
"create_for": "Vytvořit pro", "create_for": "Vytvořit pro",
"setup_organization": "Nastavení organizace",
"organization_banner_description": "Vytvořte prostředí, ve kterém budou moct vaše týmy vytvářet sdílené aplikace, pracovní postupy a typy událostí s kolektivním plánováním nebo plánováním typu round-robin.", "organization_banner_description": "Vytvořte prostředí, ve kterém budou moct vaše týmy vytvářet sdílené aplikace, pracovní postupy a typy událostí s kolektivním plánováním nebo plánováním typu round-robin.",
"organization_banner_title": "Správa organizace s více týmy", "organization_banner_title": "Správa organizace s více týmy",
"set_up_your_organization": "Nastavení organizace", "set_up_your_organization": "Nastavení organizace",
@@ -1889,12 +1887,12 @@
"admin_username": "Uživatelské jméno správce", "admin_username": "Uživatelské jméno správce",
"organization_name": "Název organizace", "organization_name": "Název organizace",
"organization_url": "Adresa URL organizace", "organization_url": "Adresa URL organizace",
"organization_verify_header": "Ověřte e-mailovou adresu organizace", "organization_verify_header": "Ověřte svou e-mailovou adresu organizace",
"organization_verify_email_body": "Pomocí uvedeného kódu ověřte vaši e-mailovou adresu, abyste mohli pokračovat v nastavení vaší organizace.", "organization_verify_email_body": "Pomocí uvedeného kódu ověřte vaši e-mailovou adresu, abyste mohli pokračovat v nastavení vaší organizace.",
"additional_url_parameters": "Další parametry adresy URL", "additional_url_parameters": "Další parametry adresy URL",
"about_your_organization": "Informace o vaší organizaci", "about_your_organization": "Informace o vaší organizaci",
"about_your_organization_description": "Organizace jsou sdílená prostředí, ve kterých můžete vytvořit několik týmů se sdílenými členy, typy událostí, aplikacemi, pracovními postupy a mnoha dalšími věcmi.", "about_your_organization_description": "Organizace jsou sdílená prostředí, ve kterých můžete vytvořit několik týmů se sdílenými členy, typy událostí, aplikacemi, pracovními postupy a mnoha dalšími věcmi.",
"create_your_teams": "Vytvoření týmů", "create_your_teams": "Vytvořit tým",
"create_your_teams_description": "Přidejte do své organizace členy a pusťte se do společného plánování", "create_your_teams_description": "Přidejte do své organizace členy a pusťte se do společného plánování",
"invite_organization_admins": "Pozvěte správce své organizace", "invite_organization_admins": "Pozvěte správce své organizace",
"invite_organization_admins_description": "Tito správci budou mít přístup ke všem týmům ve vaší organizaci. Správce a členy týmů můžete přidat později.", "invite_organization_admins_description": "Tito správci budou mít přístup ke všem týmům ve vaší organizaci. Správce a členy týmů můžete přidat později.",
@@ -1934,11 +1932,11 @@
"404_the_org": "Organizace", "404_the_org": "Organizace",
"404_the_team": "Tým", "404_the_team": "Tým",
"404_claim_entity_org": "Vyžádejte si subdoménu pro svou organizaci", "404_claim_entity_org": "Vyžádejte si subdoménu pro svou organizaci",
"404_claim_entity_team": "Vyžádejte si tento tým a začněte kolektivně spravovat rozvrhy", "404_claim_entity_team": "Vyžádejte si přístup k tomuto týmu a začněte kolektivně spravovat rozvrhy",
"insights_all_org_filter": "Všechny aplikace", "insights_all_org_filter": "Všechny aplikace",
"insights_team_filter": "Tým: {{teamName}}", "insights_team_filter": "Tým: {{teamName}}",
"insights_user_filter": "Uživatel: {{userName}}", "insights_user_filter": "Uživatel: {{userName}}",
"insights_subtitle": "Zobrazte si přehled o rezervacích napříč vašimi událostmi", "insights_subtitle": "Zobrazte si Insight rezervací napříč vašimi událostmi",
"custom_plan": "Vlastní plán", "custom_plan": "Vlastní plán",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Přidejte své nové řetězce nahoru ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Přidejte své nové řetězce nahoru ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
} }
@@ -960,7 +960,7 @@
"reschedule_placeholder": "Lad andre vide, hvorfor du har brug for at omlægge", "reschedule_placeholder": "Lad andre vide, hvorfor du har brug for at omlægge",
"emailed_information_about_cancelled_event": "Vi har sendt en e-mail til dig og de andre deltagere for at give dem besked.", "emailed_information_about_cancelled_event": "Vi har sendt en e-mail til dig og de andre deltagere for at give dem besked.",
"this_input_will_shown_booking_this_event": "Dette input vil blive vist ved booking af denne begivenhed", "this_input_will_shown_booking_this_event": "Dette input vil blive vist ved booking af denne begivenhed",
"meeting_url_in_conformation_email": "Møde-url er i bekræftelses-mailen", "meeting_url_in_confirmation_email": "Møde-url er i bekræftelses-mailen",
"url_start_with_https": "URL'en skal starte med http:// eller https://", "url_start_with_https": "URL'en skal starte med http:// eller https://",
"number_provided": "Telefonnummer vil blive angivet", "number_provided": "Telefonnummer vil blive angivet",
"before_event_trigger": "før begivenheden starter", "before_event_trigger": "før begivenheden starter",
+11 -8
View File
@@ -136,7 +136,7 @@
"team_upgrade_banner_description": "Vielen Dank, dass Sie unseren neuen Teamplan getestet haben. Wir haben festgestellt, dass Ihr Team „{{teamName}}“ aktualisiert werden muss.", "team_upgrade_banner_description": "Vielen Dank, dass Sie unseren neuen Teamplan getestet haben. Wir haben festgestellt, dass Ihr Team „{{teamName}}“ aktualisiert werden muss.",
"upgrade_banner_action": "Hier aufrüsten", "upgrade_banner_action": "Hier aufrüsten",
"team_upgraded_successfully": "Das Update für Ihr Team war erfolgreich!", "team_upgraded_successfully": "Das Update für Ihr Team war erfolgreich!",
"org_upgrade_banner_description": "Vielen Dank, dass Sie unseren neuen Organisationsplan getestet haben. Wir haben festgestellt, dass Ihre Organisation „{{teamName}}“ ein Upgrade benötigt.", "org_upgrade_banner_description": "Vielen Dank, dass Sie unseren neuen Tarif „Organization“ getestet haben. Wir haben festgestellt, dass Ihre Organization „{{teamName}}“ ein Upgrade benötigt.",
"org_upgraded_successfully": "Das Upgrade für Ihre Organization war erfolgreich!", "org_upgraded_successfully": "Das Upgrade für Ihre Organization war erfolgreich!",
"use_link_to_reset_password": "Benutzen Sie den Link, um Ihr Passwort zurückzusetzen", "use_link_to_reset_password": "Benutzen Sie den Link, um Ihr Passwort zurückzusetzen",
"hey_there": "Hallo,", "hey_there": "Hallo,",
@@ -225,6 +225,7 @@
"create_account": "Konto erstellen", "create_account": "Konto erstellen",
"confirm_password": "Passwort bestätigen", "confirm_password": "Passwort bestätigen",
"confirm_auth_change": "Dies ändert die Art und Weise, wie Sie sich anmelden", "confirm_auth_change": "Dies ändert die Art und Weise, wie Sie sich anmelden",
"confirm_auth_email_change": "Wenn Sie Ihre E-Mail-Adresse ändern, wird Ihre derzeitige Authentifizierungsmethode für die Anmeldung bei Cal.com unterbrochen. Wir werden Sie bitten, Ihre neue E-Mail-Adresse zu verifizieren. In der Folge werden Sie abgemeldet und verwenden Ihre neue E-Mail-Adresse, um sich anstelle Ihrer aktuellen Authentifizierungsmethode anzumelden, nachdem Sie Ihr Passwort anhand der Anweisungen, die Ihnen per E-Mail zugesandt werden, festgelegt haben.",
"reset_your_password": "Legen Sie Ihr neues Passwort mit den Anweisungen fest, die an Ihre E-Mail-Adresse gesendet wurden.", "reset_your_password": "Legen Sie Ihr neues Passwort mit den Anweisungen fest, die an Ihre E-Mail-Adresse gesendet wurden.",
"email_change": "Melden Sie sich mit Ihrer neuen E-Mail-Adresse und Ihrem Passwort wieder an.", "email_change": "Melden Sie sich mit Ihrer neuen E-Mail-Adresse und Ihrem Passwort wieder an.",
"create_your_account": "Erstellen Sie Ihr Konto", "create_your_account": "Erstellen Sie Ihr Konto",
@@ -255,6 +256,7 @@
"available_apps": "Verfügbare Apps", "available_apps": "Verfügbare Apps",
"available_apps_lower_case": "Verfügbare Apps", "available_apps_lower_case": "Verfügbare Apps",
"available_apps_desc": "Sie haben keine Apps installiert. Sehen Sie sich beliebte Apps unten an und entdecken Sie noch mehr in unserem <1>App Store</1>", "available_apps_desc": "Sie haben keine Apps installiert. Sehen Sie sich beliebte Apps unten an und entdecken Sie noch mehr in unserem <1>App Store</1>",
"fixed_host_helper": "Füge jeden hinzu, der an der Veranstaltung teilnehmen muss. <1>Mehr erfahren</1>",
"check_email_reset_password": "Überprüfen Sie Ihre E-Mail. Wir haben Ihnen einen Link zum Zurücksetzen Ihres Passworts gesendet.", "check_email_reset_password": "Überprüfen Sie Ihre E-Mail. Wir haben Ihnen einen Link zum Zurücksetzen Ihres Passworts gesendet.",
"finish": "Fertig", "finish": "Fertig",
"organization_general_description": "Einstellungen für die Sprache und Zeitzone Ihres Teams verwalten", "organization_general_description": "Einstellungen für die Sprache und Zeitzone Ihres Teams verwalten",
@@ -527,7 +529,7 @@
"location": "Ort", "location": "Ort",
"address": "Adresse", "address": "Adresse",
"enter_address": "Geben Sie eine Adresse ein", "enter_address": "Geben Sie eine Adresse ein",
"in_person_attendee_address": "In Person (Adresse von Ihnen)", "in_person_attendee_address": "Vor Ort (Adresse von Ihnen)",
"yes": "Ja", "yes": "Ja",
"no": "Nein", "no": "Nein",
"additional_notes": "Zusätzliche Notizen", "additional_notes": "Zusätzliche Notizen",
@@ -537,7 +539,7 @@
"booking_confirmation": "Bestätigen Sie {{eventTypeTitle}} mit {{profileName}}", "booking_confirmation": "Bestätigen Sie {{eventTypeTitle}} mit {{profileName}}",
"booking_reschedule_confirmation": "Planen Sie Ihr {{eventTypeTitle}} mit {{profileName}} um", "booking_reschedule_confirmation": "Planen Sie Ihr {{eventTypeTitle}} mit {{profileName}} um",
"in_person_meeting": "Vor-Ort-Termin", "in_person_meeting": "Vor-Ort-Termin",
"in_person": "Persönlich (Organisator-Adresse)", "in_person": "Vor Ort (Organisator-Adresse)",
"link_meeting": "Termin verknüpfen", "link_meeting": "Termin verknüpfen",
"phone_number": "Telefonnummer", "phone_number": "Telefonnummer",
"attendee_phone_number": "Telefonnummer", "attendee_phone_number": "Telefonnummer",
@@ -559,6 +561,7 @@
"leave": "Verlassen", "leave": "Verlassen",
"profile": "Profil", "profile": "Profil",
"my_team_url": "Meine Team-URL", "my_team_url": "Meine Team-URL",
"my_teams": "Meine Teams",
"team_name": "Teamname", "team_name": "Teamname",
"your_team_name": "Ihr Teamname", "your_team_name": "Ihr Teamname",
"team_updated_successfully": "Team erfolgreich aktualisiert", "team_updated_successfully": "Team erfolgreich aktualisiert",
@@ -1092,7 +1095,7 @@
"event_cancelled": "Dieser Termin ist abgesagt", "event_cancelled": "Dieser Termin ist abgesagt",
"emailed_information_about_cancelled_event": "Wir haben Ihnen und den anderen Teilnehmern eine E-Mail gesendet, damit alle Bescheid wissen.", "emailed_information_about_cancelled_event": "Wir haben Ihnen und den anderen Teilnehmern eine E-Mail gesendet, damit alle Bescheid wissen.",
"this_input_will_shown_booking_this_event": "Diese Eingabe wird bei der Buchung dieses Termins angezeigt", "this_input_will_shown_booking_this_event": "Diese Eingabe wird bei der Buchung dieses Termins angezeigt",
"meeting_url_in_conformation_email": "Termin-URL ist in der Bestätigungsmail", "meeting_url_in_confirmation_email": "Termin-URL ist in der Bestätigungsmail",
"url_start_with_https": "URL muss mit http:// oder https:// beginnen", "url_start_with_https": "URL muss mit http:// oder https:// beginnen",
"number_provided": "Telefonnummer wird angegeben", "number_provided": "Telefonnummer wird angegeben",
"before_event_trigger": "vor Beginn des Termins", "before_event_trigger": "vor Beginn des Termins",
@@ -1676,10 +1679,9 @@
"delete_sso_configuration_confirmation_description": "Sind Sie sicher, dass Sie die {{connectionType}}-Konfiguration löschen möchten? Ihre Teammitglieder, die sich über {{connectionType}} anmelden, werden keinen Zugriff mehr auf Cal.com haben.", "delete_sso_configuration_confirmation_description": "Sind Sie sicher, dass Sie die {{connectionType}}-Konfiguration löschen möchten? Ihre Teammitglieder, die sich über {{connectionType}} anmelden, werden keinen Zugriff mehr auf Cal.com haben.",
"organizer_timezone": "Zeitzone des Veranstalters", "organizer_timezone": "Zeitzone des Veranstalters",
"email_user_cta": "Einladung anzeigen", "email_user_cta": "Einladung anzeigen",
"email_no_user_invite_heading": "Sie wurden eingeladen, einem {{entity}} in {{appName}} beizutreten",
"email_no_user_invite_subheading": "{{invitedBy}} hat Sie eingeladen, dem Team in {{appName}} beizutreten. {{appName}} ist der Event-Planer, der es Ihnen und Ihrem Team ermöglicht, Meetings ohne ständiges hin und her zu planen.", "email_no_user_invite_subheading": "{{invitedBy}} hat Sie eingeladen, dem Team in {{appName}} beizutreten. {{appName}} ist der Event-Planer, der es Ihnen und Ihrem Team ermöglicht, Meetings ohne ständiges hin und her zu planen.",
"email_user_invite_subheading": "{{invitedBy}} hat Sie eingeladen, dem {{entity}} „{{teamName}}“ in {{appName}} beizutreten. {{appName}} ist der Terminplaner, der es Ihnen und Ihrem {{entity}} ermöglicht, Meetings ohne ständiges hin und her zu planen.", "email_user_invite_subheading_team": "{{invitedBy}} hat Sie eingeladen, dem Team „{{teamName}}“ beizutreten. {{appName}} ist der Event-Planer, der es Ihnen und Ihrem Team ermöglicht, Meetings ohne Hin und Her zu planen.",
"email_no_user_invite_steps_intro": "Wir begleiten Sie durch ein paar Schritte, danach können Sie in kürzester Zeit stressfreie Planung mit Ihrem {{entity}} genießen.", "email_no_user_invite_steps_intro": "Wir begleiten Sie durch ein paar Schritte, danach können Sie in kürzester Zeit stressfreie Planung mit Ihrer {{entity}} genießen.",
"email_no_user_step_one": "Wählen Sie Ihren Benutzernamen", "email_no_user_step_one": "Wählen Sie Ihren Benutzernamen",
"email_no_user_step_two": "Verbinden Sie Ihr Kalenderkonto", "email_no_user_step_two": "Verbinden Sie Ihr Kalenderkonto",
"email_no_user_step_three": "Legen Sie Ihre Verfügbarkeit fest", "email_no_user_step_three": "Legen Sie Ihre Verfügbarkeit fest",
@@ -1899,7 +1901,6 @@
"install_app_on": "App installieren auf", "install_app_on": "App installieren auf",
"create_for": "Erstellen für", "create_for": "Erstellen für",
"currency": "Währung", "currency": "Währung",
"setup_organization": "Eine Organization einrichten",
"organization_banner_description": "Schaffen Sie Umgebungen, in der Teams gemeinsame Apps, Workflows und Termintypen mit Round Robin und kollektiver Terminplanung erstellen können.", "organization_banner_description": "Schaffen Sie Umgebungen, in der Teams gemeinsame Apps, Workflows und Termintypen mit Round Robin und kollektiver Terminplanung erstellen können.",
"organization_banner_title": "Verwalten Sie Organizations mit mehreren Teams", "organization_banner_title": "Verwalten Sie Organizations mit mehreren Teams",
"set_up_your_organization": "Ihre Organization einrichten", "set_up_your_organization": "Ihre Organization einrichten",
@@ -1974,6 +1975,8 @@
"org_team_names_example_5": "z.B. Data Analytics Team", "org_team_names_example_5": "z.B. Data Analytics Team",
"org_max_team_warnings": "Weitere Teams können Sie zu einem späteren Zeitpunkt hinzufügen.", "org_max_team_warnings": "Weitere Teams können Sie zu einem späteren Zeitpunkt hinzufügen.",
"what_is_this_meeting_about": "Worum geht es in diesem Termin?", "what_is_this_meeting_about": "Worum geht es in diesem Termin?",
"add_to_team": "Zum Team hinzufügen",
"user_isnt_in_any_teams": "Dieser Benutzer ist in keinem Team",
"kyc_verification_information": "Aus Sicherheitsgründen müssen Sie Ihren {{teamOrAccount}} verifizieren, bevor Sie Textnachrichten an die Teilnehmer senden können. Bitte kontaktieren Sie uns unter <a>{{supportEmail}}</a> und geben Sie folgende Informationen an:", "kyc_verification_information": "Aus Sicherheitsgründen müssen Sie Ihren {{teamOrAccount}} verifizieren, bevor Sie Textnachrichten an die Teilnehmer senden können. Bitte kontaktieren Sie uns unter <a>{{supportEmail}}</a> und geben Sie folgende Informationen an:",
"org_admin_other_teams": "Weitere Teams", "org_admin_other_teams": "Weitere Teams",
"no_other_teams_found": "Keine weiteren Teams gefunden", "no_other_teams_found": "Keine weiteren Teams gefunden",
+38 -11
View File
@@ -266,6 +266,7 @@
"nearly_there_instructions": "Last thing, a brief description about you and a photo really helps you get bookings and let people know who they’re booking with.", "nearly_there_instructions": "Last thing, a brief description about you and a photo really helps you get bookings and let people know who they’re booking with.",
"set_availability_instructions": "Define ranges of time when you are available on a recurring basis. You can create more of these later and assign them to different calendars.", "set_availability_instructions": "Define ranges of time when you are available on a recurring basis. You can create more of these later and assign them to different calendars.",
"set_availability": "Set your availability", "set_availability": "Set your availability",
"availability_settings": "Availability Settings",
"continue_without_calendar": "Continue without calendar", "continue_without_calendar": "Continue without calendar",
"connect_your_calendar": "Connect your calendar", "connect_your_calendar": "Connect your calendar",
"connect_your_video_app": "Connect your video apps", "connect_your_video_app": "Connect your video apps",
@@ -290,9 +291,9 @@
"add_another_calendar": "Add another calendar", "add_another_calendar": "Add another calendar",
"other": "Other", "other": "Other",
"email_sign_in_subject": "Your sign-in link for {{appName}}", "email_sign_in_subject": "Your sign-in link for {{appName}}",
"emailed_you_and_attendees": "We emailed you and the other attendees a calendar invitation with all the details.", "emailed_you_and_attendees": "We sent an email with a calendar invitation with the details to everyone.",
"emailed_you_and_attendees_recurring": "We emailed you and the other attendees a calendar invitation for the first of these recurring events.", "emailed_you_and_attendees_recurring": "We sent an email with a calendar invitation with the details to everyone for the first of these recurring events.",
"emailed_you_and_any_other_attendees": "You and any other attendees have been emailed with this information.", "emailed_you_and_any_other_attendees": "We sent an email to everyone with this information.",
"needs_to_be_confirmed_or_rejected": "Your booking still needs to be confirmed or rejected.", "needs_to_be_confirmed_or_rejected": "Your booking still needs to be confirmed or rejected.",
"needs_to_be_confirmed_or_rejected_recurring": "Your recurring meeting still needs to be confirmed or rejected.", "needs_to_be_confirmed_or_rejected_recurring": "Your recurring meeting still needs to be confirmed or rejected.",
"user_needs_to_confirm_or_reject_booking": "{{user}} still needs to confirm or reject the booking.", "user_needs_to_confirm_or_reject_booking": "{{user}} still needs to confirm or reject the booking.",
@@ -590,6 +591,8 @@
"invite_new_member": "Invite a new team member", "invite_new_member": "Invite a new team member",
"invite_new_member_description": "Note: This will <1>cost an extra seat ($15/m)</1> on your subscription.", "invite_new_member_description": "Note: This will <1>cost an extra seat ($15/m)</1> on your subscription.",
"invite_new_team_member": "Invite someone to your team.", "invite_new_team_member": "Invite someone to your team.",
"upload_csv_file": "Upload a .csv file",
"invite_via_email": "Invite via email",
"change_member_role": "Change team member role", "change_member_role": "Change team member role",
"disable_cal_branding": "Disable {{appName}} branding", "disable_cal_branding": "Disable {{appName}} branding",
"disable_cal_branding_description": "Hide all {{appName}} branding from your public pages.", "disable_cal_branding_description": "Hide all {{appName}} branding from your public pages.",
@@ -678,8 +681,8 @@
"new_event_type_btn": "New event type", "new_event_type_btn": "New event type",
"new_event_type_heading": "Create your first event type", "new_event_type_heading": "Create your first event type",
"new_event_type_description": "Event types enable you to share links that show available times on your calendar and allow people to make bookings with you.", "new_event_type_description": "Event types enable you to share links that show available times on your calendar and allow people to make bookings with you.",
"event_type_created_successfully": "{{eventTypeTitle}} event type created successfully", "event_type_created_successfully": "Event type created successfully",
"event_type_updated_successfully": "{{eventTypeTitle}} event type updated successfully", "event_type_updated_successfully": "Event type updated successfully",
"event_type_deleted_successfully": "Event type deleted successfully", "event_type_deleted_successfully": "Event type deleted successfully",
"hours": "Hours", "hours": "Hours",
"people": "People", "people": "People",
@@ -972,6 +975,8 @@
"offer_seats_description": "Offer seats for booking. This automatically disables guest & opt-in bookings.", "offer_seats_description": "Offer seats for booking. This automatically disables guest & opt-in bookings.",
"seats_available_one": "Seat available", "seats_available_one": "Seat available",
"seats_available_other": "Seats available", "seats_available_other": "Seats available",
"seats_nearly_full": "Seats almost full",
"seats_half_full": "Seats filling fast",
"number_of_seats": "Number of seats per booking", "number_of_seats": "Number of seats per booking",
"enter_number_of_seats": "Enter number of seats", "enter_number_of_seats": "Enter number of seats",
"you_can_manage_your_schedules": "You can manage your schedules on the Availability page.", "you_can_manage_your_schedules": "You can manage your schedules on the Availability page.",
@@ -1052,12 +1057,15 @@
"how_you_want_add_cal_site": "How do you want to add {{appName}} to your site?", "how_you_want_add_cal_site": "How do you want to add {{appName}} to your site?",
"choose_ways_put_cal_site": "Choose one of the following ways to put {{appName}} on your site.", "choose_ways_put_cal_site": "Choose one of the following ways to put {{appName}} on your site.",
"setting_up_zapier": "Setting up your Zapier integration", "setting_up_zapier": "Setting up your Zapier integration",
"setting_up_make": "Setting up your Make integration",
"generate_api_key": "Generate API key", "generate_api_key": "Generate API key",
"generate_api_key_description": "Generate an API key to use with {{appName}} at", "generate_api_key_description": "Generate an API key to use with {{appName}} at",
"your_unique_api_key": "Your unique API key", "your_unique_api_key": "Your unique API key",
"copy_safe_api_key": "Copy this API key and save it somewhere safe. If you lose this key you have to generate a new one.", "copy_safe_api_key": "Copy this API key and save it somewhere safe. If you lose this key you have to generate a new one.",
"zapier_setup_instructions": "<0>Log into your Zapier account and create a new Zap.</0><1>Select Cal.com as your Trigger app. Also choose a Trigger event.</1><2>Choose your account and then enter your Unique API Key.</2><3>Test your Trigger.</3><4>You're set!</4>", "zapier_setup_instructions": "<0>Log into your Zapier account and create a new Zap.</0><1>Select Cal.com as your Trigger app. Also choose a Trigger event.</1><2>Choose your account and then enter your Unique API Key.</2><3>Test your Trigger.</3><4>You're set!</4>",
"make_setup_instructions": "<0>Go to <1><0>Make Invite Link</0></1> and install the Cal.com app.</0><1>Log into your Make account and create a new Scenario.</1><2>Select Cal.com as your Trigger app. Also choose a Trigger event.</2><3>Choose your account and then enter your Unique API Key.</3><4>Test your Trigger.</4><5>You're set!</5>",
"install_zapier_app": "Please first install the Zapier App in the app store.", "install_zapier_app": "Please first install the Zapier App in the app store.",
"install_make_app": "Please first install the Make App in the app store.",
"connect_apple_server": "Connect to Apple Server", "connect_apple_server": "Connect to Apple Server",
"calendar_url": "Calendar URL", "calendar_url": "Calendar URL",
"apple_server_generate_password": "Generate an app specific password to use with {{appName}} at", "apple_server_generate_password": "Generate an app specific password to use with {{appName}} at",
@@ -1095,9 +1103,9 @@
"reschedule_optional": "Reason for rescheduling (optional)", "reschedule_optional": "Reason for rescheduling (optional)",
"reschedule_placeholder": "Let others know why you need to reschedule", "reschedule_placeholder": "Let others know why you need to reschedule",
"event_cancelled": "This event is canceled", "event_cancelled": "This event is canceled",
"emailed_information_about_cancelled_event": "We emailed you and the other attendees to let them know.", "emailed_information_about_cancelled_event": "We sent an email to everyone to let them know.",
"this_input_will_shown_booking_this_event": "This input will be shown when booking this event", "this_input_will_shown_booking_this_event": "This input will be shown when booking this event",
"meeting_url_in_conformation_email": "Meeting url is in the confirmation email", "meeting_url_in_confirmation_email": "Meeting url is in the confirmation email",
"url_start_with_https": "URL needs to start with http:// or https://", "url_start_with_https": "URL needs to start with http:// or https://",
"number_provided": "Phone number will be provided", "number_provided": "Phone number will be provided",
"before_event_trigger": "before event starts", "before_event_trigger": "before event starts",
@@ -1107,6 +1115,7 @@
"email_attendee_action": "send email to attendees", "email_attendee_action": "send email to attendees",
"sms_attendee_action": "Send SMS to attendee", "sms_attendee_action": "Send SMS to attendee",
"sms_number_action": "send SMS to a specific number", "sms_number_action": "send SMS to a specific number",
"send_reminder_sms": "Easily send meeting reminders via SMS to your attendees",
"whatsapp_number_action": "send WhatsApp message to a specific number", "whatsapp_number_action": "send WhatsApp message to a specific number",
"whatsapp_attendee_action": "send WhatsApp message to attendee", "whatsapp_attendee_action": "send WhatsApp message to attendee",
"workflows": "Workflows", "workflows": "Workflows",
@@ -1449,6 +1458,7 @@
"add_limit": "Add Limit", "add_limit": "Add Limit",
"team_name_required": "Team name required", "team_name_required": "Team name required",
"show_attendees": "Share attendee information between guests", "show_attendees": "Share attendee information between guests",
"show_available_seats_count": "Show the number of available seats",
"how_booking_questions_as_variables": "How to use booking questions as variables?", "how_booking_questions_as_variables": "How to use booking questions as variables?",
"format": "Format", "format": "Format",
"uppercase_for_letters": "Use uppercase for all letters", "uppercase_for_letters": "Use uppercase for all letters",
@@ -1682,9 +1692,11 @@
"delete_sso_configuration_confirmation_description": "Are you sure you want to delete the {{connectionType}} configuration? Your team members who use {{connectionType}} login will no longer be able to access Cal.com.", "delete_sso_configuration_confirmation_description": "Are you sure you want to delete the {{connectionType}} configuration? Your team members who use {{connectionType}} login will no longer be able to access Cal.com.",
"organizer_timezone": "Organizer timezone", "organizer_timezone": "Organizer timezone",
"email_user_cta": "View Invitation", "email_user_cta": "View Invitation",
"email_no_user_invite_heading": "You’ve been invited to join a {{appName}} {{entity}}", "email_no_user_invite_heading_team": "You’ve been invited to join a {{appName}} team",
"email_no_user_invite_heading_org": "You’ve been invited to join a {{appName}} organization",
"email_no_user_invite_subheading": "{{invitedBy}} has invited you to join their team on {{appName}}. {{appName}} is the event-juggling scheduler that enables you and your team to schedule meetings without the email tennis.", "email_no_user_invite_subheading": "{{invitedBy}} has invited you to join their team on {{appName}}. {{appName}} is the event-juggling scheduler that enables you and your team to schedule meetings without the email tennis.",
"email_user_invite_subheading": "{{invitedBy}} has invited you to join their {{entity}} `{{teamName}}` on {{appName}}. {{appName}} is the event-juggling scheduler that enables you and your {{entity}} to schedule meetings without the email tennis.", "email_user_invite_subheading_team": "{{invitedBy}} has invited you to join their team `{{teamName}}` on {{appName}}. {{appName}} is the event-juggling scheduler that enables you and your team to schedule meetings without the email tennis.",
"email_user_invite_subheading_org": "{{invitedBy}} has invited you to join their organization `{{teamName}}` on {{appName}}. {{appName}} is the event-juggling scheduler that enables you and your organization to schedule meetings without the email tennis.",
"email_no_user_invite_steps_intro": "We’ll walk you through a few short steps and you’ll be enjoying stress free scheduling with your {{entity}} in no time.", "email_no_user_invite_steps_intro": "We’ll walk you through a few short steps and you’ll be enjoying stress free scheduling with your {{entity}} in no time.",
"email_no_user_step_one": "Choose your username", "email_no_user_step_one": "Choose your username",
"email_no_user_step_two": "Connect your calendar account", "email_no_user_step_two": "Connect your calendar account",
@@ -1907,7 +1919,6 @@
"install_app_on": "Install app on", "install_app_on": "Install app on",
"create_for": "Create for", "create_for": "Create for",
"currency": "Currency", "currency": "Currency",
"setup_organization": "Setup an Organization",
"organization_banner_description": "Create an environments where your teams can create shared apps, workflows and event types with round-robin and collective scheduling.", "organization_banner_description": "Create an environments where your teams can create shared apps, workflows and event types with round-robin and collective scheduling.",
"organization_banner_title": "Manage organizations with multiple teams", "organization_banner_title": "Manage organizations with multiple teams",
"set_up_your_organization": "Set up your organization", "set_up_your_organization": "Set up your organization",
@@ -2010,6 +2021,13 @@
"member_removed": "Member removed", "member_removed": "Member removed",
"my_availability": "My Availability", "my_availability": "My Availability",
"team_availability": "Team Availability", "team_availability": "Team Availability",
"backup_code": "Backup Code",
"backup_codes": "Backup Codes",
"backup_code_instructions": "Each backup code can be used exactly once to grant access without your authenticator.",
"backup_codes_copied": "Backup codes copied!",
"incorrect_backup_code": "Backup code is incorrect.",
"lost_access": "Lost access",
"missing_backup_codes": "No backup codes found. Please generate them in your settings.",
"admin_org_notification_email_subject": "New organization created: pending action", "admin_org_notification_email_subject": "New organization created: pending action",
"hi_admin": "Hi Administrator", "hi_admin": "Hi Administrator",
"admin_org_notification_email_title": "An organization requires DNS setup", "admin_org_notification_email_title": "An organization requires DNS setup",
@@ -2025,6 +2043,15 @@
"mark_dns_configured": "Mark as DNS configured", "mark_dns_configured": "Mark as DNS configured",
"value": "Value", "value": "Value",
"your_organization_updated_sucessfully": "Your organization updated successfully", "your_organization_updated_sucessfully": "Your organization updated successfully",
"seat_options_doesnt_multiple_durations": "Seat option doesn't support multiple durations", "team_no_event_types": "This team has no event types",
"seat_options_doesnt_multiple_durations": "Seat option doesn't support multiple durations",
"include_calendar_event": "Include calendar event",
"recently_added":"Recently added",
"no_members_found": "No members found",
"event_setup_length_error":"Event Setup: The duration must be at least 1 minute.",
"availability_schedules":"Availability Schedules",
"view_only_edit_availability_not_onboarded":"This user has not completed onboarding. You will not be able to set their availability until they have completed onboarding.",
"view_only_edit_availability":"You are viewing this user's availability. You can only edit your own availability.",
"edit_users_availability":"Edit user's availability: {{username}}",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
} }
+9 -11
View File
@@ -129,7 +129,7 @@
"team_upgrade_banner_description": "Gracias por probar nuestro nuevo plan de equipo. Notamos que su equipo \"{{teamName}}\" necesita actualizarse.", "team_upgrade_banner_description": "Gracias por probar nuestro nuevo plan de equipo. Notamos que su equipo \"{{teamName}}\" necesita actualizarse.",
"upgrade_banner_action": "Actualizar aquí", "upgrade_banner_action": "Actualizar aquí",
"team_upgraded_successfully": "¡Tu equipo se actualizó con éxito!", "team_upgraded_successfully": "¡Tu equipo se actualizó con éxito!",
"org_upgrade_banner_description": "Gracias por probar nuestro nuevo plan de equipo. Notamos que su equipo \"{{teamName}}\" necesita actualizarse.", "org_upgrade_banner_description": "Gracias por probar nuestro nuevo plan Organization. Notamos que su Organization \"{{teamName}}\" necesita actualizarse.",
"org_upgraded_successfully": "Su Organization se actualizó con éxito.", "org_upgraded_successfully": "Su Organization se actualizó con éxito.",
"use_link_to_reset_password": "Utilice el enlace de abajo para restablecer su contraseña", "use_link_to_reset_password": "Utilice el enlace de abajo para restablecer su contraseña",
"hey_there": "Hola,", "hey_there": "Hola,",
@@ -306,7 +306,7 @@
"password_has_been_reset_login": "Su contraseña ha sido restablecida. Ahora puede iniciar sesión con su nueva contraseña.", "password_has_been_reset_login": "Su contraseña ha sido restablecida. Ahora puede iniciar sesión con su nueva contraseña.",
"layout": "Diseño", "layout": "Diseño",
"bookerlayout_default_title": "Vista predeterminada", "bookerlayout_default_title": "Vista predeterminada",
"bookerlayout_description": "Puede seleccionar varios y quienes le reserven pueden cambiar de vista.", "bookerlayout_description": "Puede seleccionar varias y quienes le reserven pueden cambiar de vista.",
"bookerlayout_user_settings_title": "Diseño de reserva", "bookerlayout_user_settings_title": "Diseño de reserva",
"bookerlayout_user_settings_description": "Puede seleccionar varios y quienes le reservan pueden cambiar de vista. Esto se puede anular por evento.", "bookerlayout_user_settings_description": "Puede seleccionar varios y quienes le reservan pueden cambiar de vista. Esto se puede anular por evento.",
"bookerlayout_month_view": "Mes", "bookerlayout_month_view": "Mes",
@@ -315,7 +315,7 @@
"bookerlayout_error_min_one_enabled": "Al menos un diseño tiene que estar habilitado.", "bookerlayout_error_min_one_enabled": "Al menos un diseño tiene que estar habilitado.",
"bookerlayout_error_default_not_enabled": "El diseño que seleccionó como vista predeterminada no es parte de los diseños habilitados.", "bookerlayout_error_default_not_enabled": "El diseño que seleccionó como vista predeterminada no es parte de los diseños habilitados.",
"bookerlayout_error_unknown_layout": "El diseño seleccionado no es un diseño válido.", "bookerlayout_error_unknown_layout": "El diseño seleccionado no es un diseño válido.",
"bookerlayout_override_global_settings": "Puede gestionar esto para todos sus tipos de eventos en <2>configuración / apariencia</2> o <6>sobrescribir solo para este evento</6>.", "bookerlayout_override_global_settings": "Puede gestionar esto para todos sus tipos de eventos en <2>configuración / apariencia</2> o <6>anular solo para este evento</6>.",
"unexpected_error_try_again": "Ocurrió un error inesperado. Inténtelo de nuevo.", "unexpected_error_try_again": "Ocurrió un error inesperado. Inténtelo de nuevo.",
"sunday_time_error": "Hora inválida del domingo", "sunday_time_error": "Hora inválida del domingo",
"monday_time_error": "Hora inválida del lunes", "monday_time_error": "Hora inválida del lunes",
@@ -551,7 +551,7 @@
"team_description": "Comentarios sobre tu equipo. Esta información aparecerá en la página de la URL de tu equipo.", "team_description": "Comentarios sobre tu equipo. Esta información aparecerá en la página de la URL de tu equipo.",
"org_description": "Algunas frases sobre su organización. Esto aparecerá en la página de la URL de su organización.", "org_description": "Algunas frases sobre su organización. Esto aparecerá en la página de la URL de su organización.",
"members": "Miembros", "members": "Miembros",
"organization_members": "Miembros de la organización", "organization_members": "Miembros de Organization",
"member": "Miembro", "member": "Miembro",
"number_member_one": "{{count}} miembro", "number_member_one": "{{count}} miembro",
"number_member_other": "{{count}} miembros", "number_member_other": "{{count}} miembros",
@@ -699,7 +699,7 @@
"create_team_to_get_started": "Crea un equipo para empezar", "create_team_to_get_started": "Crea un equipo para empezar",
"teams": "Equipos", "teams": "Equipos",
"team": "Equipo", "team": "Equipo",
"organization": "Organización", "organization": "Organization",
"team_billing": "Facturación del equipo", "team_billing": "Facturación del equipo",
"team_billing_description": "Gestione la facturación para su equipo", "team_billing_description": "Gestione la facturación para su equipo",
"upgrade_to_flexible_pro_title": "Hemos cambiado la facturación de los equipos", "upgrade_to_flexible_pro_title": "Hemos cambiado la facturación de los equipos",
@@ -1075,7 +1075,7 @@
"event_cancelled": "Este evento se canceló", "event_cancelled": "Este evento se canceló",
"emailed_information_about_cancelled_event": "Le enviamos un correo electrónico a usted y a los demás asistentes para informarles.", "emailed_information_about_cancelled_event": "Le enviamos un correo electrónico a usted y a los demás asistentes para informarles.",
"this_input_will_shown_booking_this_event": "Esta entrada se mostrará al reservar este evento.", "this_input_will_shown_booking_this_event": "Esta entrada se mostrará al reservar este evento.",
"meeting_url_in_conformation_email": "La URL de la reunión está en el correo de confirmación", "meeting_url_in_confirmation_email": "La URL de la reunión está en el correo de confirmación",
"url_start_with_https": "La URL debe empezar por http:// o https://", "url_start_with_https": "La URL debe empezar por http:// o https://",
"number_provided": "Se proporcionará el número de teléfono", "number_provided": "Se proporcionará el número de teléfono",
"before_event_trigger": "antes del inicio del evento", "before_event_trigger": "antes del inicio del evento",
@@ -1659,9 +1659,8 @@
"delete_sso_configuration_confirmation_description": "¿Está seguro de que desea eliminar la configuración de {{connectionType}}? Los miembros de su equipo que utilicen el inicio de sesión {{connectionType}} ya no podrán acceder a Cal.com.", "delete_sso_configuration_confirmation_description": "¿Está seguro de que desea eliminar la configuración de {{connectionType}}? Los miembros de su equipo que utilicen el inicio de sesión {{connectionType}} ya no podrán acceder a Cal.com.",
"organizer_timezone": "Zona horaria del organizador", "organizer_timezone": "Zona horaria del organizador",
"email_user_cta": "Ver la invitación", "email_user_cta": "Ver la invitación",
"email_no_user_invite_heading": "Lo invitaron a unirse a {{appName}} {{entity}}",
"email_no_user_invite_subheading": "{{invitedBy}} lo invitó a unirse a su equipo en {{appName}}. {{appName}} es el programador de eventos que les permite a usted y a su equipo programar reuniones sin necesidad de enviar correos electrónicos.", "email_no_user_invite_subheading": "{{invitedBy}} lo invitó a unirse a su equipo en {{appName}}. {{appName}} es el programador de eventos que les permite a usted y a su equipo programar reuniones sin necesidad de enviar correos electrónicos.",
"email_user_invite_subheading": "{{invitedBy}} lo ha invitado a unirse a su {{entity}} \"{{teamName}}\" en {{appName}}. {{appName}} es el planificador de eventos que le permite a usted y a su {{entity}} programar reuniones sin necesidad de correos electrónicos.", "email_user_invite_subheading_team": "{{invitedBy}} lo ha invitado a unirse a su equipo \"{{teamName}}\" en {{appName}}. {{appName}} es el planificador de eventos que le permite a usted y a su equipo programar reuniones sin correos electrónicos de ida y vuelta.",
"email_no_user_invite_steps_intro": "Lo guiaremos a través de unos pocos pasos y disfrutará de una programación sin estrés con su {{entity}} en muy poco tiempo.", "email_no_user_invite_steps_intro": "Lo guiaremos a través de unos pocos pasos y disfrutará de una programación sin estrés con su {{entity}} en muy poco tiempo.",
"email_no_user_step_one": "Elija su nombre de usuario", "email_no_user_step_one": "Elija su nombre de usuario",
"email_no_user_step_two": "Conecte su cuenta de calendario", "email_no_user_step_two": "Conecte su cuenta de calendario",
@@ -1878,7 +1877,6 @@
"first_event_type_webhook_description": "Cree su primer webhook para este tipo de evento", "first_event_type_webhook_description": "Cree su primer webhook para este tipo de evento",
"install_app_on": "Instalar aplicación en", "install_app_on": "Instalar aplicación en",
"create_for": "Crear para", "create_for": "Crear para",
"setup_organization": "Configurar una organización",
"organization_banner_description": "Cree entornos en los que sus equipos puedan crear aplicaciones, flujos de trabajo y tipos de eventos compartidos con programación por turnos y colectiva.", "organization_banner_description": "Cree entornos en los que sus equipos puedan crear aplicaciones, flujos de trabajo y tipos de eventos compartidos con programación por turnos y colectiva.",
"organization_banner_title": "Gestione organizaciones con varios equipos", "organization_banner_title": "Gestione organizaciones con varios equipos",
"set_up_your_organization": "Configure su organización", "set_up_your_organization": "Configure su organización",
@@ -1890,7 +1888,7 @@
"organization_name": "Nombre de la organización", "organization_name": "Nombre de la organización",
"organization_url": "URL de la organización", "organization_url": "URL de la organización",
"organization_verify_header": "Verifique el correo electrónico de su organización", "organization_verify_header": "Verifique el correo electrónico de su organización",
"organization_verify_email_body": "Utilice el código a continuación para verificar su dirección de correo electrónico para seguir configurando su organización.", "organization_verify_email_body": "Utilice el siguiente código para verificar su dirección de correo electrónico y continuar con la configuración de su organización.",
"additional_url_parameters": "Parámetros adicionales de URL", "additional_url_parameters": "Parámetros adicionales de URL",
"about_your_organization": "Acerca de su organización", "about_your_organization": "Acerca de su organización",
"about_your_organization_description": "Las organizaciones son entornos compartidos donde puede crear varios equipos con miembros, tipos de eventos, aplicaciones, flujos de trabajo compartidos y más.", "about_your_organization_description": "Las organizaciones son entornos compartidos donde puede crear varios equipos con miembros, tipos de eventos, aplicaciones, flujos de trabajo compartidos y más.",
@@ -1918,7 +1916,7 @@
"org_no_teams_yet_description": "Si usted es un administrador, asegúrese de crear equipos para que se muestren aquí.", "org_no_teams_yet_description": "Si usted es un administrador, asegúrese de crear equipos para que se muestren aquí.",
"set_up": "Configurar", "set_up": "Configurar",
"set_up_your_profile": "Configure su perfil", "set_up_your_profile": "Configure su perfil",
"set_up_your_profile_description": "Informe a las personas quién es usted dentro de {{orgName}} cuándo interactúan con su enlace público.", "set_up_your_profile_description": "Informe a las personas quién es usted dentro de {{orgName}} y cuándo interactúen con su enlace público.",
"my_profile": "Mi perfil", "my_profile": "Mi perfil",
"my_settings": "Mi configuración", "my_settings": "Mi configuración",
"crm": "CRM", "crm": "CRM",

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