Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37f01db0da | ||
|
|
560ec244e8 | ||
|
|
21a0b67b5a | ||
|
|
824145b0e2 | ||
|
|
bc89fe00ea | ||
|
|
64850b54f0 | ||
|
|
405a4cc850 | ||
|
|
eccab37607 | ||
|
|
786c1c2ba3 | ||
|
|
50970dc249 | ||
|
|
45cded6b48 | ||
|
|
b139ec8994 | ||
|
|
d058d322bc | ||
|
|
72907e5e1b | ||
|
|
d9c3c184c4 | ||
|
|
b1fff71c91 | ||
|
|
56b1243e59 | ||
|
|
a3413216f6 | ||
|
|
fd507f1d12 | ||
|
|
c820a3937a | ||
|
|
5c24ed2ef7 | ||
|
|
8d9d7bbb25 | ||
|
|
837bf5d604 | ||
|
|
c9c634cceb | ||
|
|
8ba09ce04d | ||
|
|
65d4a4f40e | ||
|
|
581d8ff4ae | ||
|
|
c562f45904 | ||
|
|
b413f0a609 | ||
|
|
ffcf9b770d | ||
|
|
ab77531167 | ||
|
|
f59f4ca26c | ||
|
|
ca13eec4c9 | ||
|
|
bf1ff9bd71 | ||
|
|
caefa93e14 | ||
|
|
7154349e9e | ||
|
|
7beb9568ba | ||
|
|
2251d5a4be | ||
|
|
f1bdff9679 | ||
|
|
c433e0c74c | ||
|
|
fb940a1023 | ||
|
|
73366e4152 | ||
|
|
9d7da10744 | ||
|
|
c4f091debf | ||
|
|
29923fe304 | ||
|
|
e8998414fc | ||
|
|
2e58e73e95 | ||
|
|
b7168948ca | ||
|
|
c188a91d64 | ||
|
|
4724b1312c | ||
|
|
d349f6536a | ||
|
|
f35f506ddf | ||
|
|
46d0b67f81 | ||
|
|
7000cf7581 | ||
|
|
1c0faf4eb6 | ||
|
|
6d0f0d6bdd | ||
|
|
cb4a3dd2a6 | ||
|
|
fc623fc511 | ||
|
|
9db0834b50 | ||
|
|
7a3cc7d263 | ||
|
|
f1381d8a82 | ||
|
|
5c136df1bb | ||
|
|
e18f08249b | ||
|
|
5b8b165a3b | ||
|
|
ab39c8f581 | ||
|
|
abc077bfb6 | ||
|
|
bb167e7763 | ||
|
|
3b92ff607a | ||
|
|
129d00a69d | ||
|
|
c86e766aee | ||
|
|
a2873c740b | ||
|
|
6e3a1369f8 | ||
|
|
e44e24ca42 | ||
|
|
768a3132fb | ||
|
|
41812bb280 | ||
|
|
cb1d3487af | ||
|
|
c95917cceb | ||
|
|
9bc40a3eb6 | ||
|
|
bd21b2dfe1 | ||
|
|
d60fb081c9 | ||
|
|
197b435b6f | ||
|
|
add297b09a | ||
|
|
d65f0ee0e8 | ||
|
|
59c0a71db7 | ||
|
|
d50151fe08 | ||
|
|
d2b0dd8eab | ||
|
|
bb0574dff8 | ||
|
|
49364eee9a | ||
|
|
adb0530d69 | ||
|
+18 |
a2d1dbebba | ||
|
|
605e744a58 | ||
|
|
aab0d6b18a | ||
|
|
dfde17111c | ||
|
|
2591ad12df | ||
|
|
3a05ae6f74 | ||
|
|
efcfd7bb9b | ||
|
|
bce62829b7 | ||
|
|
f320e486a2 | ||
|
|
8c96e04b51 | ||
|
|
9eb3698ea5 | ||
|
|
8ff07807f9 | ||
|
|
9c7f655329 | ||
|
|
5c72fca816 | ||
|
|
aeafa3ef0a | ||
|
|
3d4a3c7a93 | ||
|
|
1c1625d3fc | ||
|
|
75f9f045b8 | ||
|
|
86af383f6c | ||
|
|
84408025ed | ||
|
|
9fba8c548b | ||
|
|
56f3647ec2 | ||
|
|
a5cb3ce573 | ||
|
|
7581f4d641 | ||
|
|
a1d67b256e | ||
|
|
75f9dad16e | ||
|
|
0a0d212373 | ||
|
|
b8794ba807 | ||
|
|
9b8e4ed385 | ||
|
|
997b5d5290 | ||
|
|
d7080b41cb | ||
|
|
32754bd120 | ||
|
|
13fd02d174 | ||
|
|
d6cbac54e0 | ||
|
|
489ffdf49a | ||
|
|
37b22977ee | ||
|
|
848a040162 | ||
|
|
350d84a047 | ||
|
|
9c3cbee363 | ||
|
|
7e2ad3cea9 | ||
|
|
4ef52aa2c6 | ||
|
|
3132c64205 | ||
|
|
f25f8e0909 | ||
|
|
d8961f8eb9 | ||
|
|
9ff7cf650b | ||
|
|
ba4e717b59 | ||
|
|
f326a9a620 | ||
|
|
95be16c358 | ||
|
|
5621507f7e | ||
|
|
bc6d58fd47 | ||
|
|
db29cc1251 | ||
|
|
69888c57b0 | ||
|
|
5c0da23b97 | ||
|
|
5324ec8051 | ||
|
|
b946f89ffb | ||
|
|
3e3581fabc | ||
|
|
05ac339233 | ||
|
|
ef4675ff44 | ||
|
|
47dc85ee1b | ||
|
|
50342e33ed | ||
|
|
eebcb636f1 | ||
|
|
ed2ced899a | ||
|
|
b7ae8c9594 | ||
|
|
fce98ebb13 | ||
|
|
b81b221bf5 | ||
|
|
f9eb335d0b | ||
|
|
d7d7bcd651 | ||
|
|
4c861b9e67 | ||
|
|
2fa704c39b | ||
|
|
9ccdf21d75 | ||
|
|
9d6148da42 | ||
|
|
bf437b7d6b | ||
|
|
3701fcd68b | ||
|
|
2f90dc7cb6 | ||
|
|
829f90b82f | ||
|
|
3539693729 | ||
|
|
868ba4396d | ||
|
|
d06e36a5cd | ||
|
|
f61c9d9bbf | ||
|
|
a2d521e001 | ||
|
|
bf9c881d16 | ||
|
|
6dd0f9ce29 | ||
|
|
d34db7aef6 | ||
|
|
b4b90f2d27 | ||
|
|
3f0380617a | ||
|
|
7143923ea4 | ||
|
|
dfccf4604f | ||
|
|
c12abd75a8 | ||
|
|
2e12f09f05 | ||
|
|
4258da1df5 | ||
|
|
41ef354c7b | ||
|
|
8891641953 | ||
|
|
0150041496 | ||
|
|
13f9e97015 | ||
|
|
3c5901edb8 | ||
|
|
d26bab5880 | ||
|
|
1c451a8d51 | ||
|
|
576261bf88 | ||
|
|
7d944af7f6 | ||
|
|
dfe7056c93 | ||
|
|
b27f01449a | ||
|
|
6dd77d0494 | ||
|
|
f73d38b632 | ||
|
|
a7d8f0c84e | ||
|
|
b0164990c0 | ||
|
|
bad72ba7bf | ||
|
|
1b317a98a4 | ||
|
|
6a22d75caa | ||
|
|
a030861423 | ||
|
|
bd9cd00c04 | ||
|
|
6fd34af6f6 | ||
|
|
1a85f336c5 | ||
|
|
cc5096a54a | ||
|
|
eea7059226 | ||
|
|
63f3247b1b | ||
|
|
7a769c38fb | ||
|
|
34c2565c70 | ||
|
|
a130804f18 | ||
|
|
446e851065 | ||
|
|
f06b4bfb9c | ||
|
|
1896efa3a0 | ||
|
|
62c8603863 | ||
|
|
7d8559b0b0 | ||
|
|
7a48bbe553 | ||
|
|
817e95c246 | ||
|
|
4ac6225a35 | ||
|
|
38d9b93dee | ||
|
|
376b38d4ed | ||
|
|
0e181f6d9f | ||
|
|
4dbb692156 | ||
|
|
0068718d24 | ||
|
|
ac817b3542 | ||
|
|
97f2cffd1f | ||
|
|
b6dea9c161 | ||
|
|
57eb4746ad | ||
|
|
eff9c87964 | ||
|
|
e06c07bfec | ||
|
|
332c00dac2 | ||
|
|
e3523db6bb | ||
|
|
9c0f046b2a | ||
|
|
6e1a717ecf | ||
|
|
b2e098c781 | ||
|
|
3010900358 | ||
|
|
9b043f44a0 | ||
|
|
814674fe40 | ||
|
|
acaa79b0b8 | ||
|
|
781a0f4a7c | ||
|
|
b2028870d0 | ||
|
|
8f70be8861 | ||
|
|
53c8f8c04a | ||
|
|
c4e5d168a5 | ||
|
|
500bfb4d82 | ||
|
|
024b92b9ce | ||
|
|
f23b63cc98 | ||
|
|
edc90e3f27 | ||
|
|
05631d093f | ||
|
|
6000ff4b48 | ||
|
|
cd7de98e3f | ||
|
|
3f51858d27 | ||
|
|
2c498ace5c | ||
|
|
595d781d51 | ||
|
|
3c508568da | ||
|
|
3204d0af34 | ||
|
|
25684f9040 | ||
|
|
bc9aeef710 | ||
|
|
393411a47a | ||
|
|
356117feaf | ||
|
|
1bf68d50e0 | ||
|
|
4a35e85756 | ||
|
|
a4f56bf680 | ||
|
|
a308075bc3 | ||
|
|
efa6d464a3 | ||
|
|
811262d7ee | ||
|
|
99a5dcf96a | ||
|
|
79f092673a | ||
|
|
b285f27d00 | ||
|
|
1fa87ae179 | ||
|
|
f6f4b67bf9 | ||
|
|
8c8550e6f8 | ||
|
|
c6246c9408 | ||
|
|
ccc814eb54 | ||
|
|
2ee0ec11d6 | ||
|
|
3223beb9ac | ||
|
|
c1bcfcfa3d | ||
|
|
83aea7d28c | ||
|
|
a810a538e9 | ||
|
|
1cd0d7b5ca | ||
|
|
4540c79580 | ||
|
|
00d01882d4 | ||
|
|
6ce4122528 | ||
|
|
7ddbcfcdcf | ||
|
|
b1b01ed383 | ||
|
|
6f5d9dc41f | ||
|
|
ac190b89dd | ||
|
|
990441295c | ||
|
|
1e4c20073c | ||
|
|
80353d67a0 | ||
|
|
d55b4b051a | ||
|
|
25d90e3e12 | ||
|
|
1727eb5625 | ||
|
|
58f4224c66 | ||
|
|
5d491e0a12 | ||
|
|
1eef5a56c3 | ||
|
|
d6740503be | ||
|
|
9f647ead46 | ||
|
|
f480afcace | ||
|
|
8d73d15b8a | ||
|
|
e8370ef950 | ||
|
|
e859c8dc58 | ||
|
|
b4b38b9bc3 | ||
|
|
d32719ad8b | ||
|
|
99d0816d9f | ||
|
|
95f4b89fe8 | ||
|
|
3e63c2b717 | ||
|
|
42cd56578d | ||
|
|
31d4cb4eb7 | ||
|
|
fde3e8bea9 | ||
|
|
874ad29676 | ||
|
|
5503d9d432 | ||
|
|
43b3d68447 | ||
|
|
e3c747a867 | ||
|
|
e020a2c9a4 | ||
|
|
e6e6f09547 | ||
|
|
68ec946c59 | ||
|
|
5a6e5ce427 | ||
|
|
b2382211db | ||
|
|
12148506bd | ||
|
|
b5666fd4c3 | ||
|
|
bc7da563d8 | ||
|
|
d3acf4160d | ||
|
|
65e0c0e4c5 | ||
|
|
2dda6a509b | ||
|
|
7238414864 | ||
|
|
bab41ca8a6 | ||
|
|
376f023e0f | ||
|
|
0cbf525a78 | ||
|
|
f56fefec4d | ||
|
|
66eccd6dd8 | ||
|
|
c4b9a9e2f4 | ||
|
|
38921e58cd | ||
|
|
2c9c6c77f7 | ||
|
|
46605e049d | ||
|
|
01e1e82ced | ||
|
|
8e17716094 | ||
|
|
7c028b6479 | ||
|
|
cba4bae5cd | ||
|
|
55046f3017 | ||
|
|
4836621c31 | ||
|
|
6743aa4609 | ||
|
|
bbad0fbc18 | ||
|
|
8f6812ecb2 | ||
|
|
303b09f341 | ||
|
|
1f3ee41eb4 | ||
|
|
01c711a81e | ||
|
|
16bb5c3384 | ||
|
|
c55d97368e | ||
|
|
ef193aca31 | ||
|
|
c36a6797a9 | ||
|
|
f393fd4ad4 | ||
|
|
8987b9bdbc | ||
|
|
68bf426af0 | ||
|
|
1125900013 | ||
|
|
d272e823c3 | ||
|
|
3ea11d4f38 | ||
|
|
b4dc64594c | ||
|
|
6a763b3720 | ||
|
|
252ac1c704 | ||
|
|
c51e42a061 | ||
|
|
e3f9e477fe | ||
|
|
b265970b01 | ||
|
|
7b7b2ffc9a | ||
|
|
d019062709 | ||
|
|
ad9d728314 | ||
|
|
b564fb9a2f | ||
|
|
9c05f6839c | ||
|
|
4dea38f06d | ||
|
|
c4fc222204 | ||
|
|
00c3a2dd80 | ||
|
|
46fa0088f2 | ||
|
|
a19fd9b889 | ||
|
|
d379c10510 | ||
|
|
f3a58145ee | ||
|
|
e6125dc361 | ||
|
|
b78f718e9e | ||
|
|
53e87f7769 | ||
|
|
86eb40e0fd | ||
|
|
51caa6834a | ||
|
|
7a7a5b5285 | ||
|
|
f14ffb9e44 | ||
|
|
001b524005 | ||
|
|
a8cf4fb309 | ||
|
|
155775556d | ||
|
|
d66095bf9c | ||
|
|
0ddab30340 | ||
|
|
2a9e061575 | ||
|
|
351dce5cfd | ||
|
|
3255d9012c | ||
|
|
226dcb3fcf | ||
|
|
f80e9b2558 | ||
|
|
3f273235a6 | ||
|
|
e8554ed5c5 | ||
|
|
a03744008c | ||
|
|
fe8b13885c | ||
|
|
8995dcc82a | ||
|
|
e8e0d03265 | ||
|
|
56031f0358 | ||
|
|
1ef0f830d3 | ||
|
|
59e2f6e4a7 | ||
|
+15 |
67bb2ed798 | ||
|
|
ea4618a244 | ||
|
|
201a9d9356 | ||
|
|
40b0d57024 | ||
|
|
761f66fc5a | ||
|
|
2403d77166 | ||
|
|
b44f4d66f9 | ||
|
|
d178962c37 | ||
|
|
cf4d2b4ef5 | ||
|
|
3e4139ac23 | ||
|
|
9fb736f67a | ||
|
|
afc738141f | ||
|
|
efa195059e | ||
|
|
0f2b6bbe1a | ||
|
|
28b94a865f | ||
|
|
f45803fcfa | ||
|
|
053da1ec32 | ||
|
|
f0dcfc83c8 | ||
|
|
5eb3af54b3 | ||
|
|
3552bbc6cf | ||
|
|
028bd77c91 | ||
|
|
65a1aa6100 | ||
|
|
a09401c203 | ||
|
|
7c23773636 | ||
|
|
56ba8c0a07 | ||
|
|
5924bc0990 | ||
|
+27 |
10ffd9bacd | ||
|
|
20c45b5d88 | ||
|
|
3cab37ed8d | ||
|
|
98799b50f0 | ||
|
|
8071016848 | ||
|
|
aa5e2ac9a5 | ||
|
|
06857a4a69 | ||
|
|
b0d1720d92 | ||
|
|
e148ee7823 | ||
|
|
bb9a039931 | ||
|
|
053f93ea0a | ||
|
|
2a982c6b6d | ||
|
|
85490fef4d | ||
|
|
57afd7a2b1 | ||
|
|
0f9c3571fe | ||
|
|
1b3f676e57 | ||
|
|
3e17b1c5ef | ||
|
|
a466e71d4c | ||
|
|
5a7030d277 | ||
|
|
e72c25f02e | ||
|
|
06bc5facf3 | ||
|
|
9c8dc1b92c | ||
|
|
cfef92a7bc | ||
|
|
26149e2b04 | ||
|
|
3ca43ce3f3 | ||
|
|
7603ea6184 | ||
|
|
9c45da565e | ||
|
|
f84f1f5dda | ||
|
|
07f803e758 | ||
|
|
268ab882cd | ||
|
|
8052e6f6d4 | ||
|
|
99e9425257 | ||
|
|
471b2687c4 | ||
|
|
9f7152c4eb | ||
|
|
e819a31dd6 | ||
|
|
8356cb7d34 | ||
|
|
ceb18167cf | ||
|
|
4d5697f5a5 | ||
|
|
afe180a0ec | ||
|
|
851a388853 | ||
|
|
07e20144df | ||
|
|
1535e48ce5 | ||
|
|
0dce391f46 | ||
|
|
cfe7ea45ec | ||
|
|
c244f0fc8e | ||
|
|
5301af2e9f | ||
|
|
a4e7ca91b5 | ||
|
|
64e6e1a19a | ||
|
|
a6daf17909 | ||
|
|
c7dfa7bc89 |
No files matched your search
@@ -1,6 +1,7 @@
|
|||||||
# ********** INDEX **********
|
# ********** INDEX **********
|
||||||
#
|
#
|
||||||
# - APP STORE
|
# - APP STORE
|
||||||
|
# - BASECAMP
|
||||||
# - DAILY.CO VIDEO
|
# - DAILY.CO VIDEO
|
||||||
# - GOOGLE CALENDAR/MEET/LOGIN
|
# - GOOGLE CALENDAR/MEET/LOGIN
|
||||||
# - HUBSPOT
|
# - HUBSPOT
|
||||||
@@ -20,6 +21,14 @@
|
|||||||
|
|
||||||
# - APP STORE **********************************************************************************************
|
# - APP STORE **********************************************************************************************
|
||||||
# ⚠️ ⚠️ ⚠️ THESE WILL BE MIGRATED TO THE DATABASE TO PREVENT AWS's 4KB ENV QUOTA ⚠️ ⚠️ ⚠️
|
# ⚠️ ⚠️ ⚠️ THESE WILL BE MIGRATED TO THE DATABASE TO PREVENT AWS's 4KB ENV QUOTA ⚠️ ⚠️ ⚠️
|
||||||
|
|
||||||
|
# - BASECAMP
|
||||||
|
# Used to enable Basecamp integration with Cal.com
|
||||||
|
# @see https://github.com/calcom/cal.com#obtaining-basecamp-client-id-and-secret
|
||||||
|
BASECAMP3_CLIENT_ID=
|
||||||
|
BASECAMP3_CLIENT_SECRET=
|
||||||
|
BASECAMP3_USER_AGENT=
|
||||||
|
|
||||||
# - DAILY.CO VIDEO
|
# - DAILY.CO VIDEO
|
||||||
# Enables Cal Video. to get your key
|
# Enables Cal Video. to get your key
|
||||||
# 1. Visit our [Daily.co Partnership Form](https://go.cal.com/daily) and enter your information
|
# 1. Visit our [Daily.co Partnership Form](https://go.cal.com/daily) and enter your information
|
||||||
|
|||||||
+44
-2
@@ -126,8 +126,7 @@ TWILIO_WHATSAPP_PHONE_NUMBER=
|
|||||||
NEXT_PUBLIC_SENDER_ID=
|
NEXT_PUBLIC_SENDER_ID=
|
||||||
TWILIO_VERIFY_SID=
|
TWILIO_VERIFY_SID=
|
||||||
|
|
||||||
# This is used so we can bypass emails in auth flows for E2E testing
|
# Set it to "1" if you need to run E2E tests locally.
|
||||||
# Set it to "1" if you need to run E2E tests locally
|
|
||||||
NEXT_PUBLIC_IS_E2E=
|
NEXT_PUBLIC_IS_E2E=
|
||||||
|
|
||||||
# Used for internal billing system
|
# Used for internal billing system
|
||||||
@@ -172,6 +171,12 @@ EMAIL_SERVER_PORT=1025
|
|||||||
## You will need to provision an App Password.
|
## You will need to provision an App Password.
|
||||||
## @see https://support.google.com/accounts/answer/185833
|
## @see https://support.google.com/accounts/answer/185833
|
||||||
# EMAIL_SERVER_PASSWORD='<gmail_app_password>'
|
# EMAIL_SERVER_PASSWORD='<gmail_app_password>'
|
||||||
|
|
||||||
|
# Used for E2E for email testing
|
||||||
|
# Set it to "1" if you need to email checks in E2E tests locally
|
||||||
|
# Make sure to run mailhog container manually or with `yarn dx`
|
||||||
|
E2E_TEST_MAILHOG_ENABLED=
|
||||||
|
|
||||||
# **********************************************************************************************************
|
# **********************************************************************************************************
|
||||||
|
|
||||||
# Set the following value to true if you wish to enable Team Impersonation
|
# Set the following value to true if you wish to enable Team Impersonation
|
||||||
@@ -208,6 +213,10 @@ NEXT_PUBLIC_MINUTES_TO_BOOK=5 # Minutes
|
|||||||
# use organizations
|
# use organizations
|
||||||
ORGANIZATIONS_ENABLED=
|
ORGANIZATIONS_ENABLED=
|
||||||
|
|
||||||
|
# This variable should only be set to 1 or true if you want to autolink external provider sing-ups with
|
||||||
|
# existing organizations based on email domain address
|
||||||
|
ORGANIZATIONS_AUTOLINK=
|
||||||
|
|
||||||
# Vercel Config to create subdomains for organizations
|
# Vercel Config to create subdomains for organizations
|
||||||
# Get it from https://vercel.com/<TEAM_OR_USER_NAME>/<PROJECT_SLUG>/settings
|
# Get it from https://vercel.com/<TEAM_OR_USER_NAME>/<PROJECT_SLUG>/settings
|
||||||
PROJECT_ID_VERCEL=
|
PROJECT_ID_VERCEL=
|
||||||
@@ -215,3 +224,36 @@ PROJECT_ID_VERCEL=
|
|||||||
TEAM_ID_VERCEL=
|
TEAM_ID_VERCEL=
|
||||||
# Get it from: https://vercel.com/account/tokens
|
# Get it from: https://vercel.com/account/tokens
|
||||||
AUTH_BEARER_TOKEN_VERCEL=
|
AUTH_BEARER_TOKEN_VERCEL=
|
||||||
|
|
||||||
|
# - APPLE CALENDAR
|
||||||
|
# Used for E2E tests on Apple Calendar
|
||||||
|
E2E_TEST_APPLE_CALENDAR_EMAIL=""
|
||||||
|
E2E_TEST_APPLE_CALENDAR_PASSWORD=""
|
||||||
|
|
||||||
|
# - APP CREDENTIAL SYNC ***********************************************************************************
|
||||||
|
# Used for self-hosters that are implementing Cal.com into their applications that already have certain integrations
|
||||||
|
# Under settings/admin/apps ensure that all app secrets are set the same as the parent application
|
||||||
|
# You can use: `openssl rand -base64 32` to generate one
|
||||||
|
CALCOM_WEBHOOK_SECRET=""
|
||||||
|
# This is the header name that will be used to verify the webhook secret. Should be in lowercase
|
||||||
|
CALCOM_WEBHOOK_HEADER_NAME="calcom-webhook-secret"
|
||||||
|
CALCOM_CREDENTIAL_SYNC_ENDPOINT=""
|
||||||
|
# Key should match on Cal.com and your application
|
||||||
|
# must be 32 bytes for AES256 encryption algorithm
|
||||||
|
# You can use: `openssl rand -base64 24` to generate one
|
||||||
|
CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY=""
|
||||||
|
|
||||||
|
# - OIDC E2E TEST *******************************************************************************************
|
||||||
|
|
||||||
|
# Ensure this ADMIN EMAIL is present in the SAML_ADMINS list
|
||||||
|
E2E_TEST_SAML_ADMIN_EMAIL=
|
||||||
|
E2E_TEST_SAML_ADMIN_PASSWORD=
|
||||||
|
|
||||||
|
E2E_TEST_OIDC_CLIENT_ID=
|
||||||
|
E2E_TEST_OIDC_CLIENT_SECRET=
|
||||||
|
E2E_TEST_OIDC_PROVIDER_DOMAIN=
|
||||||
|
|
||||||
|
E2E_TEST_OIDC_USER_EMAIL=
|
||||||
|
E2E_TEST_OIDC_USER_PASSWORD=
|
||||||
|
|
||||||
|
# ***********************************************************************************************************
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
name: Add PRs to project Reviewing PRs
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
types:
|
|
||||||
- opened
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
add-PR-to-project:
|
|
||||||
name: Add PRs to project Reviewing PRs
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/add-to-project@v0.1.0
|
|
||||||
with:
|
|
||||||
project-url: https://github.com/orgs/calcom/projects/11
|
|
||||||
github-token: ${{ secrets.GH_ACCESS_TOKEN }}
|
|
||||||
@@ -5,7 +5,6 @@ on:
|
|||||||
types:
|
types:
|
||||||
- opened
|
- opened
|
||||||
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
label_on_pr:
|
label_on_pr:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# .github/workflows/chromatic.yml
|
# .github/workflows/chromatic.yml
|
||||||
|
|
||||||
# Workflow name
|
# Workflow name
|
||||||
name: 'Chromatic'
|
name: "Chromatic"
|
||||||
|
|
||||||
# Event for the workflow
|
# Event for the workflow
|
||||||
on:
|
on:
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ on:
|
|||||||
# "Scheduled workflows run on the latest commit on the default or base branch."
|
# "Scheduled workflows run on the latest commit on the default or base branch."
|
||||||
# — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule
|
# — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule
|
||||||
schedule:
|
schedule:
|
||||||
# Runs “At minute 0, 15, 30, and 45.” (see https://crontab.guru)
|
# Runs "At every 15th minute." (see https://crontab.guru)
|
||||||
- cron: "0,15,30,45 * * * *"
|
- cron: "*/15 * * * *"
|
||||||
jobs:
|
jobs:
|
||||||
cron-bookingReminder:
|
cron-bookingReminder:
|
||||||
env:
|
env:
|
||||||
@@ -20,4 +20,4 @@ jobs:
|
|||||||
-X POST \
|
-X POST \
|
||||||
-H 'content-type: application/json' \
|
-H 'content-type: application/json' \
|
||||||
-H 'authorization: ${{ secrets.CRON_API_KEY }}' \
|
-H 'authorization: ${{ secrets.CRON_API_KEY }}' \
|
||||||
--fail
|
-sSf
|
||||||
@@ -5,7 +5,7 @@ on:
|
|||||||
# "Scheduled workflows run on the latest commit on the default or base branch."
|
# "Scheduled workflows run on the latest commit on the default or base branch."
|
||||||
# — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule
|
# — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule
|
||||||
schedule:
|
schedule:
|
||||||
# Runs “Every month at 1st (see https://crontab.guru)
|
# Runs "At 00:00 on day-of-month 1." (see https://crontab.guru)
|
||||||
- cron: "0 0 1 * *"
|
- cron: "0 0 1 * *"
|
||||||
jobs:
|
jobs:
|
||||||
cron-downgradeUsers:
|
cron-downgradeUsers:
|
||||||
@@ -21,4 +21,4 @@ jobs:
|
|||||||
-X POST \
|
-X POST \
|
||||||
-H 'content-type: application/json' \
|
-H 'content-type: application/json' \
|
||||||
-H 'authorization: ${{ secrets.CRON_API_KEY }}' \
|
-H 'authorization: ${{ secrets.CRON_API_KEY }}' \
|
||||||
--fail
|
-sSf
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
name: Cron - monthlyDigestEmail
|
||||||
|
|
||||||
|
on:
|
||||||
|
# "Scheduled workflows run on the latest commit on the default or base branch."
|
||||||
|
# — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule
|
||||||
|
schedule:
|
||||||
|
# Runs on the 28th, 29th, 30th and 31st of every month (see https://crontab.guru)
|
||||||
|
- cron: "59 23 28-31 * *"
|
||||||
|
jobs:
|
||||||
|
cron-monthlyDigestEmail:
|
||||||
|
env:
|
||||||
|
APP_URL: ${{ secrets.APP_URL }}
|
||||||
|
CRON_API_KEY: ${{ secrets.CRON_API_KEY }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Check if today is the last day of the month
|
||||||
|
id: check-last-day
|
||||||
|
run: |
|
||||||
|
LAST_DAY=$(date -d tomorrow +%d)
|
||||||
|
if [ "$LAST_DAY" == "01" ]; then
|
||||||
|
echo "::set-output name=is_last_day::true"
|
||||||
|
else
|
||||||
|
echo "::set-output name=is_last_day::false"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: cURL request
|
||||||
|
if: ${{ env.APP_URL && env.CRON_API_KEY && steps.check-last-day.outputs.is_last_day == 'true' }}
|
||||||
|
run: |
|
||||||
|
curl ${{ secrets.APP_URL }}/api/cron/monthlyDigestEmail \
|
||||||
|
-X POST \
|
||||||
|
-H 'content-type: application/json' \
|
||||||
|
-H 'authorization: ${{ secrets.CRON_API_KEY }}' \
|
||||||
|
--fail
|
||||||
@@ -4,8 +4,8 @@ on:
|
|||||||
# "Scheduled workflows run on the latest commit on the default or base branch."
|
# "Scheduled workflows run on the latest commit on the default or base branch."
|
||||||
# — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule
|
# — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule
|
||||||
schedule:
|
schedule:
|
||||||
# Runs “At minute 0, 15, 30, and 45.” (see https://crontab.guru)
|
# Runs "At every 15th minute." (see https://crontab.guru)
|
||||||
- cron: "0,15,30,45 * * * *"
|
- cron: "*/15 * * * *"
|
||||||
jobs:
|
jobs:
|
||||||
cron-scheduleEmailReminders:
|
cron-scheduleEmailReminders:
|
||||||
env:
|
env:
|
||||||
@@ -20,4 +20,4 @@ jobs:
|
|||||||
-X POST \
|
-X POST \
|
||||||
-H 'content-type: application/json' \
|
-H 'content-type: application/json' \
|
||||||
-H 'authorization: ${{ secrets.CRON_API_KEY }}' \
|
-H 'authorization: ${{ secrets.CRON_API_KEY }}' \
|
||||||
--fail
|
-sSf
|
||||||
@@ -4,8 +4,8 @@ on:
|
|||||||
# "Scheduled workflows run on the latest commit on the default or base branch."
|
# "Scheduled workflows run on the latest commit on the default or base branch."
|
||||||
# — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule
|
# — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule
|
||||||
schedule:
|
schedule:
|
||||||
# Runs “At minute 0, 15, 30, and 45.” (see https://crontab.guru)
|
# Runs "At every 15th minute." (see https://crontab.guru)
|
||||||
- cron: "0,15,30,45 * * * *"
|
- cron: "*/15 * * * *"
|
||||||
jobs:
|
jobs:
|
||||||
cron-scheduleSMSReminders:
|
cron-scheduleSMSReminders:
|
||||||
env:
|
env:
|
||||||
@@ -20,4 +20,4 @@ jobs:
|
|||||||
-X POST \
|
-X POST \
|
||||||
-H 'content-type: application/json' \
|
-H 'content-type: application/json' \
|
||||||
-H 'authorization: ${{ secrets.CRON_API_KEY }}' \
|
-H 'authorization: ${{ secrets.CRON_API_KEY }}' \
|
||||||
--fail
|
-sSf
|
||||||
@@ -4,8 +4,8 @@ on:
|
|||||||
# "Scheduled workflows run on the latest commit on the default or base branch."
|
# "Scheduled workflows run on the latest commit on the default or base branch."
|
||||||
# — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule
|
# — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule
|
||||||
schedule:
|
schedule:
|
||||||
# Runs “At minute 0, 15, 30, and 45.” (see https://crontab.guru)
|
# Runs "At every 15th minute." (see https://crontab.guru)
|
||||||
- cron: "0,15,30,45 * * * *"
|
- cron: "*/15 * * * *"
|
||||||
jobs:
|
jobs:
|
||||||
cron-scheduleWhatsappReminders:
|
cron-scheduleWhatsappReminders:
|
||||||
env:
|
env:
|
||||||
@@ -20,4 +20,4 @@ jobs:
|
|||||||
-X POST \
|
-X POST \
|
||||||
-H 'content-type: application/json' \
|
-H 'content-type: application/json' \
|
||||||
-H 'authorization: ${{ secrets.CRON_API_KEY }}' \
|
-H 'authorization: ${{ secrets.CRON_API_KEY }}' \
|
||||||
--fail
|
-sSf
|
||||||
@@ -8,7 +8,7 @@ on:
|
|||||||
# "Scheduled workflows run on the latest commit on the default or base branch."
|
# "Scheduled workflows run on the latest commit on the default or base branch."
|
||||||
# — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule
|
# — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule
|
||||||
schedule:
|
schedule:
|
||||||
# Runs every day (see https://crontab.guru)
|
# Runs "At 00:00." every day (see https://crontab.guru)
|
||||||
- cron: "0 0 * * *"
|
- cron: "0 0 * * *"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
jobs:
|
jobs:
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ on:
|
|||||||
# "Scheduled workflows run on the latest commit on the default or base branch."
|
# "Scheduled workflows run on the latest commit on the default or base branch."
|
||||||
# — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule
|
# — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule
|
||||||
schedule:
|
schedule:
|
||||||
# Runs “Every month at 1st (see https://crontab.guru)
|
# Runs "At 00:00 on day-of-month 1." (see https://crontab.guru)
|
||||||
- cron: "0 0 1 * *"
|
- cron: "0 0 1 * *"
|
||||||
jobs:
|
jobs:
|
||||||
cron-syncAppMeta:
|
cron-syncAppMeta:
|
||||||
@@ -21,4 +21,4 @@ jobs:
|
|||||||
-X POST \
|
-X POST \
|
||||||
-H 'content-type: application/json' \
|
-H 'content-type: application/json' \
|
||||||
-H 'authorization: ${{ secrets.CRON_API_KEY }}' \
|
-H 'authorization: ${{ secrets.CRON_API_KEY }}' \
|
||||||
--fail
|
-sSf
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
name: Cron - webhookTriggers
|
||||||
|
|
||||||
|
on:
|
||||||
|
# "Scheduled workflows run on the latest commit on the default or base branch."
|
||||||
|
# — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule
|
||||||
|
schedule:
|
||||||
|
# Runs “every 5 minutes” (see https://crontab.guru)
|
||||||
|
- cron: "*/5 * * * *"
|
||||||
|
jobs:
|
||||||
|
cron-webhookTriggers:
|
||||||
|
env:
|
||||||
|
APP_URL: ${{ secrets.APP_URL }}
|
||||||
|
CRON_API_KEY: ${{ secrets.CRON_API_KEY }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: cURL request
|
||||||
|
if: ${{ env.APP_URL && env.CRON_API_KEY }}
|
||||||
|
run: |
|
||||||
|
curl ${{ secrets.APP_URL }}/api/cron/webhookTriggers \
|
||||||
|
-X POST \
|
||||||
|
-H 'content-type: application/json' \
|
||||||
|
-H 'authorization: ${{ secrets.CRON_API_KEY }}' \
|
||||||
|
--fail
|
||||||
@@ -19,7 +19,7 @@ jobs:
|
|||||||
token: ${{ secrets.GH_ACCESS_TOKEN }}
|
token: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||||
|
|
||||||
- name: crowdin action
|
- name: crowdin action
|
||||||
uses: crowdin/github-action@1.5.1
|
uses: crowdin/github-action@v1.13.0
|
||||||
with:
|
with:
|
||||||
upload_sources: true
|
upload_sources: true
|
||||||
upload_translations: true
|
upload_translations: true
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ jobs:
|
|||||||
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||||
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
|
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||||
DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
|
DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
|
||||||
|
E2E_TEST_APPLE_CALENDAR_EMAIL: ${{ secrets.E2E_TEST_APPLE_CALENDAR_EMAIL }}
|
||||||
|
E2E_TEST_APPLE_CALENDAR_PASSWORD: ${{ secrets.E2E_TEST_APPLE_CALENDAR_PASSWORD }}
|
||||||
|
E2E_TEST_MAILHOG_ENABLED: ${{ vars.E2E_TEST_MAILHOG_ENABLED }}
|
||||||
GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
|
GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
|
||||||
GOOGLE_LOGIN_ENABLED: ${{ vars.CI_GOOGLE_LOGIN_ENABLED }}
|
GOOGLE_LOGIN_ENABLED: ${{ vars.CI_GOOGLE_LOGIN_ENABLED }}
|
||||||
NEXTAUTH_SECRET: ${{ secrets.CI_NEXTAUTH_SECRET }}
|
NEXTAUTH_SECRET: ${{ secrets.CI_NEXTAUTH_SECRET }}
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ jobs:
|
|||||||
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||||
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
|
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||||
DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
|
DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
|
||||||
|
E2E_TEST_APPLE_CALENDAR_EMAIL: ${{ secrets.E2E_TEST_APPLE_CALENDAR_EMAIL }}
|
||||||
|
E2E_TEST_APPLE_CALENDAR_PASSWORD: ${{ secrets.E2E_TEST_APPLE_CALENDAR_PASSWORD }}
|
||||||
GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
|
GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
|
||||||
GOOGLE_LOGIN_ENABLED: ${{ vars.CI_GOOGLE_LOGIN_ENABLED }}
|
GOOGLE_LOGIN_ENABLED: ${{ vars.CI_GOOGLE_LOGIN_ENABLED }}
|
||||||
NEXTAUTH_SECRET: ${{ secrets.CI_NEXTAUTH_SECRET }}
|
NEXTAUTH_SECRET: ${{ secrets.CI_NEXTAUTH_SECRET }}
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ jobs:
|
|||||||
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||||
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
|
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||||
DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
|
DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
|
||||||
|
E2E_TEST_APPLE_CALENDAR_EMAIL: ${{ secrets.E2E_TEST_APPLE_CALENDAR_EMAIL }}
|
||||||
|
E2E_TEST_APPLE_CALENDAR_PASSWORD: ${{ secrets.E2E_TEST_APPLE_CALENDAR_PASSWORD }}
|
||||||
|
E2E_TEST_MAILHOG_ENABLED: ${{ vars.E2E_TEST_MAILHOG_ENABLED }}
|
||||||
GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
|
GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
|
||||||
GOOGLE_LOGIN_ENABLED: ${{ vars.CI_GOOGLE_LOGIN_ENABLED }}
|
GOOGLE_LOGIN_ENABLED: ${{ vars.CI_GOOGLE_LOGIN_ENABLED }}
|
||||||
NEXTAUTH_SECRET: ${{ secrets.CI_NEXTAUTH_SECRET }}
|
NEXTAUTH_SECRET: ${{ secrets.CI_NEXTAUTH_SECRET }}
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ jobs:
|
|||||||
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||||
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
|
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||||
DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
|
DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
|
||||||
|
E2E_TEST_APPLE_CALENDAR_EMAIL: ${{ secrets.E2E_TEST_APPLE_CALENDAR_EMAIL }}
|
||||||
|
E2E_TEST_APPLE_CALENDAR_PASSWORD: ${{ secrets.E2E_TEST_APPLE_CALENDAR_PASSWORD }}
|
||||||
|
E2E_TEST_MAILHOG_ENABLED: ${{ vars.E2E_TEST_MAILHOG_ENABLED }}
|
||||||
GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
|
GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
|
||||||
EMAIL_SERVER_HOST: ${{ secrets.CI_EMAIL_SERVER_HOST }}
|
EMAIL_SERVER_HOST: ${{ secrets.CI_EMAIL_SERVER_HOST }}
|
||||||
EMAIL_SERVER_PORT: ${{ secrets.CI_EMAIL_SERVER_PORT }}
|
EMAIL_SERVER_PORT: ${{ secrets.CI_EMAIL_SERVER_PORT }}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
name: Auto Comment Merge Conflicts
|
||||||
|
on: push
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
auto-comment-merge-conflicts:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: codytseng/auto-comment-merge-conflicts@v1
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
comment-body: "Hey there, there is a merge conflict, can you take a look?"
|
||||||
|
wait-ms: 3000
|
||||||
|
max-retries: 5
|
||||||
|
label-name: "🚨 merge conflict"
|
||||||
|
ignore-authors: dependabot,otherAuthor
|
||||||
@@ -7,6 +7,8 @@ env:
|
|||||||
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
||||||
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||||
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
|
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||||
|
E2E_TEST_APPLE_CALENDAR_EMAIL: ${{ secrets.E2E_TEST_APPLE_CALENDAR_EMAIL }}
|
||||||
|
E2E_TEST_APPLE_CALENDAR_PASSWORD: ${{ secrets.E2E_TEST_APPLE_CALENDAR_PASSWORD }}
|
||||||
GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
|
GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
|
||||||
GOOGLE_LOGIN_ENABLED: ${{ vars.CI_GOOGLE_LOGIN_ENABLED }}
|
GOOGLE_LOGIN_ENABLED: ${{ vars.CI_GOOGLE_LOGIN_ENABLED }}
|
||||||
NEXTAUTH_SECRET: ${{ secrets.CI_NEXTAUTH_SECRET }}
|
NEXTAUTH_SECRET: ${{ secrets.CI_NEXTAUTH_SECRET }}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ env:
|
|||||||
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
||||||
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||||
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
|
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||||
|
E2E_TEST_APPLE_CALENDAR_EMAIL: ${{ secrets.E2E_TEST_APPLE_CALENDAR_EMAIL }}
|
||||||
|
E2E_TEST_APPLE_CALENDAR_PASSWORD: ${{ secrets.E2E_TEST_APPLE_CALENDAR_PASSWORD }}
|
||||||
GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
|
GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
|
||||||
GOOGLE_LOGIN_ENABLED: ${{ vars.CI_GOOGLE_LOGIN_ENABLED }}
|
GOOGLE_LOGIN_ENABLED: ${{ vars.CI_GOOGLE_LOGIN_ENABLED }}
|
||||||
NEXTAUTH_SECRET: ${{ secrets.CI_NEXTAUTH_SECRET }}
|
NEXTAUTH_SECRET: ${{ secrets.CI_NEXTAUTH_SECRET }}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ on: # yamllint disable-line rule:truthy
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
RELEASE_TAG:
|
RELEASE_TAG:
|
||||||
description: 'v{Major}.{Minor}.{Patch}'
|
description: "v{Major}.{Minor}.{Patch}"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
@@ -21,7 +21,7 @@ jobs:
|
|||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: "Determine tag"
|
- name: "Determine tag"
|
||||||
run: "echo \"RELEASE_TAG=${GITHUB_REF#refs/tags/}\" >> $GITHUB_ENV"
|
run: 'echo "RELEASE_TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV'
|
||||||
|
|
||||||
- name: "Run remote release workflow"
|
- name: "Run remote release workflow"
|
||||||
uses: "actions/github-script@v6"
|
uses: "actions/github-script@v6"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
name: Submodule Sync
|
name: Submodule Sync
|
||||||
on:
|
on:
|
||||||
schedule:
|
schedule:
|
||||||
|
# Runs "At minute 15 past every 4th hour." (see https://crontab.guru)
|
||||||
- cron: "15 */4 * * *"
|
- cron: "15 */4 * * *"
|
||||||
workflow_dispatch: ~
|
workflow_dispatch: ~
|
||||||
jobs:
|
jobs:
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ public
|
|||||||
|
|
||||||
*.lock
|
*.lock
|
||||||
*.log
|
*.log
|
||||||
*.test.ts
|
|
||||||
|
|
||||||
.gitignore
|
.gitignore
|
||||||
.npmignore
|
.npmignore
|
||||||
|
|||||||
+5
-5
@@ -1,6 +1,6 @@
|
|||||||
# Contributing to Cal.com
|
# Contributing to Cal.com
|
||||||
|
|
||||||
Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**.
|
Contributions are what makes the open source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**.
|
||||||
|
|
||||||
- Before jumping into a PR be sure to search [existing PRs](https://github.com/calcom/cal.com/pulls) or [issues](https://github.com/calcom/cal.com/issues) for an open or closed item that relates to your submission.
|
- Before jumping into a PR be sure to search [existing PRs](https://github.com/calcom/cal.com/pulls) or [issues](https://github.com/calcom/cal.com/issues) for an open or closed item that relates to your submission.
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ Contributions are what make the open source community such an amazing place to l
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
Core Features (Booking page, availabilty, timezone calculation)
|
Core Features (Booking page, availability, timezone calculation)
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<a href="https://github.com/calcom/cal.com/issues?q=is:issue+is:open+sort:updated-desc+label:%22High+priority%22">
|
<a href="https://github.com/calcom/cal.com/issues?q=is:issue+is:open+sort:updated-desc+label:%22High+priority%22">
|
||||||
@@ -132,7 +132,7 @@ If you get errors, be sure to fix them before committing.
|
|||||||
|
|
||||||
## Making a Pull Request
|
## Making a Pull Request
|
||||||
|
|
||||||
- Be sure to [check the "Allow edits from maintainers" option](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/allowing-changes-to-a-pull-request-branch-created-from-a-fork) while creating you PR.
|
- Be sure to [check the "Allow edits from maintainers" option](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/allowing-changes-to-a-pull-request-branch-created-from-a-fork) while creating your PR.
|
||||||
- If your PR refers to or fixes an issue, be sure to add `refs #XXX` or `fixes #XXX` to the PR description. Replacing `XXX` with the respective issue number. See more about [Linking a pull request to an issue
|
- If your PR refers to or fixes an issue, be sure to add `refs #XXX` or `fixes #XXX` to the PR description. Replacing `XXX` with the respective issue number. See more about [Linking a pull request to an issue](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue).
|
||||||
](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue).
|
|
||||||
- Be sure to fill the PR Template accordingly.
|
- Be sure to fill the PR Template accordingly.
|
||||||
|
- Review [App Contribution Guidelines](./packages/app-store/CONTRIBUTING.md) when building integrations
|
||||||
@@ -221,6 +221,7 @@ echo 'NEXT_PUBLIC_DEBUG=1' >> .env
|
|||||||
```
|
```
|
||||||
|
|
||||||
1. Run [mailhog](https://github.com/mailhog/MailHog) to view emails sent during development
|
1. Run [mailhog](https://github.com/mailhog/MailHog) to view emails sent during development
|
||||||
|
> **_NOTE:_** Required when `E2E_TEST_MAILHOG_ENABLED` is "1"
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker pull mailhog/mailhog
|
docker pull mailhog/mailhog
|
||||||
@@ -422,7 +423,7 @@ yarn seed-app-store
|
|||||||
```
|
```
|
||||||
|
|
||||||
You will need to complete a few more steps to activate Google Calendar App.
|
You will need to complete a few more steps to activate Google Calendar App.
|
||||||
Make sure to complete section "Obtaining the Google API Credentials". After the do the
|
Make sure to complete section "Obtaining the Google API Credentials". After that do the
|
||||||
following
|
following
|
||||||
|
|
||||||
1. Add extra redirect URL `<Cal.com URL>/api/auth/callback/google`
|
1. Add extra redirect URL `<Cal.com URL>/api/auth/callback/google`
|
||||||
@@ -448,8 +449,8 @@ following
|
|||||||
7. Click "Create".
|
7. Click "Create".
|
||||||
8. Now copy the Client ID and Client Secret to your `.env` file into the `ZOOM_CLIENT_ID` and `ZOOM_CLIENT_SECRET` fields.
|
8. Now copy the Client ID and Client Secret to your `.env` file into the `ZOOM_CLIENT_ID` and `ZOOM_CLIENT_SECRET` fields.
|
||||||
9. Set the Redirect URL for OAuth `<Cal.com URL>/api/integrations/zoomvideo/callback` replacing Cal.com URL with the URI at which your application runs.
|
9. Set the Redirect URL for OAuth `<Cal.com URL>/api/integrations/zoomvideo/callback` replacing Cal.com URL with the URI at which your application runs.
|
||||||
10. Also add the redirect URL given above as a allow list URL and enable "Subdomain check". Make sure, it says "saved" below the form.
|
10. Also add the redirect URL given above as an allow list URL and enable "Subdomain check". Make sure, it says "saved" below the form.
|
||||||
11. You don't need to provide basic information about your app. Instead click at "Scopes" and then at "+ Add Scopes". On the left, click the category "Meeting" and check the scope `meeting:write`.
|
11. You don't need to provide basic information about your app. Instead click on "Scopes" and then on "+ Add Scopes". On the left, click the category "Meeting" and check the scope `meeting:write`.
|
||||||
12. Click "Done".
|
12. Click "Done".
|
||||||
13. You're good to go. Now you can easily add your Zoom integration in the Cal.com settings.
|
13. You're good to go. Now you can easily add your Zoom integration in the Cal.com settings.
|
||||||
|
|
||||||
@@ -461,6 +462,18 @@ following
|
|||||||
4. Now paste the API key to your `.env` file into the `DAILY_API_KEY` field in your `.env` file.
|
4. Now paste the API key to your `.env` file into the `DAILY_API_KEY` field in your `.env` file.
|
||||||
5. If you have the [Daily Scale Plan](https://daily.co/pricing) set the `DAILY_SCALE_PLAN` variable to `true` in order to use features like video recording.
|
5. If you have the [Daily Scale Plan](https://daily.co/pricing) set the `DAILY_SCALE_PLAN` variable to `true` in order to use features like video recording.
|
||||||
|
|
||||||
|
### Obtaining Basecamp Client ID and Secret
|
||||||
|
|
||||||
|
1. Visit the [37 Signals Integrations Dashboard](launchpad.37signals.com/integrations) and sign in.
|
||||||
|
2. Register a new application by clicking the Register one now link.
|
||||||
|
3. Fill in your company details.
|
||||||
|
4. Select Basecamp 4 as the product to integrate with.
|
||||||
|
5. Set the Redirect URL for OAuth `<Cal.com URL>/api/integrations/basecamp3/callback` replacing Cal.com URL with the URI at which your application runs.
|
||||||
|
6. Click on done and copy the Client ID and secret into the `BASECAMP3_CLIENT_ID` and `BASECAMP3_CLIENT_SECRET` fields.
|
||||||
|
7. Set the `BASECAMP3_CLIENT_SECRET` env variable to `{your_domain} ({support_email})`.
|
||||||
|
For example, `Cal.com (support@cal.com)`.
|
||||||
|
|
||||||
|
|
||||||
### Obtaining HubSpot Client ID and Secret
|
### Obtaining HubSpot Client ID and Secret
|
||||||
|
|
||||||
1. Open [HubSpot Developer](https://developer.hubspot.com/) and sign into your account, or create a new one.
|
1. Open [HubSpot Developer](https://developer.hubspot.com/) and sign into your account, or create a new one.
|
||||||
@@ -513,7 +526,7 @@ following
|
|||||||
3. Copy Account SID to your `.env` file into the `TWILIO_SID` field
|
3. Copy Account SID to your `.env` file into the `TWILIO_SID` field
|
||||||
4. Copy Auth Token to your `.env` file into the `TWILIO_TOKEN` field
|
4. Copy Auth Token to your `.env` file into the `TWILIO_TOKEN` field
|
||||||
5. Copy your Twilio phone number to your `.env` file into the `TWILIO_PHONE_NUMBER` field
|
5. Copy your Twilio phone number to your `.env` file into the `TWILIO_PHONE_NUMBER` field
|
||||||
6. Add your own sender id to the `.env` file into the `NEXT_PUBLIC_SENDER_ID` field (fallback is Cal.com)
|
6. Add your own sender ID to the `.env` file into the `NEXT_PUBLIC_SENDER_ID` field (fallback is Cal.com)
|
||||||
7. Create a messaging service (Develop -> Messaging -> Services)
|
7. Create a messaging service (Develop -> Messaging -> Services)
|
||||||
8. Choose any name for the messaging service
|
8. Choose any name for the messaging service
|
||||||
9. Click 'Add Senders'
|
9. Click 'Add Senders'
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
BACKEND_URL=http://localhost:3002/api
|
||||||
|
|
||||||
|
APP_ID=cal-ai
|
||||||
|
APP_URL=http://localhost:3000/apps/cal-ai
|
||||||
|
|
||||||
|
# Used to verify requests from sendgrid. You can generate a new one with: `openssl rand -hex 32`
|
||||||
|
PARSE_KEY=
|
||||||
|
|
||||||
|
OPENAI_API_KEY=
|
||||||
|
|
||||||
|
# Optionally trace completions at https://smith.langchain.com
|
||||||
|
# LANGCHAIN_TRACING_V2=true
|
||||||
|
# LANGCHAIN_ENDPOINT=
|
||||||
|
# LANGCHAIN_API_KEY=
|
||||||
|
# LANGCHAIN_PROJECT=
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# Cal.com Email Assistant
|
||||||
|
|
||||||
|
Welcome to the first stage of Cal AI!
|
||||||
|
|
||||||
|
This app lets you chat with your calendar via email:
|
||||||
|
|
||||||
|
- Turn informal emails into bookings eg. forward "wanna meet tmrw at 2pm?"
|
||||||
|
- List and rearrange your bookings eg. "Cancel my next meeting"
|
||||||
|
- Answer basic questions about your busiest times eg. "How does my Tuesday look?"
|
||||||
|
|
||||||
|
The core logic is contained in [agent/route.ts](/apps/ai/src/app/api/agent/route.ts). Here, a [LangChain Agent Executor](https://docs.langchain.com/docs/components/agents/agent-executor) is tasked with following your instructions. Given your last-known timezone, working hours, and busy times, it attempts to CRUD your bookings.
|
||||||
|
|
||||||
|
_The AI agent can only choose from a set of tools, without ever seeing your API key._
|
||||||
|
|
||||||
|
Emails are cleaned and routed in [receive/route.ts](/apps/ai/src/app/api/receive/route.ts) using [MailParser](https://nodemailer.com/extras/mailparser/).
|
||||||
|
|
||||||
|
Incoming emails are routed by email address. Addresses are verified by [DKIM record](https://support.google.com/a/answer/174124?hl=en), making it hard to spoof them.
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
|
If you haven't yet, please run the [root setup](/README.md) steps.
|
||||||
|
|
||||||
|
Before running the app, please see [env.mjs](./src/env.mjs) for all required environment variables. You'll need:
|
||||||
|
|
||||||
|
- An [OpenAI API key](https://platform.openai.com/account/api-keys) with access to GPT-4
|
||||||
|
- A [SendGrid API key](https://app.sendgrid.com/settings/api_keys)
|
||||||
|
- A default sender email (for example, `ai@cal.dev`)
|
||||||
|
- The Cal AI's app ID and URL (see [add.ts](/packages/app-store/cal-ai/api/index.ts))
|
||||||
|
|
||||||
|
To stand up the API and AI apps simultaneously, simply run `yarn dev:ai`.
|
||||||
|
|
||||||
|
### Email Router
|
||||||
|
|
||||||
|
To expose the AI app, run `ngrok http 3000` (or the AI app's port number) in a new terminal. You may need to install [nGrok](https://ngrok.com/).
|
||||||
|
|
||||||
|
To forward incoming emails to the Node.js server, one option is to use [SendGrid's Inbound Parse Webhook](https://docs.sendgrid.com/for-developers/parsing-email/setting-up-the-inbound-parse-webhook).
|
||||||
|
|
||||||
|
1. [Sign up for an account](https://signup.sendgrid.com/)
|
||||||
|
2. Go to Settings > [Inbound Parse](https://app.sendgrid.com/settings/parse) > Add Host & URL.
|
||||||
|
3. For subdomain, use `<sub>.<domain>.com` for now, where `sub` can be any subdomain but `domain.com` will need to be verified via MX records in your environment variables, eg. on [Vercel](https://vercel.com/guides/how-to-add-vercel-environment-variables).
|
||||||
|
4. Use the nGrok URL from above as the **Destination URL**.
|
||||||
|
5. Activate "POST the raw, full MIME message".
|
||||||
|
6. Send an email to `<anyone>@ai.example.com`. You should see a ping on the nGrok listener and Node.js server.
|
||||||
|
7. Adjust the logic in [receive/route.ts](/apps/ai/src/app/api/receive/route.ts), save to hot-reload, and send another email to test the behaviour.
|
||||||
|
|
||||||
|
Please feel free to improve any part of this architecture.
|
||||||
Vendored
+5
@@ -0,0 +1,5 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
const withBundleAnalyzer = require("@next/bundle-analyzer");
|
||||||
|
|
||||||
|
const plugins = [];
|
||||||
|
plugins.push(withBundleAnalyzer({ enabled: process.env.ANALYZE === "true" }));
|
||||||
|
|
||||||
|
/** @type {import("next").NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
async redirects() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: "/",
|
||||||
|
destination: "https://cal.com/ai",
|
||||||
|
permanent: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
i18n: {
|
||||||
|
defaultLocale: "en",
|
||||||
|
locales: ["en"],
|
||||||
|
},
|
||||||
|
reactStrictMode: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = () => plugins.reduce((acc, next) => next(acc), nextConfig);
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "@calcom/ai",
|
||||||
|
"version": "1.0.1",
|
||||||
|
"private": true,
|
||||||
|
"author": "Cal.com Inc.",
|
||||||
|
"dependencies": {
|
||||||
|
"@calcom/prisma": "*",
|
||||||
|
"@t3-oss/env-nextjs": "^0.6.1",
|
||||||
|
"langchain": "^0.0.131",
|
||||||
|
"mailparser": "^3.6.5",
|
||||||
|
"next": "^13.4.6",
|
||||||
|
"supports-color": "8.1.1",
|
||||||
|
"zod": "^3.22.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/mailparser": "^3.4.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "next build",
|
||||||
|
"dev": "next dev -p 3005",
|
||||||
|
"format": "npx prettier . --write",
|
||||||
|
"lint": "eslint . --ext .ts,.js,.tsx,.jsx",
|
||||||
|
"lint:fix": "eslint . --ext .ts,.js,.tsx,.jsx --fix",
|
||||||
|
"start": "next start"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import agent from "../../../utils/agent";
|
||||||
|
import sendEmail from "../../../utils/sendEmail";
|
||||||
|
import { verifyParseKey } from "../../../utils/verifyParseKey";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Launches a LangChain agent to process an incoming email,
|
||||||
|
* then sends the response to the user.
|
||||||
|
*/
|
||||||
|
export const POST = async (request: NextRequest) => {
|
||||||
|
const verified = verifyParseKey(request.url);
|
||||||
|
|
||||||
|
if (!verified) {
|
||||||
|
return new NextResponse("Unauthorized", { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = await request.json();
|
||||||
|
|
||||||
|
const { apiKey, userId, message, subject, user, replyTo } = json;
|
||||||
|
|
||||||
|
if ((!message && !subject) || !user) {
|
||||||
|
return new NextResponse("Missing fields", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await agent(`${subject}\n\n${message}`, user, apiKey, userId);
|
||||||
|
|
||||||
|
// Send response to user
|
||||||
|
await sendEmail({
|
||||||
|
subject: `Re: ${subject}`,
|
||||||
|
text: response.replace(/(?:\r\n|\r|\n)/g, "\n"),
|
||||||
|
to: user.email,
|
||||||
|
from: replyTo,
|
||||||
|
});
|
||||||
|
|
||||||
|
return new NextResponse("ok");
|
||||||
|
} catch (error) {
|
||||||
|
return new NextResponse(
|
||||||
|
(error as Error).message || "Something went wrong. Please try again or reach out for help.",
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import type { ParsedMail, Source } from "mailparser";
|
||||||
|
import { simpleParser } from "mailparser";
|
||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import prisma from "@calcom/prisma";
|
||||||
|
|
||||||
|
import { env } from "../../../env.mjs";
|
||||||
|
import { fetchAvailability } from "../../../tools/getAvailability";
|
||||||
|
import { fetchEventTypes } from "../../../tools/getEventTypes";
|
||||||
|
import getHostFromHeaders from "../../../utils/host";
|
||||||
|
import now from "../../../utils/now";
|
||||||
|
import sendEmail from "../../../utils/sendEmail";
|
||||||
|
import { verifyParseKey } from "../../../utils/verifyParseKey";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies email signature and app authorization,
|
||||||
|
* then hands off to booking agent.
|
||||||
|
*/
|
||||||
|
export const POST = async (request: NextRequest) => {
|
||||||
|
const verified = verifyParseKey(request.url);
|
||||||
|
|
||||||
|
if (!verified) {
|
||||||
|
return new NextResponse("Unauthorized", { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const body = Object.fromEntries(formData);
|
||||||
|
|
||||||
|
// body.dkim looks like {@domain-com.22222222.gappssmtp.com : pass}
|
||||||
|
const signature = (body.dkim as string).includes(" : pass");
|
||||||
|
|
||||||
|
const envelope = JSON.parse(body.envelope as string);
|
||||||
|
|
||||||
|
const aiEmail = envelope.to[0];
|
||||||
|
|
||||||
|
// Parse email from mixed MIME type
|
||||||
|
const parsed: ParsedMail = await simpleParser(body.email as Source);
|
||||||
|
|
||||||
|
if (!parsed.text && !parsed.subject) {
|
||||||
|
return new NextResponse("Email missing text and subject", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
select: {
|
||||||
|
email: true,
|
||||||
|
id: true,
|
||||||
|
timeZone: true,
|
||||||
|
credentials: {
|
||||||
|
select: {
|
||||||
|
appId: true,
|
||||||
|
key: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
where: { email: envelope.from },
|
||||||
|
});
|
||||||
|
|
||||||
|
// User is not a cal.com user or is using an unverified email.
|
||||||
|
if (!signature || !user) {
|
||||||
|
await sendEmail({
|
||||||
|
html: `Thanks for your interest in Cal AI! To get started, Make sure you have a <a href="https://cal.com/signup" target="_blank">cal.com</a> account with this email address.`,
|
||||||
|
subject: `Re: ${body.subject}`,
|
||||||
|
text: `Thanks for your interest in Cal AI! To get started, Make sure you have a cal.com account with this email address. You can sign up for an account at: https://cal.com/signup`,
|
||||||
|
to: envelope.from,
|
||||||
|
from: aiEmail,
|
||||||
|
});
|
||||||
|
|
||||||
|
return new NextResponse("ok");
|
||||||
|
}
|
||||||
|
|
||||||
|
const credential = user.credentials.find((c) => c.appId === env.APP_ID)?.key;
|
||||||
|
|
||||||
|
// User has not installed the app from the app store. Direct them to install it.
|
||||||
|
if (!(credential as { apiKey: string })?.apiKey) {
|
||||||
|
const url = env.APP_URL;
|
||||||
|
|
||||||
|
await sendEmail({
|
||||||
|
html: `Thanks for using Cal AI! To get started, the app must be installed. <a href=${url} target="_blank">Click this link</a> to install it.`,
|
||||||
|
subject: `Re: ${body.subject}`,
|
||||||
|
text: `Thanks for using Cal AI! To get started, the app must be installed. Click this link to install the Cal AI app: ${url}`,
|
||||||
|
to: envelope.from,
|
||||||
|
from: aiEmail,
|
||||||
|
});
|
||||||
|
|
||||||
|
return new NextResponse("ok");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { apiKey } = credential as { apiKey: string };
|
||||||
|
|
||||||
|
// Pre-fetch data relevant to most bookings.
|
||||||
|
const [eventTypes, availability] = await Promise.all([
|
||||||
|
fetchEventTypes({
|
||||||
|
apiKey,
|
||||||
|
}),
|
||||||
|
fetchAvailability({
|
||||||
|
apiKey,
|
||||||
|
userId: user.id,
|
||||||
|
dateFrom: now(user.timeZone),
|
||||||
|
dateTo: now(user.timeZone),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ("error" in availability) {
|
||||||
|
await sendEmail({
|
||||||
|
subject: `Re: ${body.subject}`,
|
||||||
|
text: "Sorry, there was an error fetching your availability. Please try again.",
|
||||||
|
to: user.email,
|
||||||
|
from: aiEmail,
|
||||||
|
});
|
||||||
|
console.error(availability.error);
|
||||||
|
return new NextResponse("Error fetching availability. Please try again.", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("error" in eventTypes) {
|
||||||
|
await sendEmail({
|
||||||
|
subject: `Re: ${body.subject}`,
|
||||||
|
text: "Sorry, there was an error fetching your event types. Please try again.",
|
||||||
|
to: user.email,
|
||||||
|
from: aiEmail,
|
||||||
|
});
|
||||||
|
console.error(eventTypes.error);
|
||||||
|
return new NextResponse("Error fetching event types. Please try again.", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { workingHours } = availability;
|
||||||
|
|
||||||
|
const appHost = getHostFromHeaders(request.headers);
|
||||||
|
|
||||||
|
// Hand off to long-running agent endpoint to handle the email. (don't await)
|
||||||
|
fetch(`${appHost}/api/agent?parseKey=${env.PARSE_KEY}`, {
|
||||||
|
body: JSON.stringify({
|
||||||
|
apiKey,
|
||||||
|
userId: user.id,
|
||||||
|
message: parsed.text,
|
||||||
|
subject: parsed.subject,
|
||||||
|
replyTo: aiEmail,
|
||||||
|
user: {
|
||||||
|
email: user.email,
|
||||||
|
eventTypes,
|
||||||
|
timeZone: user.timeZone,
|
||||||
|
workingHours,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 1000));
|
||||||
|
|
||||||
|
return new NextResponse("ok");
|
||||||
|
};
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { createEnv } from "@t3-oss/env-nextjs";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const env = createEnv({
|
||||||
|
/**
|
||||||
|
* Specify your client-side environment variables schema here. This way you can ensure the app
|
||||||
|
* isn't built with invalid env vars. To expose them to the client, prefix them with
|
||||||
|
* `NEXT_PUBLIC_`.
|
||||||
|
*/
|
||||||
|
client: {
|
||||||
|
// NEXT_PUBLIC_CLIENTVAR: z.string().min(1),
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g.
|
||||||
|
* middlewares) or client-side so we need to destruct manually.
|
||||||
|
*/
|
||||||
|
runtimeEnv: {
|
||||||
|
BACKEND_URL: process.env.BACKEND_URL,
|
||||||
|
APP_ID: process.env.APP_ID,
|
||||||
|
APP_URL: process.env.APP_URL,
|
||||||
|
PARSE_KEY: process.env.PARSE_KEY,
|
||||||
|
NODE_ENV: process.env.NODE_ENV,
|
||||||
|
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
|
||||||
|
SENDGRID_API_KEY: process.env.SENDGRID_API_KEY,
|
||||||
|
DATABASE_URL: process.env.DATABASE_URL,
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Specify your server-side environment variables schema here. This way you can ensure the app
|
||||||
|
* isn't built with invalid env vars.
|
||||||
|
*/
|
||||||
|
server: {
|
||||||
|
BACKEND_URL: z.string().url(),
|
||||||
|
APP_ID: z.string().min(1),
|
||||||
|
APP_URL: z.string().url(),
|
||||||
|
PARSE_KEY: z.string().min(1),
|
||||||
|
NODE_ENV: z.enum(["development", "test", "production"]),
|
||||||
|
OPENAI_API_KEY: z.string().min(1),
|
||||||
|
SENDGRID_API_KEY: z.string().min(1),
|
||||||
|
DATABASE_URL: z.string().url(),
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { DynamicStructuredTool } from "langchain/tools";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { env } from "../env.mjs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a booking for a user by event type, times, and timezone.
|
||||||
|
*/
|
||||||
|
const createBooking = async ({
|
||||||
|
apiKey,
|
||||||
|
userId,
|
||||||
|
eventTypeId,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
timeZone,
|
||||||
|
language,
|
||||||
|
responses,
|
||||||
|
}: {
|
||||||
|
apiKey: string;
|
||||||
|
userId: number;
|
||||||
|
eventTypeId: number;
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
timeZone: string;
|
||||||
|
language: string;
|
||||||
|
responses: { name?: string; email?: string; location?: string };
|
||||||
|
title?: string;
|
||||||
|
status?: string;
|
||||||
|
}): Promise<string | Error | { error: string }> => {
|
||||||
|
const params = {
|
||||||
|
apiKey,
|
||||||
|
userId: userId.toString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const urlParams = new URLSearchParams(params);
|
||||||
|
|
||||||
|
const url = `${env.BACKEND_URL}/bookings?${urlParams.toString()}`;
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
body: JSON.stringify({
|
||||||
|
end,
|
||||||
|
eventTypeId,
|
||||||
|
language,
|
||||||
|
metadata: {},
|
||||||
|
responses,
|
||||||
|
start,
|
||||||
|
timeZone,
|
||||||
|
}),
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Let GPT handle this. This will happen when wrong event type id is used.
|
||||||
|
// if (response.status === 401) throw new Error("Unauthorized");
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.status !== 200) {
|
||||||
|
return {
|
||||||
|
error: data.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Booking created";
|
||||||
|
};
|
||||||
|
|
||||||
|
const createBookingTool = (apiKey: string, userId: number) => {
|
||||||
|
return new DynamicStructuredTool({
|
||||||
|
description:
|
||||||
|
"Tries to create a booking. If the user is unavailable, it will return availability that day, allowing you to avoid the getAvailability step in many cases.",
|
||||||
|
func: async ({ eventTypeId, start, end, timeZone, language, responses, title, status }) => {
|
||||||
|
return JSON.stringify(
|
||||||
|
await createBooking({
|
||||||
|
apiKey,
|
||||||
|
userId,
|
||||||
|
end,
|
||||||
|
eventTypeId,
|
||||||
|
language,
|
||||||
|
responses,
|
||||||
|
start,
|
||||||
|
status,
|
||||||
|
timeZone,
|
||||||
|
title,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
name: "createBookingIfAvailable",
|
||||||
|
schema: z.object({
|
||||||
|
end: z
|
||||||
|
.string()
|
||||||
|
.describe("This should correspond to the event type's length, unless otherwise specified."),
|
||||||
|
eventTypeId: z.number(),
|
||||||
|
language: z.string(),
|
||||||
|
responses: z
|
||||||
|
.object({
|
||||||
|
email: z.string().optional(),
|
||||||
|
name: z.string().optional(),
|
||||||
|
})
|
||||||
|
.describe("External invited user. Not the user making the request."),
|
||||||
|
start: z.string(),
|
||||||
|
status: z.string().optional().describe("ACCEPTED, PENDING, CANCELLED or REJECTED"),
|
||||||
|
timeZone: z.string(),
|
||||||
|
title: z.string().optional(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default createBookingTool;
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { DynamicStructuredTool } from "langchain/tools";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { env } from "../env.mjs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancels a booking for a user by ID with reason.
|
||||||
|
*/
|
||||||
|
const cancelBooking = async ({
|
||||||
|
apiKey,
|
||||||
|
id,
|
||||||
|
reason,
|
||||||
|
}: {
|
||||||
|
apiKey: string;
|
||||||
|
id: string;
|
||||||
|
reason: string;
|
||||||
|
}): Promise<string | { error: string }> => {
|
||||||
|
const params = {
|
||||||
|
apiKey,
|
||||||
|
};
|
||||||
|
|
||||||
|
const urlParams = new URLSearchParams(params);
|
||||||
|
|
||||||
|
const url = `${env.BACKEND_URL}/bookings/${id}/cancel?${urlParams.toString()}`;
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
body: JSON.stringify({ reason }),
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Let GPT handle this. This will happen when wrong booking id is used.
|
||||||
|
// if (response.status === 401) throw new Error("Unauthorized");
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.status !== 200) {
|
||||||
|
return { error: data.message };
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Booking cancelled";
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelBookingTool = (apiKey: string) => {
|
||||||
|
return new DynamicStructuredTool({
|
||||||
|
description: "Cancel a booking",
|
||||||
|
func: async ({ id, reason }) => {
|
||||||
|
return JSON.stringify(
|
||||||
|
await cancelBooking({
|
||||||
|
apiKey,
|
||||||
|
id,
|
||||||
|
reason,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
name: "cancelBooking",
|
||||||
|
schema: z.object({
|
||||||
|
id: z.string(),
|
||||||
|
reason: z.string(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default cancelBookingTool;
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { DynamicStructuredTool } from "langchain/tools";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { env } from "../env.mjs";
|
||||||
|
import type { Availability } from "../types/availability";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches availability for a user by date range and event type.
|
||||||
|
*/
|
||||||
|
export const fetchAvailability = async ({
|
||||||
|
apiKey,
|
||||||
|
userId,
|
||||||
|
dateFrom,
|
||||||
|
dateTo,
|
||||||
|
eventTypeId,
|
||||||
|
}: {
|
||||||
|
apiKey: string;
|
||||||
|
userId: number;
|
||||||
|
dateFrom: string;
|
||||||
|
dateTo: string;
|
||||||
|
eventTypeId?: number;
|
||||||
|
}): Promise<Partial<Availability> | { error: string }> => {
|
||||||
|
const params: { [k: string]: string } = {
|
||||||
|
apiKey,
|
||||||
|
userId: userId.toString(),
|
||||||
|
dateFrom,
|
||||||
|
dateTo,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (eventTypeId) params["eventTypeId"] = eventTypeId.toString();
|
||||||
|
|
||||||
|
const urlParams = new URLSearchParams(params);
|
||||||
|
|
||||||
|
const url = `${env.BACKEND_URL}/availability?${urlParams.toString()}`;
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
|
||||||
|
if (response.status === 401) throw new Error("Unauthorized");
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.status !== 200) {
|
||||||
|
return { error: data.message };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
busy: data.busy,
|
||||||
|
dateRanges: data.dateRanges,
|
||||||
|
timeZone: data.timeZone,
|
||||||
|
workingHours: data.workingHours,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAvailabilityTool = (apiKey: string, userId: number) => {
|
||||||
|
return new DynamicStructuredTool({
|
||||||
|
description: "Get availability within range.",
|
||||||
|
func: async ({ dateFrom, dateTo, eventTypeId }) => {
|
||||||
|
return JSON.stringify(
|
||||||
|
await fetchAvailability({
|
||||||
|
apiKey,
|
||||||
|
userId,
|
||||||
|
dateFrom,
|
||||||
|
dateTo,
|
||||||
|
eventTypeId,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
name: "getAvailability",
|
||||||
|
schema: z.object({
|
||||||
|
dateFrom: z.string(),
|
||||||
|
dateTo: z.string(),
|
||||||
|
eventTypeId: z
|
||||||
|
.number()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"The ID of the event type to filter availability for if you've called getEventTypes, otherwise do not include."
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default getAvailabilityTool;
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { DynamicStructuredTool } from "langchain/tools";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { env } from "../env.mjs";
|
||||||
|
import type { Booking } from "../types/booking";
|
||||||
|
import { BOOKING_STATUS } from "../types/booking";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches bookings for a user by date range.
|
||||||
|
*/
|
||||||
|
const fetchBookings = async ({
|
||||||
|
apiKey,
|
||||||
|
userId,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
}: {
|
||||||
|
apiKey: string;
|
||||||
|
userId: number;
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
}): Promise<Booking[] | { error: string }> => {
|
||||||
|
const params = {
|
||||||
|
apiKey,
|
||||||
|
userId: userId.toString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const urlParams = new URLSearchParams(params);
|
||||||
|
|
||||||
|
const url = `${env.BACKEND_URL}/bookings?${urlParams.toString()}`;
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
|
||||||
|
if (response.status === 401) throw new Error("Unauthorized");
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.status !== 200) {
|
||||||
|
return { error: data.message };
|
||||||
|
}
|
||||||
|
|
||||||
|
const bookings: Booking[] = data.bookings
|
||||||
|
.filter((booking: Booking) => {
|
||||||
|
const afterFrom = new Date(booking.startTime).getTime() > new Date(from).getTime();
|
||||||
|
const beforeTo = new Date(booking.endTime).getTime() < new Date(to).getTime();
|
||||||
|
const notCancelled = booking.status !== BOOKING_STATUS.CANCELLED;
|
||||||
|
|
||||||
|
return afterFrom && beforeTo && notCancelled;
|
||||||
|
})
|
||||||
|
.map(({ endTime, eventTypeId, id, startTime, status, title }: Booking) => ({
|
||||||
|
endTime,
|
||||||
|
eventTypeId,
|
||||||
|
id,
|
||||||
|
startTime,
|
||||||
|
status,
|
||||||
|
title,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return bookings;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getBookingsTool = (apiKey: string, userId: number) => {
|
||||||
|
return new DynamicStructuredTool({
|
||||||
|
description: "Get bookings for a user between two dates.",
|
||||||
|
func: async ({ from, to }) => {
|
||||||
|
return JSON.stringify(await fetchBookings({ apiKey, userId, from, to }));
|
||||||
|
},
|
||||||
|
name: "getBookings",
|
||||||
|
schema: z.object({
|
||||||
|
from: z.string().describe("ISO 8601 datetime string"),
|
||||||
|
to: z.string().describe("ISO 8601 datetime string"),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default getBookingsTool;
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { DynamicStructuredTool } from "langchain/tools";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { env } from "../env.mjs";
|
||||||
|
import type { EventType } from "../types/eventType";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches event types by user ID.
|
||||||
|
*/
|
||||||
|
export const fetchEventTypes = async ({ apiKey }: { apiKey: string }) => {
|
||||||
|
const params = {
|
||||||
|
apiKey,
|
||||||
|
};
|
||||||
|
|
||||||
|
const urlParams = new URLSearchParams(params);
|
||||||
|
|
||||||
|
const url = `${env.BACKEND_URL}/event-types?${urlParams.toString()}`;
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
|
||||||
|
if (response.status === 401) throw new Error("Unauthorized");
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.status !== 200) {
|
||||||
|
return { error: data.message };
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.event_types.map((eventType: EventType) => ({
|
||||||
|
id: eventType.id,
|
||||||
|
length: eventType.length,
|
||||||
|
title: eventType.title,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getEventTypesTool = (apiKey: string) => {
|
||||||
|
return new DynamicStructuredTool({
|
||||||
|
description: "Get the user's event type IDs. Usually necessary to book a meeting.",
|
||||||
|
func: async () => {
|
||||||
|
return JSON.stringify(
|
||||||
|
await fetchEventTypes({
|
||||||
|
apiKey,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
name: "getEventTypes",
|
||||||
|
schema: z.object({}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default getEventTypesTool;
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { DynamicStructuredTool } from "langchain/tools";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { env } from "../env.mjs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edits a booking for a user by booking ID with new times, title, description, or status.
|
||||||
|
*/
|
||||||
|
const editBooking = async ({
|
||||||
|
apiKey,
|
||||||
|
userId,
|
||||||
|
id,
|
||||||
|
startTime, // In the docs it says start, but it's startTime: https://cal.com/docs/enterprise-features/api/api-reference/bookings#edit-an-existing-booking.
|
||||||
|
endTime, // Same here: it says end but it's endTime.
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
status,
|
||||||
|
}: {
|
||||||
|
apiKey: string;
|
||||||
|
userId: number;
|
||||||
|
id: string;
|
||||||
|
startTime?: string;
|
||||||
|
endTime?: string;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
status?: string;
|
||||||
|
}): Promise<string | { error: string }> => {
|
||||||
|
const params = {
|
||||||
|
apiKey,
|
||||||
|
userId: userId.toString(),
|
||||||
|
};
|
||||||
|
const urlParams = new URLSearchParams(params);
|
||||||
|
|
||||||
|
const url = `${env.BACKEND_URL}/bookings/${id}?${urlParams.toString()}`;
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
body: JSON.stringify({ description, endTime, startTime, status, title }),
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
method: "PATCH",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Let GPT handle this. This will happen when wrong booking id is used.
|
||||||
|
// if (response.status === 401) throw new Error("Unauthorized");
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.status !== 200) {
|
||||||
|
return { error: data.message };
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Booking edited";
|
||||||
|
};
|
||||||
|
|
||||||
|
const editBookingTool = (apiKey: string, userId: number) => {
|
||||||
|
return new DynamicStructuredTool({
|
||||||
|
description: "Edit a booking",
|
||||||
|
func: async ({ description, endTime, id, startTime, status, title }) => {
|
||||||
|
return JSON.stringify(
|
||||||
|
await editBooking({
|
||||||
|
apiKey,
|
||||||
|
userId,
|
||||||
|
description,
|
||||||
|
endTime,
|
||||||
|
id,
|
||||||
|
startTime,
|
||||||
|
status,
|
||||||
|
title,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
name: "editBooking",
|
||||||
|
schema: z.object({
|
||||||
|
description: z.string().optional(),
|
||||||
|
endTime: z.string().optional(),
|
||||||
|
id: z.string(),
|
||||||
|
startTime: z.string().optional(),
|
||||||
|
status: z.string().optional(),
|
||||||
|
title: z.string().optional(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default editBookingTool;
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
export type Availability = {
|
||||||
|
busy: {
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
title?: string;
|
||||||
|
}[];
|
||||||
|
timeZone: string;
|
||||||
|
dateRanges: {
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
}[];
|
||||||
|
workingHours: {
|
||||||
|
days: number[];
|
||||||
|
startTime: number;
|
||||||
|
endTime: number;
|
||||||
|
userId: number;
|
||||||
|
}[];
|
||||||
|
dateOverrides: {
|
||||||
|
date: string;
|
||||||
|
startTime: number;
|
||||||
|
endTime: number;
|
||||||
|
userId: number;
|
||||||
|
};
|
||||||
|
currentSeats: number;
|
||||||
|
};
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
export enum BOOKING_STATUS {
|
||||||
|
ACCEPTED = "ACCEPTED",
|
||||||
|
PENDING = "PENDING",
|
||||||
|
CANCELLED = "CANCELLED",
|
||||||
|
REJECTED = "REJECTED",
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Booking = {
|
||||||
|
id: number;
|
||||||
|
userId: number;
|
||||||
|
description: string | null;
|
||||||
|
eventTypeId: number;
|
||||||
|
uid: string;
|
||||||
|
title: string;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
attendees: { email: string; name: string; timeZone: string; locale: string }[] | null;
|
||||||
|
user: { email: string; name: string; timeZone: string; locale: string }[] | null;
|
||||||
|
payment: { id: number; success: boolean; paymentOption: string }[];
|
||||||
|
metadata: object | null;
|
||||||
|
status: BOOKING_STATUS;
|
||||||
|
responses: { email: string; name: string; location: string } | null;
|
||||||
|
};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export type EventType = {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
length: number;
|
||||||
|
metadata: object;
|
||||||
|
slug: string;
|
||||||
|
hosts: {
|
||||||
|
userId: number;
|
||||||
|
isFixed: boolean;
|
||||||
|
}[];
|
||||||
|
hidden: boolean;
|
||||||
|
// ...
|
||||||
|
};
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type { EventType } from "./eventType";
|
||||||
|
import type { WorkingHours } from "./workingHours";
|
||||||
|
|
||||||
|
export type User = {
|
||||||
|
email: string;
|
||||||
|
timeZone: string;
|
||||||
|
eventTypes: EventType[];
|
||||||
|
workingHours: WorkingHours[];
|
||||||
|
};
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export type WorkingHours = {
|
||||||
|
days: number[];
|
||||||
|
startTime: number;
|
||||||
|
endTime: number;
|
||||||
|
};
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { initializeAgentExecutorWithOptions } from "langchain/agents";
|
||||||
|
import { ChatOpenAI } from "langchain/chat_models/openai";
|
||||||
|
|
||||||
|
import { env } from "../env.mjs";
|
||||||
|
import createBookingIfAvailable from "../tools/createBookingIfAvailable";
|
||||||
|
import deleteBooking from "../tools/deleteBooking";
|
||||||
|
import getAvailability from "../tools/getAvailability";
|
||||||
|
import getBookings from "../tools/getBookings";
|
||||||
|
import updateBooking from "../tools/updateBooking";
|
||||||
|
import type { EventType } from "../types/eventType";
|
||||||
|
import type { User } from "../types/user";
|
||||||
|
import type { WorkingHours } from "../types/workingHours";
|
||||||
|
import now from "./now";
|
||||||
|
|
||||||
|
const gptModel = "gpt-4";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core of the Cal AI booking agent: a LangChain Agent Executor.
|
||||||
|
* Uses a toolchain to book meetings, list available slots, etc.
|
||||||
|
* Uses OpenAI functions to better enforce JSON-parsable output from the LLM.
|
||||||
|
*/
|
||||||
|
const agent = async (input: string, user: User, apiKey: string, userId: number) => {
|
||||||
|
const tools = [
|
||||||
|
createBookingIfAvailable(apiKey, userId),
|
||||||
|
getAvailability(apiKey, userId),
|
||||||
|
getBookings(apiKey, userId),
|
||||||
|
updateBooking(apiKey, userId),
|
||||||
|
deleteBooking(apiKey),
|
||||||
|
];
|
||||||
|
|
||||||
|
const model = new ChatOpenAI({
|
||||||
|
modelName: gptModel,
|
||||||
|
openAIApiKey: env.OPENAI_API_KEY,
|
||||||
|
temperature: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the agent executor with arguments.
|
||||||
|
*/
|
||||||
|
const executor = await initializeAgentExecutorWithOptions(tools, model, {
|
||||||
|
agentArgs: {
|
||||||
|
prefix: `You are Cal AI - a bleeding edge scheduling assistant that interfaces via email.
|
||||||
|
Make sure your final answers are definitive, complete and well formatted.
|
||||||
|
Sometimes, tools return errors. In this case, try to handle the error intelligently or ask the user for more information.
|
||||||
|
Tools will always handle times in UTC, but times sent to the user should be formatted per that user's timezone.
|
||||||
|
|
||||||
|
The current time in the user's timezone is: ${now(user.timeZone)}
|
||||||
|
The user's time zone is: ${user.timeZone}
|
||||||
|
The user's event types are: ${user.eventTypes
|
||||||
|
.map((e: EventType) => `ID: ${e.id}, Title: ${e.title}, Length: ${e.length}`)
|
||||||
|
.join("\n")}
|
||||||
|
The user's working hours are: ${user.workingHours
|
||||||
|
.map(
|
||||||
|
(w: WorkingHours) =>
|
||||||
|
`Days: ${w.days.join(", ")}, Start Time (minutes in UTC): ${
|
||||||
|
w.startTime
|
||||||
|
}, End Time (minutes in UTC): ${w.endTime}`
|
||||||
|
)
|
||||||
|
.join("\n")}
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
agentType: "openai-functions",
|
||||||
|
returnIntermediateSteps: env.NODE_ENV === "development",
|
||||||
|
verbose: env.NODE_ENV === "development",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await executor.call({ input });
|
||||||
|
const { output } = result;
|
||||||
|
|
||||||
|
return output;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default agent;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
const getHostFromHeaders = (headers: NextRequest["headers"]): string => {
|
||||||
|
return `https://${headers.get("host")}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default getHostFromHeaders;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export default function now(timeZone: string) {
|
||||||
|
return new Date().toLocaleString("en-US", {
|
||||||
|
timeZone,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import mail from "@sendgrid/mail";
|
||||||
|
|
||||||
|
const sendgridAPIKey = process.env.SENDGRID_API_KEY as string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simply send an email by address, subject, and body.
|
||||||
|
*/
|
||||||
|
const send = async ({
|
||||||
|
subject,
|
||||||
|
to,
|
||||||
|
from,
|
||||||
|
text,
|
||||||
|
html,
|
||||||
|
}: {
|
||||||
|
subject: string;
|
||||||
|
to: string;
|
||||||
|
from: string;
|
||||||
|
text: string;
|
||||||
|
html?: string;
|
||||||
|
}): Promise<boolean> => {
|
||||||
|
mail.setApiKey(sendgridAPIKey);
|
||||||
|
|
||||||
|
const msg = {
|
||||||
|
to,
|
||||||
|
from: {
|
||||||
|
email: from,
|
||||||
|
name: "Cal AI",
|
||||||
|
},
|
||||||
|
text,
|
||||||
|
html,
|
||||||
|
subject,
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await mail.send(msg);
|
||||||
|
const success = !!res;
|
||||||
|
|
||||||
|
return success;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default send;
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { env } from "../env.mjs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies that the request contains the correct parse key.
|
||||||
|
* env.PARSE_KEY must be configured as a query param in the sendgrid inbound parse settings.
|
||||||
|
*/
|
||||||
|
export const verifyParseKey = (url: NextRequest["url"]) => {
|
||||||
|
const verified = new URL(url).searchParams.get("parseKey") === env.PARSE_KEY;
|
||||||
|
|
||||||
|
return verified;
|
||||||
|
};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"extends": "@calcom/tsconfig/nextjs.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"~/*": ["*"]
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { PrismaClient } from "@prisma/client";
|
|
||||||
import type { NextMiddleware } from "next-api-middleware";
|
import type { NextMiddleware } from "next-api-middleware";
|
||||||
|
|
||||||
import { CONSOLE_URL } from "@calcom/lib/constants";
|
import { CONSOLE_URL } from "@calcom/lib/constants";
|
||||||
|
import { customPrisma } from "@calcom/prisma";
|
||||||
|
|
||||||
const LOCAL_CONSOLE_URL = process.env.NEXT_PUBLIC_CONSOLE_URL || CONSOLE_URL;
|
const LOCAL_CONSOLE_URL = process.env.NEXT_PUBLIC_CONSOLE_URL || CONSOLE_URL;
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ export const customPrismaClient: NextMiddleware = async (req, res, next) => {
|
|||||||
} = req;
|
} = req;
|
||||||
// If no custom api Id is provided, attach to request the regular cal.com prisma client.
|
// If no custom api Id is provided, attach to request the regular cal.com prisma client.
|
||||||
if (!key) {
|
if (!key) {
|
||||||
req.prisma = new PrismaClient();
|
req.prisma = customPrisma();
|
||||||
await next();
|
await next();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -26,7 +26,7 @@ export const customPrismaClient: NextMiddleware = async (req, res, next) => {
|
|||||||
res.status(400).json({ error: "no databaseUrl set up at your instance yet" });
|
res.status(400).json({ error: "no databaseUrl set up at your instance yet" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
req.prisma = new PrismaClient({ datasources: { db: { url: databaseUrl } } });
|
req.prisma = customPrisma({ datasources: { db: { url: databaseUrl } } });
|
||||||
/* @note:
|
/* @note:
|
||||||
In order to skip verifyApiKey for customPrisma requests,
|
In order to skip verifyApiKey for customPrisma requests,
|
||||||
we pass isAdmin true, and userId 0, if we detect them later,
|
we pass isAdmin true, and userId 0, if we detect them later,
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export const schemaBookingReadPublic = Booking.extend({
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
.optional(),
|
.optional(),
|
||||||
|
responses: z.record(z.any()),
|
||||||
}).pick({
|
}).pick({
|
||||||
id: true,
|
id: true,
|
||||||
userId: true,
|
userId: true,
|
||||||
|
|||||||
@@ -33,12 +33,14 @@ export const schemaEventTypeBaseBodyParams = EventType.pick({
|
|||||||
position: true,
|
position: true,
|
||||||
eventName: true,
|
eventName: true,
|
||||||
timeZone: true,
|
timeZone: true,
|
||||||
|
schedulingType: true,
|
||||||
|
// START Limit future bookings
|
||||||
periodType: true,
|
periodType: true,
|
||||||
periodStartDate: true,
|
periodStartDate: true,
|
||||||
schedulingType: true,
|
|
||||||
periodEndDate: true,
|
periodEndDate: true,
|
||||||
periodDays: true,
|
periodDays: true,
|
||||||
periodCountCalendarDays: true,
|
periodCountCalendarDays: true,
|
||||||
|
// END Limit future bookings
|
||||||
requiresConfirmation: true,
|
requiresConfirmation: true,
|
||||||
disableGuests: true,
|
disableGuests: true,
|
||||||
hideCalendarNotes: true,
|
hideCalendarNotes: true,
|
||||||
@@ -51,6 +53,8 @@ export const schemaEventTypeBaseBodyParams = EventType.pick({
|
|||||||
slotInterval: true,
|
slotInterval: true,
|
||||||
successRedirectUrl: true,
|
successRedirectUrl: true,
|
||||||
locations: true,
|
locations: true,
|
||||||
|
bookingLimits: true,
|
||||||
|
durationLimits: true,
|
||||||
})
|
})
|
||||||
.merge(z.object({ hosts: z.array(hostSchema).optional().default([]) }))
|
.merge(z.object({ hosts: z.array(hostSchema).optional().default([]) }))
|
||||||
.partial()
|
.partial()
|
||||||
@@ -66,7 +70,9 @@ const schemaEventTypeCreateParams = z
|
|||||||
recurringEvent: recurringEventInputSchema.optional(),
|
recurringEvent: recurringEventInputSchema.optional(),
|
||||||
seatsPerTimeSlot: z.number().optional(),
|
seatsPerTimeSlot: z.number().optional(),
|
||||||
seatsShowAttendees: z.boolean().optional(),
|
seatsShowAttendees: z.boolean().optional(),
|
||||||
|
seatsShowAvailabilityCount: z.boolean().optional(),
|
||||||
bookingFields: eventTypeBookingFields.optional(),
|
bookingFields: eventTypeBookingFields.optional(),
|
||||||
|
scheduleId: z.number().optional(),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
@@ -84,7 +90,9 @@ const schemaEventTypeEditParams = z
|
|||||||
length: z.number().int().optional(),
|
length: z.number().int().optional(),
|
||||||
seatsPerTimeSlot: z.number().optional(),
|
seatsPerTimeSlot: z.number().optional(),
|
||||||
seatsShowAttendees: z.boolean().optional(),
|
seatsShowAttendees: z.boolean().optional(),
|
||||||
|
seatsShowAvailabilityCount: z.boolean().optional(),
|
||||||
bookingFields: eventTypeBookingFields.optional(),
|
bookingFields: eventTypeBookingFields.optional(),
|
||||||
|
scheduleId: z.number().optional(),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
@@ -98,6 +106,7 @@ export const schemaEventTypeReadPublic = EventType.pick({
|
|||||||
position: true,
|
position: true,
|
||||||
userId: true,
|
userId: true,
|
||||||
teamId: true,
|
teamId: true,
|
||||||
|
scheduleId: true,
|
||||||
eventName: true,
|
eventName: true,
|
||||||
timeZone: true,
|
timeZone: true,
|
||||||
periodType: true,
|
periodType: true,
|
||||||
@@ -122,7 +131,10 @@ export const schemaEventTypeReadPublic = EventType.pick({
|
|||||||
metadata: true,
|
metadata: true,
|
||||||
seatsPerTimeSlot: true,
|
seatsPerTimeSlot: true,
|
||||||
seatsShowAttendees: true,
|
seatsShowAttendees: true,
|
||||||
|
seatsShowAvailabilityCount: true,
|
||||||
bookingFields: true,
|
bookingFields: true,
|
||||||
|
bookingLimits: true,
|
||||||
|
durationLimits: true,
|
||||||
}).merge(
|
}).merge(
|
||||||
z.object({
|
z.object({
|
||||||
locations: z
|
locations: z
|
||||||
|
|||||||
@@ -13,16 +13,18 @@ const schemaMembershipRequiredParams = z.object({
|
|||||||
teamId: z.number(),
|
teamId: z.number(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const membershipCreateBodySchema = Membership.partial({
|
export const membershipCreateBodySchema = Membership.omit({ id: true })
|
||||||
accepted: true,
|
.partial({
|
||||||
role: true,
|
accepted: true,
|
||||||
disableImpersonation: true,
|
role: true,
|
||||||
}).transform((v) => ({
|
disableImpersonation: true,
|
||||||
accepted: false,
|
})
|
||||||
role: MembershipRole.MEMBER,
|
.transform((v) => ({
|
||||||
disableImpersonation: false,
|
accepted: false,
|
||||||
...v,
|
role: MembershipRole.MEMBER,
|
||||||
}));
|
disableImpersonation: false,
|
||||||
|
...v,
|
||||||
|
}));
|
||||||
|
|
||||||
export const membershipEditBodySchema = Membership.omit({
|
export const membershipEditBodySchema = Membership.omit({
|
||||||
/** To avoid complication, let's avoid updating these, instead you can delete and create a new invite */
|
/** To avoid complication, let's avoid updating these, instead you can delete and create a new invite */
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { withValidation } from "next-validations";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { baseApiParams } from "./baseApiParams";
|
||||||
|
|
||||||
|
// Extracted out as utility function so can be reused
|
||||||
|
// at different endpoints that require this validation.
|
||||||
|
export const schemaQueryUserEmail = baseApiParams.extend({
|
||||||
|
email: z.string().email(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const schemaQuerySingleOrMultipleUserEmails = z.object({
|
||||||
|
email: z.union([z.string().email(), z.array(z.string().email())]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const withValidQueryUserEmail = withValidation({
|
||||||
|
schema: schemaQueryUserEmail,
|
||||||
|
type: "Zod",
|
||||||
|
mode: "query",
|
||||||
|
});
|
||||||
@@ -29,7 +29,7 @@ enum locales {
|
|||||||
RO = "ro",
|
RO = "ro",
|
||||||
NL = "nl",
|
NL = "nl",
|
||||||
PT_BR = "pt-BR",
|
PT_BR = "pt-BR",
|
||||||
ES_419 = "es-419",
|
// ES_419 = "es-419", // Disabled until Crowdin reaches at least 80% completion
|
||||||
KO = "ko",
|
KO = "ko",
|
||||||
JA = "ja",
|
JA = "ja",
|
||||||
PL = "pl",
|
PL = "pl",
|
||||||
|
|||||||
Vendored
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
import type { Session } from "next-auth";
|
import type { Session } from "next-auth";
|
||||||
import type { NextApiRequest as BaseNextApiRequest } from "next/types";
|
import type { NextApiRequest as BaseNextApiRequest } from "next/types";
|
||||||
|
|
||||||
import type { PrismaClient } from "@calcom/prisma/client";
|
import type { PrismaClient } from "@calcom/prisma";
|
||||||
|
|
||||||
export type * from "next/types";
|
export type * from "next/types";
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,6 @@
|
|||||||
"typescript": "^4.9.4",
|
"typescript": "^4.9.4",
|
||||||
"tzdata": "^1.0.30",
|
"tzdata": "^1.0.30",
|
||||||
"uuid": "^8.3.2",
|
"uuid": "^8.3.2",
|
||||||
"zod": "^3.20.2"
|
"zod": "^3.22.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -94,6 +94,9 @@ import { defaultResponder } from "@calcom/lib/server";
|
|||||||
* seatsShowAttendees:
|
* seatsShowAttendees:
|
||||||
* type: boolean
|
* type: boolean
|
||||||
* description: 'Share Attendee information in seats'
|
* description: 'Share Attendee information in seats'
|
||||||
|
* seatsShowAvailabilityCount:
|
||||||
|
* type: boolean
|
||||||
|
* description: 'Show the number of available seats'
|
||||||
* smsReminderNumber:
|
* smsReminderNumber:
|
||||||
* type: number
|
* type: number
|
||||||
* description: 'SMS reminder number'
|
* description: 'SMS reminder number'
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import type { NextApiRequest } from "next";
|
import type { NextApiRequest } from "next";
|
||||||
|
|
||||||
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
import { defaultResponder } from "@calcom/lib/server";
|
import { defaultResponder } from "@calcom/lib/server";
|
||||||
|
|
||||||
import { schemaEventTypeReadPublic } from "~/lib/validations/event-type";
|
import { schemaEventTypeReadPublic } from "~/lib/validations/event-type";
|
||||||
import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt";
|
import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt";
|
||||||
|
import { checkPermissions as canAccessTeamEventOrThrow } from "~/pages/api/teams/[teamId]/_auth-middleware";
|
||||||
|
|
||||||
import getCalLink from "../_utils/getCalLink";
|
import getCalLink from "../_utils/getCalLink";
|
||||||
|
|
||||||
@@ -42,7 +44,8 @@ import getCalLink from "../_utils/getCalLink";
|
|||||||
export async function getHandler(req: NextApiRequest) {
|
export async function getHandler(req: NextApiRequest) {
|
||||||
const { prisma, query } = req;
|
const { prisma, query } = req;
|
||||||
const { id } = schemaQueryIdParseInt.parse(query);
|
const { id } = schemaQueryIdParseInt.parse(query);
|
||||||
const event_type = await prisma.eventType.findUnique({
|
|
||||||
|
const eventType = await prisma.eventType.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
customInputs: true,
|
customInputs: true,
|
||||||
@@ -51,10 +54,43 @@ export async function getHandler(req: NextApiRequest) {
|
|||||||
owner: { select: { username: true, id: true } },
|
owner: { select: { username: true, id: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
await checkPermissions(req, eventType);
|
||||||
|
|
||||||
const link = event_type ? getCalLink(event_type) : null;
|
const link = eventType ? getCalLink(eventType) : null;
|
||||||
|
// user.defaultScheduleId doesn't work the same for team events.
|
||||||
|
if (!eventType?.scheduleId && eventType?.userId && !eventType?.teamId) {
|
||||||
|
const user = await prisma.user.findUniqueOrThrow({
|
||||||
|
where: {
|
||||||
|
id: eventType.userId,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
defaultScheduleId: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
eventType.scheduleId = user.defaultScheduleId;
|
||||||
|
}
|
||||||
|
|
||||||
return { event_type: schemaEventTypeReadPublic.parse({ ...event_type, link }) };
|
// TODO: eventType when not found should be a 404
|
||||||
|
// but API consumers may depend on the {} behaviour.
|
||||||
|
return { event_type: schemaEventTypeReadPublic.parse({ ...eventType, link }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
type BaseEventTypeCheckPermissions = {
|
||||||
|
userId: number | null;
|
||||||
|
teamId: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function checkPermissions<T extends BaseEventTypeCheckPermissions>(
|
||||||
|
req: NextApiRequest,
|
||||||
|
eventType: (T & Partial<Omit<T, keyof BaseEventTypeCheckPermissions>>) | null
|
||||||
|
) {
|
||||||
|
if (req.isAdmin) return true;
|
||||||
|
if (eventType?.teamId) {
|
||||||
|
req.query.teamId = String(eventType.teamId);
|
||||||
|
await canAccessTeamEventOrThrow(req, "MEMBER");
|
||||||
|
}
|
||||||
|
if (eventType?.userId === req.userId) return true; // is owner.
|
||||||
|
throw new HttpError({ statusCode: 403, message: "Forbidden" });
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defaultResponder(getHandler);
|
export default defaultResponder(getHandler);
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Prisma } from "@prisma/client";
|
import { Prisma } from "@prisma/client";
|
||||||
import type { NextApiRequest } from "next";
|
import type { NextApiRequest } from "next";
|
||||||
import type { z } from "zod";
|
import type { z } from "zod";
|
||||||
|
|
||||||
@@ -52,6 +52,9 @@ import checkTeamEventEditPermission from "../_utils/checkTeamEventEditPermission
|
|||||||
* slug:
|
* slug:
|
||||||
* type: string
|
* type: string
|
||||||
* description: Unique slug for the event type
|
* description: Unique slug for the event type
|
||||||
|
* scheduleId:
|
||||||
|
* type: number
|
||||||
|
* description: The ID of the schedule for this event type
|
||||||
* hosts:
|
* hosts:
|
||||||
* type: array
|
* type: array
|
||||||
* items:
|
* items:
|
||||||
@@ -143,6 +146,9 @@ import checkTeamEventEditPermission from "../_utils/checkTeamEventEditPermission
|
|||||||
* seatsShowAttendees:
|
* seatsShowAttendees:
|
||||||
* type: boolean
|
* type: boolean
|
||||||
* description: 'Share Attendee information in seats'
|
* description: 'Share Attendee information in seats'
|
||||||
|
* seatsShowAvailabilityCount:
|
||||||
|
* type: boolean
|
||||||
|
* description: 'Show the number of available seats'
|
||||||
* locations:
|
* locations:
|
||||||
* type: array
|
* type: array
|
||||||
* description: A list of all available locations for the event type
|
* description: A list of all available locations for the event type
|
||||||
@@ -199,10 +205,17 @@ import checkTeamEventEditPermission from "../_utils/checkTeamEventEditPermission
|
|||||||
export async function patchHandler(req: NextApiRequest) {
|
export async function patchHandler(req: NextApiRequest) {
|
||||||
const { prisma, query, body } = req;
|
const { prisma, query, body } = req;
|
||||||
const { id } = schemaQueryIdParseInt.parse(query);
|
const { id } = schemaQueryIdParseInt.parse(query);
|
||||||
const { hosts = [], ...parsedBody } = schemaEventTypeEditBodyParams.parse(body);
|
const {
|
||||||
|
hosts = [],
|
||||||
|
bookingLimits,
|
||||||
|
durationLimits,
|
||||||
|
...parsedBody
|
||||||
|
} = schemaEventTypeEditBodyParams.parse(body);
|
||||||
|
|
||||||
const data: Prisma.EventTypeUpdateArgs["data"] = {
|
const data: Prisma.EventTypeUpdateArgs["data"] = {
|
||||||
...parsedBody,
|
...parsedBody,
|
||||||
|
bookingLimits: bookingLimits === null ? Prisma.DbNull : bookingLimits,
|
||||||
|
durationLimits: durationLimits === null ? Prisma.DbNull : durationLimits,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (hosts) {
|
if (hosts) {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { Prisma } from "@prisma/client";
|
|
||||||
import type { NextApiRequest } from "next";
|
import type { NextApiRequest } from "next";
|
||||||
|
|
||||||
import { HttpError } from "@calcom/lib/http-error";
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
import { defaultResponder } from "@calcom/lib/server";
|
import { defaultResponder } from "@calcom/lib/server";
|
||||||
|
import type { PrismaClient } from "@calcom/prisma";
|
||||||
|
|
||||||
import { schemaEventTypeReadPublic } from "~/lib/validations/event-type";
|
import { schemaEventTypeReadPublic } from "~/lib/validations/event-type";
|
||||||
import { schemaQuerySingleOrMultipleUserIds } from "~/lib/validations/shared/queryUserId";
|
import { schemaQuerySingleOrMultipleUserIds } from "~/lib/validations/shared/queryUserId";
|
||||||
@@ -35,20 +35,12 @@ import getCalLink from "./_utils/getCalLink";
|
|||||||
* description: No event types were found
|
* description: No event types were found
|
||||||
*/
|
*/
|
||||||
async function getHandler(req: NextApiRequest) {
|
async function getHandler(req: NextApiRequest) {
|
||||||
const { userId, isAdmin, prisma } = req;
|
const { userId, prisma } = req;
|
||||||
|
const userIds = req.query.userId ? extractUserIdsFromQuery(req) : [userId];
|
||||||
const args: Prisma.EventTypeFindManyArgs = {
|
|
||||||
where: { userId },
|
|
||||||
};
|
|
||||||
/** Only admins can query other users */
|
|
||||||
if (!isAdmin && req.query.userId) throw new HttpError({ statusCode: 401, message: "ADMIN required" });
|
|
||||||
if (isAdmin && req.query.userId) {
|
|
||||||
const query = schemaQuerySingleOrMultipleUserIds.parse(req.query);
|
|
||||||
const userIds = Array.isArray(query.userId) ? query.userId : [query.userId || userId];
|
|
||||||
args.where = { userId: { in: userIds } };
|
|
||||||
}
|
|
||||||
const data = await prisma.eventType.findMany({
|
const data = await prisma.eventType.findMany({
|
||||||
...args,
|
where: {
|
||||||
|
userId: { in: userIds },
|
||||||
|
},
|
||||||
include: {
|
include: {
|
||||||
customInputs: true,
|
customInputs: true,
|
||||||
team: { select: { slug: true } },
|
team: { select: { slug: true } },
|
||||||
@@ -56,13 +48,74 @@ async function getHandler(req: NextApiRequest) {
|
|||||||
owner: { select: { username: true, id: true } },
|
owner: { select: { username: true, id: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
// this really should return [], but backwards compatibility..
|
||||||
if (data.length === 0) new HttpError({ statusCode: 404, message: "No event types were found" });
|
if (data.length === 0) new HttpError({ statusCode: 404, message: "No event types were found" });
|
||||||
return {
|
return {
|
||||||
event_types: data.map((eventType) => {
|
event_types: (await defaultScheduleId<(typeof data)[number]>({ eventTypes: data, prisma, userIds })).map(
|
||||||
const link = getCalLink(eventType);
|
(eventType) => {
|
||||||
return schemaEventTypeReadPublic.parse({ ...eventType, link });
|
const link = getCalLink(eventType);
|
||||||
}),
|
return schemaEventTypeReadPublic.parse({ ...eventType, link });
|
||||||
|
}
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
// TODO: Extract & reuse.
|
||||||
|
function extractUserIdsFromQuery({ isAdmin, query }: NextApiRequest) {
|
||||||
|
/** Guard: Only admins can query other users */
|
||||||
|
if (!isAdmin) {
|
||||||
|
throw new HttpError({ statusCode: 401, message: "ADMIN required" });
|
||||||
|
}
|
||||||
|
const { userId: userIdOrUserIds } = schemaQuerySingleOrMultipleUserIds.parse(query);
|
||||||
|
return Array.isArray(userIdOrUserIds) ? userIdOrUserIds : [userIdOrUserIds];
|
||||||
|
}
|
||||||
|
|
||||||
|
type DefaultScheduleIdEventTypeBase = {
|
||||||
|
scheduleId: number | null;
|
||||||
|
userId: number | null;
|
||||||
|
};
|
||||||
|
// If an eventType is given w/o a scheduleId
|
||||||
|
// Then we associate the default user schedule id to the eventType
|
||||||
|
async function defaultScheduleId<T extends DefaultScheduleIdEventTypeBase>({
|
||||||
|
prisma,
|
||||||
|
eventTypes,
|
||||||
|
userIds,
|
||||||
|
}: {
|
||||||
|
prisma: PrismaClient;
|
||||||
|
eventTypes: (T & Partial<Omit<T, keyof DefaultScheduleIdEventTypeBase>>)[];
|
||||||
|
userIds: number[];
|
||||||
|
}) {
|
||||||
|
// there is no event types without a scheduleId, skip the user query
|
||||||
|
if (eventTypes.every((eventType) => eventType.scheduleId)) return eventTypes;
|
||||||
|
|
||||||
|
const users = await prisma.user.findMany({
|
||||||
|
where: {
|
||||||
|
id: {
|
||||||
|
in: userIds,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
defaultScheduleId: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!users.length) {
|
||||||
|
return eventTypes;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultScheduleIds = users.reduce((result, user) => {
|
||||||
|
result[user.id] = user.defaultScheduleId;
|
||||||
|
return result;
|
||||||
|
}, {} as { [x: number]: number | null });
|
||||||
|
|
||||||
|
return eventTypes.map((eventType) => {
|
||||||
|
// realistically never happens, userId should't be null on personal event types.
|
||||||
|
if (!eventType.userId) return eventType;
|
||||||
|
return {
|
||||||
|
...eventType,
|
||||||
|
scheduleId: eventType.scheduleId || defaultScheduleIds[eventType.userId],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export default defaultResponder(getHandler);
|
export default defaultResponder(getHandler);
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Prisma } from "@prisma/client";
|
import { Prisma } from "@prisma/client";
|
||||||
import type { NextApiRequest } from "next";
|
import type { NextApiRequest } from "next";
|
||||||
|
|
||||||
import { HttpError } from "@calcom/lib/http-error";
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
@@ -60,6 +60,9 @@ import ensureOnlyMembersAsHosts from "./_utils/ensureOnlyMembersAsHosts";
|
|||||||
* hidden:
|
* hidden:
|
||||||
* type: boolean
|
* type: boolean
|
||||||
* description: If the event type should be hidden from your public booking page
|
* description: If the event type should be hidden from your public booking page
|
||||||
|
* scheduleId:
|
||||||
|
* type: number
|
||||||
|
* description: The ID of the schedule for this event type
|
||||||
* position:
|
* position:
|
||||||
* type: integer
|
* type: integer
|
||||||
* description: The position of the event type on the public booking page
|
* description: The position of the event type on the public booking page
|
||||||
@@ -178,6 +181,7 @@ import ensureOnlyMembersAsHosts from "./_utils/ensureOnlyMembersAsHosts";
|
|||||||
* position: 0
|
* position: 0
|
||||||
* eventName: null
|
* eventName: null
|
||||||
* timeZone: null
|
* timeZone: null
|
||||||
|
* scheduleId: 5
|
||||||
* periodType: UNLIMITED
|
* periodType: UNLIMITED
|
||||||
* periodStartDate: 2023-02-15T08:46:16.000Z
|
* periodStartDate: 2023-02-15T08:46:16.000Z
|
||||||
* periodEndDate: 2023-0-15T08:46:16.000Z
|
* periodEndDate: 2023-0-15T08:46:16.000Z
|
||||||
@@ -255,12 +259,19 @@ import ensureOnlyMembersAsHosts from "./_utils/ensureOnlyMembersAsHosts";
|
|||||||
async function postHandler(req: NextApiRequest) {
|
async function postHandler(req: NextApiRequest) {
|
||||||
const { userId, isAdmin, prisma, body } = req;
|
const { userId, isAdmin, prisma, body } = req;
|
||||||
|
|
||||||
const { hosts = [], ...parsedBody } = schemaEventTypeCreateBodyParams.parse(body || {});
|
const {
|
||||||
|
hosts = [],
|
||||||
|
bookingLimits,
|
||||||
|
durationLimits,
|
||||||
|
...parsedBody
|
||||||
|
} = schemaEventTypeCreateBodyParams.parse(body || {});
|
||||||
|
|
||||||
let data: Prisma.EventTypeCreateArgs["data"] = {
|
let data: Prisma.EventTypeCreateArgs["data"] = {
|
||||||
...parsedBody,
|
...parsedBody,
|
||||||
userId,
|
userId,
|
||||||
users: { connect: { id: userId } },
|
users: { connect: { id: userId } },
|
||||||
|
bookingLimits: bookingLimits === null ? Prisma.DbNull : bookingLimits,
|
||||||
|
durationLimits: durationLimits === null ? Prisma.DbNull : durationLimits,
|
||||||
};
|
};
|
||||||
|
|
||||||
await checkPermissions(req);
|
await checkPermissions(req);
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ async function getHandler(req: NextApiRequest) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const data = await prisma.eventType.findMany(args);
|
const data = await prisma.eventType.findMany(args);
|
||||||
return { event_types: data.map((attendee) => schemaEventTypeReadPublic.parse(attendee)) };
|
return { event_types: data.map((eventType) => schemaEventTypeReadPublic.parse(eventType)) };
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defaultResponder(getHandler);
|
export default defaultResponder(getHandler);
|
||||||
@@ -4,6 +4,7 @@ import type { NextApiRequest } from "next";
|
|||||||
import { defaultResponder } from "@calcom/lib/server";
|
import { defaultResponder } from "@calcom/lib/server";
|
||||||
|
|
||||||
import { withMiddleware } from "~/lib/helpers/withMiddleware";
|
import { withMiddleware } from "~/lib/helpers/withMiddleware";
|
||||||
|
import { schemaQuerySingleOrMultipleUserEmails } from "~/lib/validations/shared/queryUserEmail";
|
||||||
import { schemaUsersReadPublic } from "~/lib/validations/user";
|
import { schemaUsersReadPublic } from "~/lib/validations/user";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -19,6 +20,17 @@ import { schemaUsersReadPublic } from "~/lib/validations/user";
|
|||||||
* schema:
|
* schema:
|
||||||
* type: string
|
* type: string
|
||||||
* description: Your API key
|
* description: Your API key
|
||||||
|
* - in: query
|
||||||
|
* name: email
|
||||||
|
* required: false
|
||||||
|
* schema:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* type: string
|
||||||
|
* format: email
|
||||||
|
* style: form
|
||||||
|
* explode: true
|
||||||
|
* description: The email address or an array of email addresses to filter by
|
||||||
* tags:
|
* tags:
|
||||||
* - users
|
* - users
|
||||||
* responses:
|
* responses:
|
||||||
@@ -39,6 +51,14 @@ export async function getHandler(req: NextApiRequest) {
|
|||||||
const where: Prisma.UserWhereInput = {};
|
const where: Prisma.UserWhereInput = {};
|
||||||
// If user is not ADMIN, return only his data.
|
// If user is not ADMIN, return only his data.
|
||||||
if (!isAdmin) where.id = userId;
|
if (!isAdmin) where.id = userId;
|
||||||
|
|
||||||
|
if (req.query.email) {
|
||||||
|
const validationResult = schemaQuerySingleOrMultipleUserEmails.parse(req.query);
|
||||||
|
where.email = {
|
||||||
|
in: Array.isArray(validationResult.email) ? validationResult.email : [validationResult.email],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const [total, data] = await prisma.$transaction([
|
const [total, data] = await prisma.$transaction([
|
||||||
prisma.user.count({ where }),
|
prisma.user.count({ where }),
|
||||||
prisma.user.findMany({ where, take, skip }),
|
prisma.user.findMany({ where, take, skip }),
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Set the version of docker compose to use
|
# Set the version of docker compose to use
|
||||||
version: '3.9'
|
version: "3.9"
|
||||||
|
|
||||||
# The containers that compose the project
|
# The containers that compose the project
|
||||||
services:
|
services:
|
||||||
@@ -8,7 +8,7 @@ services:
|
|||||||
restart: always
|
restart: always
|
||||||
container_name: integration-tests-prisma
|
container_name: integration-tests-prisma
|
||||||
ports:
|
ports:
|
||||||
- '5433:5432'
|
- "5433:5432"
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: prisma
|
POSTGRES_USER: prisma
|
||||||
POSTGRES_PASSWORD: prisma
|
POSTGRES_PASSWORD: prisma
|
||||||
|
|||||||
@@ -1,9 +1,29 @@
|
|||||||
import i18n from "i18next";
|
import i18n from "i18next";
|
||||||
import { initReactI18next } from "react-i18next";
|
import { initReactI18next } from "react-i18next";
|
||||||
|
|
||||||
|
const ns = ["common"];
|
||||||
|
const supportedLngs = ["en", "fr"];
|
||||||
|
const resources = ns.reduce((acc, n) => {
|
||||||
|
supportedLngs.forEach((lng) => {
|
||||||
|
if (!acc[lng]) acc[lng] = {};
|
||||||
|
acc[lng] = {
|
||||||
|
...acc[lng],
|
||||||
|
[n]: require(`../../web/public/static/locales/${lng}/${n}.json`),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
i18n.use(initReactI18next).init({
|
i18n.use(initReactI18next).init({
|
||||||
resources: [],
|
|
||||||
debug: true,
|
debug: true,
|
||||||
|
fallbackLng: "en",
|
||||||
|
defaultNS: "common",
|
||||||
|
ns,
|
||||||
|
interpolation: {
|
||||||
|
escapeValue: false,
|
||||||
|
},
|
||||||
|
react: { useSuspense: true },
|
||||||
|
resources,
|
||||||
});
|
});
|
||||||
|
|
||||||
export default i18n;
|
export default i18n;
|
||||||
@@ -15,6 +15,7 @@ module.exports = {
|
|||||||
"storybook-addon-rtl-direction",
|
"storybook-addon-rtl-direction",
|
||||||
"storybook-react-i18next",
|
"storybook-react-i18next",
|
||||||
"storybook-addon-next",
|
"storybook-addon-next",
|
||||||
|
"storybook-addon-next-router",
|
||||||
/*{
|
/*{
|
||||||
name: "storybook-addon-next",
|
name: "storybook-addon-next",
|
||||||
options: {
|
options: {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<script>
|
<script>
|
||||||
window.global = window;
|
window.global = window;
|
||||||
window.isEmbed = ()=> {
|
window.isEmbed = () => {
|
||||||
return location.search.includes("embed=")
|
return location.search.includes("embed=");
|
||||||
}
|
};
|
||||||
</script>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@500&display=swap');
|
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@500&display=swap");
|
||||||
</style>
|
</style>
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { addDecorator } from "@storybook/react";
|
import { addDecorator } from "@storybook/react";
|
||||||
|
import { AppRouterContext } from "next/dist/shared/lib/app-router-context";
|
||||||
import { I18nextProvider } from "react-i18next";
|
import { I18nextProvider } from "react-i18next";
|
||||||
|
|
||||||
import "../styles/globals.css";
|
import "../styles/globals.css";
|
||||||
@@ -13,6 +14,21 @@ export const parameters = {
|
|||||||
date: /Date$/,
|
date: /Date$/,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
nextRouter: {
|
||||||
|
pathname: "/",
|
||||||
|
asPath: "/",
|
||||||
|
query: {},
|
||||||
|
push() {},
|
||||||
|
Provider: AppRouterContext.Provider,
|
||||||
|
},
|
||||||
|
globals: {
|
||||||
|
locale: "en",
|
||||||
|
locales: {
|
||||||
|
en: "English",
|
||||||
|
fr: "Français",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
i18n,
|
||||||
};
|
};
|
||||||
|
|
||||||
addDecorator((storyFn) => (
|
addDecorator((storyFn) => (
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export function VariantsTable({
|
|||||||
const columns = React.Children.toArray(children) as ReactElement<RowProps>[];
|
const columns = React.Children.toArray(children) as ReactElement<RowProps>[];
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
id="light-variant"
|
||||||
className={classNames(
|
className={classNames(
|
||||||
isDark &&
|
isDark &&
|
||||||
"relative py-8 before:absolute before:left-0 before:top-0 before:block before:h-full before:w-screen before:bg-[#1C1C1C]"
|
"relative py-8 before:absolute before:left-0 before:top-0 before:block before:h-full before:w-screen before:bg-[#1C1C1C]"
|
||||||
@@ -43,7 +44,7 @@ export function VariantsTable({
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{!isDark && (
|
{!isDark && (
|
||||||
<div data-mode="dark" className="dark">
|
<div id="dark-variant" data-mode="dark" className="dark">
|
||||||
<VariantsTable titles={titles} isDark columnMinWidth={columnMinWidth}>
|
<VariantsTable titles={titles} isDark columnMinWidth={columnMinWidth}>
|
||||||
{children}
|
{children}
|
||||||
</VariantsTable>
|
</VariantsTable>
|
||||||
|
|||||||
@@ -1,11 +1,21 @@
|
|||||||
import { Meta } from '@storybook/addon-docs';
|
import { Meta } from "@storybook/addon-docs";
|
||||||
|
|
||||||
<Meta title="Introduction" />
|
<Meta title="Introduction" />
|
||||||
|
|
||||||
<div className="text-center flex flex-col items-center">
|
<div className="flex flex-col items-center text-center">
|
||||||
<h1 style={{marginBottom: '24px', marginTop: '36px'}}>Welcome to Cal.com UI</h1>
|
<h1 style={{ marginBottom: "24px", marginTop: "36px" }}>Welcome to Cal.com UI</h1>
|
||||||
<p>This is the beginning of our storybook improvements to match Figma as close as possible. Like our Figma library, we will be adding more components as we go along.</p>
|
<p>
|
||||||
<p>Our <a href="https://www.figma.com/file/9MOufQNLtdkpnDucmNX10R/%E2%9D%96-Cal-DS" target="_blank">Figma</a> library is avalible for anyone to view and use. If you have any questions or concerns, please reach out to the design team.</p>
|
This is the beginning of our storybook improvements to match Figma as close as possible. Like our Figma
|
||||||
|
library, we will be adding more components as we go along.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Our{" "}
|
||||||
|
<a href="https://www.figma.com/file/9MOufQNLtdkpnDucmNX10R/%E2%9D%96-Cal-DS" target="_blank">
|
||||||
|
Figma
|
||||||
|
</a>{" "}
|
||||||
|
library is available for anyone to view and use. If you have any questions or concerns, please reach out to
|
||||||
|
the design team.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<img src="/sb-cover.jpg" />
|
<img src="/sb-cover.jpg" />
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
"next": "^13.4.6",
|
"next": "^13.4.6",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
|
"storybook-addon-next-router": "^4.0.2",
|
||||||
"storybook-addon-rtl-direction": "^0.0.19"
|
"storybook-addon-rtl-direction": "^0.0.19"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
@import url("../../../packages/features/calendars/weeklyview/styles/styles.css");
|
@import url("../../../packages/features/calendars/weeklyview/styles/styles.css");
|
||||||
|
|
||||||
.sbdocs {
|
.sbdocs {
|
||||||
font-family: 'Inter var' !important;
|
font-family: "Inter var" !important;
|
||||||
padding: 0!important;
|
padding: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
#docs-root {
|
#docs-root {
|
||||||
@@ -21,8 +21,8 @@
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
border: none;
|
border: none;
|
||||||
border-top: 2px solid rgba(0, 0, 0, 0.12);
|
border-top: 2px solid rgba(0, 0, 0, 0.12);
|
||||||
padding-top: 12px!important;
|
padding-top: 12px !important;
|
||||||
padding-bottom: 12px!important;
|
padding-bottom: 12px !important;
|
||||||
margin: 82px 0 0 0;
|
margin: 82px 0 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Docs table **/
|
/** Docs table **/
|
||||||
.custom-args-wrapper{
|
.custom-args-wrapper {
|
||||||
max-height: 400px;
|
max-height: 400px;
|
||||||
overflow-y: scroll;
|
overflow-y: scroll;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.docblock-argstable-body {
|
.docblock-argstable-body {
|
||||||
box-shadow: none!important;
|
box-shadow: none !important;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,7 +64,7 @@
|
|||||||
|
|
||||||
.docblock-argstable-body div p,
|
.docblock-argstable-body div p,
|
||||||
.docblock-argstable-body div span {
|
.docblock-argstable-body div span {
|
||||||
color: #8F8F8F !important;
|
color: #8f8f8f !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Custom components **/
|
/** Custom components **/
|
||||||
@@ -83,8 +83,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.story-title h1 span {
|
.story-title h1 span {
|
||||||
color: #9CA3AF;
|
color: #9ca3af;
|
||||||
font-family: 'Inter var';
|
font-family: "Inter var";
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-left: 8px;
|
margin-left: 8px;
|
||||||
@@ -96,7 +96,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.examples {
|
.examples {
|
||||||
background-color: #F9FAFB;
|
background-color: #f9fafb;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -111,14 +111,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.examples-title {
|
.examples-title {
|
||||||
color: #8F8F8F;
|
color: #8f8f8f;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
margin-bottom: 30px;
|
margin-bottom: 30px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.examples-item-title {
|
.examples-item-title {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #8F8F8F;
|
color: #8f8f8f;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
@@ -130,7 +130,7 @@
|
|||||||
.examples-footnote p,
|
.examples-footnote p,
|
||||||
.examples-footnote ul,
|
.examples-footnote ul,
|
||||||
.examples-footnote li {
|
.examples-footnote li {
|
||||||
color: #8F8F8F;
|
color: #8f8f8f;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,11 +139,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.examples-footnote li {
|
.examples-footnote li {
|
||||||
margin: 0!important;
|
margin: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.story-note {
|
.story-note {
|
||||||
background-color: #F9FAFB;
|
background-color: #f9fafb;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
@@ -161,12 +161,12 @@
|
|||||||
box-shadow: none !important;
|
box-shadow: none !important;
|
||||||
margin: 0 !important;
|
margin: 0 !important;
|
||||||
border: none !important;
|
border: none !important;
|
||||||
border-radius: none!important;
|
border-radius: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.docs-story > div:first-child {
|
.docs-story > div:first-child {
|
||||||
padding: 0!important;
|
padding: 0 !important;
|
||||||
margin: 0!important;
|
margin: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.docs-story .innerZoomElementWrapper > div {
|
.docs-story .innerZoomElementWrapper > div {
|
||||||
@@ -174,7 +174,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sb-main-padded {
|
.sb-main-padded {
|
||||||
padding: 0!important;
|
padding: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 1200px) {
|
@media screen and (max-width: 1200px) {
|
||||||
@@ -198,83 +198,81 @@
|
|||||||
:root {
|
:root {
|
||||||
/* background */
|
/* background */
|
||||||
|
|
||||||
--cal-bg-emphasis: #E5E7EB;
|
--cal-bg-emphasis: #e5e7eb;
|
||||||
--cal-bg: white;
|
--cal-bg: white;
|
||||||
--cal-bg-subtle: #F3F4F6;
|
--cal-bg-subtle: #f3f4f6;
|
||||||
--cal-bg-muted: #F9FAFB;
|
--cal-bg-muted: #f9fafb;
|
||||||
--cal-bg-inverted: #111827;
|
--cal-bg-inverted: #111827;
|
||||||
|
|
||||||
/* background -> components*/
|
/* background -> components*/
|
||||||
--cal-bg-info: #DEE9FC;
|
--cal-bg-info: #dee9fc;
|
||||||
--cal-bg-success: #E2FBE8;
|
--cal-bg-success: #e2fbe8;
|
||||||
--cal-bg-attention: #FCEED8;
|
--cal-bg-attention: #fceed8;
|
||||||
--cal-bg-error: #F9E3E2;
|
--cal-bg-error: #f9e3e2;
|
||||||
--cal-bg-dark-error: #752522;
|
--cal-bg-dark-error: #752522;
|
||||||
|
|
||||||
|
|
||||||
/* Borders */
|
/* Borders */
|
||||||
--cal-border-emphasis:#9CA3AF;
|
--cal-border-emphasis: #9ca3af;
|
||||||
--cal-border: #D1D5DB;
|
--cal-border: #d1d5db;
|
||||||
--cal-border-subtle:#E5E7EB;
|
--cal-border-subtle: #e5e7eb;
|
||||||
--cal-border-muted:#F3F4F6;
|
--cal-border-muted: #f3f4f6;
|
||||||
--cal-border-error: #aa2e26;
|
--cal-border-error: #aa2e26;
|
||||||
|
|
||||||
/* Content/Text */
|
/* Content/Text */
|
||||||
--cal-text-emphasis: #111827;
|
--cal-text-emphasis: #111827;
|
||||||
--cal-text:#374151;
|
--cal-text: #374151;
|
||||||
--cal-text-subtle:#6B7280;
|
--cal-text-subtle: #6b7280;
|
||||||
--cal-text-muted:#9CA3AF;
|
--cal-text-muted: #9ca3af;
|
||||||
--cal-text-inverted:white;
|
--cal-text-inverted: white;
|
||||||
|
|
||||||
/* Content/Text -> components */
|
/* Content/Text -> components */
|
||||||
--cal-text-info:#253985;
|
--cal-text-info: #253985;
|
||||||
--cal-text-success:#285231;
|
--cal-text-success: #285231;
|
||||||
--cal-text-attention:#73321B;
|
--cal-text-attention: #73321b;
|
||||||
--cal-text-error:#752522;
|
--cal-text-error: #752522;
|
||||||
|
|
||||||
/* Brand shinanigans
|
/* Brand shinanigans
|
||||||
-> These will be computed for the users theme at runtime.
|
-> These will be computed for the users theme at runtime.
|
||||||
*/
|
*/
|
||||||
--cal-brand:#111827;
|
--cal-brand: #111827;
|
||||||
--cal-brand-emphasis:#101010;
|
--cal-brand-emphasis: #101010;
|
||||||
--cal-brand-text: white;
|
--cal-brand-text: white;
|
||||||
}
|
}
|
||||||
.dark {
|
.dark {
|
||||||
/* background */
|
/* background */
|
||||||
|
|
||||||
--cal-bg-emphasis: #2B2B2B;
|
--cal-bg-emphasis: #2b2b2b;
|
||||||
--cal-bg: #101010;
|
--cal-bg: #101010;
|
||||||
--cal-bg-subtle: #2B2B2B;
|
--cal-bg-subtle: #2b2b2b;
|
||||||
--cal-bg-muted: #1C1C1C;
|
--cal-bg-muted: #1c1c1c;
|
||||||
--cal-bg-inverted: #F3F4F6;
|
--cal-bg-inverted: #f3f4f6;
|
||||||
|
|
||||||
/* background -> components*/
|
/* background -> components*/
|
||||||
--cal-bg-info: #DEE9FC;
|
--cal-bg-info: #263fa9;
|
||||||
--cal-bg-success: #E2FBE8;
|
--cal-bg-success: #306339;
|
||||||
--cal-bg-attention: #FCEED8;
|
--cal-bg-attention: #8e3b1f;
|
||||||
--cal-bg-error: #F9E3E2;
|
--cal-bg-error: #8c2822;
|
||||||
--cal-bg-dark-error: #752522;
|
--cal-bg-dark-error: #752522;
|
||||||
|
|
||||||
|
|
||||||
/* Borders */
|
/* Borders */
|
||||||
--cal-border-emphasis: #575757;
|
--cal-border-emphasis: #575757;
|
||||||
--cal-border: #444444;
|
--cal-border: #444444;
|
||||||
--cal-border-subtle: #2B2B2B;
|
--cal-border-subtle: #2b2b2b;
|
||||||
--cal-border-muted: #1C1C1C;
|
--cal-border-muted: #1c1c1c;
|
||||||
--cal-border-error: #aa2e26;
|
--cal-border-error: #aa2e26;
|
||||||
|
|
||||||
/* Content/Text */
|
/* Content/Text */
|
||||||
--cal-text-emphasis: #F3F4F6;
|
--cal-text-emphasis: #f3f4f6;
|
||||||
--cal-text: #D6D6D6;
|
--cal-text: #d6d6d6;
|
||||||
--cal-text-subtle: #757575;
|
--cal-text-subtle: #757575;
|
||||||
--cal-text-muted: #575757;
|
--cal-text-muted: #575757;
|
||||||
--cal-text-inverted: #101010;
|
--cal-text-inverted: #101010;
|
||||||
|
|
||||||
/* Content/Text -> components */
|
/* Content/Text -> components */
|
||||||
--cal-text-info: #253985;
|
--cal-text-info: #dee9fc;
|
||||||
--cal-text-success: #285231;
|
--cal-text-success: #e2fbe8;
|
||||||
--cal-text-attention: #73321B;
|
--cal-text-attention: #fceed8;
|
||||||
--cal-text-error: #752522;
|
--cal-text-error: #f9e3e2;
|
||||||
|
|
||||||
/* Brand shenanigans
|
/* Brand shenanigans
|
||||||
-> These will be computed for the users theme at runtime.
|
-> These will be computed for the users theme at runtime.
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ html,
|
|||||||
body {
|
body {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen,
|
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell, Fira Sans,
|
||||||
Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
|
Droid Sans, Helvetica Neue, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
@@ -21,7 +21,7 @@ a {
|
|||||||
}
|
}
|
||||||
.swagger-ui .opblock .opblock-summary {
|
.swagger-ui .opblock .opblock-summary {
|
||||||
display: grid;
|
display: grid;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
.opblock-summary-path {
|
.opblock-summary-path {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -43,16 +43,16 @@ a {
|
|||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
}
|
}
|
||||||
.swagger-ui .scheme-container {
|
.swagger-ui .scheme-container {
|
||||||
padding: 14px 0;
|
padding: 14px 0;
|
||||||
}
|
}
|
||||||
.swagger-ui .info {
|
.swagger-ui .info {
|
||||||
margin: 10px 0;
|
margin: 10px 0;
|
||||||
}
|
}
|
||||||
.swagger-ui .auth-wrapper {
|
.swagger-ui .auth-wrapper {
|
||||||
margin: 10px 0;
|
margin: 10px 0;
|
||||||
}
|
}
|
||||||
.swagger-ui .authorization__btn {
|
.swagger-ui .authorization__btn {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
.swagger-ui .opblock {
|
.swagger-ui .opblock {
|
||||||
margin: 0 0 5px;
|
margin: 0 0 5px;
|
||||||
@@ -73,7 +73,8 @@ a {
|
|||||||
.swagger-ui .info .title small {
|
.swagger-ui .info .title small {
|
||||||
top: 5px;
|
top: 5px;
|
||||||
}
|
}
|
||||||
.swagger-ui a.nostyle, .swagger-ui a.nostyle:visited {
|
.swagger-ui a.nostyle,
|
||||||
|
.swagger-ui a.nostyle:visited {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
div.request-snippets > div.curl-command > div:nth-child(1) {
|
div.request-snippets > div.curl-command > div:nth-child(1) {
|
||||||
@@ -91,7 +92,7 @@ a {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
div.no-margin > div > div.responses-wrapper > div.responses-inner > div > div > table > tbody > tr {
|
div.no-margin > div > div.responses-wrapper > div.responses-inner > div > div > table > tbody > tr {
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
font-size: 60%;
|
font-size: 60%;
|
||||||
|
|||||||
@@ -1,2 +1 @@
|
|||||||
public/embed
|
public/embed
|
||||||
*.test.ts
|
|
||||||
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import type { CredentialOwner } from "@calcom/app-store/types";
|
import type { CredentialOwner } from "@calcom/app-store/types";
|
||||||
|
import classNames from "@calcom/lib/classNames";
|
||||||
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
|
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
|
||||||
@@ -80,7 +81,13 @@ export default function AppListCard(props: AppListCardProps) {
|
|||||||
return (
|
return (
|
||||||
<div className={`${highlight ? "dark:bg-muted bg-yellow-100" : ""}`}>
|
<div className={`${highlight ? "dark:bg-muted bg-yellow-100" : ""}`}>
|
||||||
<div className="flex items-center gap-x-3 px-5 py-4">
|
<div className="flex items-center gap-x-3 px-5 py-4">
|
||||||
{logo ? <img className="h-10 w-10" src={logo} alt={`${title} logo`} /> : null}
|
{logo ? (
|
||||||
|
<img
|
||||||
|
className={classNames(logo.includes("-dark") && "dark:invert", "h-10 w-10")}
|
||||||
|
src={logo}
|
||||||
|
alt={`${title} logo`}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<div className="flex grow flex-col gap-y-1 truncate">
|
<div className="flex grow flex-col gap-y-1 truncate">
|
||||||
<div className="flex items-center gap-x-2">
|
<div className="flex items-center gap-x-2">
|
||||||
<h3 className="text-emphasis truncate text-sm font-semibold">{title}</h3>
|
<h3 className="text-emphasis truncate text-sm font-semibold">{title}</h3>
|
||||||
|
|||||||
@@ -1,27 +1,51 @@
|
|||||||
|
import { lookup } from "bcp-47-match";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
import { useTranslation } from "next-i18next";
|
import { useTranslation } from "next-i18next";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
import { CALCOM_VERSION } from "@calcom/lib/constants";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
|
|
||||||
export function useViewerI18n() {
|
function useViewerI18n(locale: string) {
|
||||||
return trpc.viewer.public.i18n.useQuery(undefined, {
|
return trpc.viewer.public.i18n.useQuery(
|
||||||
staleTime: Infinity,
|
{ locale, CalComVersion: CALCOM_VERSION },
|
||||||
/**
|
{
|
||||||
* i18n should never be clubbed with other queries, so that it's caching can be managed independently.
|
/**
|
||||||
* We intend to not cache i18n query
|
* i18n should never be clubbed with other queries, so that it's caching can be managed independently.
|
||||||
**/
|
**/
|
||||||
trpc: {
|
trpc: {
|
||||||
context: { skipBatch: true },
|
context: { skipBatch: true },
|
||||||
},
|
},
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function useClientLocale(locales: string[]) {
|
||||||
|
const session = useSession();
|
||||||
|
// If the user is logged in, use their locale
|
||||||
|
if (session.data?.user.locale) return session.data.user.locale;
|
||||||
|
// If the user is not logged in, use the browser locale
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
// This is the only way I found to ensure the prefetched locale is used on first render
|
||||||
|
// FIXME: Find a better way to pick the best matching locale from the browser
|
||||||
|
return lookup(locales, window.navigator.language) || window.navigator.language;
|
||||||
|
}
|
||||||
|
// If the browser is not available, use English
|
||||||
|
return "en";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useClientViewerI18n(locales: string[]) {
|
||||||
|
const clientLocale = useClientLocale(locales);
|
||||||
|
return useViewerI18n(clientLocale);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Auto-switches locale client-side to the logged in user's preference
|
* Auto-switches locale client-side to the logged in user's preference
|
||||||
*/
|
*/
|
||||||
const I18nLanguageHandler = () => {
|
const I18nLanguageHandler = (props: { locales: string[] }) => {
|
||||||
|
const { locales } = props;
|
||||||
const { i18n } = useTranslation("common");
|
const { i18n } = useTranslation("common");
|
||||||
const locale = useViewerI18n().data?.locale || i18n.language;
|
const locale = useClientViewerI18n(locales).data?.locale || i18n.language;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// bail early when i18n = {}
|
// bail early when i18n = {}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import Script from "next/script";
|
|||||||
|
|
||||||
import "@calcom/embed-core/src/embed-iframe";
|
import "@calcom/embed-core/src/embed-iframe";
|
||||||
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
|
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
|
||||||
import { WEBAPP_URL, IS_CALCOM } from "@calcom/lib/constants";
|
import { IS_CALCOM, WEBAPP_URL } from "@calcom/lib/constants";
|
||||||
import { buildCanonical } from "@calcom/lib/next-seo.config";
|
import { buildCanonical } from "@calcom/lib/next-seo.config";
|
||||||
|
|
||||||
import type { AppProps } from "@lib/app-providers";
|
import type { AppProps } from "@lib/app-providers";
|
||||||
@@ -72,7 +72,7 @@ function PageWrapper(props: AppProps) {
|
|||||||
}
|
}
|
||||||
{...seoConfig.defaultNextSeo}
|
{...seoConfig.defaultNextSeo}
|
||||||
/>
|
/>
|
||||||
<I18nLanguageHandler />
|
<I18nLanguageHandler locales={props.router.locales || []} />
|
||||||
<Script
|
<Script
|
||||||
nonce={nonce}
|
nonce={nonce}
|
||||||
id="page-status"
|
id="page-status"
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ function ConnectedCalendarsList(props: Props) {
|
|||||||
type={item.integration.type}
|
type={item.integration.type}
|
||||||
isChecked={cal.isSelected}
|
isChecked={cal.isSelected}
|
||||||
destination={cal.externalId === props.destinationCalendarId}
|
destination={cal.externalId === props.destinationCalendarId}
|
||||||
|
credentialId={cal.credentialId}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ export const InstallAppButtonChild = ({
|
|||||||
);
|
);
|
||||||
}}>
|
}}>
|
||||||
<p>
|
<p>
|
||||||
{team.name} {isInstalled && `(${t("installed")})`}
|
{t(team.name)} {isInstalled && `(${t("installed")})`}
|
||||||
</p>
|
</p>
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useFormContext } from "react-hook-form";
|
||||||
|
|
||||||
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import { Label, TextField } from "@calcom/ui";
|
||||||
|
|
||||||
|
export default function TwoFactor({ center = true }) {
|
||||||
|
const { t } = useLocale();
|
||||||
|
const methods = useFormContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={center ? "mx-auto !mt-0 max-w-sm" : "!mt-0 max-w-sm"}>
|
||||||
|
<Label className="mt-4">{t("backup_code")}</Label>
|
||||||
|
|
||||||
|
<p className="text-subtle mb-4 text-sm">{t("backup_code_instructions")}</p>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
id="backup-code"
|
||||||
|
label=""
|
||||||
|
defaultValue=""
|
||||||
|
placeholder="XXXXX-XXXXX"
|
||||||
|
minLength={10} // without dash
|
||||||
|
maxLength={11} // with dash
|
||||||
|
required
|
||||||
|
{...methods.register("backupCode")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import { useFormContext } from "react-hook-form";
|
|||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { Label, Input } from "@calcom/ui";
|
import { Label, Input } from "@calcom/ui";
|
||||||
|
|
||||||
export default function TwoFactor({ center = true }) {
|
export default function TwoFactor({ center = true, autoFocus = true }) {
|
||||||
const [value, onChange] = useState("");
|
const [value, onChange] = useState("");
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const methods = useFormContext();
|
const methods = useFormContext();
|
||||||
@@ -40,7 +40,7 @@ export default function TwoFactor({ center = true }) {
|
|||||||
name={`2fa${index + 1}`}
|
name={`2fa${index + 1}`}
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
{...digit}
|
{...digit}
|
||||||
autoFocus={index === 0}
|
autoFocus={autoFocus && index === 0}
|
||||||
autoComplete="one-time-code"
|
autoComplete="one-time-code"
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -336,7 +336,7 @@ function BookingListItem(booking: BookingItemProps) {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<tr className="hover:bg-muted group flex flex-col sm:flex-row">
|
<tr data-testid="booking-item" className="hover:bg-muted group flex flex-col sm:flex-row">
|
||||||
<td
|
<td
|
||||||
className="hidden align-top ltr:pl-6 rtl:pr-6 sm:table-cell sm:min-w-[12rem]"
|
className="hidden align-top ltr:pl-6 rtl:pr-6 sm:table-cell sm:min-w-[12rem]"
|
||||||
onClick={onClickTableData}>
|
onClick={onClickTableData}>
|
||||||
@@ -368,7 +368,7 @@ function BookingListItem(booking: BookingItemProps) {
|
|||||||
{t("error_collecting_card")}
|
{t("error_collecting_card")}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : booking.paid ? (
|
) : booking.paid ? (
|
||||||
<Badge className="ltr:mr-2 rtl:ml-2" variant="green">
|
<Badge className="ltr:mr-2 rtl:ml-2" variant="green" data-testid="paid_badge">
|
||||||
{booking.payment[0].paymentOption === "HOLD" ? t("card_held") : t("paid")}
|
{booking.payment[0].paymentOption === "HOLD" ? t("card_held") : t("paid")}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -382,24 +382,22 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{selectedLocation && LocationOptions}
|
{selectedLocation && LocationOptions}
|
||||||
<DialogFooter>
|
<DialogFooter className="mt-4">
|
||||||
<div className="mt-4 flex justify-end space-x-2 rtl:space-x-reverse">
|
<Button
|
||||||
<Button
|
onClick={() => {
|
||||||
onClick={() => {
|
setShowLocationModal(false);
|
||||||
setShowLocationModal(false);
|
setSelectedLocation?.(undefined);
|
||||||
setSelectedLocation?.(undefined);
|
setEditingLocationType?.("");
|
||||||
setEditingLocationType?.("");
|
locationFormMethods.unregister(["locationType", "locationLink"]);
|
||||||
locationFormMethods.unregister(["locationType", "locationLink"]);
|
}}
|
||||||
}}
|
type="button"
|
||||||
type="button"
|
color="secondary">
|
||||||
color="secondary">
|
{t("cancel")}
|
||||||
{t("cancel")}
|
</Button>
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button data-testid="update-location" type="submit">
|
<Button data-testid="update-location" type="submit">
|
||||||
{t("update")}
|
{t("update")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</Form>
|
</Form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export const RescheduleDialog = (props: IRescheduleDialog) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
|
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
|
||||||
<DialogContent>
|
<DialogContent enableOverflow>
|
||||||
<div className="flex flex-row space-x-3">
|
<div className="flex flex-row space-x-3">
|
||||||
<div className="flex h-10 w-10 flex-shrink-0 justify-center rounded-full bg-[#FAFAFA]">
|
<div className="flex h-10 w-10 flex-shrink-0 justify-center rounded-full bg-[#FAFAFA]">
|
||||||
<Clock className="m-auto h-6 w-6" />
|
<Clock className="m-auto h-6 w-6" />
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
Tooltip,
|
Tooltip,
|
||||||
} from "@calcom/ui";
|
} from "@calcom/ui";
|
||||||
import { Copy, Edit } from "@calcom/ui/components/icon";
|
import { Copy, Edit } from "@calcom/ui/components/icon";
|
||||||
|
import { IS_VISUAL_REGRESSION_TESTING } from "@calcom/web/constants";
|
||||||
|
|
||||||
import RequiresConfirmationController from "./RequiresConfirmationController";
|
import RequiresConfirmationController from "./RequiresConfirmationController";
|
||||||
|
|
||||||
@@ -286,36 +287,38 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||||||
}}>
|
}}>
|
||||||
{/* Textfield has some margin by default we remove that so we can keep consitant aligment */}
|
{/* Textfield has some margin by default we remove that so we can keep consitant aligment */}
|
||||||
<div className="lg:-ml-2">
|
<div className="lg:-ml-2">
|
||||||
<TextField
|
{!IS_VISUAL_REGRESSION_TESTING && (
|
||||||
disabled
|
<TextField
|
||||||
name="hashedLink"
|
disabled
|
||||||
label={t("private_link_label")}
|
name="hashedLink"
|
||||||
data-testid="generated-hash-url"
|
label={t("private_link_label")}
|
||||||
labelSrOnly
|
data-testid="generated-hash-url"
|
||||||
type="text"
|
labelSrOnly
|
||||||
hint={t("private_link_hint")}
|
type="text"
|
||||||
defaultValue={placeholderHashedLink}
|
hint={t("private_link_hint")}
|
||||||
addOnSuffix={
|
defaultValue={placeholderHashedLink}
|
||||||
<Tooltip content={eventType.hashedLink ? t("copy_to_clipboard") : t("enabled_after_update")}>
|
addOnSuffix={
|
||||||
<Button
|
<Tooltip content={eventType.hashedLink ? t("copy_to_clipboard") : t("enabled_after_update")}>
|
||||||
color="minimal"
|
<Button
|
||||||
size="sm"
|
color="minimal"
|
||||||
type="button"
|
size="sm"
|
||||||
className="hover:stroke-3 hover:text-emphasis min-w-fit !py-0 px-0 hover:bg-transparent"
|
type="button"
|
||||||
aria-label="copy link"
|
className="hover:stroke-3 hover:text-emphasis min-w-fit !py-0 px-0 hover:bg-transparent"
|
||||||
onClick={() => {
|
aria-label="copy link"
|
||||||
navigator.clipboard.writeText(placeholderHashedLink);
|
onClick={() => {
|
||||||
if (eventType.hashedLink) {
|
navigator.clipboard.writeText(placeholderHashedLink);
|
||||||
showToast(t("private_link_copied"), "success");
|
if (eventType.hashedLink) {
|
||||||
} else {
|
showToast(t("private_link_copied"), "success");
|
||||||
showToast(t("enabled_after_update_description"), "warning");
|
} else {
|
||||||
}
|
showToast(t("enabled_after_update_description"), "warning");
|
||||||
}}>
|
}
|
||||||
<Copy className="h-4 w-4" />
|
}}>
|
||||||
</Button>
|
<Copy className="h-4 w-4" />
|
||||||
</Tooltip>
|
</Button>
|
||||||
}
|
</Tooltip>
|
||||||
/>
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</SettingsToggle>
|
</SettingsToggle>
|
||||||
<hr className="border-subtle" />
|
<hr className="border-subtle" />
|
||||||
@@ -338,6 +341,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||||||
toggleGuests(false);
|
toggleGuests(false);
|
||||||
formMethods.setValue("requiresConfirmation", false);
|
formMethods.setValue("requiresConfirmation", false);
|
||||||
setRequiresConfirmation(false);
|
setRequiresConfirmation(false);
|
||||||
|
formMethods.setValue("metadata.multipleDuration", undefined);
|
||||||
formMethods.setValue("seatsPerTimeSlot", 2);
|
formMethods.setValue("seatsPerTimeSlot", 2);
|
||||||
} else {
|
} else {
|
||||||
formMethods.setValue("seatsPerTimeSlot", null);
|
formMethods.setValue("seatsPerTimeSlot", null);
|
||||||
@@ -373,6 +377,14 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||||||
defaultChecked={!!eventType.seatsShowAttendees}
|
defaultChecked={!!eventType.seatsShowAttendees}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-2">
|
||||||
|
<CheckboxField
|
||||||
|
description={t("show_available_seats_count")}
|
||||||
|
disabled={seatsLocked.disabled}
|
||||||
|
onChange={(e) => formMethods.setValue("seatsShowAvailabilityCount", e.target.checked)}
|
||||||
|
defaultChecked={!!eventType.seatsShowAvailabilityCount}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ const EventTypeScheduleDetails = memo(
|
|||||||
<Globe className="h-3.5 w-3.5 ltr:mr-2 rtl:ml-2" />
|
<Globe className="h-3.5 w-3.5 ltr:mr-2 rtl:ml-2" />
|
||||||
{schedule?.timeZone || <SkeletonText className="block h-5 w-32" />}
|
{schedule?.timeZone || <SkeletonText className="block h-5 w-32" />}
|
||||||
</span>
|
</span>
|
||||||
{!!schedule?.id && !schedule.isManaged && (
|
{!!schedule?.id && !schedule.isManaged && !schedule.readOnly && (
|
||||||
<Button
|
<Button
|
||||||
href={`/availability/${schedule.id}`}
|
href={`/availability/${schedule.id}`}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
@@ -202,6 +202,15 @@ const EventTypeSchedule = ({ eventType }: { eventType: EventTypeSetup }) => {
|
|||||||
isManaged: false,
|
isManaged: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// We push the selected schedule from the event type if it's not part of the list response. This happens if the user is an admin but not the schedule owner.
|
||||||
|
else if (eventType.schedule && !schedules.find((schedule) => schedule.id === eventType.schedule)) {
|
||||||
|
options.push({
|
||||||
|
value: eventType.schedule,
|
||||||
|
label: eventType.scheduleName ?? t("default_schedule_name"),
|
||||||
|
isDefault: false,
|
||||||
|
isManaged: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
setOptions(options);
|
setOptions(options);
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { z } from "zod";
|
|||||||
import type { EventLocationType } from "@calcom/app-store/locations";
|
import type { EventLocationType } from "@calcom/app-store/locations";
|
||||||
import { getEventLocationType, MeetLocationType, LocationType } from "@calcom/app-store/locations";
|
import { getEventLocationType, MeetLocationType, LocationType } from "@calcom/app-store/locations";
|
||||||
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
|
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
|
||||||
import cx from "@calcom/lib/classNames";
|
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
||||||
import { CAL_URL } from "@calcom/lib/constants";
|
import { CAL_URL } from "@calcom/lib/constants";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { md } from "@calcom/lib/markdownIt";
|
import { md } from "@calcom/lib/markdownIt";
|
||||||
@@ -116,6 +116,8 @@ export const EventSetupTab = (
|
|||||||
const [editingLocationType, setEditingLocationType] = useState<string>("");
|
const [editingLocationType, setEditingLocationType] = useState<string>("");
|
||||||
const [selectedLocation, setSelectedLocation] = useState<LocationOption | undefined>(undefined);
|
const [selectedLocation, setSelectedLocation] = useState<LocationOption | undefined>(undefined);
|
||||||
const [multipleDuration, setMultipleDuration] = useState(eventType.metadata?.multipleDuration);
|
const [multipleDuration, setMultipleDuration] = useState(eventType.metadata?.multipleDuration);
|
||||||
|
const orgBranding = useOrgBranding();
|
||||||
|
const seatsEnabled = formMethods.watch("seatsPerTimeSlotEnabled");
|
||||||
|
|
||||||
const locationOptions = props.locationOptions.map((locationOption) => {
|
const locationOptions = props.locationOptions.map((locationOption) => {
|
||||||
const options = locationOption.options.filter((option) => {
|
const options = locationOption.options.filter((option) => {
|
||||||
@@ -129,10 +131,12 @@ export const EventSetupTab = (
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const multipleDurationOptions = [5, 10, 15, 20, 25, 30, 45, 50, 60, 75, 80, 90, 120, 180].map((mins) => ({
|
const multipleDurationOptions = [5, 10, 15, 20, 25, 30, 45, 50, 60, 75, 80, 90, 120, 150, 180].map(
|
||||||
value: mins,
|
(mins) => ({
|
||||||
label: t("multiple_duration_mins", { count: mins }),
|
value: mins,
|
||||||
}));
|
label: t("multiple_duration_mins", { count: mins }),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
const [selectedMultipleDuration, setSelectedMultipleDuration] = useState<
|
const [selectedMultipleDuration, setSelectedMultipleDuration] = useState<
|
||||||
MultiValue<{
|
MultiValue<{
|
||||||
@@ -297,13 +301,7 @@ export const EventSetupTab = (
|
|||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<img
|
<img
|
||||||
src={eventLocationType.iconUrl}
|
src={eventLocationType.iconUrl}
|
||||||
className={cx(
|
className="h-4 w-4 dark:invert-[.65]"
|
||||||
"h-4 w-4",
|
|
||||||
// invert all the icons except app icons
|
|
||||||
eventLocationType.iconUrl &&
|
|
||||||
!eventLocationType.iconUrl.startsWith("/app-store") &&
|
|
||||||
"dark:invert"
|
|
||||||
)}
|
|
||||||
alt={`${eventLocationType.label} logo`}
|
alt={`${eventLocationType.label} logo`}
|
||||||
/>
|
/>
|
||||||
<span className="ms-1 line-clamp-1 text-sm">{`${eventLabel} ${
|
<span className="ms-1 line-clamp-1 text-sm">{`${eventLabel} ${
|
||||||
@@ -383,6 +381,9 @@ export const EventSetupTab = (
|
|||||||
|
|
||||||
const lengthLockedProps = shouldLockDisableProps("length");
|
const lengthLockedProps = shouldLockDisableProps("length");
|
||||||
const descriptionLockedProps = shouldLockDisableProps("description");
|
const descriptionLockedProps = shouldLockDisableProps("description");
|
||||||
|
const urlPrefix = orgBranding
|
||||||
|
? orgBranding?.fullDomain.replace(/^(https?:|)\/\//, "")
|
||||||
|
: `${CAL_URL?.replace(/^(https?:|)\/\//, "")}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -411,10 +412,10 @@ export const EventSetupTab = (
|
|||||||
defaultValue={eventType.slug}
|
defaultValue={eventType.slug}
|
||||||
addOnLeading={
|
addOnLeading={
|
||||||
<>
|
<>
|
||||||
{CAL_URL?.replace(/^(https?:|)\/\//, "")}/
|
{urlPrefix}/
|
||||||
{!isManagedEventType
|
{!isManagedEventType
|
||||||
? team
|
? team
|
||||||
? "team/" + team.slug
|
? (orgBranding ? "" : "team/") + team.slug
|
||||||
: eventType.users[0].username
|
: eventType.users[0].username
|
||||||
: t("username_placeholder")}
|
: t("username_placeholder")}
|
||||||
/
|
/
|
||||||
@@ -501,6 +502,8 @@ export const EventSetupTab = (
|
|||||||
<SettingsToggle
|
<SettingsToggle
|
||||||
title={t("allow_booker_to_select_duration")}
|
title={t("allow_booker_to_select_duration")}
|
||||||
checked={multipleDuration !== undefined}
|
checked={multipleDuration !== undefined}
|
||||||
|
disabled={seatsEnabled}
|
||||||
|
tooltip={seatsEnabled ? t("seat_options_doesnt_multiple_durations") : undefined}
|
||||||
onCheckedChange={() => {
|
onCheckedChange={() => {
|
||||||
if (multipleDuration !== undefined) {
|
if (multipleDuration !== undefined) {
|
||||||
setMultipleDuration(undefined);
|
setMultipleDuration(undefined);
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import type { Options } from "react-select";
|
|||||||
import type { CheckedSelectOption } from "@calcom/features/eventtypes/components/CheckedTeamSelect";
|
import type { CheckedSelectOption } from "@calcom/features/eventtypes/components/CheckedTeamSelect";
|
||||||
import CheckedTeamSelect from "@calcom/features/eventtypes/components/CheckedTeamSelect";
|
import CheckedTeamSelect from "@calcom/features/eventtypes/components/CheckedTeamSelect";
|
||||||
import ChildrenEventTypeSelect from "@calcom/features/eventtypes/components/ChildrenEventTypeSelect";
|
import ChildrenEventTypeSelect from "@calcom/features/eventtypes/components/ChildrenEventTypeSelect";
|
||||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { SchedulingType } from "@calcom/prisma/enums";
|
import { SchedulingType } from "@calcom/prisma/enums";
|
||||||
import { Label, Select } from "@calcom/ui";
|
import { Label, Select } from "@calcom/ui";
|
||||||
@@ -18,13 +17,14 @@ interface IUserToValue {
|
|||||||
id: number | null;
|
id: number | null;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
username: string | null;
|
username: string | null;
|
||||||
|
avatar: string;
|
||||||
email: string;
|
email: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mapUserToValue = ({ id, name, username, email }: IUserToValue, pendingString: string) => ({
|
const mapUserToValue = ({ id, name, username, avatar, email }: IUserToValue, pendingString: string) => ({
|
||||||
value: `${id || ""}`,
|
value: `${id || ""}`,
|
||||||
label: `${name || email || ""}${!username ? ` (${pendingString})` : ""}`,
|
label: `${name || email || ""}${!username ? ` (${pendingString})` : ""}`,
|
||||||
avatar: `${WEBAPP_URL}/${username}/avatar.png`,
|
avatar,
|
||||||
email,
|
email,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ type Props = {
|
|||||||
formMethods: UseFormReturn<FormValues>;
|
formMethods: UseFormReturn<FormValues>;
|
||||||
isUpdateMutationLoading?: boolean;
|
isUpdateMutationLoading?: boolean;
|
||||||
availability?: AvailabilityOption;
|
availability?: AvailabilityOption;
|
||||||
|
isUserOrganizationAdmin: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function getNavigation(props: {
|
function getNavigation(props: {
|
||||||
@@ -133,6 +134,7 @@ function EventTypeSingleLayout({
|
|||||||
isUpdateMutationLoading,
|
isUpdateMutationLoading,
|
||||||
formMethods,
|
formMethods,
|
||||||
availability,
|
availability,
|
||||||
|
isUserOrganizationAdmin,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const utils = trpc.useContext();
|
const utils = trpc.useContext();
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
@@ -142,7 +144,8 @@ function EventTypeSingleLayout({
|
|||||||
const hasPermsToDelete =
|
const hasPermsToDelete =
|
||||||
currentUserMembership?.role !== "MEMBER" ||
|
currentUserMembership?.role !== "MEMBER" ||
|
||||||
!currentUserMembership ||
|
!currentUserMembership ||
|
||||||
eventType.schedulingType === SchedulingType.MANAGED;
|
eventType.schedulingType === SchedulingType.MANAGED ||
|
||||||
|
isUserOrganizationAdmin;
|
||||||
|
|
||||||
const deleteMutation = trpc.viewer.eventTypes.delete.useMutation({
|
const deleteMutation = trpc.viewer.eventTypes.delete.useMutation({
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ const ConnectedCalendarItem = (prop: IConnectedCalendarItem) => {
|
|||||||
<ul className="p-4">
|
<ul className="p-4">
|
||||||
{calendars?.map((calendar, i) => (
|
{calendars?.map((calendar, i) => (
|
||||||
<CalendarSwitch
|
<CalendarSwitch
|
||||||
|
credentialId={calendar.credentialId}
|
||||||
key={calendar.externalId}
|
key={calendar.externalId}
|
||||||
externalId={calendar.externalId}
|
externalId={calendar.externalId}
|
||||||
title={calendar.name || "Nameless Calendar"}
|
title={calendar.name || "Nameless Calendar"}
|
||||||
|
|||||||
@@ -20,10 +20,11 @@ const SetupAvailability = (props: ISetupAvailabilityProps) => {
|
|||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const { nextStep } = props;
|
const { nextStep } = props;
|
||||||
|
|
||||||
|
const scheduleId = defaultScheduleId === null ? undefined : defaultScheduleId;
|
||||||
const queryAvailability = trpc.viewer.availability.schedule.get.useQuery(
|
const queryAvailability = trpc.viewer.availability.schedule.get.useQuery(
|
||||||
{ scheduleId: defaultScheduleId! },
|
{ scheduleId: defaultScheduleId ?? undefined },
|
||||||
{
|
{
|
||||||
enabled: !!defaultScheduleId,
|
enabled: !!scheduleId,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -3,12 +3,13 @@ import type { FormEvent } from "react";
|
|||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
|
||||||
|
import OrganizationAvatar from "@calcom/features/ee/organizations/components/OrganizationAvatar";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { md } from "@calcom/lib/markdownIt";
|
import { md } from "@calcom/lib/markdownIt";
|
||||||
import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||||
import turndown from "@calcom/lib/turndownService";
|
import turndown from "@calcom/lib/turndownService";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import { Avatar, Button, Editor, ImageUploader, Label, showToast } from "@calcom/ui";
|
import { Button, Editor, ImageUploader, Label, showToast } from "@calcom/ui";
|
||||||
import { ArrowRight } from "@calcom/ui/components/icon";
|
import { ArrowRight } from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
type FormData = {
|
type FormData = {
|
||||||
@@ -99,11 +100,11 @@ const UserProfile = () => {
|
|||||||
<form onSubmit={onSubmit}>
|
<form onSubmit={onSubmit}>
|
||||||
<div className="flex flex-row items-center justify-start rtl:justify-end">
|
<div className="flex flex-row items-center justify-start rtl:justify-end">
|
||||||
{user && (
|
{user && (
|
||||||
<Avatar
|
<OrganizationAvatar
|
||||||
alt={user.username || "user avatar"}
|
alt={user.username || "user avatar"}
|
||||||
gravatarFallbackMd5={user.emailMd5}
|
|
||||||
size="lg"
|
size="lg"
|
||||||
imageSrc={imageSrc}
|
imageSrc={imageSrc}
|
||||||
|
organizationSlug={user.organization?.slug}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -12,9 +12,6 @@ const DisableUserImpersonation = ({ disableImpersonation }: { disableImpersonati
|
|||||||
showToast(t("your_user_profile_updated_successfully"), "success");
|
showToast(t("your_user_profile_updated_successfully"), "success");
|
||||||
await utils.viewer.me.invalidate();
|
await utils.viewer.me.invalidate();
|
||||||
},
|
},
|
||||||
async onSettled() {
|
|
||||||
await utils.viewer.public.i18n.invalidate();
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
|||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { Button, Dialog, DialogContent, DialogFooter, Form, PasswordField } from "@calcom/ui";
|
import { Button, Dialog, DialogContent, DialogFooter, Form, PasswordField } from "@calcom/ui";
|
||||||
|
|
||||||
|
import BackupCode from "@components/auth/BackupCode";
|
||||||
import TwoFactor from "@components/auth/TwoFactor";
|
import TwoFactor from "@components/auth/TwoFactor";
|
||||||
|
|
||||||
import TwoFactorAuthAPI from "./TwoFactorAuthAPI";
|
import TwoFactorAuthAPI from "./TwoFactorAuthAPI";
|
||||||
@@ -20,6 +21,7 @@ interface DisableTwoFactorAuthModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface DisableTwoFactorValues {
|
interface DisableTwoFactorValues {
|
||||||
|
backupCode: string;
|
||||||
totpCode: string;
|
totpCode: string;
|
||||||
password: string;
|
password: string;
|
||||||
}
|
}
|
||||||
@@ -33,11 +35,19 @@ const DisableTwoFactorAuthModal = ({
|
|||||||
}: DisableTwoFactorAuthModalProps) => {
|
}: DisableTwoFactorAuthModalProps) => {
|
||||||
const [isDisabling, setIsDisabling] = useState(false);
|
const [isDisabling, setIsDisabling] = useState(false);
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
const [twoFactorLostAccess, setTwoFactorLostAccess] = useState(false);
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
|
|
||||||
const form = useForm<DisableTwoFactorValues>();
|
const form = useForm<DisableTwoFactorValues>();
|
||||||
|
|
||||||
async function handleDisable({ totpCode, password }: DisableTwoFactorValues) {
|
const resetForm = (clearPassword = true) => {
|
||||||
|
if (clearPassword) form.setValue("password", "");
|
||||||
|
form.setValue("backupCode", "");
|
||||||
|
form.setValue("totpCode", "");
|
||||||
|
setErrorMessage(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
async function handleDisable({ password, totpCode, backupCode }: DisableTwoFactorValues) {
|
||||||
if (isDisabling) {
|
if (isDisabling) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -45,8 +55,10 @@ const DisableTwoFactorAuthModal = ({
|
|||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await TwoFactorAuthAPI.disable(password, totpCode);
|
const response = await TwoFactorAuthAPI.disable(password, totpCode, backupCode);
|
||||||
if (response.status === 200) {
|
if (response.status === 200) {
|
||||||
|
setTwoFactorLostAccess(false);
|
||||||
|
resetForm();
|
||||||
onDisable();
|
onDisable();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -54,12 +66,14 @@ const DisableTwoFactorAuthModal = ({
|
|||||||
const body = await response.json();
|
const body = await response.json();
|
||||||
if (body.error === ErrorCode.IncorrectPassword) {
|
if (body.error === ErrorCode.IncorrectPassword) {
|
||||||
setErrorMessage(t("incorrect_password"));
|
setErrorMessage(t("incorrect_password"));
|
||||||
}
|
} else if (body.error === ErrorCode.SecondFactorRequired) {
|
||||||
if (body.error === ErrorCode.SecondFactorRequired) {
|
|
||||||
setErrorMessage(t("2fa_required"));
|
setErrorMessage(t("2fa_required"));
|
||||||
}
|
} else if (body.error === ErrorCode.IncorrectTwoFactorCode) {
|
||||||
if (body.error === ErrorCode.IncorrectTwoFactorCode) {
|
|
||||||
setErrorMessage(t("incorrect_2fa"));
|
setErrorMessage(t("incorrect_2fa"));
|
||||||
|
} else if (body.error === ErrorCode.IncorrectBackupCode) {
|
||||||
|
setErrorMessage(t("incorrect_backup_code"));
|
||||||
|
} else if (body.error === ErrorCode.MissingBackupCodes) {
|
||||||
|
setErrorMessage(t("missing_backup_codes"));
|
||||||
} else {
|
} else {
|
||||||
setErrorMessage(t("something_went_wrong"));
|
setErrorMessage(t("something_went_wrong"));
|
||||||
}
|
}
|
||||||
@@ -78,6 +92,7 @@ const DisableTwoFactorAuthModal = ({
|
|||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
{!disablePassword && (
|
{!disablePassword && (
|
||||||
<PasswordField
|
<PasswordField
|
||||||
|
required
|
||||||
labelProps={{
|
labelProps={{
|
||||||
className: "block text-sm font-medium text-default",
|
className: "block text-sm font-medium text-default",
|
||||||
}}
|
}}
|
||||||
@@ -85,12 +100,25 @@ const DisableTwoFactorAuthModal = ({
|
|||||||
className="border-default mt-1 block w-full rounded-md border px-3 py-2 text-sm focus:border-black focus:outline-none focus:ring-black"
|
className="border-default mt-1 block w-full rounded-md border px-3 py-2 text-sm focus:border-black focus:outline-none focus:ring-black"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<TwoFactor center={false} />
|
{twoFactorLostAccess ? (
|
||||||
|
<BackupCode center={false} />
|
||||||
|
) : (
|
||||||
|
<TwoFactor center={false} autoFocus={false} />
|
||||||
|
)}
|
||||||
|
|
||||||
{errorMessage && <p className="mt-1 text-sm text-red-700">{errorMessage}</p>}
|
{errorMessage && <p className="mt-1 text-sm text-red-700">{errorMessage}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter showDivider className="relative mt-5">
|
<DialogFooter showDivider className="relative mt-5">
|
||||||
|
<Button
|
||||||
|
color="minimal"
|
||||||
|
className="mr-auto"
|
||||||
|
onClick={() => {
|
||||||
|
setTwoFactorLostAccess(!twoFactorLostAccess);
|
||||||
|
resetForm(false);
|
||||||
|
}}>
|
||||||
|
{twoFactorLostAccess ? t("go_back") : t("lost_access")}
|
||||||
|
</Button>
|
||||||
<Button color="secondary" onClick={onCancel}>
|
<Button color="secondary" onClick={onCancel}>
|
||||||
{t("cancel")}
|
{t("cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useForm } from "react-hook-form";
|
|||||||
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
||||||
import { useCallbackRef } from "@calcom/lib/hooks/useCallbackRef";
|
import { useCallbackRef } from "@calcom/lib/hooks/useCallbackRef";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { Button, Dialog, DialogContent, DialogFooter, Form, TextField } from "@calcom/ui";
|
import { Button, Dialog, DialogContent, DialogFooter, Form, PasswordField, showToast } from "@calcom/ui";
|
||||||
|
|
||||||
import TwoFactor from "@components/auth/TwoFactor";
|
import TwoFactor from "@components/auth/TwoFactor";
|
||||||
|
|
||||||
@@ -28,6 +28,7 @@ interface EnableTwoFactorModalProps {
|
|||||||
|
|
||||||
enum SetupStep {
|
enum SetupStep {
|
||||||
ConfirmPassword,
|
ConfirmPassword,
|
||||||
|
DisplayBackupCodes,
|
||||||
DisplayQrCode,
|
DisplayQrCode,
|
||||||
EnterTotpCode,
|
EnterTotpCode,
|
||||||
}
|
}
|
||||||
@@ -54,16 +55,25 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
|
|||||||
|
|
||||||
const setupDescriptions = {
|
const setupDescriptions = {
|
||||||
[SetupStep.ConfirmPassword]: t("2fa_confirm_current_password"),
|
[SetupStep.ConfirmPassword]: t("2fa_confirm_current_password"),
|
||||||
|
[SetupStep.DisplayBackupCodes]: t("backup_code_instructions"),
|
||||||
[SetupStep.DisplayQrCode]: t("2fa_scan_image_or_use_code"),
|
[SetupStep.DisplayQrCode]: t("2fa_scan_image_or_use_code"),
|
||||||
[SetupStep.EnterTotpCode]: t("2fa_enter_six_digit_code"),
|
[SetupStep.EnterTotpCode]: t("2fa_enter_six_digit_code"),
|
||||||
};
|
};
|
||||||
const [step, setStep] = useState(SetupStep.ConfirmPassword);
|
const [step, setStep] = useState(SetupStep.ConfirmPassword);
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
|
const [backupCodes, setBackupCodes] = useState([]);
|
||||||
|
const [backupCodesUrl, setBackupCodesUrl] = useState("");
|
||||||
const [dataUri, setDataUri] = useState("");
|
const [dataUri, setDataUri] = useState("");
|
||||||
const [secret, setSecret] = useState("");
|
const [secret, setSecret] = useState("");
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const resetState = () => {
|
||||||
|
setPassword("");
|
||||||
|
setErrorMessage(null);
|
||||||
|
setStep(SetupStep.ConfirmPassword);
|
||||||
|
};
|
||||||
|
|
||||||
async function handleSetup(e: React.FormEvent) {
|
async function handleSetup(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
@@ -79,6 +89,15 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
|
|||||||
const body = await response.json();
|
const body = await response.json();
|
||||||
|
|
||||||
if (response.status === 200) {
|
if (response.status === 200) {
|
||||||
|
setBackupCodes(body.backupCodes);
|
||||||
|
|
||||||
|
// create backup codes download url
|
||||||
|
const textBlob = new Blob([body.backupCodes.map(formatBackupCode).join("\n")], {
|
||||||
|
type: "text/plain",
|
||||||
|
});
|
||||||
|
if (backupCodesUrl) URL.revokeObjectURL(backupCodesUrl);
|
||||||
|
setBackupCodesUrl(URL.createObjectURL(textBlob));
|
||||||
|
|
||||||
setDataUri(body.dataUri);
|
setDataUri(body.dataUri);
|
||||||
setSecret(body.secret);
|
setSecret(body.secret);
|
||||||
setStep(SetupStep.DisplayQrCode);
|
setStep(SetupStep.DisplayQrCode);
|
||||||
@@ -113,7 +132,7 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
|
|||||||
const body = await response.json();
|
const body = await response.json();
|
||||||
|
|
||||||
if (response.status === 200) {
|
if (response.status === 200) {
|
||||||
onEnable();
|
setStep(SetupStep.DisplayBackupCodes);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,15 +160,19 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
|
|||||||
}
|
}
|
||||||
}, [form, handleEnableRef, totpCode]);
|
}, [form, handleEnableRef, totpCode]);
|
||||||
|
|
||||||
|
const formatBackupCode = (code: string) => `${code.slice(0, 5)}-${code.slice(5, 10)}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent title={t("enable_2fa")} description={setupDescriptions[step]} type="creation">
|
<DialogContent
|
||||||
|
title={step === SetupStep.DisplayBackupCodes ? t("backup_codes") : t("enable_2fa")}
|
||||||
|
description={setupDescriptions[step]}
|
||||||
|
type="creation">
|
||||||
<WithStep step={SetupStep.ConfirmPassword} current={step}>
|
<WithStep step={SetupStep.ConfirmPassword} current={step}>
|
||||||
<form onSubmit={handleSetup}>
|
<form onSubmit={handleSetup}>
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<TextField
|
<PasswordField
|
||||||
label={t("password")}
|
label={t("password")}
|
||||||
type="password"
|
|
||||||
name="password"
|
name="password"
|
||||||
id="password"
|
id="password"
|
||||||
required
|
required
|
||||||
@@ -173,6 +196,15 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
|
|||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
</WithStep>
|
</WithStep>
|
||||||
|
<WithStep step={SetupStep.DisplayBackupCodes} current={step}>
|
||||||
|
<>
|
||||||
|
<div className="mt-5 grid grid-cols-2 gap-1 text-center font-mono md:pl-10 md:pr-10">
|
||||||
|
{backupCodes.map((code) => (
|
||||||
|
<div key={code}>{formatBackupCode(code)}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
</WithStep>
|
||||||
<Form handleSubmit={handleEnable} form={form}>
|
<Form handleSubmit={handleEnable} form={form}>
|
||||||
<WithStep step={SetupStep.EnterTotpCode} current={step}>
|
<WithStep step={SetupStep.EnterTotpCode} current={step}>
|
||||||
<div className="-mt-4 pb-2">
|
<div className="-mt-4 pb-2">
|
||||||
@@ -186,9 +218,16 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
|
|||||||
</div>
|
</div>
|
||||||
</WithStep>
|
</WithStep>
|
||||||
<DialogFooter className="mt-8" showDivider>
|
<DialogFooter className="mt-8" showDivider>
|
||||||
<Button color="secondary" onClick={onCancel}>
|
{step !== SetupStep.DisplayBackupCodes ? (
|
||||||
{t("cancel")}
|
<Button
|
||||||
</Button>
|
color="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
onCancel();
|
||||||
|
resetState();
|
||||||
|
}}>
|
||||||
|
{t("cancel")}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
<WithStep step={SetupStep.ConfirmPassword} current={step}>
|
<WithStep step={SetupStep.ConfirmPassword} current={step}>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -218,6 +257,35 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
|
|||||||
{t("enable")}
|
{t("enable")}
|
||||||
</Button>
|
</Button>
|
||||||
</WithStep>
|
</WithStep>
|
||||||
|
<WithStep step={SetupStep.DisplayBackupCodes} current={step}>
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
color="secondary"
|
||||||
|
data-testid="backup-codes-close"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
resetState();
|
||||||
|
onEnable();
|
||||||
|
}}>
|
||||||
|
{t("close")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="secondary"
|
||||||
|
data-testid="backup-codes-copy"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
navigator.clipboard.writeText(backupCodes.map(formatBackupCode).join("\n"));
|
||||||
|
showToast(t("backup_codes_copied"), "success");
|
||||||
|
}}>
|
||||||
|
{t("copy")}
|
||||||
|
</Button>
|
||||||
|
<a download="cal-backup-codes.txt" href={backupCodesUrl}>
|
||||||
|
<Button color="primary" data-testid="backup-codes-download">
|
||||||
|
{t("download")}
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
</WithStep>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</Form>
|
</Form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -19,10 +19,10 @@ const TwoFactorAuthAPI = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async disable(password: string, code: string) {
|
async disable(password: string, code: string, backupCode: string) {
|
||||||
return fetch("/api/auth/two-factor/totp/disable", {
|
return fetch("/api/auth/two-factor/totp/disable", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ password, code }),
|
body: JSON.stringify({ password, code, backupCode }),
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
|
|||||||
Loaded 100 of 970 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user