Compare commits

...
105 Commits
Author SHA1 Message Date
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
347 changed files with 10175 additions and 4119 deletions
@@ -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
+4 -5
View File
@@ -1,6 +1,6 @@
# 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.
@@ -37,7 +37,7 @@ Contributions are what make the open source community such an amazing place to l
</tr>
<tr>
<td>
Core Features (Booking page, availabilty, timezone calculation)
Core Features (Booking page, availability, timezone calculation)
</td>
<td>
<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
- 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.
- 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).
- 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](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.
+3 -3
View File
@@ -423,7 +423,7 @@ yarn seed-app-store
```
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
1. Add extra redirect URL `<Cal.com URL>/api/auth/callback/google`
@@ -449,8 +449,8 @@ following
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.
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.
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`.
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 on "Scopes" and then on "+ Add Scopes". On the left, click the category "Meeting" and check the scope `meeting:write`.
12. Click "Done".
13. You're good to go. Now you can easily add your Zoom integration in the Cal.com settings.
+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"]
}
+12 -10
View File
@@ -13,16 +13,18 @@ const schemaMembershipRequiredParams = z.object({
teamId: z.number(),
});
export const membershipCreateBodySchema = Membership.partial({
accepted: true,
role: true,
disableImpersonation: true,
}).transform((v) => ({
accepted: false,
role: MembershipRole.MEMBER,
disableImpersonation: false,
...v,
}));
export const membershipCreateBodySchema = Membership.omit({ id: true })
.partial({
accepted: true,
role: true,
disableImpersonation: true,
})
.transform((v) => ({
accepted: false,
role: MembershipRole.MEMBER,
disableImpersonation: false,
...v,
}));
export const membershipEditBodySchema = Membership.omit({
/** 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",
"tzdata": "^1.0.30",
"uuid": "^8.3.2",
"zod": "^3.20.2"
"zod": "^3.22.2"
}
}
+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">
Figma
</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.
</p>
</div>
+8 -8
View File
@@ -248,10 +248,10 @@
--cal-bg-inverted: #f3f4f6;
/* background -> components*/
--cal-bg-info: #dee9fc;
--cal-bg-success: #e2fbe8;
--cal-bg-attention: #fceed8;
--cal-bg-error: #f9e3e2;
--cal-bg-info: #263fa9;
--cal-bg-success: #306339;
--cal-bg-attention: #8e3b1f;
--cal-bg-error: #8c2822;
--cal-bg-dark-error: #752522;
/* Borders */
@@ -269,10 +269,10 @@
--cal-text-inverted: #101010;
/* Content/Text -> components */
--cal-text-info: #253985;
--cal-text-success: #285231;
--cal-text-attention: #73321b;
--cal-text-error: #752522;
--cal-text-info: #dee9fc;
--cal-text-success: #e2fbe8;
--cal-text-attention: #fceed8;
--cal-text-error: #f9e3e2;
/* Brand shenanigans
-> These will be computed for the users theme at runtime.
+8 -1
View File
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react";
import { z } from "zod";
import type { CredentialOwner } from "@calcom/app-store/types";
import classNames from "@calcom/lib/classNames";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
@@ -80,7 +81,13 @@ export default function AppListCard(props: AppListCardProps) {
return (
<div className={`${highlight ? "dark:bg-muted bg-yellow-100" : ""}`}>
<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 items-center gap-x-2">
<h3 className="text-emphasis truncate text-sm font-semibold">{title}</h3>
+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 { Label, Input } from "@calcom/ui";
export default function TwoFactor({ center = true }) {
export default function TwoFactor({ center = true, autoFocus = true }) {
const [value, onChange] = useState("");
const { t } = useLocale();
const methods = useFormContext();
@@ -40,7 +40,7 @@ export default function TwoFactor({ center = true }) {
name={`2fa${index + 1}`}
inputMode="decimal"
{...digit}
autoFocus={index === 0}
autoFocus={autoFocus && index === 0}
autoComplete="one-time-code"
/>
))}
@@ -382,24 +382,22 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
}}
/>
{selectedLocation && LocationOptions}
<DialogFooter>
<div className="mt-4 flex justify-end space-x-2 rtl:space-x-reverse">
<Button
onClick={() => {
setShowLocationModal(false);
setSelectedLocation?.(undefined);
setEditingLocationType?.("");
locationFormMethods.unregister(["locationType", "locationLink"]);
}}
type="button"
color="secondary">
{t("cancel")}
</Button>
<DialogFooter className="mt-4">
<Button
onClick={() => {
setShowLocationModal(false);
setSelectedLocation?.(undefined);
setEditingLocationType?.("");
locationFormMethods.unregister(["locationType", "locationLink"]);
}}
type="button"
color="secondary">
{t("cancel")}
</Button>
<Button data-testid="update-location" type="submit">
{t("update")}
</Button>
</div>
<Button data-testid="update-location" type="submit">
{t("update")}
</Button>
</DialogFooter>
</Form>
</div>
@@ -41,7 +41,7 @@ export const RescheduleDialog = (props: IRescheduleDialog) => {
return (
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
<DialogContent>
<DialogContent enableOverflow>
<div className="flex flex-row space-x-3">
<div className="flex h-10 w-10 flex-shrink-0 justify-center rounded-full bg-[#FAFAFA]">
<Clock className="m-auto h-6 w-6" />
@@ -306,7 +306,7 @@ export const EventSetupTab = (
"h-4 w-4",
// invert all the icons except app icons
eventLocationType.iconUrl &&
!eventLocationType.iconUrl.startsWith("/app-store") &&
eventLocationType.iconUrl.includes("-dark") &&
"dark:invert"
)}
alt={`${eventLocationType.label} logo`}
@@ -9,7 +9,6 @@ import type { Options } from "react-select";
import type { CheckedSelectOption } from "@calcom/features/eventtypes/components/CheckedTeamSelect";
import CheckedTeamSelect from "@calcom/features/eventtypes/components/CheckedTeamSelect";
import ChildrenEventTypeSelect from "@calcom/features/eventtypes/components/ChildrenEventTypeSelect";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { SchedulingType } from "@calcom/prisma/enums";
import { Label, Select } from "@calcom/ui";
@@ -18,13 +17,14 @@ interface IUserToValue {
id: number | null;
name: string | null;
username: string | null;
avatar: string;
email: string;
}
const mapUserToValue = ({ id, name, username, email }: IUserToValue, pendingString: string) => ({
const mapUserToValue = ({ id, name, username, avatar, email }: IUserToValue, pendingString: string) => ({
value: `${id || ""}`,
label: `${name || email || ""}${!username ? ` (${pendingString})` : ""}`,
avatar: `${WEBAPP_URL}/${username}/avatar.png`,
avatar,
email,
});
@@ -66,6 +66,7 @@ type Props = {
formMethods: UseFormReturn<FormValues>;
isUpdateMutationLoading?: boolean;
availability?: AvailabilityOption;
isUserOrganizationAdmin: boolean;
};
function getNavigation(props: {
@@ -133,6 +134,7 @@ function EventTypeSingleLayout({
isUpdateMutationLoading,
formMethods,
availability,
isUserOrganizationAdmin,
}: Props) {
const utils = trpc.useContext();
const { t } = useLocale();
@@ -142,7 +144,8 @@ function EventTypeSingleLayout({
const hasPermsToDelete =
currentUserMembership?.role !== "MEMBER" ||
!currentUserMembership ||
eventType.schedulingType === SchedulingType.MANAGED;
eventType.schedulingType === SchedulingType.MANAGED ||
isUserOrganizationAdmin;
const deleteMutation = trpc.viewer.eventTypes.delete.useMutation({
onSuccess: async () => {
@@ -98,14 +98,7 @@ const UserProfile = () => {
return (
<form onSubmit={onSubmit}>
<div className="flex flex-row items-center justify-start rtl:justify-end">
{user && (
<Avatar
alt={user.username || "user avatar"}
gravatarFallbackMd5={user.emailMd5}
size="lg"
imageSrc={imageSrc}
/>
)}
{user && <Avatar alt={user.username || "user avatar"} size="lg" imageSrc={imageSrc} />}
<input
ref={avatarRef}
type="hidden"
@@ -5,6 +5,7 @@ import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button, Dialog, DialogContent, DialogFooter, Form, PasswordField } from "@calcom/ui";
import BackupCode from "@components/auth/BackupCode";
import TwoFactor from "@components/auth/TwoFactor";
import TwoFactorAuthAPI from "./TwoFactorAuthAPI";
@@ -20,6 +21,7 @@ interface DisableTwoFactorAuthModalProps {
}
interface DisableTwoFactorValues {
backupCode: string;
totpCode: string;
password: string;
}
@@ -33,11 +35,19 @@ const DisableTwoFactorAuthModal = ({
}: DisableTwoFactorAuthModalProps) => {
const [isDisabling, setIsDisabling] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [twoFactorLostAccess, setTwoFactorLostAccess] = useState(false);
const { t } = useLocale();
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) {
return;
}
@@ -45,8 +55,10 @@ const DisableTwoFactorAuthModal = ({
setErrorMessage(null);
try {
const response = await TwoFactorAuthAPI.disable(password, totpCode);
const response = await TwoFactorAuthAPI.disable(password, totpCode, backupCode);
if (response.status === 200) {
setTwoFactorLostAccess(false);
resetForm();
onDisable();
return;
}
@@ -54,12 +66,14 @@ const DisableTwoFactorAuthModal = ({
const body = await response.json();
if (body.error === ErrorCode.IncorrectPassword) {
setErrorMessage(t("incorrect_password"));
}
if (body.error === ErrorCode.SecondFactorRequired) {
} else if (body.error === ErrorCode.SecondFactorRequired) {
setErrorMessage(t("2fa_required"));
}
if (body.error === ErrorCode.IncorrectTwoFactorCode) {
} else if (body.error === ErrorCode.IncorrectTwoFactorCode) {
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 {
setErrorMessage(t("something_went_wrong"));
}
@@ -78,6 +92,7 @@ const DisableTwoFactorAuthModal = ({
<div className="mb-8">
{!disablePassword && (
<PasswordField
required
labelProps={{
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"
/>
)}
<TwoFactor center={false} />
{twoFactorLostAccess ? (
<BackupCode center={false} />
) : (
<TwoFactor center={false} autoFocus={false} />
)}
{errorMessage && <p className="mt-1 text-sm text-red-700">{errorMessage}</p>}
</div>
<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}>
{t("cancel")}
</Button>
@@ -5,7 +5,7 @@ import { useForm } from "react-hook-form";
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
import { useCallbackRef } from "@calcom/lib/hooks/useCallbackRef";
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";
@@ -28,6 +28,7 @@ interface EnableTwoFactorModalProps {
enum SetupStep {
ConfirmPassword,
DisplayBackupCodes,
DisplayQrCode,
EnterTotpCode,
}
@@ -54,16 +55,25 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
const setupDescriptions = {
[SetupStep.ConfirmPassword]: t("2fa_confirm_current_password"),
[SetupStep.DisplayBackupCodes]: t("backup_code_instructions"),
[SetupStep.DisplayQrCode]: t("2fa_scan_image_or_use_code"),
[SetupStep.EnterTotpCode]: t("2fa_enter_six_digit_code"),
};
const [step, setStep] = useState(SetupStep.ConfirmPassword);
const [password, setPassword] = useState("");
const [backupCodes, setBackupCodes] = useState([]);
const [backupCodesUrl, setBackupCodesUrl] = useState("");
const [dataUri, setDataUri] = useState("");
const [secret, setSecret] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const resetState = () => {
setPassword("");
setErrorMessage(null);
setStep(SetupStep.ConfirmPassword);
};
async function handleSetup(e: React.FormEvent) {
e.preventDefault();
@@ -79,6 +89,15 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
const body = await response.json();
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);
setSecret(body.secret);
setStep(SetupStep.DisplayQrCode);
@@ -113,7 +132,7 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
const body = await response.json();
if (response.status === 200) {
onEnable();
setStep(SetupStep.DisplayBackupCodes);
return;
}
@@ -141,13 +160,18 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
}
}, [form, handleEnableRef, totpCode]);
const formatBackupCode = (code: string) => `${code.slice(0, 5)}-${code.slice(5, 10)}`;
return (
<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}>
<form onSubmit={handleSetup}>
<div className="mb-4">
<TextField
<PasswordField
label={t("password")}
type="password"
name="password"
@@ -173,6 +197,15 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
</p>
</>
</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}>
<WithStep step={SetupStep.EnterTotpCode} current={step}>
<div className="-mt-4 pb-2">
@@ -186,9 +219,16 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
</div>
</WithStep>
<DialogFooter className="mt-8" showDivider>
<Button color="secondary" onClick={onCancel}>
{t("cancel")}
</Button>
{step !== SetupStep.DisplayBackupCodes ? (
<Button
color="secondary"
onClick={() => {
onCancel();
resetState();
}}>
{t("cancel")}
</Button>
) : null}
<WithStep step={SetupStep.ConfirmPassword} current={step}>
<Button
type="submit"
@@ -218,6 +258,35 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
{t("enable")}
</Button>
</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>
</Form>
</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", {
method: "POST",
body: JSON.stringify({ password, code }),
body: JSON.stringify({ password, code, backupCode }),
headers: {
"Content-Type": "application/json",
},
+9 -27
View File
@@ -57,12 +57,10 @@ export const AdminUser = (props: { onSubmit: () => void; onError: () => void; on
}),
});
const formMethods = useForm<{
username: string;
email_address: string;
full_name: string;
password: string;
}>({
type formSchemaType = z.infer<typeof formSchema>;
const formMethods = useForm<formSchemaType>({
mode: "onChange",
resolver: zodResolver(formSchema),
});
@@ -70,7 +68,7 @@ export const AdminUser = (props: { onSubmit: () => void; onError: () => void; on
props.onError();
};
const onSubmit = formMethods.handleSubmit(async (data: z.infer<typeof formSchema>) => {
const onSubmit = formMethods.handleSubmit(async (data) => {
props.onSubmit();
const response = await fetch("/api/auth/setup", {
method: "POST",
@@ -130,11 +128,7 @@ export const AdminUser = (props: { onSubmit: () => void; onError: () => void; on
className={classNames("my-0", longWebsiteUrl && "rounded-t-none")}
onBlur={onBlur}
name="username"
onChange={async (e) => {
onChange(e.target.value);
formMethods.setValue("username", e.target.value);
await formMethods.trigger("username");
}}
onChange={(e) => onChange(e.target.value)}
/>
</>
)}
@@ -148,11 +142,7 @@ export const AdminUser = (props: { onSubmit: () => void; onError: () => void; on
<TextField
value={value || ""}
onBlur={onBlur}
onChange={async (e) => {
onChange(e.target.value);
formMethods.setValue("full_name", e.target.value);
await formMethods.trigger("full_name");
}}
onChange={(e) => onChange(e.target.value)}
color={formMethods.formState.errors.full_name ? "warn" : ""}
type="text"
name="full_name"
@@ -172,11 +162,7 @@ export const AdminUser = (props: { onSubmit: () => void; onError: () => void; on
<EmailField
value={value || ""}
onBlur={onBlur}
onChange={async (e) => {
onChange(e.target.value);
formMethods.setValue("email_address", e.target.value);
await formMethods.trigger("email_address");
}}
onChange={(e) => onChange(e.target.value)}
className="my-0"
name="email_address"
/>
@@ -191,11 +177,7 @@ export const AdminUser = (props: { onSubmit: () => void; onError: () => void; on
<PasswordField
value={value || ""}
onBlur={onBlur}
onChange={async (e) => {
onChange(e.target.value);
formMethods.setValue("password", e.target.value);
await formMethods.trigger("password");
}}
onChange={(e) => onChange(e.target.value)}
hintErrors={["caplow", "admin_min", "num"]}
name="password"
className="my-0"
@@ -4,7 +4,6 @@ import { components } from "react-select";
import type { EventLocationType } from "@calcom/app-store/locations";
import type { CredentialDataWithTeamName } from "@calcom/app-store/utils";
import { classNames } from "@calcom/lib";
import cx from "@calcom/lib/classNames";
import { Select } from "@calcom/ui";
export type LocationOption = {
@@ -28,7 +27,7 @@ const OptionWithIcon = ({ icon, label }: { icon?: string; label: string }) => {
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")}
className={classNames(icon.includes("-dark") && "dark:invert", "h-3.5 w-3.5")}
/>
)}
<span className={classNames("text-sm font-medium")}>{label}</span>
@@ -57,7 +56,13 @@ export default function LocationSelect(props: Props<LocationOption, false, Group
}}
formatOptionLabel={(e) => (
<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>
</div>
)}
+1 -1
View File
@@ -32,7 +32,7 @@ function getCspPolicy(nonce: string) {
IS_PRODUCTION ? (useNonStrictPolicy ? "'unsafe-inline'" : "") : "'unsafe-inline'"
} app.cal.com;
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'
`;
}
-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`;
};
+43 -33
View File
@@ -14,7 +14,8 @@ const {
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");
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
process.env.NEXT_PUBLIC_CALCOM_VERSION = version;
@@ -226,7 +227,7 @@ const nextConfig = {
async rewrites() {
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
...(process.env.ORGANIZATIONS_ENABLED
...(isOrganizationsEnabled
? [
{
...matcherConfigRootPath,
@@ -333,44 +334,46 @@ const nextConfig = {
},
],
},
...[
{
...matcherConfigRootPath,
headers: [
...(isOrganizationsEnabled
? [
{
key: "X-Cal-Org-path",
value: "/team/:orgSlug",
...matcherConfigRootPath,
headers: [
{
key: "X-Cal-Org-path",
value: "/team/:orgSlug",
},
],
},
],
},
{
...matcherConfigUserRoute,
headers: [
{
key: "X-Cal-Org-path",
value: "/org/:orgSlug/:user",
...matcherConfigUserRoute,
headers: [
{
key: "X-Cal-Org-path",
value: "/org/:orgSlug/:user",
},
],
},
],
},
{
...matcherConfigUserTypeRoute,
headers: [
{
key: "X-Cal-Org-path",
value: "/org/:orgSlug/:user/:type",
...matcherConfigUserTypeRoute,
headers: [
{
key: "X-Cal-Org-path",
value: "/org/:orgSlug/:user/:type",
},
],
},
],
},
{
...matcherConfigUserTypeEmbedRoute,
headers: [
{
key: "X-Cal-Org-path",
value: "/org/:orgSlug/:user/:type/embed",
...matcherConfigUserTypeEmbedRoute,
headers: [
{
key: "X-Cal-Org-path",
value: "/org/:orgSlug/:user/:type/embed",
},
],
},
],
},
],
]
: []),
];
},
async redirects() {
@@ -447,6 +450,13 @@ const nextConfig = {
},
{
source: "/support",
missing: [
{
type: "header",
key: "host",
value: orgHostPath,
},
],
destination: "/event-types?openIntercom=true",
permanent: true,
},
@@ -463,7 +473,7 @@ const nextConfig = {
// OAuth callbacks when sent to localhost:3000(w would be expected) should be redirected to corresponding to WEBAPP_URL
...(process.env.NODE_ENV === "development" &&
// 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
process.env.NEXT_PUBLIC_WEBAPP_URL !== "http://localhost:3000"
? [
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "3.2.5",
"version": "3.2.8",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
@@ -129,7 +129,7 @@
"turndown": "^7.1.1",
"uuid": "^8.3.2",
"web3": "^1.7.5",
"zod": "^3.20.2"
"zod": "^3.22.2"
},
"devDependencies": {
"@babel/core": "^7.19.6",
+1 -11
View File
@@ -1,5 +1,4 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
import dayjs from "@calcom/dayjs";
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 prisma from "@calcom/prisma";
import { IdentityProvider } from "@calcom/prisma/enums";
import { signupSchema } 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) {
if (req.method !== "POST") {
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 });
}
}
// 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) {
return res.status(400).json({ 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,
},
data: {
backupCodes: null,
twoFactorEnabled: false,
twoFactorSecret: null,
},
@@ -1,3 +1,4 @@
import crypto from "crypto";
import type { NextApiRequest, NextApiResponse } from "next";
import { authenticator } from "otplib";
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.
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({
where: {
id: session.user.id,
},
data: {
backupCodes: symmetricEncrypt(JSON.stringify(backupCodes), process.env.CALENDSO_ENCRYPTION_KEY),
twoFactorEnabled: false,
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 dataUri = await qrcode.toDataURL(keyUri);
return res.json({ secret, keyUri, dataUri });
return res.json({ secret, keyUri, dataUri, backupCodes });
}
+2 -2
View File
@@ -104,7 +104,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
});
const attendeesList = await Promise.all(attendeesListPromises);
const selectedDestinationCalendar = booking.destinationCalendar || user.destinationCalendar;
const evt: CalendarEvent = {
type: booking.title,
title: booking.title,
@@ -127,7 +127,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
attendees: attendeesList,
uid: booking.uid,
recurringEvent: parseRecurringEvent(booking.eventType?.recurringEvent),
destinationCalendar: booking.destinationCalendar || user.destinationCalendar,
destinationCalendar: selectedDestinationCalendar ? [selectedDestinationCalendar] : [],
};
await sendOrganizerRequestReminderEmail(evt);
@@ -0,0 +1 @@
export { default } from "@calcom/features/webhooks/lib/cron";
+6 -1
View File
@@ -34,13 +34,17 @@ const triggerWebhook = async ({
booking: {
userId: number | undefined;
eventTypeId: number | null;
eventTypeParentId: number | null | undefined;
teamId?: number | null;
};
}) => {
const eventTrigger: WebhookTriggerEvents = "RECORDING_READY";
// Send Webhook call if hooked to BOOKING.RECORDING_READY
const triggerForUser = !booking.teamId || (booking.teamId && booking.eventTypeParentId);
const subscriberOptions = {
userId: booking.userId,
userId: triggerForUser ? booking.userId : null,
eventTypeId: booking.eventTypeId,
triggerEvent: eventTrigger,
teamId: booking.teamId,
@@ -183,6 +187,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
booking: {
userId: booking?.user?.id,
eventTypeId: booking.eventTypeId,
eventTypeParentId: booking.eventType?.parentId,
teamId,
},
});
+2 -9
View File
@@ -1,13 +1,11 @@
import crypto from "crypto";
import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
import { getSlugOrRequestedSlug, orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import { AVATAR_FALLBACK } from "@calcom/lib/constants";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import prisma from "@calcom/prisma";
import { defaultAvatarSrc } from "@lib/profile";
const querySchema = z
.object({
username: z.string(),
@@ -75,12 +73,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
res.setHeader("x-cal-org", identity.org);
}
res.writeHead(302, {
Location: defaultAvatarSrc({
md5: crypto
.createHash("md5")
.update(identity?.email || "guest@example.com")
.digest("hex"),
}),
Location: AVATAR_FALLBACK,
});
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 { inferSSRProps } from "@calcom/types/inferSSRProps";
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 PageWrapper from "@components/PageWrapper";
@@ -81,6 +88,7 @@ export default function Apps({
<>
<AppStoreCategories categories={categories} />
<PopularAppsSlider items={appStore} />
<RecentAppsSlider items={appStore} />
</>
)}
<AllApps
+44 -13
View File
@@ -21,7 +21,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import prisma from "@calcom/prisma";
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 { WithNonceProps } from "@lib/withNonce";
@@ -29,6 +29,7 @@ import withNonce from "@lib/withNonce";
import AddToHomescreen from "@components/AddToHomescreen";
import PageWrapper from "@components/PageWrapper";
import BackupCode from "@components/auth/BackupCode";
import TwoFactor from "@components/auth/TwoFactor";
import AuthContainer from "@components/ui/AuthContainer";
@@ -39,6 +40,7 @@ interface LoginValues {
email: string;
password: string;
totpCode: string;
backupCode: string;
csrfToken: string;
}
export default function Login({
@@ -50,6 +52,8 @@ export default function Login({
totpEmail,
}: inferSSRProps<typeof _getServerSideProps> & WithNonceProps) {
const searchParams = useSearchParams();
const isTeamInvite = searchParams.get("teamInvite");
const { t } = useLocale();
const router = useRouter();
const formSchema = z
@@ -65,6 +69,7 @@ export default function Login({
const methods = useForm<LoginValues>({ resolver: zodResolver(formSchema) });
const { register, formState } = methods;
const [twoFactorRequired, setTwoFactorRequired] = useState(!!totpEmail || false);
const [twoFactorLostAccess, setTwoFactorLostAccess] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const errorMessages: { [key: string]: string } = {
@@ -92,21 +97,43 @@ export default function Login({
callbackUrl = safeCallbackUrl || "";
const LoginFooter = (
<a href={`${WEBSITE_URL}/signup`} className="text-brand-500 font-medium">
<a
href={callbackUrl !== "" ? `${WEBSITE_URL}/signup?callbackUrl=${callbackUrl}` : `${WEBSITE_URL}/signup`}
className="text-brand-500 font-medium">
{t("dont_have_an_account")}
</a>
);
const TwoFactorFooter = (
<Button
onClick={() => {
setTwoFactorRequired(false);
methods.setValue("totpCode", "");
}}
StartIcon={ArrowLeft}
color="minimal">
{t("go_back")}
</Button>
<>
<Button
onClick={() => {
if (twoFactorLostAccess) {
setTwoFactorLostAccess(false);
methods.setValue("backupCode", "");
} else {
setTwoFactorRequired(false);
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 = (
@@ -130,8 +157,9 @@ export default function Login({
if (!res) setErrorMessage(errorMessages[ErrorCode.InternalServerError]);
// we're logged in! let's do a hard refresh to the desired url
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.IncorrectBackupCode) setErrorMessage(t("incorrect_backup_code"));
else if (res.error === ErrorCode.MissingBackupCodes) setErrorMessage(t("missing_backup_codes"));
// fallback if error not found
else setErrorMessage(errorMessages[res.error] || t("something_went_wrong"));
};
@@ -160,6 +188,9 @@ export default function Login({
? LoginFooter
: null
}>
{isTeamInvite && (
<Alert severity="info" message={t("signin_or_signup_to_accept_invite")} className="mb-4 mt-4" />
)}
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(onSubmit)} noValidate data-testid="login-form">
<div>
@@ -194,7 +225,7 @@ export default function Login({
</div>
</div>
{twoFactorRequired && <TwoFactor center />}
{twoFactorRequired ? !twoFactorLostAccess ? <TwoFactor center /> : <BackupCode center /> : null}
{errorMessage && <Alert severity="error" title={errorMessage} />}
<Button
+103 -38
View File
@@ -6,6 +6,7 @@ import dayjs from "@calcom/dayjs";
import { DateOverrideInputDialog, DateOverrideList } from "@calcom/features/schedules";
import Schedule from "@calcom/features/schedules/components/Schedule";
import Shell from "@calcom/features/shell/Shell";
import { classNames } from "@calcom/lib";
import { availabilityAsString } from "@calcom/lib/availability";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { HttpError } from "@calcom/lib/http-error";
@@ -17,11 +18,6 @@ import {
ConfirmationDialogContent,
Dialog,
DialogTrigger,
Dropdown,
DropdownItem,
DropdownMenuContent,
DropdownMenuSeparator,
DropdownMenuTrigger,
Form,
Label,
showToast,
@@ -32,7 +28,7 @@ import {
Tooltip,
VerticalDivider,
} 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 { SelectSkeletonLoader } from "@components/availability/SkeletonLoader";
@@ -95,7 +91,7 @@ export default function Availability() {
const scheduleId = searchParams?.get("schedule") ? Number(searchParams.get("schedule")) : -1;
const fromEventType = searchParams?.get("fromEventType");
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(
{ scheduleId },
{
@@ -225,33 +221,60 @@ export default function Availability() {
</ConfirmationDialogContent>
</Dialog>
<VerticalDivider className="hidden sm:inline" />
<Dropdown>
<DropdownMenuTrigger asChild>
<Button className="sm:hidden" StartIcon={MoreHorizontal} variant="icon" color="secondary" />
</DropdownMenuTrigger>
<DropdownMenuContent style={{ minWidth: "200px" }}>
<DropdownItem
type="button"
color="destructive"
StartIcon={Trash}
onClick={() => setDeleteDialogOpen(true)}>
{t("delete")}
</DropdownItem>
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<ConfirmationDialogContent
isLoading={deleteMutation.isLoading}
variety="danger"
title={t("delete_schedule")}
confirmBtnText={t("delete")}
loadingText={t("delete")}
onConfirm={() => {
schedule !== undefined && deleteMutation.mutate({ scheduleId: schedule.id });
}}>
{t("delete_schedule_description")}
</ConfirmationDialogContent>
</Dialog>
<DropdownMenuSeparator />
<div className="flex h-9 flex-row items-center justify-between px-4 py-2 hover:bg-gray-100">
<div
className={classNames(
openSidebar
? "fadeIn fixed inset-0 z-50 bg-neutral-800 bg-opacity-70 transition-opacity dark:bg-opacity-70 sm:hidden"
: ""
)}>
<div
className={classNames(
"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",
openSidebar ? "translate-x-0 opacity-100" : "translate-x-full opacity-0"
)}>
<div className="flex flex-row items-center pt-5">
<Button StartIcon={ArrowLeft} color="minimal" onClick={() => setOpenSidebar(false)} />
<p className="-ml-2">{t("availability_settings")}</p>
<Dialog>
<DialogTrigger asChild>
<Button
StartIcon={Trash}
variant="icon"
color="destructive"
aria-label={t("delete")}
className="ml-16 inline"
disabled={schedule?.isLastSchedule}
tooltip={schedule?.isLastSchedule ? t("requires_at_least_one_schedule") : t("delete")}
/>
</DialogTrigger>
<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
as={Label}
htmlFor="hiddenSwitch"
@@ -267,9 +290,44 @@ export default function Availability() {
}}
/>
</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" />
<Button
className="ml-4 lg:ml-0"
@@ -278,6 +336,13 @@ export default function Availability() {
loading={updateMutation.isLoading}>
{t("save")}
</Button>
<Button
className="ml-3 sm:hidden"
StartIcon={MoreVertical}
variant="icon"
color="secondary"
onClick={() => setOpenSidebar(true)}
/>
</div>
}>
<div className="mt-4 w-full md:mt-0">
@@ -313,7 +378,7 @@ export default function Availability() {
{schedule?.workingHours && <DateOverride workingHours={schedule.workingHours} />}
</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>
<Skeleton as={Label} htmlFor="timeZone" className="mb-0 inline-block leading-none">
@@ -335,7 +400,7 @@ export default function Availability() {
/>
</div>
<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">
{t("something_doesnt_look_right")}
</Skeleton>
+3 -7
View File
@@ -183,12 +183,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
created: true,
}))
);
showToast(
t("event_type_updated_successfully", {
eventTypeTitle: eventType.title,
}),
"success"
);
showToast(t("event_type_updated_successfully"), "success");
},
async onSettled() {
await utils.viewer.eventTypes.get.invalidate();
@@ -448,7 +443,8 @@ const EventTypePage = (props: EventTypeSetupProps) => {
isUpdateMutationLoading={updateMutation.isLoading}
formMethods={formMethods}
disableBorder={tabName === "apps" || tabName === "workflows" || tabName === "webhooks"}
currentUserMembership={currentUserMembership}>
currentUserMembership={currentUserMembership}
isUserOrganizationAdmin={props.isUserOrganizationAdmin}>
<Form
form={formMethods}
id="event-type-form"
+35 -20
View File
@@ -52,10 +52,9 @@ import {
Skeleton,
Switch,
Tooltip,
ArrowButton,
} from "@calcom/ui";
import {
ArrowDown,
ArrowUp,
Clipboard,
Code,
Copy,
@@ -378,19 +377,11 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
<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">
{!(firstItem && firstItem.id === type.id) && (
<button
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>
<ArrowButton onClick={() => moveEventType(index, -1)} arrowDirection="up" />
)}
{!(lastItem && lastItem.id === type.id) && (
<button
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>
<ArrowButton onClick={() => moveEventType(index, 1)} arrowDirection="down" />
)}
<MemoizedItem type={type} group={group} readOnly={readOnly} />
<div className="mt-4 hidden sm:mt-0 sm:flex">
@@ -411,7 +402,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
)}
/>
)}
{isManagedEventType && (
{isManagedEventType && type.children && (
<AvatarGroup
className="relative right-3 top-1"
size="sm"
@@ -671,6 +662,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
title={t(`delete${isManagedEventPrefix()}_event_type`)}
confirmBtnText={t(`confirm_delete_event_type`)}
loadingText={t(`confirm_delete_event_type`)}
isLoading={deleteMutation.isLoading}
onConfirm={(e) => {
e.preventDefault();
deleteEventTypeHandler(deleteDialogTypeId);
@@ -830,6 +822,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 = ({
status,
errorMessage,
@@ -863,7 +874,7 @@ const Main = ({
<MobileTeamsTab eventTypeGroups={data.eventTypeGroups} />
) : (
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
profile={group.profile}
membershipCount={group.metadata.membershipCount}
@@ -871,12 +882,16 @@ const Main = ({
orgSlug={orgBranding?.slug}
/>
<EventTypeList
types={group.eventTypes}
group={group}
groupIndex={index}
readOnly={group.metadata.readOnly}
/>
{group.eventTypes.length ? (
<EventTypeList
types={group.eventTypes}
group={group}
groupIndex={index}
readOnly={group.metadata.readOnly}
/>
) : (
<EmptyEventTypeList group={group} />
)}
</div>
))
)}
@@ -406,7 +406,7 @@ const ProfileForm = ({
name="avatar"
render={({ field: { value } }) => (
<>
<Avatar alt="" imageSrc={value} gravatarFallbackMd5="fallback" size="lg" />
<Avatar alt="" imageSrc={value} size="lg" />
<div className="ms-4">
<ImageUploader
target="avatar"
+25 -16
View File
@@ -13,10 +13,12 @@ import { isSAMLLoginEnabled } from "@calcom/features/ee/sso/lib/saml";
import { useFlagMap } from "@calcom/features/flags/context/provider";
import { getFeatureFlagMap } from "@calcom/features/flags/server/utils";
import { IS_SELF_HOSTED, WEBAPP_URL } from "@calcom/lib/constants";
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import slugify from "@calcom/lib/slugify";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { signupSchema as apiSignupSchema } from "@calcom/prisma/zod-utils";
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
import { Alert, Button, EmailField, HeadSeo, PasswordField, TextField } from "@calcom/ui";
@@ -25,14 +27,7 @@ import PageWrapper from "@components/PageWrapper";
import { IS_GOOGLE_LOGIN_ENABLED } from "../server/lib/constants";
import { ssrInit } from "../server/lib/ssr";
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(),
const signupSchema = apiSignupSchema.extend({
apiError: z.string().optional(), // Needed to display API errors doesnt get passed to the API
});
@@ -40,12 +35,30 @@ type FormValues = z.infer<typeof signupSchema>;
type SignupProps = inferSSRProps<typeof getServerSideProps>;
const getSafeCallbackUrl = (url: string | null) => {
if (!url) return null;
let callbackUrl = url;
if (/"\//.test(callbackUrl)) callbackUrl = callbackUrl.substring(1);
// If not absolute URL, make it absolute
if (!/^https?:\/\//.test(callbackUrl)) {
callbackUrl = `${WEBAPP_URL}/${callbackUrl}`;
}
const safeCallbackUrl = getSafeRedirectUrl(callbackUrl);
return safeCallbackUrl;
};
export default function Signup({ prepopulateFormValues, token, orgSlug }: SignupProps) {
const searchParams = useSearchParams();
const telemetry = useTelemetry();
const { t, i18n } = useLocale();
const flags = useFlagMap();
const methods = useForm<FormValues>({
mode: "onChange",
resolver: zodResolver(signupSchema),
defaultValues: prepopulateFormValues,
});
@@ -60,6 +73,7 @@ export default function Signup({ prepopulateFormValues, token, orgSlug }: Signup
throw new Error(err.message);
}
};
const callbackUrl = getSafeCallbackUrl(searchParams.get("callbackUrl"));
const signUp: SubmitHandler<FormValues> = async (data) => {
await fetch("/api/auth/signup", {
@@ -77,13 +91,10 @@ export default function Signup({ prepopulateFormValues, token, orgSlug }: Signup
.then(async () => {
telemetry.event(telemetryEventTypes.signup, collectPageParameters());
const verifyOrGettingStarted = flags["email-verification"] ? "auth/verify-email" : "getting-started";
await signIn<"credentials">("credentials", {
...data,
callbackUrl: `${
searchParams?.get("callbackUrl")
? `${WEBAPP_URL}/${searchParams.get("callbackUrl")}`
: `${WEBAPP_URL}/${verifyOrGettingStarted}`
}?from=signup`,
callbackUrl: `${callbackUrl ? callbackUrl : `${WEBAPP_URL}/${verifyOrGettingStarted}`}?from=signup`,
});
})
.catch((err) => {
@@ -162,9 +173,7 @@ export default function Signup({ prepopulateFormValues, token, orgSlug }: Signup
className="w-full justify-center"
onClick={() =>
signIn("Cal.com", {
callbackUrl: searchParams?.get("callbackUrl")
? `${WEBAPP_URL}/${searchParams.get("callbackUrl")}`
: `${WEBAPP_URL}/getting-started`,
callbackUrl: callbackUrl ? callbackUrl : `${WEBAPP_URL}/getting-started`,
})
}>
{t("login_instead")}
+17
View File
@@ -1,9 +1,11 @@
import type { GetServerSidePropsContext } from "next";
import { getLayout } from "@calcom/features/MainLayout";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { TeamsListing } from "@calcom/features/ee/teams/components";
import { ShellMain } from "@calcom/features/shell/Shell";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Button } from "@calcom/ui";
@@ -41,6 +43,21 @@ function Teams() {
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const ssr = await ssrInit(context);
await ssr.viewer.me.prefetch();
const session = await getServerSession({ req: context.req, res: context.res });
const token = context.query?.token;
const resolvedUrl = context.resolvedUrl;
const callbackUrl = token ? getSafeRedirectUrl(`${WEBAPP_URL}${resolvedUrl}`) : null;
if (!session) {
return {
redirect: {
destination: callbackUrl ? `/auth/login?callbackUrl=${callbackUrl}&teamInvite=true` : "/auth/login",
permanent: false,
},
props: {},
};
}
return { props: { trpcState: ssr.dehydrate() } };
};
+1 -1
View File
@@ -25,7 +25,7 @@ const otherNonExistingRoutePrefixes = ["forms", "router", "success", "cancel"];
let subdomainRegExp = (exports.subdomainRegExp = getSubdomainRegExp(
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);
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();
additionalGuests.forEach(async (email) => {
const promises = additionalGuests.map(async (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 }) => {
@@ -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();
});
});
+1 -1
View File
@@ -64,7 +64,7 @@ test.describe("Booking with Seats", () => {
await page.waitForSelector('[data-testid="event-types"]');
const eventTitle = "My 2-seated event";
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 }) => {
@@ -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 { createEmbedsFixture, createGetActionFiredDetails } from "../fixtures/embeds";
import { createPaymentsFixture } from "../fixtures/payments";
import { createRoutingFormsFixture } from "../fixtures/routingForms";
import { createServersFixture } from "../fixtures/servers";
import { createUsersFixture } from "../fixtures/users";
@@ -23,6 +24,7 @@ export interface Fixtures {
servers: ReturnType<typeof createServersFixture>;
prisma: typeof prisma;
emails?: API;
routingForms: ReturnType<typeof createRoutingFormsFixture>;
}
declare global {
@@ -71,6 +73,9 @@ export const test = base.extend<Fixtures>({
prisma: async ({}, use) => {
await use(prisma);
},
routingForms: async ({}, use) => {
await use(createRoutingFormsFixture());
},
emails: async ({}, use) => {
if (IS_MAILHOG_ENABLED) {
const mailhogAPI = mailhog();
+21
View File
@@ -9,6 +9,8 @@ import { test } from "./lib/fixtures";
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 doubling of tests failing on logout & logout, we can group them.
test.describe("2FA Tests", async () => {
@@ -45,6 +47,8 @@ test.describe("2FA Tests", async () => {
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 expect(page.locator(`[data-testid=two-factor-switch]`).isChecked()).toBeTruthy();
@@ -103,6 +107,23 @@ test.describe("2FA Tests", async () => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
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();
return user;
+114 -34
View File
@@ -1,3 +1,4 @@
import type { Page } from "@playwright/test";
import { expect } from "@playwright/test";
import { test } from "./lib/fixtures";
@@ -9,6 +10,9 @@ import {
gotoRoutingLink,
} 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.describe("BOOKING_CREATED", async () => {
@@ -55,8 +59,6 @@ test.describe("BOOKING_CREATED", async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
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.payload.startTime = dynamic;
body.payload.endTime = dynamic;
@@ -187,8 +189,6 @@ test.describe("BOOKING_REJECTED", async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-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.payload.startTime = dynamic;
body.payload.endTime = dynamic;
@@ -246,7 +246,7 @@ test.describe("BOOKING_REJECTED", async () => {
},
],
location: "[redacted/dynamic]",
destinationCalendar: null,
destinationCalendar: [],
// hideCalendarNotes: false,
requiresConfirmation: "[redacted/dynamic]",
eventTypeId: "[redacted/dynamic]",
@@ -311,8 +311,6 @@ test.describe("BOOKING_REQUESTED", async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-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.payload.startTime = dynamic;
body.payload.endTime = dynamic;
@@ -391,54 +389,136 @@ test.describe("BOOKING_REQUESTED", async () => {
});
test.describe("FORM_SUBMITTED", async () => {
test("can submit a form and get a submission event", async ({ page, users }) => {
test("on submitting user form, triggers user webhook", async ({ page, users, routingForms }) => {
const webhookReceiver = createHttpServer();
const user = await users.create();
const user = await users.create(null, {
hasTeam: true,
});
await user.apiLogin();
await page.goto("/settings/teams/new");
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();
await page.waitForURL(/\/settings\/teams\/(\d+)\/profile$/i);
await page.waitForLoadState("networkidle");
await page.waitForLoadState("networkidle");
await page.goto(`/settings/developer/webhooks/new`);
// Add webhook
await page.fill('[name="subscriberUrl"]', webhookReceiver.url);
await page.fill('[name="secret"]', "secret");
await Promise.all([page.click("[type=submit]"), page.goForward()]);
await page.click("[type=submit]");
// Page contains the url
expect(page.locator(`text='${webhookReceiver.url}'`)).toBeDefined();
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 formId = new URL(url).pathname.split("/").at(-1);
const form = await routingForms.create({
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"]');
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: 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();
});
});
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

@@ -1074,7 +1074,7 @@
"event_cancelled": "تم إلغاء هذا الحدث",
"emailed_information_about_cancelled_event": "لقد أرسلنا إليك وإلى الحاضرين الآخرين بريداً إلكترونياً لإعلامهم.",
"this_input_will_shown_booking_this_event": "سيُعرَض هذا المُدخَل عند حجز هذا الحدث",
"meeting_url_in_conformation_email": "عنوان رابط الاجتماع موجود في رسالة التأكيد الإلكترونية",
"meeting_url_in_confirmation_email": "عنوان رابط الاجتماع موجود في رسالة التأكيد الإلكترونية",
"url_start_with_https": "يجب أن يبدأ العنوان بـ http:// أو https://",
"number_provided": "سيتم توفير رقم الهاتف",
"before_event_trigger": "قبل بدء الحدث",
@@ -1075,7 +1075,7 @@
"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.",
"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://",
"number_provided": "Bude uvedeno telefonní číslo",
"before_event_trigger": "před zahájením události",
@@ -960,7 +960,7 @@
"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.",
"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://",
"number_provided": "Telefonnummer vil blive angivet",
"before_event_trigger": "før begivenheden starter",
@@ -225,6 +225,7 @@
"create_account": "Konto erstellen",
"confirm_password": "Passwort bestätigen",
"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.",
"email_change": "Melden Sie sich mit Ihrer neuen E-Mail-Adresse und Ihrem Passwort wieder an.",
"create_your_account": "Erstellen Sie Ihr Konto",
@@ -255,6 +256,7 @@
"available_apps": "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>",
"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.",
"finish": "Fertig",
"organization_general_description": "Einstellungen für die Sprache und Zeitzone Ihres Teams verwalten",
@@ -527,7 +529,7 @@
"location": "Ort",
"address": "Adresse",
"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",
"no": "Nein",
"additional_notes": "Zusätzliche Notizen",
@@ -537,7 +539,7 @@
"booking_confirmation": "Bestätigen Sie {{eventTypeTitle}} mit {{profileName}}",
"booking_reschedule_confirmation": "Planen Sie Ihr {{eventTypeTitle}} mit {{profileName}} um",
"in_person_meeting": "Vor-Ort-Termin",
"in_person": "Persönlich (Organisator-Adresse)",
"in_person": "Vor Ort (Organisator-Adresse)",
"link_meeting": "Termin verknüpfen",
"phone_number": "Telefonnummer",
"attendee_phone_number": "Telefonnummer",
@@ -559,6 +561,7 @@
"leave": "Verlassen",
"profile": "Profil",
"my_team_url": "Meine Team-URL",
"my_teams": "Meine Teams",
"team_name": "Teamname",
"your_team_name": "Ihr Teamname",
"team_updated_successfully": "Team erfolgreich aktualisiert",
@@ -1092,7 +1095,7 @@
"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.",
"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",
"number_provided": "Telefonnummer wird angegeben",
"before_event_trigger": "vor Beginn des Termins",
@@ -1974,6 +1977,8 @@
"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.",
"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:",
"org_admin_other_teams": "Weitere Teams",
"no_other_teams_found": "Keine weiteren Teams gefunden",
+22 -4
View File
@@ -45,6 +45,7 @@
"invite_team_individual_segment": "Invite individual",
"invite_team_bulk_segment": "Bulk import",
"invite_team_notifcation_badge": "Inv.",
"signin_or_signup_to_accept_invite": "You need to Sign in or Sign up to see team invitation.",
"your_event_has_been_scheduled": "Your event has been scheduled",
"your_event_has_been_scheduled_recurring": "Your recurring event has been scheduled",
"accept_our_license": "Accept our license by changing the .env variable <1>NEXT_PUBLIC_LICENSE_CONSENT</1> to '{{agree}}'.",
@@ -266,6 +267,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.",
"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",
"availability_settings": "Availability Settings",
"continue_without_calendar": "Continue without calendar",
"connect_your_calendar": "Connect your calendar",
"connect_your_video_app": "Connect your video apps",
@@ -678,8 +680,8 @@
"new_event_type_btn": "New 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.",
"event_type_created_successfully": "{{eventTypeTitle}} event type created successfully",
"event_type_updated_successfully": "{{eventTypeTitle}} event type updated successfully",
"event_type_created_successfully": "Event type created successfully",
"event_type_updated_successfully": "Event type updated successfully",
"event_type_deleted_successfully": "Event type deleted successfully",
"hours": "Hours",
"people": "People",
@@ -1052,12 +1054,15 @@
"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.",
"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_description": "Generate an API key to use with {{appName}} at",
"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.",
"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>Log into your Make account and create a new Scenario.</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>",
"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",
"calendar_url": "Calendar URL",
"apple_server_generate_password": "Generate an app specific password to use with {{appName}} at",
@@ -1088,6 +1093,7 @@
"or_lowercase": "or",
"nevermind": "Nevermind",
"go_to": "Go to: ",
"invite_link_make": "<li>Go to<a>Make Invite Link</a> and install Cal.com app</li>",
"zapier_invite_link": "Zapier Invite Link",
"meeting_url_provided_after_confirmed": "A Meeting URL will be created once the event is confirmed.",
"dynamically_display_attendee_or_organizer": "Dynamically display the name of your attendee for you, or your name if it's viewed by your attendee",
@@ -1097,7 +1103,7 @@
"event_cancelled": "This event is canceled",
"emailed_information_about_cancelled_event": "We emailed you and the other attendees to let them know.",
"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://",
"number_provided": "Phone number will be provided",
"before_event_trigger": "before event starts",
@@ -1107,6 +1113,7 @@
"email_attendee_action": "send email to attendees",
"sms_attendee_action": "Send SMS to attendee",
"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_attendee_action": "send WhatsApp message to attendee",
"workflows": "Workflows",
@@ -2010,6 +2017,13 @@
"member_removed": "Member removed",
"my_availability": "My 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",
"hi_admin": "Hi Administrator",
"admin_org_notification_email_title": "An organization requires DNS setup",
@@ -2025,6 +2039,10 @@
"mark_dns_configured": "Mark as DNS configured",
"value": "Value",
"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",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -1075,7 +1075,7 @@
"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.",
"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://",
"number_provided": "Se proporcionará el número de teléfono",
"before_event_trigger": "antes del inicio del evento",
+31 -5
View File
@@ -45,6 +45,7 @@
"invite_team_individual_segment": "Inviter une personne",
"invite_team_bulk_segment": "Importation multiple",
"invite_team_notifcation_badge": "Inv.",
"signin_or_signup_to_accept_invite": "Vous devez vous connecter ou vous inscrire pour voir l'invitation d'équipe.",
"your_event_has_been_scheduled": "Votre événement a été planifié",
"your_event_has_been_scheduled_recurring": "Votre événement récurrent a été planifié",
"accept_our_license": "Acceptez notre licence en changeant la variable .env <1>NEXT_PUBLIC_LICENSE_CONSENT</1> en « {{agree}} ».",
@@ -266,6 +267,7 @@
"nearly_there_instructions": "Pour finir, une brève description de vous et une photo vous aideront vraiment à obtenir des réservations et à faire savoir aux gens avec qui ils prennent rendez-vous.",
"set_availability_instructions": "Définissez des plages de temps pendant lesquelles vous êtes disponible de manière récurrente. Vous pourrez en créer d'autres ultérieurement et les assigner à différents calendriers.",
"set_availability": "Définissez vos disponibilités",
"availability_settings": "Paramètres de disponibilité",
"continue_without_calendar": "Continuer sans calendrier",
"connect_your_calendar": "Connectez votre calendrier",
"connect_your_video_app": "Connectez vos applications vidéo",
@@ -678,8 +680,8 @@
"new_event_type_btn": "Nouveau type d'événement",
"new_event_type_heading": "Créez votre premier type d'événement",
"new_event_type_description": "Les types d'événements vous permettent de partager des liens qui affichent vos disponibilités sur votre calendrier et permettent aux personnes de réserver des créneaux.",
"event_type_created_successfully": "Type d'événement {{eventTypeTitle}} créé avec succès",
"event_type_updated_successfully": "Type d'événement {{eventTypeTitle}} mis à jour avec succès",
"event_type_created_successfully": "Type d'événement créé avec succès",
"event_type_updated_successfully": "Type d'événement mis à jour avec succès",
"event_type_deleted_successfully": "Type d'événement supprimé avec succès",
"hours": "heures",
"people": "Personnes",
@@ -1052,12 +1054,15 @@
"how_you_want_add_cal_site": "Comment souhaitez-vous ajouter {{appName}} à votre site ?",
"choose_ways_put_cal_site": "Choisissez l'une des méthodes suivantes pour mettre {{appName}} sur votre site.",
"setting_up_zapier": "Configuration de votre intégration Zapier",
"setting_up_make": "Configuration de votre intégration Make",
"generate_api_key": "Générer une clé API",
"generate_api_key_description": "Générer une clé API à utiliser avec {{appName}} sur",
"your_unique_api_key": "Votre clé API unique",
"copy_safe_api_key": "Copiez cette clé API et conservez-la dans un endroit sûr. Si vous perdez cette clé, vous devrez en générer une nouvelle.",
"zapier_setup_instructions": "<0>Connectez-vous à votre compte Zapier et créez un nouveau Zap.</0><1>Sélectionnez Cal.com comme application Trigger. Choisissez également un événement Trigger.</1><2>Choisissez votre compte, puis saisissez votre clé API unique.</2><3>Testez votre Trigger.</3><4>Vous êtes prêt !</4>",
"zapier_setup_instructions": "<0>Connectez-vous à votre compte Zapier et créez un nouveau Zap.</0><1>Sélectionnez Cal.com comme application déclencheur. Choisissez également un événement déclencheur.</1><2>Choisissez votre compte, puis saisissez votre clé API unique.</2><3>Testez votre déclencheur.</3><4>Vous êtes prêt !</4>",
"make_setup_instructions": "<0>Connectez-vous à votre compte Make et créez un nouveau Scénario.</0><1>Sélectionnez Cal.com comme application déclencheur. Choisissez également un événement déclencheur.</1><2>Choisissez votre compte, puis saisissez votre clé API unique.</2><3>Testez votre déclencheur.</3><4>Vous êtes prêt !</4>",
"install_zapier_app": "Veuillez d'abord installer l'application Zapier dans l'App Store.",
"install_make_app": "Veuillez d'abord installer l'application Make dans l'App Store.",
"connect_apple_server": "Se connecter au serveur d'Apple",
"calendar_url": "Lien du calendrier",
"apple_server_generate_password": "Générez un mot de passe pour application à utiliser avec {{appName}} sur",
@@ -1088,6 +1093,7 @@
"or_lowercase": "ou",
"nevermind": "Annuler",
"go_to": "Aller à : ",
"invite_link_make": "<li>Accédez<a>au lien d'invitation Make</a> et installez l'application Cal.com</li>",
"zapier_invite_link": "Lien d'invitation Zapier",
"meeting_url_provided_after_confirmed": "Un lien de rendez-vous sera créé une fois l'événement confirmé.",
"dynamically_display_attendee_or_organizer": "Affiche dynamiquement le nom du participant pour vous ou votre nom s'il est vu par le participant",
@@ -1097,7 +1103,7 @@
"event_cancelled": "Cet événement est annulé",
"emailed_information_about_cancelled_event": "Nous vous avons envoyé un e-mail à vous et aux autres participants pour les en informer.",
"this_input_will_shown_booking_this_event": "Ce champ sera affiché lors de la réservation de cet événement.",
"meeting_url_in_conformation_email": "Le lien du rendez-vous est dans l'e-mail de confirmation",
"meeting_url_in_confirmation_email": "Le lien du rendez-vous est dans l'e-mail de confirmation",
"url_start_with_https": "Le lien doit commencer par http:// ou https://",
"number_provided": "Un numéro de téléphone sera fourni",
"before_event_trigger": "avant le début de l'événement",
@@ -1107,6 +1113,7 @@
"email_attendee_action": "envoyer un e-mail aux participants",
"sms_attendee_action": "Envoyer un SMS au participant",
"sms_number_action": "envoyer un SMS à un numéro spécifique",
"send_reminder_sms": "Envoyez facilement des rappels de rendez-vous par SMS à vos participants",
"whatsapp_number_action": "envoyer un message WhatsApp à un numéro spécifique",
"whatsapp_attendee_action": "envoyer un message WhatsApp au participant",
"workflows": "Workflows",
@@ -1114,7 +1121,7 @@
"add_new_workflow": "Ajouter un nouveau workflow",
"reschedule_event_trigger": "lorsque l'événement est replanifié",
"trigger": "Déclencheur",
"triggers": "Déclencher",
"triggers": "Déclencheurs",
"action": "Action",
"workflows_to_automate_notifications": "Créez des workflows pour automatiser les notifications et les rappels.",
"workflow_name": "Nom du workflow",
@@ -1988,6 +1995,8 @@
"remove_users_from_org_confirm": "Voulez-vous vraiment supprimer {{userCount}} utilisateurs de cette organisation ?",
"user_has_no_schedules": "Cet utilisateur n'a pas encore configuré de planning",
"user_isnt_in_any_teams": "Cet utilisateur n'est dans aucune équipe",
"requires_booker_email_verification": "Nécessite la vérification par e-mail du participant",
"description_requires_booker_email_verification": "Pour assurer la vérification par e-mail du participant avant la planification des événements.",
"requires_confirmation_mandatory": "Les messages ne peuvent être envoyés aux participants que lorsque le type d'événement nécessite une confirmation.",
"kyc_verification_information": "Pour garantir la sécurité, vous devez vérifier votre {{teamOrAccount}} avant d'envoyer des messages aux participants. Veuillez nous contacter à <a>{{supportEmail}}</a> et fournir les informations suivantes :",
"kyc_verification_documents": "<ul><li>Votre {{teamOrUser}}</li><li>Pour les entreprises : Joignez votre document de vérification d'entreprise</li><li>Pour les particuliers : Joignez une pièce d'identité</li></ul>",
@@ -2001,16 +2010,28 @@
"no_other_teams_found_description": "Il n'y a pas d'autres équipes dans cette organisation.",
"attendee_first_name_variable": "Prénom du participant",
"attendee_last_name_variable": "Nom du participant",
"attendee_first_name_info": "Le prénom du participant",
"attendee_last_name_info": "Le nom de famille du participant",
"me": "Moi",
"verify_team_tooltip": "Vérifiez votre équipe pour activer l'envoi de messages aux participants",
"member_removed": "Membre supprimé",
"my_availability": "Mes disponibilités",
"team_availability": "Disponibilités de l'équipe",
"backup_code": "Code de récupération",
"backup_codes": "Codes de récupération",
"backup_code_instructions": "Chaque code de récupération peut être utilisé une seule fois pour accorder l'accès sans votre authentificateur.",
"backup_codes_copied": "Codes de récupération copiés !",
"incorrect_backup_code": "Le code de récupération est incorrect.",
"lost_access": "Accès perdu",
"missing_backup_codes": "Aucun code de récupération trouvé. Merci de les générer dans vos paramètres.",
"admin_org_notification_email_subject": "Nouvelle organisation créée : action en attente",
"hi_admin": "Bonjour administrateur",
"admin_org_notification_email_title": "Une organisation nécessite une configuration DNS",
"admin_org_notification_email_body_part1": "Une organisation avec le slug « {{orgSlug}} » a été créée.<br /><br />Assurez-vous de configurer votre registre DNS pour pointer le sous-domaine correspondant à la nouvelle organisation où l'application principale est en cours d'exécution. Autrement, l'organisation ne fonctionnera pas.<br /><br />Voici les options de base pour configurer un sous-domaine pour qu'il pointe vers son application afin qu'il charge la page de profil de l'organisation.<br /><br />Vous pouvez le faire soit avec l'enregistrement A :",
"admin_org_notification_email_body_part2": "Ou l'enregistrement CNAME :",
"admin_org_notification_email_body_part3": "Une fois le sous-domaine configuré, veuillez marquer la configuration DNS comme terminée dans les paramètres d'administration des organisations.",
"admin_org_notification_email_cta": "Accéder aux paramètres d'administration des organisations",
"org_has_been_processed": "L'organisation a été traitée",
"org_error_processing": "Une erreur s'est produite lors du traitement de cette organisation",
"orgs_page_description": "Une liste de toutes les organisations. Accepter une organisation permettra à tous les utilisateurs avec ce domaine de messagerie de s'inscrire SANS vérification d'e-mail.",
"unverified": "Non vérifié",
@@ -2018,5 +2039,10 @@
"mark_dns_configured": "Marquer comme DNS configuré",
"value": "Valeur",
"your_organization_updated_sucessfully": "Votre organisation a été mise à jour avec succès",
"team_no_event_types": "Cette équipe n'a aucun type d'événement",
"seat_options_doesnt_multiple_durations": "L'option par place ne prend pas en charge les durées multiples",
"include_calendar_event": "Inclure l'événement du calendrier",
"recently_added": "Ajouté récemment",
"no_members_found": "Aucun membre trouvé",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Ajoutez vos nouvelles chaînes ci-dessus ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -1075,7 +1075,7 @@
"event_cancelled": "האירוע בוטל",
"emailed_information_about_cancelled_event": "שלחנו לך ולשאר המשתתפים הודעת דוא״ל כדי ליידע אותם על כך.",
"this_input_will_shown_booking_this_event": "הקלט יוצג בעת הזמנת אירוע זה",
"meeting_url_in_conformation_email": "כתובת ה-URL של הפגישה נמצאת בהודעת הדוא״ל שנשלחה לאישור הפגישה",
"meeting_url_in_confirmation_email": "כתובת ה-URL של הפגישה נמצאת בהודעת הדוא״ל שנשלחה לאישור הפגישה",
"url_start_with_https": "כתובת ה-URL צריכה להתחיל עם הקידומת http://‎ או https://‎",
"number_provided": "מספר הטלפון יסופק",
"before_event_trigger": "לפני שהאירוע מתחיל",
@@ -1075,7 +1075,7 @@
"event_cancelled": "Questo evento è stato cancellato",
"emailed_information_about_cancelled_event": "Abbiamo inviato un'e-mail di notifica a te e agli altri partecipanti.",
"this_input_will_shown_booking_this_event": "Questo input verrà mostrato durante la prenotazione di questo evento",
"meeting_url_in_conformation_email": "L'URL della riunione è riportato nell'e-mail di conferma",
"meeting_url_in_confirmation_email": "L'URL della riunione è riportato nell'e-mail di conferma",
"url_start_with_https": "L'URL deve iniziare con http:// o https://",
"number_provided": "Verrà fornito il numero di telefono",
"before_event_trigger": "prima dell'inizio dell'evento",
@@ -1075,7 +1075,7 @@
"event_cancelled": "このイベントはキャンセルされました",
"emailed_information_about_cancelled_event": "あなたと他の出席者に電子メールでお知らせしました。",
"this_input_will_shown_booking_this_event": "この入力は、このイベントを予約する際に表示されます",
"meeting_url_in_conformation_email": "ミーティングURLは確認メールに記載されています",
"meeting_url_in_confirmation_email": "ミーティングURLは確認メールに記載されています",
"url_start_with_https": "URLは、http://またはhttps://で始まる必要があります",
"number_provided": "電話番号が提供されます",
"before_event_trigger": "イベント開始前に",
@@ -1075,7 +1075,7 @@
"event_cancelled": "이 이벤트는 취소되었습니다",
"emailed_information_about_cancelled_event": "귀하와 다른 참석자들에게 이메일로 알려 드렸습니다.",
"this_input_will_shown_booking_this_event": "이 입력은 이 이벤트를 예약할 때 표시됩니다.",
"meeting_url_in_conformation_email": "회의 URL은 확인 이메일에 있습니다",
"meeting_url_in_confirmation_email": "회의 URL은 확인 이메일에 있습니다",
"url_start_with_https": "URL은 http:// 또는 https://로 시작해야 합니다",
"number_provided": "전화 번호는 제공됩니다",
"before_event_trigger": "이벤트 시작 전",
@@ -1079,7 +1079,7 @@
"event_cancelled": "Deze gebeurtenis is geannuleerd",
"emailed_information_about_cancelled_event": "We hebben u en de andere deelnemers een e-mail gestuurd om hen te informeren.",
"this_input_will_shown_booking_this_event": "Deze invoer wordt getoond tijdens het boeken van dit evenement",
"meeting_url_in_conformation_email": "De vergader-URL wordt vermeld in de bevestigings-e-mail",
"meeting_url_in_confirmation_email": "De vergader-URL wordt vermeld in de bevestigings-e-mail",
"url_start_with_https": "URL moet beginnen met http:// of https://",
"number_provided": "Telefoonnummer wordt verstrekt",
"before_event_trigger": "voordat de gebeurtenis begint",
@@ -941,7 +941,7 @@
"reschedule_placeholder": "Fortell andre hvorfor du må endre tidspunktet",
"emailed_information_about_cancelled_event": "Vi sendte deg og de andre deltakerne en e-post for å informere.",
"this_input_will_shown_booking_this_event": "Dette inputfeltet vil vises når man booker denne hendelsen",
"meeting_url_in_conformation_email": "Møte-url ligger i e-postbekreftelsen",
"meeting_url_in_confirmation_email": "Møte-url ligger i e-postbekreftelsen",
"url_start_with_https": "URL må begynne med http:// eller https://",
"number_provided": "Telefonnummer vil bli oppgitt",
"before_event_trigger": "før hendelsen starter",
@@ -1075,7 +1075,7 @@
"event_cancelled": "To wydarzenie zostało anulowane",
"emailed_information_about_cancelled_event": "Wysłaliśmy wiadomość e-mail Tobie i innym uczestnikom, aby ich o tym poinformować.",
"this_input_will_shown_booking_this_event": "To wejście zostanie wyświetlone podczas rezerwacji tego wydarzenia",
"meeting_url_in_conformation_email": "Adres URL spotkania znajduje się w wiadomości e-mail z potwierdzeniem",
"meeting_url_in_confirmation_email": "Adres URL spotkania znajduje się w wiadomości e-mail z potwierdzeniem",
"url_start_with_https": "Adres URL musi zaczynać się od http:// lub https://",
"number_provided": "Numer telefonu zostanie podany.",
"before_event_trigger": "przed rozpoczęciem wydarzenia",
@@ -1091,7 +1091,7 @@
"event_cancelled": "Este evento foi cancelado",
"emailed_information_about_cancelled_event": "Avisamos por e-mail aos outros participantes e a você.",
"this_input_will_shown_booking_this_event": "Este campo será mostrado na reserva deste evento",
"meeting_url_in_conformation_email": "O URL da reunião está no e-mail de confirmação",
"meeting_url_in_confirmation_email": "O URL da reunião está no e-mail de confirmação",
"url_start_with_https": "O URL precisa começar com http:// ou https://",
"number_provided": "O número de telefone será fornecido",
"before_event_trigger": "antes do início do evento",
@@ -1075,7 +1075,7 @@
"event_cancelled": "Este evento foi cancelado",
"emailed_information_about_cancelled_event": "Enviámos um email com o aviso para si e para os restantes participantes.",
"this_input_will_shown_booking_this_event": "Este campo será mostrado na reserva deste evento",
"meeting_url_in_conformation_email": "O URL da reunião está no email de confirmação",
"meeting_url_in_confirmation_email": "O URL da reunião está no email de confirmação",
"url_start_with_https": "O URL tem de começar por http:// ou https://",
"number_provided": "Será fornecido um número de telefone",
"before_event_trigger": "antes do evento iniciar",
@@ -1075,7 +1075,7 @@
"event_cancelled": "Acest eveniment este anulat",
"emailed_information_about_cancelled_event": "V-am trimis un e-mail de informare dvs. și celorlalți participanți.",
"this_input_will_shown_booking_this_event": "Datele introduse vor fi afișate la rezervarea acestui eveniment",
"meeting_url_in_conformation_email": "Adresa URL a întâlnirii este în e-mailul de confirmare",
"meeting_url_in_confirmation_email": "Adresa URL a întâlnirii este în e-mailul de confirmare",
"url_start_with_https": "Adresa URL trebuie să înceapă cu http:// or https://",
"number_provided": "Numărul de telefon va fi furnizat",
"before_event_trigger": "înainte de începerea evenimentului",
@@ -1075,7 +1075,7 @@
"event_cancelled": "Это событие отменено",
"emailed_information_about_cancelled_event": "Мы сообщили об этом вам и другим участникам по электронной почте.",
"this_input_will_shown_booking_this_event": "Этот вопрос будет показан при бронировании события по этому шаблону",
"meeting_url_in_conformation_email": "Ссылка на встречу находится в письме с подтверждением",
"meeting_url_in_confirmation_email": "Ссылка на встречу находится в письме с подтверждением",
"url_start_with_https": "URL-адрес должен начинаться с http:// или https://",
"number_provided": "Будет указан номер телефона",
"before_event_trigger": "до начала события",
@@ -1076,7 +1076,7 @@
"event_cancelled": "Ovaj događaj je otkazan",
"emailed_information_about_cancelled_event": "Poslali smo imejl vama i drugim polaznicima kao obaveštenje.",
"this_input_will_shown_booking_this_event": "Ovaj unos će biti prikazan kad se rezerviše ovaj događaj",
"meeting_url_in_conformation_email": "Url za sastanak je u imejlu za potvrdu",
"meeting_url_in_confirmation_email": "Url za sastanak je u imejlu za potvrdu",
"url_start_with_https": "Potrebno je URL da počinje sa http:// or https://",
"number_provided": "Broj telefona će biti dostavljen",
"before_event_trigger": "pre početka događaja",
@@ -1075,7 +1075,7 @@
"event_cancelled": "Den här händelsen är inställd",
"emailed_information_about_cancelled_event": "Vi mejlade dig och de andra deltagarna för att informera om detta.",
"this_input_will_shown_booking_this_event": "Detta visas vid bokning av denna händelse",
"meeting_url_in_conformation_email": "Mötets webbadress finns i bekräftelsemejlet",
"meeting_url_in_confirmation_email": "Mötets webbadress finns i bekräftelsemejlet",
"url_start_with_https": "Webbadress behöver inledas med http:// eller https://",
"number_provided": "Telefonnummer kommer att anges",
"before_event_trigger": "innan händelsen startar",
@@ -1075,7 +1075,7 @@
"event_cancelled": "Bu etkinlik iptal edildi",
"emailed_information_about_cancelled_event": "Sizi ve diğer katılımcıları bilgilendirmek için bir e-posta gönderdik.",
"this_input_will_shown_booking_this_event": "Bu girdi, bu etkinlik için rezervasyon yapılırken gösterilecek",
"meeting_url_in_conformation_email": "Toplantı URL'si onay e-postasında mevcut",
"meeting_url_in_confirmation_email": "Toplantı URL'si onay e-postasında mevcut",
"url_start_with_https": "URL'nin http:// veya https:// ile başlaması gerekiyor",
"number_provided": "Telefon numarası sağlanacaktır",
"before_event_trigger": "etkinlik başlamadan önce",
@@ -1075,7 +1075,7 @@
"event_cancelled": "Цей захід скасовано",
"emailed_information_about_cancelled_event": "Ми сповістили вас та інших учасників електронною поштою.",
"this_input_will_shown_booking_this_event": "Це поле введення буде показано під час бронювання часу для цього заходу",
"meeting_url_in_conformation_email": "URL-адресу наради вказано в листі з підтвердженням",
"meeting_url_in_confirmation_email": "URL-адресу наради вказано в листі з підтвердженням",
"url_start_with_https": "URL-адреса має починатися з http:// або https://",
"number_provided": "Номер телефону буде надано",
"before_event_trigger": "до початку заходу",
@@ -1075,7 +1075,7 @@
"event_cancelled": "Sự kiện này bị huỷ",
"emailed_information_about_cancelled_event": "Chúng tôi đã gửi email cho bạn và những người tham dự khác để cho họ biết.",
"this_input_will_shown_booking_this_event": "Trường này sẽ được hiển thị khi đặt sự kiện này",
"meeting_url_in_conformation_email": "Url cuộc họp nằm trong email xác nhận",
"meeting_url_in_confirmation_email": "Url cuộc họp nằm trong email xác nhận",
"url_start_with_https": "URL cần bắt đầu bằng http:// hoặc https://",
"number_provided": "Số điện thoại sẽ được cung ứng",
"before_event_trigger": "trước khi sự kiện bắt đầu",
@@ -1079,7 +1079,7 @@
"event_cancelled": "此活动已取消",
"emailed_information_about_cancelled_event": "我们已向您和其他参与者发送电子邮件来通知他们。",
"this_input_will_shown_booking_this_event": "在预约此活动时将显示该输入框",
"meeting_url_in_conformation_email": "会议链接含在确认电子邮件中",
"meeting_url_in_confirmation_email": "会议链接含在确认电子邮件中",
"url_start_with_https": "链接需要以 http:// 或 https:// 开头",
"number_provided": "将提供电话号码",
"before_event_trigger": "活动开始前",
@@ -1944,5 +1944,7 @@
"insights_user_filter": "用户:{{userName}}",
"insights_subtitle": "查看您活动的预约 insights",
"custom_plan": "自定义计划",
"include_calendar_event": "包括日历活动",
"recently_added": "最近添加",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ 在此上方添加您的新字符串 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -1075,7 +1075,7 @@
"event_cancelled": "此活動已取消",
"emailed_information_about_cancelled_event": "我們已給您和其他與會者發送通知電子郵件。",
"this_input_will_shown_booking_this_event": "輸入欄會在預約此活動的時候顯示",
"meeting_url_in_conformation_email": "會議網址請參見確認電子郵件",
"meeting_url_in_confirmation_email": "會議網址請參見確認電子郵件",
"url_start_with_https": "URL 需要以 http:// 或 https:// 開頭",
"number_provided": "將提供電話號碼",
"before_event_trigger": "活動開始前",
@@ -1854,7 +1854,7 @@
"payment_app_commission": "需付款 ({{paymentFeePercentage}}% + {{fee, currency}} 每筆交易佣金)",
"email_invite_team": "已邀請 {{email}}",
"email_invite_team_bulk": "已邀請 {{userCount}} 位使用者",
"error_collecting_card": "卡片發生錯誤",
"error_collecting_card": "收集卡片時發生錯誤",
"image_size_limit_exceed": "上傳的圖片大小不得超過 5MB 限制",
"unauthorized_workflow_error_message": "{{errorCode}}:您未獲得授權,無法啟用或停用此工作流程",
"inline_embed": "內嵌式嵌入",
+15 -17
View File
@@ -58,10 +58,10 @@
--cal-bg-inverted: #f3f4f6;
/* background -> components*/
--cal-bg-info: #dee9fc;
--cal-bg-success: #e2fbe8;
--cal-bg-attention: #fceed8;
--cal-bg-error: #f9e3e2;
--cal-bg-info: #263fa9;
--cal-bg-success: #306339;
--cal-bg-attention: #8e3b1f;
--cal-bg-error: #8c2822;
--cal-bg-dark-error: #752522;
/* Borders */
@@ -80,10 +80,10 @@
--cal-text-inverted: #101010;
/* Content/Text -> components */
--cal-text-info: #253985;
--cal-text-success: #285231;
--cal-text-attention: #73321b;
--cal-text-error: #752522;
--cal-text-info: #dee9fc;
--cal-text-success: #e2fbe8;
--cal-text-attention: #fceed8;
--cal-text-error: #f9e3e2;
/* Brand shenanigans
-> These will be computed for the users theme at runtime.
@@ -207,16 +207,14 @@ html.todesktop nav a svg{
https://github.com/tailwindlabs/tailwindcss/pull/5732
*/
@layer utilities {
@layer responsive {
/* Chrome, Safari and Opera */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
/* Chrome, Safari and Opera */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
.no-scrollbar {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
}

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