Compare commits

..
67 Commits
Author SHA1 Message Date
Omar López ab1eab9c54 chore: v4.5.9 (#16926)
release.yml / release (push) Canceled after 0s
2024-10-03 12:26:35 -07:00
MuhammadandAnik Dhabal Babu a64b7752c9 fix: Tooltip must be used within TooltipProvider (#16918)
* fixes #16833

* Update index.tsx

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-10-03 15:37:11 +00:00
Keith WilliamsandSyed Ali Shahbaz be728e9c01 feat: add Mintlify docs (#16787)
* feat: Add Mintlify docs

* Fixed the mint.json for v2

* Fixed versioning issue

* Add titles to ICS feed endpoints

* Added errors to v1

* Added rate-limit to API v1

* feat: added v1-v2 differences and v1 auth

* Trying to eliminate reasons for v1 not showing on publication

* Used the swagger openapi validator

* remove deprecated v1 endpoints

* Moved api docs into new folders without api-reference

* Renamed api to api-reference

* removed the versioning

* Got rid of anchor warnings

* Fixed naming

* Just as a test - using the same v2 openapi spec for v1 reference

* Removed 'externalDocs' from v1

* Removed securitySchemes

* Updated the diff between v1/v2

* Added v1 in front of /attendees to test

* Moved paths up and removed externalDocs

* Testing - Added content to the response

* Reduced paths to 1

* Testing: anything at this point

* Fixed validation

* Moved the other .mdx files to see if that's disrupting something

* Trying with a tab

* Empty tags array

* Added back servers field

* Removed all other schemas

* Removed v1 for now

* Added platform docs

* Language fixes

* Added v1 back

* add API V1 response for attendees

* remove unnecessary endpoint docs in API V1

* Removing platform and API for now

* Updated v2 openapi doc

---------

Co-authored-by: Syed Ali Shahbaz <alishahbaz7@gmail.com>
2024-10-03 15:26:01 +04:00
Benny Joo 2146c04444 fix: i18n for app router (#16909)
* fix: translations for app router

* fix build error
2024-10-03 00:32:33 +01:00
Calcom BotandReplexica 0b94473c7f feat: update translations (#16911)
Co-authored-by: Replexica <support@replexica.com>
2024-10-02 17:44:36 -04:00
2a1dc619ee feat: Multiple Private links per Event Type (#15896)
* feat: Change hashedLink field to array

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* feat: Implement core single-use links logic

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* fix: Get private link from list of hashedLinks on event list page

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* fix: Fix defalut single-use links form field value

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* fix: Fix some type errors while compiling

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* fix: Use onChange callback instead of setting form values directly

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* feat: Add e2e tests for single-use links

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* refactor: Refactor single-use-links admin interface

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* fix: Fix settings toggle bugs

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* chore: Remove redundant inline comments

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* fix: Remove debug statements

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* chore: Remove single-use links e2e tests

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* feat: Remove private-link feature and override with single-use-links

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* fix: Fix type errors

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* refactor: Remove redundant migrations

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* fix: Add managedEvents restriction

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* fix: Fix single-use-links for org

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* fix: Fix recommended UI changes

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* fix: Fix duration not respected for private links

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* fix: Fix team event page title

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* feat: Add logic to copy each single-use-links from event listing page

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* feat: Add single-use links support for managed event types

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* fix: Fix some type errors

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* fix: Fix type errors

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* refactor: Disable adding new single-use links when field is locked

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* fix: Address feedbacks

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* refactor: Hide add new link button for locked single-use link field

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* chore: Use empty string for single-use link fallback value condition

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* fix: Fix org single-use links in listing page

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* feat: Auto toggle unlock button for managed event's single-use links option

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* Revert "feat: Auto toggle unlock button for managed event's single-use links option"

This reverts commit 5a2ed6f0bf428d52de153335499f02f42ccdf18f.

* chore: Disable parent toggle for single-use-links

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* feat: Remove tooltip and give info message below field

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* add tooltip

* tooltip message

* fix: Fix typo

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* fix: Fix typescript type errors

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* fix: Fix few unit tests

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* refactor: Remove redundant query

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* merge conflict fix

* More merge conflict fix and refactor

* fix tests, code refactor

* fix: type check

* fix: duration config and keep links when updating event type

* fix: e2e

* refactor: Replace single-use-links with multiple-private-links

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

---------

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Amit91848 <samit91848@gmail.com>
2024-10-03 00:58:54 +05:30
Benny JooandKeith Williams 48a2fc2f67 fix: translations for sidebar (#16910)
* fix: translations for sidebar

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

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-10-02 18:21:25 +00:00
4553455098 chore: refactor shell to individual files (#16893)
* banners

* useAuthHooks

* fixes to redirect and banner

* extract useIntercom to custom hook

* use app theme

* extract user-dropdown to new component

* Navigation Item

* navigation and profile dropdown

* Fix import

* navigation and sidebar

* fix type errors

* fix banners being an async call

* fix types

* fix banner prop type

* fix mobile nav item import

* fix banner types

* (revert) layout banner render method to fix type error

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
2024-10-02 15:35:56 +01:00
b4f1b5aebe refactor: handleNewBooking #4 (#15673)
* refactor: handleNewBooking #3

* refactor: create booking factor

* refactor: handleNewBooking

* refactor: seats and rescheduleUId

* chore: remove comment

* fix: type err

* chore: add missing statement

* chore: use less params and other improvements

* chore: name

* chore: improvement

* fix: type err

* Typo fix

* chore: fix type err

* refactor: more readable

* refactor: improve code

* fix: conflicts

---------

Co-authored-by: Joe Au-Yeung <j.auyeung419@gmail.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com>
2024-10-02 10:26:25 -04:00
a99e285d76 fix: fix ui overflow on phone for calendar settings (#16661)
* fix: fix ui overflow on phone for calendar settings

* fix: overflow issue with long emails

* Update CalendarSwitch.tsx

---------

Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-10-02 12:24:25 +00:00
Lauris Skraucis bdea436f8b docs: v2 refresh tokens and slots endpoints swagger (#16788)
* docs: oauth refresh endpoint

* docs: slots reserve and delete

* docs: get slots

* swagger

* fix: reserving slot

* Revert "fix: reserving slot"

This reverts commit 8a3ce8464d5dbf67e58da6d3b1aeb51df97240cb.

* Update oauth-flow.controller.ts
2024-10-02 08:44:05 +00:00
Alex van Andel 182d0fa228 fix: Better defaulting to NEXT_PUBLIC_LOGGER_LEVEL=4 (#16900)
* fix: Better defaulting to NEXT_PUBLIC_LOGGER_LEVEL=4

* chore: Minor fixes to prettyLogTemplate (as it wasnt very pretty)
2024-10-01 17:38:02 +00:00
Alex van Andel 0d7f560c43 chore: improve instrumentation deps (#16899)
* Remove Sentry from direct import in instrumentation file

* chore: Exclude Sentry configs when not needed
2024-10-01 17:52:13 +01:00
Alex van Andel 1377f1a214 chore: Release v4.5.8 (#16896)
release.yml / release (push) Canceled after 0s
2024-10-01 15:38:59 +01:00
Lauris Skraucis e8dfa50119 fix: booker atom recurring event (#16719)
* fix: BookerEmbed prop type

* fix: booker atom not displaying recurring event

* fix: count interpolation in i18n
2024-10-01 14:00:04 +00:00
706e8e4c1f feat: added workflows to all actions (#16691)
* feat: added workflows

* moved the condition from Editor to excludedToolbarItems

* revert editor changes

* Update WorkflowStepContainer.tsx

* update

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: unknown <adhabal2002@gmail.com>
2024-10-01 18:50:17 +05:30
Alex van Andel a95729f4a1 chore: Couple fixes to improve error reporting (#16895)
* chore: Couple fixes to improve error reporting

* tweak: Fix error message

* refactor: Use findUniqueOrThrow instead of guard

* fix: Broken test, missing ID - suspect it passes because only one record exists

* chore: Minor refactor to split ZodEffect from ZodType

* fix: Satify typescript
2024-10-01 09:18:51 -03:00
Lauris Skraucis 4b6a389212 feat: v2 rate limiting (#16882)
* refactor: pass redis storage to throttler guard

* chore: upgrade throttler to latest

* feat: ApiKey RateLimit table

* chore: upgrade redis storage throttler

* feat: rate limit by api key

* refactor: on delete api key cascade rate limit

* fix: permissions guard work with oauth credentials

* chore: set rate limit in env

* tests: throttler

* feat: include rate limit name in response

* fix: correctly handle multiple rate limits

* chore: remove unused import

* delete migrations

* chore: prisma migration

* doc

* dummy

* fix: permissions guard unit test

* refactor: remove route specific @Throttles

* fix: permissions guard
2024-10-01 12:01:56 +00:00
Morgan 9d3be8de03 chore: improve event-type atom desc, data fetch (#16890) 2024-10-01 10:38:37 +00:00
Benny Joo b283f1f8cd fix: theme is not configured in app router (#16889)
* fix: theme is not configured in app router

* fix type error

* remove logs

* use testBothFutureAndLegacyRoutes

* add APP_ROUTER_SETTINGS_MY_ACCOUNT_ENABLED
2024-10-01 04:39:37 -04:00
5ff52e1239 chore: add saml signup test / improve signup e2e test (#16849)
* add comment to signup-view

* add a basic test for saml button in signup

* add a test for cookie consent checkbox

* refactor and remove duplicates

* rename data-testid for saml button

* add test for saml

* remove code not needed

* better naming

* improve

* address code nit

* skip saml test when needed

* fix flag

* improve

* improve more

* refactor

* refactor

* refactor

* fix and add 1 more test

* remove tag

* remove unneeded code

* fix

* fix test

* it needs to be parallel mode

* reduce code

* simplify

* fix

* fix flake

* fix another flake

* add comments + refactor

---------

Co-authored-by: unknown <adhabal2002@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-10-01 09:34:20 +01:00
Calcom BotandReplexica 2b1cb9f878 feat: update translations (#16886)
Co-authored-by: Replexica <support@replexica.com>
2024-10-01 06:23:01 +00:00
Benny Joo 74a46e8ba3 chore: (1) settings/security - remove pages router and use app router (#16795)
* remove pages router and use app router

* add settings layout

* remove env vars
2024-09-30 23:27:58 +01:00
Jevtić NenadandRajiv Sahal 5b5f7c1c48 fix: Fix conditional check for oAuth client id (#16809)
Co-authored-by: Rajiv Sahal <sahalrajiv-extc@atharvacoe.ac.in>
2024-09-30 18:56:17 -03:00
Udit Takkar 9375efffe3 feat: prevent sending multiple instant meeting request (#16881) 2024-09-30 18:54:55 -03:00
sean-brydon da59c9de12 fix getEventTypeWorkflows (#16879) 2024-09-30 22:09:18 +01:00
2a734faf45 fix: undefined in meeting url workflow variable (#16771)
* make bookerUrl mandatory

* add missing bookerUrl

* add fallback

* fix roundRObinReassignment

* clean up

* allow bookerUrl to be undefined

* code clean up

* make bookerUrl mandatory

* fix type errors

* fix type error

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
2024-09-30 15:25:51 -04:00
Calcom BotandReplexica 934cefecce feat: update translations (#16884)
Co-authored-by: Replexica <support@replexica.com>
2024-09-30 15:18:41 +00:00
Rajiv SahalandMorgan c88811aeee feat: stripe connect atom (#16190)
* update stripe service

* init endpoints for stripe connect atom

* update stripe controller

* update stripe service

* add generic function to create app credential

* update stripe controller

* update stripe service

* add handler to construct redirectUrl

* restructure files for better readibility

* better naming

* replace STRIPE_PRIVATE_KEY with STRIPE_API_KEY

* fix naming

* remove unused query param

* frontend for stripe connect atom

* custom hooks for stripe connect atom

* abstract response schema into separate file

* fixup

* input dto for stripe controller

* update stripe module

* update endpoints module to include stripe module

* updatte stripe module

* fixups and add check endpoint to stripe controller

* add method to check stripe account

* add helper fn to get on error return value

* update stripe connect atom

* custom hook to check user stripe credentials

* add stripe connect to atom exports

* update stripe connect styling

* add qs stringify package

* translations for stripe connect atom

* updaet typing

* update output dtos for stripe endpoints

* add error message

* fix merge conflicts

* add stripe connect atom to atom exports

* add query param for error rediect link

* add handler for onCheck success and error redirect link

* update index value

* fix merge conflicts

* fixup

* resolve merge conflicts

* war with merge conflicts

* update examples app

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2024-09-30 19:56:22 +05:30
Rajiv Sahal f46c3c65fb filter connected calendars before displaying it in tooltip (#16883) 2024-09-30 19:45:28 +05:30
Rajiv Sahal 1d203dc566 feat: frontend to handle multiple calendars for connect atoms (#16527)
* handle multiple calendars in connect atoms

* resolve merge conflicts

* fixup

* frontend for multiple calendars

* fixup

* remove comment

* add missing props

* add prop for multi calendar view

* add multi calendar support

* fixup
2024-09-30 11:33:18 +00:00
Rajiv Sahal 26a1c749f7 chore: refactor billing service v2 (#16805) 2024-09-30 13:56:21 +03:00
MorganandSomay Chauhan ad347cb59f feat: Event Type Atom base pr (#16831)
* feat: Event Type Atom base pr

* fixup! Merge branch 'main' into chore-event-type-atom-platform-wrapper-base-pr

* Update EventTypeWebWrapper.tsx

---------

Co-authored-by: Somay Chauhan <somaychauhan98@gmail.com>
2024-09-30 13:44:11 +03:00
Udit TakkarandSyed Ali Shahbaz ac300ff670 refactor: enable infinite scrolling for all (#16463)
* refactor: enable infinite scrolling for all

* chore: remove unused code

* fix: tests

* chore: type err

* chore: update email embed test

* chore: update tests

* chore: update tests

* chore: fix horizontal tabitem

* chore

* fix: conflicts

* chore: conflict

* fix: E2E tests

* chore: remove idle

* fix test

---------

Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com>
2024-09-30 10:44:04 +01:00
c834b927b6 refactor: event advanced web wrapper (#16711)
* chore: EventTypeWebWrapper base pr

* fixup! chore: EventTypeWebWrapper base pr

* fixup! Merge branch 'main' into morgan/cal-4183-eventtypeatom-web-wrapper-base

* wip

* fix: error

* Event type atom wrapper handles app dir

* fixup! Merge branch 'main' into morgan/cal-4183-eventtypeatom-web-wrapper-base

* fixup! fixup! Merge branch 'main' into morgan/cal-4183-eventtypeatom-web-wrapper-base

* remove console log

* review comments

* fixup! review comments

* refactor: event advanced web wrapper

* rename `connectedCalendars` -> `calendarsQueryData`

* Update BookerLayoutSelector.tsx

* moved useMeQuery up the parent tree

---------

Co-authored-by: Morgan Vernay <morgan@cal.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2024-09-30 12:15:11 +03:00
Somay Chauhan 34ba27407b refactor: event availability web wrapper (#16808)
* refactor: event availability web wrapper

* useMeQuery moved up the parent component
2024-09-30 08:16:18 +00:00
Somay Chauhan 8431cd2ef8 refactor: event recurring web wrapper (#16764) 2024-09-30 08:11:13 +00:00
Benny Joo cd09a56491 fix hydration error from html mismatch (#16876) 2024-09-30 06:04:27 +05:30
Calcom BotandReplexica 5c05a422a8 feat: update translations (#16851)
Co-authored-by: Replexica <support@replexica.com>
2024-09-28 22:42:30 +00:00
Benny Joo e805c0f00a fix spacing in signup (#16850) 2024-09-27 18:43:30 +00:00
Priyansh 2fa7f7931d Fix TODO in AppCard.tsx (#16863) 2024-09-27 17:51:19 +00:00
sean-brydon 20729b3a4e chore: Move getRemindersFromRemovedTeams and deleteAllWorkflowReminders to workflow repo (#16861)
* wip

* move deleteAllWorkflowReminders to repo
2024-09-27 10:42:09 -04:00
Lauris Skraucis 4d9777af8b docs: v2 event-types (#16854)
* docs: optional get event-types query params

* docs: create event type input

* docs: update event type input

* docs: child inputs of create and update event type inputs

* docs: updated swagger

* refactor: add disabled option to booking window

* refactor: seats description

* swagger
2024-09-27 09:48:22 +00:00
Hariom Balhara 1534588b34 feat: Forward all the page params to embed (#16330)
* Forward all query params

* Add Unit tests

* Add example in playground
2024-09-27 15:14:56 +05:30
Morgan 7183ec7f1b fix: add teamId to platform urls (#16852) 2024-09-27 09:19:20 +03:00
Hariom BalharaandPeer Richelsen d836497d56 fix: Don't AutoScroll to embed when multiple-duration event's booking page is embedded (#16411)
* Fix and add test and eslint rule

* Fix eslint errors

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2024-09-27 09:57:04 +05:30
Syed Ali ShahbazandUdit Takkar 38fa0fae1d fix: insights UI/UX (#16610)
* remove old code remnant

* -

* WIP

* type change

* --

* typefix

* fix type

* fix type

* change type name

* add basewhere to eventsByStatus

* add basewhere to the rest

* fix rendering null popular event list item

* URL params for start and endtime to have YYYY-MM-DD format

* remove console log

* resolve suggestion

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-09-27 03:36:32 +00:00
Mohit 2872b263fd fix: <a href> is not clickable cause of z-index (#16842) 2024-09-27 02:38:31 +00:00
Keith Williams 92e53adc8e chore: v4.5.7 (#16848)
release.yml / release (push) Canceled after 0s
2024-09-27 08:19:58 +09:00
Calcom BotandReplexica 72cc4ab13e feat: update translations (#16843)
Co-authored-by: Replexica <support@replexica.com>
2024-09-26 22:41:09 +00:00
4e82ffe2ad feat: adding Bolna Voice AI integration for cal.com (#16828)
* fix: #11213, #11215

* Use default colour

* adding bolna app for cal.com

* cleanup by removing redundant changes

---------

Co-authored-by: Siddharth Movaliya <siddraj1992@gmail.com>
Co-authored-by: Sean Brydon <sean@cal.com>
2024-09-26 21:20:28 +00:00
Benny Joo 90f6c7e239 feat: New UI for SignUp / Login (#16802) 2024-09-26 15:27:56 -04:00
Keith Williams ffa58d1cdb chore: v4.5.6 (#16837)
release.yml / release (push) Canceled after 0s
2024-09-27 03:00:13 +09:00
Calcom BotandReplexica f834e93652 feat: update translations (#16839)
Co-authored-by: Replexica <support@replexica.com>
2024-09-26 17:59:54 +00:00
ae7fae7da4 feat: Add hideCalendarEventDetails option to event types (#16817)
* Add `hideCalendarEventDetails` to DB

Co-authored-by: Alex van Andel <emrysal@users.noreply.github.com>

* Add option to hide calendar event details

* Pass `hideCalendarEventDetails` to event object

* Add to API

* Add to CalendarEvent class

* Pass `hideCalendarEventDetails` to calendar services

* Adjust test

* fix: move `hideCalendarEventDetails` to `api/v2/event-types_2024_06_14` and remove from older versions

- Added `hideCalendarEventDetails` to `api/v2/event-types_2024_06_14`
- Removed `hideCalendarEventDetails` from `api/v1` and `api/v2/event-types_2024_04_15`

* fix: calEventRaw is undefined (use event instead)

* chore: Remove debug artifact

* fix: description id + update to copy

* fix: Attempt at fixing type error

* Add hideCalendarEventDetails to test builder

---------

Co-authored-by: Alex van Andel <emrysal@users.noreply.github.com>
Co-authored-by: Somay Chauhan <somaychauhan98@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2024-09-26 17:08:25 +00:00
Keith Williams b6c6e41603 chore: Allow redirecting /docs to external docs solution (#16836)
* chore: Allow redirecting /docs to external docs solution

* Added DOCS_URL to .env.example
2024-09-26 16:35:35 +00:00
Lauris Skraucis e4dc2d0877 fix: platform cancel booking webhook not triggered (#16827)
* fix: pass oauth id to webhooks upon cancel booking

* chore: v2 use latest libraries
2024-09-26 14:49:53 +00:00
Lauris Skraucis e43690f190 chore: bump slots rate limit (#16826) 2024-09-26 13:54:12 +00:00
Lauris Skraucis 5bbccad41d fix: selected calendars atom deselect calendar (#16825) 2024-09-26 08:26:33 +00:00
Calcom BotandReplexica bc53037f37 feat: update translations (#16813)
Co-authored-by: Replexica <support@replexica.com>
2024-09-25 23:23:31 +00:00
Nick Holden b4a1a8e7bf chore: Replace campsite.co with campsite.com (#16819) 2024-09-25 18:41:07 +00:00
Keith Williams ac43d90d53 feat: Improve API v2 docs (#16818)
* feat: Improve API v2 docs

* Consistency

* Made the descriptions complete sentences with period

* Update apps/api/v2/src/ee/schedules/schedules_2024_06_11/controllers/schedules.controller.ts
2024-09-25 18:09:53 +00:00
Anik Dhabal Babu 2b24b4472d fix: OOO issue with setting the start and end dates to the same (#16810) 2024-09-25 13:44:42 +00:00
Peer Richelsen 715cbc1c38 chore: small changes to license page (#16812) 2024-09-25 15:14:38 +02:00
Alex van Andel d670976a69 chore: Release v4.5.5 (#16804)
release.yml / release (push) Canceled after 0s
2024-09-24 21:35:29 +00:00
Benny Joo 17ff01af75 fix: Revert "feat: Update UI of signup / login pages (#16710)" (#16801)
* Revert "feat: Update UI of signup / login pages (#16710)"

This reverts commit b879aeef09.

* revert
2024-09-24 20:07:37 +00:00
Alex van Andel 77af36e2af bugfix: Failed foreign key constraint check due to daily_video 0 (#16798) 2024-09-24 19:15:04 +00:00
530 changed files with 32709 additions and 5222 deletions
+1 -1
View File
@@ -334,8 +334,8 @@ AB_TEST_BUCKET_PROBABILITY=50
# whether we redirect to the future/event-types from event-types or not
APP_ROUTER_EVENT_TYPES_ENABLED=0
APP_ROUTER_SETTINGS_DEVELOPER_ENABLED=0
APP_ROUTER_SETTINGS_SECURITY_ENABLED=0
APP_ROUTER_SETTINGS_ADMIN_ENABLED=0
APP_ROUTER_SETTINGS_MY_ACCOUNT_ENABLED=0
APP_ROUTER_SETTINGS_PLATFORM_ENABLED=0
APP_ROUTER_SETTINGS_ORG_ENABLED=0
APP_ROUTER_APPS_INSTALLED_CATEGORY_ENABLED=0
@@ -2,7 +2,7 @@ import type { NextApiRequest } from "next";
import handleCancelBooking from "@calcom/features/bookings/lib/handleCancelBooking";
import { defaultResponder } from "@calcom/lib/server";
import { schemaBookingCancelParams } from "@calcom/prisma/zod-utils";
import { bookingCancelSchema } from "@calcom/prisma/zod-utils";
import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt";
@@ -65,7 +65,7 @@ import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransform
*/
async function handler(req: NextApiRequest) {
const { id, allRemainingBookings, cancellationReason } = schemaQueryIdParseInt
.merge(schemaBookingCancelParams.pick({ allRemainingBookings: true, cancellationReason: true }))
.merge(bookingCancelSchema.pick({ allRemainingBookings: true, cancellationReason: true }))
.parse({
...req.query,
allRemainingBookings: req.query.allRemainingBookings === "true",
+2 -1
View File
@@ -26,4 +26,5 @@ WEB_APP_URL=http://localhost:3000/
CALCOM_LICENSE_KEY=
API_KEY_PREFIX=cal_
GET_LICENSE_KEY_URL="https://console.cal.com/api/license"
IS_E2E=false
IS_E2E=false
DOCS_URL=
+4 -3
View File
@@ -28,13 +28,14 @@
"dependencies": {
"@calcom/platform-constants": "*",
"@calcom/platform-enums": "*",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.40",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.44",
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
"@calcom/prisma": "*",
"@golevelup/ts-jest": "^0.4.0",
"@microsoft/microsoft-graph-types-beta": "^0.42.0-preview",
"@nest-lab/throttler-storage-redis": "1.0.0",
"@nestjs/bull": "^10.1.1",
"@nestjs/common": "^10.0.0",
"@nestjs/config": "^3.1.1",
@@ -43,7 +44,7 @@
"@nestjs/passport": "^10.0.2",
"@nestjs/platform-express": "^10.0.0",
"@nestjs/swagger": "^7.3.0",
"@nestjs/throttler": "^5.1.2",
"@nestjs/throttler": "6.2.1",
"@sentry/node": "^8.8.0",
"body-parser": "^1.20.2",
"bull": "^4.12.4",
@@ -57,10 +58,10 @@
"ioredis": "^5.3.2",
"luxon": "^3.4.4",
"nest-winston": "^1.9.4",
"nestjs-throttler-storage-redis": "^0.4.1",
"next-auth": "^4.22.1",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"qs-stringify": "^1.2.1",
"querystring": "^0.2.1",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.8.1",
+373 -14
View File
@@ -1,26 +1,385 @@
import { AppModule } from "@/app.module";
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
import { CustomThrottlerGuard } from "@/lib/throttler-guard";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
import { TestingModule } from "@nestjs/testing";
import { Test } from "@nestjs/testing";
import * as request from "supertest";
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
import { RateLimitRepositoryFixture } from "test/fixtures/repository/rate-limit.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
describe("AppController (e2e)", () => {
let app: INestApplication;
import { X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
import { User, PlatformOAuthClient, Team, RateLimit } from "@calcom/prisma/client";
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
describe("AppController", () => {
describe("Rate limiting", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
let rateLimitRepositoryFixture: RateLimitRepositoryFixture;
const userEmail = "app-rate-limits-e2e@api.com";
let user: User;
app = moduleFixture.createNestApplication();
await app.init();
});
let organization: Team;
let oAuthClient: PlatformOAuthClient;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let profilesRepositoryFixture: ProfileRepositoryFixture;
it("/ (GET)", () => {
return request(app.getHttpServer()).get("/health").expect("OK");
});
let apiKeyString: string;
afterAll(async () => {
await app.close();
let rateLimit: RateLimit;
let apiKeyStringWithRateLimit: string;
let apiKeyStringWithMultipleLimits: string;
let firstRateLimitWithMultipleLimits: RateLimit;
let secondRateLimitWithMultipleLimits: RateLimit;
const mockDefaultLimit = 5;
const mockDefaultTtl = 2500;
const mockDefaultBlockDuration = 5000;
beforeEach(async () => {
const moduleRef: TestingModule = await Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule, SchedulesModule_2024_04_15],
}).compile();
jest.spyOn(CustomThrottlerGuard.prototype, "getDefaultLimit").mockReturnValue(mockDefaultLimit);
jest.spyOn(CustomThrottlerGuard.prototype, "getDefaultTtl").mockReturnValue(mockDefaultTtl);
jest
.spyOn(CustomThrottlerGuard.prototype, "getDefaultBlockDuration")
.mockReturnValue(mockDefaultBlockDuration);
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
user = await userRepositoryFixture.create({
email: userEmail,
username: userEmail,
});
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
const { keyString } = await apiKeysRepositoryFixture.createApiKey(user.id, null);
apiKeyString = `cal_test_${keyString}`;
rateLimitRepositoryFixture = new RateLimitRepositoryFixture(moduleRef);
const { apiKey, keyString: keyStringWithRateLimit } = await apiKeysRepositoryFixture.createApiKey(
user.id,
null
);
apiKeyStringWithRateLimit = `cal_test_${keyStringWithRateLimit}`;
rateLimit = await rateLimitRepositoryFixture.createRateLimit("long", apiKey.id, 2000, 3, 4000);
const { apiKey: apiKeyWithMultipleLimits, keyString: keyStringWithMultipleLimits } =
await apiKeysRepositoryFixture.createApiKey(user.id, null);
apiKeyStringWithMultipleLimits = `cal_test_${keyStringWithMultipleLimits}`;
firstRateLimitWithMultipleLimits = await rateLimitRepositoryFixture.createRateLimit(
"short",
apiKeyWithMultipleLimits.id,
1000,
2,
2000
);
secondRateLimitWithMultipleLimits = await rateLimitRepositoryFixture.createRateLimit(
"long",
apiKeyWithMultipleLimits.id,
2000,
3,
4000
);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
organization = await organizationsRepositoryFixture.create({ name: "ecorp" });
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
oAuthClient = await createOAuthClient(organization.id);
profilesRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
await profilesRepositoryFixture.create({
uid: "asd-asd",
username: userEmail,
user: { connect: { id: user.id } },
organization: { connect: { id: organization.id } },
});
app = moduleRef.createNestApplication();
await app.init();
});
async function createOAuthClient(organizationId: number) {
const data = {
logo: "logo-url",
name: "name",
redirectUris: ["http://localhost:5555"],
permissions: 1023,
};
const secret = "secret";
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
return client;
}
it(
"api key with default rate limit - should enforce rate limits and reset after block duration",
async () => {
const limit = mockDefaultLimit;
const blockDuration = mockDefaultBlockDuration;
for (let i = 1; i <= limit; i++) {
const response = await request(app.getHttpServer())
.get("/v2/me")
.set({ Authorization: `Bearer ${apiKeyString}` })
.expect(200);
expect(response.headers["x-ratelimit-limit-default"]).toBe(limit.toString());
expect(response.headers["x-ratelimit-remaining-default"]).toBe((limit - i).toString());
expect(Number(response.headers["x-ratelimit-reset-default"])).toBeGreaterThan(0);
}
const blockedResponse = await request(app.getHttpServer())
.get("/v2/me")
.set("Authorization", `Bearer ${apiKeyString}`)
.expect(429);
expect(blockedResponse.headers["x-ratelimit-limit-default"]).toBe(limit.toString());
expect(blockedResponse.headers["x-ratelimit-remaining-default"]).toBe("0");
expect(Number(blockedResponse.headers["x-ratelimit-reset-default"])).toBeGreaterThanOrEqual(
blockDuration / 1000
);
await new Promise((resolve) => setTimeout(resolve, blockDuration));
const afterBlockResponse = await request(app.getHttpServer())
.get("/v2/me")
.set("Authorization", `Bearer ${apiKeyString}`)
.expect(200);
expect(afterBlockResponse.headers["x-ratelimit-limit-default"]).toBe(limit.toString());
expect(afterBlockResponse.headers["x-ratelimit-remaining-default"]).toBe((limit - 1).toString());
expect(Number(afterBlockResponse.headers["x-ratelimit-reset-default"])).toBeGreaterThan(0);
},
15 * 1000
);
it(
"api key with custom rate limit - should enforce rate limits and reset after block duration",
async () => {
const limit = rateLimit.limit;
const blockDuration = rateLimit.blockDuration;
const name = rateLimit.name;
for (let i = 1; i <= limit; i++) {
const response = await request(app.getHttpServer())
.get("/v2/me")
.set({ Authorization: `Bearer ${apiKeyStringWithRateLimit}` })
.expect(200);
expect(response.headers[`x-ratelimit-limit-${name}`]).toBe(limit.toString());
expect(response.headers[`x-ratelimit-remaining-${name}`]).toBe((limit - i).toString());
expect(Number(response.headers[`x-ratelimit-reset-${name}`])).toBeGreaterThan(0);
}
const blockedResponse = await request(app.getHttpServer())
.get("/v2/me")
.set("Authorization", `Bearer ${apiKeyStringWithRateLimit}`)
.expect(429);
expect(blockedResponse.headers[`x-ratelimit-limit-${name}`]).toBe(limit.toString());
expect(blockedResponse.headers[`x-ratelimit-remaining-${name}`]).toBe("0");
expect(Number(blockedResponse.headers[`x-ratelimit-reset-${name}`])).toBeGreaterThanOrEqual(
blockDuration / 1000
);
await new Promise((resolve) => setTimeout(resolve, blockDuration));
const afterBlockResponse = await request(app.getHttpServer())
.get("/v2/me")
.set("Authorization", `Bearer ${apiKeyStringWithRateLimit}`)
.expect(200);
expect(afterBlockResponse.headers[`x-ratelimit-limit-${name}`]).toBe(limit.toString());
expect(afterBlockResponse.headers[`x-ratelimit-remaining-${name}`]).toBe((limit - 1).toString());
expect(Number(afterBlockResponse.headers[`x-ratelimit-reset-${name}`])).toBeGreaterThan(0);
},
15 * 1000
);
it(
"api key with multiple rate limits - should enforce both short and long rate limits",
async () => {
const shortLimit = firstRateLimitWithMultipleLimits.limit;
const longLimit = secondRateLimitWithMultipleLimits.limit;
const shortName = firstRateLimitWithMultipleLimits.name;
const longName = secondRateLimitWithMultipleLimits.name;
const shortBlock = firstRateLimitWithMultipleLimits.blockDuration;
const longBlock = secondRateLimitWithMultipleLimits.blockDuration;
let requestsMade = 0;
// note(Lauris): exhaust short limit to have remaining 0 for it
for (let i = 1; i <= shortLimit; i++) {
const response = await request(app.getHttpServer())
.get("/v2/me")
.set({ Authorization: `Bearer ${apiKeyStringWithMultipleLimits}` })
.expect(200);
requestsMade++;
expect(response.headers[`x-ratelimit-limit-${shortName}`]).toBe(shortLimit.toString());
expect(response.headers[`x-ratelimit-remaining-${shortName}`]).toBe((shortLimit - i).toString());
expect(Number(response.headers[`x-ratelimit-reset-${shortName}`])).toBeGreaterThan(0);
expect(response.headers[`x-ratelimit-limit-${longName}`]).toBe(longLimit.toString());
expect(response.headers[`x-ratelimit-remaining-${longName}`]).toBe((longLimit - i).toString());
expect(Number(response.headers[`x-ratelimit-reset-${longName}`])).toBeGreaterThan(0);
}
// note(Lauris): short limit exhausted, now exhaust long limit to have remaining 0 for it
for (let i = requestsMade; i < longLimit; i++) {
const responseAfterShortLimit = await request(app.getHttpServer())
.get("/v2/me")
.set({ Authorization: `Bearer ${apiKeyStringWithMultipleLimits}` })
.expect(200);
requestsMade++;
expect(responseAfterShortLimit.headers[`x-ratelimit-limit-${shortName}`]).toBe(
shortLimit.toString()
);
expect(responseAfterShortLimit.headers[`x-ratelimit-remaining-${shortName}`]).toBe("0");
expect(Number(responseAfterShortLimit.headers[`x-ratelimit-reset-${shortName}`])).toBeGreaterThan(
0
);
expect(responseAfterShortLimit.headers[`x-ratelimit-limit-${longName}`]).toBe(longLimit.toString());
expect(responseAfterShortLimit.headers[`x-ratelimit-remaining-${longName}`]).toBe(
(longLimit - requestsMade).toString()
);
expect(Number(responseAfterShortLimit.headers[`x-ratelimit-reset-${longName}`])).toBeGreaterThan(0);
}
// note(Lauris): both have remaining 0 so now exceed both
const blockedResponseLong = await request(app.getHttpServer())
.get("/v2/me")
.set({ Authorization: `Bearer ${apiKeyStringWithMultipleLimits}` })
.expect(429);
expect(blockedResponseLong.headers[`x-ratelimit-limit-${shortName}`]).toBe(shortLimit.toString());
expect(blockedResponseLong.headers[`x-ratelimit-remaining-${shortName}`]).toBe("0");
expect(Number(blockedResponseLong.headers[`x-ratelimit-reset-${shortName}`])).toBeGreaterThanOrEqual(
firstRateLimitWithMultipleLimits.blockDuration / 1000
);
expect(blockedResponseLong.headers[`x-ratelimit-limit-${longName}`]).toBe(longLimit.toString());
expect(blockedResponseLong.headers[`x-ratelimit-remaining-${longName}`]).toBe("0");
expect(Number(blockedResponseLong.headers[`x-ratelimit-reset-${longName}`])).toBeGreaterThanOrEqual(
secondRateLimitWithMultipleLimits.blockDuration / 1000
);
// note(Lauris): wait for short limit to reset
await new Promise((resolve) => setTimeout(resolve, shortBlock));
const responseAfterShortLimitReload = await request(app.getHttpServer())
.get("/v2/me")
.set({ Authorization: `Bearer ${apiKeyStringWithMultipleLimits}` })
.expect(200);
expect(responseAfterShortLimitReload.headers[`x-ratelimit-limit-${shortName}`]).toBe(
shortLimit.toString()
);
expect(responseAfterShortLimitReload.headers[`x-ratelimit-remaining-${shortName}`]).toBe(
(shortLimit - 1).toString()
);
expect(
Number(responseAfterShortLimitReload.headers[`x-ratelimit-reset-${shortName}`])
).toBeGreaterThan(0);
expect(responseAfterShortLimitReload.headers[`x-ratelimit-limit-${longName}`]).toBe(
longLimit.toString()
);
expect(responseAfterShortLimitReload.headers[`x-ratelimit-remaining-${longName}`]).toBe(
(longLimit - requestsMade).toString()
);
expect(
Number(responseAfterShortLimitReload.headers[`x-ratelimit-reset-${longName}`])
).toBeGreaterThan(0);
// note(Lauris): wait for long limit to reset
await new Promise((resolve) => setTimeout(resolve, longBlock));
const responseAfterLongLimitReload = await request(app.getHttpServer())
.get("/v2/me")
.set({ Authorization: `Bearer ${apiKeyStringWithMultipleLimits}` })
.expect(200);
expect(responseAfterLongLimitReload.headers[`x-ratelimit-limit-${shortName}`]).toBe(
shortLimit.toString()
);
expect(responseAfterLongLimitReload.headers[`x-ratelimit-remaining-${shortName}`]).toBe(
(shortLimit - 1).toString()
);
expect(
Number(responseAfterLongLimitReload.headers[`x-ratelimit-reset-${shortName}`])
).toBeGreaterThan(0);
expect(responseAfterLongLimitReload.headers[`x-ratelimit-limit-${longName}`]).toBe(
longLimit.toString()
);
expect(responseAfterLongLimitReload.headers[`x-ratelimit-remaining-${longName}`]).toBe(
(longLimit - 1).toString()
);
expect(Number(responseAfterLongLimitReload.headers[`x-ratelimit-reset-${longName}`])).toBeGreaterThan(
0
);
},
30 * 1000
);
it(
"non api key with default rate limit - should enforce rate limits and reset after block duration",
async () => {
const limit = mockDefaultLimit;
const blockDuration = mockDefaultBlockDuration;
for (let i = 1; i <= limit; i++) {
const response = await request(app.getHttpServer())
.get("/v2/me")
.set(X_CAL_CLIENT_ID, oAuthClient.id)
.set(X_CAL_SECRET_KEY, oAuthClient.secret)
.expect(200);
expect(response.headers["x-ratelimit-limit-default"]).toBe(limit.toString());
expect(response.headers["x-ratelimit-remaining-default"]).toBe((limit - i).toString());
expect(Number(response.headers["x-ratelimit-reset-default"])).toBeGreaterThan(0);
}
const blockedResponse = await request(app.getHttpServer())
.get("/v2/me")
.set(X_CAL_CLIENT_ID, oAuthClient.id)
.set(X_CAL_SECRET_KEY, oAuthClient.secret)
.expect(429);
expect(blockedResponse.headers["x-ratelimit-limit-default"]).toBe(limit.toString());
expect(blockedResponse.headers["x-ratelimit-remaining-default"]).toBe("0");
expect(Number(blockedResponse.headers["x-ratelimit-reset-default"])).toBeGreaterThanOrEqual(
blockDuration / 1000
);
await new Promise((resolve) => setTimeout(resolve, blockDuration));
const afterBlockResponse = await request(app.getHttpServer())
.get("/v2/me")
.set(X_CAL_CLIENT_ID, oAuthClient.id)
.set(X_CAL_SECRET_KEY, oAuthClient.secret)
.expect(200);
expect(afterBlockResponse.headers["x-ratelimit-limit-default"]).toBe(limit.toString());
expect(afterBlockResponse.headers["x-ratelimit-remaining-default"]).toBe((limit - 1).toString());
expect(Number(afterBlockResponse.headers["x-ratelimit-reset-default"])).toBeGreaterThan(0);
},
15 * 1000
);
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(userEmail);
await organizationsRepositoryFixture.delete(organization.id);
await app.close();
});
});
});
+18 -5
View File
@@ -1,6 +1,7 @@
import appConfig from "@/config/app";
import { CustomThrottlerGuard } from "@/lib/throttler-guard";
import { AppLoggerMiddleware } from "@/middleware/app.logger.middleware";
import { RedirectsMiddleware } from "@/middleware/app.redirects.middleware";
import { RewriterMiddleware } from "@/middleware/app.rewrites.middleware";
import { JsonBodyMiddleware } from "@/middleware/body/json.body.middleware";
import { RawBodyMiddleware } from "@/middleware/body/raw.body.middleware";
@@ -12,12 +13,12 @@ import { JwtModule } from "@/modules/jwt/jwt.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { RedisModule } from "@/modules/redis/redis.module";
import { RedisService } from "@/modules/redis/redis.service";
import { ThrottlerStorageRedisService } from "@nest-lab/throttler-storage-redis";
import { BullModule } from "@nestjs/bull";
import { MiddlewareConsumer, Module, NestModule, RequestMethod } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { APP_GUARD, APP_INTERCEPTOR } from "@nestjs/core";
import { seconds, ThrottlerModule } from "@nestjs/throttler";
import { ThrottlerStorageRedisService } from "nestjs-throttler-storage-redis";
import { AppController } from "./app.controller";
@@ -33,16 +34,19 @@ import { AppController } from "./app.controller";
BullModule.forRoot({
redis: `${process.env.REDIS_URL}${process.env.NODE_ENV === "production" ? "?tls=true" : ""}`,
}),
// Rate limiting here is handled by the CustomThrottlerGuard
ThrottlerModule.forRootAsync({
imports: [RedisModule],
inject: [RedisService],
useFactory: (redisService: RedisService) => ({
// note(Lauris): IMPORTANT: rate limiting is enforced by CustomThrottlerGuard, but we need to have at least one
// entry in the throttlers array otherwise CustomThrottlerGuard is not invoked at all. If we specify only ThrottlerModule
// without .forRootAsync then throttler options are not passed to CustomThrottlerGuard containing redis connection etc.
// So we need to specify at least one dummy throttler here and CustomThrottlerGuard is actually handling the default and custom rate limits.
throttlers: [
{
name: "long",
ttl: seconds(60), // Time to live for the long period in seconds
limit: 120, // Maximum number of requests within the long ttl
name: "dummy",
ttl: seconds(60),
limit: 120,
},
],
storage: new ThrottlerStorageRedisService(redisService.redis),
@@ -55,6 +59,13 @@ import { AppController } from "./app.controller";
],
controllers: [AppController],
providers: [
{
provide: ThrottlerStorageRedisService,
useFactory: (redisService: RedisService) => {
return new ThrottlerStorageRedisService(redisService.redis);
},
inject: [RedisService],
},
{
provide: APP_INTERCEPTOR,
useClass: ResponseInterceptor,
@@ -79,6 +90,8 @@ export class AppModule implements NestModule {
.forRoutes("*")
.apply(AppLoggerMiddleware)
.forRoutes("*")
.apply(RedirectsMiddleware)
.forRoutes("/")
.apply(RewriterMiddleware)
.forRoutes("/");
}
@@ -68,6 +68,7 @@ export class CalendarsController {
@Post("/ics-feed/save")
@UseGuards(ApiAuthGuard)
@ApiOperation({ summary: "Save an ICS feed" })
async createIcsFeed(
@GetUser("id") userId: number,
@GetUser("email") userEmail: string,
@@ -78,6 +79,7 @@ export class CalendarsController {
@Get("/ics-feed/check")
@UseGuards(ApiAuthGuard)
@ApiOperation({ summary: "Check an ICS feed" })
async checkIcsFeed(@GetUser("id") userId: number): Promise<ApiResponse> {
return await this.icsFeedService.check(userId);
}
@@ -260,6 +260,7 @@ describe("Event types Endpoints", () => {
},
requiresBookerEmailVerification: false,
hideCalendarNotes: false,
hideCalendarEventDetails: false,
lockTimeZoneToggleOnBookingPage: true,
color: {
darkThemeHex: "#292929",
@@ -297,6 +298,7 @@ describe("Event types Endpoints", () => {
);
expect(createdEventType.hideCalendarNotes).toEqual(body.hideCalendarNotes);
expect(createdEventType.hideCalendarEventDetails).toEqual(body.hideCalendarEventDetails);
expect(createdEventType.lockTimeZoneToggleOnBookingPage).toEqual(
body.lockTimeZoneToggleOnBookingPage
);
@@ -351,6 +353,7 @@ describe("Event types Endpoints", () => {
eventType.requiresBookerEmailVerification
);
expect(fetchedEventType.hideCalendarNotes).toEqual(eventType.hideCalendarNotes);
expect(fetchedEventType.hideCalendarEventDetails).toEqual(eventType.hideCalendarEventDetails);
expect(fetchedEventType.lockTimeZoneToggleOnBookingPage).toEqual(
eventType.lockTimeZoneToggleOnBookingPage
);
@@ -667,6 +670,7 @@ describe("Event types Endpoints", () => {
},
requiresBookerEmailVerification: true,
hideCalendarNotes: true,
hideCalendarEventDetails: true,
lockTimeZoneToggleOnBookingPage: true,
color: {
darkThemeHex: "#292929",
@@ -706,6 +710,7 @@ describe("Event types Endpoints", () => {
body.requiresBookerEmailVerification
);
expect(updatedEventType.hideCalendarNotes).toEqual(body.hideCalendarNotes);
expect(updatedEventType.hideCalendarEventDetails).toEqual(body.hideCalendarEventDetails);
expect(updatedEventType.lockTimeZoneToggleOnBookingPage).toEqual(
body.lockTimeZoneToggleOnBookingPage
);
@@ -724,6 +729,7 @@ describe("Event types Endpoints", () => {
eventType.customName = updatedEventType.customName;
eventType.requiresBookerEmailVerification = updatedEventType.requiresBookerEmailVerification;
eventType.hideCalendarNotes = updatedEventType.hideCalendarNotes;
eventType.hideCalendarEventDetails = updatedEventType.hideCalendarEventDetails;
eventType.lockTimeZoneToggleOnBookingPage = updatedEventType.lockTimeZoneToggleOnBookingPage;
eventType.color = updatedEventType.color;
});
@@ -774,6 +780,7 @@ describe("Event types Endpoints", () => {
eventType.requiresBookerEmailVerification
);
expect(fetchedEventType.hideCalendarNotes).toEqual(eventType.hideCalendarNotes);
expect(fetchedEventType.hideCalendarEventDetails).toEqual(eventType.hideCalendarEventDetails);
expect(fetchedEventType.lockTimeZoneToggleOnBookingPage).toEqual(
eventType.lockTimeZoneToggleOnBookingPage
);
@@ -811,6 +818,7 @@ describe("Event types Endpoints", () => {
eventType.requiresBookerEmailVerification
);
expect(fetchedEventType.hideCalendarNotes).toEqual(eventType.hideCalendarNotes);
expect(fetchedEventType.hideCalendarEventDetails).toEqual(eventType.hideCalendarEventDetails);
expect(fetchedEventType.lockTimeZoneToggleOnBookingPage).toEqual(
eventType.lockTimeZoneToggleOnBookingPage
);
@@ -1,12 +1,9 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { UsersService } from "@/modules/users/services/users.service";
import { UserWithProfile } from "@/modules/users/users.repository";
import { Injectable } from "@nestjs/common";
import { getEventTypeById } from "@calcom/platform-libraries";
import { InputEventTransformed_2024_06_14 } from "@calcom/platform-types";
import type { PrismaClient } from "@calcom/prisma";
@Injectable()
export class EventTypesRepository_2024_06_14 {
@@ -69,21 +66,6 @@ export class EventTypesRepository_2024_06_14 {
});
}
async getUserEventTypeForAtom(
user: UserWithProfile,
isUserOrganizationAdmin: boolean,
eventTypeId: number
) {
return await getEventTypeById({
currentOrganizationId: this.usersService.getUserMainOrgId(user),
eventTypeId,
userId: user.id,
prisma: this.dbRead.prisma as unknown as PrismaClient,
isUserOrganizationAdmin,
isTrpcCall: true,
});
}
async getEventTypeById(eventTypeId: number) {
return this.dbRead.prisma.eventType.findUnique({
where: { id: eventTypeId },
@@ -142,27 +142,6 @@ export class EventTypesService_2024_06_14 {
});
}
async getUserEventTypeForAtom(user: UserWithProfile, eventTypeId: number) {
const organizationId = this.usersService.getUserMainOrgId(user);
const isUserOrganizationAdmin = organizationId
? await this.membershipsRepository.isUserOrganizationAdmin(user.id, organizationId)
: false;
const eventType = await this.eventTypesRepository.getUserEventTypeForAtom(
user,
isUserOrganizationAdmin,
eventTypeId
);
if (!eventType) {
return null;
}
this.checkUserOwnsEventType(user.id, eventType.eventType);
return eventType;
}
async getEventTypesPublicByUsername(username: string): Promise<EventTypesPublic> {
const user = await this.usersRepository.findByUsername(username);
if (!user) {
@@ -242,6 +242,10 @@ export class InputEventTypesService_2024_06_14 {
return transformConfirmationPolicyApiToInternal(requiresConfirmation);
}
transformInputRecurrignEvent(recurrence: CreateEventTypeInput_2024_06_14["recurrence"]) {
if (!recurrence || recurrence.disabled) {
return undefined;
}
return transformRecurrenceApiToInternal(recurrence);
}
@@ -80,6 +80,7 @@ type Input = Pick<
| "eventName"
| "destinationCalendar"
| "useEventTypeDestinationCalendarEmail"
| "hideCalendarEventDetails"
>;
@Injectable()
@@ -111,6 +112,7 @@ export class OutputEventTypesService_2024_06_14 {
hideCalendarNotes,
seatsShowAttendees,
useEventTypeDestinationCalendarEmail,
hideCalendarEventDetails,
} = databaseEventType;
const locations = this.transformLocations(databaseEventType.locations);
@@ -182,6 +184,7 @@ export class OutputEventTypesService_2024_06_14 {
customName,
destinationCalendar,
useDestinationCalendarEmail: useEventTypeDestinationCalendarEmail,
hideCalendarEventDetails,
};
}
@@ -23,8 +23,7 @@ import {
Patch,
UseGuards,
} from "@nestjs/common";
import { ApiResponse, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
import { Throttle } from "@nestjs/throttler";
import { ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
import { SCHEDULE_READ, SCHEDULE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
import { UpdateScheduleInput_2024_04_15 } from "@calcom/platform-types";
@@ -57,11 +56,6 @@ export class SchedulesController_2024_04_15 {
@Get("/default")
@Permissions([SCHEDULE_READ])
@ApiResponse({
status: 200,
description: "Returns the default schedule",
type: GetDefaultScheduleOutput_2024_04_15,
})
async getDefaultSchedule(
@GetUser() user: UserWithProfile
): Promise<GetDefaultScheduleOutput_2024_04_15 | null> {
@@ -78,7 +72,6 @@ export class SchedulesController_2024_04_15 {
@Get("/:scheduleId")
@Permissions([SCHEDULE_READ])
@Throttle({ default: { limit: 10, ttl: 60000 } }) // allow 10 requests per minute (for :scheduleId)
async getSchedule(
@GetUser() user: UserWithProfile,
@Param("scheduleId") scheduleId: number
@@ -18,7 +18,6 @@ import {
UseGuards,
} from "@nestjs/common";
import { ApiHeader, ApiOperation, ApiResponse, ApiTags as DocsTags } from "@nestjs/swagger";
import { Throttle } from "@nestjs/throttler";
import { SCHEDULE_READ, SCHEDULE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
import {
@@ -57,17 +56,19 @@ export class SchedulesController_2024_06_11 {
@ApiOperation({
summary: "Create a schedule",
description: `
Create a schedule for the authenticated user.
The point of creating schedules is for event types to be available at specific times.
First goal of schedules is to have a default schedule. If you are platform customer and created managed users, then it is important to note that each managed user should have a default schedule.
1. If you passed \`timeZone\` when creating managed user, then the default schedule from Monday to Friday from 9AM to 5PM will be created with that timezone. Managed user can then change the default schedule via \`AvailabilitySettings\` atom.
2. If you did not, then we assume you want that user has specific schedule right away. You should create default schedule by specifying
\`"isDefault": true\` in the request body. Until the user has a default schedule that user can't be booked or manage his / her schedule via the AvailabilitySettings atom.
The first goal of schedules is to have a default schedule. If you are platform customer and created managed users, then it is important to note that each managed user should have a default schedule.
1. If you passed \`timeZone\` when creating managed user, then the default schedule from Monday to Friday from 9AM to 5PM will be created with that timezone. The managed user can then change the default schedule via the \`AvailabilitySettings\` atom.
2. If you did not, then we assume you want the user to have this specific schedule right away. You should create a default schedule by specifying
\`"isDefault": true\` in the request body. Until the user has a default schedule the user can't be booked nor manage their schedule via the AvailabilitySettings atom.
Second goal is to create other schedules that event types can point to, so that when that event is booked availability is not checked against the default schedule but against that specific schedule.
After creating a non default schedule you can update event type to point to that schedule via the PATCH \`event-types/{eventTypeId}\` endpoint.
The second goal of schedules is to create another schedule that event types can point to. This is useful for when an event is booked because availability is not checked against the default schedule but instead against that specific schedule.
After creating a non-default schedule, you can update an event type to point to that schedule via the PATCH \`event-types/{eventTypeId}\` endpoint.
When specifying start time and end time for each day use 24 hour format e.g. 08:00, 15:00 etc.
When specifying start time and end time for each day use the 24 hour format e.g. 08:00, 15:00 etc.
`,
})
async createSchedule(
@@ -86,10 +87,12 @@ export class SchedulesController_2024_06_11 {
@Permissions([SCHEDULE_READ])
@ApiResponse({
status: 200,
description: "Returns the default schedule of the authenticated user",
type: GetDefaultScheduleOutput_2024_06_11,
})
@ApiOperation({ summary: "Get default schedule (of the authenticated user)" })
@ApiOperation({
summary: "Get default schedule",
description: "Get the default schedule of the authenticated user.",
})
async getDefaultSchedule(@GetUser() user: UserWithProfile): Promise<GetScheduleOutput_2024_06_11> {
const schedule = await this.schedulesService.getUserScheduleDefault(user.id);
@@ -101,7 +104,6 @@ export class SchedulesController_2024_06_11 {
@Get("/:scheduleId")
@Permissions([SCHEDULE_READ])
@Throttle({ default: { limit: 10, ttl: 60000 } }) // allow 10 requests per minute (for :scheduleId)
@ApiOperation({ summary: "Get a schedule" })
async getSchedule(
@GetUser() user: UserWithProfile,
@@ -117,7 +119,10 @@ export class SchedulesController_2024_06_11 {
@Get("/")
@Permissions([SCHEDULE_READ])
@ApiOperation({ summary: "Get all schedules (of the authenticated user)" })
@ApiOperation({
summary: "Get all schedules",
description: "Get all schedules of the authenticated user.",
})
async getSchedules(@GetUser() user: UserWithProfile): Promise<GetSchedulesOutput_2024_06_11> {
const schedules = await this.schedulesService.getUserSchedules(user.id);
+4
View File
@@ -19,6 +19,10 @@ export type Environment = {
CALCOM_LICENSE_KEY: string;
GET_LICENSE_KEY_URL: string;
API_KEY_PREFIX: string;
DOCS_URL: string;
RATE_LIMIT_DEFAULT_TTL_MS: number;
RATE_LIMIT_DEFAULT_LIMIT: number;
RATE_LIMIT_DEFAULT_BLOCK_DURATION_MS: number;
};
export const getEnv = <K extends keyof Environment>(key: K, fallback?: Environment[K]): Environment[K] => {
+160 -11
View File
@@ -1,42 +1,191 @@
import { isApiKey } from "@/lib/api-key";
import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { getEnv } from "@/env";
import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { ThrottlerStorageRedisService } from "@nest-lab/throttler-storage-redis";
import { Inject, Injectable, Logger, UnauthorizedException } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { ThrottlerGuard, ThrottlerModuleOptions, ThrottlerStorage } from "@nestjs/throttler";
import { Request } from "express";
import {
ThrottlerGuard,
ThrottlerException,
ThrottlerRequest,
ThrottlerModuleOptions,
seconds,
} from "@nestjs/throttler";
import { Request, Response } from "express";
import { z } from "zod";
import { X_CAL_CLIENT_ID } from "@calcom/platform-constants";
const rateLimitSchema = z.object({
name: z.string(),
limit: z.number(),
ttl: z.number(),
blockDuration: z.number(),
});
type RateLimitType = z.infer<typeof rateLimitSchema>;
const rateLimitsSchema = z.array(rateLimitSchema);
@Injectable()
export class CustomThrottlerGuard extends ThrottlerGuard {
private logger = new Logger("CustomThrottlerGuard");
private defaultTttl = Number(getEnv("RATE_LIMIT_DEFAULT_TTL_MS", 60 * 1000));
private defaultLimit = Number(getEnv("RATE_LIMIT_DEFAULT_LIMIT", 120));
private defaultBlockDuration = Number(getEnv("RATE_LIMIT_DEFAULT_BLOCK_DURATION_MS", 60 * 1000));
constructor(
options: ThrottlerModuleOptions,
storageService: ThrottlerStorage,
@Inject(ThrottlerStorageRedisService) protected readonly storageService: ThrottlerStorageRedisService,
reflector: Reflector,
private readonly config: ConfigService
private readonly dbRead: PrismaReadService
) {
super(options, storageService, reflector);
this.storageService = storageService;
}
protected async handleRequest(requestProps: ThrottlerRequest): Promise<boolean> {
const { context } = requestProps;
const request = context.switchToHttp().getRequest<Request>();
const response = context.switchToHttp().getResponse<Response>();
const tracker = await this.getTracker(request);
if (tracker.startsWith("api_key_")) {
return this.handleApiKeyRequest(tracker, response);
} else {
return this.handleNonApiKeyRequest(tracker, response);
}
}
private async handleApiKeyRequest(tracker: string, response: Response): Promise<boolean> {
const rateLimits = await this.getRateLimitsForApiKeyTracker(tracker);
let allLimitsBlocked = true;
for (const rateLimit of rateLimits) {
const { isBlocked } = await this.incrementRateLimit(tracker, rateLimit, response);
if (!isBlocked) {
allLimitsBlocked = false;
}
}
if (allLimitsBlocked) {
throw new ThrottlerException("Too many requests. Please try again later.");
}
return true;
}
private async handleNonApiKeyRequest(tracker: string, response: Response): Promise<boolean> {
const rateLimit = this.getDefaultRateLimit();
const { isBlocked } = await this.incrementRateLimit(tracker, rateLimit, response);
if (isBlocked) {
throw new ThrottlerException("Too many requests. Please try again later.");
}
return true;
}
private getDefaultRateLimit() {
return {
name: "default",
limit: this.getDefaultLimit(),
ttl: this.getDefaultTtl(),
blockDuration: this.getDefaultBlockDuration(),
};
}
getDefaultLimit() {
return this.defaultLimit;
}
getDefaultTtl() {
return this.defaultTttl;
}
getDefaultBlockDuration() {
return this.defaultBlockDuration;
}
private async getRateLimitsForApiKeyTracker(tracker: string) {
const cacheKey = `rate_limit:${tracker}`;
const cachedRateLimits = await this.storageService.redis.get(cacheKey);
if (cachedRateLimits) {
return rateLimitsSchema.parse(JSON.parse(cachedRateLimits));
}
const apiKey = tracker.replace("api_key_", "");
let rateLimits: RateLimitType[];
const apiKeyRecord = await this.dbRead.prisma.apiKey.findUnique({
where: { hashedKey: apiKey },
select: { id: true },
});
if (!apiKeyRecord) {
throw new UnauthorizedException("Invalid API Key");
}
rateLimits = await this.dbRead.prisma.rateLimit.findMany({
where: { apiKeyId: apiKeyRecord.id },
select: { name: true, limit: true, ttl: true, blockDuration: true },
});
if (!rateLimits || rateLimits.length === 0) {
rateLimits = [this.getDefaultRateLimit()];
}
await this.storageService.redis.setex(cacheKey, 3600, JSON.stringify(rateLimits));
return rateLimits;
}
private async incrementRateLimit(tracker: string, rateLimit: RateLimitType, response: Response) {
const { name, limit, ttl, blockDuration } = rateLimit;
const key = `${tracker}:${limit}:${ttl}`;
const { isBlocked, totalHits, timeToExpire, timeToBlockExpire } = await this.storageService.increment(
key,
ttl,
limit,
blockDuration,
name
);
const nameFirstUpper = name.charAt(0).toUpperCase() + name.slice(1);
response.setHeader(`X-RateLimit-Limit-${nameFirstUpper}`, limit);
response.setHeader(
`X-RateLimit-Remaining-${nameFirstUpper}`,
timeToBlockExpire ? 0 : Math.max(0, limit - totalHits)
);
response.setHeader(`X-RateLimit-Reset-${nameFirstUpper}`, timeToBlockExpire || timeToExpire);
this.logger.log(`Rate limit for ${tracker} incremented. Remaining: ${limit - totalHits}`);
return { isBlocked };
}
protected async getTracker(request: Request): Promise<string> {
const authorizationHeader = request.get("Authorization")?.replace("Bearer ", "");
if (authorizationHeader) {
return isApiKey(authorizationHeader, this.config.get<string>("api.apiKeyPrefix") ?? "cal_")
? `api_key_${authorizationHeader}`
const apiKeyPrefix = getEnv("API_KEY_PREFIX", "cal_");
return isApiKey(authorizationHeader, apiKeyPrefix)
? `api_key_${hashAPIKey(stripApiKey(authorizationHeader, apiKeyPrefix))}`
: `access_token_${authorizationHeader}`;
}
const oauthClientId = request.get(X_CAL_CLIENT_ID);
if (oauthClientId) {
return oauthClientId;
return `oauth_client_${oauthClientId}`;
}
if (request.ip) {
return request.ip;
return `ip_${request.ip}`;
}
this.logger.log(`no tracker found: ${request.url}`);
+7 -4
View File
@@ -114,11 +114,14 @@ async function generateSwagger(app: NestExpressApplication<Server>) {
}
fs.writeFileSync(outputFile, JSON.stringify(document, null, 2), { encoding: "utf8" });
SwaggerModule.setup("docs", app, document, {
customCss: ".swagger-ui .topbar { display: none }",
});
logger.log(`Swagger documentation available in the "/docs" endpoint\n`);
if (!process.env.DOCS_URL) {
SwaggerModule.setup("docs", app, document, {
customCss: ".swagger-ui .topbar { display: none }",
});
logger.log(`Swagger documentation available in the "/docs" endpoint\n`);
}
}
run().catch((error: Error) => {
@@ -0,0 +1,12 @@
import { Injectable, NestMiddleware } from "@nestjs/common";
import { Request, Response } from "express";
@Injectable()
export class RedirectsMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: () => void) {
if (process.env.DOCS_URL && (req.url.startsWith("/v2/docs") || req.url.startsWith("/docs"))) {
return res.redirect(process.env.DOCS_URL);
}
next();
}
}
@@ -1,7 +1,7 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import { App } from "@prisma/client";
import { App, Prisma } from "@prisma/client";
@Injectable()
export class AppsRepository {
@@ -10,4 +10,15 @@ export class AppsRepository {
async getAppBySlug(slug: string): Promise<App | null> {
return await this.dbRead.prisma.app.findUnique({ where: { slug } });
}
async createAppCredential(type: string, key: Prisma.InputJsonValue, userId: number, appId: string) {
return this.dbWrite.prisma.credential.create({
data: {
type: type,
key: key,
userId: userId,
appId: appId,
},
});
}
}
@@ -0,0 +1,23 @@
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
import { AtomsController } from "@/modules/atoms/controllers/atoms.controller";
import { EventTypesAtomService } from "@/modules/atoms/services/event-types-atom.service";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { UsersService } from "@/modules/users/services/users.service";
import { UsersRepository } from "@/modules/users/users.repository";
import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule, EventTypesModule_2024_06_14],
providers: [
EventTypesAtomService,
MembershipsRepository,
UsersRepository,
UsersService,
SchedulesRepository_2024_06_11,
],
exports: [EventTypesAtomService],
controllers: [AtomsController],
})
export class AtomsModule {}
@@ -0,0 +1,67 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { EventTypesAtomService } from "@/modules/atoms/services/event-types-atom.service";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { UserWithProfile } from "@/modules/users/users.repository";
import {
Controller,
Get,
Param,
ParseIntPipe,
UseGuards,
Version,
VERSION_NEUTRAL,
Patch,
Body,
} from "@nestjs/common";
import { ApiTags as DocsTags, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import type { UpdateEventTypeReturn } from "@calcom/platform-libraries";
import { ApiResponse } from "@calcom/platform-types";
/*
Endpoints used only by platform atoms, reusing code from other modules, data is already formatted and ready to be used by frontend atoms
these endpoints should not be recommended for use by third party and are excluded from docs
*/
@Controller({
path: "/v2/atoms",
version: API_VERSIONS_VALUES,
})
@DocsTags("Atoms - endpoints for atoms")
@DocsExcludeController(true)
export class AtomsController {
constructor(private readonly eventTypesService: EventTypesAtomService) {}
@Get("event-types/:eventTypeId")
@Version(VERSION_NEUTRAL)
@UseGuards(ApiAuthGuard)
async getAtomEventType(
@GetUser() user: UserWithProfile,
@Param("eventTypeId", ParseIntPipe) eventTypeId: number
): Promise<ApiResponse<unknown>> {
const eventType = await this.eventTypesService.getUserEventType(user, eventTypeId);
return {
status: SUCCESS_STATUS,
data: eventType,
};
}
@Patch("event-types/:eventTypeId")
@Version(VERSION_NEUTRAL)
@UseGuards(ApiAuthGuard)
async updateAtomEventType(
@GetUser() user: UserWithProfile,
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
@Body() body: UpdateEventTypeReturn
): Promise<ApiResponse<UpdateEventTypeReturn>> {
const eventType = await this.eventTypesService.updateEventType(eventTypeId, body, user);
return {
status: SUCCESS_STATUS,
data: eventType,
};
}
}
@@ -0,0 +1,80 @@
import { EventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/event-types.service";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { UsersService } from "@/modules/users/services/users.service";
import { UserWithProfile } from "@/modules/users/users.repository";
import { Injectable, NotFoundException } from "@nestjs/common";
import {
updateEventType,
TUpdateEventTypeInputSchema,
systemBeforeFieldEmail,
getEventTypeById,
} from "@calcom/platform-libraries";
import { PrismaClient } from "@calcom/prisma/client";
@Injectable()
export class EventTypesAtomService {
constructor(
private readonly membershipsRepository: MembershipsRepository,
private readonly usersService: UsersService,
private readonly dbWrite: PrismaWriteService,
private readonly dbRead: PrismaReadService,
private readonly eventTypeService: EventTypesService_2024_06_14
) {}
async getUserEventType(user: UserWithProfile, eventTypeId: number) {
this.eventTypeService.checkUserOwnsEventType(user.id, { id: eventTypeId, userId: user.id });
const organizationId = this.usersService.getUserMainOrgId(user);
const isUserOrganizationAdmin = organizationId
? await this.membershipsRepository.isUserOrganizationAdmin(user.id, organizationId)
: false;
const eventType = await getEventTypeById({
currentOrganizationId: this.usersService.getUserMainOrgId(user),
eventTypeId,
userId: user.id,
prisma: this.dbRead.prisma as unknown as PrismaClient,
isUserOrganizationAdmin,
isTrpcCall: true,
});
if (!eventType) {
throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
}
this.eventTypeService.checkUserOwnsEventType(user.id, eventType.eventType);
return eventType;
}
async updateEventType(eventTypeId: number, body: TUpdateEventTypeInputSchema, user: UserWithProfile) {
this.eventTypeService.checkCanUpdateEventType(user.id, eventTypeId, body.scheduleId);
const eventTypeUser = await this.eventTypeService.getUserToUpdateEvent(user);
const bookingFields = [...(body.bookingFields || [])];
if (
!bookingFields.find((field) => field.type === "email") &&
!bookingFields.find((field) => field.type === "phone")
) {
bookingFields.push(systemBeforeFieldEmail);
}
const eventType = await updateEventType({
input: { id: eventTypeId, ...body, bookingFields },
ctx: {
user: eventTypeUser,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
prisma: this.dbWrite.prisma,
},
});
if (!eventType) {
throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
}
return eventType.eventType;
}
}
@@ -1,3 +1,4 @@
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { createMock } from "@golevelup/ts-jest";
import { ExecutionContext } from "@nestjs/common";
@@ -26,7 +27,8 @@ describe("PermissionsGuard", () => {
return null;
}
}),
})
}),
createMock<OAuthClientRepository>()
);
});
@@ -38,7 +40,7 @@ describe("PermissionsGuard", () => {
it("should return false", async () => {
const mockContext = createMockExecutionContext({});
jest.spyOn(reflector, "get").mockReturnValue([SCHEDULE_WRITE]);
jest.spyOn(guard, "getOAuthClientPermissions").mockResolvedValue(0);
jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(0);
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
});
@@ -51,7 +53,7 @@ describe("PermissionsGuard", () => {
let oAuthClientPermissions = 0;
oAuthClientPermissions |= SCHEDULE_WRITE;
jest.spyOn(guard, "getOAuthClientPermissions").mockResolvedValue(oAuthClientPermissions);
jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(oAuthClientPermissions);
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
});
@@ -62,7 +64,7 @@ describe("PermissionsGuard", () => {
let oAuthClientPermissions = 0;
oAuthClientPermissions |= SCHEDULE_WRITE;
oAuthClientPermissions |= SCHEDULE_READ;
jest.spyOn(guard, "getOAuthClientPermissions").mockResolvedValue(oAuthClientPermissions);
jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(oAuthClientPermissions);
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
});
@@ -73,7 +75,7 @@ describe("PermissionsGuard", () => {
let oAuthClientPermissions = 0;
oAuthClientPermissions |= SCHEDULE_WRITE;
jest.spyOn(guard, "getOAuthClientPermissions").mockResolvedValue(oAuthClientPermissions);
jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(oAuthClientPermissions);
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
});
@@ -83,7 +85,7 @@ describe("PermissionsGuard", () => {
let oAuthClientPermissions = 0;
oAuthClientPermissions |= APPS_WRITE;
jest.spyOn(guard, "getOAuthClientPermissions").mockResolvedValue(oAuthClientPermissions);
jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(oAuthClientPermissions);
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
});
@@ -94,7 +96,63 @@ describe("PermissionsGuard", () => {
let oAuthClientPermissions = 0;
oAuthClientPermissions |= SCHEDULE_WRITE;
jest.spyOn(guard, "getOAuthClientPermissions").mockResolvedValue(oAuthClientPermissions);
jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(oAuthClientPermissions);
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
});
});
describe("when oauth id is provided", () => {
it("should return true for valid permissions", async () => {
const mockContext = createMockExecutionContext({ "x-cal-client-id": "100" });
jest.spyOn(reflector, "get").mockReturnValue([SCHEDULE_WRITE]);
let oAuthClientPermissions = 0;
oAuthClientPermissions |= SCHEDULE_WRITE;
jest.spyOn(guard, "getOAuthClientPermissionsById").mockResolvedValue(oAuthClientPermissions);
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
});
it("should return true for multiple valid permissions", async () => {
const mockContext = createMockExecutionContext({ "x-cal-client-id": "100" });
jest.spyOn(reflector, "get").mockReturnValue([SCHEDULE_WRITE, SCHEDULE_READ]);
let oAuthClientPermissions = 0;
oAuthClientPermissions |= SCHEDULE_WRITE;
oAuthClientPermissions |= SCHEDULE_READ;
jest.spyOn(guard, "getOAuthClientPermissionsById").mockResolvedValue(oAuthClientPermissions);
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
});
it("should return true for empty Permissions decorator", async () => {
const mockContext = createMockExecutionContext({ "x-cal-client-id": "100" });
jest.spyOn(reflector, "get").mockReturnValue([]);
let oAuthClientPermissions = 0;
oAuthClientPermissions |= SCHEDULE_WRITE;
jest.spyOn(guard, "getOAuthClientPermissionsById").mockResolvedValue(oAuthClientPermissions);
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
});
it("should return false for invalid permissions", async () => {
const mockContext = createMockExecutionContext({ "x-cal-client-id": "100" });
jest.spyOn(reflector, "get").mockReturnValue([SCHEDULE_WRITE]);
let oAuthClientPermissions = 0;
oAuthClientPermissions |= APPS_WRITE;
jest.spyOn(guard, "getOAuthClientPermissionsById").mockResolvedValue(oAuthClientPermissions);
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
});
it("should return false for a missing permission", async () => {
const mockContext = createMockExecutionContext({ "x-cal-client-id": "100" });
jest.spyOn(reflector, "get").mockReturnValue([SCHEDULE_WRITE, SCHEDULE_READ]);
let oAuthClientPermissions = 0;
oAuthClientPermissions |= SCHEDULE_WRITE;
jest.spyOn(guard, "getOAuthClientPermissionsById").mockResolvedValue(oAuthClientPermissions);
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
});
@@ -1,11 +1,13 @@
import { isApiKey } from "@/lib/api-key";
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { Injectable, CanActivate, ExecutionContext } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Reflector } from "@nestjs/core";
import { getToken } from "next-auth/jwt";
import { X_CAL_CLIENT_ID } from "@calcom/platform-constants";
import { hasPermissions } from "@calcom/platform-utils";
@Injectable()
@@ -13,7 +15,8 @@ export class PermissionsGuard implements CanActivate {
constructor(
private reflector: Reflector,
private tokensRepository: TokensRepository,
private readonly config: ConfigService
private readonly config: ConfigService,
private readonly oAuthClientRepository: OAuthClientRepository
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
@@ -27,12 +30,13 @@ export class PermissionsGuard implements CanActivate {
const authString = request.get("Authorization")?.replace("Bearer ", "");
const nextAuthSecret = this.config.get("next.authSecret", { infer: true });
const nextAuthToken = await getToken({ req: request, secret: nextAuthSecret });
const oAuthClientId = request.params?.clientId || request.get(X_CAL_CLIENT_ID);
if (nextAuthToken) {
return true;
}
if (!authString) {
if (!authString && !oAuthClientId) {
return false;
}
@@ -41,7 +45,9 @@ export class PermissionsGuard implements CanActivate {
return true;
}
const oAuthClientPermissions = await this.getOAuthClientPermissions(authString);
const oAuthClientPermissions = authString
? await this.getOAuthClientPermissionsByAccessToken(authString)
: await this.getOAuthClientPermissionsById(oAuthClientId);
if (!oAuthClientPermissions) {
return false;
@@ -50,8 +56,13 @@ export class PermissionsGuard implements CanActivate {
return hasPermissions(oAuthClientPermissions, [...requiredPermissions]);
}
async getOAuthClientPermissions(accessToken: string) {
async getOAuthClientPermissionsByAccessToken(accessToken: string) {
const oAuthClient = await this.tokensRepository.getAccessTokenClient(accessToken);
return oAuthClient?.permissions;
}
async getOAuthClientPermissionsById(id: string) {
const oAuthClient = await this.oAuthClientRepository.getOAuthClient(id);
return oAuthClient?.permissions;
}
}
@@ -68,20 +68,14 @@ export class BillingController {
@Param("teamId") teamId: number,
@Body() input: SubscribeToPlanInput
): Promise<ApiResponse<SubscribeTeamToBillingResponseDto | undefined>> {
const { action, url } = await this.billingService.createSubscriptionForTeam(teamId, input.plan);
if (action === "redirect") {
return {
status: "success",
data: {
action: "redirect",
url,
},
};
}
const customerId = await this.billingService.createTeamBilling(teamId);
const url = await this.billingService.redirectToSubscribeCheckout(teamId, input.plan, customerId);
return {
status: "success",
data: {
url,
},
};
}
@@ -92,20 +86,13 @@ export class BillingController {
@Param("teamId") teamId: number,
@Body() input: SubscribeToPlanInput
): Promise<ApiResponse<SubscribeTeamToBillingResponseDto | undefined>> {
const { action, url } = await this.billingService.updateSubscriptionForTeam(teamId, input.plan);
if (action === "redirect") {
return {
status: "success",
data: {
action: "redirect",
url,
},
};
}
const url = await this.billingService.updateSubscriptionForTeam(teamId, input.plan);
return {
status: "success",
data: {
url,
},
};
}
@@ -47,7 +47,7 @@ export class BillingService implements OnModuleDestroy {
}
}
async createSubscriptionForTeam(teamId: number, plan: PlatformPlan) {
async createTeamBilling(teamId: number) {
const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId);
let customerId = teamWithBilling?.platformBilling?.customerId;
@@ -60,6 +60,10 @@ export class BillingService implements OnModuleDestroy {
});
}
return customerId;
}
async redirectToSubscribeCheckout(teamId: number, plan: PlatformPlan, customerId?: string) {
const { url } = await this.stripeService.stripe.checkout.sessions.create({
customer: customerId,
line_items: [
@@ -90,7 +94,7 @@ export class BillingService implements OnModuleDestroy {
if (!url) throw new InternalServerErrorException("Failed to create Stripe session.");
return { action: "redirect", url };
return url;
}
async updateSubscriptionForTeam(teamId: number, plan: PlatformPlan) {
@@ -111,7 +115,7 @@ export class BillingService implements OnModuleDestroy {
if (!url) throw new InternalServerErrorException("Failed to create Stripe session.");
return { action: "redirect", url };
return url;
}
async setSubscriptionForTeam(teamId: number, subscriptionId: string, plan: PlatformPlan) {
@@ -1,7 +1,9 @@
import { PlatformEndpointsModule } from "@/ee/platform-endpoints-module";
import { AtomsModule } from "@/modules/atoms/atoms.module";
import { BillingModule } from "@/modules/billing/billing.module";
import { DestinationCalendarsModule } from "@/modules/destination-calendars/destination-calendars.module";
import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module";
import { StripeModule } from "@/modules/stripe/stripe.module";
import { TimezoneModule } from "@/modules/timezones/timezones.module";
import type { MiddlewareConsumer, NestModule } from "@nestjs/common";
import { Module } from "@nestjs/common";
@@ -18,6 +20,8 @@ import { WebhooksModule } from "./webhooks/webhooks.module";
UsersModule,
WebhooksModule,
DestinationCalendarsModule,
AtomsModule,
StripeModule,
],
})
export class EndpointsModule implements NestModule {
@@ -25,9 +25,11 @@ import {
import {
ApiTags as DocsTags,
ApiExcludeController as DocsExcludeController,
ApiExcludeEndpoint as DocsExcludeEndpoint,
ApiOperation as DocsOperation,
ApiOkResponse as DocsOkResponse,
ApiBadRequestResponse as DocsBadRequestResponse,
ApiHeader as DocsHeader,
} from "@nestjs/swagger";
import { Response as ExpressResponse } from "express";
@@ -37,8 +39,7 @@ import { SUCCESS_STATUS, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
path: "/v2/oauth/:clientId",
version: API_VERSIONS_VALUES,
})
@DocsExcludeController(getEnv("NODE_ENV") === "production")
@DocsTags("OAuth - development only")
@DocsTags("OAuth")
export class OAuthFlowController {
constructor(
private readonly oauthClientRepository: OAuthClientRepository,
@@ -62,6 +63,7 @@ export class OAuthFlowController {
description:
"Bad request if the OAuth client is not found, if the redirect URI is invalid, or if the user has already authorized the client.",
})
@DocsExcludeEndpoint(getEnv("NODE_ENV") === "production")
async authorize(
@Param("clientId") clientId: string,
@Body() body: OAuthAuthorizeInput,
@@ -108,6 +110,7 @@ export class OAuthFlowController {
description:
"Bad request if the authorization code is missing, invalid, or if the client ID and secret do not match.",
})
@DocsExcludeEndpoint(getEnv("NODE_ENV") === "production")
async exchange(
@Headers("Authorization") authorization: string,
@Param("clientId") clientId: string,
@@ -138,6 +141,12 @@ export class OAuthFlowController {
@Post("/refresh")
@HttpCode(HttpStatus.OK)
@UseGuards(ApiAuthGuard)
@DocsTags("Managed users")
@DocsHeader({
name: X_CAL_SECRET_KEY,
description: "OAuth client secret key.",
required: true,
})
async refreshAccessToken(
@Param("clientId") clientId: string,
@Headers(X_CAL_SECRET_KEY) secretKey: string,
@@ -1,6 +1,8 @@
import { ApiProperty as DocsProperty } from "@nestjs/swagger";
import { IsString } from "class-validator";
export class RefreshTokenInput {
@IsString()
@DocsProperty({ description: "Managed user's refresh token." })
refreshToken!: string;
}
@@ -300,6 +300,7 @@ describe("Organizations Event Types Endpoints", () => {
},
requiresBookerEmailVerification: true,
hideCalendarNotes: true,
hideCalendarEventDetails: true,
lockTimeZoneToggleOnBookingPage: true,
color: {
darkThemeHex: "#292929",
@@ -330,6 +331,7 @@ describe("Organizations Event Types Endpoints", () => {
expect(data.confirmationPolicy).toEqual(body.confirmationPolicy);
expect(data.requiresBookerEmailVerification).toEqual(body.requiresBookerEmailVerification);
expect(data.hideCalendarNotes).toEqual(body.hideCalendarNotes);
expect(data.hideCalendarEventDetails).toEqual(body.hideCalendarEventDetails);
expect(data.lockTimeZoneToggleOnBookingPage).toEqual(body.lockTimeZoneToggleOnBookingPage);
expect(data.color).toEqual(body.color);
@@ -65,6 +65,7 @@ type Input = Pick<
| "requiresConfirmationWillBlockSlot"
| "eventName"
| "useEventTypeDestinationCalendarEmail"
| "hideCalendarEventDetails"
>;
@Injectable()
@@ -1,7 +1,7 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { SlotsService } from "@/modules/slots/services/slots.service";
import { Query, Body, Controller, Get, Delete, Post, Req, Res } from "@nestjs/common";
import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
import { ApiTags as DocsTags, ApiCreatedResponse, ApiOkResponse, ApiOperation } from "@nestjs/swagger";
import { Response as ExpressResponse, Request as ExpressRequest } from "express";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
@@ -19,6 +19,21 @@ export class SlotsController {
constructor(private readonly slotsService: SlotsService) {}
@Post("/reserve")
@ApiCreatedResponse({
description: "Successful response returning uid of reserved slot.",
schema: {
type: "object",
properties: {
status: { type: "string", example: "success" },
data: {
type: "object",
properties: {
uid: { type: "string", example: "e2a7bcf9-cc7b-40a0-80d3-657d391775a6" },
},
},
},
},
})
@ApiOperation({ summary: "Reserve a slot" })
async reserveSlot(
@Body() body: ReserveSlotInput,
@@ -35,6 +50,15 @@ export class SlotsController {
}
@Delete("/selected-slot")
@ApiOkResponse({
description: "Response deleting reserved slot by uid.",
schema: {
type: "object",
properties: {
status: { type: "string", example: "success" },
},
},
})
@ApiOperation({ summary: "Delete a selected slot" })
async deleteSelectedSlot(
@Query() params: RemoveSelectedSlotInput,
@@ -50,6 +74,45 @@ export class SlotsController {
}
@Get("/available")
@ApiOkResponse({
description: "Available time slots retrieved successfully",
schema: {
type: "object",
properties: {
status: { type: "string", example: "success" },
data: {
type: "object",
properties: {
slots: {
type: "object",
additionalProperties: {
type: "array",
items: {
type: "object",
properties: {
time: { type: "string", format: "date-time", example: "2024-09-25T08:00:00.000Z" },
},
},
},
},
},
},
},
example: {
status: "success",
data: {
slots: {
"2024-09-25": [{ time: "2024-09-25T08:00:00.000Z" }, { time: "2024-09-25T08:15:00.000Z" }],
"2024-09-26": [
{ time: "2024-09-26T08:00:00.000Z" },
{ time: "2024-09-26T08:15:00.000Z" },
{ time: "2024-09-26T08:30:00.000Z" },
],
},
},
},
},
})
@ApiOperation({ summary: "Get available slots" })
async getAvailableSlots(
@Query() query: GetAvailableSlotsInput,
@@ -0,0 +1,101 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import {
StripConnectOutputDto,
StripConnectOutputResponseDto,
StripCredentialsCheckOutputResponseDto,
StripCredentialsSaveOutputResponseDto,
} from "@/modules/stripe/outputs/stripe.output";
import { StripeService } from "@/modules/stripe/stripe.service";
import { getOnErrorReturnToValueFromQueryState } from "@/modules/stripe/utils/getReturnToValueFromQueryState";
import { UserWithProfile } from "@/modules/users/users.repository";
import {
Controller,
Query,
UseGuards,
Get,
HttpCode,
HttpStatus,
Redirect,
Req,
BadRequestException,
Headers,
} from "@nestjs/common";
import { ApiTags as DocsTags } from "@nestjs/swagger";
import { plainToClass } from "class-transformer";
import { Request } from "express";
import { stringify } from "querystring";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
@Controller({
path: "/v2/stripe",
version: API_VERSIONS_VALUES,
})
@DocsTags("Stripe")
export class StripeController {
constructor(private readonly stripeService: StripeService) {}
@Get("/connect")
@UseGuards(ApiAuthGuard)
@HttpCode(HttpStatus.OK)
async redirect(
@Req() req: Request,
@Headers("Authorization") authorization: string,
@GetUser() user: UserWithProfile,
@Query("redir") redir?: string | null,
@Query("errorRedir") errorRedir?: string | null
): Promise<StripConnectOutputResponseDto> {
const origin = req.headers.origin;
const accessToken = authorization.replace("Bearer ", "");
const state = {
onErrorReturnTo: !!errorRedir ? errorRedir : origin,
fromApp: false,
returnTo: !!redir ? redir : origin,
accessToken,
};
const stripeRedirectUrl = await this.stripeService.getStripeRedirectUrl(
JSON.stringify(state),
user.email,
user.name
);
return {
status: SUCCESS_STATUS,
data: plainToClass(StripConnectOutputDto, { authUrl: stripeRedirectUrl }, { strategy: "excludeAll" }),
};
}
@Get("/save")
@UseGuards()
@Redirect(undefined, 301)
async save(
@Query("state") state: string,
@Query("code") code: string,
@Query("error") error: string | undefined,
@Query("error_description") error_description: string | undefined
): Promise<StripCredentialsSaveOutputResponseDto> {
const accessToken = JSON.parse(state).accessToken;
// user cancels flow
if (error === "access_denied") {
return { url: getOnErrorReturnToValueFromQueryState(state) };
}
if (error) {
throw new BadRequestException(stringify({ error, error_description }));
}
return await this.stripeService.saveStripeAccount(state, code, accessToken);
}
@Get("/check")
@UseGuards(ApiAuthGuard)
@HttpCode(HttpStatus.OK)
async check(@GetUser() user: UserWithProfile): Promise<StripCredentialsCheckOutputResponseDto> {
return await this.stripeService.checkIfStripeAccountConnected(user.id);
}
}
@@ -0,0 +1,7 @@
import { IsString, IsOptional } from "class-validator";
export class StripeConnectQueryParamsInputDto {
@IsString()
@IsOptional()
readonly redir?: string;
}
@@ -0,0 +1,33 @@
import { ApiProperty } from "@nestjs/swagger";
import { Expose, Type } from "class-transformer";
import { IsString, ValidateNested, IsEnum } from "class-validator";
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
export class StripConnectOutputDto {
@IsString()
@Expose()
readonly authUrl!: string;
}
export class StripConnectOutputResponseDto {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@Expose()
@ValidateNested()
@Type(() => StripConnectOutputDto)
data!: StripConnectOutputDto;
}
export class StripCredentialsCheckOutputResponseDto {
@ApiProperty({ example: SUCCESS_STATUS })
status!: typeof SUCCESS_STATUS;
}
export class StripCredentialsSaveOutputResponseDto {
@IsString()
@Expose()
readonly url!: string;
}
@@ -1,10 +1,16 @@
import { AppsRepository } from "@/modules/apps/apps.repository";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { StripeController } from "@/modules/stripe/controllers/stripe.controller";
import { StripeService } from "@/modules/stripe/stripe.service";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
@Module({
imports: [ConfigModule],
imports: [ConfigModule, PrismaModule],
exports: [StripeService],
providers: [StripeService],
providers: [StripeService, AppsRepository, CredentialsRepository, TokensRepository],
controllers: [StripeController],
})
export class StripeModule {}
@@ -1,15 +1,130 @@
import { AppConfig } from "@/config/type";
import { Injectable } from "@nestjs/common";
import { AppsRepository } from "@/modules/apps/apps.repository";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { getReturnToValueFromQueryState } from "@/modules/stripe/utils/getReturnToValueFromQueryState";
import { stripeInstance } from "@/modules/stripe/utils/newStripeInstance";
import { StripeData } from "@/modules/stripe/utils/stripeDataSchemas";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { Injectable, NotFoundException, BadRequestException, UnauthorizedException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import type { Prisma } from "@prisma/client";
import Stripe from "stripe";
import { z } from "zod";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { stripeKeysResponseSchema } from "./utils/stripeDataSchemas";
import stringify = require("qs-stringify");
@Injectable()
export class StripeService {
public stripe: Stripe;
private redirectUri = `${this.config.get("api.url")}/stripe/save`;
constructor(configService: ConfigService<AppConfig>) {
constructor(
configService: ConfigService<AppConfig>,
private readonly config: ConfigService,
private readonly appsRepository: AppsRepository,
private readonly credentialRepository: CredentialsRepository,
private readonly tokensRepository: TokensRepository
) {
this.stripe = new Stripe(configService.get("stripe.apiKey", { infer: true }) ?? "", {
apiVersion: "2020-08-27",
});
}
async getStripeRedirectUrl(state: string, userEmail?: string, userName?: string | null) {
const { client_id } = await this.getStripeAppKeys();
const stripeConnectParams: Stripe.OAuthAuthorizeUrlParams = {
client_id,
scope: "read_write",
response_type: "code",
stripe_user: {
email: userEmail,
first_name: userName || undefined,
/** We need this so E2E don't fail for international users */
country: process.env.NEXT_PUBLIC_IS_E2E ? "US" : undefined,
},
redirect_uri: this.redirectUri,
state: state,
};
const params = z.record(z.any()).parse(stripeConnectParams);
const query = stringify(params);
const url = `https://connect.stripe.com/oauth/authorize?${query}`;
return url;
}
async getStripeAppKeys() {
const app = await this.appsRepository.getAppBySlug("stripe");
const { client_id, client_secret } = stripeKeysResponseSchema.parse(app?.keys);
if (!client_id) {
throw new NotFoundException("Stripe app not found");
}
if (!client_secret) {
throw new NotFoundException("Stripe app not found");
}
return { client_id, client_secret };
}
async saveStripeAccount(state: string, code: string, accessToken: string): Promise<{ url: string }> {
const userId = await this.tokensRepository.getAccessTokenOwnerId(accessToken);
if (!userId) {
throw new UnauthorizedException("Invalid Access token.");
}
const response = await stripeInstance.oauth.token({
grant_type: "authorization_code",
code: code?.toString(),
});
const data: StripeData = { ...response, default_currency: "" };
if (response["stripe_user_id"]) {
const account = await stripeInstance.accounts.retrieve(response["stripe_user_id"]);
data["default_currency"] = account.default_currency;
}
await this.appsRepository.createAppCredential(
"stripe_payment",
data as unknown as Prisma.InputJsonObject,
userId,
"stripe"
);
return { url: getReturnToValueFromQueryState(state) };
}
async checkIfStripeAccountConnected(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
const stripeCredentials = await this.credentialRepository.getByTypeAndUserId("stripe_payment", userId);
if (!stripeCredentials) {
throw new NotFoundException("Credentials for stripe not found.");
}
if (stripeCredentials.invalid) {
throw new BadRequestException("Invalid stripe credentials.");
}
const stripeKey = JSON.stringify(stripeCredentials.key);
const stripeKeyObject = JSON.parse(stripeKey);
const stripeAccount = await stripeInstance.accounts.retrieve(stripeKeyObject?.stripe_user_id);
// both of these should be true for an account to be fully active
if (!stripeAccount.payouts_enabled || !stripeAccount.charges_enabled) {
throw new BadRequestException("Stripe account is not an active account");
}
return {
status: SUCCESS_STATUS,
};
}
}
@@ -0,0 +1,19 @@
export const getReturnToValueFromQueryState = (queryState: string | string[] | undefined) => {
let returnTo = "";
try {
returnTo = JSON.parse(`${queryState}`).returnTo;
} catch (error) {
console.info("No 'returnTo' in req.query.state");
}
return returnTo;
};
export const getOnErrorReturnToValueFromQueryState = (queryState: string | string[] | undefined) => {
let returnTo = "";
try {
returnTo = JSON.parse(`${queryState}`).onErrorReturnTo;
} catch (error) {
console.info("No 'onErrorReturnTo' in req.query.state");
}
return returnTo;
};
@@ -0,0 +1,6 @@
import Stripe from "stripe";
const stripeApiKey = process.env.STRIPE_API_KEY || "";
export const stripeInstance = new Stripe(stripeApiKey, {
apiVersion: "2020-08-27",
});
@@ -0,0 +1,24 @@
import { z } from "zod";
export const stripeOAuthTokenSchema = z.object({
access_token: z.string().optional(),
scope: z.string().optional(),
livemode: z.boolean().optional(),
token_type: z.literal("bearer").optional(),
refresh_token: z.string().optional(),
stripe_user_id: z.string().optional(),
stripe_publishable_key: z.string().optional(),
});
export const stripeDataSchema = stripeOAuthTokenSchema.extend({
default_currency: z.string(),
});
export type StripeData = z.infer<typeof stripeDataSchema>;
export const stripeKeysResponseSchema = z.object({
client_id: z.string().startsWith("ca_").min(1),
client_secret: z.string().startsWith("sk_").min(1),
public_key: z.string().startsWith("pk_").min(1),
webhook_secret: z.string().startsWith("whsec_").min(1),
});
@@ -3,12 +3,12 @@ import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repo
import { UsersService } from "@/modules/users/services/users.service";
import { WebhooksService } from "@/modules/webhooks/services/webhooks.service";
import {
BadRequestException,
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
NotFoundException,
ForbiddenException,
BadRequestException,
} from "@nestjs/common";
import { Request } from "express";
@@ -39,14 +39,10 @@ export class IsOAuthClientWebhookGuard implements CanActivate {
throw new BadRequestException("webhookId parameter not specified in the request");
}
if (!webhookId) {
if (!oAuthClientId) {
throw new BadRequestException("oAuthClientId parameter not specified in the request");
}
if (!user || !webhookId || !oAuthClientId) {
return false;
}
const oAuthClient = await this.oAuthClientRepository.getOAuthClient(oAuthClientId);
if (!oAuthClient) {
+477 -153
View File
@@ -3248,6 +3248,7 @@
"/v2/calendars/ics-feed/save": {
"post": {
"operationId": "CalendarsController_createIcsFeed",
"summary": "Save an ICS feed",
"parameters": [],
"requestBody": {
"required": true,
@@ -3279,6 +3280,7 @@
"/v2/calendars/ics-feed/check": {
"get": {
"operationId": "CalendarsController_checkIcsFeed",
"summary": "Check an ICS feed",
"parameters": [],
"responses": {
"200": {
@@ -4106,6 +4108,147 @@
]
}
},
"/v2/oauth/{clientId}/authorize": {
"post": {
"operationId": "OAuthFlowController_authorize",
"summary": "Authorize an OAuth client",
"description": "Redirects the user to the specified 'redirect_uri' with an authorization code in query parameter if the client is authorized successfully. The code is then exchanged for access and refresh tokens via the `/exchange` endpoint.",
"parameters": [
{
"name": "clientId",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/OAuthAuthorizeInput"
}
}
}
},
"responses": {
"200": {
"description": "The user is redirected to the 'redirect_uri' with an authorization code in query parameter e.g. `redirectUri?code=secretcode.`"
},
"400": {
"description": "Bad request if the OAuth client is not found, if the redirect URI is invalid, or if the user has already authorized the client."
}
},
"tags": [
"oauth"
]
}
},
"/v2/oauth/{clientId}/exchange": {
"post": {
"operationId": "OAuthFlowController_exchange",
"summary": "Exchange authorization code for access tokens",
"description": "Exchanges the authorization code received from the `/authorize` endpoint for access and refresh tokens. The authorization code should be provided in the 'Authorization' header prefixed with 'Bearer '.",
"parameters": [
{
"name": "Authorization",
"required": true,
"in": "header",
"schema": {
"type": "string"
}
},
{
"name": "clientId",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ExchangeAuthorizationCodeInput"
}
}
}
},
"responses": {
"200": {
"description": "Successfully exchanged authorization code for access and refresh tokens.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/KeysResponseDto"
}
}
}
},
"400": {
"description": "Bad request if the authorization code is missing, invalid, or if the client ID and secret do not match."
}
},
"tags": [
"oauth"
]
}
},
"/v2/oauth/{clientId}/refresh": {
"post": {
"operationId": "OAuthFlowController_refreshAccessToken",
"parameters": [
{
"name": "clientId",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
},
{
"name": "x-cal-secret-key",
"required": true,
"in": "header",
"description": "OAuth client secret key.",
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RefreshTokenInput"
}
}
}
},
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/KeysResponseDto"
}
}
}
}
},
"tags": [
"oauth",
"Managed users"
]
}
},
"/v2/oauth-clients": {
"post": {
"operationId": "OAuthClientsController_createOAuthClient",
@@ -4304,150 +4447,11 @@
]
}
},
"/v2/oauth/{clientId}/authorize": {
"post": {
"operationId": "OAuthFlowController_authorize",
"summary": "Authorize an OAuth client",
"description": "Redirects the user to the specified 'redirect_uri' with an authorization code in query parameter if the client is authorized successfully. The code is then exchanged for access and refresh tokens via the `/exchange` endpoint.",
"parameters": [
{
"name": "clientId",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/OAuthAuthorizeInput"
}
}
}
},
"responses": {
"200": {
"description": "The user is redirected to the 'redirect_uri' with an authorization code in query parameter e.g. `redirectUri?code=secretcode.`"
},
"400": {
"description": "Bad request if the OAuth client is not found, if the redirect URI is invalid, or if the user has already authorized the client."
}
},
"tags": [
"OAuth - development only"
]
}
},
"/v2/oauth/{clientId}/exchange": {
"post": {
"operationId": "OAuthFlowController_exchange",
"summary": "Exchange authorization code for access tokens",
"description": "Exchanges the authorization code received from the `/authorize` endpoint for access and refresh tokens. The authorization code should be provided in the 'Authorization' header prefixed with 'Bearer '.",
"parameters": [
{
"name": "Authorization",
"required": true,
"in": "header",
"schema": {
"type": "string"
}
},
{
"name": "clientId",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ExchangeAuthorizationCodeInput"
}
}
}
},
"responses": {
"200": {
"description": "Successfully exchanged authorization code for access and refresh tokens.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/KeysResponseDto"
}
}
}
},
"400": {
"description": "Bad request if the authorization code is missing, invalid, or if the client ID and secret do not match."
}
},
"tags": [
"OAuth - development only"
]
}
},
"/v2/oauth/{clientId}/refresh": {
"post": {
"operationId": "OAuthFlowController_refreshAccessToken",
"parameters": [
{
"name": "clientId",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
},
{
"name": "x-cal-secret-key",
"required": true,
"in": "header",
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RefreshTokenInput"
}
}
}
},
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/KeysResponseDto"
}
}
}
}
},
"tags": [
"OAuth - development only"
]
}
},
"/v2/schedules": {
"post": {
"operationId": "SchedulesController_2024_06_11_createSchedule",
"summary": "Create a schedule",
"description": "\n The point of creating schedules is for event types to be available at specific times.\n\n First goal of schedules is to have a default schedule. If you are platform customer and created managed users, then it is important to note that each managed user should have a default schedule.\n 1. If you passed `timeZone` when creating managed user, then the default schedule from Monday to Friday from 9AM to 5PM will be created with that timezone. Managed user can then change the default schedule via `AvailabilitySettings` atom.\n 2. If you did not, then we assume you want that user has specific schedule right away. You should create default schedule by specifying\n `\"isDefault\": true` in the request body. Until the user has a default schedule that user can't be booked or manage his / her schedule via the AvailabilitySettings atom.\n\n Second goal is to create other schedules that event types can point to, so that when that event is booked availability is not checked against the default schedule but against that specific schedule.\n After creating a non default schedule you can update event type to point to that schedule via the PATCH `event-types/{eventTypeId}` endpoint.\n\n When specifying start time and end time for each day use 24 hour format e.g. 08:00, 15:00 etc.\n ",
"description": "\n Create a schedule for the authenticated user.\n\n The point of creating schedules is for event types to be available at specific times.\n\n The first goal of schedules is to have a default schedule. If you are platform customer and created managed users, then it is important to note that each managed user should have a default schedule.\n 1. If you passed `timeZone` when creating managed user, then the default schedule from Monday to Friday from 9AM to 5PM will be created with that timezone. The managed user can then change the default schedule via the `AvailabilitySettings` atom.\n 2. If you did not, then we assume you want the user to have this specific schedule right away. You should create a default schedule by specifying\n `\"isDefault\": true` in the request body. Until the user has a default schedule the user can't be booked nor manage their schedule via the AvailabilitySettings atom.\n\n The second goal of schedules is to create another schedule that event types can point to. This is useful for when an event is booked because availability is not checked against the default schedule but instead against that specific schedule.\n After creating a non-default schedule, you can update an event type to point to that schedule via the PATCH `event-types/{eventTypeId}` endpoint.\n\n When specifying start time and end time for each day use the 24 hour format e.g. 08:00, 15:00 etc.\n ",
"parameters": [
{
"name": "Authorization",
@@ -4496,7 +4500,8 @@
},
"get": {
"operationId": "SchedulesController_2024_06_11_getSchedules",
"summary": "Get all schedules (of the authenticated user)",
"summary": "Get all schedules",
"description": "Get all schedules of the authenticated user.",
"parameters": [
{
"name": "Authorization",
@@ -4537,7 +4542,8 @@
"/v2/schedules/default": {
"get": {
"operationId": "SchedulesController_2024_06_11_getDefaultSchedule",
"summary": "Get default schedule (of the authenticated user)",
"summary": "Get default schedule",
"description": "Get the default schedule of the authenticated user.",
"parameters": [
{
"name": "Authorization",
@@ -4560,7 +4566,7 @@
],
"responses": {
"200": {
"description": "Returns the default schedule of the authenticated user",
"description": "",
"content": {
"application/json": {
"schema": {
@@ -4822,11 +4828,26 @@
},
"responses": {
"201": {
"description": "",
"description": "Successful response returning uid of reserved slot.",
"content": {
"application/json": {
"schema": {
"type": "object"
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "success"
},
"data": {
"type": "object",
"properties": {
"uid": {
"type": "string",
"example": "e2a7bcf9-cc7b-40a0-80d3-657d391775a6"
}
}
}
}
}
}
}
@@ -4841,14 +4862,31 @@
"delete": {
"operationId": "SlotsController_deleteSelectedSlot",
"summary": "Delete a selected slot",
"parameters": [],
"parameters": [
{
"name": "uid",
"required": true,
"in": "query",
"description": "Unique identifier for the slot to be removed.",
"example": "e2a7bcf9-cc7b-40a0-80d3-657d391775a6",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"description": "Response deleting reserved slot by uid.",
"content": {
"application/json": {
"schema": {
"type": "object"
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "success"
}
}
}
}
}
@@ -4863,14 +4901,128 @@
"get": {
"operationId": "SlotsController_getAvailableSlots",
"summary": "Get available slots",
"parameters": [],
"parameters": [
{
"name": "startTime",
"required": true,
"in": "query",
"description": "Start date string starting from which to fetch slots in UTC timezone.",
"example": "2022-06-14T00:00:00.000Z",
"schema": {
"type": "string"
}
},
{
"name": "endTime",
"required": true,
"in": "query",
"description": "End date string until which to fetch slots in UTC timezone.",
"example": "2022-06-14T23:59:59.999Z",
"schema": {
"type": "string"
}
},
{
"name": "eventTypeId",
"required": true,
"in": "query",
"description": "Event Type ID for which slots are being fetched.",
"example": 100,
"schema": {
"type": "number"
}
},
{
"name": "eventTypeSlug",
"required": true,
"in": "query",
"description": "Slug of the event type for which slots are being fetched.",
"schema": {
"type": "string"
}
},
{
"name": "usernameList",
"required": true,
"in": "query",
"description": "Only for dynamic events - list of usernames for which slots are being fetched.",
"schema": {
"type": "array",
"items": {
"type": "string"
}
}
},
{
"name": "duration",
"required": true,
"in": "query",
"description": "Only for dynamic events - length of returned slots.",
"schema": {
"type": "number"
}
}
],
"responses": {
"200": {
"description": "",
"description": "Available time slots retrieved successfully",
"content": {
"application/json": {
"schema": {
"type": "object"
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "success"
},
"data": {
"type": "object",
"properties": {
"slots": {
"type": "object",
"additionalProperties": {
"type": "array",
"items": {
"type": "object",
"properties": {
"time": {
"type": "string",
"format": "date-time",
"example": "2024-09-25T08:00:00.000Z"
}
}
}
}
}
}
}
},
"example": {
"status": "success",
"data": {
"slots": {
"2024-09-25": [
{
"time": "2024-09-25T08:00:00.000Z"
},
{
"time": "2024-09-25T08:15:00.000Z"
}
],
"2024-09-26": [
{
"time": "2024-09-26T08:00:00.000Z"
},
{
"time": "2024-09-26T08:15:00.000Z"
},
{
"time": "2024-09-26T08:30:00.000Z"
}
]
}
}
}
}
}
}
@@ -4881,6 +5033,95 @@
]
}
},
"/v2/stripe/connect": {
"get": {
"operationId": "StripeController_redirect",
"parameters": [
{
"name": "Authorization",
"required": true,
"in": "header",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/StripConnectOutputResponseDto"
}
}
}
}
},
"tags": [
"Stripe"
]
}
},
"/v2/stripe/save": {
"get": {
"operationId": "StripeController_save",
"parameters": [
{
"name": "state",
"required": true,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "code",
"required": true,
"in": "query",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/StripCredentialsSaveOutputResponseDto"
}
}
}
}
},
"tags": [
"Stripe"
]
}
},
"/v2/stripe/check": {
"get": {
"operationId": "StripeController_check",
"parameters": [],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/StripCredentialsCheckOutputResponseDto"
}
}
}
}
},
"tags": [
"Stripe"
]
}
},
"/v2/timezones": {
"get": {
"operationId": "TimezonesController_getTimeZones",
@@ -5689,7 +5930,8 @@
"type": "object",
"properties": {
"refreshToken": {
"type": "string"
"type": "string",
"description": "Managed user's refresh token."
}
},
"required": [
@@ -9528,6 +9770,9 @@
"useDestinationCalendarEmail": {
"type": "boolean"
},
"hideCalendarEventDetails": {
"type": "boolean"
},
"teamId": {
"type": "number",
"nullable": true
@@ -10740,6 +10985,60 @@
}
}
},
"StripConnectOutputDto": {
"type": "object",
"properties": {
"authUrl": {
"type": "string"
}
},
"required": [
"authUrl"
]
},
"StripConnectOutputResponseDto": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "success",
"enum": [
"success",
"error"
]
},
"data": {
"$ref": "#/components/schemas/StripConnectOutputDto"
}
},
"required": [
"status",
"data"
]
},
"StripCredentialsSaveOutputResponseDto": {
"type": "object",
"properties": {
"url": {
"type": "string"
}
},
"required": [
"url"
]
},
"StripCredentialsCheckOutputResponseDto": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "success"
}
},
"required": [
"status"
]
},
"GetDefaultScheduleOutput_2024_06_11": {
"type": "object",
"properties": {
@@ -12133,7 +12432,32 @@
},
"ReserveSlotInput": {
"type": "object",
"properties": {}
"properties": {
"eventTypeId": {
"type": "number",
"description": "Event Type ID for which timeslot is being reserved.",
"example": 100
},
"slotUtcStartDate": {
"type": "string",
"description": "Start date of the slot in UTC timezone.",
"example": "2022-06-14T00:00:00.000Z"
},
"slotUtcEndDate": {
"type": "string",
"description": "End date of the slot in UTC timezone.",
"example": "2022-06-14T00:30:00.000Z"
},
"bookingUid": {
"type": "string",
"description": "Optional but only for events with seats. Used to retrieve booking of a seated event."
}
},
"required": [
"eventTypeId",
"slotUtcStartDate",
"slotUtcEndDate"
]
},
"UserWebhookOutputDto": {
"type": "object",
@@ -0,0 +1,22 @@
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { TestingModule } from "@nestjs/testing";
export class RateLimitRepositoryFixture {
private dbWrite: PrismaWriteService["prisma"];
constructor(private readonly module: TestingModule) {
this.dbWrite = module.get(PrismaWriteService).prisma;
}
async createRateLimit(name: string, apiKeyId: string, ttl: number, limit: number, blockDuration: number) {
return await this.dbWrite.rateLimit.create({
data: {
name,
apiKeyId,
ttl,
limit,
blockDuration,
},
});
}
}
+4
View File
@@ -16,6 +16,10 @@ const env: Partial<Omit<Environment, "NODE_ENV">> = {
API_KEY_PREFIX: "cal_test_",
GET_LICENSE_KEY_URL: " https://console.cal.com/api/license",
CALCOM_LICENSE_KEY: "c4234812-12ab-42s6-a1e3-55bedd4a5bb7",
RATE_LIMIT_DEFAULT_TTL_MS: 60000,
// note(Lauris): setting high limit so that e2e tests themselves are not rate limited
RATE_LIMIT_DEFAULT_LIMIT: 10000,
RATE_LIMIT_DEFAULT_BLOCK_DURATION_MS: 60000,
};
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
+1 -1
View File
@@ -8,8 +8,8 @@ import { FUTURE_ROUTES_ENABLED_COOKIE_NAME, FUTURE_ROUTES_OVERRIDE_COOKIE_NAME }
const ROUTES: [URLPattern, boolean][] = [
["/event-types", process.env.APP_ROUTER_EVENT_TYPES_ENABLED === "1"] as const,
["/settings/developer/:path*", process.env.APP_ROUTER_SETTINGS_DEVELOPER_ENABLED === "1"] as const,
["/settings/security/:path*", process.env.APP_ROUTER_SETTINGS_SECURITY_ENABLED === "1"] as const,
["/settings/admin/:path*", process.env.APP_ROUTER_SETTINGS_ADMIN_ENABLED === "1"] as const,
["/settings/my-account/:path*", process.env.APP_ROUTER_SETTINGS_MY_ACCOUNT_ENABLED === "1"] as const,
["/settings/platform/:path*", process.env.APP_ROUTER_SETTINGS_PLATFORM_ENABLED === "1"] as const,
["/settings/organizations/:path*", process.env.APP_ROUTER_SETTINGS_ORG_ENABLED === "1"] as const,
["/apps/installed/:category", process.env.APP_ROUTER_APPS_INSTALLED_CATEGORY_ENABLED === "1"] as const,
-3
View File
@@ -21,8 +21,5 @@ export const withAppDirSsr =
...("trpcState" in props && {
dehydratedState: props.trpcState,
}),
...("isInfiniteScrollEnabled" in props && {
isInfiniteScrollEnabled: props.isInfiniteScrollEnabled,
}),
};
};
+1 -1
View File
@@ -51,7 +51,7 @@ export function WithLayout<T extends Record<string, any>>({
requiresLicense={requiresLicense || !!(Page && "requiresLicense" in Page && Page.requiresLicense)}
nonce={nonce}
themeBasis={null}
isThemeSupported={!!(Page && "isThemeSupported" in Page && Page.isThemeSupported)}
isThemeSupported={Page && "isThemeSupported" in Page ? (Page.isThemeSupported as boolean) : undefined}
isBookingPage={isBookingPage || !!(Page && "isBookingPage" in Page && Page.isBookingPage)}
{...props}>
{pageWithServerLayout}
@@ -0,0 +1,5 @@
import { WithLayout } from "app/layoutHOC";
import { getLayout } from "@calcom/features/settings/appDir/SettingsLayoutAppDir";
export default WithLayout({ getServerLayout: getLayout })<"L">;
@@ -47,6 +47,7 @@ export default function CancelBooking(props: Props) {
const cancelBookingRef = useCallback((node: HTMLTextAreaElement) => {
if (node !== null) {
// eslint-disable-next-line @calcom/eslint/no-scroll-into-view-embed -- CancelBooking is not usually used in embed mode
node.scrollIntoView({ behavior: "smooth" });
node.focus();
}
@@ -11,6 +11,7 @@ const CreateEventsOnCalendarSelect = (props: ICreateEventsOnCalendarSelectProps)
const { calendar } = props;
const { t } = useLocale();
const mutation = trpc.viewer.setDestinationCalendar.useMutation();
const connectedCalendarsQuery = trpc.viewer.connectedCalendars.useQuery();
return (
<>
@@ -26,6 +27,7 @@ const CreateEventsOnCalendarSelect = (props: ICreateEventsOnCalendarSelectProps)
mutation.mutate(calendar);
}}
hidePlaceholder
calendarsQueryData={connectedCalendarsQuery.data}
/>
</div>
</div>
+5 -11
View File
@@ -1,15 +1,9 @@
import * as Sentry from "@sentry/nextjs";
export function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
});
export async function register() {
if (process.env.NEXT_PUBLIC_SENTRY_DSN && process.env.NEXT_RUNTIME === "nodejs") {
await import("./sentry.server.config");
}
if (process.env.NEXT_RUNTIME === "edge") {
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
});
if (process.env.NEXT_PUBLIC_SENTRY_DSN && process.env.NEXT_RUNTIME === "edge") {
await import("./sentry.edge.config");
}
}
+7 -5
View File
@@ -71,10 +71,12 @@ const CustomI18nextProvider = (props: { children: React.ReactElement; i18n?: SSR
**/
const session = useSession();
const locale =
session?.data?.user.locale ?? typeof window !== "undefined"
? window.document.documentElement.lang || "en"
const fallbackLocale =
typeof window !== "undefined" && window.document.documentElement.lang
? window.document.documentElement.lang
: "en";
const newLocale = typeof window !== "undefined" && window.calNewLocale ? window.calNewLocale : null;
const locale = session?.data?.user.locale ?? newLocale ?? fallbackLocale;
useEffect(() => {
try {
@@ -127,8 +129,8 @@ type CalcomThemeProps = Readonly<{
isBookingPage: boolean;
themeBasis: string | null;
nonce: string | undefined;
isThemeSupported: boolean;
children: React.ReactNode;
isThemeSupported?: boolean;
}>;
const CalcomThemeProvider = (props: CalcomThemeProps) => {
@@ -273,7 +275,7 @@ const AppProviders = (props: PageWrapperProps) => {
<CalcomThemeProvider
themeBasis={props.themeBasis}
nonce={props.nonce}
isThemeSupported={/* undefined gets treated as true */ props.isThemeSupported ?? true}
isThemeSupported={/* undefined gets treated as true */ props.isThemeSupported}
isBookingPage={props.isBookingPage || isBookingPage}>
<FeatureFlagsProvider>
<OrgBrandProvider>
@@ -127,6 +127,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
queryDuration,
eventData.length
),
durationConfig: eventData.metadata?.multipleDuration ?? [],
booking,
user: name,
slug,
@@ -1,7 +1,6 @@
import type { GetServerSidePropsContext } from "next";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { ENABLE_INFINITE_EVENT_TYPES_FOR_ORG } from "@calcom/lib/constants";
import { ssrInit } from "@server/lib/ssr";
@@ -18,9 +17,5 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
};
}
const isInfiniteScrollEnabled = session.user?.org?.slug
? ENABLE_INFINITE_EVENT_TYPES_FOR_ORG.includes(session.user.org.slug)
: false;
return { props: { trpcState: ssr.dehydrate(), isInfiniteScrollEnabled } };
return { props: { trpcState: ssr.dehydrate() } };
};
+17 -26
View File
@@ -39,7 +39,7 @@ interface LoginValues {
}
const GoogleIcon = () => (
<img className="text-subtle mr-2 h-4 w-4 dark:invert" src="/google-icon-colored.svg" alt="" />
<img className="text-subtle mr-2 h-4 w-4" src="/google-icon-colored.svg" alt="Continue with Google Icon" />
);
export type PageProps = inferSSRProps<typeof getServerSideProps>;
export default function Login({
@@ -95,6 +95,12 @@ PageProps & WithNonceProps<{}>) {
callbackUrl = safeCallbackUrl || "";
const LoginFooter = (
<Link href={`${WEBSITE_URL}/signup`} className="text-brand-500 font-medium">
{t("dont_have_an_account")}
</Link>
);
const TwoFactorFooter = (
<>
<Button
@@ -172,27 +178,6 @@ PageProps & WithNonceProps<{}>) {
? true
: isSAMLLoginEnabled && !isPending && data?.connectionExists;
const LoginFooter = (
<div className="flex flex-col items-center gap-1">
<div className="flex items-center gap-1">
<p className="text-subtle">{t("dont_have_an_account")}</p>
<Link href={`${WEBSITE_URL}/signup`} className="text-brand-500 font-medium">
{t("sign_up")}
</Link>
</div>
{displaySSOLogin && (
<div className="flex items-center gap-1">
<p className="text-subtle">Have a SAML account?</p>
<SAMLLogin
samlTenantID={samlTenantID}
samlProductID={samlProductID}
setErrorMessage={setErrorMessage}
/>
</div>
)}
</div>
);
return (
<div className="dark:bg-brand dark:text-brand-contrast text-emphasis min-h-screen [--cal-brand-emphasis:#101010] [--cal-brand-subtle:#9CA3AF] [--cal-brand-text:white] [--cal-brand:#111827] dark:[--cal-brand-emphasis:#e1e1e1] dark:[--cal-brand-text:black] dark:[--cal-brand:white]">
<AuthContainer
@@ -231,8 +216,16 @@ PageProps & WithNonceProps<{}>) {
{lastUsed === "google" && <LastUsed />}
</Button>
)}
{displaySSOLogin && (
<SAMLLogin
disabled={formState.isSubmitting}
samlTenantID={samlTenantID}
samlProductID={samlProductID}
setErrorMessage={setErrorMessage}
/>
)}
</div>
{isGoogleLoginEnabled && (
{(isGoogleLoginEnabled || displaySSOLogin) && (
<div className="my-8">
<div className="relative flex items-center">
<div className="border-subtle flex-grow border-t" />
@@ -288,9 +281,7 @@ PageProps & WithNonceProps<{}>) {
disabled={formState.isSubmitting}
className="w-full justify-center">
<span>{twoFactorRequired ? t("submit") : t("sign_in")}</span>
{lastUsed === "credentials" && (
<span className="absolute right-3 text-xs text-gray-600">{t("last_used")}</span>
)}
{lastUsed === "credentials" && <LastUsed className="text-gray-600" />}
</Button>
</div>
</form>
@@ -16,6 +16,7 @@ export default function Type({
entity,
duration,
hashedLink,
durationConfig,
}: PageProps) {
return (
<main className={getBookerWrapperClasses({ isEmbed: !!isEmbed })}>
@@ -35,6 +36,7 @@ export default function Type({
entity={entity}
duration={duration}
hashedLink={hashedLink}
durationConfig={durationConfig}
/>
</main>
);
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -1,6 +1,7 @@
"use client";
import Shell, { MobileNavigationMoreItems } from "@calcom/features/shell/Shell";
import Shell from "@calcom/features/shell/Shell";
import { MobileNavigationMoreItems } from "@calcom/features/shell/navigation/Navigation";
import { useLocale } from "@calcom/lib/hooks/useLocale";
export default function MorePage() {
@@ -304,6 +304,7 @@ const AppearanceView = ({
description={t("bookerlayout_user_settings_description")}
isDisabled={isBookerLayoutFormSubmitting || !isBookerLayoutFormDirty}
isLoading={mutation.isPending}
user={user}
/>
</Form>
@@ -42,7 +42,6 @@ export const CreateTeamEventType = () => {
SubmitButton={SubmitButton}
onSuccessMutation={onSuccessMutation}
onErrorMutation={onErrorMutation}
isInfiniteScrollEnabled={false}
/>
);
};
+95 -81
View File
@@ -180,7 +180,7 @@ export default function Signup({
emailVerificationEnabled,
}: SignupProps) {
const isOrgInviteByLink = orgSlug && !prepopulateFormValues?.username;
const displayMiddleDivider = isGoogleLoginEnabled; // Add the isOutlookLoginEnabled flag here when Outlook login is added
const [isSamlSignup, setIsSamlSignup] = useState(false);
const [premiumUsername, setPremiumUsername] = useState(false);
const [usernameTaken, setUsernameTaken] = useState(false);
const [isGoogleLoading, setIsGoogleLoading] = useState(false);
@@ -206,10 +206,10 @@ export default function Signup({
}
}, [redirectUrl]);
const [COOKIE_CONSENT, setCOOKIE_CONSENT] = useState(false);
const [userConsentToCookie, setUserConsentToCookie] = useState(false); // No need to be checked for user to proceed
function handleConsentChange(consent: boolean) {
setCOOKIE_CONSENT(!consent);
setUserConsentToCookie(!consent);
}
const loadingSubmitState = isSubmitSuccessful || isSubmitting;
@@ -297,7 +297,7 @@ export default function Signup({
return (
<>
{IS_CALCOM && (!IS_EUROPE || COOKIE_CONSENT) ? (
{IS_CALCOM && (!IS_EUROPE || userConsentToCookie) ? (
<>
{process.env.NEXT_PUBLIC_GTM_ID && (
<>
@@ -335,15 +335,17 @@ export default function Signup({
<div className="bg-muted 2xl:border-subtle grid w-full max-w-[1440px] grid-cols-1 grid-rows-1 overflow-hidden lg:grid-cols-2 2xl:rounded-[20px] 2xl:border 2xl:py-6">
<HeadSeo title={t("sign_up")} description={t("sign_up")} />
{/* Left side */}
<div className="ml-auto mr-auto mt-0 flex w-full max-w-xl flex-col px-4 pt-6 sm:px-16 md:px-20 lg:mt-12 2xl:px-28">
<div className="ml-auto mr-auto mt-0 flex w-full max-w-xl flex-col px-4 pt-6 sm:px-16 md:px-20 lg:mt-24 2xl:px-28">
{displayBackButton && (
<div className="flex w-fit lg:-mt-12">
<Button
color="minimal"
className="hover:bg-subtle todesktop:mt-10 [&[aria-current='page']]:bg-emphasis [&[aria-current='page']]:text-emphasis group-hover:text-default text-emphasis group mb-6 flex h-6 max-h-6 w-full flex-row items-center rounded-md px-3 py-2 text-sm font-medium leading-4 transition"
className="hover:bg-subtle todesktop:mt-10 mb-6 flex h-6 max-h-6 w-full items-center rounded-md px-3 py-2"
StartIcon="arrow-left"
data-testid="signup-back-button"
onClick={() => {
setDisplayEmailForm(false);
setIsSamlSignup(false);
}}>
{t("back")}
</Button>
@@ -412,12 +414,14 @@ export default function Signup({
/>
{/* Password */}
<PasswordField
data-testid="signup-passwordfield"
label={t("password")}
{...register("password")}
hintErrors={["caplow", "min", "num"]}
/>
{!isSamlSignup && (
<PasswordField
data-testid="signup-passwordfield"
label={t("password")}
{...register("password")}
hintErrors={["caplow", "min", "num"]}
/>
)}
{/* Cloudflare Turnstile Captcha */}
{CLOUDFLARE_SITE_ID ? (
<TurnstileCaptcha
@@ -429,7 +433,8 @@ export default function Signup({
) : null}
<CheckboxField
onChange={() => handleConsentChange(COOKIE_CONSENT)}
data-testid="signup-cookie-content-checkbox"
onChange={() => handleConsentChange(userConsentToCookie)}
description={t("cookie_consent_checkbox")}
/>
{errors.apiError && (
@@ -440,25 +445,65 @@ export default function Signup({
data-testid="signup-error-message"
/>
)}
<Button
type="submit"
className="my-2 w-full justify-center"
loading={loadingSubmitState}
disabled={
!!formMethods.formState.errors.username ||
!!formMethods.formState.errors.email ||
!formMethods.getValues("email") ||
!formMethods.getValues("password") ||
(CLOUDFLARE_SITE_ID &&
!process.env.NEXT_PUBLIC_IS_E2E &&
!formMethods.getValues("cfToken")) ||
isSubmitting ||
usernameTaken
}>
{premiumUsername && !usernameTaken
? `Create Account for ${getPremiumPlanPriceValue()}`
: t("create_account")}
</Button>
{isSamlSignup ? (
<Button
data-testid="saml-submit-button"
color="primary"
disabled={
!!formMethods.formState.errors.username ||
!!formMethods.formState.errors.email ||
!formMethods.getValues("email") ||
!formMethods.getValues("username") ||
premiumUsername ||
isSubmitting
}
onClick={() => {
const username = formMethods.getValues("username");
if (!username) {
// should not be reached but needed to bypass type errors
showToast("error", t("username_required"));
return;
}
localStorage.setItem("username", username);
const sp = new URLSearchParams();
// @NOTE: don't remove username query param as it's required right now for stripe payment page
sp.set("username", username);
sp.set("email", formMethods.getValues("email"));
router.push(
`${process.env.NEXT_PUBLIC_WEBAPP_URL}/auth/sso/saml` + `?${sp.toString()}`
);
}}
className={classNames(
"my-2 w-full justify-center rounded-md text-center",
formMethods.formState.errors.username && formMethods.formState.errors.email
? "opacity-50"
: ""
)}>
<Icon name="shield-check" className="mr-2 h-5 w-5" />
{t("create_account_with_saml")}
</Button>
) : (
<Button
type="submit"
data-testid="signup-submit-button"
className="my-2 w-full justify-center"
loading={loadingSubmitState}
disabled={
!!formMethods.formState.errors.username ||
!!formMethods.formState.errors.email ||
!formMethods.getValues("email") ||
!formMethods.getValues("password") ||
(CLOUDFLARE_SITE_ID &&
!process.env.NEXT_PUBLIC_IS_E2E &&
!formMethods.getValues("cfToken")) ||
isSubmitting ||
usernameTaken
}>
{premiumUsername && !usernameTaken
? `${t("create_account")} (${getPremiumPlanPriceValue()})`
: t("create_account")}
</Button>
)}
</Form>
</div>
)}
@@ -472,17 +517,15 @@ export default function Signup({
loading={isGoogleLoading}
CustomStartIcon={
<img
className={classNames(
"text-subtle mr-2 h-4 w-4 dark:invert",
premiumUsername && "opacity-50"
)}
className={classNames("text-subtle mr-2 h-4 w-4", premiumUsername && "opacity-50")}
src="/google-icon-colored.svg"
alt=""
alt="Continue with Google Icon"
/>
}
className={classNames("w-full justify-center rounded-md text-center")}
data-testid="continue-with-google-button"
onClick={async () => {
setIsSamlSignup(false);
setIsGoogleLoading(true);
const baseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL;
const GOOGLE_AUTH_URL = `${baseUrl}/auth/sso/google`;
@@ -501,12 +544,12 @@ export default function Signup({
router.push(url);
}}>
Continue with Google
{t("continue_with_google")}
</Button>
) : null}
</div>
{displayMiddleDivider && (
{isGoogleLoginEnabled && (
<div className="mt-6">
<div className="relative flex items-center">
<div className="border-subtle flex-grow border-t" />
@@ -519,60 +562,31 @@ export default function Signup({
)}
{/* Lower Row */}
<div className="mt-6 flex flex-col gap-2 md:flex-row">
<div className="mt-6 flex flex-col gap-2">
<Button
color="secondary"
disabled={isGoogleLoading}
className={classNames("w-full justify-center rounded-md text-center")}
onClick={() => {
setDisplayEmailForm(true);
setIsSamlSignup(false);
}}
data-testid="continue-with-email-button">
Continue with email
{t("continue_with_email")}
</Button>
{isSAMLLoginEnabled ? (
{isSAMLLoginEnabled && (
<Button
color="secondary"
disabled={
!!formMethods.formState.errors.username ||
!!formMethods.formState.errors.email ||
premiumUsername ||
isSubmitting ||
isGoogleLoading
}
className={classNames(
"w-full justify-center rounded-md text-center",
formMethods.formState.errors.username && formMethods.formState.errors.email
? "opacity-50"
: ""
)}
data-testid="continue-with-saml-button"
color="minimal"
disabled={isGoogleLoading}
className={classNames("w-full justify-center rounded-md text-center")}
onClick={() => {
if (!formMethods.getValues("username")) {
formMethods.trigger("username");
}
if (!formMethods.getValues("email")) {
formMethods.trigger("email");
return;
}
const username = formMethods.getValues("username");
if (!username) {
showToast("error", t("username_required"));
return;
}
localStorage.setItem("username", username);
const sp = new URLSearchParams();
// @NOTE: don't remove username query param as it's required right now for stripe payment page
sp.set("username", username);
sp.set("email", formMethods.getValues("email"));
router.push(
`${process.env.NEXT_PUBLIC_WEBAPP_URL}/auth/sso/saml` + `?${sp.toString()}`
);
setDisplayEmailForm(true);
setIsSamlSignup(true);
}}>
<Icon name="shield-check" className="mr-2 h-5 w-5" />
{t("saml_sso")}
{`${t("or").toLocaleLowerCase()} ${t("saml_sso")}`}
</Button>
) : null}
)}
</div>
</div>
)}
@@ -586,7 +600,7 @@ export default function Signup({
{t("sign_in")}
</Link>
</div>
<div className="text-subtle ">
<div className="text-subtle">
<Trans
i18nKey="signing_up_terms"
components={[
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "4.5.4",
"version": "4.5.9",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
@@ -136,7 +136,7 @@
"stripe": "^9.16.0",
"superjson": "1.9.1",
"tailwindcss-radix": "^2.6.0",
"turndown": "^7.1.1",
"turndown": "^7.1.3",
"uuid": "^8.3.2",
"zod": "^3.22.4"
},
+4
View File
@@ -3,6 +3,8 @@
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Meta } from "@calcom/ui";
import PageWrapper from "@components/PageWrapper";
import { getServerSideProps } from "@server/lib/setup/getServerSideProps";
import type { PageProps } from "~/auth/setup-view";
@@ -17,6 +19,8 @@ const Page = (props: PageProps) => {
</>
);
};
Page.PageWrapper = PageWrapper;
export default Page;
export { getServerSideProps };
@@ -1,26 +0,0 @@
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Meta } from "@calcom/ui";
import PageWrapper from "@components/PageWrapper";
import ProfileImpersonationViewWrapper from "~/settings/security/impersonation-view";
const Page = () => {
const { t } = useLocale();
return (
<>
<Meta
title={t("impersonation")}
description={t("impersonation_description")}
borderInShellHeader={true}
/>
<ProfileImpersonationViewWrapper />
</>
);
};
Page.getLayout = getLayout;
Page.PageWrapper = PageWrapper;
export default Page;
@@ -1,23 +0,0 @@
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Meta } from "@calcom/ui";
import PageWrapper from "@components/PageWrapper";
import PasswordViewWrapper from "~/settings/security/password-view";
const Page = () => {
const { t } = useLocale();
return (
<>
<Meta title={t("password")} description={t("password_description")} borderInShellHeader={true} />
<PasswordViewWrapper />
</>
);
};
Page.getLayout = getLayout;
Page.PageWrapper = PageWrapper;
export default Page;
-25
View File
@@ -1,25 +0,0 @@
import UserSSOView from "@calcom/features/ee/sso/page/user-sso-view";
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Meta } from "@calcom/ui";
import PageWrapper from "@components/PageWrapper";
const Page = () => {
const { t } = useLocale();
return (
<>
<Meta
title={t("sso_configuration")}
description={t("sso_configuration_description")}
borderInShellHeader={true}
/>
<UserSSOView />
</>
);
};
Page.PageWrapper = PageWrapper;
Page.getLayout = getLayout;
export default Page;
@@ -1,22 +0,0 @@
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Meta } from "@calcom/ui";
import PageWrapper from "@components/PageWrapper";
import TwoFactorAuthView from "~/settings/security/two-factor-auth-view";
const Page = () => {
const { t } = useLocale();
return (
<>
<Meta title={t("2fa")} description={t("set_up_two_factor_authentication")} borderInShellHeader={true} />
<TwoFactorAuthView />
</>
);
};
Page.getLayout = getLayout;
Page.PageWrapper = PageWrapper;
export default Page;
+2 -1
View File
@@ -1,8 +1,9 @@
import { expect } from "@playwright/test";
import { testBothFutureAndLegacyRoutes } from "playwright/lib/future-legacy-routes";
import { test } from "./lib/fixtures";
test.describe("Change Theme Test", () => {
testBothFutureAndLegacyRoutes.describe("Change Theme Test", () => {
test("change theme to dark", async ({ page, users }) => {
const pro = await users.create();
await pro.apiLogin();
+6 -2
View File
@@ -292,6 +292,12 @@ export const createUsersFixture = (
{ title: "Paid", slug: "paid", length: 30, price: 1000 },
{ title: "Opt in", slug: "opt-in", requiresConfirmation: true, length: 30 },
{ title: "Seated", slug: "seated", seatsPerTimeSlot: 2, length: 30 },
{
title: "Multiple duration",
slug: "multiple-duration",
length: 30,
metadata: { multipleDuration: [30, 60, 90] },
},
];
if (opts?.eventTypes) defaultEventTypes = defaultEventTypes.concat(opts.eventTypes);
@@ -573,8 +579,6 @@ export const createUsersFixture = (
await updateChildrenEventTypes({
eventTypeId: teamEvent.id,
currentUserId: user.id,
hashedLink: "",
connectedLink: null,
oldEventType: {
team: null,
},
+8 -4
View File
@@ -26,12 +26,12 @@ test.describe("hash my url", () => {
// We wait for the page to load
await page.locator(".primary-navigation >> text=Advanced").click();
// ignore if it is already checked, and click if unchecked
const hashedLinkCheck = await page.locator('[data-testid="hashedLinkCheck"]');
const hashedLinkCheck = await page.locator('[data-testid="multiplePrivateLinksCheck"]');
await hashedLinkCheck.click();
// we wait for the hashedLink setting to load
const $url = await page.locator('//*[@data-testid="generated-hash-url"]').inputValue();
const $url = await page.locator('//*[@data-testid="generated-hash-url-0"]').inputValue();
// click update
await page.locator('[data-testid="update-eventtype"]').press("Enter");
@@ -50,8 +50,12 @@ test.describe("hash my url", () => {
await page.locator("ul[data-testid=event-types] > li a").first().click();
// We wait for the page to load
await page.locator(".primary-navigation >> text=Advanced").click();
const hashedLinkCheck2 = await page.locator('[data-testid="multiplePrivateLinksCheck"]');
await hashedLinkCheck2.click();
// we wait for the hashedLink setting to load
const $newUrl = await page.locator('//*[@data-testid="generated-hash-url"]').inputValue();
const $newUrl = await page.locator('//*[@data-testid="generated-hash-url-0"]').inputValue();
expect($url !== $newUrl).toBeTruthy();
// Ensure that private URL is enabled after modifying the event type.
@@ -63,7 +67,7 @@ test.describe("hash my url", () => {
action: () => page.locator("[data-testid=update-eventtype]").click(),
});
await page.locator(".primary-navigation >> text=Advanced").click();
const $url2 = await page.locator('//*[@data-testid="generated-hash-url"]').inputValue();
const $url2 = await page.locator('//*[@data-testid="generated-hash-url-0"]').inputValue();
expect($url2.includes("somethingrandom")).toBeTruthy();
});
});
@@ -249,6 +249,7 @@ test.describe("Manage Booking Questions", () => {
},
select: {
id: true,
name: true,
},
});
@@ -256,7 +257,10 @@ test.describe("Manage Booking Questions", () => {
const webhookReceiver = await addWebhook(undefined, teamId);
await test.step("Go to First Team Event", async () => {
const $eventTypes = page.locator("[data-testid=event-types]").nth(1).locator("li a");
await page.getByTestId(`horizontal-tab-${team?.name}`).click();
await page.waitForLoadState("networkidle");
const $eventTypes = page.locator("[data-testid=event-types]").locator("li a");
const firstEventTypeElement = $eventTypes.first();
await firstEventTypeElement.click();
@@ -38,7 +38,7 @@ async function setupManagedEvent({
const memberUser = users.get().find((u) => u.name === teamMateName)!;
const { team } = await adminUser.getFirstTeamMembership();
const managedEvent = await adminUser.getFirstTeamEvent(team.id, SchedulingType.MANAGED);
return { adminUser, memberUser, managedEvent, teamMateName, teamEventTitle };
return { adminUser, memberUser, managedEvent, teamMateName, teamEventTitle, teamId: team.id };
}
/** Short hand to get elements by translation key */
@@ -129,7 +129,9 @@ test.describe("Managed Event Types", () => {
test("Provides discrete field lock/unlock state for admin", async ({ page, users }) => {
const { adminUser, teamEventTitle } = await setupManagedEvent({ users });
await adminUser.apiLogin();
await page.goto("/event-types");
const teamMembership = await adminUser.getFirstTeamMembership();
await page.goto(`/event-types?teamId=${teamMembership.team.id}`);
await page.getByTestId("event-types").locator(`a[title="${teamEventTitle}"]`).click();
await page.waitForURL("event-types/**");
@@ -169,7 +171,7 @@ test.describe("Managed Event Types", () => {
users,
browser,
}) => {
const { adminUser, memberUser, teamEventTitle } = await setupManagedEvent({
const { adminUser, memberUser, teamEventTitle, teamId } = await setupManagedEvent({
users,
unlockedFields: {
title: true,
@@ -185,7 +187,7 @@ test.describe("Managed Event Types", () => {
// We edit the managed event as original owner
const [adminContext, adminPage] = await adminUser.apiLoginOnNewBrowser(browser);
await adminPage.goto("/event-types");
await adminPage.goto(`/event-types?teamId=${teamId}`);
await adminPage.getByTestId("event-types").locator(`a[title="${teamEventTitle}"]`).click();
await adminPage.waitForURL("event-types/**");
await adminPage.locator('input[name="length"]').fill(`45`);
+1 -1
View File
@@ -49,7 +49,7 @@ test.describe("OIDC", () => {
// Login a user using the OIDC provider.
// The credentials are handled by the provider, so we don't need to create a user in the db.
await page.goto("/auth/login");
await page.click('[data-testid="saml"]');
await page.click('[data-testid="samlAndOidc"]');
// Redirected outide of the app, the user would be redirected to the OIDC provider.
await page.waitForURL(/https:\/\/[^/]+\/oauth2\/v1\/authorize\?.*/);
await page.getByRole("textbox", { name: "Username" }).fill(OIDC_USER_EMAIL);
@@ -47,19 +47,28 @@ test.describe("user1NotMemberOfOrg1 is part of team1MemberOfOrg1", () => {
await user1NotMemberOfOrg1.apiLogin();
await page.goto("/event-types");
const userEventLinksSelector = `[data-testid=slug-${user1NotMemberOfOrg1.username}] [data-testid="preview-link-button"]`;
await page.waitForSelector(userEventLinksSelector);
await page.waitForSelector(`[data-testid="event-types"] [data-testid="preview-link-button"]`, {
timeout: 5000,
});
const userEventLinksLocators = await page
.locator(`[data-testid="event-types"] [data-testid="preview-link-button"]`)
.all();
// Get all the event links
const userEventLinksLocators = await page.locator(userEventLinksSelector).all();
expect(userEventLinksLocators.length).toBeGreaterThan(0);
for (const userEventLinkLocator of userEventLinksLocators) {
const href = await userEventLinkLocator.getAttribute("href");
expect(href).toContain(WEBAPP_URL);
}
await page.goto(`/event-types?teamId=${team1MemberOfOrg1.id}`);
await page.waitForSelector(`[data-testid="event-types"] [data-testid="preview-link-button"]`, {
timeout: 5000,
});
const teamEventLinksLocators = await page
.locator(`[data-testid=slug-${team1MemberOfOrg1.slug}] [data-testid="preview-link-button"]`)
.locator(`[data-testid="event-types"] [data-testid="preview-link-button"]`)
.all();
expect(teamEventLinksLocators.length).toBeGreaterThan(0);
@@ -299,6 +299,8 @@ test.describe("Organization", () => {
await expect(page.locator("text=Create your account")).toBeVisible();
await expect(page.locator('[data-testid="continue-with-email-button"]')).toBeVisible();
await page.locator('[data-testid="continue-with-email-button"]').click();
await expect(page.locator('[data-testid="signup-submit-button"]')).toBeVisible();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
await page.locator('input[name="username"]').fill(existingUser.username!);
await page
@@ -147,9 +147,11 @@ test.describe("Unpublished Organization Redirection", () => {
},
data: {
hashedLink: {
create: {
link: generateHashedLink(eventType.id),
},
create: [
{
link: generateHashedLink(eventType.id),
},
],
},
},
include: {
@@ -158,7 +160,7 @@ test.describe("Unpublished Organization Redirection", () => {
});
await doOnOrgDomain({ page, orgSlug }, async () => {
await page.goto(`/d/${privateEvent.hashedLink?.link}/${privateEvent.slug}`);
await page.goto(`/d/${privateEvent.hashedLink[0]?.link}/${privateEvent.slug}`);
// Expect the empty screen, indicating the event is inaccessible.
await expect(page.getByTestId("empty-screen")).toBeVisible();
@@ -180,9 +182,11 @@ test.describe("Unpublished Organization Redirection", () => {
},
data: {
hashedLink: {
create: {
link: generateHashedLink(eventType.id),
},
create: [
{
link: generateHashedLink(eventType.id),
},
],
},
},
include: {
@@ -191,7 +195,7 @@ test.describe("Unpublished Organization Redirection", () => {
});
await doOnOrgDomain({ page, orgSlug }, async () => {
await page.goto(`/d/${privateEvent.hashedLink?.link}/${privateEvent.slug}?orgRedirection=true`);
await page.goto(`/d/${privateEvent.hashedLink[0]?.link}/${privateEvent.slug}?orgRedirection=true`);
// Verify that the event page is visible.
await expect(page.getByTestId("event-title")).toBeVisible();
+66 -2
View File
@@ -1,17 +1,81 @@
import { IS_SAML_LOGIN_ENABLED } from "../server/lib/constants";
import { expect } from "@playwright/test";
import { isSAMLLoginEnabled } from "@calcom/features/ee/sso/lib/saml";
import { IS_PREMIUM_USERNAME_ENABLED } from "@calcom/lib/constants";
import { login } from "./fixtures/users";
import { test } from "./lib/fixtures";
test.describe.configure({ mode: "parallel" });
test.describe("SAML tests", () => {
// eslint-disable-next-line playwright/no-skipped-test
test.skip(!isSAMLLoginEnabled, "Skipping due to SAML login being disabled");
test("test SAML configuration UI with pro@example.com", async ({ page }) => {
// TODO: Figure out a way to use the users from fixtures here, right now we cannot set
// the SAML_ADMINS env variables dynamically
await login({ username: "pro", email: "pro@example.com", password: "pro" }, page);
// eslint-disable-next-line playwright/no-skipped-test
test.skip(!IS_SAML_LOGIN_ENABLED, "It should only run if SAML is enabled");
// Try to go Security page
await page.goto("/settings/security/sso");
// It should redirect you to the event-types page
// await page.waitForSelector("[data-testid=saml_config]");
});
test.describe("SAML Signup Flow Test", async () => {
test.beforeEach(async ({ page }) => {
await page.goto("/signup");
await expect(page.locator("text=Create your account")).toBeVisible(); // this prevents flaky test
await page.getByTestId("continue-with-saml-button").click();
await page.waitForSelector('[data-testid="saml-submit-button"]');
});
test("Submit button should be disabled without username", async ({ page }) => {
await page.locator('input[name="email"]').fill("tester123@example.com");
const submitButton = page.getByTestId("saml-submit-button");
await expect(submitButton).toBeVisible();
await expect(submitButton).toBeDisabled();
});
test("Submit button should be disabled without email", async ({ page }) => {
await page.locator('input[name="username"]').fill("tester123");
const submitButton = page.getByTestId("saml-submit-button");
await expect(submitButton).toBeVisible();
await expect(submitButton).toBeDisabled();
});
test("Password input should not exist", async ({ page }) => {
await expect(page.locator('input[name="password"]')).toBeHidden();
});
test("Checkbox for cookie consent does not need to be checked", async ({ page }) => {
// Fill form
await page.locator('input[name="username"]').fill("tester123");
await page.locator('input[name="email"]').fill("tester123@example.com");
const submitButton = page.getByTestId("saml-submit-button");
const checkbox = page.getByTestId("signup-cookie-content-checkbox");
await checkbox.check();
await expect(submitButton).toBeEnabled();
// the cookie consent checkbox does not need to be checked for user to proceed
await checkbox.uncheck();
await expect(submitButton).toBeEnabled();
});
test("Submit button should be disabled with a premium username", async ({ page }) => {
// eslint-disable-next-line playwright/no-skipped-test
test.skip(!IS_PREMIUM_USERNAME_ENABLED, "Only run on Cal.com");
// Fill form
await page.locator('input[name="username"]').fill("pro");
await page.locator('input[name="email"]').fill("pro@example.com");
// Submit form
const submitButton = page.getByTestId("saml-submit-button");
await expect(submitButton).toBeDisabled();
});
});
});
+92 -37
View File
@@ -1,3 +1,4 @@
import type { Page } from "@playwright/test";
import { expect } from "@playwright/test";
import { randomBytes } from "crypto";
@@ -10,24 +11,38 @@ import { expectInvitationEmailToBeReceived } from "./team/expects";
test.describe.configure({ mode: "parallel" });
const preventFlakyTest = async (page: Page) => {
await expect(page.locator("text=Create your account")).toBeVisible();
};
test.describe("Signup Main Page Test", async () => {
test.beforeEach(async ({ features }) => {
features.reset();
test.beforeEach(async ({ page }) => {
await page.goto("/signup");
await preventFlakyTest(page);
});
test("Continue with email button must exist / work", async ({ page }) => {
await page.goto("/signup");
await expect(page.locator("text=Create your account")).toBeVisible();
await expect(page.locator('[data-testid="continue-with-email-button"]')).toBeVisible();
await page.locator('[data-testid="continue-with-email-button"]').click();
const button = page.getByTestId("continue-with-email-button");
await expect(button).toBeVisible();
await expect(button).toBeEnabled();
await button.click();
await expect(page.getByTestId("signup-back-button")).toBeVisible();
});
test("Continue with google button must exist / work", async ({ page }) => {
await page.goto("/signup");
await expect(page.locator('[data-testid="continue-with-google-button"]')).toBeVisible();
await page.locator('[data-testid="continue-with-google-button"]').click();
const button = page.getByTestId("continue-with-google-button");
await expect(button).toBeVisible();
await expect(button).toBeEnabled();
await button.click();
await page.waitForURL("/auth/sso/google");
});
test("Continue with SAML button must exist / work", async ({ page }) => {
const button = page.getByTestId("continue-with-saml-button");
await expect(button).toBeVisible();
await expect(button).toBeEnabled();
await button.click();
await expect(page.getByTestId("signup-back-button")).toBeVisible();
});
});
test.describe("Email Signup Flow Test", async () => {
@@ -45,9 +60,10 @@ test.describe("Email Signup Flow Test", async () => {
});
await page.goto("/signup");
await expect(page.locator("text=Create your account")).toBeVisible();
await expect(page.locator('[data-testid="continue-with-email-button"]')).toBeVisible();
await page.locator('[data-testid="continue-with-email-button"]').click();
await preventFlakyTest(page);
const continueWithEmailButton = page.getByTestId("continue-with-email-button");
await expect(continueWithEmailButton).toBeVisible();
await continueWithEmailButton.click();
const alertMessage = "Username or email is already taken";
@@ -57,7 +73,8 @@ test.describe("Email Signup Flow Test", async () => {
await page.locator('input[name="password"]').fill("Password99!");
// Submit form
await page.click('button[type="submit"]');
const submitButton = page.getByTestId("signup-submit-button");
await submitButton.click();
const alert = await page.waitForSelector('[data-testid="alert"]');
const alertMessageInner = await alert.innerText();
@@ -74,9 +91,10 @@ test.describe("Email Signup Flow Test", async () => {
});
await page.goto("/signup");
await expect(page.locator("text=Create your account")).toBeVisible();
await expect(page.locator('[data-testid="continue-with-email-button"]')).toBeVisible();
await page.locator('[data-testid="continue-with-email-button"]').click();
await preventFlakyTest(page);
const continueWithEmailButton = page.getByTestId("continue-with-email-button");
await expect(continueWithEmailButton).toBeVisible();
await continueWithEmailButton.click();
const alertMessage = "Username or email is already taken";
@@ -86,8 +104,9 @@ test.describe("Email Signup Flow Test", async () => {
await page.locator('input[name="password"]').fill("Password99!");
// Submit form
const submitButton = page.getByTestId("signup-submit-button");
await submitButton.click();
await page.click('button[type="submit"]');
const alert = await page.waitForSelector('[data-testid="alert"]');
const alertMessageInner = await alert.innerText();
@@ -107,16 +126,19 @@ test.describe("Email Signup Flow Test", async () => {
// Signup with premium username name
await page.goto("/signup");
await expect(page.locator("text=Create your account")).toBeVisible();
await expect(page.locator('[data-testid="continue-with-email-button"]')).toBeVisible();
await page.locator('[data-testid="continue-with-email-button"]').click();
await preventFlakyTest(page);
const continueWithEmailButton = page.getByTestId("continue-with-email-button");
await expect(continueWithEmailButton).toBeVisible();
await continueWithEmailButton.click();
// Fill form
await page.locator('input[name="username"]').fill("rock");
await page.locator('input[name="email"]').fill(userToCreate.email);
await page.locator('input[name="password"]').fill(userToCreate.password);
await page.click('button[type="submit"]');
// Submit form
const submitButton = page.getByTestId("signup-submit-button");
await submitButton.click();
// Check that stripe checkout is present
const expectedUrl = "https://checkout.stripe.com";
@@ -128,7 +150,7 @@ test.describe("Email Signup Flow Test", async () => {
expect(url).toContain(expectedUrl);
// TODO: complete the stripe checkout flow
});
test("Signup with valid (non premium) username", async ({ page, users, features }) => {
test("Signup with valid (non premium) username", async ({ page, users }) => {
const userToCreate = users.buildForSignup({
username: "rick-jones",
password: "Password99!",
@@ -137,16 +159,20 @@ test.describe("Email Signup Flow Test", async () => {
});
await page.goto("/signup");
await expect(page.locator("text=Create your account")).toBeVisible();
await expect(page.locator('[data-testid="continue-with-email-button"]')).toBeVisible();
await page.locator('[data-testid="continue-with-email-button"]').click();
await preventFlakyTest(page);
const continueWithEmailButton = page.getByTestId("continue-with-email-button");
await expect(continueWithEmailButton).toBeVisible();
await continueWithEmailButton.click();
// Fill form
await page.locator('input[name="username"]').fill(userToCreate.username);
await page.locator('input[name="email"]').fill(userToCreate.email);
await page.locator('input[name="password"]').fill(userToCreate.password);
await page.click('button[type="submit"]');
// Submit form
const submitButton = page.getByTestId("signup-submit-button");
await submitButton.click();
await page.waitForURL("/auth/verify-email**");
// Check that the URL matches the expected URL
@@ -158,9 +184,11 @@ test.describe("Email Signup Flow Test", async () => {
test("Signup fields prefilled with query params", async ({ page, users }) => {
const signupUrlWithParams = "/signup?username=rick-jones&email=rick-jones%40example.com";
await page.goto(signupUrlWithParams);
await expect(page.locator("text=Create your account")).toBeVisible();
await expect(page.locator('[data-testid="continue-with-email-button"]')).toBeVisible();
await page.locator('[data-testid="continue-with-email-button"]').click();
await preventFlakyTest(page);
const continueWithEmailButton = page.getByTestId("continue-with-email-button");
await expect(continueWithEmailButton).toBeVisible();
await continueWithEmailButton.click();
await expect(page.getByTestId("signup-submit-button")).toBeVisible();
// Fill form
const usernameInput = page.locator('input[name="username"]');
@@ -210,7 +238,8 @@ test.describe("Email Signup Flow Test", async () => {
const signupUrlWithToken = `/signup?token=${token}`;
await page.goto(signupUrlWithToken);
await expect(page.locator("text=Create your account")).toBeVisible();
await preventFlakyTest(page);
await expect(page.getByTestId("signup-submit-button")).toBeVisible();
const usernameField = page.locator('input[name="username"]');
const emailField = page.locator('input[name="email"]');
@@ -241,17 +270,18 @@ test.describe("Email Signup Flow Test", async () => {
});
await page.goto("/signup");
await expect(page.locator("text=Create your account")).toBeVisible();
await expect(page.locator('[data-testid="continue-with-email-button"]')).toBeVisible();
await page.locator('[data-testid="continue-with-email-button"]').click();
await preventFlakyTest(page);
const continueWithEmailButton = page.getByTestId("continue-with-email-button");
await expect(continueWithEmailButton).toBeVisible();
await continueWithEmailButton.click();
// Fill form
await page.locator('input[name="username"]').fill(userToCreate.username);
await page.locator('input[name="email"]').fill(userToCreate.email);
await page.locator('input[name="password"]').fill(userToCreate.password);
const submitButton = page.locator('button[type="submit"]');
await expect(submitButton).toBeEnabled();
// Submit form
const submitButton = page.getByTestId("signup-submit-button");
await submitButton.click();
await page.waitForURL((url) => url.pathname.includes("/auth/verify-email"));
@@ -314,8 +344,10 @@ test.describe("Email Signup Flow Test", async () => {
const url = new URL(newPage.url());
expect(url.pathname).toBe("/signup");
await expect(page.locator('[data-testid="continue-with-email-button"]')).toBeVisible();
await page.locator('[data-testid="continue-with-email-button"]').click();
const continueWithEmailButton = page.getByTestId("continue-with-email-button");
await expect(continueWithEmailButton).toBeVisible();
await continueWithEmailButton.click();
await expect(page.getByTestId("signup-submit-button")).toBeVisible();
// Check required fields
await newPage.locator("input[name=password]").fill(`P4ssw0rd!`);
await newPage.locator("button[type=submit]").click();
@@ -324,4 +356,27 @@ test.describe("Email Signup Flow Test", async () => {
await context.close();
});
});
test("Checkbox for cookie consent does not need to be checked", async ({ page, users }) => {
await page.goto("/signup");
await preventFlakyTest(page);
// Navigate to email form
await page.getByTestId("continue-with-email-button").click();
// Fill form
await page.locator('input[name="username"]').fill("pro");
await page.locator('input[name="email"]').fill("pro@example.com");
await page.locator('input[name="password"]').fill("Password99!");
const submitButton = page.getByTestId("signup-submit-button");
const checkbox = page.getByTestId("signup-cookie-content-checkbox");
await checkbox.check();
await expect(submitButton).toBeEnabled();
// the cookie consent checkbox does not need to be checked for user to proceed
await checkbox.uncheck();
await expect(submitButton).toBeEnabled();
});
});
+1 -1
View File
@@ -263,7 +263,7 @@ testBothFutureAndLegacyRoutes.describe("Teams - NonOrg", (routeVariant) => {
id: teamEventId,
} = await owner.getFirstTeamEvent(team.id);
await page.goto("/event-types");
await page.goto(`/event-types?teamId=${team.id}`);
await page.getByTestId(`event-type-options-${teamEventId}`).first().click();
await page.getByTestId("embed").click();
+1
View File
@@ -106,6 +106,7 @@ test.describe("BOOKING_CREATED", async () => {
location: "[redacted/dynamic]",
destinationCalendar: null,
hideCalendarNotes: false,
hideCalendarEventDetails: false,
requiresConfirmation: "[redacted/dynamic]",
eventTypeId: "[redacted/dynamic]",
seatsShowAttendees: true,
+21 -3
View File
@@ -876,12 +876,17 @@
"copy_private_link": "نسخ الرابط الخاص",
"copy_private_link_to_event": "نسخ الرابط الخاص للحدث",
"private_link_description": "إنشاء عنوان URL خاص لمشاركته دون الكشف عن اسم المستخدم الخاص بك في {{appName}}",
"multiple_private_links_title": "روابط خاصة",
"multiple_private_links_description": "إنشاء روابط خاصة دون الكشف عن اسم المستخدم، والتي سيتم تدميرها بمجرد استخدامها",
"add_a_multiple_private_link": "إضافة رابط جديد",
"multiple_private_link_copied": "تم نسخ الرابط الخاص",
"invitees_can_schedule": "يمكن للمدعوين الجدولة",
"date_range": "الفترة الزمنية",
"calendar_days": "أيام التقويم",
"business_days": "أيام العمل",
"set_address_place": "تعيين عنوان أو مكان",
"set_link_meeting": "تعيين رابطًا للاجتماع",
"managed_event_field_parent_control_disabled": "لا يمكن تبديله. يمكن فتحه فقط لأنواع الأحداث الفرعية",
"cal_invitee_phone_number_scheduling": "سيطلب {{appName}} من المدعو لديك إدخال رقم الهاتف قبل الجدولة.",
"cal_provide_google_meet_location": "سيوفر {{appName}} رابطًا لـ Google Meet.",
"cal_provide_zoom_meeting_url": "سيوفر {{appName}} رابطًا للاجتماع عبر Zoom.",
@@ -972,6 +977,8 @@
"signin_with_google": "تسجيل الدخول عبر Google",
"signin_with_saml": "تسجيل الدخول عبر SAML",
"signin_with_saml_oidc": "تسجيل الدخول عبر SAML/OIDC",
"continue_with_email": "الاستمرار باستخدام البريد الإلكتروني",
"continue_with_google": "الاستمرار باستخدام جوجل",
"last_used": "آخر استخدام",
"you_will_need_to_generate": "ستحتاج إلى إنشاء access token من أداة الجدولة القديمة لديك.",
"import": "استيراد",
@@ -1027,9 +1034,9 @@
"installed_other": "{{count}} مثبت",
"verify_wallet": "تأكيد المحفظة",
"create_events_on": "إنشاء أحداث في",
"enterprise_license": "هذه هي ميزة للمؤسسات",
"enterprise_license": "هذه ميزة تجارية",
"enterprise_license_locally": "يمكنك تجرِبة هذه المِيزة على جهازك وليس على البيئة الإنتاجية.",
"enterprise_license_sales": "للترقية إلى الإصدار المؤسسي، يرجى الاتصال بفريق المبيعات. إذا كان مفتاح الترخيص موجوداً بالفعل، يرجى الاتصال بـ support@cal.com للحصول على المساعدة.",
"enterprise_license_sales": "لشراء ترخيص تجاري، يرجى التواصل مع فريق المبيعات لدينا. إذا كان لديك مفتاح ترخيص بالفعل، يرجى الاتصال بـ support@cal.com للحصول على المساعدة.",
"missing_license": "الترخيص مفقود",
"next_steps": "الخطوات التالية",
"acquire_commercial_license": "الحصول على ترخيص تجاري",
@@ -2042,6 +2049,7 @@
"collect_no_show_fee": "تحصيل رسوم عدم الحضور",
"no_show_fee_charged": "تم تحصيل رسوم عدم حضور",
"insights": "Insights",
"routing_forms": "نماذج التوجيه",
"testing_workflow_info_message": "عند اختبار سير العمل هذا، يجب أن تدرك أنه لا يمكن جدولة رسائل البريد الإلكتروني والرسائل القصيرة إلا قبل ساعة واحدة على الأقل",
"insights_no_data_found_for_filter": "لم يتم العثور على بيانات لعامل التصفية المحدد أو التواريخ المحددة.",
"acknowledge_booking_no_show_fee": "أقر بأنه إذا لم أحضر هذا الحدث فسيتم سحب رسوم عدم حضور من بطاقتي تبلغ {{amount, currency}}.",
@@ -2166,7 +2174,7 @@
"select_time": "اختيار الوقت",
"select_date": "اختيار التاريخ",
"connecting_you_to_someone": "نقوم بربطك بشخص ما.",
"please_do_not_close_this_tab": "يرجى عدم إغلاق هذه النافذة",
"please_do_not_close_this_tab": "من فضلك لا تغلق أو تحدّث هذه الصفحة",
"see_all_available_times": "رؤية كل الأوقات المتاحة",
"org_team_names_example_1": "مثال، فريق التسويق",
"org_team_names_example_2": "مثال، فريق المبيعات",
@@ -2480,6 +2488,7 @@
"lock_org_users_eventtypes_description": "منع الأعضاء من إنشاء أنواع الأحداث الخاصة بهم.",
"add_to_event_type": "إضافة إلى نوع الحدث",
"create_account_password": "إنشاء كلمة مرور للحساب",
"create_account_with_saml": "إنشاء حساب باستخدام SAML",
"error_creating_account_password": "فشل في إنشاء كلمة مرور للحساب",
"cannot_create_account_password_cal_provider": "لا يمكن إنشاء كلمة مرور للحسابات الخاصة بـ cal",
"cannot_create_account_password_already_existing": "لا يمكن إنشاء كلمة مرور للحسابات التي تم إنشاؤها بالفعل",
@@ -2598,6 +2607,9 @@
"outlook_connect_atom_label": "اتصال بتقويم Outlook",
"outlook_connect_atom_already_connected_label": "تم الاتصال بتقويم Outlook",
"outlook_connect_atom_loading_label": "جارٍ التحقق من تقويم Outlook",
"stripe_connect_atom_label": "الاتصال بـ Stripe",
"stripe_connect_atom_already_connected_label": "تم الاتصال بـ Stripe",
"stripe_connect_atom_loading_label": "جارٍ التحقق من بيانات اعتماد Stripe",
"booking_question_response_variables": "متغيرات استجابة أسئلة الحجز",
"managed_by_teamAdmins": "يدار بواسطة {{teamAdmins}}",
"number_of_options": "{{count}} خيارات",
@@ -2609,5 +2621,11 @@
"limit_team_booking_frequency_description": "حدد عدد المرات التي يمكن فيها حجز الأعضاء عبر جميع أنواع فعاليات الفريق (الجماعية وround-robin)",
"booking_limits_updated_successfully": "تم تحديث حدود الحجز بنجاح",
"you_are_unauthorized_to_make_this_change_to_the_booking": "أنت غير مخول لإجراء هذا التغيير على الحجز",
"hide_calendar_event_details": "إخفاء تفاصيل الأحداث في التقويمات المشتركة",
"description_hide_calendar_event_details": "عند مشاركة التقويم، تكون الأحداث مرئية للقراء ولكن تفاصيلها تكون مخفية عن أولئك الذين ليس لديهم صلاحية الكتابة.",
"last_number_of_days": "آخر {{count}} يوم",
"month_to_date": "منذ بداية الشهر",
"year_to_date": "منذ بداية السنة",
"custom_range": "نطاق مخصص",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ أضف السلاسل الجديدة أعلاه هنا ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+21 -3
View File
@@ -876,12 +876,17 @@
"copy_private_link": "Şəxsi linki kopyala",
"copy_private_link_to_event": "Tədbirə şəxsi linki kopyala",
"private_link_description": "Öz {{appName}} istifadəçi adınızı açıqlamadan paylaşmaq üçün şəxsi URL yaradın",
"multiple_private_links_title": "Şəxsi Keçidlər",
"multiple_private_links_description": "İstifadəçi adını açıqlamadan şəxsi URL-lər yaradın, istifadə edildikdən sonra məhv ediləcək",
"add_a_multiple_private_link": "Yeni keçid əlavə edin",
"multiple_private_link_copied": "Şəxsi keçid kopyalandı",
"invitees_can_schedule": "Dəvətlilər cədvəl qura bilər",
"date_range": "Tarix Aralığı",
"calendar_days": "təqvim günləri",
"business_days": "iş günləri",
"set_address_place": "Ünvan və ya yer təyin edin",
"set_link_meeting": "Görüş üçün link təyin edin",
"managed_event_field_parent_control_disabled": "Dəyişdirilə bilməz. Yalnız uşaq tədbir növləri üçün açılabilir",
"cal_invitee_phone_number_scheduling": "{{appName}} dəvətlilərinizdən cədvəl qurmazdan əvvəl telefon nömrəsi daxil etmələrini istəyəcək.",
"cal_provide_google_meet_location": "{{appName}} Google Meet yeri təmin edəcək.",
"cal_provide_zoom_meeting_url": "{{appName}} Zoom görüş URL-i təmin edəcək.",
@@ -972,6 +977,8 @@
"signin_with_google": "Google ilə daxil olun",
"signin_with_saml": "SAML ilə daxil olun",
"signin_with_saml_oidc": "SAML/OIDC ilə daxil olun",
"continue_with_email": "Email ilə davam edin",
"continue_with_google": "Google ilə davam edin",
"last_used": "Son istifadə",
"you_will_need_to_generate": "Köhnə planlama alətinizdən bir giriş tokeni yaratmalısınız.",
"import": "İdxal et",
@@ -1027,9 +1034,9 @@
"installed_other": "{{count}} quraşdırılıb",
"verify_wallet": "Cüzdanı Təsdiqlə",
"create_events_on": "Tədbirləri yarat",
"enterprise_license": "Bu, müəssisə xüsusiyyətidir",
"enterprise_license": "Bu kommersiya xüsusiyyətidir",
"enterprise_license_locally": "Bu xüsusiyyəti yerli olaraq sınaqdan keçirə bilərsiniz, lakin istehsalda deyil.",
"enterprise_license_sales": "Müəssisə versiyasına yüksəltmək üçün satış komandamızla əlaqə saxlayın. Əgər lisenziya açarı artıq mövcuddursa, kömək üçün support@cal.com ilə əlaqə saxlayın.",
"enterprise_license_sales": "Kommersiya lisenziyası almaq üçün satış komandamızla əlaqə saxlayın. Əgər lisenziya açarı artıq mövcuddursa, kömək üçün support@cal.com ilə əlaqə saxlayın.",
"missing_license": "Lisenziya yoxdur",
"next_steps": "Növbəti Addımlar",
"acquire_commercial_license": "Kommersiya lisenziyası əldə edin",
@@ -2042,6 +2049,7 @@
"collect_no_show_fee": "Gəlməmə haqqını toplayın",
"no_show_fee_charged": "Gəlməmə haqqı tutuldu",
"insights": "Görüşlər",
"routing_forms": "Marşrutlaşdırma Formları",
"testing_workflow_info_message": "Bu iş axınını sınaqdan keçirərkən, e-poçtların və SMS-lərin ən azı 1 saat əvvəl planlaşdırıla biləcəyini unutmayın",
"insights_no_data_found_for_filter": "Seçilmiş filtr və ya tarixlər üçün məlumat tapılmadı.",
"acknowledge_booking_no_show_fee": "Bu tədbirə qatılmasam, kartıma {{amount, currency}} gəlməmə haqqı tətbiq olunacağını qəbul edirəm.",
@@ -2166,7 +2174,7 @@
"select_time": "Vaxtı Seçin",
"select_date": "Tarixi Seçin",
"connecting_you_to_someone": "Sizi kiməsə qoşuruq.",
"please_do_not_close_this_tab": "Zəhmət olmasa, bu nişanı bağlamayın",
"please_do_not_close_this_tab": "Zəhmət olmasa, bu səhifəni bağlamayın və ya yeniləməyin",
"see_all_available_times": "Bütün mövcud vaxtları görün",
"org_team_names_example_1": "məs. Marketinq Komandası",
"org_team_names_example_2": "məs. Satış Komandası",
@@ -2480,6 +2488,7 @@
"lock_org_users_eventtypes_description": "Üzvlərin öz tədbir növlərini yaratmasının qarşısını al.",
"add_to_event_type": "Tədbir növünə əlavə et",
"create_account_password": "Hesab parolu yarat",
"create_account_with_saml": "SAML ilə hesab yaradın",
"error_creating_account_password": "Hesab parolu yaratmaq alınmadı",
"cannot_create_account_password_cal_provider": "cal hesabları üçün hesab parolu yaradıla bilmir",
"cannot_create_account_password_already_existing": "Artıq yaradılmışlar üçün hesab parolu yaradıla bilmir",
@@ -2598,6 +2607,9 @@
"outlook_connect_atom_label": "Outlook Təqvimini Bağla",
"outlook_connect_atom_already_connected_label": "Bağlı Outlook Təqvimi",
"outlook_connect_atom_loading_label": "Outlook Təqvimi Yoxlanılır",
"stripe_connect_atom_label": "Stripe-ə qoşulun",
"stripe_connect_atom_already_connected_label": "Stripe qoşulub",
"stripe_connect_atom_loading_label": "Stripe məlumatları yoxlanılır",
"booking_question_response_variables": "Rezervasiya sual cavab dəyişənləri",
"managed_by_teamAdmins": "{{teamAdmins}} tərəfindən idarə olunur",
"number_of_options": "{{count}} seçim",
@@ -2609,5 +2621,11 @@
"limit_team_booking_frequency_description": "Komanda üzvlərinin bütün komanda tədbir növləri (kollektiv və round-robin) üzrə neçə dəfə rezervasiya edilə biləcəyini məhdudlaşdırın",
"booking_limits_updated_successfully": "Rezervasiya məhdudiyyətləri uğurla yeniləndi",
"you_are_unauthorized_to_make_this_change_to_the_booking": "Bu dəyişikliyi etmək üçün səlahiyyətiniz yoxdur",
"hide_calendar_event_details": "Təqvim hadisə detallarını gizlət",
"description_hide_calendar_event_details": "Təqvim paylaşıldıqda, hadisələr oxucular üçün görünür, lakin yazma icazəsi olmayanlar üçün detallar gizlədilir.",
"last_number_of_days": "son {{count}} gün",
"month_to_date": "bu ayın tarixinə qədər",
"year_to_date": "bu ilin tarixinə qədər",
"custom_range": "xüsusi aralıq",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Yeni sətirləri bura əlavə edin ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+21 -3
View File
@@ -876,12 +876,17 @@
"copy_private_link": "Zkopírovat soukromý odkaz",
"copy_private_link_to_event": "Zkopírovat soukromý odkaz na událost",
"private_link_description": "Vygenerujte soukromou adresu URL, kterou budete sdílet, aniž byste odhalili své uživatelské jméno ve službě {{appName}}",
"multiple_private_links_title": "Soukromé odkazy",
"multiple_private_links_description": "Vytvořte soukromé URL adresy bez odhalení uživatelského jména, které budou po použití zničeny",
"add_a_multiple_private_link": "Přidat nový odkaz",
"multiple_private_link_copied": "Soukromý odkaz zkopírován",
"invitees_can_schedule": "Pozvaní mohou plánovat",
"date_range": "Časové rozmezí",
"calendar_days": "kalendářních dní",
"business_days": "pracovních dní",
"set_address_place": "Zadejte adresu nebo místo",
"set_link_meeting": "Poslat odkaz na schůzku",
"managed_event_field_parent_control_disabled": "Nelze přepnout. Lze odemknout pouze pro podtypy událostí",
"cal_invitee_phone_number_scheduling": "{{appName}} vyzve pozvaného, aby před naplánováním zadal telefonní číslo.",
"cal_provide_google_meet_location": "{{appName}} poskytne místo Google Meet.",
"cal_provide_zoom_meeting_url": "{{appName}} poskytne URL Zoom meetingu.",
@@ -972,6 +977,8 @@
"signin_with_google": "Přihlásit se pomocí Googlu",
"signin_with_saml": "Přihlásit se pomocí SAML",
"signin_with_saml_oidc": "Přihlášení pomocí SAML/OIDC",
"continue_with_email": "Pokračovat s e-mailem",
"continue_with_google": "Pokračovat s Googlem",
"last_used": "Naposledy použito",
"you_will_need_to_generate": "Budete muset vygenerovat přístupový token ve vašem dřívějším plánovacím nástroji.",
"import": "Importovat",
@@ -1027,9 +1034,9 @@
"installed_other": "Nainstalováno: {{count}}",
"verify_wallet": "Ověřit peněženku",
"create_events_on": "Vytvořit události v:",
"enterprise_license": "Jedná se o firemní funkci",
"enterprise_license": "Toto je komerční funkce",
"enterprise_license_locally": "Tuto funkci můžete testovat lokálně, ale ne v produkčním prostředí.",
"enterprise_license_sales": "Pro upgrade na enterprise edici kontaktujte náš prodejní tým. Pokud již máte licenční klíč, kontaktujte prosím support@cal.com pro pomoc.",
"enterprise_license_sales": "Pro zakoupení komerční licence se obraťte na náš prodejní tým. Pokud již máte licenční klíč, kontaktujte prosím podporu na adrese support@cal.com pro pomoc.",
"missing_license": "Chybějící licence",
"next_steps": "Další kroky",
"acquire_commercial_license": "Získat komerční licenci",
@@ -2042,6 +2049,7 @@
"collect_no_show_fee": "Vybrat poplatek za nedostavení se",
"no_show_fee_charged": "Poplatek za nedostavení se stržen",
"insights": "Insights",
"routing_forms": "Směrovací formuláře",
"testing_workflow_info_message": "Při testování tohoto pracovního postupu mějte na paměti, že e-maily a SMS lze naplánovat pouze nejméně 1 hodinu předem.",
"insights_no_data_found_for_filter": "Pro vybraný filtr nebo vybraná data nebyla nalezena žádná data.",
"acknowledge_booking_no_show_fee": "Beru na vědomí, že pokud se této události nezúčastním, bude z mé karty stržen poplatek za nedostavení se ve výši {{amount, currency}}.",
@@ -2166,7 +2174,7 @@
"select_time": "Vyberte čas",
"select_date": "Vyberte datum",
"connecting_you_to_someone": "Spojujeme vás s někým.",
"please_do_not_close_this_tab": "Prosím, nezavírejte tuto kartu.",
"please_do_not_close_this_tab": "Prosím, nezavírejte ani neobnovujte tuto stránku",
"see_all_available_times": "Zobrazit všechny dostupné časy",
"org_team_names_example_1": "Např. marketingový tým",
"org_team_names_example_2": "Např. obchodní tým",
@@ -2480,6 +2488,7 @@
"lock_org_users_eventtypes_description": "Zabránit členům ve vytváření vlastních typů událostí.",
"add_to_event_type": "Přidat k typu události",
"create_account_password": "Vytvořit heslo k účtu",
"create_account_with_saml": "Vytvořit účet pomocí SAML",
"error_creating_account_password": "Nepodařilo se vytvořit heslo k účtu",
"cannot_create_account_password_cal_provider": "Nelze vytvořit heslo k účtu pro cal účty",
"cannot_create_account_password_already_existing": "Nelze vytvořit heslo k účtu pro již vytvořené účty",
@@ -2598,6 +2607,9 @@
"outlook_connect_atom_label": "Připojit Outlook Kalendář",
"outlook_connect_atom_already_connected_label": "Připojený Outlook Kalendář",
"outlook_connect_atom_loading_label": "Kontrola kalendáře Outlook",
"stripe_connect_atom_label": "Připojit ke Stripe",
"stripe_connect_atom_already_connected_label": "Stripe připojeno",
"stripe_connect_atom_loading_label": "Kontrola přihlašovacích údajů Stripe",
"booking_question_response_variables": "Proměnné odpovědí na otázky při rezervaci",
"managed_by_teamAdmins": "Spravováno {{teamAdmins}}",
"number_of_options": "{{count}} možností",
@@ -2609,5 +2621,11 @@
"limit_team_booking_frequency_description": "Omezte, kolikrát mohou být členové rezervováni napříč všemi typy týmových událostí (kolektivní a round-robin)",
"booking_limits_updated_successfully": "Limity rezervací byly úspěšně aktualizovány",
"you_are_unauthorized_to_make_this_change_to_the_booking": "Nemáte oprávnění provést tuto změnu v rezervaci",
"hide_calendar_event_details": "Skrýt podrobnosti událostí v kalendáři na sdílených kalendářích",
"description_hide_calendar_event_details": "Když je kalendář sdílen, události jsou viditelné pro čtenáře, ale jejich podrobnosti jsou skryty pro ty, kteří nemají přístup k úpravám.",
"last_number_of_days": "posledních {{count}} dní",
"month_to_date": "od začátku měsíce",
"year_to_date": "od začátku roku",
"custom_range": "vlastní rozsah",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Přidejte své nové řetězce nahoru ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+21 -3
View File
@@ -876,12 +876,17 @@
"copy_private_link": "Kopiér privat link",
"copy_private_link_to_event": "Kopiér privat link til begivenhed",
"private_link_description": "Generer en privat URL til deling uden at afsløre dit {{appName}} brugernavn",
"multiple_private_links_title": "Private links",
"multiple_private_links_description": "Generer private URL'er uden at afsløre brugernavnet, som vil blive slettet efter brug",
"add_a_multiple_private_link": "Tilføj nyt link",
"multiple_private_link_copied": "Privat link kopieret",
"invitees_can_schedule": "Inviterede kan planlægge",
"date_range": "Datointerval",
"calendar_days": "kalenderdage",
"business_days": "hverdage",
"set_address_place": "Angiv en adresse eller et sted",
"set_link_meeting": "Angiv et link til mødet",
"managed_event_field_parent_control_disabled": "Kan ikke aktiveres. Det kan kun låses op for underbegivenhedstyper",
"cal_invitee_phone_number_scheduling": "{{appName}} vil bede din inviterede om at indtaste et telefonnummer før planlægning.",
"cal_provide_google_meet_location": "{{appName}} vil angive en Google Meet placering.",
"cal_provide_zoom_meeting_url": "{{appName}} angiver en URL-adresse for Zoom-mødet.",
@@ -972,6 +977,8 @@
"signin_with_google": "Log ind med Google",
"signin_with_saml": "Log ind med SAML",
"signin_with_saml_oidc": "Log ind med SAML/OIDC",
"continue_with_email": "Fortsæt med e-mail",
"continue_with_google": "Fortsæt med Google",
"last_used": "Sidst brugt",
"you_will_need_to_generate": "Du skal generere et adgangs token fra dit gamle planlægningsværktøj.",
"import": "Importér",
@@ -1027,9 +1034,9 @@
"installed_other": "{{count}} installeret",
"verify_wallet": "Verificér Wallet",
"create_events_on": "Opret begivenheder på",
"enterprise_license": "Dette er en virksomhedsfunktion",
"enterprise_license": "Dette er en kommerciel funktion",
"enterprise_license_locally": "Du kan teste denne funktion lokalt, men ikke i produktion.",
"enterprise_license_sales": "For at opgradere til enterprise-udgaven, kontakt vores salgsteam. Hvis en licensnøgle allerede er på plads, kontakt support@cal.com for hjælp.",
"enterprise_license_sales": "For at købe en kommerciel licens, kontakt venligst vores salgsteam. Hvis du allerede har en licensnøgle, kontakt venligst support@cal.com for hjælp.",
"missing_license": "Manglende Licens",
"next_steps": "Næste Trin",
"acquire_commercial_license": "Erhverv en kommerciel licens",
@@ -2042,6 +2049,7 @@
"collect_no_show_fee": "Opkræv gebyr for udeblivelse",
"no_show_fee_charged": "Gebyr for udeblivelse opkrævet",
"insights": "Indsigter",
"routing_forms": "Ruteformularer",
"testing_workflow_info_message": "Når du tester denne arbejdsgang, skal du være opmærksom på, at e-mails og SMS'er kun kan planlægges mindst 1 time i forvejen",
"insights_no_data_found_for_filter": "Ingen data fundet for det valgte filter eller de valgte datoer.",
"acknowledge_booking_no_show_fee": "Jeg anerkender, at hvis jeg ikke deltager i denne begivenhed, vil et gebyr for udeblivelse på {{amount, currency}} blive opkrævet på mit kort.",
@@ -2166,7 +2174,7 @@
"select_time": "Vælg tid",
"select_date": "Vælg dato",
"connecting_you_to_someone": "Vi forbinder dig med en person.",
"please_do_not_close_this_tab": "Luk venligst ikke denne fane",
"please_do_not_close_this_tab": "Luk eller opdater venligst ikke denne side",
"see_all_available_times": "Se alle tilgængelige tidspunkter",
"org_team_names_example_1": "f.eks. Marketingteam",
"org_team_names_example_2": "f.eks. Salgsteam",
@@ -2480,6 +2488,7 @@
"lock_org_users_eventtypes_description": "Forhindre medlemmer i at oprette deres egne begivenhedstyper.",
"add_to_event_type": "Tilføj til begivenhedstype",
"create_account_password": "Opret kontoadgangskode",
"create_account_with_saml": "Opret konto med SAML",
"error_creating_account_password": "Kunne ikke oprette kontoadgangskode",
"cannot_create_account_password_cal_provider": "Kan ikke oprette kontoadgangskode for cal-konti",
"cannot_create_account_password_already_existing": "Kan ikke oprette kontoadgangskode for allerede oprettede konti",
@@ -2598,6 +2607,9 @@
"outlook_connect_atom_label": "Forbind Outlook Kalender",
"outlook_connect_atom_already_connected_label": "Forbundet Outlook Kalender",
"outlook_connect_atom_loading_label": "Kontrollerer Outlook Kalender",
"stripe_connect_atom_label": "Forbind til Stripe",
"stripe_connect_atom_already_connected_label": "Stripe forbundet",
"stripe_connect_atom_loading_label": "Kontrollerer Stripe legitimationsoplysninger",
"booking_question_response_variables": "Booking spørgsmål svar variabler",
"managed_by_teamAdmins": "Administreret af {{teamAdmins}}",
"number_of_options": "{{count}} muligheder",
@@ -2609,5 +2621,11 @@
"limit_team_booking_frequency_description": "Begræns, hvor mange gange medlemmer kan bookes på tværs af alle teambegivenhedstyper (kollektiv og round-robin)",
"booking_limits_updated_successfully": "Bookinggrænser opdateret med succes",
"you_are_unauthorized_to_make_this_change_to_the_booking": "Du har ikke tilladelse til at foretage denne ændring i bookingen",
"hide_calendar_event_details": "Skjul kalenderbegivenhedsdetaljer på delte kalendere",
"description_hide_calendar_event_details": "Når en kalender deles, er begivenheder synlige for læsere, men deres detaljer er skjult for dem uden skriveadgang.",
"last_number_of_days": "sidste {{count}} dage",
"month_to_date": "måned til dato",
"year_to_date": "år til dato",
"custom_range": "tilpasset interval",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Tilføj dine nye strenge ovenfor her ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+21 -3
View File
@@ -876,12 +876,17 @@
"copy_private_link": "Privaten Link kopieren",
"copy_private_link_to_event": "Privaten Link zum Ereignis kopieren",
"private_link_description": "Erzeugen Sie einen privaten Link, den Sie teilen können, ohne Ihren {{appName}}-Benutzername preiszugeben",
"multiple_private_links_title": "Private Links",
"multiple_private_links_description": "Generieren Sie private URLs ohne den Benutzernamen preiszugeben. Diese werden nach einmaliger Verwendung gelöscht",
"add_a_multiple_private_link": "Neuen Link hinzufügen",
"multiple_private_link_copied": "Privater Link kopiert",
"invitees_can_schedule": "Teilnehmende können folgendes buchen",
"date_range": "Datumszeitraum",
"calendar_days": "Kalendertage",
"business_days": "Werktage",
"set_address_place": "Adresse oder Ort festlegen",
"set_link_meeting": "Einen Link zum Termin setzen",
"managed_event_field_parent_control_disabled": "Kann nicht umgeschaltet werden. Kann nur für untergeordnete Ereignistypen freigeschaltet werden",
"cal_invitee_phone_number_scheduling": "{{appName}} wird Ihren Teilnehmer bitten, vor der Planung eine Telefonnummer einzugeben.",
"cal_provide_google_meet_location": "{{appName}} wird einen Google Meet Ort zur Verfügung stellen.",
"cal_provide_zoom_meeting_url": "{{appName}} wird eine Zoom Meeting URL zur Verfügung stellen.",
@@ -972,6 +977,8 @@
"signin_with_google": "Mit Google anmelden",
"signin_with_saml": "Anmelden mit SAML",
"signin_with_saml_oidc": "Anmelden mit SAML/OIDC",
"continue_with_email": "Mit E-Mail fortfahren",
"continue_with_google": "Mit Google fortfahren",
"last_used": "Zuletzt verwendet",
"you_will_need_to_generate": "Sie müssen ein Zugriffstoken in ihrem alten Kalender-Tool erzeugen.",
"import": "Importieren",
@@ -1027,9 +1034,9 @@
"installed_other": "{{count}} installiert",
"verify_wallet": "Wallet verifizieren",
"create_events_on": "Erstelle Termine in:",
"enterprise_license": "Das ist eine Enterprise-Funktion",
"enterprise_license": "Dies ist eine kommerzielle Funktion",
"enterprise_license_locally": "Sie können diese Funktion lokal testen, aber nicht in der Produktionsumgebung.",
"enterprise_license_sales": "Um zur Enterprise Edition upzugraden, wenden Sie sich bitte an unser Vertriebsteam. Wenn bereits ein Lizenzschlüssel vorhanden ist, wenden Sie sich für weitere Hilfe bitte an support@cal.com.",
"enterprise_license_sales": "Um eine kommerzielle Lizenz zu erwerben, wenden Sie sich bitte an unser Vertriebsteam. Falls bereits ein Lizenzschlüssel vorhanden ist, kontaktieren Sie bitte support@cal.com für Unterstützung.",
"missing_license": "Lizenz fehlt",
"next_steps": "Nächste Schritte",
"acquire_commercial_license": "Eine kommerzielle Lizenz erwerben",
@@ -2042,6 +2049,7 @@
"collect_no_show_fee": "Gebühr für das Nichterscheinen anfordern",
"no_show_fee_charged": "Gebühr für das Nichterscheinen berechnet",
"insights": "Insights",
"routing_forms": "Weiterleitungsformulare",
"testing_workflow_info_message": "Bitte beachten Sie beim Testen dieses Workflows, dass E-Mails und SMS spätestens 1 Stunde im Voraus geplant werden können",
"insights_no_data_found_for_filter": "Keine Daten für den ausgewählten Filter oder das ausgewählte Datum gefunden.",
"acknowledge_booking_no_show_fee": "Ich bestätige, dass meiner Karte eine Gebühr von {{amount, currency}} berechnet wird, sofern ich nicht an diesem Ereignis teilnehme.",
@@ -2166,7 +2174,7 @@
"select_time": "Zeit auswählen",
"select_date": "Datum auswählen",
"connecting_you_to_someone": "Wir verbinden Sie mit jemandem.",
"please_do_not_close_this_tab": "Diesen Tab bitte nicht schließen",
"please_do_not_close_this_tab": "Bitte schließen oder aktualisieren Sie diese Seite nicht",
"see_all_available_times": "Alle verfügbaren Zeiten ansehen",
"org_team_names_example_1": "z.B. Marketing-Team",
"org_team_names_example_2": "z.B. Vertriebsteam",
@@ -2480,6 +2488,7 @@
"lock_org_users_eventtypes_description": "Mitglieder daran hindern, ihre eigenen Termintypen zu erstellen.",
"add_to_event_type": "Zu Termintyp hinzufügen",
"create_account_password": "Kontopasswort erstellen",
"create_account_with_saml": "Konto mit SAML erstellen",
"error_creating_account_password": "Passwort konnte nicht erstellt werden",
"cannot_create_account_password_cal_provider": "Kann kein Kontopasswort für Cal-Konten erstellen",
"cannot_create_account_password_already_existing": "Es kann kein Passwort für bereits erstellte Konten erstellt werden",
@@ -2598,6 +2607,9 @@
"outlook_connect_atom_label": "Outlook Kalender verbinden",
"outlook_connect_atom_already_connected_label": "Outlook Kalender verbunden",
"outlook_connect_atom_loading_label": "Outlook Kalender wird überprüft",
"stripe_connect_atom_label": "Mit Stripe verbinden",
"stripe_connect_atom_already_connected_label": "Stripe verbunden",
"stripe_connect_atom_loading_label": "Überprüfe Stripe-Anmeldedaten",
"booking_question_response_variables": "Variablen für Buchungsfragen-Antworten",
"managed_by_teamAdmins": "Verwaltet von {{teamAdmins}}",
"number_of_options": "{{count}} Optionen",
@@ -2609,5 +2621,11 @@
"limit_team_booking_frequency_description": "Begrenzen Sie, wie oft Mitglieder über alle Team-Ereignistypen hinweg (kollektiv und round-robin) gebucht werden können",
"booking_limits_updated_successfully": "Buchungslimits erfolgreich aktualisiert",
"you_are_unauthorized_to_make_this_change_to_the_booking": "Sie sind nicht berechtigt, diese Änderung an der Buchung vorzunehmen",
"hide_calendar_event_details": "Kalenderereignisdetails in geteilten Kalendern verbergen",
"description_hide_calendar_event_details": "Wenn ein Kalender geteilt wird, sind Ereignisse für Leser sichtbar, aber ihre Details bleiben für Personen ohne Schreibzugriff verborgen.",
"last_number_of_days": "letzte {{count}} Tage",
"month_to_date": "aktueller Monat",
"year_to_date": "aktuelles Jahr",
"custom_range": "benutzerdefinierter Zeitraum",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Fügen Sie Ihre neuen Code-Zeilen über dieser hinzu ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+21 -3
View File
@@ -876,12 +876,17 @@
"copy_private_link": "Copy private link",
"copy_private_link_to_event": "Copy private link to event",
"private_link_description": "Generate a private URL to share without exposing your {{appName}} username",
"multiple_private_links_title": "Private Links",
"multiple_private_links_description": "Generate private URLs without exposing the username, which will be destroyed once used",
"add_a_multiple_private_link": "Add new link",
"multiple_private_link_copied": "Private link copied",
"invitees_can_schedule": "Invitees can schedule",
"date_range": "Date Range",
"calendar_days": "calendar days",
"business_days": "business days",
"set_address_place": "Set an address or place",
"set_link_meeting": "Set a link to the meeting",
"managed_event_field_parent_control_disabled": "Can't be toggled. It can only be unlocked for child event types",
"cal_invitee_phone_number_scheduling": "{{appName}} will ask your invitee to enter a phone number before scheduling.",
"cal_provide_google_meet_location": "{{appName}} will provide a Google Meet location.",
"cal_provide_zoom_meeting_url": "{{appName}} will provide a Zoom meeting URL.",
@@ -972,6 +977,8 @@
"signin_with_google": "Sign in with Google",
"signin_with_saml": "Sign in with SAML",
"signin_with_saml_oidc": "Sign in with SAML/OIDC",
"continue_with_email": "Continue with email",
"continue_with_google": "Continue with Google",
"last_used": "Last used",
"you_will_need_to_generate": "You will need to generate an access token from your old scheduling tool.",
"import": "Import",
@@ -1027,9 +1034,9 @@
"installed_other": "{{count}} installed",
"verify_wallet": "Verify Wallet",
"create_events_on": "Create events on",
"enterprise_license": "This is an enterprise feature",
"enterprise_license": "This is a commercial feature",
"enterprise_license_locally": "You can test this feature locally but not on production.",
"enterprise_license_sales": "To upgrade to the enterprise edition, please reach out to our sales team. If a license key is already in place, please contact support@cal.com for help.",
"enterprise_license_sales": "To purchase a commercial license, please reach out to our sales team. If a license key is already in place, please contact support@cal.com for help.",
"missing_license": "Missing License",
"next_steps": "Next Steps",
"acquire_commercial_license": "Acquire a commercial license",
@@ -2042,6 +2049,7 @@
"collect_no_show_fee": "Collect no-show fee",
"no_show_fee_charged": "No-show fee charged",
"insights": "Insights",
"routing_forms": "Routing Forms",
"testing_workflow_info_message": "When testing this workflow, be aware that Emails and SMS can only be scheduled at least 1 hour in advance",
"insights_no_data_found_for_filter": "No data found for the selected filter or selected dates.",
"acknowledge_booking_no_show_fee": "I acknowledge that if I do not attend this event that a {{amount, currency}} no show fee will be applied to my card.",
@@ -2166,7 +2174,7 @@
"select_time": "Select Time",
"select_date": "Select Date",
"connecting_you_to_someone": "We are connecting you to someone.",
"please_do_not_close_this_tab": "Please do not close this tab",
"please_do_not_close_this_tab": "Please do not close or refresh this page",
"see_all_available_times": "See all available times",
"org_team_names_example_1": "e.g. Marketing Team",
"org_team_names_example_2": "e.g. Sales Team",
@@ -2484,6 +2492,7 @@
"lock_org_users_eventtypes_description": "Prevent members from creating their own event types.",
"add_to_event_type": "Add to event type",
"create_account_password": "Create account password",
"create_account_with_saml": "Create Account with SAML",
"error_creating_account_password": "Failed to create account password",
"cannot_create_account_password_cal_provider": "Cannot create account password for cal accounts",
"cannot_create_account_password_already_existing": "Cannot create account password for already created ones",
@@ -2602,6 +2611,9 @@
"outlook_connect_atom_label": "Connect Outlook Calendar",
"outlook_connect_atom_already_connected_label": "Connected Outlook Calendar",
"outlook_connect_atom_loading_label": "Checking Outlook Calendar",
"stripe_connect_atom_label": "Connect to Stripe",
"stripe_connect_atom_already_connected_label": "Connected Stripe",
"stripe_connect_atom_loading_label": "Checking Stripe credentials",
"booking_question_response_variables": "Booking question response variables",
"managed_by_teamAdmins": "Managed by {{teamAdmins}}",
"number_of_options":"{{count}} options",
@@ -2613,5 +2625,11 @@
"limit_team_booking_frequency_description": "Limit how many times members can be booked across all team event types (collective and round-robin)",
"booking_limits_updated_successfully": "Booking limits updated successfully",
"you_are_unauthorized_to_make_this_change_to_the_booking": "You are unauthorized to make this change to the booking",
"hide_calendar_event_details": "Hide calendar event details on shared calendars",
"description_hide_calendar_event_details": "When a calendar is shared, events are visible to readers but their details are hidden from those without write access.",
"last_number_of_days": "last {{count}} days",
"month_to_date": "month to date",
"year_to_date": "year to date",
"custom_range": "custom range",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -876,12 +876,17 @@
"copy_private_link": "Copiar enlace privado",
"copy_private_link_to_event": "Copiar enlace privado al evento",
"private_link_description": "Genera una URL privada para compartir sin exponer tu nombre de usuario de {{appName}}",
"multiple_private_links_title": "Enlaces Privados",
"multiple_private_links_description": "Genera URLs privadas sin exponer el nombre de usuario, las cuales se destruirán una vez utilizadas",
"add_a_multiple_private_link": "Agregar nuevo enlace",
"multiple_private_link_copied": "Enlace privado copiado",
"invitees_can_schedule": "Los invitados pueden programar",
"date_range": "Rango de fechas",
"calendar_days": "días calendario",
"business_days": "días hábiles",
"set_address_place": "Establecer una dirección o lugar",
"set_link_meeting": "Establecer un enlace para la reunión",
"managed_event_field_parent_control_disabled": "No se puede alternar. Solo se puede desbloquear para tipos de eventos secundarios",
"cal_invitee_phone_number_scheduling": "{{appName}} pedirá a tu invitado que ingrese un número de teléfono antes de programar.",
"cal_provide_google_meet_location": "{{appName}} proporcionará una ubicación de Google Meet.",
"cal_provide_zoom_meeting_url": "{{appName}} proporcionará una URL de reunión de Zoom.",
@@ -972,6 +977,8 @@
"signin_with_google": "Iniciar sesión con Google",
"signin_with_saml": "Iniciar sesión con SAML",
"signin_with_saml_oidc": "Iniciar sesión con SAML/OIDC",
"continue_with_email": "Continuar con correo electrónico",
"continue_with_google": "Continuar con Google",
"last_used": "Último uso",
"you_will_need_to_generate": "Necesitarás generar un token de acceso desde tu antigua herramienta de programación.",
"import": "Importar",
@@ -1027,9 +1034,9 @@
"installed_other": "{{count}} instalado(s)",
"verify_wallet": "Verificar billetera",
"create_events_on": "Crear eventos en",
"enterprise_license": "Esta es una característica empresarial",
"enterprise_license": "Esta es una función comercial",
"enterprise_license_locally": "Puedes probar esta característica localmente pero no en producción.",
"enterprise_license_sales": "Para actualizar a la edición empresarial, por favor contacta a nuestro equipo de ventas. Si ya tienes una clave de licencia, contacta a support@cal.com para obtener ayuda.",
"enterprise_license_sales": "Para adquirir una licencia comercial, comuníquese con nuestro equipo de ventas. Si ya tiene una clave de licencia, por favor contacte a support@cal.com para obtener ayuda.",
"missing_license": "Licencia faltante",
"next_steps": "Próximos pasos",
"acquire_commercial_license": "Adquirir una licencia comercial",
@@ -2042,6 +2049,7 @@
"collect_no_show_fee": "Cobrar tarifa de no presentación",
"no_show_fee_charged": "Tarifa de no presentación cobrada",
"insights": "Perspectivas",
"routing_forms": "Formularios de Enrutamiento",
"testing_workflow_info_message": "Al probar este flujo de trabajo, tenga en cuenta que los correos electrónicos y SMS solo se pueden programar con al menos 1 hora de anticipación",
"insights_no_data_found_for_filter": "No se encontraron datos para el filtro o las fechas seleccionadas.",
"acknowledge_booking_no_show_fee": "Reconozco que si no asisto a este evento, se aplicará una tarifa de no presentación de {{amount, currency}} a mi tarjeta.",
@@ -2166,7 +2174,7 @@
"select_time": "Seleccionar hora",
"select_date": "Seleccionar fecha",
"connecting_you_to_someone": "Te estamos conectando con alguien.",
"please_do_not_close_this_tab": "Por favor, no cierres esta pestaña",
"please_do_not_close_this_tab": "Por favor, no cierres ni actualices esta página",
"see_all_available_times": "Ver todos los horarios disponibles",
"org_team_names_example_1": "p. ej. Equipo de Marketing",
"org_team_names_example_2": "p. ej. Equipo de Ventas",
@@ -2480,6 +2488,7 @@
"lock_org_users_eventtypes_description": "Evitar que los miembros creen sus propios tipos de eventos.",
"add_to_event_type": "Agregar al tipo de evento",
"create_account_password": "Crear contraseña de cuenta",
"create_account_with_saml": "Crear cuenta con SAML",
"error_creating_account_password": "Error al crear la contraseña de la cuenta",
"cannot_create_account_password_cal_provider": "No se puede crear la contraseña de la cuenta para cuentas cal",
"cannot_create_account_password_already_existing": "No se puede crear la contraseña de la cuenta para las ya creadas",
@@ -2598,6 +2607,9 @@
"outlook_connect_atom_label": "Conectar Calendario de Outlook",
"outlook_connect_atom_already_connected_label": "Calendario de Outlook conectado",
"outlook_connect_atom_loading_label": "Verificando Calendario de Outlook",
"stripe_connect_atom_label": "Conectar con Stripe",
"stripe_connect_atom_already_connected_label": "Stripe conectado",
"stripe_connect_atom_loading_label": "Verificando credenciales de Stripe",
"booking_question_response_variables": "Variables de respuesta de preguntas de reserva",
"managed_by_teamAdmins": "Gestionado por {{teamAdmins}}",
"number_of_options": "{{count}} opciones",
@@ -2609,5 +2621,11 @@
"limit_team_booking_frequency_description": "Limita cuántas veces se pueden reservar los miembros en todos los tipos de eventos del equipo (colectivos y round-robin)",
"booking_limits_updated_successfully": "Límites de reservas actualizados con éxito",
"you_are_unauthorized_to_make_this_change_to_the_booking": "No tienes autorización para realizar este cambio en la reserva",
"hide_calendar_event_details": "Ocultar detalles de eventos del calendario en calendarios compartidos",
"description_hide_calendar_event_details": "Cuando se comparte un calendario, los eventos son visibles para los lectores, pero sus detalles están ocultos para aquellos sin acceso de escritura.",
"last_number_of_days": "últimos {{count}} días",
"month_to_date": "mes hasta la fecha",
"year_to_date": "año hasta la fecha",
"custom_range": "rango personalizado",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Agrega tus nuevas cadenas arriba de esta línea ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+21 -3
View File
@@ -876,12 +876,17 @@
"copy_private_link": "Copiar enlace privado",
"copy_private_link_to_event": "Copiar enlace privado al evento",
"private_link_description": "Generar una URL privada para compartir sin exponer su nombre de usuario de {{appName}}",
"multiple_private_links_title": "Enlaces privados",
"multiple_private_links_description": "Genera URLs privadas sin exponer el nombre de usuario, que se destruirán una vez utilizadas",
"add_a_multiple_private_link": "Agregar nuevo enlace",
"multiple_private_link_copied": "Enlace privado copiado",
"invitees_can_schedule": "Los invitados pueden programar",
"date_range": "Rango de Fechas",
"calendar_days": "Días del Calendário",
"business_days": "Días Laborales",
"set_address_place": "Establecer una dirección o un lugar",
"set_link_meeting": "Establecer un enlace a la reunión",
"managed_event_field_parent_control_disabled": "No se puede alternar. Solo se puede desbloquear para tipos de eventos secundarios",
"cal_invitee_phone_number_scheduling": "{{appName}} le pedirá a su invitado que introduzca un número de teléfono antes de agendar.",
"cal_provide_google_meet_location": "{{appName}} proporcionará una URL de reunión de Google Meet.",
"cal_provide_zoom_meeting_url": "{{appName}} proporcionará una URL de reunión de Zoom.",
@@ -972,6 +977,8 @@
"signin_with_google": "Iniciar sesión con Google",
"signin_with_saml": "Iniciar sesión con SAML",
"signin_with_saml_oidc": "Inicie sesión con SAML/OIDC",
"continue_with_email": "Continuar con correo electrónico",
"continue_with_google": "Continuar con Google",
"last_used": "Último uso",
"you_will_need_to_generate": "Necesitarás generar un token de acceso desde tu antigua herramienta de programación.",
"import": "Importar",
@@ -1027,9 +1034,9 @@
"installed_other": "{{count}} instaladas",
"verify_wallet": "Verificar billetera",
"create_events_on": "Crear eventos en",
"enterprise_license": "Esta es una función empresarial",
"enterprise_license": "Esta es una función comercial",
"enterprise_license_locally": "Puedes probar esta función localmente pero no en producción.",
"enterprise_license_sales": "Para actualizar a la edición empresarial, por favor contacta a nuestro equipo de ventas. Si ya tienes una clave de licencia, por favor contacta a support@cal.com para obtener ayuda.",
"enterprise_license_sales": "Para adquirir una licencia comercial, por favor contacta a nuestro equipo de ventas. Si ya tienes una clave de licencia, contacta a support@cal.com para obtener ayuda.",
"missing_license": "Falta la licencia",
"next_steps": "Pasos siguientes",
"acquire_commercial_license": "Adquirir una licencia comercial",
@@ -2042,6 +2049,7 @@
"collect_no_show_fee": "Cobrar comisión por no presentarse",
"no_show_fee_charged": "Comisión por no presentarse cobrada",
"insights": "Insights",
"routing_forms": "Formularios de enrutamiento",
"testing_workflow_info_message": "Al probar este flujo de trabajo, tenga en cuenta que los correos electrónicos y los SMS solo se pueden programar con al menos 1 hora de anticipación",
"insights_no_data_found_for_filter": "No se encontraron datos para el filtro seleccionado o las fechas seleccionadas.",
"acknowledge_booking_no_show_fee": "Reconozco que si no asisto a este evento, se aplicará a mi tarjeta una tarifa de {{amount, currency}} por no presentarme.",
@@ -2166,7 +2174,7 @@
"select_time": "Seleccione la hora",
"select_date": "Seleccione la fecha",
"connecting_you_to_someone": "Te estamos conectando con alguien.",
"please_do_not_close_this_tab": "Por favor, no cierres esta pestaña",
"please_do_not_close_this_tab": "Por favor, no cierres ni actualices esta página",
"see_all_available_times": "Ver todas las horas disponibles",
"org_team_names_example_1": "ej. Equipo de marketing",
"org_team_names_example_2": "ej. Equipo de ventas",
@@ -2480,6 +2488,7 @@
"lock_org_users_eventtypes_description": "Evitar que los miembros creen sus propios tipos de eventos.",
"add_to_event_type": "Añadir al tipo de evento",
"create_account_password": "Crear contraseña de cuenta",
"create_account_with_saml": "Crear cuenta con SAML",
"error_creating_account_password": "Error al crear la contraseña de la cuenta",
"cannot_create_account_password_cal_provider": "No se puede crear la contraseña de la cuenta para cuentas de calendario",
"cannot_create_account_password_already_existing": "No se puede crear la contraseña de la cuenta para las ya existentes",
@@ -2598,6 +2607,9 @@
"outlook_connect_atom_label": "Conectar Calendario de Outlook",
"outlook_connect_atom_already_connected_label": "Calendario de Outlook conectado",
"outlook_connect_atom_loading_label": "Comprobando el calendario de Outlook",
"stripe_connect_atom_label": "Conectar con Stripe",
"stripe_connect_atom_already_connected_label": "Stripe conectado",
"stripe_connect_atom_loading_label": "Verificando credenciales de Stripe",
"booking_question_response_variables": "Variables de respuesta a preguntas de reserva",
"managed_by_teamAdmins": "Gestionado por {{teamAdmins}}",
"number_of_options": "{{count}} opciones",
@@ -2609,5 +2621,11 @@
"limit_team_booking_frequency_description": "Limita cuántas veces se pueden reservar los miembros en todos los tipos de eventos del equipo (colectivos y round-robin)",
"booking_limits_updated_successfully": "Límites de reservas actualizados con éxito",
"you_are_unauthorized_to_make_this_change_to_the_booking": "No tienes autorización para realizar este cambio en la reserva",
"hide_calendar_event_details": "Ocultar detalles de eventos del calendario en calendarios compartidos",
"description_hide_calendar_event_details": "Cuando se comparte un calendario, los eventos son visibles para los lectores, pero sus detalles están ocultos para aquellos sin acceso de escritura.",
"last_number_of_days": "últimos {{count}} días",
"month_to_date": "mes hasta la fecha",
"year_to_date": "año hasta la fecha",
"custom_range": "rango personalizado",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Agregue sus nuevas cadenas arriba ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+21 -3
View File
@@ -876,12 +876,17 @@
"copy_private_link": "Kopeeri privaatne link",
"copy_private_link_to_event": "Kopeeri sündmuse privaatne link",
"private_link_description": "Loo privaatne URL, mida jagate ilma oma {{appName}} kasutajanime paljastamata",
"multiple_private_links_title": "Privaatsed lingid",
"multiple_private_links_description": "Loo privaatsed URL-id ilma kasutajanime avalikustamata, mis hävitatakse pärast kasutamist",
"add_a_multiple_private_link": "Lisa uus link",
"multiple_private_link_copied": "Privaatne link kopeeritud",
"invitees_can_schedule": "Kutsutud saavad ajakava koostada",
"date_range": "Kuupäevavahemik",
"calendar_days": "kalendripäevad",
"business_days": "äripäevad",
"set_address_place": "Määra aadress või koht",
"set_link_meeting": "Määra koosoleku link",
"managed_event_field_parent_control_disabled": "Ei saa lülitada. Seda saab avada ainult alamürituste tüüpide jaoks",
"cal_invitee_phone_number_scheduling": "{{appName}} palub teie kutsutul enne ajastamist telefoninumbri sisestada.",
"cal_provide_google_meet_location": "{{appName}} pakub Google Meeti asukohta.",
"cal_provide_zoom_meeting_url": "{{appName}} annab Zoomi koosoleku URL-i.",
@@ -972,6 +977,8 @@
"signin_with_google": "Logi sisse Google'iga",
"signin_with_saml": "Logi sisse SAML-iga",
"signin_with_saml_oidc": "Logi sisse SAML-i/OIDC-ga",
"continue_with_email": "Jätka e-postiga",
"continue_with_google": "Jätka Google'iga",
"last_used": "Viimati kasutatud",
"you_will_need_to_generate": "Peate oma vanast ajastamistööriistast looma juurdepääsuluba.",
"import": "Import",
@@ -1027,9 +1034,9 @@
"installed_other": "{{count}} installitud",
"verify_wallet": "Kinnita rahakott",
"create_events_on": "Loo sündmusi",
"enterprise_license": "See on ettevõtte funktsioon",
"enterprise_license": "See on ärifunktsioon",
"enterprise_license_locally": "Saate seda funktsiooni kohapeal testida, kuid mitte tootmises.",
"enterprise_license_sales": "Ettevõtte versioonile üleminekuks võtke ühendust meie müügimeeskonnaga. Kui litsentsivõti on juba olemas, võtke abi saamiseks ühendust aadressil support@cal.com.",
"enterprise_license_sales": "Ärilise litsentsi ostmiseks võtke palun ühendust meie müügimeeskonnaga. Kui litsentsivõti on juba olemas, pöörduge abi saamiseks aadressil support@cal.com.",
"missing_license": "Puuduv litsents",
"next_steps": "Järgmised sammud",
"acquire_commercial_license": "Oma ärilitsents",
@@ -2042,6 +2049,7 @@
"collect_no_show_fee": "Koguge mitteilmumise tasu",
"no_show_fee_charged": "Tasutakse mitteilmumise tasu",
"insights": "Teadmisi",
"routing_forms": "Suunamise vormid",
"testing_workflow_info_message": "Selle töövoo testimisel pidage meeles, et e-kirju ja SMS-e saab ajastada ainult vähemalt 1 tund ette",
"insights_no_data_found_for_filter": "Valitud filtri või valitud kuupäevade kohta andmeid ei leitud.",
"acknowledge_booking_no_show_fee": "Ma tean, et kui ma sellel üritusel ei osale, rakendatakse minu kaardile {{summa, valuuta}} mitteilmumise tasu.",
@@ -2166,7 +2174,7 @@
"select_time": "Vali aeg",
"select_date": "Vali kuupäev",
"connecting_you_to_someone": "Me ühendame teid kellegagi.",
"please_do_not_close_this_tab": "Palun ärge sulgege seda vahekaarti",
"please_do_not_close_this_tab": "Palun ärge sulgege ega värskendage seda lehte",
"see_all_available_times": "Vaata kõiki saadaolevaid aegu",
"org_team_names_example_1": "nt turundusmeeskond",
"org_team_names_example_2": "nt müügimeeskond",
@@ -2480,6 +2488,7 @@
"lock_org_users_eventtypes_description": "Takistage liikmetel oma sündmuste tüüpide loomist.",
"add_to_event_type": "Lisa sündmuse tüübile",
"create_account_password": "Loo konto parool",
"create_account_with_saml": "Loo konto SAML-iga",
"error_creating_account_password": "Konto parooli loomine ebaõnnestus",
"cannot_create_account_password_cal_provider": "Ei saa luua konto parooli cal kontodele",
"cannot_create_account_password_already_existing": "Ei saa luua konto parooli juba loodud paroolidele",
@@ -2598,6 +2607,9 @@
"outlook_connect_atom_label": "Ühenda Outlooki kalender",
"outlook_connect_atom_already_connected_label": "Outlooki kalender on ühendatud",
"outlook_connect_atom_loading_label": "Outlooki kalendri kontrollimine",
"stripe_connect_atom_label": "Ühenda Stripe'iga",
"stripe_connect_atom_already_connected_label": "Stripe on ühendatud",
"stripe_connect_atom_loading_label": "Stripe'i andmete kontrollimine",
"booking_question_response_variables": "Broneerimisküsimuste vastuse muutujad",
"managed_by_teamAdmins": "Haldab {{teamAdmins}}",
"number_of_options": "{{count}} valikut",
@@ -2609,5 +2621,11 @@
"limit_team_booking_frequency_description": "Piirake, mitu korda saab liikmeid broneerida kõigi meeskonna sündmusetüüpide lõikes (ühised ja round-robin)",
"booking_limits_updated_successfully": "Broneerimispiirangud on edukalt uuendatud",
"you_are_unauthorized_to_make_this_change_to_the_booking": "Teil puuduvad õigused selle broneeringu muutmiseks",
"hide_calendar_event_details": "Peida kalendrisündmuste üksikasjad jagatud kalendrites",
"description_hide_calendar_event_details": "Kui kalender on jagatud, on sündmused lugejatele nähtavad, kuid nende üksikasjad on peidetud nende eest, kellel puudub kirjutamisõigus.",
"last_number_of_days": "viimased {{count}} päeva",
"month_to_date": "kuu algusest tänaseni",
"year_to_date": "aasta algusest tänaseni",
"custom_range": "kohandatud vahemik",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+21 -3
View File
@@ -876,12 +876,17 @@
"copy_private_link": "Copier le lien privé",
"copy_private_link_to_event": "Copier le lien privé de l'événement",
"private_link_description": "Générez un lien privé à partager sans révéler votre nom d'utilisateur {{appName}}.",
"multiple_private_links_title": "Liens privés",
"multiple_private_links_description": "Générez des URL privées sans exposer le nom d'utilisateur, qui seront détruites une fois utilisées",
"add_a_multiple_private_link": "Ajouter un nouveau lien",
"multiple_private_link_copied": "Lien privé copié",
"invitees_can_schedule": "Les invités peuvent réserver",
"date_range": "Plage de dates",
"calendar_days": "jours calendaires",
"business_days": "jours ouvrés",
"set_address_place": "Définir une adresse ou un lieu",
"set_link_meeting": "Définir un lien vers le rendez-vous",
"managed_event_field_parent_control_disabled": "Ne peut pas être modifié. Il ne peut être déverrouillé que pour les types d'événements enfants",
"cal_invitee_phone_number_scheduling": "{{appName}} demandera à votre invité d'entrer un numéro de téléphone avant de faire une réservation.",
"cal_provide_google_meet_location": "{{appName}} fournira un lien Google Meet.",
"cal_provide_zoom_meeting_url": "{{appName}} fournira un lien de rendez-vous Zoom.",
@@ -972,6 +977,8 @@
"signin_with_google": "Se connecter avec Google",
"signin_with_saml": "Se connecter avec SAML",
"signin_with_saml_oidc": "Se connecter avec SAML/OIDC",
"continue_with_email": "Continuer avec l'e-mail",
"continue_with_google": "Continuer avec Google",
"last_used": "Dernière utilisation",
"you_will_need_to_generate": "Vous devrez générer un token d'accès à partir de votre ancien outil de planification.",
"import": "Importer",
@@ -1027,9 +1034,9 @@
"installed_other": "{{count}} installation(s)",
"verify_wallet": "Vérifier le portefeuille",
"create_events_on": "Créer des événements dans :",
"enterprise_license": "Il s'agit d'une fonctionnalité d'entreprise",
"enterprise_license": "Ceci est une fonctionnalité commerciale",
"enterprise_license_locally": "Vous pouvez tester cette fonctionnalité en local, mais pas en production.",
"enterprise_license_sales": "Pour passer sur l'édition entreprise, veuillez contacter notre équipe commerciale. Si une clé de licence est déjà en place, veuillez contacter support@cal.com pour obtenir de l'aide.",
"enterprise_license_sales": "Pour acheter une licence commerciale, veuillez contacter notre équipe commerciale. Si une clé de licence est déjà en place, veuillez contacter support@cal.com pour obtenir de l'aide.",
"missing_license": "Licence manquante",
"next_steps": "Prochaines étapes",
"acquire_commercial_license": "Obtenir une licence commerciale",
@@ -2042,6 +2049,7 @@
"collect_no_show_fee": "Percevoir les frais d'absence",
"no_show_fee_charged": "Frais d'absence facturés",
"insights": "Statistiques",
"routing_forms": "Formulaires de routage",
"testing_workflow_info_message": "Lors du test de ce workflow, sachez que les e-mails et les SMS ne peuvent être programmés qu'au moins 1 heure à l'avance",
"insights_no_data_found_for_filter": "Aucune donnée trouvée pour le filtre ou les dates sélectionnés.",
"acknowledge_booking_no_show_fee": "Je reconnais que si je ne participe pas à cet événement, des frais d'absence de {{amount, currency}} seront appliqués à ma carte.",
@@ -2166,7 +2174,7 @@
"select_time": "Sélectionner un créneau",
"select_date": "Sélectionner une date",
"connecting_you_to_someone": "Nous vous mettons en relation avec quelqu'un.",
"please_do_not_close_this_tab": "Veuillez ne pas fermer cet onglet",
"please_do_not_close_this_tab": "Veuillez ne pas fermer ou actualiser cette page",
"see_all_available_times": "Voir tous les créneaux disponibles",
"org_team_names_example_1": "p. ex. Équipe marketing",
"org_team_names_example_2": "p. ex. Équipe de vente",
@@ -2480,6 +2488,7 @@
"lock_org_users_eventtypes_description": "Empêchez les membres de créer leurs propres types d'évènements.",
"add_to_event_type": "Ajouter un type d'évènement",
"create_account_password": "Créer le mot de passe du compte",
"create_account_with_saml": "Créer un compte avec SAML",
"error_creating_account_password": "Échec de la création du mot de passe du compte",
"cannot_create_account_password_cal_provider": "Impossible de créer un mot de passe pour les comptes cal",
"cannot_create_account_password_already_existing": "Impossible de créer un mot de passe pour un compte déjà créé",
@@ -2598,6 +2607,9 @@
"outlook_connect_atom_label": "Connecter le calendrier Outlook",
"outlook_connect_atom_already_connected_label": "Calendrier Outlook connecté",
"outlook_connect_atom_loading_label": "Vérification du calendrier Outlook",
"stripe_connect_atom_label": "Se connecter à Stripe",
"stripe_connect_atom_already_connected_label": "Stripe connecté",
"stripe_connect_atom_loading_label": "Vérification des identifiants Stripe",
"booking_question_response_variables": "Variables de réponse aux questions de réservation",
"managed_by_teamAdmins": "Géré par {{teamAdmins}}",
"number_of_options": "{{count}} options",
@@ -2609,5 +2621,11 @@
"limit_team_booking_frequency_description": "Limitez le nombre de fois où les membres peuvent être réservés pour tous les types d'événements d'équipe (collectifs et round robin)",
"booking_limits_updated_successfully": "Limites de réservation mises à jour avec succès",
"you_are_unauthorized_to_make_this_change_to_the_booking": "Vous n'êtes pas autorisé à apporter cette modification à la réservation",
"hide_calendar_event_details": "Masquer les détails des événements du calendrier sur les calendriers partagés",
"description_hide_calendar_event_details": "Lorsqu'un calendrier est partagé, les événements sont visibles pour les lecteurs, mais leurs détails sont cachés pour ceux qui n'ont pas d'accès en écriture.",
"last_number_of_days": "{{count}} derniers jours",
"month_to_date": "mois en cours",
"year_to_date": "année en cours",
"custom_range": "période personnalisée",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Ajoutez vos nouvelles chaînes ci-dessus ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+21 -3
View File
@@ -876,12 +876,17 @@
"copy_private_link": "העתקת קישור פרטי",
"copy_private_link_to_event": "העתקת קישור פרטי לאירוע",
"private_link_description": "ניתן ליצור כתובת קישור פרטית מבלי לחשוף את המשתמש שלך- {{appName}}",
"multiple_private_links_title": "קישורים פרטיים",
"multiple_private_links_description": "צור כתובות URL פרטיות מבלי לחשוף את שם המשתמש, אשר יושמדו לאחר השימוש",
"add_a_multiple_private_link": "הוסף קישור חדש",
"multiple_private_link_copied": "הקישור הפרטי הועתק",
"invitees_can_schedule": "המוזמנים יכולים לקבוע מועד",
"date_range": "טווח תאריכים",
"calendar_days": "ימים קלנדריים",
"business_days": "ימי עסקים",
"set_address_place": "הגדר כתובת או מקום",
"set_link_meeting": "הגדר קישור לפגישה",
"managed_event_field_parent_control_disabled": "לא ניתן להפעיל. ניתן לפתוח רק עבור סוגי אירועים משניים",
"cal_invitee_phone_number_scheduling": "{{appName}} יציג לנמען בקשה להזנת מספר טלפון לפני קביעת ביצוע התזמון.",
"cal_provide_google_meet_location": "{{appName}} יספק מיקום Google Meet.",
"cal_provide_zoom_meeting_url": "{{appName}} יספק קישור לפגישת Zoom.",
@@ -972,6 +977,8 @@
"signin_with_google": "כניסה עם Google",
"signin_with_saml": "כניסה עם SAML",
"signin_with_saml_oidc": "כניסה עם SAML/OIDC",
"continue_with_email": "המשך עם אימייל",
"continue_with_google": "המשך עם Google",
"last_used": "שימוש אחרון",
"you_will_need_to_generate": "תצטרך ליצור טוקן גישה מהכלי הישן שבו השתמשת לקביעת מועדים.",
"import": "ייבוא",
@@ -1027,9 +1034,9 @@
"installed_other": "{{count}} התקנות",
"verify_wallet": "אימות הארנק",
"create_events_on": "ליצור אירועים ב-",
"enterprise_license": "תכונה זו זמינה רק במינוי Enterprise",
"enterprise_license": "זוהי תכונה מסחרית",
"enterprise_license_locally": "אפשר לבדוק את היכולת הזאת מקומית אבל לא בסביבה המבצעית.",
"enterprise_license_sales": "כדי לשדרג למהדורה הארגונית, נא לגשת לצוות המכירות שלנו. אם כבר יש לך מפתח רישיון, נא ליצור קשר עם support@cal.com לקבלת עזרה.",
"enterprise_license_sales": "כדי לרכוש רישיון מסחרי, אנא פנו לצוות המכירות שלנו. אם מפתח רישיון כבר קיים, אנא צרו קשר עם support@cal.com לקבלת עזרה.",
"missing_license": "חסר רישיון",
"next_steps": "השלבים הבאים",
"acquire_commercial_license": "רכישת רישיון לשימוש מסחרי",
@@ -2042,6 +2049,7 @@
"collect_no_show_fee": "גביית דמי אי-הגעה",
"no_show_fee_charged": "דמי אי-הגעה חויבו",
"insights": "Insights",
"routing_forms": "טפסי ניתוב",
"testing_workflow_info_message": "במהלך בדיקת תהליך העבודה הזה, קח/י בחשבון שניתן לתזמן הודעות דוא\"ל ו-SMS לפחות שעה אחת מראש",
"insights_no_data_found_for_filter": "לא נמצאו נתונים עבור המסנן שנבחר או התאריכים שנבחרו.",
"acknowledge_booking_no_show_fee": "מובן לי שאם לא אשתתף באירוע הזה, דמי אי-הגעה בסך {{amount, currency}} ינוכו מהכרטיס שלי.",
@@ -2166,7 +2174,7 @@
"select_time": "בחירת שעה",
"select_date": "בחירת תאריך",
"connecting_you_to_someone": "אנו מחברים אותך למישהו או מישהי.",
"please_do_not_close_this_tab": "נא לא לסגור את הלשונית הזאת",
"please_do_not_close_this_tab": "אנא אל תסגור או תרענן דף זה",
"see_all_available_times": "לצפייה בכל המועדים הפנויים",
"org_team_names_example_1": "לדוגמה, מחלקת שיווק",
"org_team_names_example_2": "לדוגמה, מחלקת מכירות",
@@ -2480,6 +2488,7 @@
"lock_org_users_eventtypes_description": "מנע מחברים ליצור סוגי אירועים משלהם.",
"add_to_event_type": "הוספה לסוגי האירועים",
"create_account_password": "יצירת סיסמת חשבון",
"create_account_with_saml": "צור חשבון עם SAML",
"error_creating_account_password": "יצירת סיסמת חשבון נכשלה",
"cannot_create_account_password_cal_provider": "לא ניתן ליצור סיסמת חשבון לחשבונות cal",
"cannot_create_account_password_already_existing": "לא ניתן ליצור סיסמת חשבון לחשבונות שכבר נוצרה להם סיסמה",
@@ -2598,6 +2607,9 @@
"outlook_connect_atom_label": "חבר את לוח השנה של Outlook",
"outlook_connect_atom_already_connected_label": "לוח השנה של Outlook מחובר",
"outlook_connect_atom_loading_label": "בודק את לוח השנה של Outlook",
"stripe_connect_atom_label": "התחבר ל-Stripe",
"stripe_connect_atom_already_connected_label": "מחובר ל-Stripe",
"stripe_connect_atom_loading_label": "בודק אישורי Stripe",
"booking_question_response_variables": "משתני תגובה לשאלות הזמנה",
"managed_by_teamAdmins": "מנוהל על ידי {{teamAdmins}}",
"number_of_options": "{{count}} אפשרויות",
@@ -2609,5 +2621,11 @@
"limit_team_booking_frequency_description": "הגבל כמה פעמים ניתן להזמין חברי צוות בכל סוגי האירועים של הצוות (קולקטיבי ו-round robin)",
"booking_limits_updated_successfully": "מגבלות ההזמנה עודכנו בהצלחה",
"you_are_unauthorized_to_make_this_change_to_the_booking": "אין לך הרשאה לבצע שינוי זה בהזמנה",
"hide_calendar_event_details": "הסתר פרטי אירועי לוח שנה בלוחות שנה משותפים",
"description_hide_calendar_event_details": "כאשר לוח שנה משותף, האירועים נראים לקוראים אך פרטיהם מוסתרים מאלו שאין להם גישת כתיבה.",
"last_number_of_days": "ב{{count}} הימים האחרונים",
"month_to_date": "מתחילת החודש",
"year_to_date": "מתחילת השנה",
"custom_range": "טווח מותאם אישית",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+21 -3
View File
@@ -876,12 +876,17 @@
"copy_private_link": "Privát link másolása",
"copy_private_link_to_event": "Privát link másolása az eseményhez",
"private_link_description": "Hozzon létre egy privát URL-t, amelyet megoszthat anélkül, hogy felfedné {{appName}} felhasználónevét",
"multiple_private_links_title": "Privát linkek",
"multiple_private_links_description": "Privát URL-ek generálása a felhasználónév felfedése nélkül, amelyek használat után megsemmisülnek",
"add_a_multiple_private_link": "Új link hozzáadása",
"multiple_private_link_copied": "Privát link másolva",
"invitees_can_schedule": "A meghívottak ütemezhetnek",
"date_range": "Időintervallum",
"calendar_days": "naptári napok",
"business_days": "munkanapok",
"set_address_place": "Állítson be egy címet vagy helyet",
"set_link_meeting": "Állítson be egy hivatkozást az értekezlethez",
"managed_event_field_parent_control_disabled": "Nem kapcsolható át. Csak a gyerek eseménytípusok számára oldható fel",
"cal_invitee_phone_number_scheduling": "{{appName}} megkéri a meghívottat, hogy adjon meg egy telefonszámot az ütemezés előtt.",
"cal_provide_google_meet_location": "{{appName}} biztosít egy Google Meet-helyszínt.",
"cal_provide_zoom_meeting_url": "{{appName}} megadja a Zoom megbeszélés URL-jét.",
@@ -972,6 +977,8 @@
"signin_with_google": "Jelentkezzen be a Google-lal",
"signin_with_saml": "Jelentkezzen be SAML-lel",
"signin_with_saml_oidc": "Jelentkezzen be SAML/OIDC segítségével",
"continue_with_email": "Folytatás e-maillel",
"continue_with_google": "Folytatás Google-lal",
"last_used": "Utoljára használt",
"you_will_need_to_generate": "A régi ütemező eszközből létre kell hoznia egy hozzáférési tokent.",
"import": "Importálás",
@@ -1027,9 +1034,9 @@
"installed_other": "{{count}} telepítve",
"verify_wallet": "A Tárca ellenőrzése",
"create_events_on": "Események létrehozása",
"enterprise_license": "Ez egy vállalati szolgáltatás",
"enterprise_license": "Ez egy kereskedelmi funkció",
"enterprise_license_locally": "Ezt a funkciót helyben tesztelheti, éles környezetben azonban nem.",
"enterprise_license_sales": "A vállalati kiadásra való frissítéshez forduljon értékesítési csapatunkhoz. Ha a licenckulcs már rendelkezésre áll, segítségért forduljon a support@cal.com címhez.",
"enterprise_license_sales": "Kereskedelmi licenc vásárlásához kérjük, vegye fel a kapcsolatot értékesítési csapatunkkal. Ha már rendelkezik licenckulccsal, kérjük, lépjen kapcsolatba a support@cal.com címen segítségért.",
"missing_license": "Hiányzó licenc",
"next_steps": "Következő lépések",
"acquire_commercial_license": "Szerezzen kereskedelmi licenc-et",
@@ -2042,6 +2049,7 @@
"collect_no_show_fee": "Szedje be a meg nem jelenési díjat",
"no_show_fee_charged": "Meg nem jelenés díja levonva",
"insights": "Betekintés",
"routing_forms": "Irányító űrlapok",
"testing_workflow_info_message": "A munkafolyamat tesztelésekor ügyeljen arra, hogy az e-mailek és SMS-ek legalább 1 órával későbbre ütemezhetők",
"insights_no_data_found_for_filter": "Nem található adat a kiválasztott szűrőhöz vagy a kiválasztott dátumokhoz.",
"acknowledge_booking_no_show_fee": "Tudomásul veszem, hogy ha nem veszek részt ezen az eseményen, {{amount, currency}} meg nem jelenési díj kerül felszámításra.",
@@ -2166,7 +2174,7 @@
"select_time": "Válasszon időt",
"select_date": "Válasszon dátumot",
"connecting_you_to_someone": "Összekapcsolunk valakivel.",
"please_do_not_close_this_tab": "Kérjük, ne zárja be ezt a lapot",
"please_do_not_close_this_tab": "Kérjük, ne zárja be vagy frissítse ezt az oldalt",
"see_all_available_times": "Az összes elérhető időpont megtekintése",
"org_team_names_example_1": "például. Marketing Csapat",
"org_team_names_example_2": "például. Értékesítési csapat",
@@ -2480,6 +2488,7 @@
"lock_org_users_eventtypes_description": "Megakadályozza, hogy a tagok saját eseménytípusokat hozzanak létre.",
"add_to_event_type": "Hozzáadás az eseménytípushoz",
"create_account_password": "Fiók jelszavának létrehozása",
"create_account_with_saml": "Fiók létrehozása SAML-lel",
"error_creating_account_password": "Nem sikerült létrehozni a fiók jelszavát",
"cannot_create_account_password_cal_provider": "Nem lehet fiókjelszót létrehozni a cal fiókokhoz",
"cannot_create_account_password_already_existing": "A már létrehozott fiókokhoz nem lehet jelszót létrehozni",
@@ -2598,6 +2607,9 @@
"outlook_connect_atom_label": "Outlook Naptár csatlakoztatása",
"outlook_connect_atom_already_connected_label": "Outlook Naptár csatlakoztatva",
"outlook_connect_atom_loading_label": "Outlook Naptár ellenőrzése",
"stripe_connect_atom_label": "Csatlakozás a Stripe-hoz",
"stripe_connect_atom_already_connected_label": "Stripe csatlakoztatva",
"stripe_connect_atom_loading_label": "Stripe hitelesítő adatok ellenőrzése",
"booking_question_response_variables": "Foglalási kérdés válaszváltozók",
"managed_by_teamAdmins": "{{teamAdmins}} által kezelve",
"number_of_options": "{{count}} lehetőség",
@@ -2609,5 +2621,11 @@
"limit_team_booking_frequency_description": "Korlátozza, hogy a tagok hányszor foglalhatók az összes csapat eseménytípusra (kollektív és round-robin)",
"booking_limits_updated_successfully": "A foglalási korlátok sikeresen frissítve",
"you_are_unauthorized_to_make_this_change_to_the_booking": "Nincs jogosultsága a foglalás módosításához",
"hide_calendar_event_details": "Naptáresemény részleteinek elrejtése megosztott naptárakon",
"description_hide_calendar_event_details": "Amikor egy naptár meg van osztva, az események láthatóak az olvasók számára, de a részletek rejtve maradnak azok elől, akiknek nincs írási hozzáférésük.",
"last_number_of_days": "az elmúlt {{count}} nap",
"month_to_date": "hónap elejétől",
"year_to_date": "év elejétől",
"custom_range": "egyéni tartomány",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Adja hozzá az új karakterláncokat fent ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+21 -3
View File
@@ -876,12 +876,17 @@
"copy_private_link": "Copia link privato",
"copy_private_link_to_event": "Copia link privato nell'evento",
"private_link_description": "Genera un URL privato da condividere senza mostrare il tuo nome utente {{appName}}",
"multiple_private_links_title": "Link Privati",
"multiple_private_links_description": "Genera URL privati senza esporre il nome utente, che verranno distrutti una volta utilizzati",
"add_a_multiple_private_link": "Aggiungi nuovo link",
"multiple_private_link_copied": "Link privato copiato",
"invitees_can_schedule": "Gli inviti possono programmare",
"date_range": "Range di date",
"calendar_days": "giorni del calendario",
"business_days": "giorni lavorativi",
"set_address_place": "Imposta un indirizzo o un luogo",
"set_link_meeting": "Imposta un collegamento alla riunione",
"managed_event_field_parent_control_disabled": "Non può essere attivato. Può essere sbloccato solo per i tipi di eventi figli",
"cal_invitee_phone_number_scheduling": "{{appName}} chiederà al tuo invitato di inserire un numero di telefono prima della pianificazione.",
"cal_provide_google_meet_location": "{{appName}} fornirà un Google Meet location.",
"cal_provide_zoom_meeting_url": "{{appName}} fornirà un URL di riunione Zoom.",
@@ -972,6 +977,8 @@
"signin_with_google": "Accedi con Google",
"signin_with_saml": "Accedi con SAML",
"signin_with_saml_oidc": "Accedi con SAML/OIDC",
"continue_with_email": "Continua con l'email",
"continue_with_google": "Continua con Google",
"last_used": "Ultimo utilizzo",
"you_will_need_to_generate": "Dovrai generare un token di accesso con il tuo vecchio strumento di pianificazione.",
"import": "Importa",
@@ -1027,9 +1034,9 @@
"installed_other": "{{count}} installate",
"verify_wallet": "Verifica Wallet",
"create_events_on": "Crea eventi su:",
"enterprise_license": "Questa è una funzione Enterprise",
"enterprise_license": "Questa è una funzionalità commerciale",
"enterprise_license_locally": "Puoi testare questa funzione localmente ma non in ambiente di produzione.",
"enterprise_license_sales": "Per passare all'edizione aziendale, contatta i nostri agenti di vendita. Se esiste già una chiave di licenza, contatta support@cal.com per chiedere assistenza.",
"enterprise_license_sales": "Per acquistare una licenza commerciale, contatta il nostro team di vendita. Se hai già una chiave di licenza, contatta support@cal.com per assistenza.",
"missing_license": "Licenza mancante",
"next_steps": "Prossimi Passi",
"acquire_commercial_license": "Acquista una licenza commerciale",
@@ -2042,6 +2049,7 @@
"collect_no_show_fee": "Riscuoti penale per mancata presentazione",
"no_show_fee_charged": "Penale per mancata presentazione addebitata",
"insights": "Insights",
"routing_forms": "Moduli di Routing",
"testing_workflow_info_message": "Durante il test di questo flusso di lavoro, tieni presente che le e-mail e i messaggi SMS devono essere programmati con almeno 1 ora di anticipo",
"insights_no_data_found_for_filter": "Nessun dato trovato per il filtro selezionato o le date selezionate.",
"acknowledge_booking_no_show_fee": "Confermo che in caso di mia mancata partecipazione a questo evento, una penale per mancata presentazione di {{amount, currency}} verrà addebitata sulla mia carta.",
@@ -2166,7 +2174,7 @@
"select_time": "Seleziona l'ora",
"select_date": "Seleziona la data",
"connecting_you_to_someone": "Ti stiamo collegando con qualcuno.",
"please_do_not_close_this_tab": "Non chiudere questa scheda",
"please_do_not_close_this_tab": "Per favore, non chiudere o aggiornare questa pagina",
"see_all_available_times": "Vedi tutti gli orari disponibili",
"org_team_names_example_1": "ad es., team di marketing",
"org_team_names_example_2": "ad es., team vendite",
@@ -2480,6 +2488,7 @@
"lock_org_users_eventtypes_description": "Impedisci ai membri di creare propri tipi di evento.",
"add_to_event_type": "Aggiungi al tipo di evento",
"create_account_password": "Crea password per l'account",
"create_account_with_saml": "Crea un account con SAML",
"error_creating_account_password": "Non è stato possibile creare la password per l'account",
"cannot_create_account_password_cal_provider": "Impossibile creare password per gli account cal",
"cannot_create_account_password_already_existing": "Impossibile creare password per account già creati",
@@ -2598,6 +2607,9 @@
"outlook_connect_atom_label": "Connetti Calendario Outlook",
"outlook_connect_atom_already_connected_label": "Calendario Outlook connesso",
"outlook_connect_atom_loading_label": "Verifica Calendario Outlook",
"stripe_connect_atom_label": "Collegati a Stripe",
"stripe_connect_atom_already_connected_label": "Stripe collegato",
"stripe_connect_atom_loading_label": "Verifica delle credenziali Stripe in corso",
"booking_question_response_variables": "Variabili di risposta alle domande di prenotazione",
"managed_by_teamAdmins": "Gestito da {{teamAdmins}}",
"number_of_options": "{{count}} opzioni",
@@ -2609,5 +2621,11 @@
"limit_team_booking_frequency_description": "Limita quante volte i membri possono essere prenotati in tutti i tipi di eventi del team (collettivi e round-robin)",
"booking_limits_updated_successfully": "Limiti di prenotazione aggiornati con successo",
"you_are_unauthorized_to_make_this_change_to_the_booking": "Non sei autorizzato a effettuare questa modifica alla prenotazione",
"hide_calendar_event_details": "Nascondi i dettagli degli eventi del calendario nei calendari condivisi",
"description_hide_calendar_event_details": "Quando un calendario è condiviso, gli eventi sono visibili ai lettori ma i loro dettagli sono nascosti a chi non ha accesso in scrittura.",
"last_number_of_days": "ultimi {{count}} giorni",
"month_to_date": "mese corrente",
"year_to_date": "anno corrente",
"custom_range": "intervallo personalizzato",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Aggiungi le tue nuove stringhe qui sopra ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+19 -1
View File
@@ -876,12 +876,17 @@
"copy_private_link": "プライベートリンクをコピー",
"copy_private_link_to_event": "イベントのプライベートリンクをコピー",
"private_link_description": "{{appName}} ユーザー名を公開せずに共有するプライベートURLを生成",
"multiple_private_links_title": "プライベートリンク",
"multiple_private_links_description": "ユーザー名を公開せずにプライベートURLを生成し、一度使用されると破棄されます",
"add_a_multiple_private_link": "新しいリンクを追加",
"multiple_private_link_copied": "プライベートリンクがコピーされました",
"invitees_can_schedule": "招待されるメンバーはスケジュールできる",
"date_range": "日付の範囲",
"calendar_days": "暦日",
"business_days": "営業日",
"set_address_place": "住所または場所を設定",
"set_link_meeting": "ミーティングのリンクを設定",
"managed_event_field_parent_control_disabled": "切り替えできません。子イベントタイプのためにのみ解除できます",
"cal_invitee_phone_number_scheduling": "招待されるメンバーは予約する前に {{appName}} によって電話番号の入力を求められる。",
"cal_provide_google_meet_location": "{{appName}} は GoogleMeet の場所を提供する。",
"cal_provide_zoom_meeting_url": "{{appName}} は Zoom ミーティングの URLを提供する。",
@@ -972,6 +977,8 @@
"signin_with_google": "Google アカウントを使用してサインイン",
"signin_with_saml": "SAML を使用してサインイン",
"signin_with_saml_oidc": "SAML/OIDCでサインイン",
"continue_with_email": "メールで続行",
"continue_with_google": "Googleで続行",
"last_used": "最終使用",
"you_will_need_to_generate": "従来のスケジュールツールからアクセストークンを生成する必要があります。",
"import": "インポートする",
@@ -2042,6 +2049,7 @@
"collect_no_show_fee": "不参加費用を徴収する",
"no_show_fee_charged": "不参加費用が請求されました",
"insights": "Insights",
"routing_forms": "ルーティングフォーム",
"testing_workflow_info_message": "このワークフローをテストする場合、少なくとも 1 時間前でなければメールと SMS のスケジュール設定ができないことにご注意ください",
"insights_no_data_found_for_filter": "選択したフィルターまたは日付のデータが見つかりませんでした。",
"acknowledge_booking_no_show_fee": "このイベントに出席しなかった場合、不参加費用として {{amount, currency}} が私のカードに請求されることに同意します。",
@@ -2166,7 +2174,7 @@
"select_time": "時間帯を選ぶ",
"select_date": "日付を選ぶ",
"connecting_you_to_someone": "誰かに接続しています。",
"please_do_not_close_this_tab": "このタブを閉じないでください",
"please_do_not_close_this_tab": "このページを閉じたり、リフレッシュしたりしないでください",
"see_all_available_times": "出席できる時間帯をすべて表示",
"org_team_names_example_1": "例:マーケティングチーム",
"org_team_names_example_2": "例:営業チーム",
@@ -2480,6 +2488,7 @@
"lock_org_users_eventtypes_description": "メンバーが独自のイベントタイプを作成するのを防ぎます。",
"add_to_event_type": "イベントタイプに追加",
"create_account_password": "アカウントパスワードを作成",
"create_account_with_saml": "SAMLでアカウントを作成",
"error_creating_account_password": "アカウントパスワードの作成に失敗しました",
"cannot_create_account_password_cal_provider": "calアカウントのパスワードを作成できません",
"cannot_create_account_password_already_existing": "既に作成されたアカウントのパスワードは作成できません",
@@ -2598,6 +2607,9 @@
"outlook_connect_atom_label": "Outlookカレンダーを接続",
"outlook_connect_atom_already_connected_label": "Outlookカレンダーが接続されました",
"outlook_connect_atom_loading_label": "Outlookカレンダーを確認中",
"stripe_connect_atom_label": "Stripeに接続",
"stripe_connect_atom_already_connected_label": "Stripeに接続済み",
"stripe_connect_atom_loading_label": "Stripeの認証情報を確認中",
"booking_question_response_variables": "予約質問の回答変数",
"managed_by_teamAdmins": "{{teamAdmins}}によって管理されています",
"number_of_options": "{{count}}のオプション",
@@ -2609,5 +2621,11 @@
"limit_team_booking_frequency_description": "チームメンバーが全てのチームイベントタイプ(集団およびround-robin)で予約される回数を制限",
"booking_limits_updated_successfully": "予約制限が正常に更新されました",
"you_are_unauthorized_to_make_this_change_to_the_booking": "この予約の変更を行う権限がありません",
"hide_calendar_event_details": "共有カレンダーのイベント詳細を非表示にする",
"description_hide_calendar_event_details": "カレンダーが共有されると、イベントは閲覧者に表示されますが、書き込み権限のない人には詳細が非表示になります。",
"last_number_of_days": "過去{{count}}日間",
"month_to_date": "月初から今日まで",
"year_to_date": "年初から今日まで",
"custom_range": "カスタム範囲",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ この上に新しい文字列を追加してください ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+21 -3
View File
@@ -876,12 +876,17 @@
"copy_private_link": "ចម្លងតំណផ្ទាល់ខ្លួន",
"copy_private_link_to_event": "ចម្លងតំណផ្ទាល់ខ្លួនទៅព្រឹត្តិការណ៍",
"private_link_description": "បង្កើត URL ផ្ទាល់ខ្លួនដើម្បីចែករំលែកដោយមិនបង្ហាញឈ្មោះអ្នកប្រើ {{appName}} របស់អ្នក",
"multiple_private_links_title": "តំណភ្ជាប់ឯកជន",
"multiple_private_links_description": "បង្កើត URL ឯកជនដោយមិនបង្ហាញឈ្មោះអ្នកប្រើ ហើយវានឹងត្រូវបានបំផ្លាញបន្ទាប់ពីប្រើប្រាស់",
"add_a_multiple_private_link": "បន្ថែមតំណភ្ជាប់ថ្មី",
"multiple_private_link_copied": "បានចម្លងតំណភ្ជាប់ឯកជន",
"invitees_can_schedule": "ភ្ញៀវអាចកំណត់ពេលវេលា",
"date_range": "ជួរថ្ងៃទី",
"calendar_days": "ថ្ងៃប្រតិទិន",
"business_days": "ថ្ងៃធ្វើការ",
"set_address_place": "កំណត់អាសយដ្ឋាន ឬទីកន្លែង",
"set_link_meeting": "កំណត់តំណទៅកិច្ចប្រជុំ",
"managed_event_field_parent_control_disabled": "មិនអាចបើកបិទបានទេ។ វាអាចត្រូវបានដោះសោរតែសម្រាប់ប្រភេទព្រឹត្តិការណ៍កូនប៉ុណ្ណោះ",
"cal_invitee_phone_number_scheduling": "{{appName}} នឹងស្នើឱ្យភ្ញៀវរបស់អ្នកបញ្ចូលលេខទូរស័ព្ទមុនពេលកំណត់ពេលវេលា។",
"cal_provide_google_meet_location": "{{appName}} នឹងផ្តល់ទីតាំង Google Meet។",
"cal_provide_zoom_meeting_url": "{{appName}} នឹងផ្តល់ URL កិច្ចប្រជុំ Zoom។",
@@ -972,6 +977,8 @@
"signin_with_google": "ចូលប្រើដោយ Google",
"signin_with_saml": "ចូលប្រើដោយ SAML",
"signin_with_saml_oidc": "ចូលប្រើដោយ SAML/OIDC",
"continue_with_email": "បន្តដោយប្រើអ៊ីមែល",
"continue_with_google": "បន្តដោយប្រើ Google",
"last_used": "ប្រើចុងក្រោយ",
"you_will_need_to_generate": "អ្នកត្រូវតែបង្កើតសញ្ញាសម្ងាត់ចូលពីឧបករណ៍កំណត់ពេលវេលាចាស់របស់អ្នក។",
"import": "នាំចូល",
@@ -1027,9 +1034,9 @@
"installed_other": "{{count}} បានដំឡើង",
"verify_wallet": "ផ្ទៀងផ្ទាត់កាបូប",
"create_events_on": "បង្កើតព្រឹត្តិការណ៍នៅលើ",
"enterprise_license": "នេះគឺជម្រើសសម្រាប់សហគ្រាស",
"enterprise_license": "នេះគឺជាមុខងារជំនួញ",
"enterprise_license_locally": "Bạn có thể thử tính năng này tại địa phương nhưng không phải trên môi trường sản xuất.",
"enterprise_license_sales": "Để nâng cấp lên phiên bản doanh nghiệp, vui lòng liên hệ với đội ngũ bán hàng của chúng tôi. Nếu khóa giấy phép đã có sẵn, vui lòng liên hệ support@cal.com để được hỗ trợ.",
"enterprise_license_sales": "ដើម្បីទិញអាជ្ញាប័ណ្ណជំនួញ សូមទាក់ទងក្រុមលក់របស់យើង។ ប្រសិនបើមានកូនសោអាជ្ញាប័ណ្ណរួចហើយ សូមទាក់ទង support@cal.com សម្រាប់ជំនួយ។",
"missing_license": "បាត់បង់អាជ្ញាប័ណ្ណ",
"next_steps": "ជំហានបន្ទាប់",
"acquire_commercial_license": "ទទួលបានអាជ្ញាប័ណ្ណពាណិជ្ជកម្ម",
@@ -2042,6 +2049,7 @@
"collect_no_show_fee": "ប្រមូលថ្លៃសេវាកម្មមិនមកចូលរួម",
"no_show_fee_charged": "បានគិតថ្លៃសេវាកម្មមិនមកចូលរួម",
"insights": "ព័ត៌មានជ្រៅ",
"routing_forms": "បែបបទបញ្ជូន",
"testing_workflow_info_message": "នៅពេលសាកល្បងដំណើរការនេះ សូមចំណាំថា អ៊ីមែល និងសារ SMS អាចត្រូវបានកំណត់ពេលយ៉ាងហោចណាស់ 1 ម៉ោងមុន",
"insights_no_data_found_for_filter": "មិនមានទិន្នន័យសម្រាប់តម្រង ឬកាលបរិច្ឆេទដែលបានជ្រើស។",
"acknowledge_booking_no_show_fee": "ខ្ញុំយល់ព្រមថា ប្រសិនបើខ្ញុំមិនចូលរួមព្រឹត្តិការណ៍នេះ នោះថ្លៃសេវាកម្មមិនមកចូលរួមចំនួន {{amount, currency}} នឹងត្រូវបានគិតលើកាតរបស់ខ្ញុំ។",
@@ -2166,7 +2174,7 @@
"select_time": "ជ្រើសរើសពេលវេលា",
"select_date": "ជ្រើសរើសកាលបរិច្ឆេទ",
"connecting_you_to_someone": "យើងកំពុងភ្ជាប់អ្នកទៅនរណាម្នាក់។",
"please_do_not_close_this_tab": "សូមកុំបិទផ្ទាំងនេះ",
"please_do_not_close_this_tab": "សូមកុំបិទ ឬធ្វើការបន្ទាន់សម័យទំព័រនេះ",
"see_all_available_times": "មើលពេលវេលាទាំងអស់ដែលអាចប្រើបាន",
"org_team_names_example_1": "ឧ. ក្រុមទីផ្សារ",
"org_team_names_example_2": "ឧ. ក្រុមលក់",
@@ -2480,6 +2488,7 @@
"lock_org_users_eventtypes_description": "បង្រ្កាបសមាជិកមិនឱ្យបង្កើតប្រភេទព្រឹត្តិការណ៍ផ្ទាល់ខ្លួន។",
"add_to_event_type": "បន្ថែមទៅប្រភេទព្រឹត្តិការណ៍",
"create_account_password": "បង្កើតពាក្យសម្ងាត់គណនី",
"create_account_with_saml": "បង្កើតគណនីដោយប្រើ SAML",
"error_creating_account_password": "បរាជ័យក្នុងការបង្កើតពាក្យសម្ងាត់គណនី",
"cannot_create_account_password_cal_provider": "មិនអាចបង្កើតពាក្យសម្ងាត់គណនីសម្រាប់គណនី cal",
"cannot_create_account_password_already_existing": "មិនអាចបង្កើតពាក្យសម្ងាត់គណនីសម្រាប់គណនីដែលបានបង្កើតរួចហើយ",
@@ -2598,6 +2607,9 @@
"outlook_connect_atom_label": "ភ្ជាប់ប្រតិទិន Outlook",
"outlook_connect_atom_already_connected_label": "បានភ្ជាប់ប្រតិទិន Outlook",
"outlook_connect_atom_loading_label": "កំពុងពិនិត្យប្រតិទិន Outlook",
"stripe_connect_atom_label": "ភ្ជាប់ទៅកាន់ Stripe",
"stripe_connect_atom_already_connected_label": "បានភ្ជាប់ទៅកាន់ Stripe",
"stripe_connect_atom_loading_label": "កំពុងពិនិត្យសមាជិកភាព Stripe",
"booking_question_response_variables": "អថេរឆ្លើយសំណួរការកក់",
"managed_by_teamAdmins": "គ្រប់គ្រងដោយ {{teamAdmins}}",
"number_of_options": "{{count}} ជម្រើស",
@@ -2609,5 +2621,11 @@
"limit_team_booking_frequency_description": "ដែនកំណត់ចំនួនដងដែលសមាជិកអាចត្រូវបានកក់ក្នុងប្រភេទព្រឹត្តិការណ៍ក្រុមទាំងអស់ (សហគមន៍ និង round-robin)",
"booking_limits_updated_successfully": "ដែនកំណត់ការកក់ត្រូវបានធ្វើបច្ចុប្បន្នភាពដោយជោគជ័យ",
"you_are_unauthorized_to_make_this_change_to_the_booking": "អ្នកមិនមានសិទ្ធិធ្វើការផ្លាស់ប្តូរនេះលើការកក់ទេ",
"hide_calendar_event_details": "លាក់ព័ត៌មានលម្អិតនៃព្រឹត្តិការណ៍ប្រចាំប្រតិទិននៅលើប្រតិទិនដែលបានចែករំលែក",
"description_hide_calendar_event_details": "នៅពេលដែលប្រតិទិនត្រូវបានចែករំលែក ព្រឹត្តិការណ៍នឹងមើលឃើញបានសម្រាប់អ្នកអាន ប៉ុន្តែព័ត៌មានលម្អិតនឹងត្រូវបានលាក់ពីអ្នកដែលមិនមានសិទ្ធិចូលសរសេរ។",
"last_number_of_days": "ចុងក្រោយ {{count}} ថ្ងៃ",
"month_to_date": "ខែនេះ",
"year_to_date": "ឆ្នាំនេះ",
"custom_range": "ជួរដែលកំណត់ដោយខ្លួនឯង",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ បន្ថែមខ្សែអក្សរថ្មីរបស់អ្នកនៅខាងលើនេះ ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+22 -4
View File
@@ -876,12 +876,17 @@
"copy_private_link": "비공개 링크 복사",
"copy_private_link_to_event": "이벤트의 비공개 링크 복사",
"private_link_description": "{{appName}} 사용자 이름을 노출하지 않고 공유할 비공개 URL 생성",
"multiple_private_links_title": "비공개 링크",
"multiple_private_links_description": "사용자 이름을 노출하지 않고 비공개 URL을 생성하며, 사용 후에는 삭제됩니다.",
"add_a_multiple_private_link": "새 링크 추가",
"multiple_private_link_copied": "비공개 링크가 복사되었습니다.",
"invitees_can_schedule": "초대받은 사람은 일정을 잡을 수 있습니다.",
"date_range": "날짜 범위",
"calendar_days": "일",
"business_days": "영업일",
"set_address_place": "주소 또는 장소 설정",
"set_link_meeting": "회의 링크 설정",
"managed_event_field_parent_control_disabled": "전환할 수 없습니다. 하위 이벤트 유형에 대해서만 잠금 해제할 수 있습니다.",
"cal_invitee_phone_number_scheduling": "{{appName}}은 일정을 잡기 전에 초대받은 사람에게 전화번호를 요청합니다.",
"cal_provide_google_meet_location": "{{appName}}은 Google Meet 위치를 제공합니다.",
"cal_provide_zoom_meeting_url": "{{appName}}은 Zoom 회의 URL을 제공합니다.",
@@ -972,6 +977,8 @@
"signin_with_google": "Google로 로그인",
"signin_with_saml": "SAML로 로그인",
"signin_with_saml_oidc": "SAML/OIDC로 로그인",
"continue_with_email": "이메일로 계속하기",
"continue_with_google": "Google로 계속하기",
"last_used": "마지막 사용",
"you_will_need_to_generate": "이전 일정 관리 도구에서 액세스 토큰을 생성해야 합니다.",
"import": "가져오기",
@@ -1027,9 +1034,9 @@
"installed_other": "{{count}}개 설치됨",
"verify_wallet": "지갑 인증",
"create_events_on": "이벤트 생성일:",
"enterprise_license": "엔터프라이즈 기능입니다",
"enterprise_license": "이것은 상업용 기능입니다",
"enterprise_license_locally": "이 기능은 로컬에서 테스트할 수 있지만 실제 운영 환경에서는 할 수 없습니다.",
"enterprise_license_sales": "기업용 버전으로 업그레이드하려면 영업팀에 문의하세요. 라이센스 키가 이미 있는 경우 support@cal.com에 문의하여 도움을 받으세요.",
"enterprise_license_sales": "상업용 라이선스를 구매하려면 영업팀에 문의하십시오. 이미 라이선스 키가 있는 경우, 지원이 필요하면 support@cal.com으로 연락해 주십시오.",
"missing_license": "라이선스 없음",
"next_steps": "다음 단계",
"acquire_commercial_license": "상용 라이선스 취득하기",
@@ -2041,7 +2048,8 @@
"error_charging_card": "노쇼 수수료를 부과하는 중 문제가 발생했습니다. 나중에 다시 시도해주세요.",
"collect_no_show_fee": "노쇼 수수료 청구",
"no_show_fee_charged": "노쇼 수수료 부과됨",
"insights": "Insights",
"insights": "인사이트",
"routing_forms": "라우팅 양식",
"testing_workflow_info_message": "이 워크플로를 테스트할 때 이메일 및 SMS는 최소 1시간 전에만 예약할 수 있다는 점에 유의하세요",
"insights_no_data_found_for_filter": "선택한 필터 또는 날짜에 대한 데이터를 찾을 수 없습니다.",
"acknowledge_booking_no_show_fee": "본인은 이 이벤트에 참석하지 않을 경우 {{amount, currency}}에 대한 노쇼 수수료가 내 카드에 부과됨을 인정합니다.",
@@ -2166,7 +2174,7 @@
"select_time": "시간 선택",
"select_date": "날짜 선택",
"connecting_you_to_someone": "다른 사람과 연결 중입니다.",
"please_do_not_close_this_tab": "이 탭을 닫지 마세요",
"please_do_not_close_this_tab": "이 페이지를 닫거나 새로 고침하지 마세요",
"see_all_available_times": "모든 사용 가능한 시간 보기",
"org_team_names_example_1": "예: 마케팅 팀",
"org_team_names_example_2": "예: 세일즈 팀",
@@ -2480,6 +2488,7 @@
"lock_org_users_eventtypes_description": "회원이 자신의 이벤트 유형을 생성하지 못하도록 방지합니다.",
"add_to_event_type": "이벤트 유형에 추가",
"create_account_password": "계정 비밀번호 생성",
"create_account_with_saml": "SAML로 계정 만들기",
"error_creating_account_password": "계정 비밀번호 생성 실패",
"cannot_create_account_password_cal_provider": "cal 계정에 대해 계정 비밀번호를 생성할 수 없습니다",
"cannot_create_account_password_already_existing": "이미 생성된 계정에 대해 계정 비밀번호를 생성할 수 없습니다",
@@ -2598,6 +2607,9 @@
"outlook_connect_atom_label": "Outlook 캘린더 연결",
"outlook_connect_atom_already_connected_label": "Outlook 캘린더 연결됨",
"outlook_connect_atom_loading_label": "Outlook 캘린더 확인 중",
"stripe_connect_atom_label": "Stripe에 연결",
"stripe_connect_atom_already_connected_label": "Stripe에 연결됨",
"stripe_connect_atom_loading_label": "Stripe 자격 증명 확인 중",
"booking_question_response_variables": "예약 질문 응답 변수",
"managed_by_teamAdmins": "{{teamAdmins}}에 의해 관리됨",
"number_of_options": "{{count}}개의 옵션",
@@ -2609,5 +2621,11 @@
"limit_team_booking_frequency_description": "모든 팀 이벤트 유형(집단 및 round-robin)에서 팀원들이 예약될 수 있는 횟수를 제한합니다",
"booking_limits_updated_successfully": "예약 제한이 성공적으로 업데이트되었습니다",
"you_are_unauthorized_to_make_this_change_to_the_booking": "이 예약을 변경할 권한이 없습니다",
"hide_calendar_event_details": "공유 캘린더에서 이벤트 세부 정보 숨기기",
"description_hide_calendar_event_details": "캘린더가 공유되면 이벤트는 읽기 권한이 있는 사람에게 보이지만, 쓰기 권한이 없는 사람에게는 세부 정보가 숨겨집니다.",
"last_number_of_days": "지난 {{count}}일",
"month_to_date": "이번 달 현재까지",
"year_to_date": "올해 현재까지",
"custom_range": "사용자 지정 범위",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ 여기에 새 문자열을 추가하세요 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}

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