Compare commits

..
72 Commits
Author SHA1 Message Date
Alex van Andel 5bd0428d18 v3.8.0
release.yml / release (push) Canceled after 0s
2024-02-15 15:54:47 +00:00
c7089b9db6 feat: store event-type filters to localStorage (#13407)
* Updated useTypedQuery to store values to localstorage

* removed the redundant function params

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2024-02-15 10:03:42 -05:00
Morgan 81ad00a816 fix: safely delete userPassword (#13705)
* fix: safely delete userPassword

* fixup! fix: safely delete userPassword

* fixup! Merge branch 'main' into fix-safely-delete-userpassword
2024-02-15 13:19:31 +00:00
Udit TakkarandMorgan b5730da56d fix: password reset when provider is changed (#13706)
* fix: password reset when provider is changed

* chore: remove log

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2024-02-15 13:19:11 +00:00
Zeeshan BhatiandJoe Au-Yeung 9455d14ad5 feat: Add Google Profile Photo when connecting Google Calendar (#13627)
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
2024-02-15 11:51:24 +00:00
LAVIN RAJ MOHANandUdit Takkar a056160404 fix(fix/null-safety):#13673 (#13674)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-02-15 07:17:19 +00:00
Omar López 6d53cd84f8 v3.7.19
release.yml / release (push) Canceled after 0s
2024-02-14 14:19:56 -07:00
Omar López 30cb8f745d revert: Revert UserPassword (#13680) (#13685)
* Revert "revert: UserPassword (#13680)"

This reverts commit 0a68c6d048.

* fix: avoid updates for possible missing passwords
2024-02-14 16:53:27 +00:00
bafa3c97ac fix: Organizations Admin improvements (#13656)
* Admin improvements

* chore: improve spacing

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Udit Takkar <udit222001@gmail.com>
2024-02-14 10:34:45 -03:00
Alex van Andel bb48ccbad9 v3.7.18
release.yml / release (push) Canceled after 0s
2024-02-14 12:42:33 +00:00
Alex van Andel 0a68c6d048 revert: UserPassword (#13680) 2024-02-14 12:36:29 +00:00
Peer RichelsenandHariom bb9f8e5d38 fix: dont link to org url inside embeds (#13668)
* dont link to org url inside embeds

* Add comment

* Fix type issue

---------

Co-authored-by: Hariom <hariombalhara@gmail.com>
2024-02-14 11:31:24 +05:30
Carina WollendorferandCarinaWolli c99bb190f1 fix: route always selects 'custom' after saving an event redirect (#13665)
* fix to not always switch to custom on fresh load

* type fix

* type fix

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2024-02-13 22:08:03 +00:00
Omar López f0d2a45fdf v3.7.17
release.yml / release (push) Canceled after 0s
2024-02-13 14:58:51 -07:00
zomars e5f9315ee6 fix: faulty redirect 2024-02-13 12:45:21 -07:00
zomars f7782b2080 fix: hotfix failed migration 2024-02-13 12:16:38 -07:00
Omar López e1f2dbab84 fix: amend user password migration (#13662) 2024-02-13 18:40:50 +00:00
Adam Azzam c2edbbcea7 Update SettingsToggle.tsx (#13660) 2024-02-13 22:54:27 +05:30
c7e78cc181 fix: AI build (#13657)
* fix: AI build

* Updated zod to 3.22.4

* yarn.lock

* Updated yarn.lock to prevent next/tw downgrade

---------

Co-authored-by: Peer Richelsen <peer@cal.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2024-02-13 16:10:08 +00:00
7ba1557d1d fix: use destination calendar email (#12821)
* fix: use destination calendar email

* fix: success page

* feat: add primary email in destination calendar

* fix: set destination bug

* chore: fix tests

* test: add test for different email

* wip: save progress

* feat: add switch and email in booking

* fix: use userPrimaryEmail

* chore: fix type errr

* chore: update buileder

* fix: type err in advanced tab

* fix: unit tests

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

Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>

* fix: cancel, request reschedule, paymeny

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
2024-02-13 10:05:00 -05:00
Suyash SrivastavaandPeer Richelsen d5b45b757a chore: Removed Skiff from the App Store (#13650)
* rule for Prisma that includes using only "true" and not a field selector

* Removed Skiff from the app store

* Apply suggestions from code review

* Delete packages/eslint-plugin/src/rules/disallow-prisma-include-only-true-not-field-selector.ts

---------

Co-authored-by: Peer Richelsen <peer@cal.com>
2024-02-13 11:13:12 +00:00
bb3b262bd7 fix: Add location to ics file (#13646)
* Add location to ics file

* Add tests

* fix: type err

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Udit Takkar <udit222001@gmail.com>
2024-02-13 16:35:04 +05:30
d4f41e803a fix: Collective Events: Duration parameter in link not working (#13523)
* set the selectedDuration in store to duration passed in query

* Pass durationConfig prop to Booker component in /team/[slig]/[type] pages

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2024-02-13 12:23:27 +05:30
74eafa59e1 fix: loading animation of update buttons (#13131)
* fixed loading animation of update buttons

* Update BookerLayoutSelector.tsx

* fix: update button animation in settings appearance page

* Update appearance.tsx

* Update appearance.tsx

---------

Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-02-13 06:32:21 +00:00
Udit Takkarandzomars e6cd016cad fix: usage of CAL_URL (#13326)
Co-authored-by: zomars <zomars@me.com>
2024-02-13 06:03:45 +00:00
Omar López 7b0d5959cb refactor: migrates user password to it's own table (#13628) 2024-02-12 14:13:21 -07:00
Alex van Andel 7702ded85e v3.7.16
release.yml / release (push) Canceled after 0s
2024-02-12 18:31:59 +00:00
Alex van Andel 5acb3e8f97 fix: Missing date ranges when specifying a specific slot time (#13645) 2024-02-12 18:25:49 +00:00
sean-brydon 41046aaf27 fix: use org logo for sub teams (#13632)
* fix: use org logo for sub teams

* chore: remove unneeded type
2024-02-12 12:59:25 -03:00
Hariom Balhara 7cffb30daa fix: Errors due to namespacing of embed (#13637) 2024-02-12 07:46:08 -07:00
Alex van Andel 26bacb6c8e fix: Regenerated lock file 2024-02-12 13:38:13 +00:00
Crowdin Bot 01b5c1f11e New Crowdin translations by Github Action 2024-02-11 14:32:05 +00:00
Anik Dhabal Babu f5ba04599b fix: unable to setup localy (#13634) 2024-02-11 14:28:40 +00:00
Brace Sproul 994e602c8d refactor: Bump LangChain to 0.1 & update other LangChain imports (#13622) 2024-02-10 19:30:42 +00:00
Carina WollendorferandCarinaWolli a776c72d48 fix: improvements for variables in routing-from routes (#13624)
* add variable description

* type fixes

* type fixes

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2024-02-10 19:28:59 +00:00
sean-brydon 6adb5b413d fix: troubleshooter scroll (#13631) 2024-02-10 19:26:21 +00:00
alannnc b183a5779a fix: oncancel modal action week layout (#13625) 2024-02-09 23:02:05 +00:00
alannnc 16c1cf7b23 fix ooo section to use settings layout (#13623) 2024-02-09 19:57:08 -03:00
Alex van Andel cf4ee142de v3.7.15
release.yml / release (push) Canceled after 0s
2024-02-09 17:46:11 +00:00
Keith Williams a5d99bbec9 chore: Remove platform from label ignore list (#13618) 2024-02-09 12:42:56 -03:00
Crowdin Bot 5d0468a986 New Crowdin translations by Github Action 2024-02-09 14:11:25 +00:00
Hariom Balhara 7fef9db4ce fix: Reduce payload even further. (#13599)
* Revert "Revert event-type profile based querying (#13588)"

This reverts commit c0c40185fc.

* Add huge event-types seeder

* Fix missing where causing wrong event-types for user
2024-02-09 09:07:26 -05:00
Carina WollendorferandCarinaWolli 296a367955 feat: round-robin priority ranking (#13566)
* improve design of assignment tab

* design fix

* add prioity dialog

* implement UI

* add backend to save priorities

* add priorty to rr algorithm + tests

* fix defaulValue for select + added translations

* add toggle for round robin fixed hosts

* don't show priorities for fixed hosts

* type fixes

* code clean up and type fixes

* clean up getValues and setValue

* code clean up

* fix tests

* code clean up

* fix undefined check

* allow null for priority

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2024-02-09 12:12:06 +00:00
Crowdin Bot 1ff2d51ee5 New Crowdin translations by Github Action 2024-02-09 10:17:14 +00:00
Morgan 092cb0f691 fix: organizer_default_conferencing_app translatable key in app location (#13614) 2024-02-09 10:13:30 +00:00
zomars 0c74a98721 upgrades copycat 2024-02-08 14:11:24 -07:00
Omar LópezandKeith Williams 2082a4f4ed chore: upgrades boxyhq jackson (#13477)
* chore: upgrades boxyhq jackson

* Update jackson.ts

reduces bundle size

* Update yarn.lock

* Update apps/web/next.config.js

* Upgrades again

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-02-08 17:51:29 -03:00
Anant JainandUdit Takkar f06ef84f61 fix: Fixes teams UI load issue (#13593)
* fixes teams uiload issue

* revert: yarn.lock

---------

Co-authored-by: Udit Takkar <udit222001@gmail.com>
2024-02-08 18:58:21 +00:00
Udit Takkar 1080d7f88f fix: update valid_for_secs (#13604) 2024-02-08 18:46:02 +00:00
4ebd52c9af test: Create unit tests for the questions (teste2e-multiSelectQuestion) (#11569)
* Remove unnecessary changes

* add changes

* Requested changes

* Requested changes

* FIx failing tests

* FIx failing tests

* Update regularBookings.ts

* FIx failing tests

* Refactor

* add unit tests for all questions

---------

Co-authored-by: gitstart-calcom <gitstart-calcom@users.noreply.github.com>
Co-authored-by: GitStart-Cal.com <121884634+gitstart-calcom@users.noreply.github.com>
Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
2024-02-08 15:00:19 -03:00
Morgan e0ac95578c chore: refactor handle new reccuring booking (#13597)
* chore: refactor handle new reccuring booking

* fixup! chore: refactor handle new reccuring booking

* fixup! fixup! chore: refactor handle new reccuring booking
2024-02-08 16:59:49 +00:00
Hariom BalharaandJoe Au-Yeung 473012f2c0 fix: organization user shouldn't be asked for payment for a premium username (#13535)
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
2024-02-08 16:46:21 +00:00
Hariom BalharaandJoe Au-Yeung 1293588ac4 test: Bookings: Add more automated tests for organization (#13576)
* Avoid selecting unused props

* Add automated tests

* Add existing user invite and booking

---------

Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
2024-02-08 16:20:00 +00:00
Hariom Balhara c0c40185fc Revert event-type profile based querying (#13588) 2024-02-08 09:40:17 -03:00
3348e7be2f fix: prevent duplicate calendar account linking (#13310)
* fix: prevent signing up multiple times from same account

* revert: lark calendar changes

* credential clean up if duplicate

* fix code duplication and check before credential creation

* feat: useRouterQuery allow unset queryParam

* feat: showToast on account duplication attempt

* Small tweak to copy

* Updated other calendars not to use checkDuplicateCalendar

* Add account already linked to apps/installed/calendar

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-02-08 10:52:32 +00:00
97dcb2a2a9 fix: add missing title (#13307)
* add missing title

* add

* update

* update

* update

* fix lint

* Update AppsLayout.tsx

* update

* Update bookings-listing-view.tsx

* update

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-02-07 17:49:46 -03:00
Udit Takkar 5dad78f8fe fix: 2FA OTP flaky test (#13583)
* fix: 2FA OTP flaky test

* chore: change timeout
2024-02-07 19:02:23 +00:00
zomars 88cc83a0aa Update transform.ts 2024-02-07 10:57:06 -07:00
zomars 9dddf5e525 fix: docker's toomanyrequests error 2024-02-07 10:33:47 -07:00
Lauris Skraucis 9df9f9bb6b refactor: trpc get schedule handler (#13586) 2024-02-07 09:43:19 -07:00
LaneandLane c4803be323 fix #13527 (#13579)
Co-authored-by: Lane <>
2024-02-07 14:08:06 +00:00
Hariom BalharaandUdit Takkar 34d360a4ec fix: Reduce payload in event-type/getByViewer (#13580)
* Avoid selecting unused props

* Reduce payload

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-02-07 09:40:45 -03:00
0e6f44e2b9 feat: email confirmation on change (#13443)
* feat:show old and new email in confirmation dialog

* fix: update i18n

* chore: update styles

* wip: change email upon verification

* fix: email being changed w/o need to logout

* chore: tidy up - remove update sessison (WIP) will try do this serverside

* wip: try new approach of serverside call to nextjs then client update

* fix: tests

* fix: update without logout

* i18n: use i18n in toast

* fix: locale ready toast

* fix: restore email api template

* fix: revert changes from wrong branch

* fix: restore yarn.lock

* feat: happy e2e path

* fix: verification disabled e2e tests

* fix:move toast

* chore: cleanup early return

* fix: await input selector

* Update apps/web/pages/auth/verify-email-change.tsx

* fix: feedback

* fix: update the way we update session

* fix: reset password -> email verified

* fix: email change toast failure

* tests: add tests for error path

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-02-07 10:31:00 +00:00
Somay ChauhanandKeith Williams e377997d49 fix: filtering events bug in bookings page (#13447)
* fix: filtering events bug in bookings page

* fix: handle edge case if we change hosts in event-settings/assignment

* update: handled edge case

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-02-07 10:01:48 +00:00
Somay ChauhanandKeith Williams a0451e34c1 fix: Ensure event type duplication respects private link setting (#13438)
* fix: Ensure event type duplication respects private link setting

* chode refactor

* Fix: Handler throwing error on creating duplicate of a collective event having private-link switched on.

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-02-07 09:30:28 +01:00
Omar López d2b4760e3a refactor: removed isNotAnApiCall (#13568) 2024-02-07 04:43:36 -03:00
8469c91973 feat: integrate formbricks for team disband action (#13362)
* feat: integrate formmbricks for team disband action

* fix: attribute typing & duplicate action

* fix: track action uniformly across repo

* fix: move action tracking on success of team disband mutation

* fix: yarn lock file

* feat: bump formbricks/js to latest version

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-02-07 04:14:26 -03:00
Keith Williams 0990549e7f perf: Don't process date overrides outside of requested boundaries (#13495)
* perf: Don't process date overrides outside of requested boundaries

* Added ability to skip override calculations when not used

* Only instantiating utc date for overrides once

* Removed performance tracing

* Cleanup

* Fixed broken tests

* Fix type

* In process of fixing types

* Types

* Fixed types

* Removed unnecessary performance.now() call

* Changed override date check to use isBetween

* Attempting to fix availability test

* Put code back to test availability

* Put back isBetween because confirmed it wasn't triggering the new test failures

* Removed date check to see if this is breaking it

* Put the code back since the test failed without it too

* Skipping test to unblock pipeline unless research is done

* Removed steps to test

* Removing the piece around the troubleshooter

* 1 more try

* Adding an extra wait

* Took out the boolean check to see if that's the issue

* Put back the check

* Trying to isolate the problem

* Changed how the test loads the troubleshooter

* Changed to OR to capture range better

* Using the proper date instead of 1/1/1970

* Update availability.e2e.ts
2024-02-07 03:21:43 +00:00
Omar López e884348807 chore: adds failsafe auto migrations back (#13569) 2024-02-06 22:59:01 -03:00
zomars 5684ce2d28 Update action.yml
Updates actions
2024-02-06 17:39:19 -07:00
zomars bd8ac12f14 Update yarn.lock 2024-02-06 17:15:21 -07:00
Keith Williams 512628e8ab perf: Load bookings used for limits in bulk (#13474) 2024-02-06 16:56:05 -07:00
266 changed files with 7575 additions and 3472 deletions
+5 -2
View File
@@ -23,6 +23,9 @@ CALCOM_LICENSE_KEY=
# - DATABASE ************************************************************************************************
DATABASE_URL="postgresql://postgres:@localhost:5450/calendso"
# Needed to run migrations while using a connection pooler like PgBouncer
# Use the same one as DATABASE_URL if you're not using a connection pooler
DATABASE_DIRECT_URL="postgresql://postgres:@localhost:5450/calendso"
UPSTASH_REDIS_REST_URL=
UPSTASH_REDIS_REST_TOKEN=
INSIGHTS_DATABASE_URL=
@@ -134,8 +137,8 @@ NEXT_PUBLIC_SENDGRID_SENDER_NAME=
NEXT_PUBLIC_SENTRY_DSN=
# Formbricks Experience Management Integration
FORMBRICKS_HOST_URL=https://app.formbricks.com
FORMBRICKS_ENVIRONMENT_ID=
NEXT_PUBLIC_FORMBRICKS_HOST_URL=https://app.formbricks.com
NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID=
FORMBRICKS_FEEDBACK_SURVEY_ID=
# AvatarAPI
+1
View File
@@ -18,6 +18,7 @@ runs:
key-1: ${{ hashFiles('packages/prisma/schema.prisma', 'packages/prisma/migrations/**/**.sql', 'packages/prisma/*.ts') }}
key-2: ${{ github.event.pull_request.number || github.ref }}
DATABASE_URL: ${{ inputs.DATABASE_URL }}
DATABASE_DIRECT_URL: ${{ inputs.DATABASE_URL }}
E2E_TEST_CALCOM_QA_EMAIL: ${{ inputs.E2E_TEST_CALCOM_QA_EMAIL }}
E2E_TEST_CALCOM_QA_PASSWORD: ${{ inputs.E2E_TEST_CALCOM_QA_PASSWORD }}
E2E_TEST_CALCOM_QA_GCAL_CREDENTIALS: ${{ inputs.E2E_TEST_CALCOM_QA_GCAL_CREDENTIALS }}
+8
View File
@@ -11,6 +11,9 @@ jobs:
services:
postgres:
image: postgres:12.1
credentials:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
env:
POSTGRES_USER: postgres
POSTGRES_DB: calendso
@@ -18,6 +21,9 @@ jobs:
- 5432:5432
mailhog:
image: mailhog/mailhog:v1.0.1
credentials:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
ports:
- 8025:8025
- 1025:1025
@@ -39,6 +45,7 @@ jobs:
- uses: ./.github/actions/cache-db
env:
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
E2E_TEST_CALCOM_QA_EMAIL: ${{ secrets.E2E_TEST_CALCOM_QA_EMAIL }}
E2E_TEST_CALCOM_QA_PASSWORD: ${{ secrets.E2E_TEST_CALCOM_QA_PASSWORD }}
E2E_TEST_CALCOM_QA_GCAL_CREDENTIALS: ${{ secrets.E2E_TEST_CALCOM_QA_GCAL_CREDENTIALS }}
@@ -50,6 +57,7 @@ jobs:
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
E2E_TEST_APPLE_CALENDAR_EMAIL: ${{ secrets.E2E_TEST_APPLE_CALENDAR_EMAIL }}
E2E_TEST_APPLE_CALENDAR_PASSWORD: ${{ secrets.E2E_TEST_APPLE_CALENDAR_PASSWORD }}
+4
View File
@@ -11,6 +11,9 @@ jobs:
services:
postgres:
image: postgres:12.1
credentials:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
env:
POSTGRES_USER: postgres
POSTGRES_DB: calendso
@@ -41,6 +44,7 @@ jobs:
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
E2E_TEST_APPLE_CALENDAR_EMAIL: ${{ secrets.E2E_TEST_APPLE_CALENDAR_EMAIL }}
E2E_TEST_APPLE_CALENDAR_PASSWORD: ${{ secrets.E2E_TEST_APPLE_CALENDAR_PASSWORD }}
+7
View File
@@ -11,6 +11,9 @@ jobs:
services:
postgres:
image: postgres:12.1
credentials:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
env:
POSTGRES_USER: postgres
POSTGRES_DB: calendso
@@ -18,6 +21,9 @@ jobs:
- 5432:5432
mailhog:
image: mailhog/mailhog:v1.0.1
credentials:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
ports:
- 8025:8025
- 1025:1025
@@ -44,6 +50,7 @@ jobs:
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
E2E_TEST_APPLE_CALENDAR_EMAIL: ${{ secrets.E2E_TEST_APPLE_CALENDAR_EMAIL }}
E2E_TEST_APPLE_CALENDAR_PASSWORD: ${{ secrets.E2E_TEST_APPLE_CALENDAR_PASSWORD }}
+7
View File
@@ -11,6 +11,9 @@ jobs:
services:
postgres:
image: postgres:12.1
credentials:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
env:
POSTGRES_USER: postgres
POSTGRES_DB: calendso
@@ -18,6 +21,9 @@ jobs:
- 5432:5432
mailhog:
image: mailhog/mailhog:v1.0.1
credentials:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
ports:
- 8025:8025
- 1025:1025
@@ -42,6 +48,7 @@ jobs:
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
E2E_TEST_APPLE_CALENDAR_EMAIL: ${{ secrets.E2E_TEST_APPLE_CALENDAR_EMAIL }}
E2E_TEST_APPLE_CALENDAR_PASSWORD: ${{ secrets.E2E_TEST_APPLE_CALENDAR_PASSWORD }}
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
with:
repo-token: ${{ secrets.EQUITY_BEE_TEAM_LABELER_ACTION_TOKEN }}
organization-name: calcom
ignore-labels: "admin, app-store, ai, authentication, automated-testing, devops, platform, billing, bookings, caldav, calendar-apps, ci, console, crm-apps, docs, documentation, emails, embeds, event-types, i18n, impersonation, manual-testing, ui, performance, ops-stack, organizations, public-api, routing-forms, seats, teams, webhooks, workflows, zapier"
ignore-labels: "admin, app-store, ai, authentication, automated-testing, devops, billing, bookings, caldav, calendar-apps, ci, console, crm-apps, docs, documentation, emails, embeds, event-types, i18n, impersonation, manual-testing, ui, performance, ops-stack, organizations, public-api, routing-forms, seats, teams, webhooks, workflows, zapier"
apply-labels-from-issue:
runs-on: ubuntu-latest
@@ -7,6 +7,7 @@ env:
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
E2E_TEST_APPLE_CALENDAR_EMAIL: ${{ secrets.E2E_TEST_APPLE_CALENDAR_EMAIL }}
E2E_TEST_APPLE_CALENDAR_PASSWORD: ${{ secrets.E2E_TEST_APPLE_CALENDAR_PASSWORD }}
GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
+4
View File
@@ -7,6 +7,7 @@ env:
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
E2E_TEST_APPLE_CALENDAR_EMAIL: ${{ secrets.E2E_TEST_APPLE_CALENDAR_EMAIL }}
E2E_TEST_APPLE_CALENDAR_PASSWORD: ${{ secrets.E2E_TEST_APPLE_CALENDAR_PASSWORD }}
E2E_TEST_CALCOM_QA_EMAIL: ${{ secrets.E2E_TEST_CALCOM_QA_EMAIL }}
@@ -41,6 +42,9 @@ jobs:
services:
postgres:
image: postgres:12.1
credentials:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
env:
POSTGRES_USER: postgres
POSTGRES_DB: calendso
+226 -56
View File
@@ -1,9 +1,10 @@
// This transform config was generated by Snaplet.
// Snaplet found fields that may contain personally identifiable information (PII)
// and used that to populate this file.
// eslint-disable-next-line @typescript-eslint/triple-slash-reference
/// <reference path=".snaplet/snaplet.d.ts" />
// This config was generated by Snaplet make sure to check it over before using it.
import { copycat as c } from "@snaplet/copycat";
import { defineConfig } from "snaplet";
import type { Transform } from "./structure";
// c.setHashKey(REPLACE_ME_WITH_YOUR_HASH_KEY);
function hasStringProp<T extends string>(x: unknown, key: T): x is { [key in T]: string } {
return !!x && typeof x === "object" && key in x;
@@ -41,57 +42,226 @@ function replaceSensitiveKeys(record: object) {
const generateUsername = (x: string) => `${c.firstName(x)}-${c.lastName(x)}${c.int(x, { min: 2, max: 99 })}`;
const config: Transform = () => ({
public: {
ApiKey: ({ row }) => ({
hashedKey: c.uuid(row.hashedKey),
}),
App: ({ row }) => ({
keys: replaceSensitiveKeys(row.keys),
}),
Attendee: ({ row }) => ({
email: c.email(row.email),
name: c.fullName(row.name),
timeZone: c.timezone(row.timeZone),
}),
Credential: ({ row }) => ({
key: typeof row.key === "string" ? c.uuid(row.key) : replaceSensitiveKeys(row.key),
}),
EventType: ({ row }) => ({
slug: generateSlug(row.slug),
timeZone: c.timezone(row.timeZone),
eventName: c.words(row.eventName, { max: 3 }),
}),
ResetPasswordRequest: ({ row }) => ({
email: c.email(row.email),
}),
Schedule: ({ row }) => ({
timeZone: c.timezone(row.timeZone),
}),
Team: ({ row }) => ({
bio: c.sentence(row.bio),
name: c.words(row.name, { max: 2 }),
slug: generateSlug(row.slug),
}),
users: ({ row }) =>
row.role !== "ADMIN"
? {
bio: c.sentence(row.bio),
email: c.email(row.email),
name: c.fullName(row.name),
password: c.password(row.password),
timeZone: c.timezone(row.timeZone),
username: generateUsername(row.username),
}
: row,
VerificationToken: ({ row }) => ({
token: c.uuid(row.token),
}),
Account: ({ row }) => ({
access_token: c.uuid(row.access_token),
refresh_token: c.uuid(row.refresh_token),
}),
export default defineConfig({
transform: {
$mode: "unsafe",
public: {
Account({ row }) {
return {
refresh_token: c.uuid(row.refresh_token),
access_token: c.uuid(row.access_token),
expires_at: c.int(row.expires_at, {
min: 0,
max: Math.pow(4, 8) - 1,
}),
token_type: c.uuid(row.token_type),
id_token: c.uuid(row.id_token),
};
},
ApiKey: ({ row }) => ({
hashedKey: c.uuid(row.hashedKey),
note: c.fullName(row.note),
createdAt: c.dateString(row.createdAt, {
minYear: 2020,
}),
}),
App: ({ row }) => ({
keys: replaceSensitiveKeys(row.keys),
}),
App_RoutingForms_Form({ row }) {
return {
name: c.fullName(row.name),
};
},
Attendee: ({ row }) => ({
email: c.email(row.email),
name: c.fullName(row.name),
timeZone: c.timezone(row.timeZone),
locale: c.fullName(row.locale),
}),
Availability({ row }) {
return {
startTime: c
.dateString(row.startTime, {
minYear: 2020,
})
.slice(11, 19),
endTime: c
.dateString(row.endTime, {
minYear: 2020,
})
.slice(11, 19),
};
},
Booking({ row }) {
return {
title: c.fullName(row.title),
startTime: c.dateString(row.startTime, {
minYear: 2020,
}),
endTime: c.dateString(row.endTime, {
minYear: 2020,
}),
location: c.sentence(row.location),
metadata: {
[c.word(row.metadata)]: c.words(row.metadata),
},
};
},
Credential: ({ row }) => ({
key: typeof row.key === "string" ? c.uuid(row.key) : replaceSensitiveKeys(row.key),
}),
EventType: ({ row }) => ({
slug: generateSlug(row.slug),
timeZone: c.timezone(row.timeZone),
eventName: c.words(row.eventName, { max: 3 }),
currency: c.sentence(row.currency),
}),
EventTypeCustomInput({ row }) {
return {
label: c.fullName(row.label),
};
},
Feature({ row }) {
return {
slug: c.uuid(row.slug),
};
},
InstantMeetingToken({ row }) {
return {
token: c.uuid(row.token),
};
},
OAuthClient({ row }) {
return {
name: c.fullName(row.name),
};
},
OutOfOfficeEntry({ row }) {
return {
start: c.dateString(row.start, {
minYear: 2020,
}),
};
},
Payment({ row }) {
return {
amount: c.int(row.amount, {
min: 0,
max: Math.pow(4, 8) - 1,
}),
currency: c.sentence(row.currency),
data: {
[c.word(row.data)]: c.words(row.data),
},
};
},
ResetPasswordRequest: ({ row }) => ({
email: c.email(row.email),
}),
Schedule: ({ row }) => ({
name: c.fullName(row.name),
timeZone: c.timezone(row.timeZone),
}),
Session({ row }) {
return {
sessionToken: c.uuid(row.sessionToken),
};
},
Team: ({ row }) => ({
bio: c.sentence(row.bio),
name: c.words(row.name, { max: 2 }),
slug: generateSlug(row.slug),
timeZone: c.timezone(row.timeZone),
logoUrl: c.username(row.logoUrl),
}),
TempOrgRedirect({ row }) {
return {
from: c.dateString(row.from, {
maxYear: 1999,
}),
toUrl: c.city(row.toUrl),
};
},
VerificationToken({ row }) {
return {
id: c
.int(row.id, {
min: 1,
max: Math.pow(4, 8) - 1,
})
.toString(),
identifier: c.uuid(row.identifier),
token: c.uuid(row.token),
expires: c.dateString(row.expires, {
minYear: 2020,
}),
};
},
VerifiedNumber({ row }) {
return {
phoneNumber: c.phoneNumber(row.phoneNumber),
};
},
Webhook({ row }) {
return {
subscriberUrl: c.url(row.subscriberUrl),
secret: c.streetAddress(row.secret),
};
},
WebhookScheduledTriggers({ row }) {
return {
jobName: c.fullName(row.jobName),
payload: c.password(row.payload),
};
},
WorkflowStep({ row }) {
return {
sendTo: c.oneOf(row.sendTo, [
"Man",
"Woman",
"Transgender",
"Non-binary/non-conforming",
"Not specified",
]),
sender: c.oneOf(row.sender, [
"Man",
"Woman",
"Transgender",
"Non-binary/non-conforming",
"Not specified",
]),
};
},
users: ({ row }) =>
row.role !== "ADMIN"
? {
bio: c.sentence(row.bio),
email: c.email(row.email),
name: c.fullName(row.name),
password: c.password(row.password),
timeZone: c.timezone(row.timeZone),
username: generateUsername(row.username),
metadata: {
[c.word(row.metadata)]: c.words(row.metadata),
},
}
: row,
},
},
subset: {
enabled: true,
version: "3",
targets: [
{
table: "public.users",
rowLimit: 100,
},
],
keepDisconnectedTables: false,
followNullableRelations: true,
maxCyclesLoop: 0,
eager: false,
taskSortAlgorithm: "children",
},
});
export default config;
+34 -6
View File
@@ -8,14 +8,14 @@ test.describe("Org", () => {
const response = await page.goto("https://i.cal.com/embed");
expect(response?.status()).toBe(200);
await page.screenshot({ path: "screenshot.jpg" });
await expectPageToBeServerSideRendered(page);
await expectPageToBeRenderedWithEmbedSsr(page);
});
test("Org User(Peer) Page should be embeddable", async ({ page }) => {
const response = await page.goto("https://i.cal.com/peer/embed");
expect(response?.status()).toBe(200);
await expect(page.locator("text=Peer Richelsen")).toBeVisible();
await expectPageToBeServerSideRendered(page);
await expectPageToBeRenderedWithEmbedSsr(page);
});
test("Org User Event(peer/meet) Page should be embeddable", async ({ page }) => {
@@ -23,14 +23,14 @@ test.describe("Org", () => {
expect(response?.status()).toBe(200);
await expect(page.locator('[data-testid="decrementMonth"]')).toBeVisible();
await expect(page.locator('[data-testid="incrementMonth"]')).toBeVisible();
await expectPageToBeServerSideRendered(page);
await expectPageToBeRenderedWithEmbedSsr(page);
});
test("Org Team Profile(/sales) page should be embeddable", async ({ page }) => {
const response = await page.goto("https://i.cal.com/sales/embed");
expect(response?.status()).toBe(200);
await expect(page.locator("text=Cal.com Sales")).toBeVisible();
await expectPageToBeServerSideRendered(page);
await expectPageToBeRenderedWithEmbedSsr(page);
});
test("Org Team Event page(/sales/hippa) should be embeddable", async ({ page }) => {
@@ -38,9 +38,10 @@ test.describe("Org", () => {
expect(response?.status()).toBe(200);
await expect(page.locator('[data-testid="decrementMonth"]')).toBeVisible();
await expect(page.locator('[data-testid="incrementMonth"]')).toBeVisible();
await expectPageToBeServerSideRendered(page);
await expectPageToBeRenderedWithEmbedSsr(page);
});
});
test.describe("Dynamic Group Booking", () => {
test("Dynamic Group booking link should load", async ({ page }) => {
const users = [
@@ -63,12 +64,39 @@ test.describe("Org", () => {
expect((await page.locator('[data-testid="event-meta"] [data-testid="avatar"]').all()).length).toBe(3);
});
});
test("Organization Homepage - Has Engineering and Marketing Teams", async ({ page }) => {
const response = await page.goto("https://i.cal.com");
expect(response?.status()).toBe(200);
await expect(page.locator("text=Cal.com")).toBeVisible();
await expect(page.locator("text=Engineering")).toBeVisible();
await expect(page.locator("text=Marketing")).toBeVisible();
});
test.describe("Browse the Engineering Team", async () => {
test("By User Navigation", async ({ page }) => {
await page.goto("https://i.cal.com");
await page.click('text="Engineering"');
await expect(page.locator("text=Cal.com Engineering")).toBeVisible();
});
test("By /team/engineering", async ({ page }) => {
await page.goto("https://i.cal.com/team/engineering");
await expect(page.locator("text=Cal.com Engineering")).toBeVisible();
});
test("By /engineering", async ({ page }) => {
await page.goto("https://i.cal.com/engineering");
await expect(page.locator("text=Cal.com Engineering")).toBeVisible();
});
});
});
// This ensures that the route is actually mapped to a page that is using withEmbedSsr
async function expectPageToBeServerSideRendered(page: Page) {
async function expectPageToBeRenderedWithEmbedSsr(page: Page) {
expect(
await page.evaluate(() => {
//@ts-expect-error - __NEXT_DATA__ is a global variable defined by Next.js
return window.__NEXT_DATA__.props.pageProps.isEmbed;
})
).toBe(true);
+4 -2
View File
@@ -5,12 +5,14 @@
"author": "Cal.com Inc.",
"dependencies": {
"@calcom/prisma": "*",
"@langchain/core": "^0.1.26",
"@langchain/openai": "^0.0.14",
"@t3-oss/env-nextjs": "^0.6.1",
"langchain": "^0.0.131",
"langchain": "^0.1.17",
"mailparser": "^3.6.5",
"next": "^13.5.4",
"supports-color": "8.1.1",
"zod": "^3.22.2"
"zod": "^3.22.4"
},
"devDependencies": {
"@types/mailparser": "^3.4.0"
+1 -1
View File
@@ -1,4 +1,4 @@
import { DynamicStructuredTool } from "langchain/tools";
import { DynamicStructuredTool } from "@langchain/core/tools";
import { z } from "zod";
import type { UserList } from "~/src/types/user";
+1 -1
View File
@@ -1,4 +1,4 @@
import { DynamicStructuredTool } from "langchain/tools";
import { DynamicStructuredTool } from "@langchain/core/tools";
import { z } from "zod";
import { env } from "../env.mjs";
+1 -1
View File
@@ -1,4 +1,4 @@
import { DynamicStructuredTool } from "langchain/tools";
import { DynamicStructuredTool } from "@langchain/core/tools";
import { z } from "zod";
import { env } from "../env.mjs";
+1 -1
View File
@@ -1,4 +1,4 @@
import { DynamicStructuredTool } from "langchain/tools";
import { DynamicStructuredTool } from "@langchain/core/tools";
import { z } from "zod";
import { env } from "../env.mjs";
+1 -1
View File
@@ -1,4 +1,4 @@
import { DynamicStructuredTool } from "langchain/tools";
import { DynamicStructuredTool } from "@langchain/core/tools";
import { z } from "zod";
import { env } from "../env.mjs";
+1 -1
View File
@@ -1,4 +1,4 @@
import { DynamicStructuredTool } from "langchain/tools";
import { DynamicStructuredTool } from "@langchain/core/tools";
import { z } from "zod";
import { env } from "~/src/env.mjs";
+1 -1
View File
@@ -1,4 +1,4 @@
import { DynamicStructuredTool } from "langchain/tools";
import { DynamicStructuredTool } from "@langchain/core/tools";
import { z } from "zod";
import { env } from "../env.mjs";
+1 -1
View File
@@ -1,5 +1,5 @@
import { ChatOpenAI } from "@langchain/openai";
import { initializeAgentExecutorWithOptions } from "langchain/agents";
import { ChatOpenAI } from "langchain/chat_models/openai";
import { env } from "../env.mjs";
import createBookingIfAvailable from "../tools/createBooking";
+1 -1
View File
@@ -41,6 +41,6 @@
"typescript": "^4.9.4",
"tzdata": "^1.0.30",
"uuid": "^8.3.2",
"zod": "^3.22.2"
"zod": "^3.22.4"
}
}
+2
View File
@@ -197,6 +197,7 @@ async function handler(req: NextApiRequest) {
dateTo,
eventTypeId,
userId,
returnDateOverrides: true,
});
const team = await prisma.team.findUnique({
where: { id: teamId },
@@ -232,6 +233,7 @@ async function handler(req: NextApiRequest) {
dateFrom,
dateTo,
eventTypeId,
returnDateOverrides: true,
}),
};
});
+2 -1
View File
@@ -1,5 +1,6 @@
import type { NextApiRequest } from "next";
import getBookingDataSchemaForApi from "@calcom/features/bookings/lib/getBookingDataSchemaForApi";
import handleNewBooking from "@calcom/features/bookings/lib/handleNewBooking";
import { defaultResponder } from "@calcom/lib/server";
@@ -206,7 +207,7 @@ async function handler(req: NextApiRequest) {
const { userId, isAdmin } = req;
if (isAdmin) req.userId = req.body.userId || userId;
return await handleNewBooking(req);
return await handleNewBooking(req, getBookingDataSchemaForApi);
}
export default defaultResponder(handler);
@@ -1,4 +1,4 @@
import { CAL_URL } from "@calcom/lib/constants";
import { WEBSITE_URL } from "@calcom/lib/constants";
export default function getCalLink(eventType: {
team?: { slug: string | null } | null;
@@ -6,7 +6,7 @@ export default function getCalLink(eventType: {
users?: { username: string | null }[];
slug: string;
}) {
return `${CAL_URL}/${
return `${WEBSITE_URL}/${
eventType?.team
? `team/${eventType?.team?.slug}`
: eventType?.owner
@@ -3,7 +3,6 @@ import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import type { InferGetStaticPropsType } from "next";
import { getLayout } from "@calcom/features/MainLayoutAppDir";
import { APP_NAME } from "@calcom/lib/constants";
import { validStatuses } from "~/bookings/lib/validStatuses";
@@ -23,6 +22,6 @@ export const generateStaticParams = async () => {
return validStatuses.map((status) => ({ status }));
};
export default WithLayout({ getLayout, getData, Page })<"P">;
export default WithLayout({ getLayout: null, getData, Page })<"P">;
export const dynamic = "force-static";
+1 -3
View File
@@ -1,8 +1,6 @@
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import { getLayout } from "@calcom/features/MainLayoutAppDir";
import EventTypes from "~/event-types/views/event-types-listing-view";
export const generateMetadata = async () =>
@@ -11,4 +9,4 @@ export const generateMetadata = async () =>
(t) => t("event_types_page_subtitle")
);
export default WithLayout({ getLayout, Page: EventTypes })<"P">;
export default WithLayout({ getLayout: null, Page: EventTypes })<"P">;
+1 -3
View File
@@ -3,8 +3,6 @@ import { withAppDirSsr } from "app/WithAppDirSsr";
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import { getLayout } from "@calcom/features/MainLayoutAppDir";
import { getServerSideProps } from "@lib/insights/getServerSideProps";
import { type inferSSRProps } from "@lib/types/inferSSRProps";
@@ -16,4 +14,4 @@ export const generateMetadata = async () =>
const getData = withAppDirSsr<inferSSRProps<typeof getServerSideProps>>(getServerSideProps);
export default WithLayout({ getLayout, getData, Page: LegacyPage });
export default WithLayout({ getLayout: null, getData, Page: LegacyPage });
@@ -18,7 +18,8 @@ export const generateMetadata = async ({
}) => {
const legacyCtx = buildLegacyCtx(headers(), cookies(), params, searchParams);
const props = await getData(legacyCtx as unknown as GetServerSidePropsContext);
const { entity, user, slug, booking } = props;
const { eventData, user, slug, booking } = props;
const entity = eventData.entity;
const { trpc } = await import("@calcom/trpc");
const { data: event } = trpc.viewer.public.event.useQuery(
{ username: user, eventSlug: slug, isTeamEvent: false, org: entity.orgSlug ?? null },
@@ -16,7 +16,8 @@ export const generateMetadata = async ({
}) => {
const legacyCtx = buildLegacyCtx(headers(), cookies(), params, searchParams);
const props = await getData(legacyCtx as unknown as GetServerSidePropsContext);
const { entity, user, slug, booking } = props;
const { eventData, user, slug, booking } = props;
const entity = eventData.entity;
const { trpc } = await import("@calcom/trpc");
const { data: event } = trpc.viewer.public.event.useQuery(
{ username: user, eventSlug: slug, isTeamEvent: false, org: entity.orgSlug ?? null },
+1 -3
View File
@@ -3,8 +3,6 @@ import { withAppDirSsr } from "app/WithAppDirSsr";
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import { getLayout } from "@calcom/features/MainLayoutAppDir";
import { getServerSideProps } from "@lib/teams/getServerSideProps";
export const generateMetadata = async () =>
@@ -13,4 +11,4 @@ export const generateMetadata = async () =>
(t) => t("create_manage_teams_collaborative")
);
export default WithLayout({ getData: withAppDirSsr(getServerSideProps), getLayout, Page })<"P">;
export default WithLayout({ getData: withAppDirSsr(getServerSideProps), getLayout: null, Page })<"P">;
+1 -2
View File
@@ -1,7 +1,6 @@
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import { getLayout } from "@calcom/features/MainLayoutAppDir";
import LegacyPage from "@calcom/features/ee/workflows/pages/index";
export const generateMetadata = async () =>
@@ -10,4 +9,4 @@ export const generateMetadata = async () =>
(t) => t("workflows_to_automate_notifications")
);
export default WithLayout({ getLayout, Page: LegacyPage })<"P">;
export default WithLayout({ getLayout: null, Page: LegacyPage })<"P">;
@@ -1,5 +1,5 @@
import Link from "next/link";
import { Fragment } from "react";
import { Fragment, useEffect } from "react";
import { InstallAppButton } from "@calcom/app-store/components";
import DisconnectIntegration from "@calcom/features/apps/components/DisconnectIntegration";
@@ -15,10 +15,12 @@ import {
AppSkeletonLoader as SkeletonLoader,
ShellSubHeading,
Label,
showToast,
} from "@calcom/ui";
import { Calendar } from "@calcom/ui/components/icon";
import { QueryCell } from "@lib/QueryCell";
import useRouterQuery from "@lib/hooks/useRouterQuery";
import AppListCard from "@components/AppListCard";
import AdditionalCalendarSelector from "@components/apps/AdditionalCalendarSelector";
@@ -182,6 +184,15 @@ function ConnectedCalendarsList(props: Props) {
export function CalendarListContainer(props: { heading?: boolean; fromOnboarding?: boolean }) {
const { t } = useLocale();
const { heading = true, fromOnboarding } = props;
const { error, setQuery: setError } = useRouterQuery("error");
useEffect(() => {
if (error === "account_already_linked") {
showToast(t(error), "error", { id: error });
setError(undefined);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const utils = trpc.useContext();
const onChanged = () =>
Promise.allSettled([
@@ -2,7 +2,7 @@ import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation";
import { doesAppSupportTeamInstall } from "@calcom/app-store/utils";
import { Spinner } from "@calcom/features/calendars/weeklyview/components/spinner/Spinner";
import type { UserAdminTeams } from "@calcom/features/ee/teams/lib/getUserAdminTeams";
import { CAL_URL } from "@calcom/lib/constants";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc/react";
import type { AppFrontendPayload } from "@calcom/types/App";
@@ -125,7 +125,7 @@ export const InstallAppButtonChild = ({
StartIcon={(props) => (
<Avatar
alt={team.logo || ""}
imageSrc={team.logo || `${CAL_URL}/${team.logo}/avatar.png`} // if no image, use default avatar
imageSrc={team.logo || `${WEBAPP_URL}/${team.logo}/avatar.png`} // if no image, use default avatar
size="sm"
{...props}
/>
@@ -9,7 +9,11 @@ export default function InstalledAppsLayout({
...rest
}: { children: React.ReactNode } & ComponentProps<typeof Shell>) {
return (
<Shell {...rest} hideHeadingOnMobile>
<Shell
{...rest}
title="Installed Apps"
description="Manage your installed apps or change settings"
hideHeadingOnMobile>
<AppCategoryNavigation baseURL="/apps/installed" containerClassname="min-w-0 w-full">
{children}
</AppCategoryNavigation>
@@ -3,8 +3,6 @@ import Link from "next/link";
import type { EventTypeSetupProps, FormValues } from "pages/event-types/[type]";
import { useEffect, useState } from "react";
import { Controller, useFormContext } from "react-hook-form";
import short from "short-uuid";
import { v5 as uuidv5 } from "uuid";
import type { z } from "zod";
import type { EventNameObjectType } from "@calcom/core/event";
@@ -20,8 +18,8 @@ import { FormBuilder } from "@calcom/features/form-builder/FormBuilder";
import type { EditableSchema } from "@calcom/features/form-builder/schema";
import { BookerLayoutSelector } from "@calcom/features/settings/BookerLayoutSelector";
import { classNames } from "@calcom/lib";
import { APP_NAME, CAL_URL } from "@calcom/lib/constants";
import { IS_VISUAL_REGRESSION_TESTING } from "@calcom/lib/constants";
import { APP_NAME, IS_VISUAL_REGRESSION_TESTING, WEBSITE_URL } from "@calcom/lib/constants";
import { generateHashedLink } from "@calcom/lib/generateHashedLink";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { Prisma } from "@calcom/prisma/client";
import { trpc } from "@calcom/trpc/react";
@@ -32,6 +30,7 @@ import {
Label,
SettingsToggle,
showToast,
Switch,
TextField,
Tooltip,
} from "@calcom/ui";
@@ -41,22 +40,17 @@ import RequiresConfirmationController from "./RequiresConfirmationController";
const CustomEventTypeModal = dynamic(() => import("@components/eventtype/CustomEventTypeModal"));
const generateHashedLink = (id: number) => {
const translator = short();
const seed = `${id}:${new Date().getTime()}`;
const uid = translator.fromUUID(uuidv5(seed, uuidv5.URL));
return uid;
};
export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps, "eventType" | "team">) => {
const connectedCalendarsQuery = trpc.viewer.connectedCalendars.useQuery();
const { data: user } = trpc.viewer.me.useQuery();
const formMethods = useFormContext<FormValues>();
const { t } = useLocale();
const [showEventNameTip, setShowEventNameTip] = useState(false);
const [hashedLinkVisible, setHashedLinkVisible] = useState(!!formMethods.getValues("hashedLink"));
const [redirectUrlVisible, setRedirectUrlVisible] = useState(!!formMethods.getValues("successRedirectUrl"));
const [useEventTypeDestinationCalendarEmail, setUseEventTypeDestinationCalendarEmail] = useState(
formMethods.getValues("useEventTypeDestinationCalendarEmail")
);
const [hashedUrl, setHashedUrl] = useState(eventType.hashedLink?.link);
const bookingFields: Prisma.JsonObject = {};
@@ -82,7 +76,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
const [requiresConfirmation, setRequiresConfirmation] = useState(
formMethods.getValues("requiresConfirmation")
);
const placeholderHashedLink = `${CAL_URL}/d/${hashedUrl}/${formMethods.getValues("slug")}`;
const placeholderHashedLink = `${WEBSITE_URL}/d/${hashedUrl}/${formMethods.getValues("slug")}`;
const seatsEnabled = formMethods.watch("seatsPerTimeSlotEnabled");
const noShowFeeEnabled =
formMethods.getValues("metadata")?.apps?.stripe?.enabled === true &&
@@ -125,6 +119,8 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
const seatsLocked = shouldLockDisableProps("seatsPerTimeSlotEnabled");
const closeEventNameTip = () => setShowEventNameTip(false);
const displayDestinationCalendarSelector =
!!connectedCalendarsQuery.data?.connectedCalendars.length && !team;
return (
<div className="flex flex-col space-y-4">
@@ -134,7 +130,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
* This will fallback to each user selected destination calendar.
*/}
<div className="border-subtle space-y-6 rounded-lg border p-6">
{!!connectedCalendarsQuery.data?.connectedCalendars.length && !team && (
{displayDestinationCalendarSelector && (
<div className="flex flex-col">
<div className="flex justify-between">
<div>
@@ -180,6 +176,18 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
}
/>
</div>
{displayDestinationCalendarSelector && (
<div className="w-full">
<Switch
label={t("display_add_to_calendar_organizer")}
checked={useEventTypeDestinationCalendarEmail}
onCheckedChange={(val) => {
setUseEventTypeDestinationCalendarEmail(val);
formMethods.setValue("useEventTypeDestinationCalendarEmail", val);
}}
/>
</div>
)}
</div>
<BookerLayoutSelector fallbackToUserSettings isDark={selectedThemeIsDark} isOuterBorder={true} />
@@ -11,7 +11,7 @@ import type { EventLocationType } from "@calcom/app-store/locations";
import { getEventLocationType, MeetLocationType } from "@calcom/app-store/locations";
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
import { CAL_URL } from "@calcom/lib/constants";
import { WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { md } from "@calcom/lib/markdownIt";
import { slugify } from "@calcom/lib/slugify";
@@ -401,7 +401,7 @@ export const EventSetupTab = (
The “Add to calendar” for this event type needs to be a Google Calendar for Meet to work.
Change it{" "}
<Link
href={`${CAL_URL}/event-types/${formMethods.getValues("id")}?tabName=advanced`}
href={`${WEBAPP_URL}/event-types/${formMethods.getValues("id")}?tabName=advanced`}
className="underline">
here.
</Link>{" "}
@@ -412,7 +412,7 @@ export const EventSetupTab = (
{isChildrenManagedEventType && !locationAvailable && locationDetails && (
<p className="pl-1 text-sm leading-none text-red-600">
{t("app_not_connected", { appName: locationDetails.name })}{" "}
<a className="underline" href={`${CAL_URL}/apps/${locationDetails.slug}`}>
<a className="underline" href={`${WEBAPP_URL}/apps/${locationDetails.slug}`}>
{t("connect_now")}
</a>
</p>
@@ -444,9 +444,10 @@ export const EventSetupTab = (
const lengthLockedProps = shouldLockDisableProps("length");
const descriptionLockedProps = shouldLockDisableProps("description");
const urlPrefix = orgBranding
? orgBranding?.fullDomain.replace(/^(https?:|)\/\//, "")
: `${CAL_URL?.replace(/^(https?:|)\/\//, "")}`;
: `${WEBSITE_URL?.replace(/^(https?:|)\/\//, "")}`;
return (
<div>
+258 -140
View File
@@ -1,6 +1,6 @@
import { Trans } from "next-i18next";
import Link from "next/link";
import type { EventTypeSetupProps, FormValues } from "pages/event-types/[type]";
import type { EventTypeSetupProps, FormValues, Host } from "pages/event-types/[type]";
import { useEffect, useRef, useState } from "react";
import type { ComponentProps, Dispatch, SetStateAction } from "react";
import { Controller, useFormContext, useWatch } from "react-hook-form";
@@ -21,6 +21,13 @@ interface IUserToValue {
email: string;
}
type TeamMember = {
value: string;
label: string;
avatar: string;
email: string;
};
const mapUserToValue = ({ id, name, username, avatar, email }: IUserToValue, pendingString: string) => ({
value: `${id || ""}`,
label: `${name || email || ""}${!username ? ` (${pendingString})` : ""}`,
@@ -109,7 +116,7 @@ const AssignAllTeamMembers = ({
onInactive?: () => void;
}) => {
const { t } = useLocale();
const formMethods = useFormContext<FormValues>();
const { setValue } = useFormContext<FormValues>();
return (
<Controller<FormValues>
@@ -117,10 +124,10 @@ const AssignAllTeamMembers = ({
render={() => (
<SettingsToggle
title={t("automatically_add_all_team_members")}
description={t("including_people_who_join_in_future")}
labelClassName="mt-0.5 font-normal"
checked={assignAllTeamMembers}
onCheckedChange={(active) => {
formMethods.setValue("assignAllTeamMembers", active);
setValue("assignAllTeamMembers", active);
setAssignAllTeamMembers(active);
if (active) {
onActive();
@@ -128,7 +135,6 @@ const AssignAllTeamMembers = ({
onInactive();
}
}}
toggleSwitchAtTheEnd
/>
)}
/>
@@ -148,13 +154,13 @@ const CheckedHostField = ({
labelText?: string;
placeholder: string;
isFixed: boolean;
value: { isFixed: boolean; userId: number }[];
onChange?: (options: { isFixed: boolean; userId: number }[]) => void;
value: Host[];
onChange?: (options: Host[]) => void;
options?: Options<CheckedSelectOption>;
helperText?: React.ReactNode | string;
} & Omit<Partial<ComponentProps<typeof CheckedTeamSelect>>, "onChange" | "value">) => {
return (
<div className="bg-muted flex flex-col space-y-5 p-4">
<div className="flex flex-col rounded-md">
<div>
{labelText ? <Label>{labelText}</Label> : <></>}
<CheckedTeamSelect
@@ -165,23 +171,22 @@ const CheckedHostField = ({
options.map((option) => ({
isFixed,
userId: parseInt(option.value, 10),
priority: option.priority ?? 2,
}))
);
}}
value={(value || [])
.filter(({ isFixed: _isFixed }) => isFixed === _isFixed)
.map(
(host) =>
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
options.find((member) => member.value === host.userId.toString())!
)
.map((host) => {
const option = options.find((member) => member.value === host.userId.toString());
return option ? { ...option, priority: host.priority ?? 2, isFixed } : options[0];
})
.filter(Boolean)}
controlShouldRenderValue={false}
options={options}
placeholder={placeholder}
{...rest}
/>
{helperText && <p className="text-subtle text-sm">{helperText}</p>}
</div>
</div>
);
@@ -199,6 +204,156 @@ const FixedHostHelper = (
</Trans>
);
const FixedHosts = ({
teamMembers,
value,
onChange,
assignAllTeamMembers,
setAssignAllTeamMembers,
isRoundRobinEvent = false,
}: {
value: Host[];
onChange: (hosts: Host[]) => void;
teamMembers: TeamMember[];
assignAllTeamMembers: boolean;
setAssignAllTeamMembers: Dispatch<SetStateAction<boolean>>;
isRoundRobinEvent?: boolean;
}) => {
const { t } = useLocale();
const { getValues, setValue } = useFormContext<FormValues>();
const hasActiveFixedHosts = isRoundRobinEvent && getValues("hosts").some((host) => host.isFixed);
const [isDisabled, setIsDisabled] = useState(hasActiveFixedHosts);
return (
<div className="mt-5 rounded-lg">
{!isRoundRobinEvent ? (
<>
<div className="border-subtle mt-5 rounded-t-md border p-6 pb-5">
<Label className="mb-1 text-sm font-semibold">{t("fixed_hosts")}</Label>
<p className="text-subtle max-w-full break-words text-sm leading-tight">{FixedHostHelper}</p>
</div>
<div className="border-subtle rounded-b-md border border-t-0">
<AddMembersWithSwitch
teamMembers={teamMembers}
value={value}
onChange={onChange}
assignAllTeamMembers={assignAllTeamMembers}
setAssignAllTeamMembers={setAssignAllTeamMembers}
automaticAddAllEnabled={!isRoundRobinEvent}
isFixed={true}
onActive={() =>
setValue(
"hosts",
teamMembers.map((teamMember) => ({
isFixed: true,
userId: parseInt(teamMember.value, 10),
priority: 2,
}))
)
}
/>
</div>
</>
) : (
<SettingsToggle
toggleSwitchAtTheEnd={true}
title={t("fixed_hosts")}
description={FixedHostHelper}
checked={isDisabled}
labelClassName="text-sm"
descriptionClassName=" text-sm text-subtle"
onCheckedChange={(checked) => {
if (!checked) {
const rrHosts = getValues("hosts")
.filter((host) => !host.isFixed)
.sort((a, b) => (b.priority ?? 2) - (a.priority ?? 2));
setValue("hosts", rrHosts);
}
setIsDisabled(checked);
}}
childrenClassName="lg:ml-0">
<div className="border-subtle flex flex-col gap-6 rounded-bl-md rounded-br-md border border-t-0">
<AddMembersWithSwitch
teamMembers={teamMembers}
value={value}
onChange={onChange}
assignAllTeamMembers={assignAllTeamMembers}
setAssignAllTeamMembers={setAssignAllTeamMembers}
automaticAddAllEnabled={!isRoundRobinEvent}
isFixed={true}
onActive={() =>
setValue(
"hosts",
teamMembers.map((teamMember) => ({
isFixed: true,
userId: parseInt(teamMember.value, 10),
priority: 2,
}))
)
}
/>
</div>
</SettingsToggle>
)}
</div>
);
};
const AddMembersWithSwitch = ({
teamMembers,
value,
onChange,
assignAllTeamMembers,
setAssignAllTeamMembers,
automaticAddAllEnabled,
onActive,
isFixed,
}: {
value: Host[];
onChange: (hosts: Host[]) => void;
teamMembers: TeamMember[];
assignAllTeamMembers: boolean;
setAssignAllTeamMembers: Dispatch<SetStateAction<boolean>>;
automaticAddAllEnabled: boolean;
onActive: () => void;
isFixed: boolean;
}) => {
const { t } = useLocale();
const { setValue } = useFormContext<FormValues>();
return (
<div className="rounded-md ">
<div className="flex flex-col rounded-md px-6 pb-2 pt-6">
{automaticAddAllEnabled ? (
<div className="mb-2">
<AssignAllTeamMembers
assignAllTeamMembers={assignAllTeamMembers}
setAssignAllTeamMembers={setAssignAllTeamMembers}
onActive={onActive}
onInactive={() => setValue("hosts", [])}
/>
</div>
) : (
<></>
)}
{!assignAllTeamMembers || !automaticAddAllEnabled ? (
<CheckedHostField
value={value}
onChange={onChange}
isFixed={isFixed}
options={teamMembers.sort(sortByLabel)}
placeholder={t("add_attendees")}
/>
) : (
<></>
)}
</div>
</div>
);
};
const RoundRobinHosts = ({
teamMembers,
value,
@@ -206,68 +361,44 @@ const RoundRobinHosts = ({
assignAllTeamMembers,
setAssignAllTeamMembers,
}: {
value: { isFixed: boolean; userId: number }[];
onChange: (hosts: { isFixed: boolean; userId: number }[]) => void;
teamMembers: {
value: string;
label: string;
avatar: string;
email: string;
}[];
value: Host[];
onChange: (hosts: Host[]) => void;
teamMembers: TeamMember[];
assignAllTeamMembers: boolean;
setAssignAllTeamMembers: Dispatch<SetStateAction<boolean>>;
}) => {
const { t } = useLocale();
const formMethods = useFormContext<FormValues>();
const { setValue } = useFormContext<FormValues>();
return (
<div className="border-subtle mt-6 space-y-5 rounded-lg border px-4 py-6 sm:px-6">
<div className="flex flex-col gap-8">
<CheckedHostField
options={teamMembers.sort(sortByLabel)}
isFixed={true}
onChange={(changeValue) => {
onChange([...value.filter(({ isFixed }) => !isFixed), ...changeValue]);
}}
<div className="rounded-lg ">
<div className="border-subtle mt-5 rounded-t-md border p-6 pb-5">
<Label className="mb-1 text-sm font-semibold">{t("round_robin_hosts")}</Label>
<p className="text-subtle max-w-full break-words text-sm leading-tight">{t("round_robin_helper")}</p>
</div>
<div className="border-subtle rounded-b-md border border-t-0">
<AddMembersWithSwitch
teamMembers={teamMembers}
value={value}
placeholder={t("add_fixed_hosts")}
labelText={t("fixed_hosts")}
helperText={FixedHostHelper}
onChange={onChange}
assignAllTeamMembers={assignAllTeamMembers}
setAssignAllTeamMembers={setAssignAllTeamMembers}
automaticAddAllEnabled={true}
isFixed={false}
onActive={() =>
setValue(
"hosts",
teamMembers
.map((teamMember) => ({
isFixed: false,
userId: parseInt(teamMember.value, 10),
priority: 2,
}))
.sort((a, b) => b.priority - a.priority)
)
}
/>
<div className="bg-muted flex flex-col">
<Label className="p-4">{t("round_robin_hosts")}</Label>
<div className="px-4 pb-2">
<AssignAllTeamMembers
assignAllTeamMembers={assignAllTeamMembers}
setAssignAllTeamMembers={setAssignAllTeamMembers}
onActive={() =>
formMethods.setValue(
"hosts",
teamMembers.map((teamMember) => ({
isFixed: false,
userId: parseInt(teamMember.value, 10),
}))
)
}
onInactive={() => formMethods.setValue("hosts", [])}
/>
</div>
{assignAllTeamMembers ? (
<div className="mb-4" />
) : (
<CheckedHostField
options={teamMembers.sort(sortByLabel)}
onChange={(changeValue) =>
onChange([...value.filter(({ isFixed }) => isFixed), ...changeValue])
}
value={value}
isFixed={false}
placeholder={t("add_attendees")}
helperText={t("round_robin_helper")}
/>
)}
</div>
</div>
</div>
);
@@ -282,14 +413,14 @@ const ChildrenEventTypes = ({
assignAllTeamMembers: boolean;
setAssignAllTeamMembers: Dispatch<SetStateAction<boolean>>;
}) => {
const formMethods = useFormContext<FormValues>();
const { setValue } = useFormContext<FormValues>();
return (
<div className="border-subtle mt-6 space-y-5 rounded-lg border px-4 py-6 sm:px-6">
<div className="flex flex-col gap-4">
<AssignAllTeamMembers
assignAllTeamMembers={assignAllTeamMembers}
setAssignAllTeamMembers={setAssignAllTeamMembers}
onActive={() => formMethods.setValue("children", childrenEventTypeOptions)}
onActive={() => setValue("children", childrenEventTypeOptions)}
/>
{!assignAllTeamMembers ? (
<Controller<FormValues>
@@ -311,12 +442,7 @@ const Hosts = ({
assignAllTeamMembers,
setAssignAllTeamMembers,
}: {
teamMembers: {
value: string;
label: string;
avatar: string;
email: string;
}[];
teamMembers: TeamMember[];
assignAllTeamMembers: boolean;
setAssignAllTeamMembers: Dispatch<SetStateAction<boolean>>;
}) => {
@@ -348,61 +474,45 @@ const Hosts = ({
});
}, [schedulingType, resetField, getValues, submitCount]);
const formMethods = useFormContext<FormValues>();
return (
<Controller<FormValues>
name="hosts"
render={({ field: { onChange, value } }) => {
const schedulingTypeRender = {
COLLECTIVE: (
<div className="border-subtle mt-6 space-y-5 rounded-lg border px-4 py-6 sm:px-6">
<div className="flex flex-col gap-4">
<AssignAllTeamMembers
assignAllTeamMembers={assignAllTeamMembers}
setAssignAllTeamMembers={setAssignAllTeamMembers}
onActive={() =>
formMethods.setValue(
"hosts",
teamMembers.map((teamMember) => ({
isFixed: true,
userId: parseInt(teamMember.value, 10),
}))
)
}
onInactive={() => formMethods.setValue("hosts", [])}
/>
{assignAllTeamMembers ? (
<></>
) : (
<CheckedHostField
value={value}
onChange={onChange}
isFixed={true}
options={teamMembers.sort(sortByLabel)}
placeholder={t("add_attendees")}
labelText={t("team")}
/>
)}
</div>
</div>
<FixedHosts
teamMembers={teamMembers}
value={value}
onChange={onChange}
assignAllTeamMembers={assignAllTeamMembers}
setAssignAllTeamMembers={setAssignAllTeamMembers}
/>
),
ROUND_ROBIN: (
<>
<RoundRobinHosts
<FixedHosts
teamMembers={teamMembers}
value={value}
onChange={(changeValue) => {
onChange([...value.filter((host: Host) => !host.isFixed), ...changeValue]);
}}
assignAllTeamMembers={assignAllTeamMembers}
setAssignAllTeamMembers={setAssignAllTeamMembers}
isRoundRobinEvent={true}
/>
<RoundRobinHosts
teamMembers={teamMembers}
value={value}
onChange={(changeValue) => {
onChange(
[...value.filter((host: Host) => host.isFixed), ...changeValue].sort(
(a, b) => b.priority - a.priority
)
);
}}
assignAllTeamMembers={assignAllTeamMembers}
setAssignAllTeamMembers={setAssignAllTeamMembers}
teamMembers={teamMembers}
onChange={onChange}
value={value}
/>
{/*<TextField
required
type="number"
label={t("minimum_round_robin_hosts_count")}
defaultValue={1}
{...formMethods.register("minimumHostCount")}
addOnSuffix={<>{t("hosts")}</>}
/>*/}
</>
),
MANAGED: <></>,
@@ -449,39 +559,47 @@ export const EventTeamTab = ({
);
});
const isManagedEventType = eventType.schedulingType === SchedulingType.MANAGED;
const formMethods = useFormContext<FormValues>();
const { getValues, setValue } = useFormContext<FormValues>();
const [assignAllTeamMembers, setAssignAllTeamMembers] = useState<boolean>(
formMethods.getValues("assignAllTeamMembers") ?? false
getValues("assignAllTeamMembers") ?? false
);
return (
<div>
{team && !isManagedEventType && (
<div className="space-y-5">
<div className="flex flex-col">
<Label>{t("scheduling_type")}</Label>
<Controller<FormValues>
name="schedulingType"
render={({ field: { value, onChange } }) => (
<Select
options={schedulingTypeOptions}
value={schedulingTypeOptions.find((opt) => opt.value === value)}
className="w-full"
onChange={(val) => {
onChange(val?.value);
formMethods.setValue("assignAllTeamMembers", false);
setAssignAllTeamMembers(false);
}}
/>
)}
/>
<>
<div className="border-subtle flex flex-col rounded-md">
<div className="border-subtle rounded-t-md border p-6 pb-5">
<Label className="mb-1 text-sm font-semibold">{t("assignment")}</Label>
<p className="text-subtle max-w-full break-words text-sm leading-tight">
{t("assignment_description")}
</p>
</div>
<div className="border-subtle rounded-b-md border border-t-0 p-6">
<Label>{t("scheduling_type")}</Label>
<Controller<FormValues>
name="schedulingType"
render={({ field: { value, onChange } }) => (
<Select
options={schedulingTypeOptions}
value={schedulingTypeOptions.find((opt) => opt.value === value)}
className="w-full"
onChange={(val) => {
onChange(val?.value);
setValue("assignAllTeamMembers", false);
setAssignAllTeamMembers(false);
}}
/>
)}
/>
</div>
</div>
<Hosts
assignAllTeamMembers={assignAllTeamMembers}
setAssignAllTeamMembers={setAssignAllTeamMembers}
teamMembers={teamMembersOptions}
/>
</div>
</>
)}
{team && isManagedEventType && (
<ChildrenEventTypes
@@ -352,7 +352,7 @@ function EventTypeSingleLayout({
StartIcon={Code}
color="secondary"
variant="icon"
namespace={eventType.slug}
namespace=""
tooltip={t("embed")}
tooltipSide="bottom"
tooltipOffset={4}
@@ -4,7 +4,7 @@ import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
import { CAL_URL, IS_SELF_HOSTED } from "@calcom/lib/constants";
import { WEBSITE_URL, IS_SELF_HOSTED } from "@calcom/lib/constants";
import type { TRPCClientErrorLike } from "@calcom/trpc/client";
import { trpc } from "@calcom/trpc/react";
import type { AppRouter } from "@calcom/trpc/server/routers/_app";
@@ -45,7 +45,7 @@ export const UsernameAvailabilityField = ({
const usernamePrefix = orgBranding
? orgBranding?.fullDomain.replace(/^(https?:|)\/\//, "")
: `${CAL_URL?.replace(/^(https?:|)\/\//, "")}`;
: `${WEBSITE_URL?.replace(/^(https?:|)\/\//, "")}`;
return (
<Controller
@@ -11,10 +11,8 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
if (!session) {
return {
redirect: {
redirect: {
permanent: false,
destination: "/auth/login",
},
permanent: false,
destination: "/auth/login",
},
};
}
+6 -1
View File
@@ -11,7 +11,12 @@ export default function useRouterQuery<T extends string>(name: T) {
const setQuery = useCallback(
(newValue: string | number | null | undefined) => {
const _searchParams = new URLSearchParams(searchParams ?? undefined);
_searchParams.set(name, newValue as string);
if (typeof newValue === "undefined") {
// when newValue is of type undefined, clear the search param.
_searchParams.delete(name);
} else {
_searchParams.set(name, newValue as string);
}
router.replace(`${pathname}?${_searchParams.toString()}`);
},
[name, pathname, router, searchParams]
@@ -2,7 +2,7 @@ import type { GetServerSidePropsContext } from "next";
import { z } from "zod";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { getBookingForReschedule, getMultipleDurationValue } from "@calcom/features/bookings/lib/get-booking";
import { getBookingForReschedule } from "@calcom/features/bookings/lib/get-booking";
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
import { getSlugOrRequestedSlug } from "@calcom/features/ee/organizations/lib/orgDomains";
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
@@ -82,12 +82,11 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
return {
props: {
entity: eventData.entity,
duration: getMultipleDurationValue(
eventData.metadata?.multipleDuration,
queryDuration,
eventData.length
),
eventData: {
entity: eventData.entity,
length: eventData.length,
metadata: eventData.metadata,
},
booking,
away: false,
user: teamSlug,
@@ -10,7 +10,7 @@ import { FilterToggle } from "@calcom/features/bookings/components/FilterToggle"
import { FiltersContainer } from "@calcom/features/bookings/components/FiltersContainer";
import type { filterQuerySchema } from "@calcom/features/bookings/lib/useFilterQuery";
import { useFilterQuery } from "@calcom/features/bookings/lib/useFilterQuery";
import { ShellMain } from "@calcom/features/shell/Shell";
import Shell from "@calcom/features/shell/Shell";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback";
import type { RouterOutputs } from "@calcom/trpc/react";
@@ -147,7 +147,13 @@ export default function Bookings() {
const [animationParentRef] = useAutoAnimate<HTMLDivElement>();
return (
<ShellMain hideHeadingOnMobile heading={t("bookings")} subtitle={t("bookings_description")}>
<Shell
withoutMain={false}
hideHeadingOnMobile
heading={t("bookings")}
subtitle={t("bookings_description")}
title="Bookings"
description="Create events to share for people to book on your calendar.">
<div className="flex flex-col">
<div className="flex flex-row flex-wrap justify-between">
<HorizontalTabs tabs={tabs} />
@@ -247,6 +253,6 @@ export default function Bookings() {
</div>
</main>
</div>
</ShellMain>
</Shell>
);
}
@@ -82,6 +82,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
cancellationReason: true,
responses: true,
rejectionReason: true,
userPrimaryEmail: true,
user: {
select: {
id: true,
@@ -203,7 +203,7 @@ export default function Success(props: PageProps) {
duration: calculatedDuration,
organizer: {
name: users[0].name || "Nameless",
email: users[0].email || "Email-less",
email: bookingInfo?.userPrimaryEmail || users[0].email || "Email-less",
timeZone: users[0].timeZone,
},
confirmed: !needsConfirmation,
@@ -450,7 +450,9 @@ export default function Success(props: PageProps) {
</span>
<Badge variant="blue">{t("Host")}</Badge>
</div>
<p className="text-default">{bookingInfo.user.email}</p>
<p className="text-default">
{bookingInfo?.userPrimaryEmail ?? bookingInfo.user.email}
</p>
</div>
)}
{bookingInfo?.attendees.map((attendee) => (
@@ -16,18 +16,20 @@ import CreateEventTypeDialog from "@calcom/features/eventtypes/components/Create
import { DuplicateDialog } from "@calcom/features/eventtypes/components/DuplicateDialog";
import { TeamsFilter } from "@calcom/features/filters/components/TeamsFilter";
import { getTeamsFiltersFromQuery } from "@calcom/features/filters/lib/getTeamsFiltersFromQuery";
import { ShellMain } from "@calcom/features/shell/Shell";
import Shell from "@calcom/features/shell/Shell";
import { APP_NAME, WEBAPP_URL } from "@calcom/lib/constants";
import { CAL_URL } from "@calcom/lib/constants";
import { WEBSITE_URL } from "@calcom/lib/constants";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import useMediaQuery from "@calcom/lib/hooks/useMediaQuery";
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
import { HttpError } from "@calcom/lib/http-error";
import type { User } from "@calcom/prisma/client";
import { SchedulingType } from "@calcom/prisma/enums";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc, TRPCClientError } from "@calcom/trpc/react";
import type { UserProfile } from "@calcom/types/UserProfile";
import {
Alert,
Avatar,
@@ -76,6 +78,7 @@ import useMeQuery from "@lib/hooks/useMeQuery";
import SkeletonLoader from "@components/eventtype/SkeletonLoader";
type EventTypeGroups = RouterOutputs["viewer"]["eventTypes"]["getByViewer"]["eventTypeGroups"];
type EventTypeGroupProfile = EventTypeGroups[number]["profile"];
type GetByViewerResponse = RouterOutputs["viewer"]["eventTypes"]["getByViewer"] | undefined;
@@ -89,16 +92,27 @@ interface EventTypeListHeadingProps {
type EventTypeGroup = EventTypeGroups[number];
type EventType = EventTypeGroup["eventTypes"][number];
type DeNormalizedEventType = Omit<EventType, "userIds"> & {
users: (Pick<User, "id" | "name" | "username" | "avatarUrl"> & {
nonProfileUsername: string | null;
profile: UserProfile;
})[];
};
type DeNormalizedEventTypeGroup = Omit<EventTypeGroup, "eventTypes"> & {
eventTypes: DeNormalizedEventType[];
};
interface EventTypeListProps {
group: EventTypeGroup;
group: DeNormalizedEventTypeGroup;
groupIndex: number;
readOnly: boolean;
bookerUrl: string | null;
types: EventType[];
types: DeNormalizedEventType[];
}
interface MobileTeamsTabProps {
eventTypeGroups: EventTypeGroups;
eventTypeGroups: DeNormalizedEventTypeGroup[];
}
const querySchema = z.object({
@@ -136,7 +150,15 @@ const MobileTeamsTab: FC<MobileTeamsTabProps> = (props) => {
);
};
const Item = ({ type, group, readOnly }: { type: EventType; group: EventTypeGroup; readOnly: boolean }) => {
const Item = ({
type,
group,
readOnly,
}: {
type: DeNormalizedEventType;
group: DeNormalizedEventTypeGroup;
readOnly: boolean;
}) => {
const { t } = useLocale();
const content = () => (
@@ -166,11 +188,7 @@ const Item = ({ type, group, readOnly }: { type: EventType; group: EventTypeGrou
return readOnly ? (
<div className="flex-1 overflow-hidden pr-4 text-sm">
{content()}
<EventTypeDescription
// @ts-expect-error FIXME: We have a type mismatch here @hariombalhara @sean-brydon
eventType={type}
shortenDescription
/>
<EventTypeDescription eventType={type} shortenDescription />
</div>
) : (
<Link
@@ -197,7 +215,6 @@ const Item = ({ type, group, readOnly }: { type: EventType; group: EventTypeGrou
)}
</div>
<EventTypeDescription
// @ts-expect-error FIXME: We have a type mismatch here @hariombalhara @sean-brydon
eventType={{ ...type, descriptionAsSafeHTML: type.safeDescription }}
shortenDescription
/>
@@ -243,7 +260,7 @@ export const EventTypeList = ({
await utils.viewer.eventTypes.getByViewer.cancel();
const previousValue = utils.viewer.eventTypes.getByViewer.getData();
if (previousValue) {
const newList = [...types];
const newList = [...types.map(normalizeEventType)];
const itemIndex = newList.findIndex((item) => item.id === id);
if (itemIndex !== -1 && newList[itemIndex]) {
newList[itemIndex].hidden = !newList[itemIndex].hidden;
@@ -272,13 +289,13 @@ export const EventTypeList = ({
});
async function moveEventType(index: number, increment: 1 | -1) {
const newList = [...types];
const newList = [...types.map(normalizeEventType)];
const type = types[index];
const tmp = types[index + increment];
if (tmp) {
newList[index] = tmp;
newList[index + increment] = type;
newList[index] = normalizeEventType(tmp);
newList[index + increment] = normalizeEventType(type);
}
await utils.viewer.eventTypes.getByViewer.cancel();
@@ -306,7 +323,7 @@ export const EventTypeList = ({
}
// inject selection data into url for correct router history
const openDuplicateModal = (eventType: EventType, group: EventTypeGroup) => {
const openDuplicateModal = (eventType: DeNormalizedEventType, group: DeNormalizedEventTypeGroup) => {
const newSearchParams = new URLSearchParams(searchParams ?? undefined);
function setParamsIfDefined(key: string, value: string | number | boolean | null | undefined) {
if (value) newSearchParams.set(key, value.toString());
@@ -331,7 +348,7 @@ export const EventTypeList = ({
await utils.viewer.eventTypes.getByViewer.cancel();
const previousValue = utils.viewer.eventTypes.getByViewer.getData();
if (previousValue) {
const newList = types.filter((item) => item.id !== id);
const newList = types.filter((item) => item.id !== id).map(normalizeEventType);
utils.viewer.eventTypes.getByViewer.setData(undefined, {
...previousValue,
@@ -390,7 +407,7 @@ export const EventTypeList = ({
const embedLink = `${group.profile.slug}/${type.slug}`;
const calLink = `${bookerUrl}/${embedLink}`;
const isPrivateURLEnabled = type.hashedLink?.link;
const placeholderHashedLink = `${CAL_URL}/d/${type.hashedLink?.link}/${type.slug}`;
const placeholderHashedLink = `${WEBSITE_URL}/d/${type.hashedLink?.link}/${type.slug}`;
const isManagedEventType = type.schedulingType === SchedulingType.MANAGED;
const isChildrenManagedEventType =
type.metadata?.managedEventConfig !== undefined && type.schedulingType !== SchedulingType.MANAGED;
@@ -522,7 +539,7 @@ export const EventTypeList = ({
{!isManagedEventType && (
<DropdownMenuItem className="outline-none">
<EventTypeEmbedButton
namespace={type.slug}
namespace=""
as={DropdownItem}
type="button"
StartIcon={Code}
@@ -821,7 +838,7 @@ const Actions = () => {
);
};
const EmptyEventTypeList = ({ group }: { group: EventTypeGroup }) => {
const EmptyEventTypeList = ({ group }: { group: DeNormalizedEventTypeGroup }) => {
const { t } = useLocale();
return (
<>
@@ -843,7 +860,7 @@ const EmptyEventTypeList = ({ group }: { group: EventTypeGroup }) => {
const Main = ({
status,
errorMessage,
data,
data: rawData,
filters,
}: {
status: string;
@@ -854,7 +871,7 @@ const Main = ({
const isMobile = useMediaQuery("(max-width: 768px)");
const searchParams = useCompatSearchParams();
if (!data || status === "pending") {
if (!rawData || status === "pending") {
return <SkeletonLoader />;
}
@@ -863,7 +880,11 @@ const Main = ({
}
const isFilteredByOnlyOneItem =
(filters?.teamIds?.length === 1 || filters?.userIds?.length === 1) && data.eventTypeGroups.length === 1;
(filters?.teamIds?.length === 1 || filters?.userIds?.length === 1) &&
rawData.eventTypeGroups.length === 1;
const data = denormalizePayload(rawData);
return (
<>
{data.eventTypeGroups.length > 1 || isFilteredByOnlyOneItem ? (
@@ -871,7 +892,7 @@ const Main = ({
{isMobile ? (
<MobileTeamsTab eventTypeGroups={data.eventTypeGroups} />
) : (
data.eventTypeGroups.map((group: EventTypeGroup, index: number) => (
data.eventTypeGroups.map((group, index: number) => (
<div
className="mt-4 flex flex-col"
data-testid={`slug-${group.profile.slug}`}
@@ -953,7 +974,10 @@ const EventTypesPage: React.FC & {
}, [orgBranding, user]);
return (
<ShellMain
<Shell
withoutMain={false}
title="Event Types"
description="Create events to share for people to book on your calendar."
withoutSeo
heading={t("event_types_page_title")}
hideHeadingOnMobile
@@ -965,8 +989,39 @@ const EventTypesPage: React.FC & {
description="Create events to share for people to book on your calendar."
/>
<Main data={data} status={status} errorMessage={error?.message} filters={filters} />
</ShellMain>
</Shell>
);
};
export default EventTypesPage;
function normalizeEventType(eventType: DeNormalizedEventType): EventType {
return {
...eventType,
userIds: eventType.users.map((user) => user.id),
};
}
function denormalizePayload(data: NonNullable<GetByViewerResponse>) {
return {
...data,
eventTypeGroups: data.eventTypeGroups.map((eventTypeGroup) => {
return {
...eventTypeGroup,
eventTypes: eventTypeGroup.eventTypes.map((eventType) => {
const { userIds, ...rest } = eventType;
return {
...rest,
users: userIds.map((userId) => {
const user = data.allUsersAcrossAllEventTypes.get(userId);
if (!user) {
throw new Error(`User with id ${userId} not found in allUsersAcrossAllEventTypes`);
}
return user;
}),
};
}),
};
}),
};
}
@@ -15,7 +15,7 @@ import { validateCustomEventName } from "@calcom/core/event";
import type { EventLocationType } from "@calcom/core/location";
import type { ChildrenEventType } from "@calcom/features/eventtypes/components/ChildrenEventTypeSelect";
import { validateIntervalLimitOrder } from "@calcom/lib";
import { CAL_URL } from "@calcom/lib/constants";
import { WEBSITE_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
import { HttpError } from "@calcom/lib/http-error";
@@ -82,6 +82,8 @@ const EventWebhooksTab = dynamic(() =>
const ManagedEventTypeDialog = dynamic(() => import("@components/eventtype/ManagedEventDialog"));
export type Host = { isFixed: boolean; userId: number; priority: number };
export type FormValues = {
id: number;
title: string;
@@ -139,13 +141,14 @@ export type FormValues = {
bookingLimits?: IntervalLimit;
onlyShowFirstAvailableSlot: boolean;
children: ChildrenEventType[];
hosts: { userId: number; isFixed: boolean }[];
hosts: Host[];
bookingFields: z.infer<typeof eventTypeBookingFields>;
availability?: AvailabilityOption;
bookerLayouts: BookerLayoutSettings;
multipleDurationEnabled: boolean;
users: EventTypeSetup["users"];
assignAllTeamMembers: boolean;
useEventTypeDestinationCalendarEmail: boolean;
};
export type CustomInputParsed = typeof customInputSchema._output;
@@ -296,6 +299,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
hosts: eventType.hosts,
successRedirectUrl: eventType.successRedirectUrl || "",
users: eventType.users,
useEventTypeDestinationCalendarEmail: eventType.useEventTypeDestinationCalendarEmail,
children: eventType.children.map((ch) => ({
...ch,
created: true,
@@ -421,10 +425,9 @@ const EventTypePage = (props: EventTypeSetupProps) => {
).length;
}
const permalink = `${CAL_URL}/${team ? `team/${team.slug}` : eventType.users[0].username}/${
const permalink = `${WEBSITE_URL}/${team ? `team/${team.slug}` : eventType.users[0].username}/${
eventType.slug
}`;
const tabMap = {
setup: (
<EventSetupTab
+11 -1
View File
@@ -207,7 +207,17 @@ const nextConfig = {
images: {
unoptimized: true,
},
webpack: (config, { webpack, buildId }) => {
webpack: (config, { webpack, buildId, isServer }) => {
if (isServer) {
// Module not found fix @see https://github.com/boxyhq/jackson/issues/1535#issuecomment-1704381612
config.plugins.push(
new webpack.IgnorePlugin({
resourceRegExp:
/(^@google-cloud\/spanner|^@mongodb-js\/zstd|^@sap\/hana-client\/extension\/Stream$|^@sap\/hana-client|^@sap\/hana-client$|^aws-crt|^aws4$|^better-sqlite3$|^bson-ext$|^cardinal$|^cloudflare:sockets$|^hdb-pool$|^ioredis$|^kerberos$|^mongodb-client-encryption$|^mysql$|^oracledb$|^pg-native$|^pg-query-stream$|^react-native-sqlite-storage$|^snappy\/package\.json$|^snappy$|^sql.js$|^sqlite3$|^typeorm-aurora-data-api-driver$)/,
})
);
}
config.plugins.push(
new CopyWebpackPlugin({
patterns: [
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "3.7.14",
"version": "3.8.0",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
@@ -24,7 +24,7 @@
"yarn": "3.4.1"
},
"dependencies": {
"@boxyhq/saml-jackson": "1.8.2",
"@boxyhq/saml-jackson": "1.18.6",
"@calcom/app-store": "*",
"@calcom/app-store-cli": "*",
"@calcom/core": "*",
@@ -130,7 +130,7 @@
"tailwindcss-radix": "^2.6.0",
"turndown": "^7.1.1",
"uuid": "^8.3.2",
"zod": "^3.22.2"
"zod": "^3.22.4"
},
"devDependencies": {
"@babel/core": "^7.19.6",
+9 -5
View File
@@ -38,7 +38,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const oldPassword = req.body.oldPassword;
const newPassword = req.body.newPassword;
const currentPassword = user.password;
const currentPassword = user.password?.hash;
if (!currentPassword) {
return res.status(400).json({ error: ErrorCode.UserMissingPassword });
}
@@ -53,12 +53,16 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}
const hashedPassword = await hashPassword(newPassword);
await prisma.user.update({
await prisma.userPassword.upsert({
where: {
id: user.id,
userId: user.id,
},
data: {
password: hashedPassword,
create: {
hash: hashedPassword,
userId: user.id,
},
update: {
hash: hashedPassword,
},
});
+7 -1
View File
@@ -41,7 +41,13 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
email: maybeRequest.email,
},
data: {
password: hashedPassword,
password: {
upsert: {
create: { hash: hashedPassword },
update: { hash: hashedPassword },
},
},
emailVerified: new Date(),
},
});
} catch (e) {
+1 -1
View File
@@ -41,7 +41,7 @@ async function handler(req: NextApiRequest) {
data: {
username,
email: userEmail,
password: hashedPassword,
password: { create: { hash: hashedPassword } },
role: "ADMIN",
name: parsedQuery.data.full_name,
emailVerified: new Date(),
@@ -23,13 +23,13 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return res.status(500).json({ error: ErrorCode.InternalServerError });
}
const user = await prisma.user.findUnique({ where: { id: session.user.id } });
const user = await prisma.user.findUnique({ where: { id: session.user.id }, include: { password: true } });
if (!user) {
console.error(`Session references user that no longer exists.`);
return res.status(401).json({ message: "Not authenticated" });
}
if (!user.password && user.identityProvider === IdentityProvider.CAL) {
if (!user.password?.hash && user.identityProvider === IdentityProvider.CAL) {
return res.status(400).json({ error: ErrorCode.UserMissingPassword });
}
@@ -37,8 +37,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return res.json({ message: "Two factor disabled" });
}
if (user.password && user.identityProvider === IdentityProvider.CAL) {
const isCorrectPassword = await verifyPassword(req.body.password, user.password);
if (user.password?.hash && user.identityProvider === IdentityProvider.CAL) {
const isCorrectPassword = await verifyPassword(req.body.password, user.password.hash);
if (!isCorrectPassword) {
return res.status(400).json({ error: ErrorCode.IncorrectPassword });
}
@@ -25,7 +25,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return res.status(500).json({ error: ErrorCode.InternalServerError });
}
const user = await prisma.user.findUnique({ where: { id: session.user.id } });
const user = await prisma.user.findUnique({ where: { id: session.user.id }, include: { password: true } });
if (!user) {
console.error(`Session references user that no longer exists.`);
return res.status(401).json({ message: "Not authenticated" });
@@ -35,7 +35,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return res.status(400).json({ error: ErrorCode.ThirdPartyIdentityProviderEnabled });
}
if (!user.password) {
if (!user.password?.hash) {
return res.status(400).json({ error: ErrorCode.UserMissingPassword });
}
@@ -48,7 +48,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return res.status(500).json({ error: ErrorCode.InternalServerError });
}
const isCorrectPassword = await verifyPassword(req.body.password, user.password);
const isCorrectPassword = await verifyPassword(req.body.password, user.password.hash);
if (!isCorrectPassword) {
return res.status(400).json({ error: ErrorCode.IncorrectPassword });
}
+55 -6
View File
@@ -4,6 +4,7 @@ import { z } from "zod";
import dayjs from "@calcom/dayjs";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { prisma } from "@calcom/prisma";
import { userMetadata } from "@calcom/prisma/zod-utils";
const verifySchema = z.object({
token: z.string(),
@@ -26,23 +27,71 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return res.status(401).json({ message: "Token expired" });
}
const user = await prisma.user.update({
const user = await prisma.user.findFirst({
where: {
email: foundToken?.identifier,
},
});
if (!user) {
return res.status(401).json({ message: "Cannot find a user attached to this token" });
}
const userMetadataParsed = userMetadata.parse(user.metadata);
// Attach the new email and verify
if (userMetadataParsed?.emailChangeWaitingForVerification) {
// Ensure this email isnt in use
const existingUser = await prisma.user.findUnique({
where: { email: userMetadataParsed?.emailChangeWaitingForVerification },
select: {
id: true,
},
});
if (existingUser) {
return res.status(401).json({ message: "A User already exists with this email" });
}
const updatedEmail = userMetadataParsed.emailChangeWaitingForVerification;
delete userMetadataParsed.emailChangeWaitingForVerification;
// Update and re-verify
await prisma.user.update({
where: {
id: user.id,
},
data: {
email: updatedEmail,
metadata: userMetadataParsed,
},
});
await cleanUpVerificationTokens(foundToken.id);
return res.status(200).json({
updatedEmail,
});
}
await prisma.user.update({
where: {
id: user.id,
},
data: {
emailVerified: new Date(),
},
});
const hasCompletedOnboarding = user.completedOnboarding;
return res.redirect(`${WEBAPP_URL}/${hasCompletedOnboarding ? "/event-types" : "/getting-started"}`);
}
async function cleanUpVerificationTokens(id: number) {
// Delete token from DB after it has been used
await prisma.verificationToken.delete({
where: {
id: foundToken?.id,
id,
},
});
const hasCompletedOnboarding = user.completedOnboarding;
res.redirect(`${WEBAPP_URL}/${hasCompletedOnboarding ? "/event-types" : "/getting-started"}`);
}
+1 -3
View File
@@ -17,9 +17,7 @@ async function handler(req: NextApiRequest & { userId?: number }, res: NextApiRe
const session = await getServerSession({ req, res });
/* To mimic API behavior and comply with types */
req.userId = session?.user?.id || -1;
const booking = await handleNewBooking(req, {
isNotAnApiCall: true,
});
const booking = await handleNewBooking(req);
return booking;
}
+3 -95
View File
@@ -1,13 +1,11 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import handleNewBooking from "@calcom/features/bookings/lib/handleNewBooking";
import type { BookingResponse, RecurringBookingCreateBody } from "@calcom/features/bookings/types";
import { handleNewRecurringBooking } from "@calcom/features/bookings/lib/handleNewRecurringBooking";
import type { BookingResponse } from "@calcom/features/bookings/types";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import getIP from "@calcom/lib/getIP";
import { defaultResponder } from "@calcom/lib/server";
import { SchedulingType } from "@calcom/prisma/client";
import type { AppsStatus } from "@calcom/types/Calendar";
// @TODO: Didn't look at the contents of this function in order to not break old booking page.
@@ -18,102 +16,12 @@ async function handler(req: NextApiRequest & { userId?: number }, res: NextApiRe
rateLimitingType: "core",
identifier: userIp,
});
const data: RecurringBookingCreateBody[] = req.body;
const session = await getServerSession({ req, res });
const createdBookings: BookingResponse[] = [];
const allRecurringDates: { start: string | undefined; end: string | undefined }[] = data.map((booking) => {
return { start: booking.start, end: booking.end };
});
const appsStatus: AppsStatus[] | undefined = undefined;
/* To mimic API behavior and comply with types */
req.userId = session?.user?.id || -1;
const numSlotsToCheckForAvailability = 2;
let thirdPartyRecurringEventId = null;
const createdBookings: BookingResponse[] = await handleNewRecurringBooking(req);
// for round robin, the first slot needs to be handled first to define the lucky user
const firstBooking = data[0];
const isRoundRobin = firstBooking.schedulingType === SchedulingType.ROUND_ROBIN;
let luckyUsers = undefined;
if (isRoundRobin) {
const recurringEventReq: NextApiRequest & { userId?: number } = req;
recurringEventReq.body = {
...firstBooking,
appsStatus,
allRecurringDates,
isFirstRecurringSlot: true,
thirdPartyRecurringEventId,
numSlotsToCheckForAvailability,
currentRecurringIndex: 0,
noEmail: false,
};
const firstBookingResult = await handleNewBooking(recurringEventReq, {
isNotAnApiCall: true,
});
luckyUsers = firstBookingResult.luckyUsers?.map((user) => user.id);
}
for (let key = isRoundRobin ? 1 : 0; key < data.length; key++) {
const booking = data[key];
// Disable AppStatus in Recurring Booking Email as it requires us to iterate backwards to be able to compute the AppsStatus for all the bookings except the very first slot and then send that slot's email with statuses
// It is also doubtful that how useful is to have the AppsStatus of all the bookings in the email.
// It is more important to iterate forward and check for conflicts for only first few bookings defined by 'numSlotsToCheckForAvailability'
// if (key === 0) {
// const calcAppsStatus: { [key: string]: AppsStatus } = createdBookings
// .flatMap((book) => (book.appsStatus !== undefined ? book.appsStatus : []))
// .reduce((prev, curr) => {
// if (prev[curr.type]) {
// prev[curr.type].failures += curr.failures;
// prev[curr.type].success += curr.success;
// } else {
// prev[curr.type] = curr;
// }
// return prev;
// }, {} as { [key: string]: AppsStatus });
// appsStatus = Object.values(calcAppsStatus);
// }
const recurringEventReq: NextApiRequest & { userId?: number } = req;
recurringEventReq.body = {
...booking,
appsStatus,
allRecurringDates,
isFirstRecurringSlot: key == 0,
thirdPartyRecurringEventId,
numSlotsToCheckForAvailability,
currentRecurringIndex: key,
noEmail: key !== 0,
luckyUsers,
};
const promiseEachRecurringBooking: ReturnType<typeof handleNewBooking> = handleNewBooking(
recurringEventReq,
{
isNotAnApiCall: true,
}
);
const eachRecurringBooking = await promiseEachRecurringBooking;
createdBookings.push(eachRecurringBooking);
if (!thirdPartyRecurringEventId) {
if (eachRecurringBooking.references && eachRecurringBooking.references.length > 0) {
for (const reference of eachRecurringBooking.references!) {
if (reference.thirdPartyRecurringEventId) {
thirdPartyRecurringEventId = reference.thirdPartyRecurringEventId;
break;
}
}
}
}
}
return createdBookings;
}
+1 -1
View File
@@ -119,7 +119,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
endTime: booking.endTime.toISOString(),
organizer: {
id: user.id,
email: user.email,
email: booking?.userPrimaryEmail ?? user.email,
name,
timeZone: user.timeZone,
language: { translate: tOrganizer, locale: user.locale ?? "en" },
+1 -1
View File
@@ -230,7 +230,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
startTime: booking.startTime.toISOString(),
endTime: booking.endTime.toISOString(),
organizer: {
email: booking.user?.email || "Email-less",
email: booking?.userPrimaryEmail || booking.user?.email || "Email-less",
name: booking.user?.name || "Nameless",
timeZone: booking.user?.timeZone || "Europe/London",
language: { translate: t, locale: booking?.user?.locale ?? "en" },
+9
View File
@@ -6,9 +6,12 @@ import type { GetStaticPaths } from "next";
import Link from "next/link";
import { IS_PRODUCTION } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import prisma from "@calcom/prisma";
import { showToast } from "@calcom/ui";
import { getStaticProps } from "@lib/apps/[slug]/getStaticProps";
import useRouterQuery from "@lib/hooks/useRouterQuery";
import type { inferSSRProps } from "@lib/types/inferSSRProps";
import PageWrapper from "@components/PageWrapper";
@@ -17,6 +20,12 @@ import App from "@components/apps/App";
const md = new MarkdownIt("default", { html: true, breaks: true });
function SingleAppPage(props: inferSSRProps<typeof getStaticProps>) {
const { error, setQuery: setError } = useRouterQuery("error");
const { t } = useLocale();
if (error === "account_already_linked") {
showToast(t(error), "error", { id: error });
setError(undefined);
}
// If it's not production environment, it would be a better idea to inform that the App is disabled.
if (props.isAppDisabled) {
if (!IS_PRODUCTION) {
@@ -26,6 +26,8 @@ export default function Apps({ apps }: PageProps) {
<Shell
isPublic
backPath="/apps"
title="Apps Store"
description="Connecting people, technology and the workplace."
smallHeading
heading={
<>
@@ -45,7 +47,7 @@ export default function Apps({ apps }: PageProps) {
<div className="mb-16">
<div className="grid-col-1 grid grid-cols-1 gap-3 md:grid-cols-3">
{apps
.sort((a, b) => (b.installCount || 0) - (a.installCount || 0))
?.sort((a, b) => (b.installCount || 0) - (a.installCount || 0))
.map((app) => {
return <AppCard key={app.slug} app={app} />;
})}
+6 -1
View File
@@ -16,7 +16,12 @@ export default function Apps({ categories }: Omit<inferSSRProps<typeof getServer
const { t, isLocaleReady } = useLocale();
return (
<Shell isPublic large hideHeadingOnMobile>
<Shell
isPublic
large
hideHeadingOnMobile
title="Apps Store"
description="Connecting people, technology and the workplace.">
<div className="text-md flex items-center gap-1 px-4 pb-3 pt-3 font-normal md:px-8 lg:px-0 lg:pt-0">
<Link
href="/apps"
+12 -2
View File
@@ -3,7 +3,7 @@
import type { ChangeEventHandler } from "react";
import { useState } from "react";
import { getLayout } from "@calcom/features/MainLayout";
import Shell from "@calcom/features/shell/Shell";
import { classNames } from "@calcom/lib";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
@@ -103,4 +103,14 @@ export default function Apps({
export { getServerSideProps };
Apps.PageWrapper = PageWrapper;
Apps.getLayout = getLayout;
Apps.getLayout = (page: React.ReactElement) => {
return (
<Shell
title="Apps Store"
description="Create forms to direct attendees to the correct destinations."
withoutMain={true}
hideHeadingOnMobile>
{page}
</Shell>
);
};
@@ -0,0 +1,96 @@
"use client";
import type { GetServerSidePropsContext } from "next";
import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { Toaster } from "react-hot-toast";
import { z } from "zod";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { showToast } from "@calcom/ui";
import PageWrapper from "@components/PageWrapper";
interface PageProps {
token: string;
updateSession: string;
updatedEmail: string;
}
function VerifyEmailChange(props: PageProps) {
const { update } = useSession();
const { t, isLocaleReady } = useLocale();
const router = useRouter();
useEffect(() => {
async function updateSession() {
await update({ email: props.updatedEmail });
router.push("/event-types");
}
if (props.updateSession) {
updateSession();
if (isLocaleReady) {
showToast(t("verify_email_change_success_toast", { email: props.updatedEmail }), "success");
}
} else {
if (isLocaleReady) {
showToast(t("verify_email_change_failure_toast"), "error");
}
}
// We only need this to run on inital mount. These props can't and won't change due to it being fetched serveside.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div className="flex h-screen w-full items-center justify-center">
<Toaster position="bottom-right" />
</div>
);
}
const tokenSchema = z.object({
token: z.string(),
});
export async function getServerSideProps(context: GetServerSidePropsContext) {
const { token } = tokenSchema.parse(context.query);
if (!token) {
return {
notFound: true,
};
}
const params = new URLSearchParams({
token,
});
const response = await fetch(`${WEBAPP_URL}/api/auth/verify-email?${params.toString()}`, {
method: "POST",
});
if (!response.ok) {
return {
props: {
updateSession: false,
token,
updatedEmail: false,
},
};
}
const data = await response.json();
return {
props: {
updateSession: true,
token,
updatedEmail: data.updatedEmail ?? null,
},
};
}
export default VerifyEmailChange;
VerifyEmailChange.PageWrapper = PageWrapper;
+6 -6
View File
@@ -3,9 +3,8 @@ import Link from "next/link";
import { useRouter, usePathname } from "next/navigation";
import { useCallback } from "react";
import { getLayout } from "@calcom/features/MainLayout";
import { NewScheduleButton, ScheduleListItem } from "@calcom/features/schedules";
import { ShellMain } from "@calcom/features/shell/Shell";
import Shell from "@calcom/features/shell/Shell";
import { AvailabilitySliderTable } from "@calcom/features/timezone-buddy/components/AvailabilitySliderTable";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@@ -167,9 +166,12 @@ export default function AvailabilityPage() {
);
return (
<div>
<ShellMain
<Shell
heading={t("availability")}
title="Availability"
description="Configure times when you are available for bookings."
hideHeadingOnMobile
withoutMain={false}
subtitle={t("configure_availability")}
CTA={
<div className="flex gap-2">
@@ -189,11 +191,9 @@ export default function AvailabilityPage() {
</div>
}>
{searchParams?.get("type") === "team" ? <AvailabilitySliderTable /> : <AvailabilityListWithQuery />}
</ShellMain>
</Shell>
</div>
);
}
AvailabilityPage.getLayout = getLayout;
AvailabilityPage.PageWrapper = PageWrapper;
-4
View File
@@ -1,7 +1,5 @@
import type { GetStaticPaths } from "next";
import { getLayout } from "@calcom/features/MainLayout";
import PageWrapper from "@components/PageWrapper";
import { validStatuses } from "~/bookings/lib/validStatuses";
@@ -12,11 +10,9 @@ export { getStaticProps } from "~/bookings/views/bookings-listing-view.getStatic
const BookingsListingPage = new Proxy<{
(): JSX.Element;
PageWrapper?: typeof PageWrapper;
getLayout?: typeof getLayout;
}>(BookingsListingView, {});
BookingsListingPage.PageWrapper = PageWrapper;
BookingsListingPage.getLayout = getLayout;
export const getStaticPaths: GetStaticPaths = () => {
return {
@@ -8,6 +8,7 @@ export type {
EventTypeSetup,
EventTypeSetupProps,
FormValues,
Host,
} from "~/event-types/views/event-types-single-view";
EventTypePageWrapper.PageWrapper = PageWrapper;
-3
View File
@@ -1,12 +1,9 @@
import { getLayout } from "@calcom/features/MainLayout";
import PageWrapper from "@components/PageWrapper";
import EventTypesPage from "~/event-types/views/event-types-listing-view";
export { getServerSideProps } from "@lib/event-types/getServerSideProps";
EventTypesPage.getLayout = getLayout;
EventTypesPage.PageWrapper = PageWrapper;
export default EventTypesPage;
+8 -5
View File
@@ -1,6 +1,5 @@
"use client";
import { getLayout } from "@calcom/features/MainLayout";
import {
AverageEventDurationChart,
BookingKPICards,
@@ -11,7 +10,7 @@ import {
} from "@calcom/features/insights/components";
import { FiltersProvider } from "@calcom/features/insights/context/FiltersProvider";
import { Filters } from "@calcom/features/insights/filters";
import { ShellMain } from "@calcom/features/shell/Shell";
import Shell from "@calcom/features/shell/Shell";
import { UpgradeTip } from "@calcom/features/tips";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@@ -47,7 +46,12 @@ export default function InsightsPage() {
return (
<div>
<ShellMain heading="Insights" subtitle={t("insights_subtitle")}>
<Shell
withoutMain={false}
heading="Insights"
subtitle={t("insights_subtitle")}
title="Insights"
description="View booking insights across your events.">
<UpgradeTip
plan="team"
title={t("make_informed_decisions")}
@@ -99,12 +103,11 @@ export default function InsightsPage() {
</FiltersProvider>
)}
</UpgradeTip>
</ShellMain>
</Shell>
</div>
);
}
InsightsPage.PageWrapper = PageWrapper;
InsightsPage.getLayout = getLayout;
export { getServerSideProps };
@@ -139,6 +139,9 @@ const AppearanceView = ({
showToast(t("error_updating_settings"), "error");
}
},
onSettled: async () => {
await utils.viewer.me.invalidate();
},
});
return (
@@ -184,6 +187,7 @@ const AppearanceView = ({
</div>
<SectionBottomActions className="mb-6" align="end">
<Button
loading={mutation.isPending}
disabled={isUserThemeSubmitting || !isUserThemeDirty}
type="submit"
data-testid="update-theme-btn"
@@ -210,6 +214,7 @@ const AppearanceView = ({
title={t("bookerlayout_user_settings_title")}
description={t("bookerlayout_user_settings_description")}
isDisabled={isBookerLayoutFormSubmitting || !isBookerLayoutFormDirty}
isLoading={mutation.isPending}
/>
</Form>
@@ -295,6 +300,7 @@ const AppearanceView = ({
</div>
<SectionBottomActions align="end">
<Button
loading={mutation.isPending}
disabled={isBrandColorsFormSubmitting || !isBrandColorsFormDirty}
color="primary"
type="submit">
@@ -5,7 +5,6 @@ import { Controller, useForm, useFormState } from "react-hook-form";
import dayjs from "@calcom/dayjs";
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
import { ShellMain } from "@calcom/features/shell/Shell";
import { useHasTeamPlan } from "@calcom/lib/hooks/useHasPaidPlan";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
@@ -245,10 +244,8 @@ const OutOfOfficePage = () => {
return (
<>
<Meta title={t("out_of_office")} description={t("out_of_office_description")} borderInShellHeader />
<ShellMain>
<OutOfOfficeSection />
<OutOfOfficeEntriesList />
</ShellMain>
<OutOfOfficeSection />
<OutOfOfficeEntriesList />
</>
);
};
+33 -4
View File
@@ -93,8 +93,6 @@ const ProfileView = () => {
const updateProfileMutation = trpc.viewer.updateProfile.useMutation({
onSuccess: async (res) => {
await update(res);
showToast(t("settings_updated_successfully"), "success");
// signout user only in case of password reset
if (res.signOutUser && tempFormValues && res.passwordReset) {
showToast(t("password_reset_email", { email: tempFormValues.email }), "success");
@@ -105,6 +103,12 @@ const ProfileView = () => {
utils.viewer.shouldVerifyEmail.invalidate();
}
if (res.hasEmailBeenChanged && res.sendEmailVerification) {
showToast(t("change_of_email_toast", { email: tempFormValues?.email }), "success");
} else {
showToast(t("settings_updated_successfully"), "success");
}
setConfirmAuthEmailChangeWarningDialogOpen(false);
setTempFormValues(null);
},
@@ -342,6 +346,20 @@ const ProfileView = () => {
type="creation"
Icon={AlertTriangle}>
<div className="mb-10">
<div className="mb-4 grid gap-2 md:grid-cols-2">
<div>
<span className="text-emphasis mb-2 block text-sm font-medium leading-none">
{t("old_email_address")}
</span>
<p className="text-subtle leading-none">{user.email}</p>
</div>
<div>
<span className="text-emphasis mb-2 block text-sm font-medium leading-none">
{t("new_email_address")}
</span>
<p className="text-subtle leading-none">{tempFormValues?.email}</p>
</div>
</div>
<PasswordField
data-testid="password"
name="password"
@@ -356,6 +374,7 @@ const ProfileView = () => {
</div>
<DialogFooter showDivider>
<Button
data-testId="profile-update-email-submit-button"
color="primary"
loading={confirmPasswordMutation.isPending}
onClick={(e) => onConfirmPassword(e)}>
@@ -489,7 +508,12 @@ const ProfileForm = ({
<TextField label={t("full_name")} {...formMethods.register("name")} />
</div>
<div className="mt-6">
<TextField label={t("email")} hint={t("change_email_hint")} {...formMethods.register("email")} />
<TextField
label={t("email")}
hint={t("change_email_hint")}
data-testId="profile-form-email"
{...formMethods.register("email")}
/>
</div>
<div className="mt-6">
<Label>{t("about")}</Label>
@@ -506,7 +530,12 @@ const ProfileForm = ({
</div>
</div>
<SectionBottomActions align="end">
<Button loading={isPending} disabled={isDisabled} color="primary" type="submit">
<Button
loading={isPending}
disabled={isDisabled}
color="primary"
type="submit"
data-testId="profile-submit-button">
{t("update")}
</Button>
</SectionBottomActions>
+25 -5
View File
@@ -1,5 +1,7 @@
"use client";
import { useSearchParams } from "next/navigation";
import { Booker } from "@calcom/atoms";
import { getBookerWrapperClasses } from "@calcom/features/bookings/Booker/utils/getBookerWrapperClasses";
import { BookerSeo } from "@calcom/features/bookings/components/BookerSeo";
@@ -14,6 +16,15 @@ export type PageProps = inferSSRProps<typeof getServerSideProps> & EmbedProps;
export { getServerSideProps };
export const getMultipleDurationValue = (
multipleDurationConfig: number[] | undefined,
queryDuration: string | string[] | null | undefined,
defaultValue: number
) => {
if (!multipleDurationConfig) return null;
if (multipleDurationConfig.includes(Number(queryDuration))) return Number(queryDuration);
return defaultValue;
};
export default function Type({
slug,
user,
@@ -21,10 +32,11 @@ export default function Type({
away,
isEmbed,
isBrandingHidden,
entity,
duration,
eventData,
isInstantMeeting,
}: PageProps) {
const searchParams = useSearchParams();
return (
<main className={getBookerWrapperClasses({ isEmbed: !!isEmbed })}>
<BookerSeo
@@ -33,7 +45,7 @@ export default function Type({
rescheduleUid={booking?.uid}
hideBranding={isBrandingHidden}
isTeamEvent
entity={entity}
entity={eventData.entity}
bookingData={booking}
/>
<Booker
@@ -44,8 +56,16 @@ export default function Type({
isInstantMeeting={isInstantMeeting}
hideBranding={isBrandingHidden}
isTeamEvent
entity={entity}
duration={duration}
entity={eventData.entity}
durationConfig={eventData.metadata?.multipleDuration}
/* TODO: Currently unused, evaluate it is needed-
* Possible alternative approach is to have onDurationChange.
*/
duration={getMultipleDurationValue(
eventData.metadata?.multipleDuration,
searchParams?.get("duration"),
eventData.length
)}
/>
</main>
);
+6 -5
View File
@@ -1,8 +1,7 @@
"use client";
import { getLayout } from "@calcom/features/MainLayout";
import { TeamsListing } from "@calcom/features/ee/teams/components";
import { ShellMain } from "@calcom/features/shell/Shell";
import Shell from "@calcom/features/shell/Shell";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
@@ -18,8 +17,11 @@ function Teams() {
const [user] = trpc.viewer.me.useSuspenseQuery();
return (
<ShellMain
<Shell
withoutMain={false}
heading={t("teams")}
title="Teams"
description="Create and manage teams to use collaborative features."
hideHeadingOnMobile
subtitle={t("create_manage_teams_collaborative")}
CTA={
@@ -35,11 +37,10 @@ function Teams() {
)
}>
<TeamsListing />
</ShellMain>
</Shell>
);
}
Teams.requiresLicense = false;
Teams.PageWrapper = PageWrapper;
Teams.getLayout = getLayout;
export default Teams;
@@ -86,7 +86,8 @@ testBothFutureAndLegacyRoutes.describe("Forgot password", async () => {
});
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
expect(await verifyPassword(newPassword, updatedUser.password!)).toBeTruthy();
const updatedPassword = updatedUser.password!.hash;
expect(await verifyPassword(newPassword, updatedPassword)).toBeTruthy();
// finally, make sure the same URL cannot be used to reset the password again, as it should be expired.
await page.goto(`/auth/forgot-password/${id}`);
+17 -23
View File
@@ -21,29 +21,23 @@ test.describe("Availablity tests", () => {
});
test("Date Overrides", async ({ page }) => {
await test.step("Can add a date override", async () => {
await page.getByTestId("schedules").first().click();
await page.getByTestId("add-override").click();
await page.locator('[id="modal-title"]').waitFor();
await page.getByTestId("incrementMonth").click();
await page.locator('[data-testid="day"][data-disabled="false"]').first().click();
await page.getByTestId("date-override-mark-unavailable").click();
await page.getByTestId("add-override-submit-btn").click();
await page.getByTestId("dialog-rejection").click();
await expect(page.locator('[data-testid="date-overrides-list"] > li')).toHaveCount(1);
await page.locator('[form="availability-form"][type="submit"]').click();
});
await test.step("Date override is displayed in troubleshooter", async () => {
const response = await page.waitForResponse("**/api/trpc/availability/schedule.update?batch=1");
const json = await response.json();
// @ts-expect-error trust me bro
const date = json[0].result.data.json.schedule.availability.find((a) => !!a.date);
const troubleshooterURL = `/availability/troubleshoot?date=${dayjs(date.date).format("YYYY-MM-DD")}`;
await page.goto(troubleshooterURL);
await page.waitForLoadState("networkidle");
await expect(page.locator('[data-testid="troubleshooter-busy-time"]')).toHaveCount(1);
});
await page.getByTestId("schedules").first().click();
await page.getByTestId("add-override").click();
await page.locator('[id="modal-title"]').waitFor();
await page.getByTestId("incrementMonth").click();
await page.locator('[data-testid="day"][data-disabled="false"]').first().click();
await page.getByTestId("date-override-mark-unavailable").click();
await page.getByTestId("add-override-submit-btn").click();
await page.getByTestId("dialog-rejection").click();
await expect(page.locator('[data-testid="date-overrides-list"] > li')).toHaveCount(1);
await page.locator('[form="availability-form"][type="submit"]').click();
const response = await page.waitForResponse("**/api/trpc/availability/schedule.update?batch=1");
const json = await response.json();
const nextMonth = dayjs().add(1, "month").startOf("month");
const troubleshooterURL = `/availability/troubleshoot?date=${nextMonth.format("YYYY-MM-DD")}`;
await page.goto(troubleshooterURL);
await page.waitForLoadState("networkidle");
await expect(page.locator('[data-testid="troubleshooter-busy-time"]')).toHaveCount(1);
});
test("Schedule listing", async ({ page }) => {
+2
View File
@@ -13,6 +13,7 @@ export const createEmailsFixture = () => {
if (IS_MAILHOG_ENABLED) {
const mailhogAPI = mailhog();
return {
messages: mailhogAPI.messages.bind(mailhogAPI),
search: (query: string, kind?: string, start?: number, limit?: number) => {
if (kind === "from" || kind === "to") {
if (!hasUUID(query)) {
@@ -27,6 +28,7 @@ export const createEmailsFixture = () => {
};
} else {
return {
messages: unimplemented,
search: unimplemented,
deleteMessage: unimplemented,
};
+7 -2
View File
@@ -2,6 +2,7 @@ import type { Page } from "@playwright/test";
import type { Team } from "@prisma/client";
import { prisma } from "@calcom/prisma";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
const getRandomSlug = () => `org-${Math.random().toString(36).substring(7)}`;
@@ -15,8 +16,12 @@ export const createOrgsFixture = (page: Page) => {
slug: opts.slug || getRandomSlug(),
requestedSlug: opts.requestedSlug,
});
store.orgs.push(org);
return org;
const orgWithMetadata = {
...org,
metadata: teamMetadataSchema.parse(org.metadata),
};
store.orgs.push(orgWithMetadata);
return orgWithMetadata;
},
get: () => store.orgs,
deleteAll: async () => {
+12 -4
View File
@@ -196,6 +196,9 @@ export const createUsersFixture = (
password: opts?.password ?? uname,
};
},
/**
* In case organizationId is passed, it simulates a scenario where a nonexistent user is added to an organization.
*/
create: async (
opts?:
| (CustomUserOpts & {
@@ -649,7 +652,6 @@ type SupportedTestWorkflows = PrismaType.WorkflowCreateInput;
type CustomUserOptsKeys =
| "username"
| "password"
| "completedOnboarding"
| "locale"
| "name"
@@ -666,6 +668,7 @@ type CustomUserOpts = Partial<Pick<Prisma.User, CustomUserOptsKeys>> & {
useExactUsername?: boolean;
roleInOrganization?: MembershipRole;
schedule?: Schedule;
password?: string | null;
};
// creates the actual user in the db.
@@ -687,7 +690,11 @@ const createUser = (
username: uname,
name: opts?.name,
email: opts?.email ?? `${uname}@example.com`,
password: hashPassword(uname),
password: {
create: {
hash: hashPassword(uname),
},
},
emailVerified: new Date(),
completedOnboarding: opts?.completedOnboarding ?? true,
timeZone: opts?.timeZone ?? TimeZoneEnum.UK,
@@ -726,6 +733,7 @@ const createUser = (
throw new Error("Missing role for user in organization");
}
return {
organizationId,
profiles: {
create: {
uid: ProfileRepository.generateProfileUid(),
@@ -787,7 +795,7 @@ async function confirmPendingPayment(page: Page) {
// login using a replay of an E2E routine.
export async function login(
user: Pick<Prisma.User, "username"> & Partial<Pick<Prisma.User, "password" | "email">>,
user: Pick<Prisma.User, "username"> & Partial<Pick<Prisma.User, "email">> & { password?: string | null },
page: Page
) {
// get locators
@@ -808,7 +816,7 @@ export async function login(
}
export async function apiLogin(
user: Pick<Prisma.User, "username"> & Partial<Pick<Prisma.User, "password" | "email">>,
user: Pick<Prisma.User, "username"> & Partial<Pick<Prisma.User, "email">> & { password: string | null },
page: Page
) {
const csrfToken = await page
+7 -1
View File
@@ -225,7 +225,10 @@ export async function getEmailsReceivedByUser({
if (!emails) return null;
const matchingEmails = await emails.search(userEmail, "to");
if (!matchingEmails?.total) {
console.log(`No emails received by ${userEmail}`);
console.log(
`No emails received by ${userEmail}. All emails sent to:`,
(await emails.messages())?.items.map((e) => e.to)
);
}
return matchingEmails;
}
@@ -358,6 +361,9 @@ export async function doOnOrgDomain(
"x-cal-force-slug": orgSlug,
});
await callback({ page });
await page.setExtraHTTPHeaders({
"x-cal-force-slug": "",
});
}
// When App directory is there, this is the 404 page text. We should work on fixing the 404 page as it changed due to app directory.
+10
View File
@@ -42,6 +42,8 @@ test.describe("2FA Tests", async () => {
await fillOtp({ page, secret: "123456", noRetry: true });
await expect(page.locator('[data-testid="error-submitting-code"]')).toBeVisible();
await removeOtpInput(page);
await fillOtp({
page,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
@@ -162,6 +164,14 @@ test.describe("2FA Tests", async () => {
});
});
async function removeOtpInput(page: Page) {
await page.locator('input[name="2fa6"]').waitFor({ state: "visible", timeout: 30_000 });
// Remove one OTP input
await page.locator('input[name="2fa6"]').focus();
await page.keyboard.press("Backspace");
}
async function fillOtp({ page, secret, noRetry }: { page: Page; secret: string; noRetry?: boolean }) {
let token = authenticator.generate(secret);
if (!noRetry && !totpAuthenticatorCheck(token, secret)) {
@@ -0,0 +1,488 @@
import type { Page } from "@playwright/test";
import { expect } from "@playwright/test";
import { getOrgUsernameFromEmail } from "@calcom/features/auth/signup/utils/getOrgUsernameFromEmail";
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
import { MembershipRole, SchedulingType } from "@calcom/prisma/enums";
import { test } from "../lib/fixtures";
import {
bookTimeSlot,
doOnOrgDomain,
NotFoundPageTextAppDir,
selectFirstAvailableTimeSlotNextMonth,
testName,
} from "../lib/testUtils";
import { expectExistingUserToBeInvitedToOrganization } from "../team/expects";
import { acceptTeamOrOrgInvite, inviteExistingUserToOrganization } from "./lib/inviteUser";
test.describe("Bookings", () => {
test.afterEach(({ orgs, users }) => {
orgs.deleteAll();
users.deleteAll();
});
test.describe("Team Event", () => {
test("Can create a booking for Collective EventType", async ({ page, users, orgs }) => {
const org = await orgs.create({
name: "TestOrg",
});
const teamMatesObj = [
{ name: "teammate-1" },
{ name: "teammate-2" },
{ name: "teammate-3" },
{ name: "teammate-4" },
];
const owner = await users.create(
{
username: "pro-user",
name: "pro-user",
organizationId: org.id,
roleInOrganization: MembershipRole.MEMBER,
},
{
hasTeam: true,
teammates: teamMatesObj,
schedulingType: SchedulingType.COLLECTIVE,
}
);
const { team } = await owner.getFirstTeamMembership();
const teamEvent = await owner.getFirstTeamEvent(team.id);
await expectPageToBeNotFound({ page, url: `/team/${team.slug}/${teamEvent.slug}` });
await doOnOrgDomain(
{
orgSlug: org.slug,
page,
},
async () => {
await bookTeamEvent({ page, team, event: teamEvent });
// All the teammates should be in the booking
for (const teammate of teamMatesObj.concat([{ name: owner.name || "" }])) {
await expect(page.getByText(teammate.name, { exact: true })).toBeVisible();
}
}
);
// TODO: Assert whether the user received an email
});
test("Can create a booking for Round Robin EventType", async ({ page, users, orgs }) => {
const org = await orgs.create({
name: "TestOrg",
});
const teamMatesObj = [
{ name: "teammate-1" },
{ name: "teammate-2" },
{ name: "teammate-3" },
{ name: "teammate-4" },
];
const owner = await users.create(
{
username: "pro-user",
name: "pro-user",
organizationId: org.id,
roleInOrganization: MembershipRole.MEMBER,
},
{
hasTeam: true,
teammates: teamMatesObj,
schedulingType: SchedulingType.ROUND_ROBIN,
}
);
const { team } = await owner.getFirstTeamMembership();
const teamEvent = await owner.getFirstTeamEvent(team.id);
await expectPageToBeNotFound({ page, url: `/team/${team.slug}/${teamEvent.slug}` });
await doOnOrgDomain(
{
orgSlug: org.slug,
page,
},
async () => {
await bookTeamEvent({ page, team, event: teamEvent });
// Since all the users have the same leastRecentlyBooked value
// Anyone of the teammates could be the Host of the booking.
const chosenUser = await page.getByTestId("booking-host-name").textContent();
expect(chosenUser).not.toBeNull();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
expect(teamMatesObj.concat([{ name: owner.name! }]).some(({ name }) => name === chosenUser)).toBe(
true
);
}
);
// TODO: Assert whether the user received an email
});
test("Can access booking page with event slug and team page in lowercase/uppercase/mixedcase", async ({
page,
orgs,
users,
}) => {
const org = await orgs.create({
name: "TestOrg",
});
const teamMatesObj = [
{ name: "teammate-1" },
{ name: "teammate-2" },
{ name: "teammate-3" },
{ name: "teammate-4" },
];
const owner = await users.create(
{
username: "pro-user",
name: "pro-user",
organizationId: org.id,
roleInOrganization: MembershipRole.MEMBER,
},
{
hasTeam: true,
teammates: teamMatesObj,
schedulingType: SchedulingType.COLLECTIVE,
}
);
const { team } = await owner.getFirstTeamMembership();
const { slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id);
const teamSlugUpperCase = team.slug?.toUpperCase();
const teamEventSlugUpperCase = teamEventSlug.toUpperCase();
// This is the most closest to the actual user flow as org1.cal.com maps to /org/orgSlug
await page.goto(`/org/${org.slug}/${teamSlugUpperCase}/${teamEventSlugUpperCase}`);
await page.waitForSelector("[data-testid=day]");
});
});
test.describe("User Event", () => {
test("Can create a booking", async ({ page, users, orgs }) => {
const org = await orgs.create({
name: "TestOrg",
});
const user = await users.create({
username: "pro-user",
name: "pro-user",
organizationId: org.id,
roleInOrganization: MembershipRole.MEMBER,
});
const event = await user.getFirstEventAsOwner();
await page.goto(`/${user.username}/${event.slug}`);
// Shouldn't be servable on the non-org domain
await expect(page.locator(`text=${NotFoundPageTextAppDir}`)).toBeVisible();
await doOnOrgDomain(
{
orgSlug: org.slug,
page,
},
async () => {
await bookUserEvent({ page, user, event });
}
);
});
test.describe("User Event with same slug as another user's", () => {
test("booking is created for first user when first user is booked", async ({ page, users, orgs }) => {
const org = await orgs.create({
name: "TestOrg",
});
const user1 = await users.create({
username: "user1",
name: "User 1",
organizationId: org.id,
roleInOrganization: MembershipRole.MEMBER,
});
const user2 = await users.create({
username: "user2",
name: "User2",
organizationId: org.id,
roleInOrganization: MembershipRole.MEMBER,
});
const user1Event = await user1.getFirstEventAsOwner();
await doOnOrgDomain(
{
orgSlug: org.slug,
page,
},
async () => {
await bookUserEvent({ page, user: user1, event: user1Event });
}
);
});
test("booking is created for second user when second user is booked", async ({ page, users, orgs }) => {
const org = await orgs.create({
name: "TestOrg",
});
const user1 = await users.create({
username: "user1",
name: "User 1",
organizationId: org.id,
roleInOrganization: MembershipRole.MEMBER,
});
const user2 = await users.create({
username: "user2",
name: "User2",
organizationId: org.id,
roleInOrganization: MembershipRole.MEMBER,
});
const user2Event = await user2.getFirstEventAsOwner();
await doOnOrgDomain(
{
orgSlug: org.slug,
page,
},
async () => {
await bookUserEvent({ page, user: user2, event: user2Event });
}
);
});
});
});
test.describe("Scenario with same username in and outside organization", () => {
test("Can create a booking for user with same username in and outside organization", async ({
page,
users,
orgs,
}) => {
const org = await orgs.create({
name: "TestOrg",
});
const username = "john";
const userInsideOrganization = await users.create({
username,
name: "John Inside Organization",
organizationId: org.id,
roleInOrganization: MembershipRole.MEMBER,
eventTypes: [
{
title: "John Inside Org's Meeting",
slug: "john-inside-org-meeting",
length: 15,
},
],
});
const userOutsideOrganization = await users.create({
username,
name: "John Outside Organization",
eventTypes: [
{
title: "John Outside Org's Meeting",
slug: "john-outside-org-meeting",
length: 15,
},
],
});
const eventForUserInsideOrganization = await userInsideOrganization.getFirstEventAsOwner();
const eventForUserOutsideOrganization = await userOutsideOrganization.getFirstEventAsOwner();
// John Inside Org's meeting can't be accessed on userOutsideOrganization's namespace
await expectPageToBeNotFound({
page,
url: `/${userOutsideOrganization.username}/john-inside-org-meeting`,
});
await bookUserEvent({ page, user: userOutsideOrganization, event: eventForUserOutsideOrganization });
await doOnOrgDomain(
{
orgSlug: org.slug,
page,
},
async () => {
// John Outside Org's meeting can't be accessed on userInsideOrganization's namespaces
await expectPageToBeNotFound({
page,
url: `/${userInsideOrganization.username}/john-outside-org-meeting`,
});
await bookUserEvent({ page, user: userInsideOrganization, event: eventForUserInsideOrganization });
}
);
});
});
test.describe("Inviting an existing user and then", () => {
test("create a booking on new link", async ({ page, browser, users, orgs, emails }) => {
const org = await orgs.create({
name: "TestOrg",
});
const owner = await users.create({
username: "owner",
name: "owner",
organizationId: org.id,
roleInOrganization: MembershipRole.OWNER,
});
const userOutsideOrganization = await users.create({
username: "john",
name: "John Outside Organization",
});
await owner.apiLogin();
const { invitedUserEmail } = await inviteExistingUserToOrganization({
page,
organizationId: org.id,
user: userOutsideOrganization,
usersFixture: users,
});
const inviteLink = await expectExistingUserToBeInvitedToOrganization(page, emails, invitedUserEmail);
if (!inviteLink) {
throw new Error("Invite link not found");
}
const usernameInOrg = getOrgUsernameFromEmail(
invitedUserEmail,
org.metadata?.orgAutoAcceptEmail ?? null
);
const usernameOutsideOrg = userOutsideOrganization.username;
// Before invite is accepted the booking page isn't available
await expectPageToBeNotFound({ page, url: `/${usernameInOrg}` });
await userOutsideOrganization.apiLogin();
await acceptTeamOrOrgInvite(page);
await test.step("Book through new link", async () => {
await doOnOrgDomain(
{
orgSlug: org.slug,
page,
},
async () => {
await bookUserEvent({
page,
user: {
username: usernameInOrg,
name: userOutsideOrganization.name,
},
event: await userOutsideOrganization.getFirstEventAsOwner(),
});
}
);
});
await test.step("Booking through old link redirects to new link on org domain", async () => {
const event = await userOutsideOrganization.getFirstEventAsOwner();
await expectRedirectToOrgDomain({
page,
org,
eventSlug: `/${usernameOutsideOrg}/${event.slug}`,
expectedEventSlug: `/${usernameInOrg}/${event.slug}`,
});
// As the redirection correctly happens, the booking would work too which we have verified in previous step. But we can't test that with org domain as that domain doesn't exist.
});
});
});
});
async function bookUserEvent({
page,
user,
event,
}: {
page: Page;
user: {
username: string | null;
name: string | null;
};
event: { slug: string; title: string };
}) {
await page.goto(`/${user.username}/${event.slug}`);
await selectFirstAvailableTimeSlotNextMonth(page);
await bookTimeSlot(page);
await expect(page.getByTestId("success-page")).toBeVisible();
// The title of the booking
const BookingTitle = `${event.title} between ${user.name} and ${testName}`;
await expect(page.getByTestId("booking-title")).toHaveText(BookingTitle);
// The booker should be in the attendee list
await expect(page.getByTestId(`attendee-name-${testName}`)).toHaveText(testName);
}
async function bookTeamEvent({
page,
team,
event,
}: {
page: Page;
team: {
slug: string | null;
name: string | null;
};
event: { slug: string; title: string };
}) {
// Note that even though the default way to access a team booking in an organization is to not use /team in the URL, but it isn't testable with playwright as the rewrite is taken care of by Next.js config which can't handle on the fly org slug's handling
// So, we are using /team in the URL to access the team booking
// There are separate tests to verify that the next.config.js rewrites are working
// Also there are additional checkly tests that verify absolute e2e flow. They are in __checks__/organization.spec.ts
await page.goto(`/team/${team.slug}/${event.slug}`);
await selectFirstAvailableTimeSlotNextMonth(page);
await bookTimeSlot(page);
await expect(page.getByTestId("success-page")).toBeVisible();
// The title of the booking
const BookingTitle = `${event.title} between ${team.name} and ${testName}`;
await expect(page.getByTestId("booking-title")).toHaveText(BookingTitle);
// The booker should be in the attendee list
await expect(page.getByTestId(`attendee-name-${testName}`)).toHaveText(testName);
}
async function expectPageToBeNotFound({ page, url }: { page: Page; url: string }) {
await page.goto(`${url}`);
await expect(page.locator(`text=${NotFoundPageTextAppDir}`)).toBeVisible();
}
async function expectRedirectToOrgDomain({
page,
org,
eventSlug,
expectedEventSlug,
}: {
page: Page;
org: { slug: string | null };
eventSlug: string;
expectedEventSlug: string;
}) {
if (!org.slug) {
throw new Error("Org slug is not defined");
}
page.goto(eventSlug).catch((e) => {
console.log("Expected navigation error to happen");
});
const orgSlug = org.slug;
const orgRedirectUrl = await new Promise(async (resolve) => {
page.on("request", (request) => {
if (request.isNavigationRequest()) {
const requestedUrl = request.url();
console.log("Requested navigation to", requestedUrl);
// Resolve on redirection to org domain
if (requestedUrl.includes(orgSlug)) {
resolve(requestedUrl);
}
}
});
});
expect(orgRedirectUrl).toContain(`${getOrgFullOrigin(org.slug)}${expectedEventSlug}`);
}
@@ -0,0 +1,57 @@
import type { Page } from "@playwright/test";
import type { createUsersFixture } from "playwright/fixtures/users";
export const inviteUserToOrganization = async ({
page,
organizationId,
email,
usersFixture,
}: {
page: Page;
organizationId: number;
email: string;
usersFixture: ReturnType<typeof createUsersFixture>;
}) => {
await page.goto("/settings/organizations/members");
await page.waitForLoadState("networkidle");
const invitedUserEmail = usersFixture.trackEmail({
username: email.split("@")[0],
domain: email.split("@")[1],
});
await inviteAnEmail(page, invitedUserEmail);
return { invitedUserEmail };
};
export const inviteExistingUserToOrganization = async ({
page,
organizationId,
user,
usersFixture,
}: {
page: Page;
organizationId: number;
user: {
email: string;
};
usersFixture: ReturnType<typeof createUsersFixture>;
}) => {
await page.goto("/settings/organizations/members");
await page.waitForLoadState("networkidle");
await inviteAnEmail(page, user.email);
await page.waitForSelector('[data-testid="toast-success"]');
return { invitedUserEmail: user.email };
};
export async function acceptTeamOrOrgInvite(page: Page) {
await page.goto("/settings/teams");
await page.click('[data-testid^="accept-invitation"]');
await page.waitForLoadState("networkidle");
}
async function inviteAnEmail(page: Page, invitedUserEmail: string) {
await page.locator('button:text("Add")').click();
await page.locator('input[name="inviteUser"]').fill(invitedUserEmail);
await page.locator('button:text("Send invite")').click();
await page.waitForLoadState("networkidle");
}
@@ -377,7 +377,7 @@ async function signupFromInviteLink({
return { email };
}
async function signupFromEmailInviteLink({
export async function signupFromEmailInviteLink({
browser,
inviteLink,
expectedUsername,
@@ -385,8 +385,8 @@ async function signupFromEmailInviteLink({
}: {
browser: Browser;
inviteLink: string;
expectedUsername: string;
expectedEmail: string;
expectedUsername?: string;
expectedEmail?: string;
}) {
// Follow invite link in new window
const context = await browser.newContext();
@@ -396,10 +396,14 @@ async function signupFromEmailInviteLink({
await signupPage.locator(`[data-testid="signup-usernamefield"]`).waitFor({ state: "visible" });
await expect(signupPage.locator(`[data-testid="signup-usernamefield"]`)).toBeDisabled();
// await for value. initial value is ""
await expect(signupPage.locator(`[data-testid="signup-usernamefield"]`)).toHaveValue(expectedUsername);
if (expectedUsername) {
await expect(signupPage.locator(`[data-testid="signup-usernamefield"]`)).toHaveValue(expectedUsername);
}
await expect(signupPage.locator(`[data-testid="signup-emailfield"]`)).toBeDisabled();
await expect(signupPage.locator(`[data-testid="signup-emailfield"]`)).toHaveValue(expectedEmail);
if (expectedEmail) {
await expect(signupPage.locator(`[data-testid="signup-emailfield"]`)).toHaveValue(expectedEmail);
}
await signupPage.waitForLoadState("networkidle");
// Check required fields
@@ -0,0 +1,80 @@
import { expect } from "@playwright/test";
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { test } from "../lib/fixtures";
import { fillStripeTestCheckout } from "../lib/testUtils";
test.describe("Teams", () => {
test.afterEach(({ orgs, users }) => {
orgs.deleteAll();
users.deleteAll();
});
test("Can create teams via Wizard", async ({ page, users, orgs }) => {
const org = await orgs.create({
name: "TestOrg",
});
const user = await users.create({
organizationId: org.id,
roleInOrganization: MembershipRole.ADMIN,
});
const inviteeEmail = `${user.username}+invitee@example.com`;
await user.apiLogin();
await page.goto("/teams");
await test.step("Can create team", async () => {
// Click text=Create Team
await page.locator("text=Create a new Team").click();
await page.waitForURL((url) => url.pathname === "/settings/teams/new");
// Fill input[name="name"]
await page.locator('input[name="name"]').fill(`${user.username}'s Team`);
// Click text=Continue
await page.click("[type=submit]");
// TODO: Figure out a way to make this more reliable
// eslint-disable-next-line playwright/no-conditional-in-test
if (IS_TEAM_BILLING_ENABLED) await fillStripeTestCheckout(page);
await expect(page).toHaveURL(/\/settings\/teams\/(\d+)\/onboard-members.*$/i);
await page.waitForSelector('[data-testid="pending-member-list"]');
expect(await page.getByTestId("pending-member-item").count()).toBe(1);
});
await test.step("Can add members", async () => {
await page.getByTestId("new-member-button").click();
await page.locator('[placeholder="email\\@example\\.com"]').fill(inviteeEmail);
await page.getByTestId("invite-new-member-button").click();
await expect(page.locator(`li:has-text("${inviteeEmail}")`)).toBeVisible();
// locator.count() does not await for the expected number of elements
// https://github.com/microsoft/playwright/issues/14278
// using toHaveCount() is more reliable
await expect(page.getByTestId("pending-member-item")).toHaveCount(2);
});
await test.step("Can remove members", async () => {
await expect(page.getByTestId("pending-member-item")).toHaveCount(2);
const lastRemoveMemberButton = page.getByTestId("remove-member-button").last();
await lastRemoveMemberButton.click();
await page.waitForLoadState("networkidle");
await expect(page.getByTestId("pending-member-item")).toHaveCount(1);
// Cleanup here since this user is created without our fixtures.
await prisma.user.delete({ where: { email: inviteeEmail } });
});
await test.step("Can finish team creation", async () => {
await page.getByTestId("publish-button").click();
await expect(page).toHaveURL(/\/settings\/teams\/(\d+)\/profile$/i);
});
await test.step("Can disband team", async () => {
await page.waitForURL(/\/settings\/teams\/(\d+)\/profile$/i);
await page.getByTestId("disband-team-button").click();
await page.getByTestId("dialog-confirmation").click();
await page.waitForURL("/teams");
expect(await page.locator(`text=${user.username}'s Team`).count()).toEqual(0);
});
});
});
+162 -1
View File
@@ -1,8 +1,14 @@
import { expect } from "@playwright/test";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { test } from "./lib/fixtures";
test.describe.configure({ mode: "parallel" });
test.afterEach(({ users }) => users.deleteAll());
test.afterEach(async ({ users }) => {
await users.deleteAll();
});
test.describe("Teams", () => {
test("Profile page is loaded for users in Organization", async ({ page, users }) => {
@@ -20,3 +26,158 @@ test.describe("Teams", () => {
await page.getByTestId("profile-upload-avatar").isVisible();
});
});
test.describe("Update Profile", () => {
test("Cannot update a users email when existing user has same email (verification enabled)", async ({
page,
users,
prisma,
features,
}) => {
const emailVerificationEnabled = features.get("email-verification");
// eslint-disable-next-line playwright/no-conditional-in-test, playwright/no-skipped-test
if (!emailVerificationEnabled?.enabled) test.skip();
const user = await users.create({
name: "update-profile-user",
});
const [emailInfo, emailDomain] = user.email.split("@");
const email = `${emailInfo}-updated@${emailDomain}`;
await user.apiLogin();
await page.goto("/settings/my-account/profile");
const emailInput = page.getByTestId("profile-form-email");
await emailInput.fill(email);
await page.getByTestId("profile-submit-button").click();
await page.getByTestId("password").fill(user?.username ?? "Nameless User");
await page.getByTestId("profile-update-email-submit-button").click();
const toastLocator = await page.waitForSelector('[data-testId="toast-success"]');
const textContent = await toastLocator.textContent();
expect(textContent).toContain(email);
// Instead of dealing with emails in e2e lets just get the token and navigate to it
const verificationToken = await prisma.verificationToken.findFirst({
where: {
identifier: user.email,
},
});
const params = new URLSearchParams({
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
token: verificationToken!.token,
});
await users.create({
email,
});
const verifyUrl = `${WEBAPP_URL}/auth/verify-email-change?${params.toString()}`;
await page.goto(verifyUrl);
const errorLocator = await page.waitForSelector('[data-testId="toast-error"]');
expect(errorLocator).toBeDefined();
await page.goto("/settings/my-account/profile");
const emailInputUpdated = page.getByTestId("profile-form-email");
expect(await emailInputUpdated.inputValue()).toEqual(user.email);
});
test("Can update a users email (verification enabled)", async ({ page, users, prisma, features }) => {
const emailVerificationEnabled = features.get("email-verification");
// eslint-disable-next-line playwright/no-conditional-in-test, playwright/no-skipped-test
if (!emailVerificationEnabled?.enabled) test.skip();
const user = await users.create({
name: "update-profile-user",
});
const [emailInfo, emailDomain] = user.email.split("@");
const email = `${emailInfo}-updated@${emailDomain}`;
await user.apiLogin();
await page.goto("/settings/my-account/profile");
const emailInput = page.getByTestId("profile-form-email");
await emailInput.fill(email);
await page.getByTestId("profile-submit-button").click();
await page.getByTestId("password").fill(user?.username ?? "Nameless User");
await page.getByTestId("profile-update-email-submit-button").click();
const toastLocator = await page.waitForSelector('[data-testId="toast-success"]');
const textContent = await toastLocator.textContent();
expect(textContent).toContain(email);
// Instead of dealing with emails in e2e lets just get the token and navigate to it
const verificationToken = await prisma.verificationToken.findFirst({
where: {
identifier: user.email,
},
});
const params = new URLSearchParams({
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
token: verificationToken!.token,
});
const verifyUrl = `${WEBAPP_URL}/auth/verify-email-change?${params.toString()}`;
await page.goto(verifyUrl);
const successLocator = await page.waitForSelector('[data-testId="toast-success"]');
expect(await successLocator.textContent()).toContain(email);
await page.goto("/settings/my-account/profile");
const emailInputUpdated = page.getByTestId("profile-form-email");
expect(await emailInputUpdated.inputValue()).toEqual(email);
});
test("Can update a users email (verification disabled)", async ({ page, users, prisma, features }) => {
const emailVerificationEnabled = features.get("email-verification");
// eslint-disable-next-line playwright/no-conditional-in-test, playwright/no-skipped-test
if (emailVerificationEnabled?.enabled) test.skip();
const user = await users.create({
name: "update-profile-user",
});
const [emailInfo, emailDomain] = user.email.split("@");
const email = `${emailInfo}-updated@${emailDomain}`;
await user.apiLogin();
await page.goto("/settings/my-account/profile");
const emailInput = page.getByTestId("profile-form-email");
await emailInput.fill(email);
await page.getByTestId("profile-submit-button").click();
await page.getByTestId("password").fill(user?.username ?? "Nameless User");
await page.getByTestId("profile-update-email-submit-button").click();
const toastLocator = await page.waitForSelector('[data-testId="toast-success"]');
expect(await toastLocator.isVisible()).toBe(true);
const emailInputUpdated = page.getByTestId("profile-form-email");
expect(await emailInputUpdated.inputValue()).toEqual(email);
});
});
+13 -3
View File
@@ -10,7 +10,7 @@ export async function expectInvitationEmailToBeReceived(
page: Page,
emails: ReturnType<typeof createEmailsFixture>,
userEmail: string,
subject: string,
subject?: string | null,
returnLink?: string
) {
if (!emails) return null;
@@ -21,10 +21,20 @@ export async function expectInvitationEmailToBeReceived(
expect(receivedEmails?.total).toBe(1);
const [firstReceivedEmail] = (receivedEmails as Messages).items;
expect(firstReceivedEmail.subject).toBe(subject);
if (subject) {
expect(firstReceivedEmail.subject).toBe(subject);
}
if (!returnLink) return;
const dom = new JSDOM(firstReceivedEmail.html);
const anchor = dom.window.document.querySelector(`a[href*="${returnLink}"]`);
return anchor?.getAttribute("href");
}
export async function expectExistingUserToBeInvitedToOrganization(
page: Page,
emails: ReturnType<typeof createEmailsFixture>,
userEmail: string,
subject?: string | null
) {
return expectInvitationEmailToBeReceived(page, emails, userEmail, subject, "settings/team");
}
+1 -210
View File
@@ -2,15 +2,13 @@ import { expect } from "@playwright/test";
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
import { prisma } from "@calcom/prisma";
import { MembershipRole, SchedulingType } from "@calcom/prisma/enums";
import { SchedulingType } from "@calcom/prisma/enums";
import { test } from "./lib/fixtures";
import { testBothFutureAndLegacyRoutes } from "./lib/future-legacy-routes";
import {
bookTimeSlot,
doOnOrgDomain,
fillStripeTestCheckout,
NotFoundPageTextAppDir,
selectFirstAvailableTimeSlotNextMonth,
testName,
todo,
@@ -282,210 +280,3 @@ testBothFutureAndLegacyRoutes.describe("Teams - NonOrg", (routeVariant) => {
todo("Reschedule a Collective EventType booking");
todo("Reschedule a Round Robin EventType booking");
});
test.describe("Teams - Org", () => {
test.afterEach(({ orgs, users }) => {
orgs.deleteAll();
users.deleteAll();
});
test("Can create teams via Wizard", async ({ page, users, orgs }) => {
const org = await orgs.create({
name: "TestOrg",
});
const user = await users.create({
organizationId: org.id,
roleInOrganization: MembershipRole.ADMIN,
});
const inviteeEmail = `${user.username}+invitee@example.com`;
await user.apiLogin();
await page.goto("/teams");
await test.step("Can create team", async () => {
// Click text=Create Team
await page.locator("text=Create a new Team").click();
await page.waitForURL((url) => url.pathname === "/settings/teams/new");
// Fill input[name="name"]
await page.locator('input[name="name"]').fill(`${user.username}'s Team`);
// Click text=Continue
await page.click("[type=submit]");
// TODO: Figure out a way to make this more reliable
// eslint-disable-next-line playwright/no-conditional-in-test
if (IS_TEAM_BILLING_ENABLED) await fillStripeTestCheckout(page);
await expect(page).toHaveURL(/\/settings\/teams\/(\d+)\/onboard-members.*$/i);
await page.waitForSelector('[data-testid="pending-member-list"]');
expect(await page.getByTestId("pending-member-item").count()).toBe(1);
});
await test.step("Can add members", async () => {
await page.getByTestId("new-member-button").click();
await page.locator('[placeholder="email\\@example\\.com"]').fill(inviteeEmail);
await page.getByTestId("invite-new-member-button").click();
await expect(page.locator(`li:has-text("${inviteeEmail}")`)).toBeVisible();
// locator.count() does not await for the expected number of elements
// https://github.com/microsoft/playwright/issues/14278
// using toHaveCount() is more reliable
await expect(page.getByTestId("pending-member-item")).toHaveCount(2);
});
await test.step("Can remove members", async () => {
await expect(page.getByTestId("pending-member-item")).toHaveCount(2);
const lastRemoveMemberButton = page.getByTestId("remove-member-button").last();
await lastRemoveMemberButton.click();
await page.waitForLoadState("networkidle");
await expect(page.getByTestId("pending-member-item")).toHaveCount(1);
// Cleanup here since this user is created without our fixtures.
await prisma.user.delete({ where: { email: inviteeEmail } });
});
await test.step("Can finish team creation", async () => {
await page.getByTestId("publish-button").click();
await expect(page).toHaveURL(/\/settings\/teams\/(\d+)\/profile$/i);
});
await test.step("Can disband team", async () => {
await page.waitForURL(/\/settings\/teams\/(\d+)\/profile$/i);
await page.getByTestId("disband-team-button").click();
await page.getByTestId("dialog-confirmation").click();
await page.waitForURL("/teams");
expect(await page.locator(`text=${user.username}'s Team`).count()).toEqual(0);
});
});
test("Can create a booking for Collective EventType", async ({ page, users, orgs }) => {
const org = await orgs.create({
name: "TestOrg",
});
const teamMatesObj = [
{ name: "teammate-1" },
{ name: "teammate-2" },
{ name: "teammate-3" },
{ name: "teammate-4" },
];
const owner = await users.create(
{
username: "pro-user",
name: "pro-user",
organizationId: org.id,
roleInOrganization: MembershipRole.MEMBER,
},
{
hasTeam: true,
teammates: teamMatesObj,
schedulingType: SchedulingType.COLLECTIVE,
}
);
const { team } = await owner.getFirstTeamMembership();
const { title: teamEventTitle, slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id);
await page.goto(`/team/${team.slug}/${teamEventSlug}`);
await expect(page.locator(`text=${NotFoundPageTextAppDir}`)).toBeVisible();
await doOnOrgDomain(
{
orgSlug: org.slug,
page,
},
async () => {
await page.goto(`/team/${team.slug}/${teamEventSlug}`);
await selectFirstAvailableTimeSlotNextMonth(page);
await bookTimeSlot(page);
await expect(page.getByTestId("success-page")).toBeVisible();
// The title of the booking
const BookingTitle = `${teamEventTitle} between ${team.name} and ${testName}`;
await expect(page.getByTestId("booking-title")).toHaveText(BookingTitle);
// The booker should be in the attendee list
await expect(page.getByTestId(`attendee-name-${testName}`)).toHaveText(testName);
// All the teammates should be in the booking
for (const teammate of teamMatesObj.concat([{ name: owner.name || "" }])) {
await expect(page.getByText(teammate.name, { exact: true })).toBeVisible();
}
}
);
// TODO: Assert whether the user received an email
});
test("Can create a booking for Round Robin EventType", async ({ page, users }) => {
const teamMatesObj = [
{ name: "teammate-1" },
{ name: "teammate-2" },
{ name: "teammate-3" },
{ name: "teammate-4" },
];
const owner = await users.create(
{ username: "pro-user", name: "pro-user" },
{
hasTeam: true,
teammates: teamMatesObj,
schedulingType: SchedulingType.ROUND_ROBIN,
}
);
const { team } = await owner.getFirstTeamMembership();
const { title: teamEventTitle, slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id);
await page.goto(`/team/${team.slug}/${teamEventSlug}`);
await selectFirstAvailableTimeSlotNextMonth(page);
await bookTimeSlot(page);
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
// The person who booked the meeting should be in the attendee list
await expect(page.getByTestId(`attendee-name-${testName}`)).toHaveText(testName);
// The title of the booking
const BookingTitle = `${teamEventTitle} between ${team.name} and ${testName}`;
await expect(page.getByTestId("booking-title")).toHaveText(BookingTitle);
// Since all the users have the same leastRecentlyBooked value
// Anyone of the teammates could be the Host of the booking.
const chosenUser = await page.getByTestId("booking-host-name").textContent();
expect(chosenUser).not.toBeNull();
expect(teamMatesObj.concat([{ name: owner.name! }]).some(({ name }) => name === chosenUser)).toBe(true);
// TODO: Assert whether the user received an email
});
test("Can access booking page with event slug and team page in lowercase/uppercase/mixedcase", async ({
page,
orgs,
users,
}) => {
const org = await orgs.create({
name: "TestOrg",
});
const teamMatesObj = [
{ name: "teammate-1" },
{ name: "teammate-2" },
{ name: "teammate-3" },
{ name: "teammate-4" },
];
const owner = await users.create(
{
username: "pro-user",
name: "pro-user",
organizationId: org.id,
roleInOrganization: MembershipRole.MEMBER,
},
{
hasTeam: true,
teammates: teamMatesObj,
schedulingType: SchedulingType.COLLECTIVE,
}
);
const { team } = await owner.getFirstTeamMembership();
const { slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id);
const teamSlugUpperCase = team.slug?.toUpperCase();
const teamEventSlugUpperCase = teamEventSlug.toUpperCase();
// This is the most closest to the actual user flow as org1.cal.com maps to /org/orgSlug
await page.goto(`/org/${org.slug}/${teamSlugUpperCase}/${teamEventSlugUpperCase}`);
await page.waitForSelector("[data-testid=day]");
});
});
+22 -3
View File
@@ -13,10 +13,17 @@
"reset_password_subject": "{{appName}}: Reset password instructions",
"verify_email_subject": "{{appName}}: Verify your account",
"check_your_email": "Check your email",
"old_email_address":"Old Email",
"new_email_address":"New Email",
"verify_email_page_body": "We've sent an email to {{email}}. It is important to verify your email address to guarantee the best email and calendar deliverability from {{appName}}.",
"verify_email_banner_body": "Verify your email address to guarantee the best email and calendar deliverability",
"verify_email_email_header": "Verify your email address",
"verify_email_email_button": "Verify email",
"verify_email_change_description":"You have recently requested to change the email address you use to log into your {{appName}} account. Please click the button below to confirm your new email address.",
"verify_email_change_success_toast":"Updated your email to {{email}}",
"verify_email_change_failure_toast":"Failed to update email.",
"change_of_email":"Verify your new {{appName}} email address",
"change_of_email_toast":"We have sent a verification link to {{email}}. We will update your email address once you click this link.",
"copy_somewhere_safe": "Save this API key somewhere safe. You will not be able to view it again.",
"verify_email_email_body": "Please verify your email address by clicking the button below.",
"verify_email_by_code_email_body": "Please verify your email address by using the below code.",
@@ -800,6 +807,7 @@
"additional_input_description": "Require scheduler to input additional inputs prior the booking is confirmed",
"label": "Label",
"placeholder": "Placeholder",
"display_add_to_calendar_organizer":"Display 'Add to calendar' email as the organizer",
"type": "Type",
"edit": "Edit",
"add_input": "Add an Input",
@@ -2187,7 +2195,7 @@
"create_your_org_description": "Upgrade to Enterprise and receive a subdomain, unified billing, Insights, extensive whitelabeling and more",
"other_payment_app_enabled": "You can only enable one payment app per event type",
"admin_delete_organization_description": "<ul><li>Teams that are member of this organization will also be deleted along with their event-types</li><li>Users that were part of the organization will not be deleted and their event-types will also remain intact.</li><li>Usernames would be changed to allow them to exist outside the organization</li></ul>",
"admin_delete_organization_title": "Delete organization?",
"admin_delete_organization_title": "Delete {{organizationName}}?",
"published": "Published",
"unpublished": "Unpublished",
"publish": "Publish",
@@ -2237,11 +2245,22 @@
"add_a_redirect": "Add a redirect",
"create_entry": "Create entry",
"time_range": "Time range",
"automatically_add_all_team_members": "Automatically add all team members",
"including_people_who_join_in_future": "Including people who join the team in the future",
"automatically_add_all_team_members": "Add all team members, including future members",
"redirect_to": "Redirect to",
"having_trouble_finding_time": "Having trouble finding a time?",
"show_more": "Show more",
"assignment_description":"Schedule meetings when everyone is available or rotate through members on your team",
"lowest": "lowest",
"low": "low",
"medium": "medium",
"high": "high",
"Highest": "highest",
"send_booker_to": "Send Booker to",
"set_priority": "Set Priority",
"priority_for_user": "Priority for {{userName}}",
"change_priority": "change priority",
"field_identifiers_as_variables": "Use field identifiers as variables for your custom event redirect",
"field_identifiers_as_variables_with_example": "Use field identifiers as variables for your custom event redirect (e.g. {{variable}})",
"account_already_linked": "Account is already linked",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+20 -2
View File
@@ -13,10 +13,17 @@
"reset_password_subject": "{{appName}}: הנחיות לאיפוס סיסמה",
"verify_email_subject": "{{appName}}: יש לאמת את החשבון",
"check_your_email": "בדוק/בדקי את הדוא״ל שלך",
"old_email_address": "כתובת דוא״ל ישנה",
"new_email_address": "כתובת דוא״ל חדשה",
"verify_email_page_body": "שלחנו הודעת דוא״ל לכתובת {{email}}. חשוב שתאמת/י את כתובת הדוא״ל שלך כדי להבטיח שתוכל/י לקבל הודעות מ-{{appName}} לכתובת הדוא״ל וללוח השנה שלך.",
"verify_email_banner_body": "יש לאמת את כתובת הדוא״ל כדי להבטיח שתוכל/י לקבל הודעות לכתובת הדוא״ל וללוח השנה בצורה הטובה ביותר",
"verify_email_email_header": "יש לאמת את כתובת הדוא״ל שלך",
"verify_email_email_button": "אימות כתובת דוא״ל",
"verify_email_change_description": "בדקות האחרונות ביקשת לשנות את כתובת הדוא״ל שמשמשת אותך לכניסה לחשבון שלך אצל {{appName}}. נא ללחוץ על הכפתור להלן כדי לאשר את כתובת הדוא״ל החדשה שלך.",
"verify_email_change_success_toast": "כתובת הדוא״ל שלך עודכנה ל־{{email}}",
"verify_email_change_failure_toast": "עדכון כתובת הדוא״ל נכשל.",
"change_of_email": "נא לאמת את כתובת הדוא״ל החדשה שלך באתר {{appName}}",
"change_of_email_toast": "שלחנו קישור לאימות אל {{email}}. נעדכן את כתובת הדוא״ל שלך לאחר לחיצה על הקישור הזה.",
"copy_somewhere_safe": "חשוב לשמור את מפתח ה-API הזה במקום בטוח. לא תהיה אפשרות לראות אותו שוב.",
"verify_email_email_body": "נא לאמת את כתובת הדוא״ל שלך על ידי לחיצה על הכפתור להלן.",
"verify_email_by_code_email_body": "נא לאמת את כתובת הדוא״ל באמצעות הקוד שמתחת.",
@@ -2237,11 +2244,22 @@
"add_a_redirect": "הוספת הפנייה",
"create_entry": "יצירת רשומה",
"time_range": "טווח זמן",
"automatically_add_all_team_members": "הוספת כל חברי הצוות אוטומטית",
"including_people_who_join_in_future": "כולל אנשים שיצטרפו לצוות בעתיד",
"automatically_add_all_team_members": "הוספת כל חברי הצוות, כולל חברים עתידיים",
"redirect_to": "הפניה אל",
"having_trouble_finding_time": "לא הצלחת למצוא חלון זמן פנוי?",
"show_more": "הצג עוד",
"assignment_description": "תזמון פגישות כשכולם זמינים או להחליף בסבב בין החברים בצוות שלך",
"lowest": "הנמוכה ביותר",
"low": "נמוכה",
"medium": "בינונית",
"high": "גבוהה",
"Highest": "הגבוהה ביותר",
"send_booker_to": "שליחת מזמן אל",
"set_priority": "הגדרת עדיפות",
"priority_for_user": "העדיפות של {{userName}}",
"change_priority": "שינוי עדיפות",
"field_identifiers_as_variables": "להשתמש במזהי השדות כמשתנים להפניות האירועים בהתאמה אישית",
"field_identifiers_as_variables_with_example": "להשתמש במזהי השדות כמשתנים להפניות האירועים בהתאמה אישית (למשל: {{variable}})",
"account_already_linked": "החשבון כבר מקושר",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -108,6 +108,7 @@ describe("handleChildrenEventTypes", () => {
timeZone,
requiresBookerEmailVerification,
lockTimeZoneToggleOnBookingPage,
useEventTypeDestinationCalendarEmail,
...evType
} = mockFindFirstEventType({
id: 123,
@@ -156,6 +157,7 @@ describe("handleChildrenEventTypes", () => {
scheduleId,
requiresBookerEmailVerification,
lockTimeZoneToggleOnBookingPage,
useEventTypeDestinationCalendarEmail,
...evType
} = mockFindFirstEventType({
metadata: { managedEventConfig: {} },
@@ -250,6 +252,7 @@ describe("handleChildrenEventTypes", () => {
timeZone,
requiresBookerEmailVerification,
lockTimeZoneToggleOnBookingPage,
useEventTypeDestinationCalendarEmail,
...evType
} = mockFindFirstEventType({
id: 123,
@@ -298,6 +301,7 @@ describe("handleChildrenEventTypes", () => {
userId,
requiresBookerEmailVerification,
lockTimeZoneToggleOnBookingPage,
useEventTypeDestinationCalendarEmail,
...evType
} = mockFindFirstEventType({
metadata: { managedEventConfig: {} },
@@ -350,6 +354,7 @@ describe("handleChildrenEventTypes", () => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
requiresBookerEmailVerification,
lockTimeZoneToggleOnBookingPage,
useEventTypeDestinationCalendarEmail,
...evType
} = mockFindFirstEventType({
metadata: { managedEventConfig: {} },
+150 -2
View File
@@ -8,8 +8,8 @@ import { buildUser } from "@calcom/lib/test/builder";
it("can find lucky user with maximize availability", async () => {
const user1 = buildUser({
id: 1,
username: "test",
name: "Test User",
username: "test1",
name: "Test User 1",
email: "test@example.com",
bookings: [
{
@@ -43,3 +43,151 @@ it("can find lucky user with maximize availability", async () => {
})
).resolves.toStrictEqual(users[1]);
});
it("can find lucky user with maximize availability and priority ranking", async () => {
const user1 = buildUser({
id: 1,
username: "test1",
name: "Test User 1",
email: "test@example.com",
priority: 2,
bookings: [
{
createdAt: new Date("2022-01-25T05:30:00.000Z"),
},
{
createdAt: new Date("2022-01-25T06:30:00.000Z"),
},
],
});
const user2 = buildUser({
id: 2,
username: "test2",
name: "Test User 2",
email: "tes2t@example.com",
bookings: [
{
createdAt: new Date("2022-01-25T04:30:00.000Z"),
},
],
});
const users = [user1, user2];
// TODO: we may be able to use native prisma generics somehow?
prismaMock.user.findMany.mockResolvedValue(users);
prismaMock.booking.findMany.mockResolvedValue([]);
const test = await getLuckyUser("MAXIMIZE_AVAILABILITY", {
availableUsers: users,
eventTypeId: 1,
});
// both users have medium priority (one user has no priority set, default to medium) so pick least recently booked
await expect(
getLuckyUser("MAXIMIZE_AVAILABILITY", {
availableUsers: users,
eventTypeId: 1,
})
).resolves.toStrictEqual(users[1]);
const userLowest = buildUser({
id: 1,
username: "test1",
name: "Test User 1",
email: "test@example.com",
priority: 0,
bookings: [
{
createdAt: new Date("2022-01-25T03:30:00.000Z"),
},
],
});
const userMedium = buildUser({
id: 2,
username: "test2",
name: "Test User 2",
email: "tes2t@example.com",
priority: 2,
bookings: [
{
createdAt: new Date("2022-01-25T04:30:00.000Z"),
},
],
});
const userHighest = buildUser({
id: 2,
username: "test2",
name: "Test User 2",
email: "tes2t@example.com",
priority: 4,
bookings: [
{
createdAt: new Date("2022-01-25T05:30:00.000Z"),
},
],
});
const usersWithPriorities = [userLowest, userMedium, userHighest];
// TODO: we may be able to use native prisma generics somehow?
prismaMock.user.findMany.mockResolvedValue(usersWithPriorities);
prismaMock.booking.findMany.mockResolvedValue([]);
// pick the user with the highest priority
await expect(
getLuckyUser("MAXIMIZE_AVAILABILITY", {
availableUsers: usersWithPriorities,
eventTypeId: 1,
})
).resolves.toStrictEqual(usersWithPriorities[2]);
const userLow = buildUser({
id: 1,
username: "test1",
name: "Test User 1",
email: "test@example.com",
priority: 0,
bookings: [
{
createdAt: new Date("2022-01-25T02:30:00.000Z"),
},
],
});
const userHighLeastRecentBooking = buildUser({
id: 2,
username: "test2",
name: "Test User 2",
email: "tes2t@example.com",
priority: 3,
bookings: [
{
createdAt: new Date("2022-01-25T03:30:00.000Z"),
},
],
});
const userHighRecentBooking = buildUser({
id: 3,
username: "test3",
name: "Test User 3",
email: "test3t@example.com",
priority: 3,
bookings: [
{
createdAt: new Date("2022-01-25T04:30:00.000Z"),
},
],
});
const usersWithSamePriorities = [userLow, userHighLeastRecentBooking, userHighRecentBooking];
// TODO: we may be able to use native prisma generics somehow?
prismaMock.user.findMany.mockResolvedValue(usersWithSamePriorities);
prismaMock.booking.findMany.mockResolvedValue([]);
// pick the least recently booked user of the two with the highest priority
await expect(
getLuckyUser("MAXIMIZE_AVAILABILITY", {
availableUsers: usersWithSamePriorities,
eventTypeId: 1,
})
).resolves.toStrictEqual(usersWithSamePriorities[1]);
});
+10 -6
View File
@@ -8,7 +8,7 @@ import { expect, vi } from "vitest";
import "vitest-fetch-mock";
import dayjs from "@calcom/dayjs";
import { CAL_URL } from "@calcom/lib/constants";
import { WEBSITE_URL } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { BookingStatus } from "@calcom/prisma/enums";
@@ -307,16 +307,18 @@ export function expectWebhookToHaveBeenCalledWith(
export function expectWorkflowToBeTriggered({
emails,
organizer,
destinationEmail,
}: {
emails: Fixtures["emails"];
organizer: { email: string; name: string; timeZone: string };
destinationEmail?: string;
}) {
const subjectPattern = /^Reminder: /i;
expect(emails.get()).toEqual(
expect.arrayContaining([
expect.objectContaining({
subject: expect.stringMatching(subjectPattern),
to: organizer.email,
to: destinationEmail ?? organizer.email,
}),
])
);
@@ -370,6 +372,7 @@ export function expectSuccessfulBookingCreationEmails({
recurrence,
bookingTimeRange,
booking,
destinationEmail,
}: {
emails: Fixtures["emails"];
organizer: { email: string; name: string; timeZone: string };
@@ -381,8 +384,9 @@ export function expectSuccessfulBookingCreationEmails({
eventDomain?: string;
bookingTimeRange?: { start: Date; end: Date };
booking: { uid: string; urlOrigin?: string };
destinationEmail?: string;
}) {
const bookingUrlOrigin = booking.urlOrigin || CAL_URL;
const bookingUrlOrigin = booking.urlOrigin || WEBSITE_URL;
expect(emails).toHaveEmail(
{
titleTag: "confirmed_event_type_subject",
@@ -409,7 +413,7 @@ export function expectSuccessfulBookingCreationEmails({
},
}
: null),
to: `${organizer.email}`,
to: `${destinationEmail ?? organizer.email}`,
ics: {
filename: "event.ics",
iCalUID: `${iCalUID}`,
@@ -417,7 +421,7 @@ export function expectSuccessfulBookingCreationEmails({
method: "REQUEST",
},
},
`${organizer.email}`
`${destinationEmail ?? organizer.email}`
);
expect(emails).toHaveEmail(
@@ -742,7 +746,7 @@ export function expectBookingRequestRescheduledEmails({
booking: { uid: string; urlOrigin?: string };
bookNewTimePath: string;
}) {
const bookingUrlOrigin = booking.urlOrigin || CAL_URL;
const bookingUrlOrigin = booking.urlOrigin || WEBSITE_URL;
expect(emails).toHaveEmail(
{
+1 -1
View File
@@ -80,7 +80,7 @@
"@changesets/cli": "^2.26.1",
"@deploysentinel/playwright": "^0.3.3",
"@playwright/test": "^1.31.2",
"@snaplet/copycat": "^0.3.0",
"@snaplet/copycat": "^4.1.0",
"@testing-library/jest-dom": "^5.16.5",
"@types/jsdom": "^21.1.3",
"@types/jsonwebtoken": "^9.0.3",
@@ -58,7 +58,6 @@ import sendgrid_config_json from "./sendgrid/config.json";
import shimmervideo_config_json from "./shimmervideo/config.json";
import signal_config_json from "./signal/config.json";
import sirius_video_config_json from "./sirius_video/config.json";
import skiff_config_json from "./skiff/config.json";
import { metadata as stripepayment__metadata_ts } from "./stripepayment/_metadata";
import sylapsvideo_config_json from "./sylapsvideo/config.json";
import { metadata as tandemvideo__metadata_ts } from "./tandemvideo/_metadata";
@@ -141,7 +140,6 @@ export const appStoreMetadata = {
shimmervideo: shimmervideo_config_json,
signal: signal_config_json,
sirius_video: sirius_video_config_json,
skiff: skiff_config_json,
stripepayment: stripepayment__metadata_ts,
sylapsvideo: sylapsvideo_config_json,
tandemvideo: tandemvideo__metadata_ts,
@@ -58,7 +58,6 @@ export const apiHandlers = {
shimmervideo: import("./shimmervideo/api"),
signal: import("./signal/api"),
sirius_video: import("./sirius_video/api"),
skiff: import("./skiff/api"),
stripepayment: import("./stripepayment/api"),
sylapsvideo: import("./sylapsvideo/api"),
tandemvideo: import("./tandemvideo/api"),
+1 -2
View File
@@ -5,7 +5,6 @@ import { useEffect, useRef } from "react";
import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation";
import classNames from "@calcom/lib/classNames";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { CAL_URL } from "@calcom/lib/constants";
import { deriveAppDictKeyFromType } from "@calcom/lib/deriveAppDictKeyFromType";
import { useHasTeamPlan } from "@calcom/lib/hooks/useHasPaidPlan";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@@ -156,7 +155,7 @@ export const AppDependencyComponent = ({
<div>
<>
<Link
href={`${CAL_URL}/apps/${dependency.slug}`}
href={`${WEBAPP_URL}/apps/${dependency.slug}`}
className="text-info flex items-center underline">
<span className="mr-1">
{t("connect_app", { dependencyName: dependency.name })}
@@ -200,7 +200,7 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => {
createInstantCalVideoRoom: (endTime: string) => createInstantMeeting(endTime),
getRecordingDownloadLink: async (recordingId: string): Promise<GetAccessLinkResponseSchema> => {
try {
const res = await fetcher(`/recordings/${recordingId}/access-link?valid_for_secs=172800`).then(
const res = await fetcher(`/recordings/${recordingId}/access-link?valid_for_secs=43200`).then(
getAccessLinkResponseSchema.parse
);
return Promise.resolve(res);
@@ -11,6 +11,7 @@ import { encodeOAuthState } from "../../_utils/oauth/encodeOAuthState";
export const scopes = [
"https://www.googleapis.com/auth/calendar.readonly",
"https://www.googleapis.com/auth/calendar.events",
"https://www.googleapis.com/auth/userinfo.profile",
];
let client_id = "";
@@ -1,11 +1,14 @@
import type { Auth } from "googleapis";
import { google } from "googleapis";
import type { NextApiRequest, NextApiResponse } from "next";
import { WEBAPP_URL_FOR_OAUTH, WEBAPP_URL } from "@calcom/lib/constants";
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
import prisma from "@calcom/prisma";
import { Prisma } from "@calcom/prisma/client";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
@@ -71,15 +74,6 @@ async function getHandler(req: NextApiRequest, res: NextApiResponse) {
}
}
const credential = await prisma.credential.create({
data: {
type: "google_calendar",
key,
userId: req.session.user.id,
appId: "google-calendar",
},
});
// Set the primary calendar as the first selected calendar
// We can ignore this type error because we just validated the key when we init oAuth2Client
@@ -93,10 +87,27 @@ async function getHandler(req: NextApiRequest, res: NextApiResponse) {
});
const cals = await calendar.calendarList.list({ fields: "items(id,summary,primary,accessRole)" });
const primaryCal = cals.data.items?.find((cal) => cal.primary);
// Primary calendar won't be null, this check satisfies typescript.
if (!primaryCal?.id) {
throw new HttpError({ message: "Internal Error", statusCode: 500 });
}
if (primaryCal?.id) {
// Update the user's profile photo with google profile photo if it's null
// Since we don't want to block the user from using the app if this fails, we don't await this
updateProfilePhoto(oAuth2Client, req.session.user.id);
const credential = await prisma.credential.create({
data: {
type: "google_calendar",
key,
userId: req.session.user.id,
appId: "google-calendar",
},
});
// Wrapping in a try/catch to reduce chance of race conditions-
// also this improves performance for most of the happy-paths.
try {
await prisma.selectedCalendar.create({
data: {
userId: req.session.user.id,
@@ -105,6 +116,19 @@ async function getHandler(req: NextApiRequest, res: NextApiResponse) {
integration: "google_calendar",
},
});
} catch (error) {
await prisma.credential.delete({ where: { id: credential.id } });
let errorMessage = "something_went_wrong";
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
errorMessage = "account_already_linked";
}
res.redirect(
`${
getSafeRedirectUrl(state?.onErrorReturnTo) ??
getInstalledAppPath({ variant: "calendar", slug: "google-calendar" })
}?error=${errorMessage}`
);
return;
}
}
@@ -138,6 +162,23 @@ async function getHandler(req: NextApiRequest, res: NextApiResponse) {
);
}
async function updateProfilePhoto(oAuth2Client: Auth.OAuth2Client, userId: number) {
try {
const oauth2 = google.oauth2({ version: "v2", auth: oAuth2Client });
const userDetails = await oauth2.userinfo.get();
if (userDetails.data?.picture) {
await prisma.user.update({
where: { id: userId, avatarUrl: null, avatar: null },
data: {
avatarUrl: userDetails.data.picture,
},
});
}
} catch (error) {
logger.error("Error updating avatarUrl from google calendar connect", error);
}
}
export default defaultHandler({
GET: Promise.resolve({ default: defaultResponder(getHandler) }),
});

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