Compare commits

..
95 Commits
Author SHA1 Message Date
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
Alex van Andel a810a538e9 v3.2.5
release.yml / release (push) Canceled after 0s
2023-08-25 17:23:22 +01:00
sean-brydon 1cd0d7b5ca use team id :/ (#10957) 2023-08-25 16:14:28 +00:00
Alex van Andel 4540c79580 v3.2.4
release.yml / release (push) Canceled after 0s
2023-08-25 15:32:55 +01:00
Udit Takkar 00d01882d4 fix: private team regression (#10914)
Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
2023-08-25 14:43:48 +01:00
Omar López 6ce4122528 fix: allows patching booking and period limits from API (#10935) 2023-08-25 14:29:04 +01:00
7ddbcfcdcf feat: Adds filter-by-email on /users/_get endpoint for admin calls (#10928)
* adds filter by email on users get for admins

* swagger update

* Update apps/api/pages/api/users/_get.ts

Co-authored-by: Omar López <zomars@me.com>

* .parse is required, also fixed some other issues

---------

Co-authored-by: Omar López <zomars@me.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2023-08-25 14:15:13 +01:00
b1b01ed383 remove close button from TopBanner (#10705)
Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Shivam Kalra <shivamkalra98@gmail.com>
2023-08-25 15:50:39 +03:00
6f5d9dc41f test: Create unit tests for react components in packages/ui/components/form/select (#10459)
Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2023-08-25 12:18:53 +00:00
GitStart-Cal.comandgitstart-calcom ac190b89dd chore: add UnpublishedEntity in storybook (CALCOM-10760) (#10864)
Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
2023-08-25 15:12:19 +03:00
990441295c chore: add ImageUploader in storybook (CALCOM-10760) (#10865)
Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2023-08-25 15:06:55 +03:00
1e4c20073c chore: fix all @typescript-eslint/no-non-null-assertion warnings (#10635)
Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2023-08-25 14:56:20 +03:00
80353d67a0 chore: fix all @typescript-eslint/no-unused-vars warnings (#10645)
Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2023-08-25 11:48:24 +00:00
Keith Williams d55b4b051a perf: Fix slowest db query (again) (#10954) 2023-08-25 14:27:37 +03:00
Hariom Balhara 25d90e3e12 test: Upcoming bookings sorting order (#10950)
* Fix order by for bookings array
Fix orderBy for bookings

* Test

* Text change

* Add comments
2023-08-25 12:55:41 +03:00
Hariom Balhara 1727eb5625 fix: Unordered bookings (#10943) 2023-08-25 15:06:57 +05:30
sean-brydon 58f4224c66 Fix invite flow (#10951) 2023-08-25 09:19:03 +00:00
Udit Takkar 5d491e0a12 fix: unauthorized after email change (#10821) 2023-08-24 16:47:34 -07:00
alannncandOmar López 1eef5a56c3 fix: include orgs slug in getUserByUsername just like [user]/[type] page (#10723)
Co-authored-by: Omar López <zomars@me.com>
2023-08-24 16:41:06 -07:00
d6740503be feat: Column View Available Slots Fix (#9815)
Co-authored-by: Vinoth Kumar V <vinoth_kumar_v@Vinoths-MacBook-Pro.local>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: alannnc <alannnc@gmail.com>
Co-authored-by: Shivam Kalra <shivamkalra98@gmail.com>
Co-authored-by: Omar López <zomars@me.com>
2023-08-24 16:39:45 -07:00
David Oksman 9f647ead46 fix: missing timezones in dropdown list (#10823) 2023-08-24 13:57:11 -07:00
Thibault Mathian f480afcace fix: caching and fetching of feature flags (#10837) 2023-08-24 13:43:52 -07:00
Hariom Balhara 8d73d15b8a perf: Lazy load framer motion stuff as much as we can (#10876) 2023-08-24 13:40:01 -07:00
Hariom Balhara e8370ef950 perf: Remove non booker apps from booker page (#10905) 2023-08-24 13:38:15 -07:00
Hariom Balhara e859c8dc58 test: Add attendee phone number test (#10932) 2023-08-24 11:21:49 -07:00
Keith Williams b4b38b9bc3 v3.2.3
release.yml / release (push) Canceled after 0s
2023-08-24 17:16:02 +03:00
Crowdin Bot d32719ad8b New Crowdin translations by Github Action 2023-08-24 12:47:12 +00:00
Hariom Balhara 99d0816d9f phoneSchema missing async parsing (#10929) 2023-08-24 12:43:52 +00:00
95f4b89fe8 fix: #10783 (UI updated on API Creation Form ) (#10786)
* Fix #10783

* Update the changes #10783

* Updated the changes on the another dialog footer

* Updated the changes

---------

Co-authored-by: Hem Pun <hemthegentlmen@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2023-08-24 11:20:48 +01:00
Abhijeet SinghandAbhijeet Singh 3e63c2b717 fix: Italics not being shown in the description after saving (#10848)
* added ignore rule for em tag in turndown

* added ignore rule for em tag in turndown

---------

Co-authored-by: Abhijeet Singh <asing9829@gmail.com>
2023-08-24 11:20:00 +01:00
Omar López 42cd56578d fix: Reduced boilerplate for publicViewer router (#10849) 2023-08-24 11:19:24 +01:00
Ujwal KumarandBailey Pumfleet 31d4cb4eb7 Transform # character to - in slug (#10896)
Co-authored-by: Bailey Pumfleet <bailey@pumfleet.co.uk>
2023-08-24 11:19:10 +01:00
sean-brydonandBailey Pumfleet fde3e8bea9 Invalidate and add success message (#10907)
Co-authored-by: Bailey Pumfleet <bailey@pumfleet.co.uk>
2023-08-24 11:18:59 +01:00
GitStart-Cal.comandgitstart-calcom 874ad29676 chore: add Table in storybook (CALCOM-10760) (#10869)
Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
2023-08-24 11:07:57 +01:00
Hariom Balhara 5503d9d432 perf: bookings query improvement (#10687) 2023-08-24 11:14:10 +02:00
Keith WilliamsandShivam Kalra 43b3d68447 chore: Don't render certain components when running meticulous tests (#10772)
* chore: Don't render credits when running meticulous tests

* fix: type window Meticulous

* fix: window reference error

* chore: window interface

* chore: disable tzdialog popup on meticulous

* chore: IS_VISUAL_REGRESSION_TESTING

* fix: constants

* fix: only run on preview builds

---------

Co-authored-by: Shivam Kalra <shivamkalra98@gmail.com>
2023-08-24 13:42:53 +05:30
GitStart-Cal.comandgitstart-calcom e3c747a867 test: Create unit tests for react components is packages/ui/components/navigation (#10698)
Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
2023-08-24 13:23:54 +05:30
sean-brydonandOmar López e020a2c9a4 feat: Availability Slider enabled for teams (#10902)
Co-authored-by: Omar López <zomars@me.com>
2023-08-23 21:23:24 +00:00
Leo Giovanetti e6e6f09547 feat: admin org list now shows all, dns check added (#10875)
* chore: admin org list now shows all, dns check added

* Updating email template

* type fixes
2023-08-23 21:01:12 +00:00
Leo GiovanettiandJoe Au-Yeung 68ec946c59 fix: add org avatar to org booking pages [CAL-2333] (#10847)
* Add org avatar to org booking pages

* Using web app url for org avatars + missing i18n string

---------

Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
2023-08-23 20:10:02 +00:00
368 changed files with 11454 additions and 4406 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
+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.
+15
View File
@@ -0,0 +1,15 @@
const withBundleAnalyzer = require("@next/bundle-analyzer");
const plugins = [];
plugins.push(withBundleAnalyzer({ enabled: process.env.ANALYZE === "true" }));
/** @type {import("next").NextConfig} */
const nextConfig = {
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": "0.1.0",
"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 }
);
}
};
+151
View File
@@ -0,0 +1,151 @@
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,
credentials: {
select: {
appId: true,
key: true,
},
},
},
where: { email: envelope.from },
});
if (!signature || !user?.email || !user?.id) {
await sendEmail({
subject: `Re: ${body.subject}`,
text: "Sorry, you are not authorized to use this service. Please verify your email address and try again.",
to: user?.email || "",
from: aiEmail,
});
return new NextResponse();
}
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,
dateTo: now,
}),
]);
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 { timeZone, 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,
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,111 @@
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",
});
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;
+65
View File
@@ -0,0 +1,65 @@
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",
});
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;
+84
View File
@@ -0,0 +1,84 @@
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;
+84
View File
@@ -0,0 +1,84 @@
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",
});
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.
Current UTC time is: ${now}
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;
+1
View File
@@ -0,0 +1 @@
export default new Date().toISOString();
+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"]
}
+7 -1
View File
@@ -33,12 +33,14 @@ export const schemaEventTypeBaseBodyParams = EventType.pick({
position: true,
eventName: true,
timeZone: true,
schedulingType: true,
// START Limit future bookings
periodType: true,
periodStartDate: true,
schedulingType: true,
periodEndDate: true,
periodDays: true,
periodCountCalendarDays: true,
// END Limit future bookings
requiresConfirmation: true,
disableGuests: true,
hideCalendarNotes: true,
@@ -51,6 +53,8 @@ export const schemaEventTypeBaseBodyParams = EventType.pick({
slotInterval: true,
successRedirectUrl: true,
locations: true,
bookingLimits: true,
durationLimits: true,
})
.merge(z.object({ hosts: z.array(hostSchema).optional().default([]) }))
.partial()
@@ -126,6 +130,8 @@ export const schemaEventTypeReadPublic = EventType.pick({
seatsPerTimeSlot: true,
seatsShowAttendees: true,
bookingFields: true,
bookingLimits: true,
durationLimits: true,
}).merge(
z.object({
locations: z
+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 */
@@ -0,0 +1,20 @@
import { withValidation } from "next-validations";
import { z } from "zod";
import { baseApiParams } from "./baseApiParams";
// Extracted out as utility function so can be reused
// at different endpoints that require this validation.
export const schemaQueryUserEmail = baseApiParams.extend({
email: z.string().email(),
});
export const schemaQuerySingleOrMultipleUserEmails = z.object({
email: z.union([z.string().email(), z.array(z.string().email())]),
});
export const withValidQueryUserEmail = withValidation({
schema: schemaQueryUserEmail,
type: "Zod",
mode: "query",
});
+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,4 +1,4 @@
import type { Prisma } from "@prisma/client";
import { Prisma } from "@prisma/client";
import type { NextApiRequest } from "next";
import type { z } from "zod";
@@ -202,10 +202,17 @@ import checkTeamEventEditPermission from "../_utils/checkTeamEventEditPermission
export async function patchHandler(req: NextApiRequest) {
const { prisma, query, body } = req;
const { id } = schemaQueryIdParseInt.parse(query);
const { hosts = [], ...parsedBody } = schemaEventTypeEditBodyParams.parse(body);
const {
hosts = [],
bookingLimits,
durationLimits,
...parsedBody
} = schemaEventTypeEditBodyParams.parse(body);
const data: Prisma.EventTypeUpdateArgs["data"] = {
...parsedBody,
bookingLimits: bookingLimits === null ? Prisma.DbNull : bookingLimits,
durationLimits: durationLimits === null ? Prisma.DbNull : durationLimits,
};
if (hosts) {
+9 -2
View File
@@ -1,4 +1,4 @@
import type { Prisma } from "@prisma/client";
import { Prisma } from "@prisma/client";
import type { NextApiRequest } from "next";
import { HttpError } from "@calcom/lib/http-error";
@@ -259,12 +259,19 @@ import ensureOnlyMembersAsHosts from "./_utils/ensureOnlyMembersAsHosts";
async function postHandler(req: NextApiRequest) {
const { userId, isAdmin, prisma, body } = req;
const { hosts = [], ...parsedBody } = schemaEventTypeCreateBodyParams.parse(body || {});
const {
hosts = [],
bookingLimits,
durationLimits,
...parsedBody
} = schemaEventTypeCreateBodyParams.parse(body || {});
let data: Prisma.EventTypeCreateArgs["data"] = {
...parsedBody,
userId,
users: { connect: { id: userId } },
bookingLimits: bookingLimits === null ? Prisma.DbNull : bookingLimits,
durationLimits: durationLimits === null ? Prisma.DbNull : durationLimits,
};
await checkPermissions(req);
+20
View File
@@ -4,6 +4,7 @@ import type { NextApiRequest } from "next";
import { defaultResponder } from "@calcom/lib/server";
import { withMiddleware } from "~/lib/helpers/withMiddleware";
import { schemaQuerySingleOrMultipleUserEmails } from "~/lib/validations/shared/queryUserEmail";
import { schemaUsersReadPublic } from "~/lib/validations/user";
/**
@@ -19,6 +20,17 @@ import { schemaUsersReadPublic } from "~/lib/validations/user";
* schema:
* type: string
* description: Your API key
* - in: query
* name: email
* required: false
* schema:
* type: array
* items:
* type: string
* format: email
* style: form
* explode: true
* description: The email address or an array of email addresses to filter by
* tags:
* - users
* responses:
@@ -39,6 +51,14 @@ export async function getHandler(req: NextApiRequest) {
const where: Prisma.UserWhereInput = {};
// If user is not ADMIN, return only his data.
if (!isAdmin) where.id = userId;
if (req.query.email) {
const validationResult = schemaQuerySingleOrMultipleUserEmails.parse(req.query);
where.email = {
in: Array.isArray(validationResult.email) ? validationResult.email : [validationResult.email],
};
}
const [total, data] = await prisma.$transaction([
prisma.user.count({ where }),
prisma.user.findMany({ where, take, skip }),
+21 -1
View File
@@ -1,9 +1,29 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
const ns = ["common"];
const supportedLngs = ["en", "fr"];
const resources = ns.reduce((acc, n) => {
supportedLngs.forEach((lng) => {
if (!acc[lng]) acc[lng] = {};
acc[lng] = {
...acc[lng],
[n]: require(`../../web/public/static/locales/${lng}/${n}.json`),
};
});
return acc;
}, {});
i18n.use(initReactI18next).init({
resources: [],
debug: true,
fallbackLng: "en",
defaultNS: "common",
ns,
interpolation: {
escapeValue: false,
},
react: { useSuspense: true },
resources,
});
export default i18n;
+8
View File
@@ -21,6 +21,14 @@ export const parameters = {
push() {},
Provider: AppRouterContext.Provider,
},
globals: {
locale: "en",
locales: {
en: "English",
fr: "Français",
},
},
i18n,
};
addDecorator((storyFn) => (
+2 -1
View File
@@ -18,6 +18,7 @@ export function VariantsTable({
const columns = React.Children.toArray(children) as ReactElement<RowProps>[];
return (
<div
id="light-variant"
className={classNames(
isDark &&
"relative py-8 before:absolute before:left-0 before:top-0 before:block before:h-full before:w-screen before:bg-[#1C1C1C]"
@@ -43,7 +44,7 @@ export function VariantsTable({
</table>
</div>
{!isDark && (
<div data-mode="dark" className="dark">
<div id="dark-variant" data-mode="dark" className="dark">
<VariantsTable titles={titles} isDark columnMinWidth={columnMinWidth}>
{children}
</VariantsTable>
+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.
+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"
/>
))}
@@ -336,7 +336,7 @@ function BookingListItem(booking: BookingItemProps) {
</DialogContent>
</Dialog>
<tr className="hover:bg-muted group flex flex-col sm:flex-row">
<tr data-testid="booking-item" className="hover:bg-muted group flex flex-col sm:flex-row">
<td
className="hidden align-top ltr:pl-6 rtl:pr-6 sm:table-cell sm:min-w-[12rem]"
onClick={onClickTableData}>
@@ -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" />
@@ -35,6 +35,7 @@ import {
Tooltip,
} from "@calcom/ui";
import { Copy, Edit } from "@calcom/ui/components/icon";
import { IS_VISUAL_REGRESSION_TESTING } from "@calcom/web/constants";
import RequiresConfirmationController from "./RequiresConfirmationController";
@@ -286,36 +287,38 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
}}>
{/* Textfield has some margin by default we remove that so we can keep consitant aligment */}
<div className="lg:-ml-2">
<TextField
disabled
name="hashedLink"
label={t("private_link_label")}
data-testid="generated-hash-url"
labelSrOnly
type="text"
hint={t("private_link_hint")}
defaultValue={placeholderHashedLink}
addOnSuffix={
<Tooltip content={eventType.hashedLink ? t("copy_to_clipboard") : t("enabled_after_update")}>
<Button
color="minimal"
size="sm"
type="button"
className="hover:stroke-3 hover:text-emphasis min-w-fit !py-0 px-0 hover:bg-transparent"
aria-label="copy link"
onClick={() => {
navigator.clipboard.writeText(placeholderHashedLink);
if (eventType.hashedLink) {
showToast(t("private_link_copied"), "success");
} else {
showToast(t("enabled_after_update_description"), "warning");
}
}}>
<Copy className="h-4 w-4" />
</Button>
</Tooltip>
}
/>
{!IS_VISUAL_REGRESSION_TESTING && (
<TextField
disabled
name="hashedLink"
label={t("private_link_label")}
data-testid="generated-hash-url"
labelSrOnly
type="text"
hint={t("private_link_hint")}
defaultValue={placeholderHashedLink}
addOnSuffix={
<Tooltip content={eventType.hashedLink ? t("copy_to_clipboard") : t("enabled_after_update")}>
<Button
color="minimal"
size="sm"
type="button"
className="hover:stroke-3 hover:text-emphasis min-w-fit !py-0 px-0 hover:bg-transparent"
aria-label="copy link"
onClick={() => {
navigator.clipboard.writeText(placeholderHashedLink);
if (eventType.hashedLink) {
showToast(t("private_link_copied"), "success");
} else {
showToast(t("enabled_after_update_description"), "warning");
}
}}>
<Copy className="h-4 w-4" />
</Button>
</Tooltip>
}
/>
)}
</div>
</SettingsToggle>
<hr className="border-subtle" />
@@ -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,
});
@@ -22,7 +22,7 @@ const SetupAvailability = (props: ISetupAvailabilityProps) => {
const scheduleId = defaultScheduleId === null ? undefined : defaultScheduleId;
const queryAvailability = trpc.viewer.availability.schedule.get.useQuery(
{ scheduleId },
{ scheduleId: defaultScheduleId ?? undefined },
{
enabled: !!scheduleId,
}
@@ -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"
+1
View File
@@ -0,0 +1 @@
export const IS_VISUAL_REGRESSION_TESTING = Boolean(globalThis.window?.Meticulous?.isRunningAsTest);
+36 -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() {
@@ -463,7 +466,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.2",
"version": "3.2.7",
"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
View File
@@ -75,6 +75,7 @@ export default function Custom404() {
)}`
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const isSuccessPage = pathname?.startsWith("/booking");
-1
View File
@@ -53,7 +53,6 @@ export function UserPage(props: InferGetServerSidePropsType<typeof getServerSide
orgSlug: _orgSlug,
...query
} = useRouterQuery();
const nameOrUsername = user.name || user.username || "";
/*
const telemetry = useTelemetry();
+1 -1
View File
@@ -51,7 +51,7 @@ class MyDocument extends Document<Props> {
<meta name="msapplication-TileColor" content="#ff0000" />
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#f9fafb" />
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#1C1C1C" />
{(!IS_PRODUCTION || process.env.VERCEL_ENV === "preview") && (
{!IS_PRODUCTION && process.env.VERCEL_ENV === "preview" && (
// eslint-disable-next-line @next/next/no-sync-scripts
<script
data-project-id="KjpMrKTnXquJVKfeqmjdTffVPf1a6Unw2LZ58iE4"
+7 -27
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();
@@ -39,19 +29,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const username = slugify(data.username);
const userEmail = email.toLowerCase();
const validationResponse = (
incomingEmail: string,
validation: { isValid: boolean; email: string | undefined }
) => {
const { isValid, email } = validation;
if (!isValid) {
const message: string =
email !== incomingEmail ? "Username already taken" : "Email address is already registered";
return res.status(409).json({ message });
}
};
if (!username) {
res.status(422).json({ message: "Invalid username" });
return;
@@ -79,11 +56,15 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}
if (foundToken?.teamId) {
const teamUserValidation = await validateUsernameInTeam(username, userEmail, foundToken?.teamId);
return validationResponse(userEmail, teamUserValidation);
if (!teamUserValidation.isValid) {
return res.status(409).json({ message: "Username or email is already taken" });
}
}
} else {
const userValidation = await validateUsername(username, userEmail);
return validationResponse(userEmail, userValidation);
if (!userValidation.isValid) {
return res.status(409).json({ message: "Username or email is already taken" });
}
}
const hashedPassword = await hashPassword(password);
@@ -94,7 +75,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
id: foundToken.teamId,
},
});
if (team) {
const teamMetadata = teamMetadataSchema.parse(team?.metadata);
@@ -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";
-48
View File
@@ -7,54 +7,6 @@ import { getTranslation } from "@calcom/lib/server/i18n";
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (IS_PRODUCTION) return res.write("Only for development purposes"), res.end();
const t = await getTranslation("en", "common");
const language = { translate: t, locale: "en" };
const evt = {
type: "30min",
title: "30min between Pro Example and pro@example.com",
description: null,
additionalNotes: "asdasdas",
startTime: "2022-06-03T09:00:00-06:00",
endTime: "2022-06-03T09:30:00-06:00",
organizer: {
name: "Pro Example",
email: "pro@example.com",
timeZone: "Europe/London",
language,
},
attendees: [
{
email: "pro@example.com",
name: "pro@example.com",
timeZone: "America/Chihuahua",
language,
},
],
location: "Zoom video",
destinationCalendar: null,
hideCalendarNotes: false,
uid: "xxyPr4cg2xx4XoS2KeMEQy",
metadata: {},
recurringEvent: null,
appsStatus: [
{
appName: "Outlook Calendar",
type: "office365_calendar",
success: 1,
failures: 0,
errors: [],
warnings: [],
},
{
appName: "Google Meet",
type: "conferencing",
success: 0,
failures: 1,
errors: [],
warnings: ["In order to use Google Meet you must set your destination calendar to a Google Calendar"],
},
],
};
res.statusCode = 200;
+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,
},
});
+36 -12
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({
@@ -65,6 +67,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 } = {
@@ -98,15 +101,35 @@ export default function Login({
);
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 +153,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"));
};
@@ -194,7 +218,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>
+13 -16
View File
@@ -2,7 +2,6 @@ import { useAutoAnimate } from "@formkit/auto-animate/react";
import { useRouter, useSearchParams, usePathname } from "next/navigation";
import { useCallback } from "react";
import { useOrgBranding } from "@calcom/ee/organizations/context/provider";
import { getLayout } from "@calcom/features/MainLayout";
import { NewScheduleButton, ScheduleListItem } from "@calcom/features/schedules";
import { ShellMain } from "@calcom/features/shell/Shell";
@@ -135,7 +134,6 @@ export default function AvailabilityPage() {
const searchParams = useSearchParams();
const router = useRouter();
const pathname = usePathname();
const orgBranding = useOrgBranding();
// Get a new searchParams string by merging the current
// searchParams with a provided key/value pair
@@ -156,23 +154,22 @@ export default function AvailabilityPage() {
subtitle={t("configure_availability")}
CTA={
<div className="flex gap-2">
{orgBranding && (
<ToggleGroup
className="hidden md:block"
defaultValue={searchParams?.get("type") ?? "mine"}
onValueChange={(value) => {
router.push(`${pathname}?${createQueryString("type", value)}`);
}}
options={[
{ value: "mine", label: t("my_availability") },
{ value: "team", label: t("team_availability") },
]}
/>
)}
<ToggleGroup
className="hidden md:block"
defaultValue={searchParams?.get("type") ?? "mine"}
onValueChange={(value) => {
if (!value) return;
router.push(`${pathname}?${createQueryString("type", value)}`);
}}
options={[
{ value: "mine", label: t("my_availability") },
{ value: "team", label: t("team_availability") },
]}
/>
<NewScheduleButton />
</div>
}>
{searchParams?.get("type") === "team" && orgBranding ? (
{searchParams?.get("type") === "team" ? (
<AvailabilitySliderTable />
) : (
<WithQuery
+1 -1
View File
@@ -179,7 +179,7 @@ export default function Bookings() {
)}
<div className="pt-2 xl:pt-0">
<div className="border-subtle overflow-hidden rounded-md border">
<table className="w-full max-w-full table-fixed">
<table data-testid={`${status}-bookings`} className="w-full max-w-full table-fixed">
<tbody className="bg-default divide-subtle divide-y" data-testid="bookings">
{query.data.pages.map((page, index) => (
<Fragment key={index}>
+3 -6
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();
@@ -411,6 +406,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
throw new Error(t("seats_and_no_show_fee_error"));
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { availability, ...rest } = input;
updateMutation.mutate({
...rest,
@@ -495,6 +491,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
}
}
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { availability, ...rest } = input;
updateMutation.mutate({
...rest,
+32 -18
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">
@@ -830,6 +821,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,
@@ -871,12 +881,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>
))
)}
@@ -1,9 +1,9 @@
import UnverifiedOrgsPage from "@calcom/features/ee/organizations/pages/settings/admin/unverifiedOrgPage";
import AdminOrgsPage from "@calcom/features/ee/organizations/pages/settings/admin/AdminOrgPage";
import type { CalPageWrapper } from "@components/PageWrapper";
import PageWrapper from "@components/PageWrapper";
const Page = UnverifiedOrgsPage as CalPageWrapper;
const Page = AdminOrgsPage as CalPageWrapper;
Page.PageWrapper = PageWrapper;
export default Page;
+17 -15
View File
@@ -77,26 +77,25 @@ type FormValues = {
const ProfileView = () => {
const { t } = useLocale();
const utils = trpc.useContext();
const { data: _session, update } = useSession();
const { update } = useSession();
const { data: user, isLoading } = trpc.viewer.me.useQuery();
const updateProfileMutation = trpc.viewer.updateProfile.useMutation({
onSuccess: async (res) => {
await update(res);
showToast(t("settings_updated_successfully"), "success");
if (res.signOutUser && tempFormValues) {
if (res.passwordReset) {
showToast(t("password_reset_email", { email: tempFormValues.email }), "success");
// sign out the user to avoid unauthorized access error
await signOut({ callbackUrl: "/auth/logout?passReset=true" });
} else {
// sign out the user to avoid unauthorized access error
await signOut({ callbackUrl: "/auth/logout?emailChange=true" });
}
// signout user only in case of password reset
if (res.signOutUser && tempFormValues && res.passwordReset) {
showToast(t("password_reset_email", { email: tempFormValues.email }), "success");
await signOut({ callbackUrl: "/auth/logout?passReset=true" });
} else {
utils.viewer.me.invalidate();
utils.viewer.avatar.invalidate();
utils.viewer.shouldVerifyEmail.invalidate();
}
utils.viewer.me.invalidate();
utils.viewer.avatar.invalidate();
setConfirmAuthEmailChangeWarningDialogOpen(false);
update(res);
setTempFormValues(null);
},
onError: () => {
@@ -325,7 +324,10 @@ const ProfileView = () => {
{confirmPasswordErrorMessage && <Alert severity="error" title={confirmPasswordErrorMessage} />}
</div>
<DialogFooter showDivider>
<Button color="primary" onClick={(e) => onConfirmPassword(e)}>
<Button
color="primary"
loading={confirmPasswordMutation.isLoading}
onClick={(e) => onConfirmPassword(e)}>
{t("confirm")}
</Button>
<DialogClose />
@@ -345,7 +347,7 @@ const ProfileView = () => {
<DialogFooter>
<Button
color="primary"
disabled={updateProfileMutation.isLoading}
loading={updateProfileMutation.isLoading}
onClick={(e) => onConfirmAuthEmailChange(e)}>
{t("confirm")}
</Button>
+3 -8
View File
@@ -17,6 +17,7 @@ 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 +26,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
});
@@ -46,6 +40,7 @@ export default function Signup({ prepopulateFormValues, token, orgSlug }: Signup
const { t, i18n } = useLocale();
const flags = useFlagMap();
const methods = useForm<FormValues>({
mode: "onChange",
resolver: zodResolver(signupSchema),
defaultValues: prepopulateFormValues,
});
+11 -3
View File
@@ -199,19 +199,27 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain }
<SubTeams />
) : (
<>
{(showMembers.isOn || !team.eventTypes.length) && <Team team={team} />}
{(showMembers.isOn || !team.eventTypes.length) &&
(team.isPrivate ? (
<div className="w-full text-center">
<h2 className="text-emphasis font-semibold">{t("you_cannot_see_team_members")}</h2>
</div>
) : (
<Team team={team} />
))}
{!showMembers.isOn && team.eventTypes.length > 0 && (
<div className="mx-auto max-w-3xl ">
<EventTypes />
{!team.hideBookATeamMember && (
{/* Hide "Book a team member button when team is private or hideBookATeamMember is true" */}
{(!team.hideBookATeamMember || team.isPrivate) && (
<div>
<div className="relative mt-12">
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className="border-subtle w-full border-t" />
</div>
<div className="relative flex justify-center">
<span className="dark:bg-darkgray-50 bg-subtle text-subtle dark:text-inverted px-2 text-sm">
<span className="dark:bg-darkgray-50 bg-subtle text-subtle px-2 text-sm">
{t("or")}
</span>
</div>
+2 -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 }) => {
+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 }) => {
+113
View File
@@ -0,0 +1,113 @@
import { expect } from "@playwright/test";
import { BookingStatus } from "@calcom/prisma/client";
import type { Fixtures } from "./lib/fixtures";
import { test } from "./lib/fixtures";
test.afterEach(({ users }) => users.deleteAll());
test.describe("Bookings", () => {
test.describe("Upcoming bookings", () => {
test("show attendee bookings and organizer bookings in asc order by startDate", async ({
page,
users,
bookings,
}) => {
const firstUser = await users.create();
const secondUser = await users.create();
const bookingWhereFirstUserIsOrganizerFixture = await createBooking({
title: "Booking as organizer",
bookingsFixture: bookings,
// Create a booking 3 days from today
relativeDate: 3,
organizer: firstUser,
organizerEventType: firstUser.eventTypes[0],
attendees: [
{ name: "First", email: "first@cal.com", timeZone: "Europe/Berlin" },
{ name: "Second", email: "second@cal.com", timeZone: "Europe/Berlin" },
{ name: "Third", email: "third@cal.com", timeZone: "Europe/Berlin" },
],
});
const bookingWhereFirstUserIsOrganizer = await bookingWhereFirstUserIsOrganizerFixture.self();
const bookingWhereFirstUserIsAttendeeFixture = await createBooking({
title: "Booking as attendee",
bookingsFixture: bookings,
organizer: secondUser,
// Booking created 2 days from today
relativeDate: 2,
organizerEventType: secondUser.eventTypes[0],
attendees: [
{ name: "OrganizerAsBooker", email: firstUser.email, timeZone: "Europe/Berlin" },
{ name: "Second", email: "second@cal.com", timeZone: "Europe/Berlin" },
{ name: "Third", email: "third@cal.com", timeZone: "Europe/Berlin" },
],
});
const bookingWhereFirstUserIsAttendee = await bookingWhereFirstUserIsAttendeeFixture.self();
await firstUser.apiLogin();
await page.goto(`/bookings/upcoming`);
const upcomingBookings = page.locator('[data-testid="upcoming-bookings"]');
const firstUpcomingBooking = upcomingBookings.locator('[data-testid="booking-item"]').nth(0);
const secondUpcomingBooking = upcomingBookings.locator('[data-testid="booking-item"]').nth(1);
await expect(
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
firstUpcomingBooking.locator(`text=${bookingWhereFirstUserIsAttendee!.title}`)
).toBeVisible();
await expect(
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
secondUpcomingBooking.locator(`text=${bookingWhereFirstUserIsOrganizer!.title}`)
).toBeVisible();
});
});
});
async function createBooking({
bookingsFixture,
organizer,
organizerEventType,
attendees,
/**
* Relative date from today
* 0 means today
* 1 means tomorrow
*/
relativeDate = 0,
durationMins = 30,
title,
}: {
bookingsFixture: Fixtures["bookings"];
organizer: {
id: number;
username: string | null;
};
organizerEventType: {
id: number;
};
attendees: {
name: string;
email: string;
timeZone: string;
}[];
relativeDate?: number;
durationMins?: number;
title: string;
}) {
const DAY_MS = 24 * 60 * 60 * 1000;
const bookingDurationMs = durationMins * 60 * 1000;
const startTime = new Date(Date.now() + relativeDate * DAY_MS);
const endTime = new Date(Date.now() + relativeDate * DAY_MS + bookingDurationMs);
return await bookingsFixture.create(organizer.id, organizer.username, organizerEventType.id, {
title,
status: BookingStatus.ACCEPTED,
startTime,
endTime,
attendees: {
createMany: {
data: [...attendees],
},
},
});
}
+43
View File
@@ -1,3 +1,4 @@
import type { Page } from "@playwright/test";
import { expect } from "@playwright/test";
import { WEBAPP_URL } from "@calcom/lib/constants";
@@ -91,6 +92,7 @@ test.describe("Event Types tests", () => {
expect(formTitle).toBe(firstTitle);
expect(formSlug).toContain(firstSlug);
});
test("edit first event", async ({ page }) => {
const $eventTypes = page.locator("[data-testid=event-types] > li a");
const firstEventTypeElement = $eventTypes.first();
@@ -152,5 +154,46 @@ test.describe("Event Types tests", () => {
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
});
test.describe("Different Locations Tests", () => {
test("can add Attendee Phone Number location and book with it", async ({ page }) => {
await gotoFirstEventType(page);
await selectAttendeePhoneNumber(page);
await saveEventType(page);
await gotoBookingPage(page);
await selectFirstAvailableTimeSlotNextMonth(page);
await page.locator(`[data-fob-field-name="location"] input`).fill("9199999999");
await bookTimeSlot(page);
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
await expect(page.locator("text=+19199999999")).toBeVisible();
});
});
});
});
const selectAttendeePhoneNumber = async (page: Page) => {
const locationOptionText = "Attendee Phone Number";
await page.locator("#location-select").click();
await page.locator(`text=${locationOptionText}`).click();
};
async function gotoFirstEventType(page: Page) {
const $eventTypes = page.locator("[data-testid=event-types] > li a");
const firstEventTypeElement = $eventTypes.first();
await firstEventTypeElement.click();
await page.waitForURL((url) => {
return !!url.pathname.match(/\/event-types\/.+/);
});
}
async function saveEventType(page: Page) {
await page.locator("[data-testid=update-eventtype]").click();
}
async function gotoBookingPage(page: Page) {
const previewLink = await page.locator("[data-testid=preview-button]").getAttribute("href");
await page.goto(previewLink ?? "");
}
+6 -3
View File
@@ -19,9 +19,12 @@ export const createBookingsFixture = (page: Page) => {
username: string | null,
eventTypeId = -1,
{
title = "",
rescheduled = false,
paid = false,
status = "ACCEPTED",
startTime,
endTime,
attendees = {
create: {
email: "attendee@example.com",
@@ -39,9 +42,9 @@ export const createBookingsFixture = (page: Page) => {
const booking = await prisma.booking.create({
data: {
uid: uid,
title: "30min",
startTime: startDate,
endTime: endDateParam || dayjs().add(1, "day").add(30, "minutes").toDate(),
title: title || "30min",
startTime: startTime || startDate,
endTime: endTime || endDateParam || dayjs().add(1, "day").add(30, "minutes").toDate(),
user: {
connect: {
id: userId,
+1 -1
View File
@@ -542,7 +542,7 @@ export async function apiLogin(
.then((json) => json.csrfToken);
const data = {
email: user.email ?? `${user.username}@example.com`,
password: user.password ?? user.username!,
password: user.password ?? user.username,
callbackURL: "http://localhost:3000/",
redirect: "false",
json: "true",
+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;
+1 -1
View File
@@ -246,7 +246,7 @@ test.describe("BOOKING_REJECTED", async () => {
},
],
location: "[redacted/dynamic]",
destinationCalendar: null,
destinationCalendar: [],
// hideCalendarNotes: false,
requiresConfirmation: "[redacted/dynamic]",
eventTypeId: "[redacted/dynamic]",
@@ -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",
@@ -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",
+28 -3
View File
@@ -266,6 +266,7 @@
"nearly_there_instructions": "Last thing, a brief description about you and a photo really helps you get bookings and let people know who they’re booking with.",
"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 +679,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",
@@ -2010,6 +2011,30 @@
"member_removed": "Member removed",
"my_availability": "My Availability",
"team_availability": "Team Availability",
"seat_options_doesnt_multiple_durations": "Seat option doesn't support multiple durations",
"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",
"admin_org_notification_email_body_part1": "An organization with slug \"{{orgSlug}}\" was created.<br /><br />Please be sure to configure your DNS registry to point the subdomain corresponding to the new organization to where the main app is running. Otherwise the organization will not work.<br /><br />Here are just the very basic options to configure a subdomain to point to their app so it loads the organization profile page.<br /><br />You can do it either with the A Record:",
"admin_org_notification_email_body_part2": "Or the CNAME record:",
"admin_org_notification_email_body_part3": "Once you configure the subdomain, please mark the DNS configuration as done in Organizations Admin Settings.",
"admin_org_notification_email_cta": "Go to Organizations Admin Settings",
"org_has_been_processed": "Org has been processed",
"org_error_processing": "There has been an error processing this organization",
"orgs_page_description": "A list of all organizations. Accepting an organization will allow all users with that email domain to sign up WITHOUT email verifciation.",
"unverified": "Unverified",
"dns_missing": "DNS Missing",
"mark_dns_configured": "Mark as DNS configured",
"value": "Value",
"your_organization_updated_sucessfully": "Your organization updated successfully",
"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",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+32 -2
View File
@@ -266,6 +266,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 +679,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",
@@ -1988,6 +1989,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,10 +2004,37 @@
"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é",
"dns_missing": "DNS manquant",
"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",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Ajoutez vos nouvelles chaînes ci-dessus ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -1944,5 +1944,6 @@
"insights_user_filter": "用户:{{userName}}",
"insights_subtitle": "查看您活动的预约 insights",
"custom_plan": "自定义计划",
"include_calendar_event": "包括日历活动",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ 在此上方添加您的新字符串 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -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": "內嵌式嵌入",
+8 -8
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.
+129 -97
View File
@@ -269,12 +269,14 @@ describe("getSchedule", () => {
createBookingScenario(scenarioData);
const scheduleForDayWithAGoogleCalendarBooking = await getSchedule({
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${plus1DateString}T18:30:00.000Z`,
endTime: `${plus2DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: false,
input: {
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${plus1DateString}T18:30:00.000Z`,
endTime: `${plus2DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: false,
},
});
// As per Google Calendar Availability, only 4PM(4-4:45PM) GMT slot would be available
@@ -353,13 +355,15 @@ describe("getSchedule", () => {
// Day Plus 2 is completely free - It only has non accepted bookings
const scheduleOnCompletelyFreeDay = await getSchedule({
eventTypeId: 1,
// EventTypeSlug doesn't matter for non-dynamic events
eventTypeSlug: "",
startTime: `${plus1DateString}T18:30:00.000Z`,
endTime: `${plus2DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: false,
input: {
eventTypeId: 1,
// EventTypeSlug doesn't matter for non-dynamic events
eventTypeSlug: "",
startTime: `${plus1DateString}T18:30:00.000Z`,
endTime: `${plus2DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: false,
}
});
// getSchedule returns timeslots in GMT
@@ -384,12 +388,14 @@ describe("getSchedule", () => {
// Day plus 3
const scheduleForDayWithOneBooking = await getSchedule({
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${plus2DateString}T18:30:00.000Z`,
endTime: `${plus3DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: false,
input: {
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${plus2DateString}T18:30:00.000Z`,
endTime: `${plus3DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: false,
}
});
expect(scheduleForDayWithOneBooking).toHaveTimeSlots(
@@ -448,12 +454,14 @@ describe("getSchedule", () => {
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
const scheduleForEventWith30Length = await getSchedule({
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${plus1DateString}T18:30:00.000Z`,
endTime: `${plus2DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: false,
input: {
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${plus1DateString}T18:30:00.000Z`,
endTime: `${plus2DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: false,
}
});
expect(scheduleForEventWith30Length).toHaveTimeSlots(
@@ -482,12 +490,14 @@ describe("getSchedule", () => {
);
const scheduleForEventWith30minsLengthAndSlotInterval2hrs = await getSchedule({
eventTypeId: 2,
eventTypeSlug: "",
startTime: `${plus1DateString}T18:30:00.000Z`,
endTime: `${plus2DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: false,
input: {
eventTypeId: 2,
eventTypeSlug: "",
startTime: `${plus1DateString}T18:30:00.000Z`,
endTime: `${plus2DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: false,
}
});
// `slotInterval` takes precedence over `length`
// 4:30 is utc so it is 10:00 in IST
@@ -545,12 +555,14 @@ describe("getSchedule", () => {
const { dateString: todayDateString } = getDate();
const { dateString: minus1DateString } = getDate({ dateIncrement: -1 });
const scheduleForEventWithBookingNotice13Hrs = await getSchedule({
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${minus1DateString}T18:30:00.000Z`,
endTime: `${todayDateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: false,
input: {
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${minus1DateString}T18:30:00.000Z`,
endTime: `${todayDateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: false,
}
});
expect(scheduleForEventWithBookingNotice13Hrs).toHaveTimeSlots(
[
@@ -564,12 +576,14 @@ describe("getSchedule", () => {
);
const scheduleForEventWithBookingNotice10Hrs = await getSchedule({
eventTypeId: 2,
eventTypeSlug: "",
startTime: `${minus1DateString}T18:30:00.000Z`,
endTime: `${todayDateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: false,
input: {
eventTypeId: 2,
eventTypeSlug: "",
startTime: `${minus1DateString}T18:30:00.000Z`,
endTime: `${todayDateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: false,
}
});
expect(scheduleForEventWithBookingNotice10Hrs).toHaveTimeSlots(
[
@@ -627,12 +641,14 @@ describe("getSchedule", () => {
createBookingScenario(scenarioData);
const scheduleForEventOnADayWithNonCalBooking = await getSchedule({
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${plus2DateString}T18:30:00.000Z`,
endTime: `${plus3DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: false,
input: {
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${plus2DateString}T18:30:00.000Z`,
endTime: `${plus3DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: false,
}
});
expect(scheduleForEventOnADayWithNonCalBooking).toHaveTimeSlots(
@@ -700,12 +716,14 @@ describe("getSchedule", () => {
createBookingScenario(scenarioData);
const scheduleForEventOnADayWithCalBooking = await getSchedule({
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${plus1DateString}T18:30:00.000Z`,
endTime: `${plus2DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: false,
input: {
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${plus1DateString}T18:30:00.000Z`,
endTime: `${plus2DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: false,
}
});
expect(scheduleForEventOnADayWithCalBooking).toHaveTimeSlots(
@@ -757,12 +775,14 @@ describe("getSchedule", () => {
createBookingScenario(scenarioData);
const schedule = await getSchedule({
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${plus1DateString}T18:30:00.000Z`,
endTime: `${plus2DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: false,
input: {
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${plus1DateString}T18:30:00.000Z`,
endTime: `${plus2DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: false,
}
});
expect(schedule).toHaveTimeSlots(
@@ -820,12 +840,14 @@ describe("getSchedule", () => {
createBookingScenario(scenarioData);
const scheduleForEventOnADayWithDateOverride = await getSchedule({
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${plus1DateString}T18:30:00.000Z`,
endTime: `${plus2DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: false,
input: {
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${plus1DateString}T18:30:00.000Z`,
endTime: `${plus2DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: false,
}
});
expect(scheduleForEventOnADayWithDateOverride).toHaveTimeSlots(
@@ -905,12 +927,14 @@ describe("getSchedule", () => {
// Requesting this user's availability for their
// individual Event Type
const thisUserAvailability = await getSchedule({
eventTypeId: 2,
eventTypeSlug: "",
startTime: `${plus1DateString}T18:30:00.000Z`,
endTime: `${plus2DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: false,
input: {
eventTypeId: 2,
eventTypeSlug: "",
startTime: `${plus1DateString}T18:30:00.000Z`,
endTime: `${plus2DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: false,
}
});
expect(thisUserAvailability).toHaveTimeSlots(
@@ -1002,12 +1026,14 @@ describe("getSchedule", () => {
});
const scheduleForTeamEventOnADayWithNoBooking = await getSchedule({
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${todayDateString}T18:30:00.000Z`,
endTime: `${plus1DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: true,
input: {
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${todayDateString}T18:30:00.000Z`,
endTime: `${plus1DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: true,
}
});
expect(scheduleForTeamEventOnADayWithNoBooking).toHaveTimeSlots(
@@ -1030,12 +1056,14 @@ describe("getSchedule", () => {
);
const scheduleForTeamEventOnADayWithOneBookingForEachUser = await getSchedule({
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${plus1DateString}T18:30:00.000Z`,
endTime: `${plus2DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: true,
input: {
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${plus1DateString}T18:30:00.000Z`,
endTime: `${plus2DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: true,
}
});
// A user with blocked time in another event, still affects Team Event availability
@@ -1137,12 +1165,14 @@ describe("getSchedule", () => {
hosts: [],
});
const scheduleForTeamEventOnADayWithOneBookingForEachUserButOnDifferentTimeslots = await getSchedule({
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${plus1DateString}T18:30:00.000Z`,
endTime: `${plus2DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: true,
input: {
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${plus1DateString}T18:30:00.000Z`,
endTime: `${plus2DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: true,
}
});
// A user with blocked time in another event, still affects Team Event availability
expect(scheduleForTeamEventOnADayWithOneBookingForEachUserButOnDifferentTimeslots).toHaveTimeSlots(
@@ -1163,12 +1193,14 @@ describe("getSchedule", () => {
);
const scheduleForTeamEventOnADayWithOneBookingForEachUserOnSameTimeSlot = await getSchedule({
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${plus2DateString}T18:30:00.000Z`,
endTime: `${plus3DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: true,
input: {
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${plus2DateString}T18:30:00.000Z`,
endTime: `${plus3DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: true,
}
});
// A user with blocked time in another event, still affects Team Event availability
expect(scheduleForTeamEventOnADayWithOneBookingForEachUserOnSameTimeSlot).toHaveTimeSlots(
@@ -303,6 +303,7 @@ describe("handleChildrenEventTypes", () => {
timeZone: _timeZone,
parentId: _parentId,
userId: _userId,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
requiresBookerEmailVerification,
...evType
} = mockFindFirstEventType({
+8
View File
@@ -0,0 +1,8 @@
{
"builds": [
{
"src": "package.json",
"use": "https://profile-next-cold-starts.vercel.app/builder.tgz"
}
]
}
+1
View File
@@ -30,6 +30,7 @@
"db-studio": "yarn prisma studio",
"deploy": "turbo run deploy",
"dev:all": "turbo run dev --scope=\"@calcom/web\" --scope=\"@calcom/website\" --scope=\"@calcom/console\"",
"dev:ai": "turbo run dev --scope=\"@calcom/web\" --scope=\"@calcom/api\" --scope=\"@calcom/ai\"",
"dev:api": "turbo run dev --scope=\"@calcom/web\" --scope=\"@calcom/api\"",
"dev:api:console": "turbo run dev --scope=\"@calcom/web\" --scope=\"@calcom/api\" --scope=\"@calcom/console\"",
"dev:console": "turbo run dev --scope=\"@calcom/web\" --scope=\"@calcom/console\"",
+44 -6
View File
@@ -40,6 +40,7 @@ type App = Partial<AppMeta> & {
function generateFiles() {
const browserOutput = [`import dynamic from "next/dynamic"`];
const metadataOutput = [];
const bookerMetadataOutput = [];
const schemasOutput = [];
const appKeysSchemasOutput = [];
const serverOutput = [];
@@ -70,22 +71,30 @@ function generateFiles() {
}
});
function forEachAppDir(callback: (arg: App) => void) {
function forEachAppDir(callback: (arg: App) => void, filter: (arg: App) => boolean = () => true) {
for (let i = 0; i < appDirs.length; i++) {
const configPath = path.join(APP_STORE_PATH, appDirs[i].path, "config.json");
const metadataPath = path.join(APP_STORE_PATH, appDirs[i].path, "_metadata.ts");
let app;
if (fs.existsSync(configPath)) {
app = JSON.parse(fs.readFileSync(configPath).toString());
} else if (fs.existsSync(metadataPath)) {
// eslint-disable-next-line @typescript-eslint/no-var-requires
app = require(metadataPath).metadata;
} else {
app = {};
}
callback({
const finalApp = {
...app,
name: appDirs[i].name,
path: appDirs[i].path,
});
};
if (filter(finalApp)) {
callback(finalApp);
}
}
}
@@ -130,7 +139,8 @@ function generateFiles() {
lazyImport?: boolean;
importConfig: ImportConfig;
entryObjectKeyGetter?: (arg: App, importName?: string) => string;
}
},
filter?: (arg: App) => boolean
) {
const output: string[] = [];
@@ -174,7 +184,7 @@ function generateFiles() {
}
}
}
});
}, filter);
}
function createExportObject() {
@@ -201,7 +211,7 @@ function generateFiles() {
}
}
}
});
}, filter);
output.push(`};`);
}
@@ -247,6 +257,25 @@ function generateFiles() {
})
);
bookerMetadataOutput.push(
...getExportedObject(
"appStoreMetadata",
{
// Try looking for config.json and if it's not found use _metadata.ts to generate appStoreMetadata
importConfig: [
{
fileToBeImported: "config.json",
importName: "default",
},
{
fileToBeImported: "_metadata.ts",
importName: "metadata",
},
],
},
isBookerApp
)
);
schemasOutput.push(
...getExportedObject("appDataSchemas", {
// Import path must have / even for windows and not \
@@ -312,6 +341,7 @@ function generateFiles() {
["apps.browser.generated.tsx", browserOutput],
["apps.schemas.generated.ts", schemasOutput],
["apps.keys-schemas.generated.ts", appKeysSchemasOutput],
["bookerApps.metadata.generated.ts", bookerMetadataOutput],
];
filesToGenerate.forEach(([fileName, output]) => {
fs.writeFileSync(`${APP_STORE_PATH}/${fileName}`, formatOutput(`${banner}${output.join("\n")}`));
@@ -347,3 +377,11 @@ if (isInWatchMode) {
} else {
generateFiles();
}
function isBookerApp(app: App) {
// Right now there are only two types of Apps that booker needs.
// Note that currently payment apps' meta don't need to be accessed on booker. We just access from DB eventType.metadata
// 1. It is a location app(e.g. any Conferencing App)
// 2. It is a tag manager app(e.g. Google Analytics, GTM, Fathom)
return !!(app.appData?.location || app.appData?.tag);
}
+1 -1
View File
@@ -1,7 +1,7 @@
import Script from "next/script";
import { getEventTypeAppData } from "@calcom/app-store/_utils/getEventTypeAppData";
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
import { appStoreMetadata } from "@calcom/app-store/bookerAppsMetaData";
import type { appDataSchemas } from "./apps.schemas.generated";
+21 -10
View File
@@ -5,15 +5,21 @@ export type GetAppData = (key: string) => unknown;
export type SetAppData = (key: string, value: unknown) => void;
type LockedIcon = JSX.Element | false | undefined;
type Disabled = boolean | undefined;
// eslint-disable-next-line @typescript-eslint/no-empty-function
const EventTypeAppContext = React.createContext<[GetAppData, SetAppData, LockedIcon, Disabled]>([
() => ({}),
() => ({}),
undefined,
undefined,
]);
export type SetAppDataGeneric<TAppData extends ZodType> = <
type AppContext = {
getAppData: GetAppData;
setAppData: SetAppData;
LockedIcon?: LockedIcon;
disabled?: Disabled;
};
// eslint-disable-next-line @typescript-eslint/no-empty-function
const EventTypeAppContext = React.createContext<AppContext>({
getAppData: () => ({}),
setAppData: () => ({}),
});
type SetAppDataGeneric<TAppData extends ZodType> = <
TKey extends keyof z.infer<TAppData>,
TValue extends z.infer<TAppData>[TKey]
>(
@@ -21,7 +27,7 @@ export type SetAppDataGeneric<TAppData extends ZodType> = <
value: TValue
) => void;
export type GetAppDataGeneric<TAppData extends ZodType> = <TKey extends keyof z.infer<TAppData>>(
type GetAppDataGeneric<TAppData extends ZodType> = <TKey extends keyof z.infer<TAppData>>(
key: TKey
) => z.infer<TAppData>[TKey];
@@ -29,7 +35,12 @@ export const useAppContextWithSchema = <TAppData extends ZodType>() => {
type GetAppData = GetAppDataGeneric<TAppData>;
type SetAppData = SetAppDataGeneric<TAppData>;
// TODO: Not able to do it without type assertion here
const context = React.useContext(EventTypeAppContext) as [GetAppData, SetAppData, LockedIcon, Disabled];
const context = React.useContext(EventTypeAppContext) as {
getAppData: GetAppData;
setAppData: SetAppData;
LockedIcon: LockedIcon;
disabled: Disabled;
};
return context;
};
export default EventTypeAppContext;
+3 -8
View File
@@ -1,12 +1,11 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import Link from "next/link";
import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext";
import { classNames } from "@calcom/lib";
import type { RouterOutputs } from "@calcom/trpc/react";
import { Switch, Badge, Avatar } from "@calcom/ui";
import type { SetAppDataGeneric } from "../EventTypeAppContext";
import type { eventTypeAppCardZod } from "../eventTypeAppCardZod";
import type { CredentialOwner } from "../types";
import OmniInstallAppButton from "./OmniInstallAppButton";
@@ -16,24 +15,20 @@ export default function AppCard({
switchOnClick,
switchChecked,
children,
setAppData,
returnTo,
teamId,
disableSwitch,
LockedIcon,
}: {
app: RouterOutputs["viewer"]["integrations"]["items"][number] & { credentialOwner?: CredentialOwner };
description?: React.ReactNode;
switchChecked?: boolean;
switchOnClick?: (e: boolean) => void;
children?: React.ReactNode;
setAppData: SetAppDataGeneric<typeof eventTypeAppCardZod>;
returnTo?: string;
teamId?: number;
disableSwitch?: boolean;
LockedIcon?: React.ReactNode;
}) {
const [animationRef] = useAutoAnimate<HTMLDivElement>();
const { setAppData, LockedIcon, disabled } = useAppContextWithSchema();
return (
<div
@@ -92,7 +87,7 @@ export default function AppCard({
{app?.isInstalled || app.credentialOwner ? (
<div className="ml-auto flex items-center">
<Switch
disabled={!app.enabled || disableSwitch}
disabled={!app.enabled || disabled}
onCheckedChange={(enabled) => {
if (switchOnClick) {
switchOnClick(enabled);
@@ -19,7 +19,7 @@ export const EventTypeAppCard = (props: {
const { app, getAppData, setAppData, LockedIcon, disabled } = props;
return (
<ErrorBoundary message={`There is some problem with ${app.name} App`}>
<EventTypeAppContext.Provider value={[getAppData, setAppData, LockedIcon, disabled]}>
<EventTypeAppContext.Provider value={{ getAppData, setAppData, LockedIcon, disabled }}>
<DynamicComponent
slug={app.slug === "stripe" ? "stripepayment" : app.slug}
componentMap={EventTypeAddonMap}
+1 -1
View File
@@ -5,7 +5,7 @@ import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext";
import type { EventTypeAppCardApp } from "../types";
function useIsAppEnabled(app: EventTypeAppCardApp) {
const [getAppData, setAppData] = useAppContextWithSchema();
const { getAppData, setAppData } = useAppContextWithSchema();
const [enabled, setEnabled] = useState(() => {
if (!app.credentialOwner) {
return getAppData("enabled");
+2 -15
View File
@@ -1,7 +1,7 @@
import type { AppMeta } from "@calcom/types/App";
import { appStoreMetadata as rawAppStoreMetadata } from "./apps.metadata.generated";
import { getAppAssetFullPath } from "./getAppAssetFullPath";
import { getNormalizedAppMetadata } from "./getNormalizedAppMetadata";
type RawAppStoreMetaData = typeof rawAppStoreMetadata;
type AppStoreMetaData = {
@@ -10,18 +10,5 @@ type AppStoreMetaData = {
export const appStoreMetadata = {} as AppStoreMetaData;
for (const [key, value] of Object.entries(rawAppStoreMetadata)) {
const dirName = "dirName" in value ? value.dirName : value.slug;
if (!dirName) {
throw new Error(`Couldn't derive dirName for app ${key}`);
}
const metadata = (appStoreMetadata[key as keyof typeof appStoreMetadata] = {
appData: null,
dirName,
__template: "",
...value,
} as AppStoreMetaData[keyof AppStoreMetaData]);
metadata.logo = getAppAssetFullPath(metadata.logo, {
dirName,
isTemplate: metadata.isTemplate,
});
appStoreMetadata[key as keyof typeof appStoreMetadata] = getNormalizedAppMetadata(value);
}
@@ -6,6 +6,7 @@ import amie_config_json from "./amie/config.json";
import { metadata as applecalendar__metadata_ts } from "./applecalendar/_metadata";
import around_config_json from "./around/config.json";
import basecamp3_config_json from "./basecamp3/config.json";
import cal_ai_config_json from "./cal-ai/config.json";
import { metadata as caldavcalendar__metadata_ts } from "./caldavcalendar/_metadata";
import campfire_config_json from "./campfire/config.json";
import closecom_config_json from "./closecom/config.json";
@@ -75,6 +76,7 @@ export const appStoreMetadata = {
applecalendar: applecalendar__metadata_ts,
around: around_config_json,
basecamp3: basecamp3_config_json,
"cal-ai": cal_ai_config_json,
caldavcalendar: caldavcalendar__metadata_ts,
campfire: campfire_config_json,
closecom: closecom_config_json,
@@ -7,6 +7,7 @@ export const apiHandlers = {
applecalendar: import("./applecalendar/api"),
around: import("./around/api"),
basecamp3: import("./basecamp3/api"),
"cal-ai": import("./cal-ai/api"),
caldavcalendar: import("./caldavcalendar/api"),
campfire: import("./campfire/api"),
closecom: import("./closecom/api"),

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