Compare commits

...
47 Commits
Author SHA1 Message Date
Alex van Andel a90848edcb v3.3.6
Auto Comment Merge Conflicts / auto-comment-merge-conflicts (push) Canceled after 0s
release.yml / release (push) Canceled after 0s
2023-10-06 15:13:59 +01:00
Syed Ali Shahbaz 49b0f48be0 feat: add parentid eventtype API along with children eventTypeIds (#11714)
* add parentId

* Allow POST parentId

* adds eventtypeid of children

* add doc

* adds userId in children

* adds validations

* adds docstring

* fix status codes

* check fix for owners
2023-10-06 13:58:51 +00:00
Udit Takkar ceb8906c27 fix: check if appslug exist (#11735) 2023-10-06 14:43:28 +01:00
Alex van Andel f9c578c87d fix: Backwards compatibility on google reschedule/cancel (#11733) 2023-10-06 14:27:36 +01:00
Peer Richelsen c105793486 more contrast for cal video (#11729) 2023-10-06 12:09:21 +00:00
Peer Richelsen 59b503eb7e yarn 2023-10-06 13:01:22 +01:00
Peer Richelsen b6a28b1950 chore: experimental warning for recurring events (#11727) 2023-10-06 11:00:01 +00:00
Crowdin Bot b6ea551580 New Crowdin translations by Github Action 2023-10-06 10:38:53 +00:00
c5bf75385f fix: add number_sms_notifications translation (#11724)
Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Peer Richelsen <peer@cal.com>
2023-10-06 10:35:56 +00:00
AldrinandSyed Ali Shahbaz 1298b05e1a fix: Single Use Private link does not regenerate after use (#11705)
* feat: pass down props for hashedlink

* feat: update booking mapper

---------

Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com>
2023-10-06 11:07:55 +01:00
Udit Takkar e0c27d8721 fix: remove subteam logo (#11715) 2023-10-06 09:47:16 +02:00
666428ba53 fix: location icon doesn't change according to theme (#11504)
* Pass icon from eventmeta parent component

* Update readme

* fix: use dark:invert on map pin

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* chore: remove log

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* fix: change svg name

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* Update packages/features/bookings/components/event-meta/AvailableEventLocations.tsx

---------

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
Co-authored-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2023-10-06 07:46:21 +00:00
zomars ead11ef009 chore: Update yarn.lock 2023-10-05 13:24:21 -07:00
Siddharth Movaliyaandsean-brydon e7543c10a9 fix: fixes organization table UI flickers and adds sticky search & filter + table header (#11711)
* fix: #11213, #11215

* Update packages/ui/components/data-table/DataTableToolbar.tsx

---------

Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2023-10-05 20:24:03 +00:00
bba52db02f test: RFC: tests for EventTypeAppCardInterface (CALCOM-11005 ) (#11344)
Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
Co-authored-by: gitstart-calcom <gitstart-calcom@users.noreply.github.com>
2023-10-05 11:50:26 -03:00
Ramsay Sewell a86d4ec89d fix: typo in reporting feature sentence (#11685) 2023-10-05 06:06:17 -03:00
Carina WollendorferandCarinaWolli d415944d5b fix minutes string (#11703)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2023-10-05 09:46:00 +01:00
Alex van Andel 98c78ba7e1 v3.3.5
Auto Comment Merge Conflicts / auto-comment-merge-conflicts (push) Canceled after 0s
release.yml / release (push) Canceled after 0s
2023-10-04 22:29:48 +01:00
alannnc c554779490 fix: rescheduled value DB update on reschedule and insights view cancelleds (#11474) 2023-10-04 12:10:10 -07:00
Crowdin Bot 53655a43eb New Crowdin translations by Github Action 2023-10-04 19:08:54 +00:00
Carina WollendorferandCarinaWolli 38d1b92226 fix: google calendar busy times (#11696)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2023-10-04 12:05:50 -07:00
3e08c66888 refactor: Use template literal instead of '+' operator (#11444)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: zomars <zomars@me.com>
2023-10-03 11:52:19 -07:00
Hariom Balhara a186b130cb fix: Use only the first connected calendar as fallback to create event (#11668) 2023-10-03 10:45:18 -07:00
Roland bb052a24bc fix: alby logo on payment component (#11678) 2023-10-03 16:14:54 +00:00
a1b6b7ad66 feat: #1490 [CAL-422] Intercom App (#10714)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Ubuntu <ubuntu@ip-172-31-18-13.eu-west-3.compute.internal>
2023-10-03 10:25:27 +01:00
sean-brydon 35181e4bfd fix: booker skeleton layout shift (#11653) 2023-10-02 17:53:10 +01:00
Nic Wortel c2e3638993 fix: Add 240 and 480 mins. options for multiple duration events (#10663) 2023-10-02 12:59:08 +01:00
Hariom Balhara 3ca7456885 test: Booking flow App failure and ics file tests and improved logging (#11646) 2023-10-02 16:21:04 +05:30
Peer Richelsen 07bfaec24e fix: inverted logos and new logo for cal video (#11633)
* new logo for cal video

* fixed cal video and other inverted icons

* Discard changes to apps/web/components/eventtype/EventAdvancedTab.tsx

* moved function to lib
2023-10-02 11:36:37 +01:00
alannnc 032435fbd4 fix: app that dont require setup page (#11628)
* fix

* add new test
2023-10-01 11:45:27 -03:00
Hariom Balhara caf1b4c5d7 test: More tests in booking flow (#11510) 2023-09-30 13:28:52 +00:00
Hariom Balharaandalannnc 20898e1505 fix: Handle payment flow webhooks in case of event requiring confirmation (#11458)
Co-authored-by: alannnc <alannnc@gmail.com>
2023-09-30 10:22:32 +05:30
AldrinandPeer Richelsen 0bb99fc667 refactor: <BookingListItem/> to use <Link/> to navigate instead of r… (#11602)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-09-29 22:54:22 +01:00
DexterStorey e5b88bd0b0 fix: prisma import in extract user route in Cal AI (#11625) 2023-09-29 13:53:38 -07:00
Udit TakkarandLeo Giovanetti 4a81b59fc9 fix: profile loading fix in org (#11618)
Co-authored-by: Leo Giovanetti <hello@leog.me>
2023-09-29 21:25:56 +01:00
Lauris SkraucisandAlan e1f47ef40e refactor: Paypal App UI & UX (#11528)
* refactor(ui): price input margin

* refactor(ui): price input border

* refactor(ui): inputs spacing

* fix: Use USD as the default currency

* fix: dynamic currency symbols

* undo

* apply same styles for stripe app

* type fixes

---------

Co-authored-by: Alan <alannnc@gmail.com>
2023-09-29 13:19:04 -07:00
Rolandandalannnc e568d70e65 fix: only set stripe app data if it requires payment (#11623)
Co-authored-by: alannnc <alannnc@gmail.com>
2023-09-29 18:49:52 +00:00
Carina WollendorferandCarinaWolli 41b6377f29 fix: availability for org team event (#11611)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2023-09-29 17:56:24 +02:00
Udit TakkarandPeer Richelsen 685be1663e refactor: Setting redesign (#11124)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-09-29 15:10:13 +01:00
Alex van Andel 85a1713897 Revert "refactor: Setting redesign (#11124)"
This reverts commit 4f3cf4f948.
2023-09-29 13:37:06 +01:00
Hariom Balhara c22d406d12 Enforce CSP on /login as well (#11610) 2023-09-29 07:50:15 -03:00
Lauris Skraucis cf43d9dcfc docs: E2E browser installation & setting up Node (#11551)
* docs: installing E2E test browsers

* docs: using required Node version

* refactor: correct use of English
2023-09-29 06:46:19 -03:00
BHARGAVA_GONUGUNTA 30a23cedbc Fixes the dropdowen issue #11597 (#11607) 2023-09-29 10:45:45 +01:00
Omar LópezandPeer Richelsen 6e2a96d69c fix: Add missing prisma import (#11598)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-09-28 21:42:24 +00:00
Peer Richelsen 490e0a9bb4 chore: cal.ai app store improvements (#11596) 2023-09-28 21:32:27 +00:00
alannnc 31bc586ee8 fix: stripe selected currency (#11593) 2023-09-28 14:29:12 -07:00
+15 68bd877c5b feat: OAuth provider for Zapier (#11465)
Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: sajanlamsal <saznlamsal@gmail.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: alannnc <alannnc@gmail.com>
Co-authored-by: Leo Giovanetti <hello@leog.me>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Nitin Panghal <nitin.panghal@unthinkable.co>
Co-authored-by: Omar López <zomars@me.com>
Co-authored-by: Peer Richelsen <peer@cal.com>
Co-authored-by: zomars <zomars@me.com>
Co-authored-by: Shivam Kalra <shivamkalra98@gmail.com>
Co-authored-by: Richard Poelderl <richard.poelderl@gmail.com>
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: Nafees Nazik <84864519+G3root@users.noreply.github.com>
Co-authored-by: Chiranjeev Vishnoi <66114276+Chiranjeev-droid@users.noreply.github.com>
Co-authored-by: Denzil Samuel <71846487+samueldenzil@users.noreply.github.com>
Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com>
Co-authored-by: nitinpanghal <43965732+nitinpanghal@users.noreply.github.com>
Co-authored-by: Ahmad <57593864+Ahmadkashif@users.noreply.github.com>
Co-authored-by: Annlee Fores <annleefores@gmail.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Vijay <vijayraghav22@gmail.com>
2023-09-28 12:41:28 -07:00
331 changed files with 9633 additions and 3055 deletions
+27 -2
View File
@@ -92,7 +92,22 @@ To develop locally:
- Use `openssl rand -base64 32` to generate a key and add it under `NEXTAUTH_SECRET` in the `.env` file.
- Use `openssl rand -base64 24` to generate a key and add it under `CALENDSO_ENCRYPTION_KEY` in the `.env` file.
6. Start developing and watch for code changes:
6. Setup Node
If your Node version does not meet the project's requirements as instructed by the docs, "nvm" (Node Version Manager) allows using Node at the version required by the project:
```sh
nvm use
```
You first might need to install the specific version and then use it:
```sh
nvm install && nvm use
```
You can install nvm from [here](https://github.com/nvm-sh/nvm).
7. Start developing and watch for code changes:
```sh
yarn dev
@@ -120,6 +135,16 @@ This will run and test all flows in multiple Chromium windows to verify that no
yarn test-e2e
```
#### Resolving issues
##### E2E test browsers not installed
Run `npx playwright install` to download test browsers and resolve the error below when running `yarn test-e2e`:
```
Executable doesn't exist at /Users/alice/Library/Caches/ms-playwright/chromium-1048/chrome-mac/Chromium.app/Contents/MacOS/Chromium
```
## Linting
To check the formatting of your code:
@@ -135,4 +160,4 @@ If you get errors, be sure to fix them before committing.
- Be sure to [check the "Allow edits from maintainers" option](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/allowing-changes-to-a-pull-request-branch-created-from-a-fork) while creating your PR.
- If your PR refers to or fixes an issue, be sure to add `refs #XXX` or `fixes #XXX` to the PR description. Replacing `XXX` with the respective issue number. See more about [Linking a pull request to an issue](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue).
- Be sure to fill the PR Template accordingly.
- Review [App Contribution Guidelines](./packages/app-store/CONTRIBUTING.md) when building integrations
- Review [App Contribution Guidelines](./packages/app-store/CONTRIBUTING.md) when building integrations
+33 -6
View File
@@ -131,23 +131,39 @@ Here is what you need to be able to run Cal.com.
> If you are on Windows, run the following command on `gitbash` with admin privileges: <br> > `git clone -c core.symlinks=true https://github.com/calcom/cal.com.git` <br>
> See [docs](https://cal.com/docs/how-to-guides/how-to-troubleshoot-symbolic-link-issues-on-windows#enable-symbolic-links) for more details.
1. Go to the project folder
2. Go to the project folder
```sh
cd cal.com
```
1. Install packages with yarn
3. Install packages with yarn
```sh
yarn
```
1. Set up your `.env` file
4. Set up your `.env` file
- Duplicate `.env.example` to `.env`
- Use `openssl rand -base64 32` to generate a key and add it under `NEXTAUTH_SECRET` in the `.env` file.
- Use `openssl rand -base64 24` to generate a key and add it under `CALENDSO_ENCRYPTION_KEY` in the `.env` file.
5. Setup Node
If your Node version does not meet the project's requirements as instructed by the docs, "nvm" (Node Version Manager) allows using Node at the version required by the project:
```sh
nvm use
```
You first might need to install the specific version and then use it:
```sh
nvm install && nvm use
```
You can install nvm from [here](https://github.com/nvm-sh/nvm).
#### Quick start with `yarn dx`
> - **Requires Docker and Docker Compose to be installed**
@@ -221,6 +237,7 @@ echo 'NEXT_PUBLIC_DEBUG=1' >> .env
```
1. Run [mailhog](https://github.com/mailhog/MailHog) to view emails sent during development
> **_NOTE:_** Required when `E2E_TEST_MAILHOG_ENABLED` is "1"
```sh
@@ -259,6 +276,16 @@ yarn test-e2e
yarn playwright show-report test-results/reports/playwright-html-report
```
#### Resolving issues
##### E2E test browsers not installed
Run `npx playwright install` to download test browsers and resolve the error below when running `yarn test-e2e`:
```
Executable doesn't exist at /Users/alice/Library/Caches/ms-playwright/chromium-1048/chrome-mac/Chromium.app/Contents/MacOS/Chromium
```
### Upgrading from earlier versions
1. Pull the current version:
@@ -470,9 +497,8 @@ following
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)`.
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
@@ -507,6 +533,7 @@ For example, `Cal.com (support@cal.com)`.
### Obtaining Zoho Calendar Client ID and Secret
[Follow these steps](./packages/app-store/zohocalendar/)
### Obtaining Zoho Bigin Client ID and Secret
[Follow these steps](./packages/app-store/zoho-bigin/)
+7 -7
View File
@@ -4,9 +4,9 @@ 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?"
- 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.
@@ -24,10 +24,10 @@ 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))
- 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`.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/ai",
"version": "1.1.0",
"version": "1.1.1",
"private": true,
"author": "Cal.com Inc.",
"dependencies": {
+13 -12
View File
@@ -74,18 +74,19 @@ ${
? `The email references the following @usernames and emails: ${users
.map(
(u) =>
(u.id ? `, id: ${u.id}` : "id: (non user)") +
(u.username
? u.type === "fromUsername"
? `, username: @${u.username}`
: ", username: REDACTED"
: ", (no username)") +
(u.email
? u.type === "fromEmail"
? `, email: ${u.email}`
: ", email: REDACTED"
: ", (no email)") +
";"
`${
(u.id ? `, id: ${u.id}` : "id: (non user)") +
(u.username
? u.type === "fromUsername"
? `, username: @${u.username}`
: ", username: REDACTED"
: ", (no username)") +
(u.email
? u.type === "fromEmail"
? `, email: ${u.email}`
: ", email: REDACTED"
: ", (no email)")
};`
)
.join("\n")}`
: ""
+4 -2
View File
@@ -1,8 +1,10 @@
import prisma from "@calcom/prisma";
import type { UserList } from "../types/user";
/*
* Extracts usernames (@Example) and emails (hi@example.com) from a string
*/
import type { UserList } from "../types/user";
export const extractUsers = async (text: string) => {
const usernames = text.match(/(?<![a-zA-Z0-9_.])@[a-zA-Z0-9_]+/g)?.map((username) => username.slice(1));
const emails = text.match(/[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+/g);
+16 -1
View File
@@ -24,6 +24,11 @@ const hostSchema = _HostModel.pick({
userId: true,
});
export const childrenSchema = z.object({
id: z.number().int(),
userId: z.number().int(),
});
export const schemaEventTypeBaseBodyParams = EventType.pick({
title: true,
description: true,
@@ -45,6 +50,7 @@ export const schemaEventTypeBaseBodyParams = EventType.pick({
disableGuests: true,
hideCalendarNotes: true,
minimumBookingNotice: true,
parentId: true,
beforeEventBuffer: true,
afterEventBuffer: true,
teamId: true,
@@ -56,7 +62,12 @@ export const schemaEventTypeBaseBodyParams = EventType.pick({
bookingLimits: true,
durationLimits: true,
})
.merge(z.object({ hosts: z.array(hostSchema).optional().default([]) }))
.merge(
z.object({
children: z.array(childrenSchema).optional().default([]),
hosts: z.array(hostSchema).optional().default([]),
})
)
.partial()
.strict();
@@ -73,6 +84,7 @@ const schemaEventTypeCreateParams = z
seatsShowAvailabilityCount: z.boolean().optional(),
bookingFields: eventTypeBookingFields.optional(),
scheduleId: z.number().optional(),
parentId: z.number().optional(),
})
.strict();
@@ -125,6 +137,7 @@ export const schemaEventTypeReadPublic = EventType.pick({
price: true,
currency: true,
slotInterval: true,
parentId: true,
successRedirectUrl: true,
description: true,
locations: true,
@@ -137,6 +150,8 @@ export const schemaEventTypeReadPublic = EventType.pick({
durationLimits: true,
}).merge(
z.object({
children: z.array(childrenSchema).optional().default([]),
hosts: z.array(hostSchema).optional().default([]),
locations: z
.array(
z.object({
@@ -52,6 +52,7 @@ export async function getHandler(req: NextApiRequest) {
team: { select: { slug: true } },
users: true,
owner: { select: { username: true, id: true } },
children: { select: { id: true, userId: true } },
},
});
await checkPermissions(req, eventType);
+1
View File
@@ -46,6 +46,7 @@ async function getHandler(req: NextApiRequest) {
team: { select: { slug: true } },
users: true,
owner: { select: { username: true, id: true } },
children: { select: { id: true, userId: true } },
},
});
// this really should return [], but backwards compatibility..
+11 -1
View File
@@ -6,7 +6,9 @@ import { defaultResponder } from "@calcom/lib/server";
import { schemaEventTypeCreateBodyParams, schemaEventTypeReadPublic } from "~/lib/validations/event-type";
import checkParentEventOwnership from "./_utils/checkParentEventOwnership";
import checkTeamEventEditPermission from "./_utils/checkTeamEventEditPermission";
import checkUserMembership from "./_utils/checkUserMembership";
import ensureOnlyMembersAsHosts from "./_utils/ensureOnlyMembersAsHosts";
/**
@@ -118,10 +120,13 @@ import ensureOnlyMembersAsHosts from "./_utils/ensureOnlyMembersAsHosts";
* schedulingType:
* type: string
* description: The type of scheduling if a Team event. Required for team events only
* enum: [ROUND_ROBIN, COLLECTIVE]
* enum: [ROUND_ROBIN, COLLECTIVE, MANAGED]
* price:
* type: integer
* description: Price of the event type booking
* parentId:
* type: integer
* description: EventTypeId of the parent managed event
* currency:
* type: string
* description: Currency acronym. Eg- usd, eur, gbp, etc.
@@ -276,6 +281,11 @@ async function postHandler(req: NextApiRequest) {
await checkPermissions(req);
if (parsedBody.parentId) {
await checkParentEventOwnership(parsedBody.parentId, userId);
await checkUserMembership(parsedBody.parentId, parsedBody.userId);
}
if (isAdmin && parsedBody.userId) {
data = { ...parsedBody, users: { connect: { id: parsedBody.userId } } };
}
@@ -0,0 +1,52 @@
import { HttpError } from "@calcom/lib/http-error";
/**
* Checks if a user, identified by the provided userId, has ownership (or admin rights) over
* the team associated with the event type identified by the parentId.
*
* @param parentId - The ID of the parent event type.
* @param userId - The ID of the user.
*
* @throws {HttpError} If the parent event type is not found,
* if the parent event type doesn't belong to any team,
* or if the user doesn't have ownership or admin rights to the associated team.
*/
export default async function checkParentEventOwnership(parentId: number, userId: number) {
const parentEventType = await prisma.eventType.findUnique({
where: {
id: parentId,
},
select: {
teamId: true,
},
});
if (!parentEventType) {
throw new HttpError({
statusCode: 404,
message: "Parent event type not found.",
});
}
if (!parentEventType.teamId) {
throw new HttpError({
statusCode: 400,
message: "This event type is not capable of having children",
});
}
const teamMember = await prisma.membership.findFirst({
where: {
teamId: parentEventType.teamId,
userId: userId,
OR: [{ role: "OWNER" }, { role: "ADMIN" }],
},
});
if (!teamMember) {
throw new HttpError({
statusCode: 403,
message: "User is not authorized to access the team to which the parent event type belongs.",
});
}
}
@@ -0,0 +1,52 @@
import { HttpError } from "@calcom/lib/http-error";
/**
* Checks if a user, identified by the provided userId, is a member of the team associated
* with the event type identified by the parentId.
*
* @param parentId - The ID of the event type.
* @param userId - The ID of the user.
*
* @throws {HttpError} If the event type is not found,
* if the event type doesn't belong to any team,
* or if the user isn't a member of the associated team.
*/
export default async function checkUserMembership(parentId: number, userId: number) {
const parentEventType = await prisma.eventType.findUnique({
where: {
id: parentId,
},
select: {
teamId: true,
},
});
if (!parentEventType) {
throw new HttpError({
statusCode: 404,
message: "Event type not found.",
});
}
if (!parentEventType.teamId) {
throw new HttpError({
statusCode: 400,
message: "This event type is not capable of having children.",
});
}
const teamMember = await prisma.membership.findFirst({
where: {
teamId: parentEventType.teamId,
userId: userId,
accepted: true,
},
});
if (!teamMember) {
throw new HttpError({
statusCode: 400,
message: "User is not a team member.",
});
}
}
+2 -1
View File
@@ -1,3 +1,5 @@
import prismaMock from "../../../../../tests/libs/__mocks__/prisma";
import type { Request, Response } from "express";
import type { NextApiRequest, NextApiResponse } from "next";
import { createMocks } from "node-mocks-http";
@@ -8,7 +10,6 @@ import sendPayload from "@calcom/features/webhooks/lib/sendPayload";
import { buildBooking, buildEventType, buildWebhook } from "@calcom/lib/test/builder";
import prisma from "@calcom/prisma";
import prismaMock from "../../../../../tests/libs/__mocks__/prisma";
import handler from "../../../pages/api/bookings/_post";
type CustomNextApiRequest = NextApiRequest & Request;
+4 -4
View File
@@ -265,8 +265,8 @@ export const AppPage = ({
{price !== 0 && (
<span className="block text-right">
{feeType === "usage-based" ? commission + "% + " + priceInDollar + "/booking" : priceInDollar}
{feeType === "monthly" && "/" + t("month")}
{feeType === "usage-based" ? `${commission}% + ${priceInDollar}/booking` : priceInDollar}
{feeType === "monthly" && `/${t("month")}`}
</span>
)}
@@ -286,7 +286,7 @@ export const AppPage = ({
currency: "USD",
useGrouping: false,
}).format(price)}
{feeType === "monthly" && "/" + t("month")}
{feeType === "monthly" && `/${t("month")}`}
</>
)}
</span>
@@ -323,7 +323,7 @@ export const AppPage = ({
target="_blank"
rel="noreferrer"
className="text-emphasis font-normal no-underline hover:underline"
href={"mailto:" + email}>
href={`mailto:${email}`}>
<Mail className="text-subtle -mt-px mr-1 inline h-4 w-4" />
{email}
@@ -130,7 +130,7 @@ function ConnectedCalendarsList(props: Props) {
title={t("something_went_wrong")}
message={
<span>
<Link href={"/apps/" + item.integration.slug}>{item.integration.name}</Link>:{" "}
<Link href={`/apps/${item.integration.slug}`}>{item.integration.name}</Link>:{" "}
{t("calendar_error")}
</span>
}
@@ -76,7 +76,7 @@ export const InstallAppButtonChild = ({
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
className="max-w-min"
className="w-auto"
onInteractOutside={(event) => {
if (mutation.isLoading) event.preventDefault();
}}>
@@ -95,6 +95,7 @@ export const InstallAppButtonChild = ({
return (
<DropdownItem
className="flex"
type="button"
data-testid={team.isUser ? "install-app-button-personal" : "anything else"}
key={team.id}
+109 -106
View File
@@ -1,4 +1,4 @@
import { useRouter } from "next/navigation";
import Link from "next/link";
import { useState } from "react";
import type { EventLocationType } from "@calcom/app-store/locations";
@@ -26,11 +26,11 @@ import {
DialogFooter,
MeetingTimeInTimezones,
showToast,
Tooltip,
TableActions,
TextAreaField,
Tooltip,
} from "@calcom/ui";
import { Check, Clock, MapPin, RefreshCcw, Send, Ban, X, CreditCard } from "@calcom/ui/components/icon";
import { Ban, Check, Clock, CreditCard, MapPin, RefreshCcw, Send, X } from "@calcom/ui/components/icon";
import useMeQuery from "@lib/hooks/useMeQuery";
@@ -58,7 +58,6 @@ function BookingListItem(booking: BookingItemProps) {
i18n: { language },
} = useLocale();
const utils = trpc.useContext();
const router = useRouter();
const [rejectionReason, setRejectionReason] = useState<string>("");
const [rejectionDialogIsOpen, setRejectionDialogIsOpen] = useState(false);
const [chargeCardDialogIsOpen, setChargeCardDialogIsOpen] = useState(false);
@@ -259,14 +258,16 @@ function BookingListItem(booking: BookingItemProps) {
.concat(booking.recurringInfo?.bookings[BookingStatus.PENDING])
.sort((date1: Date, date2: Date) => date1.getTime() - date2.getTime());
const onClickTableData = () => {
const buildBookingLink = () => {
const urlSearchParams = new URLSearchParams({
allRemainingBookings: isTabRecurring.toString(),
});
if (booking.attendees[0]) urlSearchParams.set("email", booking.attendees[0].email);
router.push(`/booking/${booking.uid}?${urlSearchParams.toString()}`);
return `/booking/${booking.uid}?${urlSearchParams.toString()}`;
};
const bookingLink = buildBookingLink();
const title = booking.title;
// To be used after we run query on legacy bookings
// const showRecordingsButtons = booking.isRecorded && isPast && isConfirmed;
@@ -337,54 +338,11 @@ function BookingListItem(booking: BookingItemProps) {
</Dialog>
<tr data-testid="booking-item" className="hover:bg-muted group flex flex-col sm:flex-row">
<td
className="hidden align-top ltr:pl-6 rtl:pr-6 sm:table-cell sm:min-w-[12rem]"
onClick={onClickTableData}>
<div className="cursor-pointer py-4">
<div className="text-emphasis text-sm leading-6">{startTime}</div>
<div className="text-subtle text-sm">
{formatTime(booking.startTime, user?.timeFormat, user?.timeZone)} -{" "}
{formatTime(booking.endTime, user?.timeFormat, user?.timeZone)}
<MeetingTimeInTimezones
timeFormat={user?.timeFormat}
userTimezone={user?.timeZone}
startTime={booking.startTime}
endTime={booking.endTime}
attendees={booking.attendees}
/>
</div>
{isPending && (
<Badge className="ltr:mr-2 rtl:ml-2" variant="orange">
{t("unconfirmed")}
</Badge>
)}
{booking.eventType?.team && (
<Badge className="ltr:mr-2 rtl:ml-2" variant="gray">
{booking.eventType.team.name}
</Badge>
)}
{booking.paid && !booking.payment[0] ? (
<Badge className="ltr:mr-2 rtl:ml-2" variant="orange">
{t("error_collecting_card")}
</Badge>
) : booking.paid ? (
<Badge className="ltr:mr-2 rtl:ml-2" variant="green" data-testid="paid_badge">
{booking.payment[0].paymentOption === "HOLD" ? t("card_held") : t("paid")}
</Badge>
) : null}
{recurringDates !== undefined && (
<div className="text-muted mt-2 text-sm">
<RecurringBookingsTooltip booking={booking} recurringDates={recurringDates} />
</div>
)}
</div>
</td>
<td className={"w-full px-4" + (isRejected ? " line-through" : "")} onClick={onClickTableData}>
{/* Time and Badges for mobile */}
<div className="w-full pb-2 pt-4 sm:hidden">
<div className="flex w-full items-center justify-between sm:hidden">
<td className="hidden align-top ltr:pl-6 rtl:pr-6 sm:table-cell sm:min-w-[12rem]">
<Link href={bookingLink}>
<div className="cursor-pointer py-4">
<div className="text-emphasis text-sm leading-6">{startTime}</div>
<div className="text-subtle pr-2 text-sm">
<div className="text-subtle text-sm">
{formatTime(booking.startTime, user?.timeFormat, user?.timeZone)} -{" "}
{formatTime(booking.endTime, user?.timeFormat, user?.timeZone)}
<MeetingTimeInTimezones
@@ -395,66 +353,111 @@ function BookingListItem(booking: BookingItemProps) {
attendees={booking.attendees}
/>
</div>
{isPending && (
<Badge className="ltr:mr-2 rtl:ml-2" variant="orange">
{t("unconfirmed")}
</Badge>
)}
{booking.eventType?.team && (
<Badge className="ltr:mr-2 rtl:ml-2" variant="gray">
{booking.eventType.team.name}
</Badge>
)}
{booking.paid && !booking.payment[0] ? (
<Badge className="ltr:mr-2 rtl:ml-2" variant="orange">
{t("error_collecting_card")}
</Badge>
) : booking.paid ? (
<Badge className="ltr:mr-2 rtl:ml-2" variant="green" data-testid="paid_badge">
{booking.payment[0].paymentOption === "HOLD" ? t("card_held") : t("paid")}
</Badge>
) : null}
{recurringDates !== undefined && (
<div className="text-muted mt-2 text-sm">
<RecurringBookingsTooltip booking={booking} recurringDates={recurringDates} />
</div>
)}
</div>
{isPending && (
<Badge className="ltr:mr-2 rtl:ml-2 sm:hidden" variant="orange">
{t("unconfirmed")}
</Badge>
)}
{booking.eventType?.team && (
<Badge className="ltr:mr-2 rtl:ml-2 sm:hidden" variant="gray">
{booking.eventType.team.name}
</Badge>
)}
{!!booking?.eventType?.price && !booking.paid && (
<Badge className="ltr:mr-2 rtl:ml-2 sm:hidden" variant="orange">
{t("pending_payment")}
</Badge>
)}
{recurringDates !== undefined && (
<div className="text-muted text-sm sm:hidden">
<RecurringBookingsTooltip booking={booking} recurringDates={recurringDates} />
</Link>
</td>
<td className={`w-full px-4${isRejected ? " line-through" : ""}`}>
<Link href={bookingLink}>
{/* Time and Badges for mobile */}
<div className="w-full pb-2 pt-4 sm:hidden">
<div className="flex w-full items-center justify-between sm:hidden">
<div className="text-emphasis text-sm leading-6">{startTime}</div>
<div className="text-subtle pr-2 text-sm">
{formatTime(booking.startTime, user?.timeFormat, user?.timeZone)} -{" "}
{formatTime(booking.endTime, user?.timeFormat, user?.timeZone)}
<MeetingTimeInTimezones
timeFormat={user?.timeFormat}
userTimezone={user?.timeZone}
startTime={booking.startTime}
endTime={booking.endTime}
attendees={booking.attendees}
/>
</div>
</div>
)}
</div>
<div className="cursor-pointer py-4">
<div
title={title}
className={classNames(
"max-w-10/12 sm:max-w-56 text-emphasis text-sm font-medium leading-6 md:max-w-full",
isCancelled ? "line-through" : ""
)}>
{title}
<span> </span>
{paymentAppData.enabled && !booking.paid && booking.payment.length && (
<Badge className="me-2 ms-2 hidden sm:inline-flex" variant="orange">
{isPending && (
<Badge className="ltr:mr-2 rtl:ml-2 sm:hidden" variant="orange">
{t("unconfirmed")}
</Badge>
)}
{booking.eventType?.team && (
<Badge className="ltr:mr-2 rtl:ml-2 sm:hidden" variant="gray">
{booking.eventType.team.name}
</Badge>
)}
{!!booking?.eventType?.price && !booking.paid && (
<Badge className="ltr:mr-2 rtl:ml-2 sm:hidden" variant="orange">
{t("pending_payment")}
</Badge>
)}
{recurringDates !== undefined && (
<div className="text-muted text-sm sm:hidden">
<RecurringBookingsTooltip booking={booking} recurringDates={recurringDates} />
</div>
)}
</div>
{booking.description && (
<div className="cursor-pointer py-4">
<div
className="max-w-10/12 sm:max-w-32 md:max-w-52 xl:max-w-80 text-default truncate text-sm"
title={booking.description}>
&quot;{booking.description}&quot;
title={title}
className={classNames(
"max-w-10/12 sm:max-w-56 text-emphasis text-sm font-medium leading-6 md:max-w-full",
isCancelled ? "line-through" : ""
)}>
{title}
<span> </span>
{paymentAppData.enabled && !booking.paid && booking.payment.length && (
<Badge className="me-2 ms-2 hidden sm:inline-flex" variant="orange">
{t("pending_payment")}
</Badge>
)}
</div>
)}
{booking.attendees.length !== 0 && (
<DisplayAttendees
attendees={booking.attendees}
user={booking.user}
currentEmail={user?.email}
/>
)}
{isCancelled && booking.rescheduled && (
<div className="mt-2 inline-block md:hidden">
<RequestSentMessage />
</div>
)}
</div>
{booking.description && (
<div
className="max-w-10/12 sm:max-w-32 md:max-w-52 xl:max-w-80 text-default truncate text-sm"
title={booking.description}>
&quot;{booking.description}&quot;
</div>
)}
{booking.attendees.length !== 0 && (
<DisplayAttendees
attendees={booking.attendees}
user={booking.user}
currentEmail={user?.email}
/>
)}
{isCancelled && booking.rescheduled && (
<div className="mt-2 inline-block md:hidden">
<RequestSentMessage />
</div>
)}
</div>
</Link>
</td>
<td className="flex w-full justify-end py-4 pl-4 text-right text-sm font-medium ltr:pr-4 rtl:pl-4 sm:pl-0">
{isUpcoming && !isCancelled ? (
@@ -573,7 +576,7 @@ const FirstAttendee = ({
<a
key={user.email}
className=" hover:text-blue-500"
href={"mailto:" + user.email}
href={`mailto:${user.email}`}
onClick={(e) => e.stopPropagation()}>
{user.name}
</a>
@@ -587,7 +590,7 @@ type AttendeeProps = {
const Attendee = ({ email, name }: AttendeeProps) => {
return (
<a className="hover:text-blue-500" href={"mailto:" + email} onClick={(e) => e.stopPropagation()}>
<a className="hover:text-blue-500" href={`mailto:${email}`} onClick={(e) => e.stopPropagation()}>
{name || email}
</a>
);
@@ -121,7 +121,7 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Invalid URL for ${eventLocationType.label}. ${
sampleUrl ? "Sample URL: " + sampleUrl : ""
sampleUrl ? `Sample URL: ${sampleUrl}` : ""
}`,
});
}
@@ -67,7 +67,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
(!user?.theme && typeof document !== "undefined" && document.documentElement.classList.contains("dark"));
eventType.bookingFields.forEach(({ name }) => {
bookingFields[name] = name + " input";
bookingFields[name] = `${name} input`;
});
const eventNameObject: EventNameObjectType = {
@@ -189,7 +189,7 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
value: 0,
},
...[5, 10, 15, 20, 30, 45, 60, 90, 120].map((minutes) => ({
label: minutes + " " + t("minutes"),
label: `${minutes} ${t("minutes")}`,
value: minutes,
})),
];
@@ -225,7 +225,7 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
value: 0,
},
...[5, 10, 15, 20, 30, 45, 60, 90, 120].map((minutes) => ({
label: minutes + " " + t("minutes"),
label: `${minutes} ${t("minutes")}`,
value: minutes,
})),
];
@@ -272,7 +272,7 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
value: -1,
},
...[5, 10, 15, 20, 30, 45, 60, 75, 90, 105, 120].map((minutes) => ({
label: minutes + " " + t("minutes"),
label: `${minutes} ${t("minutes")}`,
value: minutes,
})),
];
@@ -13,8 +13,10 @@ import type { EventLocationType } 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 { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
import { classNames } from "@calcom/lib";
import { CAL_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import invertLogoOnDark from "@calcom/lib/invertLogoOnDark";
import { md } from "@calcom/lib/markdownIt";
import { slugify } from "@calcom/lib/slugify";
import turndown from "@calcom/lib/turndownService";
@@ -131,12 +133,12 @@ export const EventSetupTab = (
};
});
const multipleDurationOptions = [5, 10, 15, 20, 25, 30, 45, 50, 60, 75, 80, 90, 120, 150, 180].map(
(mins) => ({
value: mins,
label: t("multiple_duration_mins", { count: mins }),
})
);
const multipleDurationOptions = [
5, 10, 15, 20, 25, 30, 45, 50, 60, 75, 80, 90, 120, 150, 180, 240, 480,
].map((mins) => ({
value: mins,
label: t("multiple_duration_mins", { count: mins }),
}));
const [selectedMultipleDuration, setSelectedMultipleDuration] = useState<
MultiValue<{
@@ -292,7 +294,6 @@ export const EventSetupTab = (
const eventLabel =
location[eventLocationType.defaultValueVariable] || t(eventLocationType.label);
return (
<li
key={`${location.type}${index}`}
@@ -301,7 +302,10 @@ export const EventSetupTab = (
<div className="flex items-center">
<img
src={eventLocationType.iconUrl}
className="h-4 w-4 dark:invert-[.65]"
className={classNames(
"h-4 w-4",
classNames(invertLogoOnDark(eventLocationType.iconUrl))
)}
alt={`${eventLocationType.label} logo`}
/>
<span className="ms-1 line-clamp-1 text-sm">{`${eventLabel} ${
@@ -247,7 +247,7 @@ function EventTypeSingleLayout({
return (
<Shell
backPath="/event-types"
title={eventType.title + " | " + t("event_type")}
title={`${eventType.title} | ${t("event_type")}`}
heading={eventType.title}
CTA={
<div className="flex items-center justify-end">
@@ -47,6 +47,11 @@ export default function RecurringEventController({
<Alert severity="warning" title={t("warning_payment_recurring_event")} />
) : (
<>
<Alert
className="mb-4"
severity="warning"
title="Experimental: Recurring Events are currently experimental and causes some issues sometimes when checking for availability. We are working on fixing this."
/>
<SettingsToggle
toggleSwitchAtTheEnd={true}
switchContainerClassName={classNames(
+1 -1
View File
@@ -22,7 +22,7 @@ const Member = ({ member, teamName }: { member: MemberType; teamName: string | n
return (
<Link key={member.id} href={{ pathname: `/${member.username}`, query: queryParamsToForward }}>
<div className="sm:min-w-80 sm:max-w-80 bg-default hover:bg-muted border-subtle group flex min-h-full flex-col space-y-2 rounded-md border p-4 hover:cursor-pointer">
<Avatar size="md" alt={member.name || ""} imageSrc={"/" + member.username + "/avatar.png"} />
<Avatar size="md" alt={member.name || ""} imageSrc={`/${member.username}/avatar.png`} />
<section className="mt-2 line-clamp-4 w-full space-y-1">
<p className="text-default font-medium">{member.name}</p>
<div className="text-subtle line-clamp-3 overflow-ellipsis text-sm font-normal">
@@ -4,6 +4,7 @@ import { components } from "react-select";
import type { EventLocationType } from "@calcom/app-store/locations";
import type { CredentialDataWithTeamName } from "@calcom/app-store/utils";
import { classNames } from "@calcom/lib";
import invertLogoOnDark from "@calcom/lib/invertLogoOnDark";
import { Select } from "@calcom/ui";
export type LocationOption = {
@@ -22,7 +23,7 @@ export type GroupOptionType = GroupBase<LocationOption>;
const OptionWithIcon = ({ icon, label }: { icon?: string; label: string }) => {
return (
<div className="flex items-center gap-3">
{icon && <img src={icon} alt="cover" className="h-3.5 w-3.5 dark:invert-[.65]" />}
{icon && <img src={icon} alt="cover" className={classNames("h-3.5 w-3.5", invertLogoOnDark(icon))} />}
<span className={classNames("text-sm font-medium")}>{label}</span>
</div>
);
+2 -2
View File
@@ -179,14 +179,14 @@ function getThemeProviderProps({
);
}
const appearanceIdSuffix = themeBasis ? ":" + themeBasis : "";
const appearanceIdSuffix = themeBasis ? `:${themeBasis}` : "";
const forcedTheme = themeSupport === ThemeSupport.None ? "light" : undefined;
let embedExplicitlySetThemeSuffix = "";
if (typeof window !== "undefined") {
const embedTheme = window.getEmbedTheme();
if (embedTheme) {
embedExplicitlySetThemeSuffix = ":" + embedTheme;
embedExplicitlySetThemeSuffix = `:${embedTheme}`;
}
}
+1 -1
View File
@@ -21,7 +21,7 @@ function getCspPolicy(nonce: string) {
script-src ${
IS_PRODUCTION
? // 'self' 'unsafe-inline' https: added for Browsers not supporting strict-dynamic not supporting strict-dynamic
"'nonce-" + nonce + "' 'strict-dynamic' 'self' 'unsafe-inline' https:"
`'nonce-${nonce}' 'strict-dynamic' 'self' 'unsafe-inline' https:`
: // Note: We could use 'strict-dynamic' with 'nonce-..' instead of unsafe-inline but there are some streaming related scripts that get blocked(because they don't have nonce on them). It causes a really frustrating full page error model by Next.js to show up sometimes
"'unsafe-inline' 'unsafe-eval' https: http:"
};
+3 -8
View File
@@ -15,14 +15,9 @@ export default function withEmbedSsr(getServerSideProps: GetServerSideProps) {
const destinationUrlObj = new URL(ssrResponse.redirect.destination, "https://base");
// Make sure that redirect happens to /embed page and pass on embed query param as is for preserving Cal JS API namespace
const newDestinationUrl =
destinationUrlObj.pathname +
"/embed?" +
destinationUrlObj.searchParams.toString() +
"&layout=" +
layout +
"&embed=" +
embed;
const newDestinationUrl = `${
destinationUrlObj.pathname
}/embed?${destinationUrlObj.searchParams.toString()}&layout=${layout}&embed=${embed}`;
return {
...ssrResponse,
+2 -1
View File
@@ -40,7 +40,7 @@ const middleware: NextMiddleware = async (req) => {
requestHeaders.set("x-cal-timezone", req.headers.get("x-vercel-ip-timezone") ?? "");
}
if (url.pathname.startsWith("/auth/login")) {
if (url.pathname.startsWith("/auth/login") || url.pathname.startsWith("/login")) {
// Use this header to actually enforce CSP, otherwise it is running in Report Only mode on all pages.
requestHeaders.set("x-csp-enforce", "true");
}
@@ -68,6 +68,7 @@ export const config = {
matcher: [
"/:path*/embed",
"/api/trpc/:path*",
"/login",
"/auth/login",
/**
* Paths required by routingForms.handle
+2 -2
View File
@@ -21,11 +21,11 @@ process.env.NEXT_PUBLIC_CALCOM_VERSION = version;
// So we can test deploy previews preview
if (process.env.VERCEL_URL && !process.env.NEXT_PUBLIC_WEBAPP_URL) {
process.env.NEXT_PUBLIC_WEBAPP_URL = "https://" + process.env.VERCEL_URL;
process.env.NEXT_PUBLIC_WEBAPP_URL = `https://${process.env.VERCEL_URL}`;
}
// Check for configuration of NEXTAUTH_URL before overriding
if (!process.env.NEXTAUTH_URL && process.env.NEXT_PUBLIC_WEBAPP_URL) {
process.env.NEXTAUTH_URL = process.env.NEXT_PUBLIC_WEBAPP_URL + "/api/auth";
process.env.NEXTAUTH_URL = `${process.env.NEXT_PUBLIC_WEBAPP_URL}/api/auth`;
}
if (!process.env.NEXT_PUBLIC_WEBSITE_URL) {
process.env.NEXT_PUBLIC_WEBSITE_URL = process.env.NEXT_PUBLIC_WEBAPP_URL;
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "3.3.4",
"version": "3.3.6",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
@@ -49,6 +49,7 @@
"@radix-ui/react-collapsible": "^1.0.0",
"@radix-ui/react-dialog": "^1.0.4",
"@radix-ui/react-dropdown-menu": "^2.0.5",
"@radix-ui/react-hover-card": "^1.0.7",
"@radix-ui/react-id": "^1.0.0",
"@radix-ui/react-popover": "^1.0.2",
"@radix-ui/react-radio-group": "^1.0.0",
+1 -1
View File
@@ -125,7 +125,7 @@ export function UserPage(props: InferGetServerSidePropsType<typeof getServerSide
{user.away ? (
<div className="overflow-hidden rounded-sm border ">
<div className="text-muted p-8 text-center">
<h2 className="font-cal text-default mb-2 text-3xl">😴{" " + t("user_away")}</h2>
<h2 className="font-cal text-default mb-2 text-3xl">😴{` ${t("user_away")}`}</h2>
<p className="mx-auto max-w-md">{t("user_away_description") as string}</p>
</div>
</div>
+14
View File
@@ -0,0 +1,14 @@
import type { NextApiRequest, NextApiResponse } from "next";
import isAuthorized from "@calcom/features/auth/lib/oAuthAuthorization";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const requriedScopes = ["READ_PROFILE"];
const account = await isAuthorized(req, requriedScopes);
if (!account) {
return res.status(401).json({ message: "Unauthorized" });
}
return res.status(201).json({ username: account.name });
}
@@ -0,0 +1,68 @@
import jwt from "jsonwebtoken";
import type { NextApiRequest, NextApiResponse } from "next";
import type { OAuthTokenPayload } from "pages/api/auth/oauth/token";
import prisma from "@calcom/prisma";
import { generateSecret } from "@calcom/trpc/server/routers/viewer/oAuth/addClient.handler";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== "POST") {
res.status(405).json({ message: "Invalid method" });
return;
}
const refreshToken = req.headers.authorization?.split(" ")[1] || "";
const { client_id, client_secret, grant_type } = req.body;
if (grant_type !== "refresh_token") {
res.status(400).json({ message: "grant type invalid" });
return;
}
const [hashedSecret] = generateSecret(client_secret);
const client = await prisma.oAuthClient.findFirst({
where: {
clientId: client_id,
clientSecret: hashedSecret,
},
select: {
redirectUri: true,
},
});
if (!client) {
res.status(401).json({ message: "Unauthorized" });
return;
}
const secretKey = process.env.CALENDSO_ENCRYPTION_KEY || "";
let decodedRefreshToken: OAuthTokenPayload;
try {
decodedRefreshToken = jwt.verify(refreshToken, secretKey) as OAuthTokenPayload;
} catch {
res.status(401).json({ message: "Unauthorized" });
return;
}
if (!decodedRefreshToken || decodedRefreshToken.token_type !== "Refresh Token") {
res.status(401).json({ message: "Unauthorized" });
return;
}
const payload: OAuthTokenPayload = {
userId: decodedRefreshToken.userId,
scope: decodedRefreshToken.scope,
token_type: "Access Token",
clientId: client_id,
};
const access_token = jwt.sign(payload, secretKey, {
expiresIn: 1800, // 30 min
});
res.status(200).json({ access_token });
}
+104
View File
@@ -0,0 +1,104 @@
import jwt from "jsonwebtoken";
import type { NextApiRequest, NextApiResponse } from "next";
import prisma from "@calcom/prisma";
import { generateSecret } from "@calcom/trpc/server/routers/viewer/oAuth/addClient.handler";
export type OAuthTokenPayload = {
userId?: number | null;
teamId?: number | null;
token_type: string;
scope: string[];
clientId: string;
};
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== "POST") {
res.status(405).json({ message: "Invalid method" });
return;
}
const { code, client_id, client_secret, grant_type, redirect_uri } = req.body;
if (grant_type !== "authorization_code") {
res.status(400).json({ message: "grant_type invalid" });
return;
}
const [hashedSecret] = generateSecret(client_secret);
const client = await prisma.oAuthClient.findFirst({
where: {
clientId: client_id,
clientSecret: hashedSecret,
},
select: {
redirectUri: true,
},
});
if (!client || client.redirectUri !== redirect_uri) {
res.status(401).json({ message: "Unauthorized" });
return;
}
const accessCode = await prisma.accessCode.findFirst({
where: {
code: code,
clientId: client_id,
expiresAt: {
gt: new Date(),
},
},
});
//delete all expired accessCodes + the one that is used here
await prisma.accessCode.deleteMany({
where: {
OR: [
{
expiresAt: {
lt: new Date(),
},
},
{
code: code,
clientId: client_id,
},
],
},
});
if (!accessCode) {
res.status(401).json({ message: "Unauthorized" });
return;
}
const secretKey = process.env.CALENDSO_ENCRYPTION_KEY || "";
const payloadAuthToken: OAuthTokenPayload = {
userId: accessCode.userId,
teamId: accessCode.teamId,
scope: accessCode.scopes,
token_type: "Access Token",
clientId: client_id,
};
const payloadRefreshToken: OAuthTokenPayload = {
userId: accessCode.userId,
teamId: accessCode.teamId,
scope: accessCode.scopes,
token_type: "Refresh Token",
clientId: client_id,
};
const access_token = jwt.sign(payloadAuthToken, secretKey, {
expiresIn: 1800, // 30 min
});
const refresh_token = jwt.sign(payloadRefreshToken, secretKey, {
expiresIn: 30 * 24 * 60 * 60, // 30 days
});
res.status(200).json({ access_token, refresh_token });
}
@@ -19,7 +19,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return res.status(400).json({ message: "Google client_secret missing." });
// use differnt callback to normal calendar connection
const redirect_uri = WEBAPP_URL + "/api/teams/googleworkspace/callback";
const redirect_uri = `${WEBAPP_URL}/api/teams/googleworkspace/callback`;
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri);
const authUrl = oAuth2Client.generateAuthUrl({
@@ -36,7 +36,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
if (!client_secret || typeof client_secret !== "string")
return res.status(400).json({ message: "Google client_secret missing." });
const redirect_uri = WEBAPP_URL + "/api/teams/googleworkspace/callback";
const redirect_uri = `${WEBAPP_URL}/api/teams/googleworkspace/callback`;
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri);
if (!code) {
@@ -54,11 +54,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
});
if (!teamId) {
res.redirect(getSafeRedirectUrl(WEBAPP_URL + "/settings") ?? `${WEBAPP_URL}/teams`);
res.redirect(getSafeRedirectUrl(`${WEBAPP_URL}/settings`) ?? `${WEBAPP_URL}/teams`);
}
res.redirect(
getSafeRedirectUrl(WEBAPP_URL + `/settings/teams/${teamId}/members?inviteModal=true&bulk=true`) ??
getSafeRedirectUrl(`${WEBAPP_URL}/settings/teams/${teamId}/members?inviteModal=true&bulk=true`) ??
`${WEBAPP_URL}/teams`
);
}
+4
View File
@@ -0,0 +1,4 @@
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
import { oAuthRouter } from "@calcom/trpc/server/routers/viewer/oAuth/_router";
export default createNextApiHandler(oAuthRouter);
+1 -1
View File
@@ -31,7 +31,7 @@ export default function Apps({ categories }: inferSSRProps<typeof getServerSideP
{categories.map((category) => (
<Link
key={category.name}
href={"/apps/categories/" + category.name}
href={`/apps/categories/${category.name}`}
data-testid={`app-store-category-${category.name}`}
className="bg-subtle relative flex rounded-sm px-6 py-4 sm:block">
<div className="self-center">
+179
View File
@@ -0,0 +1,179 @@
import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import { useSearchParams } from "next/navigation";
import { useState, useEffect } from "react";
import { APP_NAME } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Avatar, Button, Select } from "@calcom/ui";
import { Plus, Info } from "@calcom/ui/components/icon";
import PageWrapper from "@components/PageWrapper";
export default function Authorize() {
const { t } = useLocale();
const { status } = useSession();
const router = useRouter();
const searchParams = useSearchParams();
const client_id = searchParams?.get("client_id") as string;
const state = searchParams?.get("state") as string;
const scope = searchParams?.get("scope") as string;
const queryString = searchParams.toString();
const [selectedAccount, setSelectedAccount] = useState<{ value: string; label: string } | null>();
const scopes = scope ? scope.toString().split(",") : [];
const { data: client, isLoading: isLoadingGetClient } = trpc.viewer.oAuth.getClient.useQuery(
{
clientId: client_id as string,
},
{
enabled: status !== "loading",
}
);
const { data, isLoading: isLoadingProfiles } = trpc.viewer.teamsAndUserProfilesQuery.useQuery();
const generateAuthCodeMutation = trpc.viewer.oAuth.generateAuthCode.useMutation({
onSuccess: (data) => {
window.location.href = `${client?.redirectUri}?code=${data.authorizationCode}&state=${state}`;
},
});
const mappedProfiles = data
? data
.filter((profile) => !profile.readOnly)
.map((profile) => ({
label: profile.name || profile.slug || "",
value: profile.slug || "",
}))
: [];
useEffect(() => {
if (mappedProfiles.length > 0) {
setSelectedAccount(mappedProfiles[0]);
}
}, [isLoadingProfiles]);
useEffect(() => {
if (status === "unauthenticated") {
const urlSearchParams = new URLSearchParams({
callbackUrl: `auth/oauth2/authorize?${queryString}`,
});
router.replace(`/auth/login?${urlSearchParams.toString()}`);
}
}, [status]);
const isLoading = isLoadingGetClient || isLoadingProfiles || status !== "authenticated";
if (isLoading) {
return <></>;
}
if (!client) {
return <div>{t("unauthorized")}</div>;
}
return (
<div className="flex min-h-screen items-center justify-center">
<div className="mt-2 max-w-xl rounded-md bg-white px-9 pb-3 pt-2">
<div className="flex items-center justify-center">
<Avatar
alt=""
fallback={<Plus className="text-subtle h-6 w-6" />}
className="items-center"
imageSrc={client.logo}
size="lg"
/>
<div className="relative -ml-6 h-24 w-24">
<div className="absolute inset-0 flex items-center justify-center">
<div className="flex h-[70px] w-[70px] items-center justify-center rounded-full bg-white">
<img src="/cal-com-icon.svg" alt="Logo" className="h-16 w-16 rounded-full" />
</div>
</div>
</div>
</div>
<h1 className="px-5 pb-5 pt-3 text-center text-2xl font-bold tracking-tight">
{t("access_cal_account", { clientName: client.name, appName: APP_NAME })}
</h1>
<div className="mb-1 text-sm font-medium">{t("select_account_team")}</div>
<Select
isSearchable={true}
id="account-select"
onChange={(value) => {
setSelectedAccount(value);
}}
className="w-52"
defaultValue={selectedAccount || mappedProfiles[0]}
options={mappedProfiles}
/>
<div className="mb-4 mt-5 font-medium">{t("allow_client_to", { clientName: client.name })}</div>
<ul className="space-y-4 text-sm">
<li className="relative pl-5">
<span className="absolute left-0">&#10003;</span>{" "}
{t("associate_with_cal_account", { clientName: client.name })}
</li>
<li className="relative pl-5">
<span className="absolute left-0">&#10003;</span> {t("see_personal_info")}
</li>
<li className="relative pl-5">
<span className="absolute left-0">&#10003;</span> {t("see_primary_email_address")}
</li>
<li className="relative pl-5">
<span className="absolute left-0">&#10003;</span> {t("connect_installed_apps")}
</li>
<li className="relative pl-5">
<span className="absolute left-0">&#10003;</span> {t("access_event_type")}
</li>
<li className="relative pl-5">
<span className="absolute left-0">&#10003;</span> {t("access_availability")}
</li>
<li className="relative pl-5">
<span className="absolute left-0">&#10003;</span> {t("access_bookings")}
</li>
</ul>
<div className="bg-subtle mb-8 mt-8 flex rounded-md p-3">
<div>
<Info className="mr-1 mt-0.5 h-4 w-4" />
</div>
<div className="ml-1 ">
<div className="mb-1 text-sm font-medium">
{t("allow_client_to_do", { clientName: client.name })}
</div>
<div className="text-sm">{t("oauth_access_information", { appName: APP_NAME })}</div>{" "}
</div>
</div>
<div className="border-subtle border- -mx-9 mb-4 border-b" />
<div className="flex justify-end">
<Button
className="mr-2"
color="minimal"
onClick={() => {
window.location.href = `${client.redirectUri}`;
}}>
{t("go_back")}
</Button>
<Button
onClick={() => {
generateAuthCodeMutation.mutate({
clientId: client_id as string,
scopes,
teamSlug: selectedAccount?.value.startsWith("team/")
? selectedAccount?.value.substring(5)
: undefined, // team account starts with /team/<slug>
});
}}
data-testid="allow-button">
{t("allow")}
</Button>
</div>
</div>
</div>
);
}
Authorize.PageWrapper = PageWrapper;
+4 -4
View File
@@ -30,12 +30,12 @@ export default function Provider(props: SSOProviderPageProps) {
const email = searchParams?.get("email");
if (!email) {
router.push("/auth/error?error=" + "Email not provided");
router.push(`/auth/error?error=Email not provided`);
return;
}
if (!props.isSAMLLoginEnabled) {
router.push("/auth/error?error=" + "SAML login not enabled");
router.push(`/auth/error?error=SAML login not enabled`);
return;
}
@@ -56,7 +56,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
const providerParam = asStringOrNull(context.query.provider);
const emailParam = asStringOrNull(context.query.email);
const usernameParam = asStringOrNull(context.query.username);
const successDestination = "/getting-started" + (usernameParam ? `?username=${usernameParam}` : "");
const successDestination = `/getting-started${usernameParam ? `?username=${usernameParam}` : ""}`;
if (!providerParam) {
throw new Error(`File is not named sso/[provider]`);
}
@@ -120,7 +120,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
if (error) {
return {
redirect: {
destination: "/auth/error?error=" + error,
destination: `/auth/error?error=${error}`,
permanent: false,
},
};
+1 -1
View File
@@ -120,7 +120,7 @@ export default function Verify() {
? "Your payment failed"
: sessionId
? "Payment successful!"
: "Verify your email" + " | " + APP_NAME}
: `Verify your email | ${APP_NAME}`}
</title>
</Head>
<div className="flex min-h-screen flex-col items-center justify-center px-6">
+1 -1
View File
@@ -151,7 +151,7 @@ export default function Availability() {
return (
<Shell
backPath={fromEventType ? true : "/availability"}
title={schedule?.name ? schedule.name + " | " + t("availability") : t("availability")}
title={schedule?.name ? `${schedule.name} | ${t("availability")}` : t("availability")}
heading={
<Controller
control={form.control}
+2 -2
View File
@@ -133,7 +133,7 @@ Troubleshoot.PageWrapper = PageWrapper;
function convertMinsToHrsMins(mins: number) {
const h = Math.floor(mins / 60);
const m = mins % 60;
const hs = h < 10 ? "0" + h : h;
const ms = m < 10 ? "0" + m : m;
const hs = h < 10 ? `0${h}` : h;
const ms = m < 10 ? `0${m}` : m;
return `${hs}:${ms}`;
}
+41 -40
View File
@@ -269,13 +269,13 @@ export default function Success(props: SuccessProps) {
}
if (needsConfirmation) {
if (props.profile.name !== null) {
return t("user_needs_to_confirm_or_reject_booking" + titleSuffix, {
return t(`user_needs_to_confirm_or_reject_booking${titleSuffix}`, {
user: props.profile.name,
});
}
return t("needs_to_be_confirmed_or_rejected" + titleSuffix);
return t(`needs_to_be_confirmed_or_rejected${titleSuffix}`);
}
return t("emailed_you_and_attendees" + titleSuffix);
return t(`emailed_you_and_attendees${titleSuffix}`);
}
// This is a weird case where the same route can be opened in booking flow as a success page or as a booking detail page from the app
@@ -592,23 +592,24 @@ export default function Success(props: SuccessProps) {
</span>
<div className="justify-left mt-1 flex text-left sm:mt-0">
<Link
href={
`https://calendar.google.com/calendar/r/eventedit?dates=${date
.utc()
.format("YYYYMMDDTHHmmss[Z]")}/${date
.add(calculatedDuration, "minute")
.utc()
.format("YYYYMMDDTHHmmss[Z]")}&text=${eventName}&details=${
props.eventType.description
}` +
(typeof locationVideoCallUrl === "string"
? "&location=" + encodeURIComponent(locationVideoCallUrl)
: "") +
(props.eventType.recurringEvent
? "&recur=" +
encodeURIComponent(new RRule(props.eventType.recurringEvent).toString())
: "")
}
href={`https://calendar.google.com/calendar/r/eventedit?dates=${date
.utc()
.format("YYYYMMDDTHHmmss[Z]")}/${date
.add(calculatedDuration, "minute")
.utc()
.format("YYYYMMDDTHHmmss[Z]")}&text=${eventName}&details=${
props.eventType.description
}${
typeof locationVideoCallUrl === "string"
? `&location=${encodeURIComponent(locationVideoCallUrl)}`
: ""
}${
props.eventType.recurringEvent
? `&recur=${encodeURIComponent(
new RRule(props.eventType.recurringEvent).toString()
)}`
: ""
}`}
className="text-default border-subtle h-10 w-10 rounded-sm border px-3 py-2 ltr:mr-2 rtl:ml-2">
<svg
className="-mt-1.5 inline-block h-4 w-4"
@@ -622,17 +623,17 @@ export default function Success(props: SuccessProps) {
<Link
href={
encodeURI(
"https://outlook.live.com/calendar/0/deeplink/compose?body=" +
props.eventType.description +
"&enddt=" +
date.add(calculatedDuration, "minute").utc().format() +
"&path=%2Fcalendar%2Faction%2Fcompose&rru=addevent&startdt=" +
date.utc().format() +
"&subject=" +
eventName
`https://outlook.live.com/calendar/0/deeplink/compose?body=${
props.eventType.description
}&enddt=${date
.add(calculatedDuration, "minute")
.utc()
.format()}&path=%2Fcalendar%2Faction%2Fcompose&rru=addevent&startdt=${date
.utc()
.format()}&subject=${eventName}`
) +
(locationVideoCallUrl
? "&location=" + encodeURIComponent(locationVideoCallUrl)
? `&location=${encodeURIComponent(locationVideoCallUrl)}`
: "")
}
className="border-subtle text-default mx-2 h-10 w-10 rounded-sm border px-3 py-2"
@@ -649,17 +650,17 @@ export default function Success(props: SuccessProps) {
<Link
href={
encodeURI(
"https://outlook.office.com/calendar/0/deeplink/compose?body=" +
props.eventType.description +
"&enddt=" +
date.add(calculatedDuration, "minute").utc().format() +
"&path=%2Fcalendar%2Faction%2Fcompose&rru=addevent&startdt=" +
date.utc().format() +
"&subject=" +
eventName
`https://outlook.office.com/calendar/0/deeplink/compose?body=${
props.eventType.description
}&enddt=${date
.add(calculatedDuration, "minute")
.utc()
.format()}&path=%2Fcalendar%2Faction%2Fcompose&rru=addevent&startdt=${date
.utc()
.format()}&subject=${eventName}`
) +
(locationVideoCallUrl
? "&location=" + encodeURIComponent(locationVideoCallUrl)
? `&location=${encodeURIComponent(locationVideoCallUrl)}`
: "")
}
className="text-default border-subtle mx-2 h-10 w-10 rounded-sm border px-3 py-2"
@@ -674,9 +675,9 @@ export default function Success(props: SuccessProps) {
</svg>
</Link>
<Link
href={"data:text/calendar," + eventLink()}
href={`data:text/calendar,${eventLink()}`}
className="border-subtle text-default mx-2 h-10 w-10 rounded-sm border px-3 py-2"
download={props.eventType.title + ".ics"}>
download={`${props.eventType.title}.ics`}>
<svg
version="1.1"
fill="currentColor"
+3
View File
@@ -27,6 +27,7 @@ export default function Type({
isTeamEvent,
entity,
duration,
hashedLink,
}: PageProps) {
return (
<main className={getBookerWrapperClasses({ isEmbed: !!isEmbed })}>
@@ -46,6 +47,7 @@ export default function Type({
isTeamEvent={isTeamEvent}
entity={entity}
duration={duration}
hashedLink={hashedLink}
/>
</main>
);
@@ -149,6 +151,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
// Sending the team event from the server, because this template file
// is reused for both team and user events.
isTeamEvent,
hashedLink: link,
},
};
}
+13 -13
View File
@@ -140,13 +140,13 @@ const Item = ({ type, group, readOnly }: { type: EventType; group: EventTypeGrou
<div>
<span
className="text-default font-semibold ltr:mr-1 rtl:ml-1"
data-testid={"event-type-title-" + type.id}>
data-testid={`event-type-title-${type.id}`}>
{type.title}
</span>
{group.profile.slug ? (
<small
className="text-subtle hidden font-normal leading-4 sm:inline"
data-testid={"event-type-slug-" + type.id}>
data-testid={`event-type-slug-${type.id}`}>
{`/${
type.schedulingType !== SchedulingType.MANAGED ? group.profile.slug : t("username_placeholder")
}/${type.slug}`}
@@ -177,13 +177,13 @@ const Item = ({ type, group, readOnly }: { type: EventType; group: EventTypeGrou
<div>
<span
className="text-default font-semibold ltr:mr-1 rtl:ml-1"
data-testid={"event-type-title-" + type.id}>
data-testid={`event-type-title-${type.id}`}>
{type.title}
</span>
{group.profile.slug ? (
<small
className="text-subtle hidden font-normal leading-4 sm:inline"
data-testid={"event-type-slug-" + type.id}>
data-testid={`event-type-slug-${type.id}`}>
{`/${group.profile.slug}/${type.slug}`}
</small>
) : null}
@@ -479,7 +479,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
</>
)}
<Dropdown modal={false}>
<DropdownMenuTrigger asChild data-testid={"event-type-options-" + type.id}>
<DropdownMenuTrigger asChild data-testid={`event-type-options-${type.id}`}>
<Button
type="button"
variant="icon"
@@ -493,9 +493,9 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
<DropdownMenuItem>
<DropdownItem
type="button"
data-testid={"event-type-edit-" + type.id}
data-testid={`event-type-edit-${type.id}`}
StartIcon={Edit2}
onClick={() => router.push("/event-types/" + type.id)}>
onClick={() => router.push(`/event-types/${type.id}`)}>
{t("edit")}
</DropdownItem>
</DropdownMenuItem>
@@ -505,7 +505,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
<DropdownMenuItem className="outline-none">
<DropdownItem
type="button"
data-testid={"event-type-duplicate-" + type.id}
data-testid={`event-type-duplicate-${type.id}`}
StartIcon={Copy}
onClick={() => openDuplicateModal(type, group)}>
{t("duplicate")}
@@ -555,7 +555,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
</div>
<div className="min-w-9 mx-5 flex sm:hidden">
<Dropdown>
<DropdownMenuTrigger asChild data-testid={"event-type-options-" + type.id}>
<DropdownMenuTrigger asChild data-testid={`event-type-options-${type.id}`}>
<Button type="button" variant="icon" color="secondary" StartIcon={MoreHorizontal} />
</DropdownMenuTrigger>
<DropdownMenuPortal>
@@ -573,7 +573,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
</DropdownMenuItem>
<DropdownMenuItem className="outline-none">
<DropdownItem
data-testid={"event-type-duplicate-" + type.id}
data-testid={`event-type-duplicate-${type.id}`}
onClick={() => {
navigator.clipboard.writeText(calLink);
showToast(t("link_copied"), "success");
@@ -588,7 +588,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
{isNativeShare ? (
<DropdownMenuItem className="outline-none">
<DropdownItem
data-testid={"event-type-duplicate-" + type.id}
data-testid={`event-type-duplicate-${type.id}`}
onClick={() => {
navigator
.share({
@@ -608,7 +608,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
{!readOnly && (
<DropdownMenuItem className="outline-none">
<DropdownItem
onClick={() => router.push("/event-types/" + type.id)}
onClick={() => router.push(`/event-types/${type.id}`)}
StartIcon={Edit}
className="w-full rounded-none">
{t("edit")}
@@ -620,7 +620,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
<DropdownItem
onClick={() => openDuplicateModal(type, group)}
StartIcon={Copy}
data-testid={"event-type-duplicate-" + type.id}>
data-testid={`event-type-duplicate-${type.id}`}>
{t("duplicate")}
</DropdownItem>
</DropdownMenuItem>
+5 -6
View File
@@ -63,14 +63,13 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
const eventType = booking.eventType ? booking.eventType : getDefaultEvent(dynamicEventSlugRef);
const eventPage =
(eventType.team
? "team/" + eventType.team.slug
const eventPage = `${
eventType.team
? `team/${eventType.team.slug}`
: dynamicEventSlugRef
? booking.dynamicGroupSlugRef
: booking.user?.username || "rick") /* This shouldn't happen */ +
"/" +
eventType?.slug;
: booking.user?.username || "rick" /* This shouldn't happen */
}/${eventType?.slug}`;
const destinationUrl = new URLSearchParams();
destinationUrl.set("rescheduleUid", seatReferenceUid || bookingId);
@@ -0,0 +1,11 @@
import PageWrapper from "@components/PageWrapper";
import { getLayout } from "@components/auth/layouts/AdminLayout";
import OAuthView from "./oAuthView";
const OAuthPage = () => <OAuthView />;
OAuthPage.getLayout = getLayout;
OAuthPage.PageWrapper = PageWrapper;
export default OAuthPage;
@@ -0,0 +1,151 @@
import { useState } from "react";
import { useForm } from "react-hook-form";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import { Meta, Form, Button, TextField, showToast, Tooltip, ImageUploader, Avatar } from "@calcom/ui";
import { Clipboard } from "@calcom/ui/components/icon";
import { Plus } from "@calcom/ui/components/icon";
type FormValues = {
name: string;
redirectUri: string;
logo: string;
};
export default function OAuthView() {
const oAuthForm = useForm<FormValues>();
const [clientSecret, setClientSecret] = useState("");
const [clientId, setClientId] = useState("");
const [logo, setLogo] = useState("");
const { t } = useLocale();
const mutation = trpc.viewer.oAuth.addClient.useMutation({
onSuccess: async (data) => {
setClientSecret(data.clientSecret);
setClientId(data.clientId);
showToast(`Successfully added ${data.name} as new client`, "success");
},
onError: (error) => {
showToast(`Adding clientfailed: ${error.message}`, "error");
},
});
return (
<div>
<Meta title="OAuth" description="Add new OAuth Clients" />
{!clientId ? (
<Form
form={oAuthForm}
handleSubmit={(values) => {
mutation.mutate({
name: values.name,
redirectUri: values.redirectUri,
logo: values.logo,
});
}}>
<div className="">
<TextField
{...oAuthForm.register("name")}
label="Client name"
type="text"
id="name"
placeholder=""
className="mb-3"
required
/>
<TextField
{...oAuthForm.register("redirectUri")}
label="Redirect URI"
type="text"
id="redirectUri"
placeholder=""
required
/>
<div className="mb-5 mt-5 flex items-center">
<Avatar
alt=""
fallback={<Plus className="text-subtle h-6 w-6" />}
className="mr-5 items-center"
imageSrc={logo}
size="lg"
/>
<ImageUploader
target="avatar"
id="avatar-upload"
buttonMsg="Upload Logo"
handleAvatarChange={(newLogo: string) => {
setLogo(newLogo);
oAuthForm.setValue("logo", newLogo);
}}
imageSrc={logo}
/>
</div>
</div>
<Button type="submit" className="mt-3">
{t("add_client")}
</Button>
</Form>
) : (
<div>
<div className="text-emphasis mb-5 text-xl font-semibold">{oAuthForm.getValues("name")}</div>
<div className="mb-2 font-medium">Client Id</div>
<div className="flex">
<code className="bg-subtle text-default w-full truncate rounded-md rounded-r-none py-[6px] pl-2 pr-2 align-middle font-mono">
{" "}
{clientId}
</code>
<Tooltip side="top" content="Copy to Clipboard">
<Button
onClick={() => {
navigator.clipboard.writeText(clientId);
showToast("Client ID copied!", "success");
}}
type="button"
className="rounded-l-none text-base"
StartIcon={Clipboard}>
{t("copy")}
</Button>
</Tooltip>
</div>
{clientSecret ? (
<>
<div className="mb-2 mt-4 font-medium">Client Secret</div>
<div className="flex">
<code className="bg-subtle text-default w-full truncate rounded-md rounded-r-none py-[6px] pl-2 pr-2 align-middle font-mono">
{" "}
{clientSecret}
</code>
<Tooltip side="top" content="Copy to Clipboard">
<Button
onClick={() => {
navigator.clipboard.writeText(clientSecret);
setClientSecret("");
showToast("Client secret copied!", "success");
}}
type="button"
className="rounded-l-none text-base"
StartIcon={Clipboard}>
{t("copy")}
</Button>
</Tooltip>
</div>
<div className="text-subtle text-sm">{t("copy_client_secret_info")}</div>
</>
) : (
<></>
)}
<Button
onClick={() => {
setClientId("");
setLogo("");
oAuthForm.reset();
}}
className="mt-5">
{t("add_new_client")}
</Button>
</div>
)}
</div>
);
}
@@ -190,7 +190,7 @@ const CalendarsView = () => {
}
<div className="flex-grow truncate pl-2">
<ListItemTitle component="h3" className="mb-1 space-x-2 rtl:space-x-reverse">
<Link href={"/apps/" + item.integration.slug}>
<Link href={`/apps/${item.integration.slug}`}>
{item.integration.name || item.integration.title}
</Link>
{data?.destinationCalendar?.credentialId === item.credentialId && (
+13 -6
View File
@@ -86,13 +86,20 @@ const ProfileView = () => {
const utils = trpc.useContext();
const { update } = useSession();
const [fetchedImgSrc, setFetchedImgSrc] = useState<string | undefined>();
const [fetchedImgSrc, setFetchedImgSrc] = useState<string | undefined>(undefined);
const { data: user, isLoading } = trpc.viewer.me.useQuery(undefined, {
onSuccess: (userData) => {
fetch(userData.avatar).then((res) => {
if (res.url) setFetchedImgSrc(res.url);
});
onSuccess: async (userData) => {
try {
if (!userData.organization) {
const res = await fetch(userData.avatar);
if (res.url) setFetchedImgSrc(res.url);
} else {
setFetchedImgSrc("");
}
} catch (err) {
setFetchedImgSrc("");
}
},
});
const updateProfileMutation = trpc.viewer.updateProfile.useMutation({
@@ -218,7 +225,7 @@ const ProfileView = () => {
[ErrorCode.ThirdPartyIdentityProviderEnabled]: t("account_created_with_identity_provider"),
};
if (isLoading || !user || !fetchedImgSrc)
if (isLoading || !user || fetchedImgSrc === undefined)
return (
<SkeletonLoader title={t("profile")} description={t("profile_description", { appName: APP_NAME })} />
);
+1 -1
View File
@@ -281,7 +281,7 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
return {
redirect: {
permanent: false,
destination: "/auth/login?callbackUrl=" + `${WEBAPP_URL}/${ctx.query.callbackUrl}`,
destination: `/auth/login?callbackUrl=${WEBAPP_URL}/${ctx.query.callbackUrl}`,
},
};
}
+10 -12
View File
@@ -5,6 +5,7 @@ import { usePathname } from "next/navigation";
import { useEffect } from "react";
import { sdkActionManager, useIsEmbed } from "@calcom/embed-core/embed-iframe";
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import EventTypeDescription from "@calcom/features/eventtypes/components/EventTypeDescription";
import { getFeatureFlagMap } from "@calcom/features/flags/server/utils";
@@ -43,6 +44,7 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain }
const teamName = team.name || "Nameless Team";
const isBioEmpty = !team.bio || !team.bio.replace("<p><br></p>", "").length;
const metadata = teamMetadataSchema.parse(team.metadata);
const orgBranding = useOrgBranding();
useEffect(() => {
telemetry.event(
@@ -102,7 +104,7 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain }
items={type.users.map((user) => ({
alt: user.name || "",
title: user.name || "",
image: "/" + user.username + "/avatar.png" || "",
image: `/${user.username}/avatar.png` || "",
}))}
/>
</div>
@@ -124,12 +126,6 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain }
<li key={i} className="hover:bg-muted w-full">
<Link href={`/${ch.slug}`} className="flex items-center justify-between">
<div className="flex items-center px-5 py-5">
<Avatar
size="md"
imageSrc={`/team/${ch.slug}/avatar.png`}
alt="Team Logo"
className="inline-flex justify-center"
/>
<div className="ms-3 inline-block truncate">
<span className="text-default text-sm font-bold">{ch.name}</span>
<span className="text-subtle block text-xs">
@@ -160,7 +156,7 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain }
<div className="space-y-6" data-testid="event-types">
<div className="overflow-hidden rounded-sm border dark:border-gray-900">
<div className="text-muted p-8 text-center">
<h2 className="font-cal text-emphasis mb-2 text-3xl">{" " + t("org_no_teams_yet")}</h2>
<h2 className="font-cal text-emphasis mb-2 text-3xl">{` ${t("org_no_teams_yet")}`}</h2>
<p className="text-emphasis mx-auto max-w-md">{t("org_no_teams_yet_description")}</p>
</div>
</div>
@@ -185,9 +181,11 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain }
<div className="relative">
<Avatar
alt={teamName}
imageSrc={`${WEBAPP_URL}/${team.metadata?.isOrganization ? "org" : "team"}/${
team.slug
}/avatar.png`}
imageSrc={
!!team.parent && !!orgBranding
? `${orgBranding?.fullDomain}/org/${orgBranding?.slug}/avatar.png`
: `${WEBAPP_URL}/${team.metadata?.isOrganization ? "org" : "team"}/${team.slug}/avatar.png`
}
size="lg"
/>
</div>
@@ -324,7 +322,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
...type,
users: type.users.map((user) => ({
...user,
avatar: "/" + user.username + "/avatar.png",
avatar: `/${user.username}/avatar.png`,
})),
descriptionAsSafeHTML: markdownToSafeHTML(type.description),
})) ?? null;
+3 -3
View File
@@ -46,7 +46,7 @@ export default function JoinCall(props: JoinCallPageProps) {
baseText: "#FFF",
border: "#292929",
mainAreaBg: "#111111",
mainAreaBgAccent: "#111111",
mainAreaBgAccent: "#1A1A1A",
mainAreaText: "#FFF",
supportiveText: "#FFF",
},
@@ -95,12 +95,12 @@ export default function JoinCall(props: JoinCallPageProps) {
<meta property="og:image" content={SEO_IMG_OGIMG_VIDEO} />
<meta property="og:type" content="website" />
<meta property="og:url" content={`${WEBSITE_URL}/video`} />
<meta property="og:title" content={APP_NAME + " Video"} />
<meta property="og:title" content={`${APP_NAME} Video`} />
<meta property="og:description" content={t("quick_video_meeting")} />
<meta property="twitter:image" content={SEO_IMG_OGIMG_VIDEO} />
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:url" content={`${WEBSITE_URL}/video`} />
<meta property="twitter:title" content={APP_NAME + " Video"} />
<meta property="twitter:title" content={`${APP_NAME} Video`} />
<meta property="twitter:description" content={t("quick_video_meeting")} />
</Head>
<div style={{ zIndex: 2, position: "relative" }}>
+1 -1
View File
@@ -44,7 +44,7 @@ export default function MeetingUnavailable(props: inferSSRProps<typeof getServer
</h2>
<p className="text-subtle text-center">
<Calendar className="-mt-1 mr-1 inline-block h-4 w-4" />
{dayjs(props.booking.startTime).format(detectBrowserTimeFormat + ", dddd DD MMMM YYYY")}
{dayjs(props.booking.startTime).format(`${detectBrowserTimeFormat}, dddd DD MMMM YYYY`)}
</p>
</div>
</div>
@@ -24,7 +24,7 @@ export default function MeetingNotStarted(props: inferSSRProps<typeof getServerS
<h2 className="mb-2 text-center font-medium">{props.booking.title}</h2>
<p className="text-subtle text-center">
<Calendar className="-mt-1 mr-1 inline-block h-4 w-4" />
{dayjs(props.booking.startTime).format(detectBrowserTimeFormat + ", dddd DD MMMM YYYY")}
{dayjs(props.booking.startTime).format(`${detectBrowserTimeFormat}, dddd DD MMMM YYYY`)}
</p>
</>
}
+1 -1
View File
@@ -23,7 +23,7 @@ const otherNonExistingRoutePrefixes = ["forms", "router", "success", "cancel"];
// book$ ensures that only /book is excluded from rewrite(which is at the end always) and not /booked
let subdomainRegExp = (exports.subdomainRegExp = getSubdomainRegExp(
process.env.NEXT_PUBLIC_WEBAPP_URL || "https://" + process.env.VERCEL_URL
process.env.NEXT_PUBLIC_WEBAPP_URL || `https://${process.env.VERCEL_URL}`
));
exports.orgHostPath = `^(?<orgSlug>${subdomainRegExp})\\.(?!vercel\.app).*`;
+1 -1
View File
@@ -28,7 +28,7 @@ export const createPaymentsFixture = (page: Page) => {
},
},
data: {},
externalId: "DEMO_PAYMENT_FROM_DB_" + Date.now(),
externalId: `DEMO_PAYMENT_FROM_DB_${Date.now()}`,
booking: {
connect: {
id: bookingId,
-2
View File
@@ -556,8 +556,6 @@ export async function apiLogin(
export async function setupEventWithPrice(eventType: Pick<Prisma.EventType, "id">, page: Page) {
await page.goto(`/event-types/${eventType?.id}?tabName=apps`);
await page.locator("div > .ml-auto").first().click();
await page.locator(".text-black > .bg-default > div > div:nth-child(2)").first().click();
await page.getByTestId("select-option-usd").click();
await page.getByPlaceholder("Price").fill("100");
await page.getByTestId("update-eventtype").click();
}
+239
View File
@@ -0,0 +1,239 @@
import { expect } from "@playwright/test";
import { randomString } from "@calcom/lib/random";
import prisma from "@calcom/prisma";
import { test } from "./lib/fixtures";
test.describe.configure({ mode: "parallel" });
const createTeamsAndMembership = async (userIdOne: number, userIdTwo: number) => {
const teamOne = await prisma.team.create({
data: {
name: "test-insights",
slug: `test-insights-${Date.now()}-${randomString(5)}}`,
},
});
const teamTwo = await prisma.team.create({
data: {
name: "test-insights-2",
slug: `test-insights-2-${Date.now()}-${randomString(5)}}`,
},
});
if (!userIdOne || !userIdTwo || !teamOne || !teamTwo) {
throw new Error("Failed to create test data");
}
// create memberships
await prisma.membership.create({
data: {
userId: userIdOne,
teamId: teamOne.id,
accepted: true,
role: "ADMIN",
},
});
await prisma.membership.create({
data: {
teamId: teamTwo.id,
userId: userIdOne,
accepted: true,
role: "ADMIN",
},
});
await prisma.membership.create({
data: {
teamId: teamOne.id,
userId: userIdTwo,
accepted: true,
role: "MEMBER",
},
});
await prisma.membership.create({
data: {
teamId: teamTwo.id,
userId: userIdTwo,
accepted: true,
role: "MEMBER",
},
});
return { teamOne, teamTwo };
};
test.afterAll(async ({ users }) => {
await users.deleteAll();
});
test.describe("Insights", async () => {
test("should be able to go to insights as admins", async ({ page, users }) => {
const user = await users.create();
const userTwo = await users.create();
await createTeamsAndMembership(user.id, userTwo.id);
await user.apiLogin();
// go to insights page
await page.goto("/insights");
await page.waitForLoadState("networkidle");
// expect url to have isAll and TeamId in query params
expect(page.url()).toContain("isAll=false");
expect(page.url()).toContain("teamId=");
});
test("should be able to go to insights as members", async ({ page, users }) => {
const user = await users.create();
const userTwo = await users.create();
await userTwo.apiLogin();
await createTeamsAndMembership(user.id, userTwo.id);
// go to insights page
await page.goto("/insights");
await page.waitForLoadState("networkidle");
// expect url to have isAll and TeamId in query params
expect(page.url()).toContain("isAll=false");
expect(page.url()).not.toContain("teamId=");
});
test("team select filter should have 2 teams and your account option only as member", async ({
page,
users,
}) => {
const user = await users.create();
const userTwo = await users.create();
await user.apiLogin();
await createTeamsAndMembership(user.id, userTwo.id);
// go to insights page
await page.goto("/insights");
await page.waitForLoadState("networkidle");
// get div from team select filter with this class flex flex-col gap-0.5 [&>*:first-child]:mt-1 [&>*:last-child]:mb-1
await page.getByTestId("dashboard-shell").getByText("Team: test-insights").click();
await page
.locator('div[class="flex flex-col gap-0.5 [&>*:first-child]:mt-1 [&>*:last-child]:mb-1"]')
.click();
const teamSelectFilter = await page.locator(
'div[class="hover:bg-muted flex items-center py-2 pl-3 pr-2.5 hover:cursor-pointer"]'
);
await expect(teamSelectFilter).toHaveCount(3);
});
test("Insights Organization should have isAll option true", async ({ users, page }) => {
const owner = await users.create(undefined, {
hasTeam: true,
isUnpublished: true,
isOrg: true,
hasSubteam: true,
});
await owner.apiLogin();
await page.goto("/insights");
await page.waitForLoadState("networkidle");
await page.getByTestId("dashboard-shell").getByText("All").nth(1).click();
const teamSelectFilter = await page.locator(
'div[class="hover:bg-muted flex items-center py-2 pl-3 pr-2.5 hover:cursor-pointer"]'
);
await expect(teamSelectFilter).toHaveCount(4);
});
test("should have all option in team-and-self filter as admin", async ({ page, users }) => {
const owner = await users.create();
const member = await users.create();
await createTeamsAndMembership(owner.id, member.id);
await owner.apiLogin();
await page.goto("/insights");
// get div from team select filter with this class flex flex-col gap-0.5 [&>*:first-child]:mt-1 [&>*:last-child]:mb-1
await page.getByTestId("dashboard-shell").getByText("Team: test-insights").click();
await page
.locator('div[class="flex flex-col gap-0.5 [&>*:first-child]:mt-1 [&>*:last-child]:mb-1"]')
.click();
const teamSelectFilter = await page.locator(
'div[class="hover:bg-muted flex items-center py-2 pl-3 pr-2.5 hover:cursor-pointer"]'
);
await expect(teamSelectFilter).toHaveCount(3);
});
test("should be able to switch between teams and self profile for insights", async ({ page, users }) => {
const owner = await users.create();
const member = await users.create();
await createTeamsAndMembership(owner.id, member.id);
await owner.apiLogin();
await page.goto("/insights");
// get div from team select filter with this class flex flex-col gap-0.5 [&>*:first-child]:mt-1 [&>*:last-child]:mb-1
await page.getByTestId("dashboard-shell").getByText("Team: test-insights").click();
await page
.locator('div[class="flex flex-col gap-0.5 [&>*:first-child]:mt-1 [&>*:last-child]:mb-1"]')
.click();
const teamSelectFilter = await page.locator(
'div[class="hover:bg-muted flex items-center py-2 pl-3 pr-2.5 hover:cursor-pointer"]'
);
await expect(teamSelectFilter).toHaveCount(3);
// switch to self profile
await page.getByTestId("dashboard-shell").getByText("Your Account").click();
// switch to team 1
await page.getByTestId("dashboard-shell").getByText("test-insights").nth(0).click();
// switch to team 2
await page.getByTestId("dashboard-shell").getByText("test-insights-2").click();
});
test("should be able to switch between memberUsers", async ({ page, users }) => {
const owner = await users.create();
const member = await users.create();
await createTeamsAndMembership(owner.id, member.id);
await owner.apiLogin();
await page.goto("/insights");
await page.getByText("Add filter").click();
await page.getByRole("button", { name: "User" }).click();
// <div class="flex select-none truncate font-medium" data-state="closed">People</div>
await page.locator('div[class="flex select-none truncate font-medium"]').getByText("People").click();
await page
.locator('div[class="hover:bg-muted flex items-center py-2 pl-3 pr-2.5 hover:cursor-pointer"]')
.nth(0)
.click();
await page.waitForLoadState("networkidle");
await page
.locator('div[class="hover:bg-muted flex items-center py-2 pl-3 pr-2.5 hover:cursor-pointer"]')
.nth(1)
.click();
await page.waitForLoadState("networkidle");
// press escape button to close the filter
await page.keyboard.press("Escape");
await page.getByRole("button", { name: "Clear" }).click();
// expect for "Team: test-insight" text in page
expect(await page.locator("text=Team: test-insights").isVisible()).toBeTruthy();
});
});
+2 -2
View File
@@ -23,7 +23,7 @@ export const nextServer = async ({ port = 3000 } = { port: 3000 }) => {
process.env.PLAYWRIGHT_TEST_BASE_URL =
process.env.NEXT_PUBLIC_WEBAPP_URL =
process.env.NEXT_PUBLIC_WEBSITE_URL =
"http://localhost:" + port;
`http://localhost:${port}`;
const app = next({
dev: dev,
port,
@@ -46,7 +46,7 @@ export const nextServer = async ({ port = 3000 } = { port: 3000 }) => {
resolve(server);
});
server.on("error", (error) => {
if (error) throw new Error("Could not start Next.js server -" + error.message);
if (error) throw new Error(`Could not start Next.js server - ${error.message}`);
});
});
return server;
+227
View File
@@ -0,0 +1,227 @@
import { expect } from "@playwright/test";
import { randomBytes } from "crypto";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { generateSecret } from "@calcom/trpc/server/routers/viewer/oAuth/addClient.handler";
import { test } from "./lib/fixtures";
test.afterEach(async ({ users }) => {
await users.deleteAll();
});
let client: {
clientId: string;
redirectUri: string;
orginalSecret: string;
name: string;
clientSecret: string;
logo: string | null;
};
test.describe("OAuth Provider", () => {
test.beforeAll(async () => {
client = await createTestCLient();
});
test("should create valid access toke & refresh token for user", async ({ page, users }) => {
const user = await users.create({ username: "test user", name: "test user" });
await user.apiLogin();
await page.goto(
`auth/oauth2/authorize?client_id=${client.clientId}&redirect_uri=${client.redirectUri}&response_type=code&scope=READ_PROFILE&state=1234`
);
await page.waitForLoadState("networkidle");
await page.getByTestId("allow-button").click();
await page.waitForFunction(() => {
return window.location.href.startsWith("https://example.com");
});
const url = new URL(page.url());
// authorization code that is returned to client with redirect uri
const code = url.searchParams.get("code");
// request token with authorization code
const tokenResponse = await fetch(`${WEBAPP_URL}/api/auth/oauth/token`, {
body: JSON.stringify({
code,
client_id: client.clientId,
client_secret: client.orginalSecret,
grant_type: "authorization_code",
redirect_uri: client.redirectUri,
}),
method: "POST",
headers: {
"Content-Type": "application/json",
},
});
const tokenData = await tokenResponse.json();
// test if token is valid
const meResponse = await fetch(`${WEBAPP_URL}/api/auth/oauth/me`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${tokenData.access_token}`,
},
});
const meData = await meResponse.json();
// check if user access token is valid
expect(meData.username.startsWith("test user")).toBe(true);
// request new token with refresh token
const refreshTokenResponse = await fetch(`${WEBAPP_URL}/api/auth/oauth/refreshToken`, {
body: JSON.stringify({
refresh_token: tokenData.refresh_token,
client_id: client.clientId,
client_secret: client.orginalSecret,
grant_type: "refresh_token",
}),
method: "POST",
headers: {
"Content-Type": "application/json",
},
});
const refreshTokenData = await refreshTokenResponse.json();
expect(refreshTokenData.access_token).not.toBe(tokenData.access_token);
const validTokenResponse = await fetch(`${WEBAPP_URL}/api/auth/oauth/me`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${tokenData.access_token}`,
},
});
expect(meData.username.startsWith("test user")).toBe(true);
});
test("should create valid access toke & refresh token for team", async ({ page, users }) => {
const user = await users.create({ username: "test user", name: "test user" }, { hasTeam: true });
await user.apiLogin();
await page.goto(
`auth/oauth2/authorize?client_id=${client.clientId}&redirect_uri=${client.redirectUri}&response_type=code&scope=READ_PROFILE&state=1234`
);
await page.waitForLoadState("networkidle");
await page.locator("#account-select").click();
await page.locator("#react-select-2-option-1").click();
await page.getByTestId("allow-button").click();
await page.waitForFunction(() => {
return window.location.href.startsWith("https://example.com");
});
const url = new URL(page.url());
// authorization code that is returned to client with redirect uri
const code = url.searchParams.get("code");
// request token with authorization code
const tokenResponse = await fetch(`${WEBAPP_URL}/api/auth/oauth/token`, {
body: JSON.stringify({
code,
client_id: client.clientId,
client_secret: client.orginalSecret,
grant_type: "authorization_code",
redirect_uri: client.redirectUri,
}),
method: "POST",
headers: {
"Content-Type": "application/json",
},
});
const tokenData = await tokenResponse.json();
// test if token is valid
const meResponse = await fetch(`${WEBAPP_URL}/api/auth/oauth/me`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${tokenData.access_token}`,
},
});
const meData = await meResponse.json();
// check if team access token is valid
expect(meData.username.endsWith("Team Team")).toBe(true);
// request new token with refresh token
const refreshTokenResponse = await fetch(`${WEBAPP_URL}/api/auth/oauth/refreshToken`, {
body: JSON.stringify({
refresh_token: tokenData.refresh_token,
client_id: client.clientId,
client_secret: client.orginalSecret,
grant_type: "refresh_token",
}),
method: "POST",
headers: {
"Content-Type": "application/json",
},
});
const refreshTokenData = await refreshTokenResponse.json();
expect(refreshTokenData.access_token).not.toBe(tokenData.access_token);
const validTokenResponse = await fetch(`${WEBAPP_URL}/api/auth/oauth/me`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${tokenData.access_token}`,
},
});
expect(meData.username.endsWith("Team Team")).toBe(true);
});
test("redirect not logged-in users to login page and after forward to authorization page", async ({
page,
users,
}) => {
const user = await users.create({ username: "test-user", name: "test user" });
await page.goto(
`auth/oauth2/authorize?client_id=${client.clientId}&redirect_uri=${client.redirectUri}&response_type=code&scope=READ_PROFILE&state=1234`
);
// check if user is redirected to login page
await expect(page.getByRole("heading", { name: "Welcome back" })).toBeVisible();
await page.locator("#email").fill(user.email);
await page.locator("#password").fill(user.username || "");
await page.locator('[type="submit"]').click();
await page.waitForSelector("#account-select");
await expect(page.getByText("test user")).toBeVisible();
});
});
const createTestCLient = async () => {
const [hashedSecret, secret] = generateSecret();
const clientId = randomBytes(32).toString("hex");
const client = await prisma.oAuthClient.create({
data: {
name: "Test Client",
clientId,
clientSecret: hashedSecret,
redirectUri: "https://example.com",
},
});
return { ...client, orginalSecret: secret };
};
+34
View File
@@ -200,4 +200,38 @@ test.describe("Payment app", () => {
// Expect "Getting started with Paypal APP" to be displayed
expect(await page.locator("text=Getting started with Paypal APP").first()).toBeTruthy();
});
/**
* For now almost all the payment apps show display "This app has not been setup yet"
* this can change in the future
*/
test("Should not display App is not setup already for non payment app", async ({ page, users }) => {
// We will use google analytics app for this test
const user = await users.create();
await user.apiLogin();
// Any event should work here
const paymentEvent = user.eventTypes.find((item) => item.slug === "paid");
if (!paymentEvent) {
throw new Error("No payment event found");
}
await prisma.credential.create({
data: {
type: "ga4_analytics",
userId: user.id,
appId: "ga4",
invalid: false,
key: {},
},
});
await page.goto(`event-types/${paymentEvent.id}?tabName=apps`);
await page.locator("#event-type-form").getByRole("switch").click();
// make sure Tracking ID is displayed
expect(await page.locator("text=Tracking ID").first()).toBeTruthy();
await page.getByLabel("Tracking ID").click();
await page.getByLabel("Tracking ID").fill("demo");
await page.getByTestId("update-eventtype").click();
});
});
+24
View File
@@ -0,0 +1,24 @@
import { expect } from "@playwright/test";
import { test } from "./lib/fixtures";
test.describe.configure({ mode: "parallel" });
test.afterEach(({ users }) => users.deleteAll());
test.describe("Teams", () => {
test("Profile page is loaded for users in Organization", async ({ page, users }) => {
const teamMatesObj = [{ name: "teammate-1" }, { name: "teammate-2" }];
const owner = await users.create(undefined, {
hasTeam: true,
isOrg: true,
hasSubteam: true,
teammates: teamMatesObj,
});
await owner.apiLogin();
await page.goto("/settings/my-account/profile");
// check if user avatar is loaded
await expect(page.locator('[data-testid="organization-avatar"]')).toBeVisible();
});
});

Before

Width:  |  Height:  |  Size: 322 B

After

Width:  |  Height:  |  Size: 322 B

+21 -1
View File
@@ -423,6 +423,7 @@
"booking_created": "Booking Created",
"booking_rejected": "Booking Rejected",
"booking_requested": "Booking Requested",
"booking_payment_initiated": "Booking Payment Initiated",
"meeting_ended": "Meeting Ended",
"form_submitted": "Form Submitted",
"booking_paid": "Booking Paid",
@@ -1534,6 +1535,7 @@
"problem_registering_domain": "There was a problem with registering the subdomain, please try again or contact an administrator",
"team_publish": "Publish team",
"number_text_notifications": "Phone number (Text notifications)",
"number_sms_notifications": "Phone number (SMS notifications)",
"attendee_email_variable": "Attendee email",
"attendee_email_info": "The person booking's email",
"kbar_search_placeholder": "Type a command or search...",
@@ -1657,7 +1659,7 @@
"no_recordings_found": "No recordings found",
"new_workflow_subtitle": "New workflow for...",
"reporting": "Reporting",
"reporting_feature": "See all incoming from data and download it as a CSV",
"reporting_feature": "See all incoming form data and download it as a CSV",
"teams_plan_required": "Teams plan required",
"routing_forms_are_a_great_way": "Routing forms are a great way to route your incoming leads to the right person. Upgrade to a Teams plan to access this feature.",
"choose_a_license": "Choose a license",
@@ -2054,15 +2056,33 @@
"team_no_event_types": "This team has no event types",
"seat_options_doesnt_multiple_durations": "Seat option doesn't support multiple durations",
"include_calendar_event": "Include calendar event",
"oAuth": "OAuth",
"recently_added":"Recently added",
"no_members_found": "No members found",
"event_setup_length_error":"Event Setup: The duration must be at least 1 minute.",
"availability_schedules":"Availability Schedules",
"unauthorized":"Unauthorized",
"access_cal_account": "{{clientName}} would like access to your {{appName}} account",
"select_account_team": "Select account or team",
"allow_client_to": "This will allow {{clientName}} to",
"associate_with_cal_account":"Associate you with your personal info from {{clientName}}",
"see_personal_info":"See your personal info, including any personal info you've made publicly available",
"see_primary_email_address":"See your primary email address",
"connect_installed_apps":"Connect to your installed apps",
"access_event_type": "Read, edit, delete your event-types",
"access_availability": "Read, edit, delete your availability",
"access_bookings": "Read, edit, delete your bookings",
"allow_client_to_do": "Allow {{clientName}} to do this?",
"oauth_access_information": "By clicking allow, you allow this app to use your information in accordance with their terms of service and privacy policy. You can remove access in the {{appName}} App Store.",
"allow": "Allow",
"view_only_edit_availability_not_onboarded":"This user has not completed onboarding. You will not be able to set their availability until they have completed onboarding.",
"view_only_edit_availability":"You are viewing this user's availability. You can only edit your own availability.",
"edit_users_availability":"Edit user's availability: {{username}}",
"resend_invitation": "Resend invitation",
"invitation_resent": "The invitation was resent.",
"add_client": "Add client",
"copy_client_secret_info": "After copying the secret you won't be able to view it anymore",
"add_new_client": "Add new Client",
"this_app_is_not_setup_already": "This app has not been setup yet",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -7,17 +7,32 @@
"second_other": "{{count}} segundo",
"upgrade_now": "Eguneratu orain",
"accept_invitation": "Onartu gonbidapena",
"calcom_explained": "{{appName}}-ek bilerak programatzeko azpiegitura eskaintzen du guztiontzat.",
"calcom_explained_new_user": "Bukatu zure {{appName}} kontua konfiguratzen! Bileren programazio-arazo guztiak konpontzeko urrats gutxi batzuk besterik ez zaizkizu geratzen.",
"have_any_questions": "Galderarik? Laguntzeko gaude.",
"reset_password_subject": "{{appName}}: Pasahitza berrezartzeko argibideak",
"verify_email_subject": "{{appName}}: egiaztatu zure kontua",
"check_your_email": "Begiratu zure emaila",
"verify_email_page_body": "Email bat bidali dugu {{email}} helbidera. Garrantzitsua da zure email helbidea egiaztatzea, {{appName}}-tik mezuak eta egutegiko eguneratzeak ahalik eta hobekien jasoko dituzula bermatzeko.",
"verify_email_banner_body": "Egiaztatu zure email helbidea mezuak eta egutegiko eguneratzeak ahalik eta hobekien jasoko dituzula bermatzeko",
"verify_email_email_header": "Egiaztatu zure email helbidea",
"verify_email_email_button": "Egiaztatu emaila",
"verify_email_email_body": "Mesedez, egiaztatu zure email helbidea beheko botoia sakatuz.",
"verify_email_by_code_email_body": "Mesedez, egiaztatu zure email helbidea beheko kodea erabiliz.",
"verify_email_email_link_text": "Hemen duzu esteka, botoiak sakatzea gustuko ez baduzu:",
"email_verification_code": "Sartu egiaztatze-kodea",
"email_verification_code_placeholder": "Sartu zure email helbidera bidalitako egiaztatze-kodea",
"incorrect_email_verification_code": "Egiaztatze-kodea ez da zuzena.",
"email_sent": "Email mezua zuzen bidali da",
"email_not_sent": "Errore bat gertatu da email mezua bidaltzerakoan",
"event_declined_subject": "Baztertua: {{title}} {{date}}(e)an",
"event_cancelled_subject": "Bertan behera: {{title}} {{date}}(e)an",
"event_request_declined": "Zure gertaera-eskaera baztertua izan da",
"event_request_declined_recurring": "Zure gertaera errepikari-eskaera baztertua izan da",
"event_request_cancelled": "Zure programatutako gertaera bertan behera utzi da",
"organizer": "Antolatzailea",
"need_to_reschedule_or_cancel": "Programazioa aldatu edo bertan behera utzi behar duzu?",
"no_options_available": "Ez dago aukerarik eskuragarri",
"cancellation_reason": "Bertan behera uztearen arrazoia (aukerakoa)",
"cancellation_reason_placeholder": "Zergatik utzi duzu bertan behera?",
"rejection_reason": "Errefusatzeko arrazoia",
@@ -25,7 +40,11 @@
"rejection_reason_description": "Ziur zaude erreserba errefusatu nahi duzula? Erreserba-eskaera egin duen pertsonari jakinaraziko zaio. Arrazoi bat adieraz dezakezu behean.",
"rejection_confirmation": "Errefusatu erreserba",
"manage_this_event": "Kudeatu gertaera hau",
"invite_team_member": "Gonbidatu taldekidea",
"invite_team_individual_segment": "Gonbidatu norbanakoa",
"invite_team_notifcation_badge": "Gon.",
"your_event_has_been_scheduled": "Zure gertaera programatu da",
"your_event_has_been_scheduled_recurring": "Zure gertaera errepikaria programatu da",
"error_message": "Errore-mezua honakoa ian da: '{{errorMessage}}'",
"refund_failed_subject": "Itzulketak huts egin du: {{name}} - {{date}} - {{eventType}}",
"refund_failed": "Huts egin du itzulketak {{eventType}} gertaerarako, {{userName}}(r)ekin {{date}}(e)an.",
@@ -37,26 +56,79 @@
"refunded": "Itzulita",
"payment": "Ordainketa",
"pay_now": "Ordaindu orain",
"still_waiting_for_approval": "Gertaera bat onarpenaren zain dago",
"event_is_still_waiting": "Gertaera-eskaera oraindik zain dago: {{attendeeName}} - {{date}} - {{eventType}}",
"no_more_results": "Emaitza gehiagorik ez",
"no_results": "Emaitzarik ez",
"load_more_results": "Kargatu emaitza gehiago",
"integration_meeting_id": "{{integrationName}} bileraren IDa: {{meetingId}}",
"confirmed_event_type_subject": "Baieztatua: {{eventType}} {{name}}(r)ekin {{date}}(e)an",
"new_event_request": "Gertaera berriaren eskaera: {{attendeeName}} - {{date}} - {{eventType}}",
"confirm_or_reject_request": "Baieztatu edo errefusatu eskaera",
"check_bookings_page_to_confirm_or_reject": "Begiratu zure erreserba-orrialdea erreserba baieztatu edo errefusatzeko.",
"event_awaiting_approval": "Gertaera bat zure onarpenaren zain dago",
"event_awaiting_approval_recurring": "Gertaera errepikari bat zure onarpenaren zain dago",
"someone_requested_an_event": "Norbaitek zure egutegian gertaera bat programatzeko eskaera egin du.",
"someone_requested_password_reset": "Norbaitek zure pasahitza aldatzeko esteka bat eskatu du.",
"password_reset_email_sent": "Email helbide hau gure sisteman baldin badago, berrezartzeko email mezu bat jaso behar zenuke.",
"password_reset_instructions": "Ez baduzu eskaera hau egin, segurua da email mezu honi kasurik ez egitea, eta zure pasahitza ez da aldatuko.",
"event_awaiting_approval_subject": "Onarpenaren zain: {{title}} {{date}}(e)an",
"event_still_awaiting_approval": "Gertaera bat zure onarpenaren zain dago oraindik",
"booking_submitted_subject": "Erreserba bidalita: {{title}} {{date}}(e)an",
"download_recording_subject": "Deskargatu grabaketa: {{title}} {{date}}(e)an",
"download_your_recording": "Deskargatu zure grabaketa",
"your_meeting_has_been_booked": "Zure bileraren erreserba egin da",
"event_type_has_been_rescheduled_on_time_date": "Zure {{title}} getaeraren programazioa aldatu egin da {{date}}(e)ra.",
"event_has_been_rescheduled": "Eguneratuta - Zure gertaeraren programazioa aldatu egin da",
"request_reschedule_subtitle": "{{organizer}}(e)k erreserba bertan behera utzi du eta beste denbora-tarte bat hautatzeko eskatu dizu.",
"request_reschedule_title_organizer": "Beste denbora-tarte bat hautatzeko eskatu diozu {{attendee}}(r)i",
"hi_user_name": "Kaixo {{name}}",
"ics_event_title": "{{eventType}} {{name}}(r)ekin",
"notes": "Oharrak",
"manage_my_bookings": "Kudeatu nire erreserbak",
"rejected_event_type_with_organizer": "Errefusatua: {{eventType}} {{organizer}}(r)ekin {{date}}(e)an",
"hi": "Kaixo",
"use_link_to_reset_password": "Erabili beheko esteka pasahitza berrezartzeko",
"hey_there": "Kaixo,",
"forgot_your_password_calcom": "Pasahitza ahaztu duzu? - {{appName}}",
"dismiss": "Alde batera utzi",
"no_data_yet": "Ez dago daturik",
"ping_test": "Ping testa",
"upcoming": "Laster",
"recurring": "Errepikariak",
"past": "Iraganekoak",
"choose_a_file": "Hautatu fitxategi bat...",
"upload_image": "Igo irudia",
"upload_target": "Igo {{target}}",
"no_target": "Ez dago {{target}}(r)ik",
"view_notifications": "Ikusi jakinarazpenak",
"view_public_page": "Ikusi orrialde publikoa",
"copy_public_page_link": "Kopiatu orrialde publikoaren esteka",
"sign_out": "Saioa itxi",
"add_another": "Gehitu beste bat",
"install_another": "Instalatu beste bat",
"unavailable": "Ez eskuragarri",
"set_work_schedule": "Ezarri zure laneko ordutegia",
"change_bookings_availability": "Aldatu noiz zauden prest erreserbak jasotzeko",
"select": "Hautatu...",
"text": "Testua",
"multiline_text": "Lerro ugaritako testua",
"number": "Zenbakia",
"checkbox": "Kontrol-laukia",
"is_required": "Derrigorrezkoa da",
"required": "Derrigorrezkoa",
"optional": "Hautazkoa",
"input_type": "Sarrera-mota",
"rejected": "Baztertua",
"unconfirmed": "Baieztatu gabea",
"guests": "Gonbidatuak",
"create_account": "Sortu kontua",
"confirm_password": "Baieztatu pasahitza",
"create_booking_link_with_calcom": "Sor ezazu zeure erreserba-esteka {{appName}}(e)kin",
"user_needs_to_confirm_or_reject_booking": "{{user}}(e)k erreserba baieztatu edo errefusatu behar du oraindik.",
"booking_submitted": "Zure erreserba bidali da",
"booking_confirmed": "Zure erreserba baieztatu da",
"bookerlayout_column_view": "Zutabea",
"back_to_bookings": "Itzuli erreserbatara",
"really_cancel_booking": "Benetan bertan behera utzi nahi duzu zure erreserba?",
"cannot_cancel_booking": "Ezin duzu erreserba hau bertan behera utzi",
@@ -1530,6 +1530,7 @@
"problem_registering_domain": "Un problème est survenu lors de l'enregistrement du sous-domaine, veuillez réessayer ou contacter un administrateur",
"team_publish": "Publier l'équipe",
"number_text_notifications": "Numéro de téléphone (notifications par SMS)",
"number_sms_notifications": "Numéro de téléphone (notifications par SMS)",
"attendee_email_variable": "Adresse e-mail du participant",
"attendee_email_info": "Adresse e-mail du participant",
"kbar_search_placeholder": "Saisissez une commande ou une recherche...",
@@ -1530,6 +1530,7 @@
"problem_registering_domain": "Houve um problema ao registar o subdomínio. Tente novamente ou contacte um administrador",
"team_publish": "Publicar equipa",
"number_text_notifications": "Número de telefone (notificações de texto)",
"number_sms_notifications": "Número de telefone (notificações SMS)",
"attendee_email_variable": "E-mail do participante",
"attendee_email_info": "O e-mail do responsável pela reserva",
"kbar_search_placeholder": "Digite um comando ou pesquise...",
+1 -1
View File
@@ -1,4 +1,4 @@
import prismaMock from "../../../../tests/libs/__mocks__/prisma";
import prismaMock from "../../../../tests/libs/__mocks__/prismaMock";
import { describe, expect, it } from "vitest";
@@ -1,4 +1,4 @@
import prismaMock from "../../../../tests/libs/__mocks__/prisma";
import prismaMock from "../../../../tests/libs/__mocks__/prismaMock";
import { describe, expect, it } from "vitest";
+24 -26
View File
@@ -1,20 +1,18 @@
import CalendarManagerMock from "../../../../tests/libs/__mocks__/CalendarManager";
import prismaMock from "../../../../tests/libs/__mocks__/prisma";
import prismock from "../../../../tests/libs/__mocks__/prisma";
import { diff } from "jest-diff";
import { describe, expect, vi, beforeEach, afterEach, test } from "vitest";
import prisma from "@calcom/prisma";
import type { BookingStatus } from "@calcom/prisma/enums";
import type { Slot } from "@calcom/trpc/server/routers/viewer/slots/types";
import { getAvailableSlots as getSchedule } from "@calcom/trpc/server/routers/viewer/slots/util";
import { getDate, getGoogleCalendarCredential, createBookingScenario } from "../utils/bookingScenario";
// TODO: Mock properly
prismaMock.eventType.findUnique.mockResolvedValue(null);
// @ts-expect-error Prisma v5 typings are not yet available
prismaMock.user.findMany.mockResolvedValue([]);
import {
getDate,
getGoogleCalendarCredential,
createBookingScenario,
} from "../utils/bookingScenario/bookingScenario";
vi.mock("@calcom/lib/constants", () => ({
IS_PRODUCTION: true,
@@ -146,13 +144,13 @@ const TestData = {
};
const cleanup = async () => {
await prisma.eventType.deleteMany();
await prisma.user.deleteMany();
await prisma.schedule.deleteMany();
await prisma.selectedCalendar.deleteMany();
await prisma.credential.deleteMany();
await prisma.booking.deleteMany();
await prisma.app.deleteMany();
await prismock.eventType.deleteMany();
await prismock.user.deleteMany();
await prismock.schedule.deleteMany();
await prismock.selectedCalendar.deleteMany();
await prismock.credential.deleteMany();
await prismock.booking.deleteMany();
await prismock.app.deleteMany();
};
beforeEach(async () => {
@@ -201,7 +199,7 @@ describe("getSchedule", () => {
apps: [TestData.apps.googleCalendar],
};
// An event with one accepted booking
createBookingScenario(scenarioData);
await createBookingScenario(scenarioData);
const scheduleForDayWithAGoogleCalendarBooking = await getSchedule({
input: {
@@ -228,7 +226,7 @@ describe("getSchedule", () => {
const { dateString: plus3DateString } = getDate({ dateIncrement: 3 });
// An event with one accepted booking
createBookingScenario({
await createBookingScenario({
// An event with length 30 minutes, slotInterval 45 minutes, and minimumBookingNotice 1440 minutes (24 hours)
eventTypes: [
{
@@ -354,7 +352,7 @@ describe("getSchedule", () => {
});
test("slots are available as per `length`, `slotInterval` of the event", async () => {
createBookingScenario({
await createBookingScenario({
eventTypes: [
{
id: 1,
@@ -453,7 +451,7 @@ describe("getSchedule", () => {
})()
);
createBookingScenario({
await createBookingScenario({
eventTypes: [
{
id: 1,
@@ -569,7 +567,7 @@ describe("getSchedule", () => {
apps: [TestData.apps.googleCalendar],
};
createBookingScenario(scenarioData);
await createBookingScenario(scenarioData);
const scheduleForEventOnADayWithNonCalBooking = await getSchedule({
input: {
@@ -643,7 +641,7 @@ describe("getSchedule", () => {
apps: [TestData.apps.googleCalendar],
};
createBookingScenario(scenarioData);
await createBookingScenario(scenarioData);
const scheduleForEventOnADayWithCalBooking = await getSchedule({
input: {
@@ -701,7 +699,7 @@ describe("getSchedule", () => {
apps: [TestData.apps.googleCalendar],
};
createBookingScenario(scenarioData);
await createBookingScenario(scenarioData);
const schedule = await getSchedule({
input: {
@@ -765,7 +763,7 @@ describe("getSchedule", () => {
],
};
createBookingScenario(scenarioData);
await createBookingScenario(scenarioData);
const scheduleForEventOnADayWithDateOverride = await getSchedule({
input: {
@@ -790,7 +788,7 @@ describe("getSchedule", () => {
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
createBookingScenario({
await createBookingScenario({
eventTypes: [
// A Collective Event Type hosted by this user
{
@@ -885,7 +883,7 @@ describe("getSchedule", () => {
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
createBookingScenario({
await createBookingScenario({
eventTypes: [
// An event having two users with one accepted booking
{
@@ -1010,7 +1008,7 @@ describe("getSchedule", () => {
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
const { dateString: plus3DateString } = getDate({ dateIncrement: 3 });
createBookingScenario({
await createBookingScenario({
eventTypes: [
// An event having two users with one accepted booking
{
@@ -1,4 +1,4 @@
import prismaMock from "../../../../tests/libs/__mocks__/prisma";
import prismaMock from "../../../../tests/libs/__mocks__/prismaMock";
import type { EventType } from "@prisma/client";
import { describe, expect, it, vi } from "vitest";
+1 -1
View File
@@ -1,4 +1,4 @@
import prismaMock from "../../../../tests/libs/__mocks__/prisma";
import prismaMock from "../../../../tests/libs/__mocks__/prismaMock";
import { expect, it } from "vitest";
-754
View File
@@ -1,754 +0,0 @@
import appStoreMock from "../../../../tests/libs/__mocks__/app-store";
import i18nMock from "../../../../tests/libs/__mocks__/libServerI18n";
import prismaMock from "../../../../tests/libs/__mocks__/prisma";
import type {
EventType as PrismaEventType,
User as PrismaUser,
Booking as PrismaBooking,
App as PrismaApp,
} from "@prisma/client";
import type { Prisma } from "@prisma/client";
import type { WebhookTriggerEvents } from "@prisma/client";
import { v4 as uuidv4 } from "uuid";
import { expect } from "vitest";
import "vitest-fetch-mock";
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
import logger from "@calcom/lib/logger";
import type { SchedulingType } from "@calcom/prisma/enums";
import type { BookingStatus } from "@calcom/prisma/enums";
import type { EventBusyDate } from "@calcom/types/Calendar";
import type { Fixtures } from "@calcom/web/test/fixtures/fixtures";
type App = {
slug: string;
dirName: string;
};
type InputWebhook = {
appId: string | null;
userId?: number | null;
teamId?: number | null;
eventTypeId?: number;
active: boolean;
eventTriggers: WebhookTriggerEvents[];
subscriberUrl: string;
};
/**
* Data to be mocked
*/
type ScenarioData = {
// hosts: { id: number; eventTypeId?: number; userId?: number; isFixed?: boolean }[];
/**
* Prisma would return these eventTypes
*/
eventTypes: InputEventType[];
/**
* Prisma would return these users
*/
users: InputUser[];
/**
* Prisma would return these apps
*/
apps?: App[];
bookings?: InputBooking[];
webhooks?: InputWebhook[];
};
type InputCredential = typeof TestData.credentials.google;
type InputSelectedCalendar = typeof TestData.selectedCalendars.google;
type InputUser = typeof TestData.users.example & { id: number } & {
credentials?: InputCredential[];
selectedCalendars?: InputSelectedCalendar[];
schedules: {
id: number;
name: string;
availability: {
userId: number | null;
eventTypeId: number | null;
days: number[];
startTime: Date;
endTime: Date;
date: string | null;
}[];
timeZone: string;
}[];
};
type InputEventType = {
id: number;
title?: string;
length?: number;
offsetStart?: number;
slotInterval?: number;
minimumBookingNotice?: number;
/**
* These user ids are `ScenarioData["users"]["id"]`
*/
users?: { id: number }[];
hosts?: { id: number }[];
schedulingType?: SchedulingType;
beforeEventBuffer?: number;
afterEventBuffer?: number;
requiresConfirmation?: boolean;
};
type InputBooking = {
userId?: number;
eventTypeId: number;
startTime: string;
endTime: string;
title?: string;
status: BookingStatus;
attendees?: { email: string }[];
};
const Timezones = {
"+5:30": "Asia/Kolkata",
"+6:00": "Asia/Dhaka",
};
function addEventTypes(eventTypes: InputEventType[], usersStore: InputUser[]) {
const baseEventType = {
title: "Base EventType Title",
slug: "base-event-type-slug",
timeZone: null,
beforeEventBuffer: 0,
afterEventBuffer: 0,
schedulingType: null,
//TODO: What is the purpose of periodStartDate and periodEndDate? Test these?
periodStartDate: new Date("2022-01-21T09:03:48.000Z"),
periodEndDate: new Date("2022-01-21T09:03:48.000Z"),
periodCountCalendarDays: false,
periodDays: 30,
seatsPerTimeSlot: null,
metadata: {},
minimumBookingNotice: 0,
offsetStart: 0,
};
const foundEvents: Record<number, boolean> = {};
const eventTypesWithUsers = eventTypes.map((eventType) => {
if (!eventType.slotInterval && !eventType.length) {
throw new Error("eventTypes[number]: slotInterval or length must be defined");
}
if (foundEvents[eventType.id]) {
throw new Error(`eventTypes[number]: id ${eventType.id} is not unique`);
}
foundEvents[eventType.id] = true;
const users =
eventType.users?.map((userWithJustId) => {
return usersStore.find((user) => user.id === userWithJustId.id);
}) || [];
return {
...baseEventType,
...eventType,
workflows: [],
users,
};
});
logger.silly("TestData: Creating EventType", eventTypes);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const eventTypeMock = ({ where }) => {
return new Promise((resolve) => {
const eventType = eventTypesWithUsers.find((e) => e.id === where.id) as unknown as PrismaEventType & {
users: PrismaUser[];
};
resolve(eventType);
});
};
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
prismaMock.eventType.findUnique.mockImplementation(eventTypeMock);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
prismaMock.eventType.findUniqueOrThrow.mockImplementation(eventTypeMock);
}
async function addBookings(bookings: InputBooking[], eventTypes: InputEventType[]) {
logger.silly("TestData: Creating Bookings", bookings);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
prismaMock.booking.findMany.mockImplementation((findManyArg) => {
// @ts-expect-error Prisma v5 breaks this
const where = findManyArg?.where || {};
return new Promise((resolve) => {
resolve(
// @ts-expect-error Prisma v5 breaks this
bookings
// We can improve this filter to support the entire where clause but that isn't necessary yet. So, handle what we know we pass to `findMany` and is needed
.filter((booking) => {
/**
* A user is considered busy within a given time period if there
* is a booking they own OR host. This function mocks some of the logic
* for each condition. For details see the following ticket:
* https://github.com/calcom/cal.com/issues/6374
*/
// ~~ FIRST CONDITION ensures that this booking is owned by this user
// and that the status is what we want
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const statusIn = where.OR[0].status?.in || [];
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const userIdIn = where.OR[0].userId?.in || [];
const firstConditionMatches =
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
statusIn.includes(booking.status) &&
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
(booking.userId === where.OR[0].userId || userIdIn.includes(booking.userId));
// We return this booking if either condition is met
return firstConditionMatches;
})
.map((booking) => ({
uid: uuidv4(),
title: "Test Booking Title",
...booking,
eventType: eventTypes.find((eventType) => eventType.id === booking.eventTypeId),
})) as unknown as PrismaBooking[]
);
});
});
}
async function addWebhooks(webhooks: InputWebhook[]) {
prismaMock.webhook.findMany.mockResolvedValue(
// @ts-expect-error Prisma v5 breaks this
webhooks.map((webhook) => {
return {
...webhook,
payloadTemplate: null,
secret: null,
id: uuidv4(),
createdAt: new Date(),
userId: webhook.userId || null,
eventTypeId: webhook.eventTypeId || null,
teamId: webhook.teamId || null,
};
})
);
}
function addUsers(users: InputUser[]) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
prismaMock.user.findUniqueOrThrow.mockImplementation((findUniqueArgs) => {
return new Promise((resolve) => {
// @ts-expect-error Prisma v5 breaks this
resolve({
// @ts-expect-error Prisma v5 breaks this
email: `IntegrationTestUser${findUniqueArgs?.where.id}@example.com`,
} as unknown as PrismaUser);
});
});
prismaMock.user.findMany.mockResolvedValue(
// @ts-expect-error Prisma v5 breaks this
users.map((user) => {
return {
...user,
username: `IntegrationTestUser${user.id}`,
email: `IntegrationTestUser${user.id}@example.com`,
};
}) as unknown as PrismaUser[]
);
}
export async function createBookingScenario(data: ScenarioData) {
logger.silly("TestData: Creating Scenario", data);
addUsers(data.users);
const eventType = addEventTypes(data.eventTypes, data.users);
if (data.apps) {
// @ts-expect-error Prisma v5 breaks this
prismaMock.app.findMany.mockResolvedValue(data.apps as PrismaApp[]);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
const appMock = ({ where: { slug: whereSlug } }) => {
return new Promise((resolve) => {
if (!data.apps) {
resolve(null);
return;
}
const foundApp = data.apps.find(({ slug }) => slug == whereSlug);
//TODO: Pass just the app name in data.apps and maintain apps in a separate object or load them dyamically
resolve(
({
...foundApp,
...(foundApp?.slug ? TestData.apps[foundApp.slug as keyof typeof TestData.apps] || {} : {}),
enabled: true,
createdAt: new Date(),
updatedAt: new Date(),
categories: [],
} as PrismaApp) || null
);
});
};
// FIXME: How do we know which app to return?
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
prismaMock.app.findUnique.mockImplementation(appMock);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
prismaMock.app.findFirst.mockImplementation(appMock);
}
data.bookings = data.bookings || [];
allowSuccessfulBookingCreation();
addBookings(data.bookings, data.eventTypes);
// mockBusyCalendarTimes([]);
addWebhooks(data.webhooks || []);
return {
eventType,
};
}
/**
* This fn indents to /ally compute day, month, year for the purpose of testing.
* We are not using DayJS because that's actually being tested by this code.
* - `dateIncrement` adds the increment to current day
* - `monthIncrement` adds the increment to current month
* - `yearIncrement` adds the increment to current year
*/
export const getDate = (
param: { dateIncrement?: number; monthIncrement?: number; yearIncrement?: number } = {}
) => {
let { dateIncrement, monthIncrement, yearIncrement } = param;
dateIncrement = dateIncrement || 0;
monthIncrement = monthIncrement || 0;
yearIncrement = yearIncrement || 0;
let _date = new Date().getDate() + dateIncrement;
let year = new Date().getFullYear() + yearIncrement;
// Make it start with 1 to match with DayJS requiremet
let _month = new Date().getMonth() + monthIncrement + 1;
// If last day of the month(As _month is plus 1 already it is going to be the 0th day of next month which is the last day of current month)
const lastDayOfMonth = new Date(year, _month, 0).getDate();
const numberOfDaysForNextMonth = +_date - +lastDayOfMonth;
if (numberOfDaysForNextMonth > 0) {
_date = numberOfDaysForNextMonth;
_month = _month + 1;
}
if (_month === 13) {
_month = 1;
year = year + 1;
}
const date = _date < 10 ? "0" + _date : _date;
const month = _month < 10 ? "0" + _month : _month;
return {
date,
month,
year,
dateString: `${year}-${month}-${date}`,
};
};
export function getMockedCredential({
metadataLookupKey,
key,
}: {
metadataLookupKey: string;
key: {
expiry_date?: number;
token_type?: string;
access_token?: string;
refresh_token?: string;
scope: string;
};
}) {
return {
type: appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata].type,
appId: appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata].slug,
key: {
expiry_date: Date.now() + 1000000,
token_type: "Bearer",
access_token: "ACCESS_TOKEN",
refresh_token: "REFRESH_TOKEN",
...key,
},
};
}
export function getGoogleCalendarCredential() {
return getMockedCredential({
metadataLookupKey: "googlecalendar",
key: {
scope:
"https://www.googleapis.com/auth/calendar.events https://www.googleapis.com/auth/calendar.readonly",
},
});
}
export function getZoomAppCredential() {
return getMockedCredential({
metadataLookupKey: "zoomvideo",
key: {
scope: "meeting:writed",
},
});
}
export const TestData = {
selectedCalendars: {
google: {
integration: "google_calendar",
externalId: "john@example.com",
},
},
credentials: {
google: getGoogleCalendarCredential(),
},
schedules: {
IstWorkHours: {
id: 1,
name: "9:30AM to 6PM in India - 4:00AM to 12:30PM in GMT",
availability: [
{
userId: null,
eventTypeId: null,
days: [0, 1, 2, 3, 4, 5, 6],
startTime: new Date("1970-01-01T09:30:00.000Z"),
endTime: new Date("1970-01-01T18:00:00.000Z"),
date: null,
},
],
timeZone: Timezones["+5:30"],
},
IstWorkHoursWithDateOverride: (dateString: string) => ({
id: 1,
name: "9:30AM to 6PM in India - 4:00AM to 12:30PM in GMT but with a Date Override for 2PM to 6PM IST(in GST time it is 8:30AM to 12:30PM)",
availability: [
{
userId: null,
eventTypeId: null,
days: [0, 1, 2, 3, 4, 5, 6],
startTime: new Date("1970-01-01T09:30:00.000Z"),
endTime: new Date("1970-01-01T18:00:00.000Z"),
date: null,
},
{
userId: null,
eventTypeId: null,
days: [0, 1, 2, 3, 4, 5, 6],
startTime: new Date(`1970-01-01T14:00:00.000Z`),
endTime: new Date(`1970-01-01T18:00:00.000Z`),
date: dateString,
},
],
timeZone: Timezones["+5:30"],
}),
},
users: {
example: {
name: "Example",
email: "example@example.com",
username: "example",
defaultScheduleId: 1,
timeZone: Timezones["+5:30"],
},
},
apps: {
"google-calendar": {
slug: "google-calendar",
dirName: "whatever",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
keys: {
expiry_date: Infinity,
client_id: "client_id",
client_secret: "client_secret",
redirect_uris: ["http://localhost:3000/auth/callback"],
},
},
"daily-video": {
slug: "daily-video",
dirName: "whatever",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
keys: {
expiry_date: Infinity,
api_key: "",
scale_plan: "false",
client_id: "client_id",
client_secret: "client_secret",
redirect_uris: ["http://localhost:3000/auth/callback"],
},
},
},
};
function allowSuccessfulBookingCreation() {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
prismaMock.booking.create.mockImplementation(function (booking) {
return booking.data;
});
}
export class MockError extends Error {
constructor(message: string) {
super(message);
this.name = "MockError";
}
}
export function getOrganizer({
name,
email,
id,
schedules,
credentials,
selectedCalendars,
}: {
name: string;
email: string;
id: number;
schedules: InputUser["schedules"];
credentials?: InputCredential[];
selectedCalendars?: InputSelectedCalendar[];
}) {
return {
...TestData.users.example,
name,
email,
id,
schedules,
credentials,
selectedCalendars,
};
}
export function getScenarioData({
organizer,
eventTypes,
usersApartFromOrganizer = [],
apps = [],
webhooks,
}: // hosts = [],
{
organizer: ReturnType<typeof getOrganizer>;
eventTypes: ScenarioData["eventTypes"];
apps: ScenarioData["apps"];
usersApartFromOrganizer?: ScenarioData["users"];
webhooks?: ScenarioData["webhooks"];
// hosts?: ScenarioData["hosts"];
}) {
const users = [organizer, ...usersApartFromOrganizer];
eventTypes.forEach((eventType) => {
if (
eventType.users?.filter((eventTypeUser) => {
return !users.find((userToCreate) => userToCreate.id === eventTypeUser.id);
}).length
) {
throw new Error(`EventType ${eventType.id} has users that are not present in ScenarioData["users"]`);
}
});
return {
// hosts: [...hosts],
eventTypes: [...eventTypes],
users,
apps: [...apps],
webhooks,
};
}
export function mockEnableEmailFeature() {
// @ts-expect-error Prisma v5 breaks this
prismaMock.feature.findMany.mockResolvedValue([
{
slug: "emails",
// It's a kill switch
enabled: false,
},
]);
}
export function mockNoTranslations() {
// @ts-expect-error FIXME
i18nMock.getTranslation.mockImplementation(() => {
return new Promise((resolve) => {
const identityFn = (key: string) => key;
resolve(identityFn);
});
});
}
export function mockCalendarToHaveNoBusySlots(metadataLookupKey: keyof typeof appStoreMetadata) {
const appStoreLookupKey = metadataLookupKey;
appStoreMock.default[appStoreLookupKey as keyof typeof appStoreMock.default].mockResolvedValue({
lib: {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
CalendarService: function MockCalendarService() {
return {
createEvent: () => {
return Promise.resolve({
type: "daily_video",
id: "dailyEventName",
password: "dailyvideopass",
url: "http://dailyvideo.example.com",
});
},
getAvailability: (): Promise<EventBusyDate[]> => {
return new Promise((resolve) => {
resolve([]);
});
},
};
},
},
});
}
export function mockSuccessfulVideoMeetingCreation({
metadataLookupKey,
appStoreLookupKey,
}: {
metadataLookupKey: string;
appStoreLookupKey?: string;
}) {
appStoreLookupKey = appStoreLookupKey || metadataLookupKey;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
appStoreMock.default[appStoreLookupKey as keyof typeof appStoreMock.default].mockImplementation(() => {
return new Promise((resolve) => {
resolve({
lib: {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
VideoApiAdapter: () => ({
createMeeting: () => {
return Promise.resolve({
type: appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata].type,
id: "MOCK_ID",
password: "MOCK_PASS",
url: `http://mock-${metadataLookupKey}.example.com`,
});
},
}),
},
});
});
});
}
export function mockErrorOnVideoMeetingCreation({
metadataLookupKey,
appStoreLookupKey,
}: {
metadataLookupKey: string;
appStoreLookupKey?: string;
}) {
appStoreLookupKey = appStoreLookupKey || metadataLookupKey;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
appStoreMock.default[appStoreLookupKey].mockImplementation(() => {
return new Promise((resolve) => {
resolve({
lib: {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
VideoApiAdapter: () => ({
createMeeting: () => {
throw new MockError("Error creating Video meeting");
},
}),
},
});
});
});
}
export function expectWebhookToHaveBeenCalledWith(
subscriberUrl: string,
data: {
triggerEvent: WebhookTriggerEvents;
payload: { metadata: Record<string, unknown>; responses: Record<string, unknown> };
}
) {
const fetchCalls = fetchMock.mock.calls;
const webhookFetchCall = fetchCalls.find((call) => call[0] === subscriberUrl);
if (!webhookFetchCall) {
throw new Error(`Webhook not called with ${subscriberUrl}`);
}
expect(webhookFetchCall[0]).toBe(subscriberUrl);
const body = webhookFetchCall[1]?.body;
const parsedBody = JSON.parse((body as string) || "{}");
console.log({ payload: parsedBody.payload });
expect(parsedBody.triggerEvent).toBe(data.triggerEvent);
parsedBody.payload.metadata.videoCallUrl = parsedBody.payload.metadata.videoCallUrl
? parsedBody.payload.metadata.videoCallUrl.replace(/\/video\/[a-zA-Z0-9]{22}/, "/video/DYNAMIC_UID")
: parsedBody.payload.metadata.videoCallUrl;
expect(parsedBody.payload.metadata).toContain(data.payload.metadata);
expect(parsedBody.payload.responses).toEqual(data.payload.responses);
}
export function expectWorkflowToBeTriggered() {
// TODO: Implement this.
}
export function expectBookingToBeInDatabase(booking: Partial<Prisma.BookingCreateInput>) {
const createBookingCalledWithArgs = prismaMock.booking.create.mock.calls[0];
expect(createBookingCalledWithArgs[0].data).toEqual(expect.objectContaining(booking));
}
export function getBooker({ name, email }: { name: string; email: string }) {
return {
name,
email,
};
}
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace jest {
interface Matchers<R> {
toHaveEmail(expectedEmail: { htmlToContain?: string; to: string }): R;
}
}
}
expect.extend({
toHaveEmail(
testEmail: ReturnType<Fixtures["emails"]["get"]>[number],
expectedEmail: {
//TODO: Support email HTML parsing to target specific elements
htmlToContain?: string;
to: string;
}
) {
let isHtmlContained = true;
let isToAddressExpected = true;
if (expectedEmail.htmlToContain) {
isHtmlContained = testEmail.html.includes(expectedEmail.htmlToContain);
}
isToAddressExpected = expectedEmail.to === testEmail.to;
return {
pass: isHtmlContained && isToAddressExpected,
message: () => {
if (!isHtmlContained) {
return `Email HTML is not as expected. Expected:"${expectedEmail.htmlToContain}" isn't contained in "${testEmail.html}"`;
}
return `Email To address is not as expected. Expected:${expectedEmail.to} isn't contained in ${testEmail.to}`;
},
};
},
});
@@ -0,0 +1,88 @@
import prismaMock from "../../../../../tests/libs/__mocks__/prisma";
import type { Payment, Prisma, PaymentOption, Booking } from "@prisma/client";
import { v4 as uuidv4 } from "uuid";
import "vitest-fetch-mock";
import { sendAwaitingPaymentEmail } from "@calcom/emails";
import logger from "@calcom/lib/logger";
import type { CalendarEvent } from "@calcom/types/Calendar";
import type { IAbstractPaymentService } from "@calcom/types/PaymentService";
export function getMockPaymentService() {
function createPaymentLink(/*{ paymentUid, name, email, date }*/) {
return "http://mock-payment.example.com/";
}
const paymentUid = uuidv4();
const externalId = uuidv4();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
class MockPaymentService implements IAbstractPaymentService {
// TODO: We shouldn't need to implement adding a row to Payment table but that's a requirement right now.
// We should actually delegate table creation to the core app. Here, only the payment app specific logic should come
async create(
payment: Pick<Prisma.PaymentUncheckedCreateInput, "amount" | "currency">,
bookingId: Booking["id"],
userId: Booking["userId"],
username: string | null,
bookerName: string | null,
bookerEmail: string,
paymentOption: PaymentOption
) {
const paymentCreateData = {
id: 1,
uid: paymentUid,
appId: null,
bookingId,
// booking Booking? @relation(fields: [bookingId], references: [id], onDelete: Cascade)
fee: 10,
success: true,
refunded: false,
data: {},
externalId,
paymentOption,
amount: payment.amount,
currency: payment.currency,
};
const paymentData = prismaMock.payment.create({
data: paymentCreateData,
});
logger.silly("Created mock payment", JSON.stringify({ paymentData }));
return paymentData;
}
async afterPayment(
event: CalendarEvent,
booking: {
user: { email: string | null; name: string | null; timeZone: string } | null;
id: number;
startTime: { toISOString: () => string };
uid: string;
},
paymentData: Payment
): Promise<void> {
// TODO: App implementing PaymentService is supposed to send email by itself at the moment.
await sendAwaitingPaymentEmail({
...event,
paymentInfo: {
link: createPaymentLink(/*{
paymentUid: paymentData.uid,
name: booking.user?.name,
email: booking.user?.email,
date: booking.startTime.toISOString(),
}*/),
paymentOption: paymentData.paymentOption || "ON_BOOKING",
amount: paymentData.amount,
currency: paymentData.currency,
},
});
}
}
return {
paymentUid,
externalId,
MockPaymentService,
};
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,646 @@
import prismaMock from "../../../../../tests/libs/__mocks__/prisma";
import type { WebhookTriggerEvents, Booking, BookingReference } from "@prisma/client";
import ical from "node-ical";
import { expect } from "vitest";
import "vitest-fetch-mock";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { BookingStatus } from "@calcom/prisma/enums";
import type { CalendarEvent } from "@calcom/types/Calendar";
import type { Fixtures } from "@calcom/web/test/fixtures/fixtures";
import type { InputEventType } from "./bookingScenario";
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace jest {
interface Matchers<R> {
toHaveEmail(
expectedEmail: {
//TODO: Support email HTML parsing to target specific elements
htmlToContain?: string;
to: string;
noIcs?: true;
ics?: {
filename: string;
iCalUID: string;
};
},
to: string
): R;
}
}
}
expect.extend({
toHaveEmail(
emails: Fixtures["emails"],
expectedEmail: {
//TODO: Support email HTML parsing to target specific elements
htmlToContain?: string;
to: string;
ics: {
filename: string;
iCalUID: string;
};
noIcs: true;
},
to: string
) {
const testEmail = emails.get().find((email) => email.to.includes(to));
const emailsToLog = emails
.get()
.map((email) => ({ to: email.to, html: email.html, ics: email.icalEvent }));
if (!testEmail) {
logger.silly("All Emails", JSON.stringify({ numEmails: emailsToLog.length, emailsToLog }));
return {
pass: false,
message: () => `No email sent to ${to}`,
};
}
const ics = testEmail.icalEvent;
const icsObject = ics?.content ? ical.sync.parseICS(ics?.content) : null;
let isHtmlContained = true;
let isToAddressExpected = true;
const isIcsFilenameExpected = expectedEmail.ics ? ics?.filename === expectedEmail.ics.filename : true;
const isIcsUIDExpected = expectedEmail.ics
? !!(icsObject ? icsObject[expectedEmail.ics.iCalUID] : null)
: true;
if (expectedEmail.htmlToContain) {
isHtmlContained = testEmail.html.includes(expectedEmail.htmlToContain);
}
isToAddressExpected = expectedEmail.to === testEmail.to;
if (!isHtmlContained || !isToAddressExpected) {
logger.silly("All Emails", JSON.stringify({ numEmails: emailsToLog.length, emailsToLog }));
}
return {
pass:
isHtmlContained &&
isToAddressExpected &&
(expectedEmail.noIcs ? true : isIcsFilenameExpected && isIcsUIDExpected),
message: () => {
if (!isHtmlContained) {
return `Email HTML is not as expected. Expected:"${expectedEmail.htmlToContain}" isn't contained in "${testEmail.html}"`;
}
if (!isToAddressExpected) {
return `Email To address is not as expected. Expected:${expectedEmail.to} isn't equal to ${testEmail.to}`;
}
if (!isIcsFilenameExpected) {
return `ICS Filename is not as expected. Expected:${expectedEmail.ics.filename} isn't equal to ${ics?.filename}`;
}
if (!isIcsUIDExpected) {
return `ICS UID is not as expected. Expected:${
expectedEmail.ics.iCalUID
} isn't present in ${JSON.stringify(icsObject)}`;
}
throw new Error("Unknown error");
},
};
},
});
export function expectWebhookToHaveBeenCalledWith(
subscriberUrl: string,
data: {
triggerEvent: WebhookTriggerEvents;
payload: Record<string, unknown> | null;
}
) {
const fetchCalls = fetchMock.mock.calls;
const webhooksToSubscriberUrl = fetchCalls.filter((call) => {
return call[0] === subscriberUrl;
});
logger.silly("Scanning fetchCalls for webhook", safeStringify(fetchCalls));
const webhookFetchCall = webhooksToSubscriberUrl.find((call) => {
const body = call[1]?.body;
const parsedBody = JSON.parse((body as string) || "{}");
return parsedBody.triggerEvent === data.triggerEvent;
});
if (!webhookFetchCall) {
throw new Error(
`Webhook not sent to ${subscriberUrl} for ${data.triggerEvent}. All webhooks: ${JSON.stringify(
webhooksToSubscriberUrl
)}`
);
}
expect(webhookFetchCall[0]).toBe(subscriberUrl);
const body = webhookFetchCall[1]?.body;
const parsedBody = JSON.parse((body as string) || "{}");
expect(parsedBody.triggerEvent).toBe(data.triggerEvent);
if (parsedBody.payload.metadata?.videoCallUrl) {
parsedBody.payload.metadata.videoCallUrl = parsedBody.payload.metadata.videoCallUrl
? parsedBody.payload.metadata.videoCallUrl.replace(/\/video\/[a-zA-Z0-9]{22}/, "/video/DYNAMIC_UID")
: parsedBody.payload.metadata.videoCallUrl;
}
if (data.payload) {
if (data.payload.metadata !== undefined) {
expect(parsedBody.payload.metadata).toEqual(expect.objectContaining(data.payload.metadata));
}
if (data.payload.responses !== undefined)
expect(parsedBody.payload.responses).toEqual(expect.objectContaining(data.payload.responses));
const { responses: _1, metadata: _2, ...remainingPayload } = data.payload;
expect(parsedBody.payload).toEqual(expect.objectContaining(remainingPayload));
}
}
export function expectWorkflowToBeTriggered() {
// TODO: Implement this.
}
export async function expectBookingToBeInDatabase(
booking: Partial<Booking> & Pick<Booking, "uid"> & { references?: Partial<BookingReference>[] }
) {
const actualBooking = await prismaMock.booking.findUnique({
where: {
uid: booking.uid,
},
include: {
references: true,
},
});
const { references, ...remainingBooking } = booking;
expect(actualBooking).toEqual(expect.objectContaining(remainingBooking));
expect(actualBooking?.references).toEqual(
expect.arrayContaining((references || []).map((reference) => expect.objectContaining(reference)))
);
}
export function expectSuccessfulBookingCreationEmails({
emails,
organizer,
booker,
iCalUID,
}: {
emails: Fixtures["emails"];
organizer: { email: string; name: string };
booker: { email: string; name: string };
iCalUID: string;
}) {
expect(emails).toHaveEmail(
{
htmlToContain: "<title>confirmed_event_type_subject</title>",
to: `${organizer.email}`,
ics: {
filename: "event.ics",
iCalUID: iCalUID,
},
},
`${organizer.email}`
);
expect(emails).toHaveEmail(
{
htmlToContain: "<title>confirmed_event_type_subject</title>",
to: `${booker.name} <${booker.email}>`,
ics: {
filename: "event.ics",
iCalUID: iCalUID,
},
},
`${booker.name} <${booker.email}>`
);
}
export function expectBrokenIntegrationEmails({
emails,
organizer,
}: {
emails: Fixtures["emails"];
organizer: { email: string; name: string };
}) {
// Broken Integration email is only sent to the Organizer
expect(emails).toHaveEmail(
{
htmlToContain: "<title>broken_integration</title>",
to: `${organizer.email}`,
// No ics goes in case of broken integration email it seems
// ics: {
// filename: "event.ics",
// iCalUID: iCalUID,
// },
},
`${organizer.email}`
);
// expect(emails).toHaveEmail(
// {
// htmlToContain: "<title>confirmed_event_type_subject</title>",
// to: `${booker.name} <${booker.email}>`,
// },
// `${booker.name} <${booker.email}>`
// );
}
export function expectCalendarEventCreationFailureEmails({
emails,
organizer,
booker,
iCalUID,
}: {
emails: Fixtures["emails"];
organizer: { email: string; name: string };
booker: { email: string; name: string };
iCalUID: string;
}) {
expect(emails).toHaveEmail(
{
htmlToContain: "<title>broken_integration</title>",
to: `${organizer.email}`,
ics: {
filename: "event.ics",
iCalUID,
},
},
`${organizer.email}`
);
expect(emails).toHaveEmail(
{
htmlToContain: "<title>calendar_event_creation_failure_subject</title>",
to: `${booker.name} <${booker.email}>`,
ics: {
filename: "event.ics",
iCalUID,
},
},
`${booker.name} <${booker.email}>`
);
}
export function expectSuccessfulBookingRescheduledEmails({
emails,
organizer,
booker,
iCalUID,
}: {
emails: Fixtures["emails"];
organizer: { email: string; name: string };
booker: { email: string; name: string };
iCalUID: string;
}) {
expect(emails).toHaveEmail(
{
htmlToContain: "<title>event_type_has_been_rescheduled_on_time_date</title>",
to: `${organizer.email}`,
ics: {
filename: "event.ics",
iCalUID,
},
},
`${organizer.email}`
);
expect(emails).toHaveEmail(
{
htmlToContain: "<title>event_type_has_been_rescheduled_on_time_date</title>",
to: `${booker.name} <${booker.email}>`,
ics: {
filename: "event.ics",
iCalUID,
},
},
`${booker.name} <${booker.email}>`
);
}
export function expectAwaitingPaymentEmails({
emails,
booker,
}: {
emails: Fixtures["emails"];
organizer: { email: string; name: string };
booker: { email: string; name: string };
}) {
expect(emails).toHaveEmail(
{
htmlToContain: "<title>awaiting_payment_subject</title>",
to: `${booker.name} <${booker.email}>`,
noIcs: true,
},
`${booker.email}`
);
}
export function expectBookingRequestedEmails({
emails,
organizer,
booker,
}: {
emails: Fixtures["emails"];
organizer: { email: string; name: string };
booker: { email: string; name: string };
}) {
expect(emails).toHaveEmail(
{
htmlToContain: "<title>event_awaiting_approval_subject</title>",
to: `${organizer.email}`,
noIcs: true,
},
`${organizer.email}`
);
expect(emails).toHaveEmail(
{
htmlToContain: "<title>booking_submitted_subject</title>",
to: `${booker.email}`,
noIcs: true,
},
`${booker.email}`
);
}
export function expectBookingRequestedWebhookToHaveBeenFired({
booker,
location,
subscriberUrl,
paidEvent,
eventType,
}: {
organizer: { email: string; name: string };
booker: { email: string; name: string };
subscriberUrl: string;
location: string;
paidEvent?: boolean;
eventType: InputEventType;
}) {
// There is an inconsistency in the way we send the data to the webhook for paid events and unpaid events. Fix that and then remove this if statement.
if (!paidEvent) {
expectWebhookToHaveBeenCalledWith(subscriberUrl, {
triggerEvent: "BOOKING_REQUESTED",
payload: {
eventTitle: eventType.title,
eventDescription: eventType.description,
metadata: {
// In a Pending Booking Request, we don't send the video call url
},
responses: {
name: { label: "your_name", value: booker.name },
email: { label: "email_address", value: booker.email },
location: {
label: "location",
value: { optionValue: "", value: location },
},
},
},
});
} else {
expectWebhookToHaveBeenCalledWith(subscriberUrl, {
triggerEvent: "BOOKING_REQUESTED",
payload: {
eventTitle: eventType.title,
eventDescription: eventType.description,
metadata: {
// In a Pending Booking Request, we don't send the video call url
},
responses: {
name: { label: "name", value: booker.name },
email: { label: "email", value: booker.email },
location: {
label: "location",
value: { optionValue: "", value: location },
},
},
},
});
}
}
export function expectBookingCreatedWebhookToHaveBeenFired({
booker,
location,
subscriberUrl,
paidEvent,
videoCallUrl,
}: {
organizer: { email: string; name: string };
booker: { email: string; name: string };
subscriberUrl: string;
location: string;
paidEvent?: boolean;
videoCallUrl?: string | null;
}) {
if (!paidEvent) {
expectWebhookToHaveBeenCalledWith(subscriberUrl, {
triggerEvent: "BOOKING_CREATED",
payload: {
metadata: {
...(videoCallUrl ? { videoCallUrl } : null),
},
responses: {
name: { label: "your_name", value: booker.name },
email: { label: "email_address", value: booker.email },
location: {
label: "location",
value: { optionValue: "", value: location },
},
},
},
});
} else {
expectWebhookToHaveBeenCalledWith(subscriberUrl, {
triggerEvent: "BOOKING_CREATED",
payload: {
// FIXME: File this bug and link ticket here. This is a bug in the code. metadata must be sent here like other BOOKING_CREATED webhook
metadata: null,
responses: {
name: { label: "name", value: booker.name },
email: { label: "email", value: booker.email },
location: {
label: "location",
value: { optionValue: "", value: location },
},
},
},
});
}
}
export function expectBookingRescheduledWebhookToHaveBeenFired({
booker,
location,
subscriberUrl,
videoCallUrl,
}: {
organizer: { email: string; name: string };
booker: { email: string; name: string };
subscriberUrl: string;
location: string;
paidEvent?: boolean;
videoCallUrl?: string;
}) {
expectWebhookToHaveBeenCalledWith(subscriberUrl, {
triggerEvent: "BOOKING_RESCHEDULED",
payload: {
metadata: {
...(videoCallUrl ? { videoCallUrl } : null),
},
responses: {
name: { label: "your_name", value: booker.name },
email: { label: "email_address", value: booker.email },
location: {
label: "location",
value: { optionValue: "", value: location },
},
},
},
});
}
export function expectBookingPaymentIntiatedWebhookToHaveBeenFired({
booker,
location,
subscriberUrl,
paymentId,
}: {
organizer: { email: string; name: string };
booker: { email: string; name: string };
subscriberUrl: string;
location: string;
paymentId: number;
}) {
expectWebhookToHaveBeenCalledWith(subscriberUrl, {
triggerEvent: "BOOKING_PAYMENT_INITIATED",
payload: {
paymentId: paymentId,
metadata: {
// In a Pending Booking Request, we don't send the video call url
},
responses: {
name: { label: "your_name", value: booker.name },
email: { label: "email_address", value: booker.email },
location: {
label: "location",
value: { optionValue: "", value: location },
},
},
},
});
}
export function expectSuccessfulCalendarEventCreationInCalendar(
calendarMock: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
createEventCalls: any[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
updateEventCalls: any[];
},
expected: {
calendarId: string | null;
videoCallUrl: string;
}
) {
expect(calendarMock.createEventCalls.length).toBe(1);
const call = calendarMock.createEventCalls[0];
const calEvent = call[0];
expect(calEvent).toEqual(
expect.objectContaining({
destinationCalendar: expected.calendarId
? [
expect.objectContaining({
externalId: expected.calendarId,
}),
]
: null,
videoCallData: expect.objectContaining({
url: expected.videoCallUrl,
}),
})
);
}
export function expectSuccessfulCalendarEventUpdationInCalendar(
calendarMock: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
createEventCalls: any[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
updateEventCalls: any[];
},
expected: {
externalCalendarId: string;
calEvent: Partial<CalendarEvent>;
uid: string;
}
) {
expect(calendarMock.updateEventCalls.length).toBe(1);
const call = calendarMock.updateEventCalls[0];
const uid = call[0];
const calendarEvent = call[1];
const externalId = call[2];
expect(uid).toBe(expected.uid);
expect(calendarEvent).toEqual(expect.objectContaining(expected.calEvent));
expect(externalId).toBe(expected.externalCalendarId);
}
export function expectSuccessfulVideoMeetingCreationInCalendar(
videoMock: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
createMeetingCalls: any[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
updateMeetingCalls: any[];
},
expected: {
externalCalendarId: string;
calEvent: Partial<CalendarEvent>;
uid: string;
}
) {
expect(videoMock.createMeetingCalls.length).toBe(1);
const call = videoMock.createMeetingCalls[0];
const uid = call[0];
const calendarEvent = call[1];
const externalId = call[2];
expect(uid).toBe(expected.uid);
expect(calendarEvent).toEqual(expect.objectContaining(expected.calEvent));
expect(externalId).toBe(expected.externalCalendarId);
}
export function expectSuccessfulVideoMeetingUpdationInCalendar(
videoMock: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
createMeetingCalls: any[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
updateMeetingCalls: any[];
},
expected: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
bookingRef: any;
calEvent: Partial<CalendarEvent>;
}
) {
expect(videoMock.updateMeetingCalls.length).toBe(1);
const call = videoMock.updateMeetingCalls[0];
const bookingRef = call[0];
const calendarEvent = call[1];
expect(bookingRef).toEqual(expect.objectContaining(expected.bookingRef));
expect(calendarEvent).toEqual(expect.objectContaining(expected.calEvent));
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export async function expectBookingInDBToBeRescheduledFromTo({ from, to }: { from: any; to: any }) {
// Expect previous booking to be cancelled
await expectBookingToBeInDatabase({
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
...from,
status: BookingStatus.CANCELLED,
});
// Expect new booking to be created
await expectBookingToBeInDatabase({
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
...to,
status: BookingStatus.ACCEPTED,
});
}
+3
View File
@@ -78,6 +78,7 @@
"@playwright/test": "^1.31.2",
"@snaplet/copycat": "^0.3.0",
"@testing-library/jest-dom": "^5.16.5",
"@types/jsonwebtoken": "^9.0.3",
"c8": "^7.13.0",
"dotenv-checker": "^1.1.5",
"husky": "^8.0.0",
@@ -86,7 +87,9 @@
"jsdom": "^22.0.0",
"lint-staged": "^12.5.0",
"mailhog": "^4.16.0",
"node-ical": "^0.16.1",
"prettier": "^2.8.6",
"prismock": "^1.21.1",
"tsc-absolute": "^1.0.0",
"typescript": "^4.9.4",
"vitest": "^0.34.3",
@@ -208,7 +208,7 @@ export const AppForm = ({
</Text>
<Text>
Tip : Go and change the logo of your {isTemplate ? "template" : "app"} by replacing{" "}
{getAppDirPath(slug, isTemplate) + "/static/icon.svg"}
{`${getAppDirPath(slug, isTemplate)}/static/icon.svg`}
</Text>
<Newline />
<Text bold underline color="blue">
@@ -12,7 +12,7 @@ export function Message({
if (message.showInProgressIndicator) {
const interval = setInterval(() => {
setProgressText((progressText) => {
return progressText.length > 3 ? "" : progressText + ".";
return progressText.length > 3 ? "" : `${progressText}.`;
});
}, 1000);
return () => {
+9 -3
View File
@@ -1,7 +1,7 @@
import fs from "fs";
import path from "path";
import { APP_STORE_PATH, TEMPLATES_PATH, IS_WINDOWS_PLATFORM} from "./constants";
import { APP_STORE_PATH, TEMPLATES_PATH, IS_WINDOWS_PLATFORM } from "./constants";
import execSync from "./utils/execSync";
const slugify = (str: string) => {
@@ -70,7 +70,11 @@ export const BaseAppFork = {
const appDirPath = getAppDirPath(slug, isTemplate);
if (!editMode) {
await execSync(IS_WINDOWS_PLATFORM ? `mkdir ${appDirPath}` : `mkdir -p ${appDirPath}`);
await execSync(IS_WINDOWS_PLATFORM ? `xcopy "${TEMPLATES_PATH}\\${template}\\*" "${appDirPath}" /e /i` : `cp -r ${TEMPLATES_PATH}/${template}/* ${appDirPath}`);
await execSync(
IS_WINDOWS_PLATFORM
? `xcopy "${TEMPLATES_PATH}\\${template}\\*" "${appDirPath}" /e /i`
: `cp -r ${TEMPLATES_PATH}/${template}/* ${appDirPath}`
);
} else {
if (!oldSlug) {
throw new Error("oldSlug is required when editMode is true");
@@ -79,7 +83,9 @@ export const BaseAppFork = {
// We need to rename only if they are different
const oldAppDirPath = getAppDirPath(oldSlug, isTemplate);
await execSync(IS_WINDOWS_PLATFORM ? `move ${oldAppDirPath} ${appDirPath}` : `mv ${oldAppDirPath} ${appDirPath}`);
await execSync(
IS_WINDOWS_PLATFORM ? `move ${oldAppDirPath} ${appDirPath}` : `mv ${oldAppDirPath} ${appDirPath}`
);
}
}
updatePackageJson({ slug, appDirPath, appDescription: description });
+5 -2
View File
@@ -1,11 +1,13 @@
## App Contribution Guidelines
#### `DESCRIPTION.md`
1. images - include atleast 4 images (do we have a recommended size here?). Can show app in use and/or installation steps
2. add only file name for images, path not required. i.e. `1.jpeg`, not `/app-store/zohocalendar/1.jpeg`
3. description should include what the integration with Cal allows the user to do e.g. `Allows you to sync Cal bookings with your Zoho Calendar`
#### `README.md`
1. Include installation instructions and links to the app's website.
2. For url use `<baseUrl>/api/integrations`, rather than `<Cal.com>/api/integrations`
@@ -16,7 +18,8 @@
2. description here should not exceed 10 words (this is arbitrary, but should not be long otherwise it's truncated in the app store)
#### Others
1. Add API documentation links in comments for files `api`, `lib` and `types`
1. Add API documentation links in comments for files `api`, `lib` and `types`
2. Use [`AppDeclarativeHandler`](../types/AppHandler.d.ts) across all apps. Whatever isn't supported in it, support that.
3. README should be added in the respective app and can be linked in main README [like this](https://github.com/calcom/cal.com/pull/10429/files/155ac84537d12026f595551fe3542e810b029714#diff-b335630551682c19a781afebcf4d07bf978fb1f8ac04c6bf87428ed5106870f5R509)
4. Also, no env variables should be added by an app. They should be [added in `zod.ts`](https://github.com/calcom/cal.com/blob/main/packages/app-store/jitsivideo/zod.ts) and then they would be automatically available to be modified by the cal.com app admin. In local development you can open /settings/admin with the admin credentials (see [seed.ts](packages/prisma/seed.ts))
4. Also, no env variables should be added by an app. They should be [added in `zod.ts`](https://github.com/calcom/cal.com/blob/main/packages/app-store/jitsivideo/zod.ts) and then they would be automatically available to be modified by the cal.com app admin. In local development you can open /settings/admin with the admin credentials (see [seed.ts](packages/prisma/seed.ts))
+2 -2
View File
@@ -44,7 +44,7 @@ export default function AppCard({
<div className="flex w-full flex-col gap-2 sm:flex-row sm:gap-0">
{/* Don't know why but w-[42px] isn't working, started happening when I started using next/dynamic */}
<Link
href={"/apps/" + app.slug}
href={`/apps/${app.slug}`}
className={classNames(app?.isInstalled ? "mr-[11px]" : "mr-3", "h-auto w-10 rounded-sm")}>
<img
className={classNames(
@@ -116,7 +116,7 @@ export default function AppCard({
{app?.isInstalled && switchChecked && <hr className="border-subtle" />}
{app?.isInstalled && switchChecked ? (
app.isSetupAlready ? (
app.isSetupAlready === undefined || app.isSetupAlready ? (
<div className="relative p-4 pt-5 text-sm [&_input]:mb-0 [&_input]:leading-4">
<Link href={`/apps/${app.slug}/setup`} className="absolute right-4 top-4">
<Settings className="text-default h-4 w-4" aria-hidden="true" />
@@ -0,0 +1,97 @@
import { render, screen } from "@testing-library/react";
import type { CredentialOwner } from "types";
import { vi } from "vitest";
import type { RouterOutputs } from "@calcom/trpc";
import { DynamicComponent } from "./DynamicComponent";
import { EventTypeAppCard } from "./EventTypeAppCardInterface";
vi.mock("./DynamicComponent", async () => {
const actual = (await vi.importActual("./DynamicComponent")) as object;
return {
...actual,
DynamicComponent: vi.fn(() => <div>MockedDynamicComponent</div>),
};
});
afterEach(() => {
vi.clearAllMocks();
});
const getAppDataMock = vi.fn();
const setAppDataMock = vi.fn();
const mockProps = {
app: {
name: "TestApp",
slug: "testapp",
credentialOwner: {},
} as RouterOutputs["viewer"]["integrations"]["items"][number] & { credentialOwner?: CredentialOwner },
eventType: {},
getAppData: getAppDataMock,
setAppData: setAppDataMock,
LockedIcon: <div>MockedIcon</div>,
disabled: false,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
describe("Tests for EventTypeAppCard component", () => {
test("Should render DynamicComponent with correct slug", () => {
render(<EventTypeAppCard {...mockProps} />);
expect(DynamicComponent).toHaveBeenCalledWith(
expect.objectContaining({
slug: mockProps.app.slug,
}),
{}
);
expect(screen.getByText("MockedDynamicComponent")).toBeInTheDocument();
});
test("Should invoke getAppData and setAppData from context on render", () => {
render(
<EventTypeAppCard
{...mockProps}
value={{
getAppData: getAppDataMock(),
setAppData: setAppDataMock(),
}}
/>
);
expect(getAppDataMock).toHaveBeenCalled();
expect(setAppDataMock).toHaveBeenCalled();
});
test("Should render DynamicComponent with 'stripepayment' slug for stripe app", () => {
const stripeProps = {
...mockProps,
app: {
...mockProps.app,
slug: "stripe",
},
};
render(<EventTypeAppCard {...stripeProps} />);
expect(DynamicComponent).toHaveBeenCalledWith(
expect.objectContaining({
slug: "stripepayment",
}),
{}
);
expect(screen.getByText("MockedDynamicComponent")).toBeInTheDocument();
});
test("Should display error boundary message on child component error", () => {
(DynamicComponent as jest.Mock).mockImplementation(() => {
return Error("Mocked error from DynamicComponent");
});
render(<EventTypeAppCard {...mockProps} />);
const errorMessage = screen.getByText(`There is some problem with ${mockProps.app.name} App`);
expect(errorMessage).toBeInTheDocument();
});
});
+1 -1
View File
@@ -42,7 +42,7 @@ export const getCalendar = async (credential: CredentialPayload | null): Promise
log.warn(`calendar of type ${calendarType} is not implemented`);
return null;
}
log.info("calendarApp", calendarApp.lib.CalendarService);
log.info("Got calendarApp", calendarApp.lib.CalendarService);
const CalendarService = calendarApp.lib.CalendarService;
return new CalendarService(credential);
};
@@ -64,7 +64,7 @@ function useAddAppMutation(_type: App["type"] | null, allOptions?: UseAddAppMuta
const stateStr = encodeURIComponent(JSON.stringify(state));
const searchParams = `?state=${stateStr}${teamId ? `&teamId=${teamId}` : ""}`;
const res = await fetch(`/api/integrations/${type}/add` + searchParams);
const res = await fetch(`/api/integrations/${type}/add${searchParams}`);
if (!res.ok) {
const errorBody = await res.json();
@@ -111,9 +111,14 @@ export const AlbyPaymentComponent = (props: IAlbyPaymentComponentProps) => {
)}
<Link target="_blank" href="https://getalby.com">
<div className="mt-4 flex items-center text-sm">
Powered by
<img title="Alby" src="/app-store/alby/icon.svg" alt="Alby" className="h-8 w-8" />
Alby
Powered by&nbsp;
<img title="Alby" src="/app-store/alby/logo.svg" alt="Alby" className="h-8 dark:hidden" />
<img
title="Alby"
src="/app-store/alby/logo-dark.svg"
alt="Alby"
className="hidden h-8 dark:block"
/>
</div>
</Link>
</div>
@@ -1,6 +1,7 @@
import type { GetServerSidePropsContext } from "next";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import prisma from "@calcom/prisma";
import { getAlbyKeys } from "../../lib/getAlbyKeys";
import type { IAlbySetupProps } from "./index";
+14 -3
View File
@@ -120,6 +120,17 @@ function AlbySetupPage(props: IAlbySetupProps) {
return <div className="absolute z-50 flex h-screen w-full items-center bg-gray-200" />;
}
const albyIcon = (
<>
<img className="h-16 w-16 dark:hidden" src="/api/app-store/alby/icon-borderless.svg" alt="Alby Icon" />
<img
className="hidden h-16 w-16 dark:block"
src="/api/app-store/alby/icon-borderless-dark.svg"
alt="Alby Icon"
/>
</>
);
return (
<div className="bg-default flex h-screen">
{showContent ? (
@@ -138,13 +149,13 @@ function AlbySetupPage(props: IAlbySetupProps) {
}}
type="button"
onClick={connectWithAlby}>
<img className="h-8 w-8" src="/api/app-store/alby/icon2.svg" alt="Alby Logo" />
{albyIcon}
<span className="mr-2">Connect with Alby</span>
</button>
</>
) : (
<>
<img className="h-16 w-16" src="/api/app-store/alby/icon2.svg" alt="Alby Logo" />
{albyIcon}
<p>Alby Connected!</p>
<Badge>Email: {props.email}</Badge>
<Badge>Lightning Address: {props.lightningAddress}</Badge>
@@ -152,7 +163,7 @@ function AlbySetupPage(props: IAlbySetupProps) {
)}
{/* TODO: remove when invoices are generated using user identifier */}
<div className="mt-4 rounded bg-blue-50 p-3 text-sm text-blue-700">
<div className="mt-4 rounded bg-blue-50 p-3 text-sm text-blue-700 dark:bg-blue-950 dark:text-blue-300">
<Info className="mb-0.5 inline-flex h-4 w-4" /> Your Alby lightning address will be used to
generate invoices. If you update your lightning address, please disconnect and setup the Alby
app again.
@@ -0,0 +1,16 @@
<svg width="490" height="489" viewBox="0 0 490 489" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M90.0776 181.767C97.3318 182.509 104.344 181.661 110.765 179.495L110.917 179.632L111.159 179.874C83.8531 210.92 64.5892 251.099 56.6382 295.564C51.0044 327.034 67.8907 357.293 95.8477 370.605C141.418 392.293 192.395 404.423 246.143 404.423C299.891 404.423 349.823 392.535 395.03 371.272C423.093 358.066 440.1 327.822 434.557 296.306C426.697 251.599 407.024 211.481 379.976 179.965C345.825 140.15 380.082 179.798 380.082 179.798C380.082 179.798 391.819 182.342 398.119 181.903C422.427 180.222 442.024 160.368 443.387 136.03C445.007 106.877 421.018 82.9028 391.865 84.5384C367.86 85.8863 348.127 105.014 346.082 128.973C345.446 136.439 346.506 143.618 348.914 150.145L348.521 150.539L348.294 150.766C318.822 129.033 283.671 116.221 245.552 116.221C207.433 116.221 172.04 129.124 142.493 151.008L142.266 150.781L141.903 150.418L141.191 149.706C143.508 143.224 144.507 136.106 143.826 128.715C141.66 104.878 121.972 85.9015 98.0739 84.5536C68.8599 82.918 44.8708 106.907 46.5065 136.091C47.8392 159.777 66.4671 179.344 90.0624 181.782L90.0776 181.767Z" fill="white" stroke="white" stroke-width="19.962"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M104.98 351.417C85.2161 342.012 73.7213 320.886 77.5681 299.335C85.6099 254.325 106.07 214.797 134.451 186.083C138.783 181.691 143.311 177.572 147.991 173.695C175.69 150.872 209.281 137.514 245.492 137.514C281.703 137.514 315.051 150.781 342.69 173.453C347.385 177.299 351.913 181.434 356.26 185.811C384.928 214.691 405.555 254.582 413.537 300.016C417.338 321.597 405.737 342.724 385.913 352.053C343.478 372.014 296.09 383.175 246.098 383.175C196.105 383.175 147.718 371.787 104.95 351.417H104.98Z" fill="#FFDF6F"/>
<path d="M95.5749 121.279L82.6111 134.243L113.446 165.078C118.822 161.988 123.35 157.566 126.561 152.265L95.5749 121.279Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M395.287 96.6087C375.448 96.2756 358.183 113.056 357.986 132.895C357.91 140.15 359.955 146.934 363.529 152.659L394.894 121.294L407.857 134.258L376.796 165.305C382.944 168.727 390.184 170.454 397.847 169.803C415.505 168.303 429.772 153.977 431.195 136.318C432.922 114.903 416.232 96.9571 395.287 96.6087ZM363.529 152.659L349.233 166.94C353.927 170.817 358.44 174.967 362.772 179.329L376.796 165.305C371.374 162.276 366.8 157.914 363.529 152.659ZM422.593 298.441C414.339 251.463 392.879 209.709 362.772 179.329L356.29 185.811C351.943 181.434 347.415 177.314 342.72 173.468L349.233 166.94C320.034 142.709 284.353 128.367 245.507 128.367C206.661 128.367 170.753 142.8 141.494 167.198L148.006 173.71C143.311 177.572 138.798 181.706 134.466 186.098L127.985 179.616C98.1648 209.815 76.8714 251.19 68.5419 297.745C63.9379 323.551 77.7498 348.615 101.042 359.701C145.007 380.631 194.212 392.353 246.128 392.353C298.044 392.353 346.219 380.874 389.85 360.353C413.219 349.342 427.152 324.293 422.593 298.441ZM382.036 343.754C340.812 363.169 294.742 374.013 246.128 374.013C197.514 374.013 150.489 362.942 108.917 343.148C92.6976 335.424 83.5048 318.235 86.5943 300.97C94.3635 257.505 113.991 219.81 140.948 192.565L154.533 180.237C180.642 158.989 212.052 146.692 245.507 146.692C278.961 146.692 310.129 158.883 336.193 179.98L349.808 192.293C377.038 219.689 396.832 257.732 404.541 301.606C407.585 318.917 398.316 336.106 382.036 343.754ZM126.576 152.28C123.365 157.581 118.837 161.988 113.461 165.093L127.985 179.616C132.316 175.225 136.814 171.09 141.494 167.198L126.576 152.28ZM126.576 152.28C130.059 146.541 132.013 139.771 131.877 132.562C131.483 112.495 114.536 96.215 94.4695 96.6087C73.5548 97.0328 56.9411 114.949 58.6676 136.318C60.1063 154.067 74.5089 168.425 92.2584 169.818C100.012 170.424 107.297 168.606 113.461 165.093L82.6264 134.258L95.5902 121.294L126.576 152.28Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M407.857 134.258L376.796 165.305C371.374 162.276 366.8 157.914 363.529 152.659L394.894 121.295L407.857 134.258Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M362.772 179.329L356.29 185.811C351.943 181.434 347.415 177.315 342.72 173.468L349.232 166.94C353.927 170.817 358.44 174.967 362.772 179.329Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M148.006 173.71C143.311 177.572 138.798 181.706 134.467 186.098L127.985 179.616C132.316 175.225 136.814 171.09 141.494 167.198L148.006 173.71Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M126.576 152.28C123.366 157.581 118.837 161.988 113.461 165.093L82.6265 134.258L95.5903 121.295L126.576 152.28Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M407.857 134.243L376.796 165.29C371.374 162.261 366.8 157.899 363.529 152.644L394.894 121.279L407.857 134.243Z" fill="black"/>
<path d="M349.232 166.925L342.705 173.453C347.4 177.299 351.928 181.434 356.275 185.811L362.757 179.329C358.425 174.952 353.912 170.817 349.232 166.94V166.925Z" fill="black"/>
<path d="M141.478 167.183C136.799 171.075 132.301 175.225 127.969 179.601L134.451 186.083C138.783 181.691 143.311 177.572 147.99 173.695L141.478 167.183Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M140.873 329.064C124.971 322.582 115.536 305.665 121.048 289.4C138.041 239.241 187.624 202.924 246.143 202.924C304.662 202.924 354.245 239.226 371.253 289.4C376.766 305.665 367.33 322.597 351.429 329.064C318.943 342.3 283.399 349.585 246.158 349.585C208.918 349.585 173.373 342.3 140.888 329.064H140.873Z" fill="black"/>
<path d="M288.321 305.59C305.2 305.59 318.883 294.646 318.883 281.146C318.883 267.646 305.2 256.703 288.321 256.703C271.442 256.703 257.759 267.646 257.759 281.146C257.759 294.646 271.442 305.59 288.321 305.59Z" fill="white"/>
<path d="M200.845 305.605C217.724 305.605 231.407 294.661 231.407 281.161C231.407 267.661 217.724 256.718 200.845 256.718C183.966 256.718 170.283 267.661 170.283 281.161C170.283 294.661 183.966 305.605 200.845 305.605Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 5.8 KiB

@@ -0,0 +1,11 @@
<svg width="490" height="489" viewBox="0 0 490 489" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="36.675" cy="36.675" r="36.675" transform="matrix(-1 0 0 1 131.308 101.468)" fill="black"/>
<path d="M88.52 132.641L156.98 201.101" stroke="black" stroke-width="18.3375"/>
<circle cx="394.145" cy="138.143" r="36.675" fill="black"/>
<path d="M400.869 132.641L332.409 201.101" stroke="black" stroke-width="18.3375"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M105.043 356.993C85.276 347.583 73.7721 326.451 77.6256 304.901C94.165 212.406 163.093 143.033 245.611 143.033C328.33 143.033 397.391 212.743 413.716 305.573C417.513 327.163 405.915 348.285 386.081 357.618C343.64 377.591 296.235 388.755 246.222 388.755C195.69 388.755 147.82 377.358 105.043 356.993Z" fill="#FFDF6F"/>
<path d="M413.716 305.573L422.746 303.985L413.716 305.573ZM386.081 357.618L382.177 349.322L386.081 357.618ZM86.6512 306.515C102.633 217.14 168.694 152.201 245.611 152.201V133.864C157.491 133.864 85.6973 207.672 68.6 303.287L86.6512 306.515ZM245.611 152.201C322.713 152.201 388.911 217.457 404.686 307.161L422.746 303.985C405.872 208.028 333.946 133.864 245.611 133.864V152.201ZM382.177 349.322C340.932 368.732 294.857 379.586 246.222 379.586V397.924C297.613 397.924 346.348 386.449 389.985 365.914L382.177 349.322ZM246.222 379.586C197.082 379.586 150.556 368.505 108.984 348.714L101.102 365.272C145.084 386.21 194.298 397.924 246.222 397.924V379.586ZM404.686 307.161C407.73 324.47 398.462 341.659 382.177 349.322L389.985 365.914C413.369 354.91 427.296 329.855 422.746 303.985L404.686 307.161ZM68.6 303.287C63.9824 329.11 77.7983 354.178 101.102 365.272L108.984 348.714C92.7538 340.988 83.5618 323.792 86.6512 306.515L68.6 303.287Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M140.311 333.998C124.4 327.518 114.962 310.589 120.475 294.317C137.477 244.138 187.075 207.825 245.611 207.825C304.148 207.825 353.745 244.138 370.748 294.317C376.261 310.589 366.823 327.518 350.912 333.998C318.414 347.233 282.864 354.525 245.611 354.525C208.358 354.525 172.808 347.233 140.311 333.998Z" fill="black"/>
<ellipse cx="287.788" cy="286.065" rx="30.5625" ry="24.45" fill="white"/>
<ellipse cx="200.295" cy="286.08" rx="30.5625" ry="24.45" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

-11
View File
@@ -1,11 +0,0 @@
<svg width="554" height="554" viewBox="0 0 554 554" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M127.7 202.4C107.431 202.4 90.9999 185.969 90.9999 165.7C90.9999 145.431 107.431 129 127.7 129C147.969 129 164.4 145.431 164.4 165.7C164.4 185.969 147.969 202.4 127.7 202.4Z" fill="black"/>
<path d="M121.6 160.2L190.1 228.7" stroke="black" stroke-width="18.3"/>
<path d="M427.2 202.4C447.469 202.4 463.9 185.969 463.9 165.7C463.9 145.431 447.469 129 427.2 129C406.931 129 390.5 145.431 390.5 165.7C390.5 185.969 406.931 202.4 427.2 202.4Z" fill="black"/>
<path d="M434 160.2L365.5 228.7" stroke="black" stroke-width="18.3"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M138.1 384.6C128.451 380.122 120.543 372.597 115.592 363.182C110.641 353.768 108.922 342.987 110.7 332.5C127.3 240 196.2 170.6 278.7 170.6C361.4 170.6 430.5 240.3 446.8 333.2C448.539 343.695 446.779 354.47 441.792 363.867C436.805 373.263 428.866 380.759 419.2 385.2C375.441 405.799 327.664 416.454 279.3 416.4C228.8 416.4 180.9 405 138.1 384.6Z" fill="#FFDF6F"/>
<path d="M119.8 334.2C135.8 244.7 201.8 179.8 278.8 179.8V161.5C190.6 161.5 118.8 235.3 101.7 330.9L119.7 334.1L119.8 334.2ZM278.8 179.8C355.8 179.8 422 245.1 437.8 334.8L455.8 331.6C439 235.6 367 161.5 278.8 161.5V179.8ZM415.3 376.9C372.76 396.917 326.314 407.265 279.3 407.2V425.5C330.7 425.5 379.4 414 423.1 393.5L415.3 376.9ZM279.3 407.2C230.2 407.2 183.7 396.1 142.1 376.3L134.2 392.9C178.2 413.9 227.4 425.5 279.3 425.5V407.2ZM437.8 334.8C440.8 352.1 431.6 369.3 415.3 376.9L423.1 393.5C446.5 382.5 460.4 357.5 455.8 331.5L437.8 334.8ZM101.7 330.8C99.5558 343.269 101.577 356.098 107.451 367.303C113.325 378.509 122.725 387.47 134.2 392.8L142.1 376.3C134.267 372.688 127.84 366.599 123.81 358.973C119.78 351.347 118.371 342.606 119.8 334.1L101.6 330.9L101.7 330.8Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M173.4 361.6C157.5 355.1 148.1 338.2 153.6 321.9C170.6 271.7 220.2 235.4 278.7 235.4C337.2 235.4 386.8 271.7 403.8 321.9C409.4 338.2 399.9 355.1 384 361.6C350.559 375.19 314.796 382.153 278.7 382.1C241.5 382.1 205.9 374.8 173.4 361.6Z" fill="black"/>
<path d="M320.9 338.1C337.8 338.1 351.5 327.176 351.5 313.7C351.5 300.224 337.8 289.3 320.9 289.3C304 289.3 290.3 300.224 290.3 313.7C290.3 327.176 304 338.1 320.9 338.1Z" fill="white"/>
<path d="M233.4 338.1C250.3 338.1 264 327.176 264 313.7C264 300.224 250.3 289.3 233.4 289.3C216.5 289.3 202.8 300.224 202.8 313.7C202.8 327.176 216.5 338.1 233.4 338.1Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.5 KiB

@@ -0,0 +1,17 @@
<svg width="1211" height="525" viewBox="0 0 1211 525" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M90.0776 187.767C97.3318 188.509 104.344 187.661 110.765 185.495L110.917 185.632L111.159 185.874C83.8531 216.92 64.5892 257.099 56.6382 301.564C51.0044 333.034 67.8907 363.293 95.8477 376.605C141.418 398.293 192.395 410.423 246.143 410.423C299.891 410.423 349.823 398.535 395.03 377.272C423.093 364.066 440.1 333.822 434.557 302.306C426.697 257.599 407.024 217.481 379.976 185.965C345.825 146.15 380.082 185.798 380.082 185.798C380.082 185.798 391.819 188.342 398.119 187.903C422.427 186.222 442.024 166.368 443.387 142.03C445.007 112.877 421.018 88.9028 391.865 90.5384C367.86 91.8863 348.127 111.014 346.082 134.973C345.446 142.439 346.506 149.618 348.914 156.145L348.521 156.539L348.294 156.766C318.822 135.033 283.671 122.221 245.552 122.221C207.433 122.221 172.04 135.124 142.493 157.008L142.266 156.781L141.903 156.418L141.191 155.706C143.508 149.224 144.507 142.106 143.826 134.715C141.66 110.878 121.972 91.9015 98.0739 90.5536C68.8599 88.918 44.8708 112.907 46.5065 142.091C47.8392 165.777 66.4671 185.344 90.0624 187.782L90.0776 187.767Z" fill="white" stroke="white" stroke-width="19.962"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M104.98 357.417C85.2161 348.012 73.7213 326.886 77.5681 305.335C85.6099 260.325 106.07 220.797 134.451 192.083C138.783 187.691 143.311 183.572 147.991 179.695C175.69 156.872 209.281 143.514 245.492 143.514C281.703 143.514 315.051 156.781 342.69 179.453C347.385 183.299 351.913 187.434 356.26 191.811C384.928 220.691 405.555 260.582 413.537 306.016C417.338 327.597 405.737 348.724 385.913 358.053C343.478 378.014 296.09 389.175 246.098 389.175C196.105 389.175 147.718 377.787 104.95 357.417H104.98Z" fill="#FFDF6F"/>
<path d="M95.5749 127.279L82.6111 140.243L113.446 171.078C118.822 167.988 123.35 163.566 126.561 158.265L95.5749 127.279Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M395.287 102.609C375.448 102.276 358.183 119.056 357.986 138.895C357.91 146.15 359.955 152.934 363.529 158.659L394.894 127.294L407.857 140.258L376.796 171.305C382.944 174.727 390.184 176.454 397.847 175.803C415.505 174.303 429.772 159.977 431.195 142.318C432.922 120.903 416.232 102.957 395.287 102.609ZM363.529 158.659L349.233 172.94C353.927 176.817 358.44 180.967 362.772 185.329L376.796 171.305C371.374 168.276 366.8 163.914 363.529 158.659ZM422.593 304.441C414.339 257.463 392.879 215.709 362.772 185.329L356.29 191.811C351.943 187.434 347.415 183.314 342.72 179.468L349.233 172.94C320.034 148.709 284.353 134.367 245.507 134.367C206.661 134.367 170.753 148.8 141.494 173.198L148.006 179.71C143.311 183.572 138.798 187.706 134.466 192.098L127.985 185.616C98.1648 215.815 76.8714 257.19 68.5419 303.745C63.9379 329.551 77.7498 354.615 101.042 365.701C145.007 386.631 194.212 398.353 246.128 398.353C298.044 398.353 346.219 386.874 389.85 366.353C413.219 355.342 427.152 330.293 422.593 304.441ZM382.036 349.754C340.812 369.169 294.742 380.013 246.128 380.013C197.514 380.013 150.489 368.942 108.917 349.148C92.6976 341.424 83.5048 324.235 86.5943 306.97C94.3635 263.505 113.991 225.81 140.948 198.565L154.533 186.237C180.642 164.989 212.052 152.692 245.507 152.692C278.961 152.692 310.129 164.883 336.193 185.98L349.808 198.293C377.038 225.689 396.832 263.732 404.541 307.606C407.585 324.917 398.316 342.106 382.036 349.754ZM126.576 158.28C123.365 163.581 118.837 167.988 113.461 171.093L127.985 185.616C132.316 181.225 136.814 177.09 141.494 173.198L126.576 158.28ZM126.576 158.28C130.059 152.541 132.013 145.771 131.877 138.562C131.483 118.495 114.536 102.215 94.4695 102.609C73.5548 103.033 56.9411 120.949 58.6676 142.318C60.1063 160.067 74.5089 174.425 92.2584 175.818C100.012 176.424 107.297 174.606 113.461 171.093L82.6264 140.258L95.5902 127.294L126.576 158.28Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M407.857 140.258L376.796 171.305C371.374 168.276 366.8 163.914 363.529 158.659L394.894 127.295L407.857 140.258Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M362.772 185.329L356.29 191.811C351.943 187.434 347.415 183.315 342.72 179.468L349.232 172.94C353.927 176.817 358.44 180.967 362.772 185.329Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M148.006 179.71C143.311 183.572 138.798 187.706 134.467 192.098L127.985 185.616C132.316 181.225 136.814 177.09 141.494 173.198L148.006 179.71Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M126.576 158.28C123.366 163.581 118.837 167.988 113.461 171.093L82.6265 140.258L95.5903 127.295L126.576 158.28Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M407.857 140.243L376.796 171.29C371.374 168.261 366.8 163.899 363.529 158.644L394.894 127.279L407.857 140.243Z" fill="black"/>
<path d="M349.232 172.925L342.705 179.453C347.4 183.299 351.928 187.434 356.275 191.811L362.757 185.329C358.425 180.952 353.912 176.817 349.232 172.94V172.925Z" fill="black"/>
<path d="M141.478 173.183C136.799 177.075 132.301 181.225 127.969 185.601L134.451 192.083C138.783 187.691 143.311 183.572 147.99 179.695L141.478 173.183Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M140.873 335.064C124.971 328.582 115.536 311.665 121.048 295.4C138.041 245.241 187.624 208.924 246.143 208.924C304.662 208.924 354.245 245.226 371.253 295.4C376.766 311.665 367.33 328.597 351.429 335.064C318.943 348.3 283.399 355.585 246.158 355.585C208.918 355.585 173.373 348.3 140.888 335.064H140.873Z" fill="black"/>
<path d="M288.321 311.59C305.2 311.59 318.883 300.646 318.883 287.146C318.883 273.646 305.2 262.703 288.321 262.703C271.442 262.703 257.759 273.646 257.759 287.146C257.759 300.646 271.442 311.59 288.321 311.59Z" fill="white"/>
<path d="M200.845 311.605C217.724 311.605 231.407 300.661 231.407 287.161C231.407 273.661 217.724 262.718 200.845 262.718C183.966 262.718 170.283 273.661 170.283 287.161C170.283 300.661 183.966 311.605 200.845 311.605Z" fill="white"/>
<path d="M674.375 263.384L652.621 204.2L631.826 263.384H674.375ZM624.468 134.459H682.693L763.95 349.441L706.686 355.199L691.01 309.132H615.831L600.795 352H542.891L624.468 134.459ZM838.15 352H779.926V129.661L838.15 126.142V352ZM899.496 352H870.064V129.661L928.288 126.142V322.888L899.496 352ZM909.413 257.306L893.098 219.876C896.937 215.184 901.842 210.705 907.814 206.44C913.999 201.961 920.823 198.335 928.288 195.563C935.753 192.79 943.43 191.404 951.322 191.404C966.677 191.404 980.327 194.283 992.27 200.042C1004.21 205.8 1013.6 214.651 1020.42 226.594C1027.46 238.538 1030.98 253.787 1030.98 272.342C1030.98 290.683 1027.46 306.039 1020.42 318.409C1013.6 330.779 1004 340.057 991.631 346.242C979.474 352.427 965.185 355.519 948.762 355.519C942.364 355.519 935.966 354.559 929.568 352.64C923.383 350.72 917.198 347.734 911.013 343.682C905.041 339.417 899.069 333.872 893.098 327.047L909.413 292.816C915.598 298.575 921.676 303.053 927.648 306.252C933.62 309.238 939.378 310.731 944.923 310.731C949.829 310.731 954.201 309.238 958.04 306.252C961.879 303.267 964.971 298.895 967.317 293.136C969.663 287.378 970.836 280.553 970.836 272.662C970.836 264.77 969.663 258.159 967.317 252.827C965.185 247.282 961.985 243.123 957.72 240.351C953.668 237.578 948.549 236.192 942.364 236.192C937.246 236.192 932.02 238.111 926.688 241.95C921.357 245.789 915.598 250.908 909.413 257.306ZM1088.57 352.64L1089.85 349.121L1034.18 206.44L1087.93 191.084L1119.6 284.498L1149.35 196.842H1207.25L1139.11 370.235C1135.91 378.553 1130.69 386.231 1123.44 393.269C1116.19 400.307 1107.76 406.279 1098.16 411.184C1088.57 416.089 1078.65 419.715 1068.41 422.061L1048.9 376.953C1054.02 374.82 1059.35 372.474 1064.89 369.915C1070.65 367.356 1075.77 364.583 1080.25 361.597C1084.73 358.612 1087.5 355.626 1088.57 352.64Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 7.6 KiB

+12
View File
@@ -0,0 +1,12 @@
<svg width="1211" height="525" viewBox="0 0 1211 525" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="36.675" cy="36.675" r="36.675" transform="matrix(-1 0 0 1 131.308 101.468)" fill="black"/>
<path d="M88.52 132.641L156.98 201.101" stroke="black" stroke-width="18.3375"/>
<circle cx="394.145" cy="138.143" r="36.675" fill="black"/>
<path d="M400.869 132.641L332.409 201.101" stroke="black" stroke-width="18.3375"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M105.043 356.993C85.276 347.583 73.7721 326.451 77.6256 304.901C94.165 212.406 163.093 143.033 245.611 143.033C328.33 143.033 397.391 212.743 413.716 305.573C417.513 327.163 405.915 348.285 386.081 357.618C343.64 377.591 296.235 388.755 246.222 388.755C195.69 388.755 147.82 377.358 105.043 356.993Z" fill="#FFDF6F"/>
<path d="M413.716 305.573L422.746 303.985L413.716 305.573ZM386.081 357.618L382.177 349.322L386.081 357.618ZM86.6512 306.515C102.633 217.14 168.694 152.201 245.611 152.201V133.864C157.491 133.864 85.6973 207.672 68.6 303.287L86.6512 306.515ZM245.611 152.201C322.713 152.201 388.911 217.457 404.686 307.161L422.746 303.985C405.872 208.028 333.946 133.864 245.611 133.864V152.201ZM382.177 349.322C340.932 368.732 294.857 379.586 246.222 379.586V397.924C297.613 397.924 346.348 386.449 389.985 365.914L382.177 349.322ZM246.222 379.586C197.082 379.586 150.556 368.505 108.984 348.714L101.102 365.272C145.084 386.21 194.298 397.924 246.222 397.924V379.586ZM404.686 307.161C407.73 324.47 398.462 341.659 382.177 349.322L389.985 365.914C413.369 354.91 427.296 329.855 422.746 303.985L404.686 307.161ZM68.6 303.287C63.9824 329.11 77.7983 354.178 101.102 365.272L108.984 348.714C92.7538 340.988 83.5618 323.792 86.6512 306.515L68.6 303.287Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M140.311 333.998C124.4 327.518 114.962 310.589 120.475 294.317C137.477 244.138 187.075 207.825 245.611 207.825C304.148 207.825 353.745 244.138 370.748 294.317C376.261 310.589 366.823 327.518 350.912 333.998C318.414 347.233 282.864 354.525 245.611 354.525C208.358 354.525 172.808 347.233 140.311 333.998Z" fill="black"/>
<ellipse cx="287.788" cy="286.065" rx="30.5625" ry="24.45" fill="white"/>
<ellipse cx="200.295" cy="286.08" rx="30.5625" ry="24.45" fill="white"/>
<path d="M674.32 263.36L652.56 204.16L631.76 263.36H674.32ZM624.4 134.4H682.64L763.92 349.44L706.64 355.2L690.96 309.12H615.76L600.72 352H542.8L624.4 134.4ZM838.14 352H779.9V129.6L838.14 126.08V352ZM899.503 352H870.063V129.6L928.303 126.08V322.88L899.503 352ZM909.423 257.28L893.103 219.84C896.943 215.147 901.849 210.667 907.823 206.4C914.009 201.92 920.836 198.293 928.303 195.52C935.769 192.747 943.449 191.36 951.343 191.36C966.703 191.36 980.356 194.24 992.303 200C1004.25 205.76 1013.64 214.613 1020.46 226.56C1027.5 238.507 1031.02 253.76 1031.02 272.32C1031.02 290.667 1027.5 306.027 1020.46 318.4C1013.64 330.773 1004.04 340.053 991.663 346.24C979.503 352.427 965.209 355.52 948.783 355.52C942.383 355.52 935.983 354.56 929.583 352.64C923.396 350.72 917.209 347.733 911.023 343.68C905.049 339.413 899.076 333.867 893.103 327.04L909.423 292.8C915.609 298.56 921.689 303.04 927.663 306.24C933.636 309.227 939.396 310.72 944.943 310.72C949.849 310.72 954.223 309.227 958.063 306.24C961.903 303.253 964.996 298.88 967.343 293.12C969.689 287.36 970.863 280.533 970.863 272.64C970.863 264.747 969.689 258.133 967.343 252.8C965.209 247.253 962.009 243.093 957.743 240.32C953.689 237.547 948.569 236.16 942.383 236.16C937.263 236.16 932.036 238.08 926.703 241.92C921.369 245.76 915.609 250.88 909.423 257.28ZM1088.62 352.64L1089.9 349.12L1034.22 206.4L1087.98 191.04L1119.66 284.48L1149.42 196.8H1207.34L1139.19 370.24C1135.98 378.56 1130.76 386.24 1123.5 393.28C1116.25 400.32 1107.83 406.293 1098.22 411.2C1088.62 416.107 1078.7 419.733 1068.46 422.08L1048.94 376.96C1054.06 374.827 1059.4 372.48 1064.94 369.92C1070.7 367.36 1075.82 364.587 1080.3 361.6C1084.78 358.613 1087.56 355.627 1088.62 352.64Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

@@ -11,6 +11,7 @@ import { appKeysSchema as giphy_zod_ts } from "./giphy/zod";
import { appKeysSchema as googlecalendar_zod_ts } from "./googlecalendar/zod";
import { appKeysSchema as gtm_zod_ts } from "./gtm/zod";
import { appKeysSchema as hubspot_zod_ts } from "./hubspot/zod";
import { appKeysSchema as intercom_zod_ts } from "./intercom/zod";
import { appKeysSchema as jitsivideo_zod_ts } from "./jitsivideo/zod";
import { appKeysSchema as larkcalendar_zod_ts } from "./larkcalendar/zod";
import { appKeysSchema as make_zod_ts } from "./make/zod";
@@ -45,6 +46,7 @@ export const appKeysSchemas = {
googlecalendar: googlecalendar_zod_ts,
gtm: gtm_zod_ts,
hubspot: hubspot_zod_ts,
intercom: intercom_zod_ts,
jitsivideo: jitsivideo_zod_ts,
larkcalendar: larkcalendar_zod_ts,
make: make_zod_ts,
@@ -28,6 +28,7 @@ import { metadata as googlevideo__metadata_ts } from "./googlevideo/_metadata";
import gtm_config_json from "./gtm/config.json";
import { metadata as hubspot__metadata_ts } from "./hubspot/_metadata";
import { metadata as huddle01video__metadata_ts } from "./huddle01video/_metadata";
import intercom_config_json from "./intercom/config.json";
import { metadata as jitsivideo__metadata_ts } from "./jitsivideo/_metadata";
import { metadata as larkcalendar__metadata_ts } from "./larkcalendar/_metadata";
import make_config_json from "./make/config.json";
@@ -101,6 +102,7 @@ export const appStoreMetadata = {
gtm: gtm_config_json,
hubspot: hubspot__metadata_ts,
huddle01video: huddle01video__metadata_ts,
intercom: intercom_config_json,
jitsivideo: jitsivideo__metadata_ts,
larkcalendar: larkcalendar__metadata_ts,
make: make_config_json,
@@ -11,6 +11,7 @@ import { appDataSchema as giphy_zod_ts } from "./giphy/zod";
import { appDataSchema as googlecalendar_zod_ts } from "./googlecalendar/zod";
import { appDataSchema as gtm_zod_ts } from "./gtm/zod";
import { appDataSchema as hubspot_zod_ts } from "./hubspot/zod";
import { appDataSchema as intercom_zod_ts } from "./intercom/zod";
import { appDataSchema as jitsivideo_zod_ts } from "./jitsivideo/zod";
import { appDataSchema as larkcalendar_zod_ts } from "./larkcalendar/zod";
import { appDataSchema as make_zod_ts } from "./make/zod";
@@ -45,6 +46,7 @@ export const appDataSchemas = {
googlecalendar: googlecalendar_zod_ts,
gtm: gtm_zod_ts,
hubspot: hubspot_zod_ts,
intercom: intercom_zod_ts,
jitsivideo: jitsivideo_zod_ts,
larkcalendar: larkcalendar_zod_ts,
make: make_zod_ts,
@@ -28,6 +28,7 @@ export const apiHandlers = {
gtm: import("./gtm/api"),
hubspot: import("./hubspot/api"),
huddle01video: import("./huddle01video/api"),
intercom: import("./intercom/api"),
jitsivideo: import("./jitsivideo/api"),
larkcalendar: import("./larkcalendar/api"),
make: import("./make/api"),

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