Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
477ee1c526 | ||
|
|
6704e18e32 | ||
|
|
20f17903b9 | ||
|
|
8dabbbd09f | ||
|
|
32933ed6ef | ||
|
|
c765ea9b31 | ||
|
|
d3ddbf418f | ||
|
|
79d0513428 | ||
|
|
230b82d3bf | ||
|
|
c9b50ffb78 | ||
|
|
ada0ef242b | ||
|
|
b26e688950 | ||
|
|
d9041d03b7 | ||
|
|
f60b3c14e1 | ||
|
|
fbcc43931e | ||
|
|
a48e7cb39a | ||
|
|
db3f718ba6 | ||
|
|
ca5dd4626d | ||
|
|
4524d722f6 | ||
|
|
2094d59856 | ||
|
|
7b350a5b8f | ||
|
|
5a869228d3 | ||
|
|
dabd5eae73 | ||
|
|
cb78de231a | ||
|
|
b0a1ef8a49 | ||
|
|
0a43aa3351 | ||
|
|
ed4cce1c7e | ||
|
|
d595aa23c0 | ||
|
|
a6df49235f | ||
|
|
5df41e37a0 | ||
|
|
fca5778b6d | ||
|
|
0ddefaa141 | ||
|
|
c11f7aeffc | ||
|
|
9903fcaa05 | ||
|
|
0910f65b87 | ||
|
|
343f8ee303 | ||
|
|
de479bb2da | ||
|
|
5dc3065a47 | ||
|
|
36d315343c | ||
|
|
2171a320f5 | ||
|
|
7ee035c3a3 | ||
|
|
1de05ebf3d | ||
|
|
53d7e2ec3f | ||
|
|
f65c7e413f | ||
|
|
b762f60214 | ||
|
|
2498785c49 | ||
|
|
9a6683e01d | ||
|
|
2853288f49 | ||
|
|
d04226ab9a | ||
|
|
cb7ddc455a | ||
|
|
73aa1e8a22 | ||
|
|
a3b5263b76 | ||
|
|
af2c6c0844 | ||
|
|
113195224a | ||
|
|
828092c1d0 | ||
|
|
4b060fc2cd | ||
|
|
16c5b070b6 | ||
|
|
48dde246e9 | ||
|
|
556b382f75 | ||
|
|
85237c4985 | ||
|
|
404bc0e4d6 | ||
|
|
00553e897b | ||
|
|
ed2ce005c9 | ||
|
|
058844b6b8 | ||
|
|
77fa3702db | ||
|
|
9eb2ae470c | ||
|
|
3de7b73f5f | ||
|
|
8e7785ca64 | ||
|
|
608b891536 | ||
|
|
4f26ca1a7b | ||
|
|
c55b36f235 | ||
|
|
bdd3b132d4 | ||
|
|
9a4c20cca4 | ||
|
|
2144fcb23e | ||
|
|
f2c39fc786 | ||
|
|
e83e0a770c | ||
|
|
c818ef3188 | ||
|
|
176564f4ed | ||
|
|
27d59b6413 | ||
|
|
57a65401e7 | ||
|
|
28acbe549a | ||
|
|
3534e3c224 | ||
|
|
e48a6c3dcf | ||
|
|
7d09ccb0d7 | ||
|
|
9029c5b4c4 | ||
|
|
a4c1df3658 | ||
|
|
0b96ef5476 | ||
|
|
ca9e0f5b72 | ||
|
|
a9418b06d6 | ||
|
|
833a326c5b | ||
|
|
b7e2a62e26 | ||
|
|
f687056d60 | ||
|
|
0a39f53a4b | ||
|
|
ea0a64624c | ||
|
|
6a8726f5f8 | ||
|
|
eb97e1660b | ||
|
|
371a0f7245 | ||
|
|
270d4f6e82 | ||
|
|
48e7b616b8 | ||
|
|
8b80ea99c2 | ||
|
|
150c6e0f50 | ||
|
|
4037e87fbd | ||
|
|
a804a29516 | ||
|
|
87b514b91b | ||
|
|
693ca04626 | ||
|
|
67f83c7e7d | ||
|
|
ecfecd832d | ||
|
|
e55302cbc5 | ||
|
|
9514a68039 | ||
|
|
4e3d159401 | ||
|
|
a519941b81 | ||
|
|
f62c58532d | ||
|
|
0981c4cb23 | ||
|
|
9d06f6dd0e | ||
|
|
00df493623 | ||
|
|
2cf2b11a7b | ||
|
|
5c3fd0993e | ||
|
|
811e655720 | ||
|
|
defaafc9be | ||
|
|
e0c6feff1f | ||
|
|
d17dcbd319 | ||
|
|
90d52663ac | ||
|
|
ada74467bc | ||
|
|
7db758c2d5 | ||
|
|
2867d53442 | ||
|
|
31067fcd45 | ||
|
|
8c2ce972cc | ||
|
|
d3ab11e38e | ||
|
|
3d596fcb1a | ||
|
|
820b45a912 | ||
|
|
b945849736 | ||
|
|
6b2c59b733 | ||
|
|
b7f85d785a | ||
|
|
08e4e816c6 | ||
|
|
20430022e1 | ||
|
|
e4c71e5d2c | ||
|
|
28eda407be | ||
|
|
9dab171520 | ||
|
|
d5f359291d | ||
|
|
3c1d0a9bf1 | ||
|
|
3057edbd87 | ||
|
|
a2f859b55a | ||
|
|
b2f4eaee63 | ||
|
|
b66c3f3452 | ||
|
|
5ab0b85e52 | ||
|
|
4135c1038e | ||
|
|
fc5190a33b | ||
|
|
f29328e6a2 | ||
|
|
621f7639ff | ||
|
|
63f2f6edba | ||
|
|
199f41fb1a | ||
|
|
3ea9faa414 | ||
|
|
518ec4f605 | ||
|
|
4db0da5da4 | ||
|
|
115ded8429 | ||
|
|
71485d1b57 | ||
|
|
87071c4f19 | ||
|
|
412f15814e | ||
|
|
a57085cf64 | ||
|
|
65d1a34e39 | ||
|
|
e4abc85382 | ||
|
|
eb1eb722f2 | ||
|
|
033bc45dd1 | ||
|
|
affe85d02c | ||
|
|
d6f9a833bd | ||
|
|
e4281a852a | ||
|
|
e4b2464548 | ||
|
|
8c99a2a7af | ||
|
|
2236dfe411 | ||
|
|
a67369460a | ||
|
|
b2526300a7 | ||
|
|
d69395434f | ||
|
|
2720c9cc67 | ||
|
|
f1465a15f6 | ||
|
|
1d24f2654d | ||
|
|
f7eb86577c | ||
|
|
cc515f00b7 | ||
|
|
e695f75113 | ||
|
|
0d1b6ea5b2 | ||
|
|
26637cda08 | ||
|
|
529054aa76 | ||
|
|
8deee738c2 | ||
|
|
6848362683 | ||
|
|
09fc7e1a4c | ||
|
|
2f73be0ee6 | ||
|
|
3cdf59f2cc | ||
|
|
1f9880204e | ||
|
|
d0be48ebde |
No files matched your search
@@ -25,6 +25,7 @@ CALCOM_LICENSE_KEY=
|
|||||||
DATABASE_URL="postgresql://postgres:@localhost:5450/calendso"
|
DATABASE_URL="postgresql://postgres:@localhost:5450/calendso"
|
||||||
UPSTASH_REDIS_REST_URL=
|
UPSTASH_REDIS_REST_URL=
|
||||||
UPSTASH_REDIS_REST_TOKEN=
|
UPSTASH_REDIS_REST_TOKEN=
|
||||||
|
INSIGHTS_DATABASE_URL=
|
||||||
|
|
||||||
# Uncomment to enable a dedicated connection pool for Prisma using Prisma Data Proxy
|
# Uncomment to enable a dedicated connection pool for Prisma using Prisma Data Proxy
|
||||||
# Cold boots will be faster and you'll be able to scale your DB independently of your app.
|
# Cold boots will be faster and you'll be able to scale your DB independently of your app.
|
||||||
@@ -106,6 +107,19 @@ NEXT_PUBLIC_HELPSCOUT_KEY=
|
|||||||
NEXT_PUBLIC_FRESHCHAT_TOKEN=
|
NEXT_PUBLIC_FRESHCHAT_TOKEN=
|
||||||
NEXT_PUBLIC_FRESHCHAT_HOST=
|
NEXT_PUBLIC_FRESHCHAT_HOST=
|
||||||
|
|
||||||
|
# Google OAuth credentials
|
||||||
|
# To enable Login with Google you need to:
|
||||||
|
# 1. Set `GOOGLE_API_CREDENTIALS` above
|
||||||
|
# 2. Set `GOOGLE_LOGIN_ENABLED` to `true`
|
||||||
|
# When self-hosting please ensure you configure the Google integration as an Internal app so no one else can login to your instance
|
||||||
|
# @see https://support.google.com/cloud/answer/6158849#public-and-internal&zippy=%2Cpublic-and-internal-applications
|
||||||
|
GOOGLE_LOGIN_ENABLED=false
|
||||||
|
|
||||||
|
# - GOOGLE CALENDAR/MEET/LOGIN
|
||||||
|
# Needed to enable Google Calendar integration and Login with Google
|
||||||
|
# @see https://github.com/calcom/cal.com#obtaining-the-google-api-credentials
|
||||||
|
GOOGLE_API_CREDENTIALS=
|
||||||
|
|
||||||
# Inbox to send user feedback
|
# Inbox to send user feedback
|
||||||
SEND_FEEDBACK_EMAIL=
|
SEND_FEEDBACK_EMAIL=
|
||||||
|
|
||||||
@@ -141,6 +155,7 @@ NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE=
|
|||||||
STRIPE_TEAM_MONTHLY_PRICE_ID=
|
STRIPE_TEAM_MONTHLY_PRICE_ID=
|
||||||
STRIPE_ORG_MONTHLY_PRICE_ID=
|
STRIPE_ORG_MONTHLY_PRICE_ID=
|
||||||
STRIPE_WEBHOOK_SECRET=
|
STRIPE_WEBHOOK_SECRET=
|
||||||
|
STRIPE_WEBHOOK_SECRET_APPS=
|
||||||
STRIPE_PRIVATE_KEY=
|
STRIPE_PRIVATE_KEY=
|
||||||
STRIPE_CLIENT_ID=
|
STRIPE_CLIENT_ID=
|
||||||
PAYMENT_FEE_FIXED=
|
PAYMENT_FEE_FIXED=
|
||||||
@@ -207,6 +222,8 @@ CSP_POLICY=
|
|||||||
EDGE_CONFIG=
|
EDGE_CONFIG=
|
||||||
|
|
||||||
NEXT_PUBLIC_MINUTES_TO_BOOK=5 # Minutes
|
NEXT_PUBLIC_MINUTES_TO_BOOK=5 # Minutes
|
||||||
|
# Control time intervals on a user's Schedule availability
|
||||||
|
NEXT_PUBLIC_AVAILABILITY_SCHEDULE_INTERVAL=
|
||||||
|
|
||||||
# - ORGANIZATIONS *******************************************************************************************
|
# - ORGANIZATIONS *******************************************************************************************
|
||||||
# Enable Organizations non-prod domain setup, works in combination with organizations feature flag
|
# Enable Organizations non-prod domain setup, works in combination with organizations feature flag
|
||||||
@@ -234,6 +251,12 @@ AUTH_BEARER_TOKEN_VERCEL=
|
|||||||
E2E_TEST_APPLE_CALENDAR_EMAIL=""
|
E2E_TEST_APPLE_CALENDAR_EMAIL=""
|
||||||
E2E_TEST_APPLE_CALENDAR_PASSWORD=""
|
E2E_TEST_APPLE_CALENDAR_PASSWORD=""
|
||||||
|
|
||||||
|
# - CALCOM QA ACCOUNT
|
||||||
|
# Used for E2E tests on Cal.com that require 3rd party integrations
|
||||||
|
E2E_TEST_CALCOM_QA_EMAIL="qa@example.com"
|
||||||
|
# Replace with your own password
|
||||||
|
E2E_TEST_CALCOM_QA_PASSWORD="password"
|
||||||
|
|
||||||
# - APP CREDENTIAL SYNC ***********************************************************************************
|
# - APP CREDENTIAL SYNC ***********************************************************************************
|
||||||
# Used for self-hosters that are implementing Cal.com into their applications that already have certain integrations
|
# Used for self-hosters that are implementing Cal.com into their applications that already have certain integrations
|
||||||
# Under settings/admin/apps ensure that all app secrets are set the same as the parent application
|
# Under settings/admin/apps ensure that all app secrets are set the same as the parent application
|
||||||
@@ -261,3 +284,9 @@ E2E_TEST_OIDC_USER_EMAIL=
|
|||||||
E2E_TEST_OIDC_USER_PASSWORD=
|
E2E_TEST_OIDC_USER_PASSWORD=
|
||||||
|
|
||||||
# ***********************************************************************************************************
|
# ***********************************************************************************************************
|
||||||
|
|
||||||
|
# provide a value between 0 and 100 to ensure the percentage of traffic
|
||||||
|
# redirected from the legacy to the future pages
|
||||||
|
AB_TEST_BUCKET_PROBABILITY=50
|
||||||
|
# whether we redirect to the future/event-types from event-types or not
|
||||||
|
APP_ROUTER_EVENT_TYPES_ENABLED=1
|
||||||
@@ -13,4 +13,4 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
repo-token: ${{ secrets.GH_ACCESS_TOKEN }}
|
repo-token: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||||
organization-name: calcom
|
organization-name: calcom
|
||||||
ignore-labels: "app-store, ai, authentication, automated-testing, billing, bookings, caldav, calendar-apps, ci, console, crm-apps, docs, documentation, emails, embeds, event-types, i18n, impersonation, manual-testing, ui, performance, ops-stack, organizations, public-api, routing-forms, seats, teams, webhooks, workflows, zapier"
|
ignore-labels: "app-store, ai, authentication, automated-testing, platform, billing, bookings, caldav, calendar-apps, ci, console, crm-apps, docs, documentation, emails, embeds, event-types, i18n, impersonation, manual-testing, ui, performance, ops-stack, organizations, public-api, routing-forms, seats, teams, webhooks, workflows, zapier"
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
name: "Welcome new contributors"
|
|
||||||
|
|
||||||
on:
|
|
||||||
issues:
|
|
||||||
types: opened
|
|
||||||
pull_request:
|
|
||||||
types: opened
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
pull-requests: write
|
|
||||||
issues: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
welcome-message:
|
|
||||||
name: Welcoming New Users
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 10
|
|
||||||
if: github.event.action == 'opened'
|
|
||||||
steps:
|
|
||||||
- uses: actions/first-interaction@v1
|
|
||||||
with:
|
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
pr-message: |-
|
|
||||||
Thank you for making your first Pull Request and taking the time to improve Cal.com ! ❤️🎉
|
|
||||||
Feel free to join our [discord](https://go.cal.com/discord) and post your PR link to [collect XP and win prizes!](https://cal.com/blog/community-incentives)
|
|
||||||
issue-message: |
|
|
||||||
Thank you for opening your first issue, one of our team members will review it as soon as it possible. ❤️🎉
|
|
||||||
@@ -83,6 +83,10 @@
|
|||||||
"NEXT_PUBLIC_TEAM_IMPERSONATION": {
|
"NEXT_PUBLIC_TEAM_IMPERSONATION": {
|
||||||
"description": "Set the following value to true if you wish to enable Team Impersonation",
|
"description": "Set the following value to true if you wish to enable Team Impersonation",
|
||||||
"value": "false"
|
"value": "false"
|
||||||
|
},
|
||||||
|
"NEXT_PUBLIC_AVAILABILITY_SCHEDULE_INTERVAL": {
|
||||||
|
"description": "Control time intervals on a user's Schedule availability",
|
||||||
|
"value": "15"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const schemaAvailabilityCreateParams = z
|
|||||||
startTime: z.date().or(z.string()),
|
startTime: z.date().or(z.string()),
|
||||||
endTime: z.date().or(z.string()),
|
endTime: z.date().or(z.string()),
|
||||||
days: z.array(z.number()).optional(),
|
days: z.array(z.number()).optional(),
|
||||||
|
date: z.date().or(z.string()).optional(),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
@@ -34,6 +35,7 @@ const schemaAvailabilityEditParams = z
|
|||||||
startTime: z.date().or(z.string()).optional(),
|
startTime: z.date().or(z.string()).optional(),
|
||||||
endTime: z.date().or(z.string()).optional(),
|
endTime: z.date().or(z.string()).optional(),
|
||||||
days: z.array(z.number()).optional(),
|
days: z.array(z.number()).optional(),
|
||||||
|
date: z.date().or(z.string()).optional(),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,16 @@ export const schemaSchedulePublic = z
|
|||||||
.merge(
|
.merge(
|
||||||
z.object({
|
z.object({
|
||||||
availability: z
|
availability: z
|
||||||
.array(Availability.pick({ id: true, eventTypeId: true, days: true, startTime: true, endTime: true }))
|
.array(
|
||||||
|
Availability.pick({
|
||||||
|
id: true,
|
||||||
|
eventTypeId: true,
|
||||||
|
date: true,
|
||||||
|
days: true,
|
||||||
|
startTime: true,
|
||||||
|
endTime: true,
|
||||||
|
})
|
||||||
|
)
|
||||||
.transform((v) =>
|
.transform((v) =>
|
||||||
v.map((item) => ({
|
v.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
|
|||||||
@@ -205,8 +205,8 @@ import { defaultResponder } from "@calcom/lib/server";
|
|||||||
async function handler(req: NextApiRequest) {
|
async function handler(req: NextApiRequest) {
|
||||||
const { userId, isAdmin } = req;
|
const { userId, isAdmin } = req;
|
||||||
if (isAdmin) req.userId = req.body.userId || userId;
|
if (isAdmin) req.userId = req.body.userId || userId;
|
||||||
const booking = await handleNewBooking(req);
|
|
||||||
return booking;
|
return await handleNewBooking(req);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defaultResponder(handler);
|
export default defaultResponder(handler);
|
||||||
@@ -2,6 +2,7 @@ import type { NextApiRequest } from "next";
|
|||||||
|
|
||||||
import { HttpError } from "@calcom/lib/http-error";
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
import { defaultResponder } from "@calcom/lib/server";
|
import { defaultResponder } from "@calcom/lib/server";
|
||||||
|
import { MembershipRole } from "@calcom/prisma/enums";
|
||||||
|
|
||||||
import { schemaEventTypeReadPublic } from "~/lib/validations/event-type";
|
import { schemaEventTypeReadPublic } from "~/lib/validations/event-type";
|
||||||
import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt";
|
import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt";
|
||||||
@@ -89,7 +90,9 @@ async function checkPermissions<T extends BaseEventTypeCheckPermissions>(
|
|||||||
if (req.isAdmin) return true;
|
if (req.isAdmin) return true;
|
||||||
if (eventType?.teamId) {
|
if (eventType?.teamId) {
|
||||||
req.query.teamId = String(eventType.teamId);
|
req.query.teamId = String(eventType.teamId);
|
||||||
await canAccessTeamEventOrThrow(req, "MEMBER");
|
await canAccessTeamEventOrThrow(req, {
|
||||||
|
in: [MembershipRole.OWNER, MembershipRole.ADMIN, MembershipRole.MEMBER],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (eventType?.userId === req.userId) return true; // is owner.
|
if (eventType?.userId === req.userId) return true; // is owner.
|
||||||
throw new HttpError({ statusCode: 403, message: "Forbidden" });
|
throw new HttpError({ statusCode: 403, message: "Forbidden" });
|
||||||
|
|||||||
@@ -299,7 +299,7 @@ async function postHandler(req: NextApiRequest) {
|
|||||||
data.hosts = { createMany: { data: hosts } };
|
data.hosts = { createMany: { data: hosts } };
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventType = await prisma.eventType.create({ data });
|
const eventType = await prisma.eventType.create({ data, include: { hosts: true } });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
event_type: schemaEventTypeReadPublic.parse(eventType),
|
event_type: schemaEventTypeReadPublic.parse(eventType),
|
||||||
@@ -316,8 +316,13 @@ async function checkPermissions(req: NextApiRequest) {
|
|||||||
statusCode: 401,
|
statusCode: 401,
|
||||||
message: "ADMIN required for `userId`",
|
message: "ADMIN required for `userId`",
|
||||||
});
|
});
|
||||||
|
if (!isAdmin && body.teamId)
|
||||||
|
throw new HttpError({
|
||||||
|
statusCode: 401,
|
||||||
|
message: "ADMIN required for `teamId`",
|
||||||
|
});
|
||||||
/* Admin users are required to pass in a userId or teamId */
|
/* Admin users are required to pass in a userId or teamId */
|
||||||
if (isAdmin && (!body.userId || !body.teamId))
|
if (isAdmin && !body.userId && !body.teamId)
|
||||||
throw new HttpError({ statusCode: 400, message: "`userId` or `teamId` required" });
|
throw new HttpError({ statusCode: 400, message: "`userId` or `teamId` required" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
export default async function CalcomApi(_: NextApiRequest, res: NextApiResponse) {
|
export default async function CalcomApi(_: NextApiRequest, res: NextApiResponse) {
|
||||||
res.status(201).json({ message: "Welcome to Cal.com API - docs are at https://developer.cal.com/api" });
|
res.status(200).json({ message: "Welcome to Cal.com API - docs are at https://developer.cal.com/api" });
|
||||||
}
|
}
|
||||||
@@ -22,7 +22,11 @@ export async function checkPermissions(
|
|||||||
role: Prisma.MembershipWhereInput["role"] = MembershipRole.OWNER
|
role: Prisma.MembershipWhereInput["role"] = MembershipRole.OWNER
|
||||||
) {
|
) {
|
||||||
const { userId, prisma, isAdmin } = req;
|
const { userId, prisma, isAdmin } = req;
|
||||||
const { teamId } = schemaQueryTeamId.parse(req.query);
|
const { teamId } = schemaQueryTeamId.parse({
|
||||||
|
teamId: req.query.teamId,
|
||||||
|
version: req.query.version,
|
||||||
|
apiKey: req.query.apiKey,
|
||||||
|
});
|
||||||
const args: Prisma.TeamFindFirstArgs = { where: { id: teamId } };
|
const args: Prisma.TeamFindFirstArgs = { where: { id: teamId } };
|
||||||
/** If not ADMIN then we check if the actual user belongs to team and matches the required role */
|
/** If not ADMIN then we check if the actual user belongs to team and matches the required role */
|
||||||
if (!isAdmin) args.where = { ...args.where, members: { some: { userId, role } } };
|
if (!isAdmin) args.where = { ...args.where, members: { some: { userId, role } } };
|
||||||
|
|||||||
@@ -56,6 +56,14 @@ async function getHandler(req: NextApiRequest) {
|
|||||||
members: { some: { userId } },
|
members: { some: { userId } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
include: {
|
||||||
|
customInputs: true,
|
||||||
|
team: { select: { slug: true } },
|
||||||
|
users: true,
|
||||||
|
hosts: { select: { userId: true, isFixed: true } },
|
||||||
|
owner: { select: { username: true, id: true } },
|
||||||
|
children: { select: { id: true, userId: true } },
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const data = await prisma.eventType.findMany(args);
|
const data = await prisma.eventType.findMany(args);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { NextApiRequest } from "next";
|
import type { NextApiRequest } from "next";
|
||||||
|
|
||||||
|
import { deleteUser } from "@calcom/features/users/lib/userDeletionService";
|
||||||
import { HttpError } from "@calcom/lib/http-error";
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
import { defaultResponder } from "@calcom/lib/server";
|
import { defaultResponder } from "@calcom/lib/server";
|
||||||
|
|
||||||
@@ -41,10 +42,18 @@ export async function deleteHandler(req: NextApiRequest) {
|
|||||||
// Here we only check for ownership of the user if the user is not admin, otherwise we let ADMIN's edit any user
|
// Here we only check for ownership of the user if the user is not admin, otherwise we let ADMIN's edit any user
|
||||||
if (!isAdmin && query.userId !== req.userId) throw new HttpError({ statusCode: 403, message: "Forbidden" });
|
if (!isAdmin && query.userId !== req.userId) throw new HttpError({ statusCode: 403, message: "Forbidden" });
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({ where: { id: query.userId } });
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: query.userId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
metadata: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
if (!user) throw new HttpError({ statusCode: 404, message: "User not found" });
|
if (!user) throw new HttpError({ statusCode: 404, message: "User not found" });
|
||||||
|
|
||||||
await prisma.user.delete({ where: { id: user.id } });
|
await deleteUser(user);
|
||||||
|
|
||||||
return { message: `User with id: ${user.id} deleted successfully` };
|
return { message: `User with id: ${user.id} deleted successfully` };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { describe, expect, test, vi } from "vitest";
|
|||||||
|
|
||||||
import dayjs from "@calcom/dayjs";
|
import dayjs from "@calcom/dayjs";
|
||||||
import sendPayload from "@calcom/features/webhooks/lib/sendPayload";
|
import sendPayload from "@calcom/features/webhooks/lib/sendPayload";
|
||||||
|
import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||||
import { buildBooking, buildEventType, buildWebhook } from "@calcom/lib/test/builder";
|
import { buildBooking, buildEventType, buildWebhook } from "@calcom/lib/test/builder";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
|
|
||||||
@@ -148,7 +149,7 @@ describe.skipIf(true)("POST /api/bookings", () => {
|
|||||||
expect(res._getStatusCode()).toBe(500);
|
expect(res._getStatusCode()).toBe(500);
|
||||||
expect(JSON.parse(res._getData())).toEqual(
|
expect(JSON.parse(res._getData())).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
message: "No available users found.",
|
message: ErrorCode.NoAvailableUsersFound,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"functions": {
|
||||||
|
"pages/api/slots/*.ts": {
|
||||||
|
"memory": 512
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
"@calcom/config": "*",
|
"@calcom/config": "*",
|
||||||
"@calcom/dayjs": "*",
|
"@calcom/dayjs": "*",
|
||||||
"@calcom/ui": "*",
|
"@calcom/ui": "*",
|
||||||
"@radix-ui/react-avatar": "^1.0.0",
|
"@radix-ui/react-avatar": "^1.0.4",
|
||||||
"@radix-ui/react-collapsible": "^1.0.0",
|
"@radix-ui/react-collapsible": "^1.0.0",
|
||||||
"@radix-ui/react-dialog": "^1.0.4",
|
"@radix-ui/react-dialog": "^1.0.4",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.0.5",
|
"@radix-ui/react-dropdown-menu": "^2.0.5",
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
"storybook-addon-designs": "^6.3.1",
|
"storybook-addon-designs": "^6.3.1",
|
||||||
"storybook-addon-next": "^1.6.9",
|
"storybook-addon-next": "^1.6.9",
|
||||||
"storybook-react-i18next": "^1.1.2",
|
"storybook-react-i18next": "^1.1.2",
|
||||||
"tailwindcss": "^3.3.1",
|
"tailwindcss": "^3.3.3",
|
||||||
"typescript": "^4.9.4",
|
"typescript": "^4.9.4",
|
||||||
"vite": "^4.1.2"
|
"vite": "^4.1.2"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { getBucket } from "abTest/utils";
|
||||||
|
import type { NextMiddleware, NextRequest } from "next/server";
|
||||||
|
import { NextResponse, URLPattern } from "next/server";
|
||||||
|
import z from "zod";
|
||||||
|
|
||||||
|
const ROUTES: [URLPattern, boolean][] = [
|
||||||
|
["/event-types", process.env.APP_ROUTER_EVENT_TYPES_ENABLED === "1"] as const,
|
||||||
|
].map(([pathname, enabled]) => [
|
||||||
|
new URLPattern({
|
||||||
|
pathname,
|
||||||
|
}),
|
||||||
|
enabled,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const FUTURE_ROUTES_OVERRIDE_COOKIE_NAME = "x-calcom-future-routes-override";
|
||||||
|
const FUTURE_ROUTES_ENABLED_COOKIE_NAME = "x-calcom-future-routes-enabled";
|
||||||
|
|
||||||
|
const bucketSchema = z.union([z.literal("legacy"), z.literal("future")]);
|
||||||
|
|
||||||
|
export const abTestMiddlewareFactory =
|
||||||
|
(next: (req: NextRequest) => Promise<NextResponse<unknown>>): NextMiddleware =>
|
||||||
|
async (req: NextRequest) => {
|
||||||
|
const response = await next(req);
|
||||||
|
|
||||||
|
const { pathname } = req.nextUrl;
|
||||||
|
|
||||||
|
const override = req.cookies.has(FUTURE_ROUTES_OVERRIDE_COOKIE_NAME);
|
||||||
|
|
||||||
|
const route = ROUTES.find(([regExp]) => regExp.test(req.url)) ?? null;
|
||||||
|
|
||||||
|
const enabled = route !== null ? route[1] || override : false;
|
||||||
|
|
||||||
|
if (pathname.includes("future") || !enabled) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeParsedBucket = override
|
||||||
|
? { success: true as const, data: "future" as const }
|
||||||
|
: bucketSchema.safeParse(req.cookies.get(FUTURE_ROUTES_ENABLED_COOKIE_NAME)?.value);
|
||||||
|
|
||||||
|
if (!safeParsedBucket.success) {
|
||||||
|
// cookie does not exist or it has incorrect value
|
||||||
|
const bucket = getBucket();
|
||||||
|
|
||||||
|
response.cookies.set(FUTURE_ROUTES_ENABLED_COOKIE_NAME, bucket, {
|
||||||
|
expires: Date.now() + 1000 * 60 * 30,
|
||||||
|
httpOnly: true,
|
||||||
|
}); // 30 min in ms
|
||||||
|
|
||||||
|
if (bucket === "legacy") {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = req.nextUrl.clone();
|
||||||
|
url.pathname = `future${pathname}/`;
|
||||||
|
|
||||||
|
return NextResponse.rewrite(url, response);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (safeParsedBucket.data === "legacy") {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = req.nextUrl.clone();
|
||||||
|
url.pathname = `future${pathname}/`;
|
||||||
|
|
||||||
|
return NextResponse.rewrite(url, response);
|
||||||
|
};
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { AB_TEST_BUCKET_PROBABILITY } from "@calcom/lib/constants";
|
||||||
|
|
||||||
|
const cryptoRandom = () => {
|
||||||
|
return crypto.getRandomValues(new Uint8Array(1))[0] / 0xff;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getBucket = () => {
|
||||||
|
return cryptoRandom() * 100 < AB_TEST_BUCKET_PROBABILITY ? "future" : "legacy";
|
||||||
|
};
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createHydrateClient } from "app/_trpc/createHydrateClient";
|
||||||
|
import superjson from "superjson";
|
||||||
|
|
||||||
|
export const HydrateClient = createHydrateClient({
|
||||||
|
transformer: superjson,
|
||||||
|
});
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import type { AppRouter } from "@calcom/trpc/server/routers/_app";
|
||||||
|
|
||||||
|
import { createTRPCReact } from "@trpc/react-query";
|
||||||
|
|
||||||
|
export const trpc = createTRPCReact<AppRouter>({});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { type DehydratedState, Hydrate } from "@tanstack/react-query";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
import type { DataTransformer } from "@trpc/server";
|
||||||
|
|
||||||
|
export function createHydrateClient(opts: { transformer?: DataTransformer }) {
|
||||||
|
return function HydrateClient(props: { children: React.ReactNode; state: DehydratedState }) {
|
||||||
|
const { state, children } = props;
|
||||||
|
|
||||||
|
const transformedState: DehydratedState = useMemo(() => {
|
||||||
|
if (opts.transformer) {
|
||||||
|
return opts.transformer.deserialize(state);
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}, [state]);
|
||||||
|
|
||||||
|
return <Hydrate state={transformedState}>{children}</Hydrate>;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { type DehydratedState, QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { HydrateClient } from "app/_trpc/HydrateClient";
|
||||||
|
import { trpc } from "app/_trpc/client";
|
||||||
|
import { useState } from "react";
|
||||||
|
import superjson from "superjson";
|
||||||
|
|
||||||
|
import { httpBatchLink } from "@calcom/trpc/client/links/httpBatchLink";
|
||||||
|
import { httpLink } from "@calcom/trpc/client/links/httpLink";
|
||||||
|
import { loggerLink } from "@calcom/trpc/client/links/loggerLink";
|
||||||
|
import { splitLink } from "@calcom/trpc/client/links/splitLink";
|
||||||
|
|
||||||
|
const ENDPOINTS = [
|
||||||
|
"admin",
|
||||||
|
"apiKeys",
|
||||||
|
"appRoutingForms",
|
||||||
|
"apps",
|
||||||
|
"auth",
|
||||||
|
"availability",
|
||||||
|
"appBasecamp3",
|
||||||
|
"bookings",
|
||||||
|
"deploymentSetup",
|
||||||
|
"eventTypes",
|
||||||
|
"features",
|
||||||
|
"insights",
|
||||||
|
"payments",
|
||||||
|
"public",
|
||||||
|
"saml",
|
||||||
|
"slots",
|
||||||
|
"teams",
|
||||||
|
"organizations",
|
||||||
|
"users",
|
||||||
|
"viewer",
|
||||||
|
"webhook",
|
||||||
|
"workflows",
|
||||||
|
"appsRouter",
|
||||||
|
"googleWorkspace",
|
||||||
|
] as const;
|
||||||
|
export type Endpoint = (typeof ENDPOINTS)[number];
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const resolveEndpoint = (links: any) => {
|
||||||
|
// TODO: Update our trpc routes so they are more clear.
|
||||||
|
// This function parses paths like the following and maps them
|
||||||
|
// to the correct API endpoints.
|
||||||
|
// - viewer.me - 2 segment paths like this are for logged in requests
|
||||||
|
// - viewer.public.i18n - 3 segments paths can be public or authed
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
return (ctx: any) => {
|
||||||
|
const parts = ctx.op.path.split(".");
|
||||||
|
let endpoint;
|
||||||
|
let path = "";
|
||||||
|
if (parts.length == 2) {
|
||||||
|
endpoint = parts[0] as keyof typeof links;
|
||||||
|
path = parts[1];
|
||||||
|
} else {
|
||||||
|
endpoint = parts[1] as keyof typeof links;
|
||||||
|
path = parts.splice(2, parts.length - 2).join(".");
|
||||||
|
}
|
||||||
|
return links[endpoint]({ ...ctx, op: { ...ctx.op, path } });
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TrpcProvider: React.FC<{ children: React.ReactNode; dehydratedState?: DehydratedState }> = ({
|
||||||
|
children,
|
||||||
|
dehydratedState,
|
||||||
|
}) => {
|
||||||
|
const [queryClient] = useState(
|
||||||
|
() =>
|
||||||
|
new QueryClient({
|
||||||
|
defaultOptions: { queries: { staleTime: 5000 } },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const url =
|
||||||
|
typeof window !== "undefined"
|
||||||
|
? "/api/trpc"
|
||||||
|
: process.env.VERCEL_URL
|
||||||
|
? `https://${process.env.VERCEL_URL}/api/trpc`
|
||||||
|
: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/api/trpc`;
|
||||||
|
|
||||||
|
const [trpcClient] = useState(() =>
|
||||||
|
trpc.createClient({
|
||||||
|
links: [
|
||||||
|
// adds pretty logs to your console in development and logs errors in production
|
||||||
|
loggerLink({
|
||||||
|
enabled: (opts) =>
|
||||||
|
!!process.env.NEXT_PUBLIC_DEBUG || (opts.direction === "down" && opts.result instanceof Error),
|
||||||
|
}),
|
||||||
|
splitLink({
|
||||||
|
// check for context property `skipBatch`
|
||||||
|
condition: (op) => !!op.context.skipBatch,
|
||||||
|
// when condition is true, use normal request
|
||||||
|
true: (runtime) => {
|
||||||
|
const links = Object.fromEntries(
|
||||||
|
ENDPOINTS.map((endpoint) => [
|
||||||
|
endpoint,
|
||||||
|
httpLink({
|
||||||
|
url: `${url}/${endpoint}`,
|
||||||
|
})(runtime),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
return resolveEndpoint(links);
|
||||||
|
},
|
||||||
|
// when condition is false, use batch request
|
||||||
|
false: (runtime) => {
|
||||||
|
const links = Object.fromEntries(
|
||||||
|
ENDPOINTS.map((endpoint) => [
|
||||||
|
endpoint,
|
||||||
|
httpBatchLink({
|
||||||
|
url: `${url}/${endpoint}`,
|
||||||
|
})(runtime),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
return resolveEndpoint(links);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
transformer: superjson,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<trpc.Provider client={trpcClient} queryClient={queryClient}>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
{dehydratedState ? <HydrateClient state={dehydratedState}>{children}</HydrateClient> : children}
|
||||||
|
</QueryClientProvider>
|
||||||
|
</trpc.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { type TFunction } from "i18next";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
|
||||||
|
import { constructGenericImage } from "@calcom/lib/OgImages";
|
||||||
|
import { IS_CALCOM, WEBAPP_URL, APP_NAME, SEO_IMG_OGIMG } from "@calcom/lib/constants";
|
||||||
|
import { getFixedT } from "@calcom/lib/server/getFixedT";
|
||||||
|
|
||||||
|
import { preparePageMetadata } from "@lib/metadata";
|
||||||
|
|
||||||
|
export const _generateMetadata = async (
|
||||||
|
getTitle: (t: TFunction<string, undefined>) => string,
|
||||||
|
getDescription: (t: TFunction<string, undefined>) => string
|
||||||
|
) => {
|
||||||
|
const h = headers();
|
||||||
|
const canonical = h.get("x-pathname") ?? "";
|
||||||
|
const locale = h.get("x-locale") ?? "en";
|
||||||
|
|
||||||
|
const t = await getFixedT(locale, "common");
|
||||||
|
|
||||||
|
const title = getTitle(t);
|
||||||
|
const description = getDescription(t);
|
||||||
|
|
||||||
|
const metadataBase = new URL(IS_CALCOM ? "https://cal.com" : WEBAPP_URL);
|
||||||
|
|
||||||
|
const image =
|
||||||
|
SEO_IMG_OGIMG +
|
||||||
|
constructGenericImage({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
});
|
||||||
|
|
||||||
|
return preparePageMetadata({
|
||||||
|
title,
|
||||||
|
canonical,
|
||||||
|
image,
|
||||||
|
description,
|
||||||
|
siteName: APP_NAME,
|
||||||
|
metadataBase,
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Typescript class based component for custom-error
|
||||||
|
* @link https://nextjs.org/docs/advanced-features/custom-error-page
|
||||||
|
*/
|
||||||
|
import type { NextPage } from "next";
|
||||||
|
import type { ErrorProps } from "next/error";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
|
import logger from "@calcom/lib/logger";
|
||||||
|
import { redactError } from "@calcom/lib/redactError";
|
||||||
|
|
||||||
|
import { ErrorPage } from "@components/error/error-page";
|
||||||
|
|
||||||
|
type NextError = Error & { digest?: string };
|
||||||
|
|
||||||
|
// Ref: https://nextjs.org/docs/app/api-reference/file-conventions/error#props
|
||||||
|
export type DefaultErrorProps = {
|
||||||
|
error: NextError;
|
||||||
|
reset: () => void; // A function to reset the error boundary
|
||||||
|
};
|
||||||
|
|
||||||
|
type AugmentedError = NextError | HttpError | null;
|
||||||
|
|
||||||
|
type CustomErrorProps = {
|
||||||
|
err?: AugmentedError;
|
||||||
|
statusCode?: number;
|
||||||
|
message?: string;
|
||||||
|
} & Omit<ErrorProps, "err" | "statusCode">;
|
||||||
|
|
||||||
|
const log = logger.getSubLogger({ prefix: ["[error]"] });
|
||||||
|
|
||||||
|
const CustomError: NextPage<DefaultErrorProps> = (props) => {
|
||||||
|
const { error } = props;
|
||||||
|
let errorObject: CustomErrorProps = {
|
||||||
|
message: error.message,
|
||||||
|
err: error,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (error instanceof HttpError) {
|
||||||
|
const redactedError = redactError(error);
|
||||||
|
errorObject = {
|
||||||
|
statusCode: error.statusCode,
|
||||||
|
title: redactedError.name,
|
||||||
|
message: redactedError.message,
|
||||||
|
err: {
|
||||||
|
...redactedError,
|
||||||
|
...error,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// `error.digest` property contains an automatically generated hash of the error that can be used to match the corresponding error in server-side logs
|
||||||
|
log.debug(`${error?.toString() ?? JSON.stringify(error)}`);
|
||||||
|
log.info("errorObject: ", errorObject);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ErrorPage statusCode={errorObject.statusCode} error={errorObject.err} message={errorObject.message} />
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CustomError;
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import EventTypes from "@pages/event-types";
|
||||||
|
import { _generateMetadata } from "app/_utils";
|
||||||
|
|
||||||
|
export const generateMetadata = async () =>
|
||||||
|
await _generateMetadata(
|
||||||
|
(t) => t("event_types_page_title"),
|
||||||
|
(t) => t("event_types_page_subtitle")
|
||||||
|
);
|
||||||
|
|
||||||
|
export default EventTypes;
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// pages without layout (e.g., /availability/index.tsx) are supposed to go under (layout) folder
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { type ReactElement } from "react";
|
||||||
|
|
||||||
|
import { getLayout } from "@calcom/features/MainLayoutAppDir";
|
||||||
|
|
||||||
|
import PageWrapper from "@components/PageWrapperAppDir";
|
||||||
|
|
||||||
|
type WrapperWithLayoutProps = {
|
||||||
|
children: ReactElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function WrapperWithLayout({ children }: WrapperWithLayoutProps) {
|
||||||
|
const h = headers();
|
||||||
|
const nonce = h.get("x-nonce") ?? undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageWrapper getLayout={getLayout} requiresLicense={false} nonce={nonce} themeBasis={null}>
|
||||||
|
{children}
|
||||||
|
</PageWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { type NextPage } from "next";
|
||||||
|
|
||||||
|
import CustomError, { type DefaultErrorProps } from "./error";
|
||||||
|
|
||||||
|
export const GlobalError: NextPage<DefaultErrorProps> = (props) => {
|
||||||
|
return (
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<CustomError {...props} />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GlobalError;
|
||||||
+34
-55
@@ -1,84 +1,63 @@
|
|||||||
import type { Metadata } from "next";
|
import { dir } from "i18next";
|
||||||
import { headers as nextHeaders, cookies as nextCookies } from "next/headers";
|
import { headers, cookies } from "next/headers";
|
||||||
import Script from "next/script";
|
import Script from "next/script";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import { getLocale } from "@calcom/features/auth/lib/getLocale";
|
import { getLocale } from "@calcom/features/auth/lib/getLocale";
|
||||||
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||||
|
|
||||||
|
import { prepareRootMetadata } from "@lib/metadata";
|
||||||
|
|
||||||
import "../styles/globals.css";
|
import "../styles/globals.css";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const generateMetadata = () =>
|
||||||
icons: {
|
prepareRootMetadata({
|
||||||
icon: [
|
twitterCreator: "@calcom",
|
||||||
{
|
twitterSite: "@calcom",
|
||||||
sizes: "32x32",
|
robots: {
|
||||||
url: "/api/logo?type=favicon-32",
|
index: false,
|
||||||
},
|
follow: false,
|
||||||
{
|
|
||||||
sizes: "16x16",
|
|
||||||
url: "/api/logo?type=favicon-16",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
apple: {
|
|
||||||
sizes: "180x180",
|
|
||||||
url: "/api/logo?type=apple-touch-icon",
|
|
||||||
},
|
},
|
||||||
other: [
|
});
|
||||||
{
|
|
||||||
url: "/safari-pinned-tab.svg",
|
|
||||||
rel: "mask-icon",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
manifest: "/site.webmanifest",
|
|
||||||
themeColor: [
|
|
||||||
{ media: "(prefers-color-scheme: light)", color: "#f9fafb" },
|
|
||||||
{ media: "(prefers-color-scheme: dark)", color: "#1C1C1C" },
|
|
||||||
],
|
|
||||||
other: {
|
|
||||||
"msapplication-TileColor": "#000000",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const getInitialProps = async (
|
const getInitialProps = async (url: string) => {
|
||||||
url: string,
|
|
||||||
headers: ReturnType<typeof nextHeaders>,
|
|
||||||
cookies: ReturnType<typeof nextCookies>
|
|
||||||
) => {
|
|
||||||
const { pathname, searchParams } = new URL(url);
|
const { pathname, searchParams } = new URL(url);
|
||||||
|
|
||||||
const isEmbed = pathname.endsWith("/embed") || (searchParams?.get("embedType") ?? null) !== null;
|
const isEmbed = pathname.endsWith("/embed") || (searchParams?.get("embedType") ?? null) !== null;
|
||||||
const embedColorScheme = searchParams?.get("ui.color-scheme");
|
const embedColorScheme = searchParams?.get("ui.color-scheme");
|
||||||
|
|
||||||
// @ts-expect-error we cannot access ctx.req in app dir, however headers and cookies are only properties needed to extract the locale
|
const req = { headers: headers(), cookies: cookies() };
|
||||||
const newLocale = await getLocale({ headers, cookies });
|
const newLocale = await getLocale(req);
|
||||||
let direction = "ltr";
|
const direction = dir(newLocale);
|
||||||
|
|
||||||
try {
|
|
||||||
const intlLocale = new Intl.Locale(newLocale);
|
|
||||||
// @ts-expect-error INFO: Typescript does not know about the Intl.Locale textInfo attribute
|
|
||||||
direction = intlLocale.textInfo?.direction;
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { isEmbed, embedColorScheme, locale: newLocale, direction };
|
return { isEmbed, embedColorScheme, locale: newLocale, direction };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getFallbackProps = () => ({
|
||||||
|
locale: "en",
|
||||||
|
direction: "ltr",
|
||||||
|
isEmbed: false,
|
||||||
|
embedColorScheme: false,
|
||||||
|
});
|
||||||
|
|
||||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
const headers = nextHeaders();
|
const h = headers();
|
||||||
const cookies = nextCookies();
|
|
||||||
|
|
||||||
const fullUrl = headers.get("x-url") ?? "";
|
const fullUrl = h.get("x-url") ?? "";
|
||||||
const nonce = headers.get("x-csp") ?? "";
|
const nonce = h.get("x-csp") ?? "";
|
||||||
|
|
||||||
|
const isSSG = !fullUrl;
|
||||||
|
|
||||||
|
const { locale, direction, isEmbed, embedColorScheme } = isSSG
|
||||||
|
? getFallbackProps()
|
||||||
|
: await getInitialProps(fullUrl);
|
||||||
|
|
||||||
const { locale, direction, isEmbed, embedColorScheme } = await getInitialProps(fullUrl, headers, cookies);
|
|
||||||
return (
|
return (
|
||||||
<html
|
<html
|
||||||
lang={locale}
|
lang={locale}
|
||||||
dir={direction}
|
dir={direction}
|
||||||
style={embedColorScheme ? { colorScheme: embedColorScheme as string } : undefined}>
|
style={embedColorScheme ? { colorScheme: embedColorScheme as string } : undefined}
|
||||||
|
data-nextjs-router="app">
|
||||||
<head nonce={nonce}>
|
<head nonce={nonce}>
|
||||||
{!IS_PRODUCTION && process.env.VERCEL_ENV === "preview" && (
|
{!IS_PRODUCTION && process.env.VERCEL_ENV === "preview" && (
|
||||||
// eslint-disable-next-line @next/next/no-sync-scripts
|
// eslint-disable-next-line @next/next/no-sync-scripts
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -6,6 +6,7 @@ import { z } from "zod";
|
|||||||
import type { CredentialOwner } from "@calcom/app-store/types";
|
import type { CredentialOwner } from "@calcom/app-store/types";
|
||||||
import classNames from "@calcom/lib/classNames";
|
import classNames from "@calcom/lib/classNames";
|
||||||
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||||
|
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
|
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
|
||||||
import { Badge, ListItemText, Avatar } from "@calcom/ui";
|
import { Badge, ListItemText, Avatar } from "@calcom/ui";
|
||||||
@@ -56,7 +57,7 @@ export default function AppListCard(props: AppListCardProps) {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [highlight, setHighlight] = useState(shouldHighlight && hl === slug);
|
const [highlight, setHighlight] = useState(shouldHighlight && hl === slug);
|
||||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useCompatSearchParams();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { type DehydratedState } from "@tanstack/react-query";
|
||||||
import type { SSRConfig } from "next-i18next";
|
import type { SSRConfig } from "next-i18next";
|
||||||
import { Inter } from "next/font/google";
|
import { Inter } from "next/font/google";
|
||||||
import localFont from "next/font/local";
|
import localFont from "next/font/local";
|
||||||
@@ -10,7 +11,6 @@ import type { ReactNode } from "react";
|
|||||||
|
|
||||||
import "@calcom/embed-core/src/embed-iframe";
|
import "@calcom/embed-core/src/embed-iframe";
|
||||||
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
|
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
|
||||||
|
|
||||||
import type { AppProps } from "@lib/app-providers-app-dir";
|
import type { AppProps } from "@lib/app-providers-app-dir";
|
||||||
import AppProviders from "@lib/app-providers-app-dir";
|
import AppProviders from "@lib/app-providers-app-dir";
|
||||||
@@ -29,13 +29,14 @@ const calFont = localFont({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export type PageWrapperProps = Readonly<{
|
export type PageWrapperProps = Readonly<{
|
||||||
getLayout: (page: React.ReactElement) => ReactNode;
|
getLayout: ((page: React.ReactElement) => ReactNode) | null;
|
||||||
children: React.ReactElement;
|
children: React.ReactElement;
|
||||||
requiresLicense: boolean;
|
requiresLicense: boolean;
|
||||||
isThemeSupported: boolean;
|
|
||||||
isBookingPage: boolean;
|
|
||||||
nonce: string | undefined;
|
nonce: string | undefined;
|
||||||
themeBasis: string | null;
|
themeBasis: string | null;
|
||||||
|
dehydratedState?: DehydratedState;
|
||||||
|
isThemeSupported?: boolean;
|
||||||
|
isBookingPage?: boolean;
|
||||||
i18n?: SSRConfig;
|
i18n?: SSRConfig;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
@@ -85,4 +86,4 @@ function PageWrapper(props: PageWrapperProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default trpc.withTRPC(PageWrapper);
|
export default PageWrapper;
|
||||||
@@ -42,6 +42,7 @@ export type AppPageProps = {
|
|||||||
disableInstall?: boolean;
|
disableInstall?: boolean;
|
||||||
dependencies?: string[];
|
dependencies?: string[];
|
||||||
concurrentMeetings: AppType["concurrentMeetings"];
|
concurrentMeetings: AppType["concurrentMeetings"];
|
||||||
|
paid?: AppType["paid"];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AppPage = ({
|
export const AppPage = ({
|
||||||
@@ -67,6 +68,7 @@ export const AppPage = ({
|
|||||||
isTemplate,
|
isTemplate,
|
||||||
dependencies,
|
dependencies,
|
||||||
concurrentMeetings,
|
concurrentMeetings,
|
||||||
|
paid,
|
||||||
}: AppPageProps) => {
|
}: AppPageProps) => {
|
||||||
const { t, i18n } = useLocale();
|
const { t, i18n } = useLocale();
|
||||||
const hasDescriptionItems = descriptionItems && descriptionItems.length > 0;
|
const hasDescriptionItems = descriptionItems && descriptionItems.length > 0;
|
||||||
@@ -163,6 +165,19 @@ export const AppPage = ({
|
|||||||
className="bg-subtle text-emphasis rounded-md p-1 text-xs capitalize">
|
className="bg-subtle text-emphasis rounded-md p-1 text-xs capitalize">
|
||||||
{categories[0]}
|
{categories[0]}
|
||||||
</Link>{" "}
|
</Link>{" "}
|
||||||
|
{paid && (
|
||||||
|
<>
|
||||||
|
<Badge className="mr-1">
|
||||||
|
{Intl.NumberFormat(i18n.language, {
|
||||||
|
style: "currency",
|
||||||
|
currency: "USD",
|
||||||
|
useGrouping: false,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(paid.priceInUsd)}
|
||||||
|
/{t("month")}
|
||||||
|
</Badge>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
•{" "}
|
•{" "}
|
||||||
<a target="_blank" rel="noreferrer" href={website}>
|
<a target="_blank" rel="noreferrer" href={website}>
|
||||||
{t("published_by", { author })}
|
{t("published_by", { author })}
|
||||||
@@ -206,6 +221,7 @@ export const AppPage = ({
|
|||||||
addAppMutationInput={{ type, variant, slug }}
|
addAppMutationInput={{ type, variant, slug }}
|
||||||
multiInstall
|
multiInstall
|
||||||
concurrentMeetings={concurrentMeetings}
|
concurrentMeetings={concurrentMeetings}
|
||||||
|
paid={paid}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -244,6 +260,7 @@ export const AppPage = ({
|
|||||||
addAppMutationInput={{ type, variant, slug }}
|
addAppMutationInput={{ type, variant, slug }}
|
||||||
credentials={appDbQuery.data?.credentials}
|
credentials={appDbQuery.data?.credentials}
|
||||||
concurrentMeetings={concurrentMeetings}
|
concurrentMeetings={concurrentMeetings}
|
||||||
|
paid={paid}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -263,7 +280,7 @@ export const AppPage = ({
|
|||||||
<SkeletonButton className="mt-6 h-20 grow" />
|
<SkeletonButton className="mt-6 h-20 grow" />
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{price !== 0 && (
|
{price !== 0 && !paid && (
|
||||||
<span className="block text-right">
|
<span className="block text-right">
|
||||||
{feeType === "usage-based" ? `${commission}% + ${priceInDollar}/booking` : priceInDollar}
|
{feeType === "usage-based" ? `${commission}% + ${priceInDollar}/booking` : priceInDollar}
|
||||||
{feeType === "monthly" && `/${t("month")}`}
|
{feeType === "monthly" && `/${t("month")}`}
|
||||||
@@ -273,23 +290,27 @@ export const AppPage = ({
|
|||||||
<div className="prose-sm prose prose-a:text-default prose-headings:text-emphasis prose-code:text-default prose-strong:text-default text-default mt-8">
|
<div className="prose-sm prose prose-a:text-default prose-headings:text-emphasis prose-code:text-default prose-strong:text-default text-default mt-8">
|
||||||
{body}
|
{body}
|
||||||
</div>
|
</div>
|
||||||
<h4 className="text-emphasis mt-8 font-semibold ">{t("pricing")}</h4>
|
{!paid && (
|
||||||
<span className="text-default">
|
<>
|
||||||
{teamsPlanRequired ? (
|
<h4 className="text-emphasis mt-8 font-semibold ">{t("pricing")}</h4>
|
||||||
t("teams_plan_required")
|
<span className="text-default">
|
||||||
) : price === 0 ? (
|
{teamsPlanRequired ? (
|
||||||
t("free_to_use_apps")
|
t("teams_plan_required")
|
||||||
) : (
|
) : price === 0 ? (
|
||||||
<>
|
t("free_to_use_apps")
|
||||||
{Intl.NumberFormat(i18n.language, {
|
) : (
|
||||||
style: "currency",
|
<>
|
||||||
currency: "USD",
|
{Intl.NumberFormat(i18n.language, {
|
||||||
useGrouping: false,
|
style: "currency",
|
||||||
}).format(price)}
|
currency: "USD",
|
||||||
{feeType === "monthly" && `/${t("month")}`}
|
useGrouping: false,
|
||||||
</>
|
}).format(price)}
|
||||||
)}
|
{feeType === "monthly" && `/${t("month")}`}
|
||||||
</span>
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<h4 className="text-emphasis mb-2 mt-8 font-semibold ">{t("contact")}</h4>
|
<h4 className="text-emphasis mb-2 mt-8 font-semibold ">{t("contact")}</h4>
|
||||||
<ul className="prose-sm -ml-1 -mr-1 leading-5">
|
<ul className="prose-sm -ml-1 -mr-1 leading-5">
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export const InstallAppButtonChild = ({
|
|||||||
multiInstall,
|
multiInstall,
|
||||||
credentials,
|
credentials,
|
||||||
concurrentMeetings,
|
concurrentMeetings,
|
||||||
|
paid,
|
||||||
...props
|
...props
|
||||||
}: {
|
}: {
|
||||||
userAdminTeams?: UserAdminTeams;
|
userAdminTeams?: UserAdminTeams;
|
||||||
@@ -34,6 +35,7 @@ export const InstallAppButtonChild = ({
|
|||||||
multiInstall?: boolean;
|
multiInstall?: boolean;
|
||||||
credentials?: RouterOutputs["viewer"]["appCredentialsByType"]["credentials"];
|
credentials?: RouterOutputs["viewer"]["appCredentialsByType"]["credentials"];
|
||||||
concurrentMeetings?: boolean;
|
concurrentMeetings?: boolean;
|
||||||
|
paid?: AppFrontendPayload["paid"];
|
||||||
} & ButtonProps) => {
|
} & ButtonProps) => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
|
|
||||||
@@ -46,8 +48,27 @@ export const InstallAppButtonChild = ({
|
|||||||
if (error instanceof Error) showToast(error.message || t("app_could_not_be_installed"), "error");
|
if (error instanceof Error) showToast(error.message || t("app_could_not_be_installed"), "error");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const shouldDisableInstallation = !multiInstall ? !!(credentials && credentials.length) : false;
|
||||||
|
|
||||||
if (!userAdminTeams?.length || !doesAppSupportTeamInstall(appCategories, concurrentMeetings)) {
|
// Paid apps don't support team installs at the moment
|
||||||
|
// Also, cal.ai(the only paid app at the moment) doesn't support team install either
|
||||||
|
if (paid) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
data-testid="install-app-button"
|
||||||
|
{...props}
|
||||||
|
disabled={shouldDisableInstallation}
|
||||||
|
color="primary"
|
||||||
|
size="base">
|
||||||
|
{paid.trial ? t("start_paid_trial") : t("subscribe")}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!userAdminTeams?.length ||
|
||||||
|
!doesAppSupportTeamInstall({ appCategories, concurrentMeetings, isPaid: !!paid })
|
||||||
|
) {
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
data-testid="install-app-button"
|
data-testid="install-app-button"
|
||||||
@@ -55,6 +76,7 @@ export const InstallAppButtonChild = ({
|
|||||||
// @TODO: Overriding color and size prevent us from
|
// @TODO: Overriding color and size prevent us from
|
||||||
// having to duplicate InstallAppButton for now.
|
// having to duplicate InstallAppButton for now.
|
||||||
color="primary"
|
color="primary"
|
||||||
|
disabled={shouldDisableInstallation}
|
||||||
size="base">
|
size="base">
|
||||||
{multiInstall ? t("install_another") : t("install_app")}
|
{multiInstall ? t("install_another") : t("install_app")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -32,8 +32,6 @@ import {
|
|||||||
} from "@calcom/ui";
|
} from "@calcom/ui";
|
||||||
import { Ban, Check, Clock, CreditCard, MapPin, RefreshCcw, Send, X } 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";
|
|
||||||
|
|
||||||
import { ChargeCardDialog } from "@components/dialog/ChargeCardDialog";
|
import { ChargeCardDialog } from "@components/dialog/ChargeCardDialog";
|
||||||
import { EditLocationDialog } from "@components/dialog/EditLocationDialog";
|
import { EditLocationDialog } from "@components/dialog/EditLocationDialog";
|
||||||
import { RescheduleDialog } from "@components/dialog/RescheduleDialog";
|
import { RescheduleDialog } from "@components/dialog/RescheduleDialog";
|
||||||
@@ -45,14 +43,18 @@ type BookingItem = RouterOutputs["viewer"]["bookings"]["get"]["bookings"][number
|
|||||||
type BookingItemProps = BookingItem & {
|
type BookingItemProps = BookingItem & {
|
||||||
listingStatus: BookingListingStatus;
|
listingStatus: BookingListingStatus;
|
||||||
recurringInfo: RouterOutputs["viewer"]["bookings"]["get"]["recurringInfo"][number] | undefined;
|
recurringInfo: RouterOutputs["viewer"]["bookings"]["get"]["recurringInfo"][number] | undefined;
|
||||||
|
loggedInUser: {
|
||||||
|
userId: number | undefined;
|
||||||
|
userTimeZone: string | undefined;
|
||||||
|
userTimeFormat: number | null | undefined;
|
||||||
|
userEmail: string | undefined;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
function BookingListItem(booking: BookingItemProps) {
|
function BookingListItem(booking: BookingItemProps) {
|
||||||
// Get user so we can determine 12/24 hour format preferences
|
|
||||||
const query = useMeQuery();
|
|
||||||
const bookerUrl = useBookerUrl();
|
const bookerUrl = useBookerUrl();
|
||||||
|
const { userId, userTimeZone, userTimeFormat, userEmail } = booking.loggedInUser;
|
||||||
|
|
||||||
const user = query.data;
|
|
||||||
const {
|
const {
|
||||||
t,
|
t,
|
||||||
i18n: { language },
|
i18n: { language },
|
||||||
@@ -215,7 +217,7 @@ function BookingListItem(booking: BookingItemProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const startTime = dayjs(booking.startTime)
|
const startTime = dayjs(booking.startTime)
|
||||||
.tz(user?.timeZone)
|
.tz(userTimeZone)
|
||||||
.locale(language)
|
.locale(language)
|
||||||
.format(isUpcoming ? "ddd, D MMM" : "D MMMM YYYY");
|
.format(isUpcoming ? "ddd, D MMM" : "D MMMM YYYY");
|
||||||
const [isOpenRescheduleDialog, setIsOpenRescheduleDialog] = useState(false);
|
const [isOpenRescheduleDialog, setIsOpenRescheduleDialog] = useState(false);
|
||||||
@@ -301,7 +303,7 @@ function BookingListItem(booking: BookingItemProps) {
|
|||||||
booking={booking}
|
booking={booking}
|
||||||
isOpenDialog={viewRecordingsDialogIsOpen}
|
isOpenDialog={viewRecordingsDialogIsOpen}
|
||||||
setIsOpenDialog={setViewRecordingsDialogIsOpen}
|
setIsOpenDialog={setViewRecordingsDialogIsOpen}
|
||||||
timeFormat={user?.timeFormat ?? null}
|
timeFormat={userTimeFormat ?? null}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{/* NOTE: Should refactor this dialog component as is being rendered multiple times */}
|
{/* NOTE: Should refactor this dialog component as is being rendered multiple times */}
|
||||||
@@ -341,11 +343,11 @@ function BookingListItem(booking: BookingItemProps) {
|
|||||||
<div className="cursor-pointer py-4">
|
<div className="cursor-pointer py-4">
|
||||||
<div className="text-emphasis text-sm leading-6">{startTime}</div>
|
<div className="text-emphasis text-sm leading-6">{startTime}</div>
|
||||||
<div className="text-subtle text-sm">
|
<div className="text-subtle text-sm">
|
||||||
{formatTime(booking.startTime, user?.timeFormat, user?.timeZone)} -{" "}
|
{formatTime(booking.startTime, userTimeFormat, userTimeZone)} -{" "}
|
||||||
{formatTime(booking.endTime, user?.timeFormat, user?.timeZone)}
|
{formatTime(booking.endTime, userTimeFormat, userTimeZone)}
|
||||||
<MeetingTimeInTimezones
|
<MeetingTimeInTimezones
|
||||||
timeFormat={user?.timeFormat}
|
timeFormat={userTimeFormat}
|
||||||
userTimezone={user?.timeZone}
|
userTimezone={userTimeZone}
|
||||||
startTime={booking.startTime}
|
startTime={booking.startTime}
|
||||||
endTime={booking.endTime}
|
endTime={booking.endTime}
|
||||||
attendees={booking.attendees}
|
attendees={booking.attendees}
|
||||||
@@ -372,7 +374,12 @@ function BookingListItem(booking: BookingItemProps) {
|
|||||||
) : null}
|
) : null}
|
||||||
{recurringDates !== undefined && (
|
{recurringDates !== undefined && (
|
||||||
<div className="text-muted mt-2 text-sm">
|
<div className="text-muted mt-2 text-sm">
|
||||||
<RecurringBookingsTooltip booking={booking} recurringDates={recurringDates} />
|
<RecurringBookingsTooltip
|
||||||
|
userTimeFormat={userTimeFormat}
|
||||||
|
userTimeZone={userTimeZone}
|
||||||
|
booking={booking}
|
||||||
|
recurringDates={recurringDates}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -385,11 +392,11 @@ function BookingListItem(booking: BookingItemProps) {
|
|||||||
<div className="flex w-full items-center justify-between 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-emphasis text-sm leading-6">{startTime}</div>
|
||||||
<div className="text-subtle pr-2 text-sm">
|
<div className="text-subtle pr-2 text-sm">
|
||||||
{formatTime(booking.startTime, user?.timeFormat, user?.timeZone)} -{" "}
|
{formatTime(booking.startTime, userTimeFormat, userTimeZone)} -{" "}
|
||||||
{formatTime(booking.endTime, user?.timeFormat, user?.timeZone)}
|
{formatTime(booking.endTime, userTimeFormat, userTimeZone)}
|
||||||
<MeetingTimeInTimezones
|
<MeetingTimeInTimezones
|
||||||
timeFormat={user?.timeFormat}
|
timeFormat={userTimeFormat}
|
||||||
userTimezone={user?.timeZone}
|
userTimezone={userTimeZone}
|
||||||
startTime={booking.startTime}
|
startTime={booking.startTime}
|
||||||
endTime={booking.endTime}
|
endTime={booking.endTime}
|
||||||
attendees={booking.attendees}
|
attendees={booking.attendees}
|
||||||
@@ -414,7 +421,12 @@ function BookingListItem(booking: BookingItemProps) {
|
|||||||
)}
|
)}
|
||||||
{recurringDates !== undefined && (
|
{recurringDates !== undefined && (
|
||||||
<div className="text-muted text-sm sm:hidden">
|
<div className="text-muted text-sm sm:hidden">
|
||||||
<RecurringBookingsTooltip booking={booking} recurringDates={recurringDates} />
|
<RecurringBookingsTooltip
|
||||||
|
userTimeFormat={userTimeFormat}
|
||||||
|
userTimeZone={userTimeZone}
|
||||||
|
booking={booking}
|
||||||
|
recurringDates={recurringDates}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -446,7 +458,7 @@ function BookingListItem(booking: BookingItemProps) {
|
|||||||
<DisplayAttendees
|
<DisplayAttendees
|
||||||
attendees={booking.attendees}
|
attendees={booking.attendees}
|
||||||
user={booking.user}
|
user={booking.user}
|
||||||
currentEmail={user?.email}
|
currentEmail={userEmail}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{isCancelled && booking.rescheduled && (
|
{isCancelled && booking.rescheduled && (
|
||||||
@@ -460,7 +472,7 @@ function BookingListItem(booking: BookingItemProps) {
|
|||||||
<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">
|
<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 ? (
|
{isUpcoming && !isCancelled ? (
|
||||||
<>
|
<>
|
||||||
{isPending && user?.id === booking.user?.id && <TableActions actions={pendingActions} />}
|
{isPending && userId === booking.user?.id && <TableActions actions={pendingActions} />}
|
||||||
{isConfirmed && <TableActions actions={bookedActions} />}
|
{isConfirmed && <TableActions actions={bookedActions} />}
|
||||||
{isRejected && <div className="text-subtle text-sm">{t("rejected")}</div>}
|
{isRejected && <div className="text-subtle text-sm">{t("rejected")}</div>}
|
||||||
</>
|
</>
|
||||||
@@ -486,12 +498,16 @@ function BookingListItem(booking: BookingItemProps) {
|
|||||||
interface RecurringBookingsTooltipProps {
|
interface RecurringBookingsTooltipProps {
|
||||||
booking: BookingItemProps;
|
booking: BookingItemProps;
|
||||||
recurringDates: Date[];
|
recurringDates: Date[];
|
||||||
|
userTimeZone: string | undefined;
|
||||||
|
userTimeFormat: number | null | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RecurringBookingsTooltip = ({ booking, recurringDates }: RecurringBookingsTooltipProps) => {
|
const RecurringBookingsTooltip = ({
|
||||||
// Get user so we can determine 12/24 hour format preferences
|
booking,
|
||||||
const query = useMeQuery();
|
recurringDates,
|
||||||
const user = query.data;
|
userTimeZone,
|
||||||
|
userTimeFormat,
|
||||||
|
}: RecurringBookingsTooltipProps) => {
|
||||||
const {
|
const {
|
||||||
t,
|
t,
|
||||||
i18n: { language },
|
i18n: { language },
|
||||||
@@ -523,7 +539,7 @@ const RecurringBookingsTooltip = ({ booking, recurringDates }: RecurringBookings
|
|||||||
.includes(aDate.toDateString());
|
.includes(aDate.toDateString());
|
||||||
return (
|
return (
|
||||||
<p key={key} className={classNames(pastOrCancelled && "line-through")}>
|
<p key={key} className={classNames(pastOrCancelled && "line-through")}>
|
||||||
{formatTime(aDate, user?.timeFormat, user?.timeZone)}
|
{formatTime(aDate, userTimeFormat, userTimeZone)}
|
||||||
{" - "}
|
{" - "}
|
||||||
{dayjs(aDate).locale(language).format("D MMMM YYYY")}
|
{dayjs(aDate).locale(language).format("D MMMM YYYY")}
|
||||||
</p>
|
</p>
|
||||||
@@ -536,7 +552,7 @@ const RecurringBookingsTooltip = ({ booking, recurringDates }: RecurringBookings
|
|||||||
/>
|
/>
|
||||||
<p className="mt-1 pl-5 text-xs">
|
<p className="mt-1 pl-5 text-xs">
|
||||||
{booking.status === BookingStatus.ACCEPTED
|
{booking.status === BookingStatus.ACCEPTED
|
||||||
? `${t("event_remaining", {
|
? `${t("event_remaining_other", {
|
||||||
count: recurringCount,
|
count: recurringCount,
|
||||||
})}`
|
})}`
|
||||||
: getEveryFreqFor({
|
: getEveryFreqFor({
|
||||||
|
|||||||
@@ -134,7 +134,9 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||||||
{!!connectedCalendarsQuery.data?.connectedCalendars.length && !team && (
|
{!!connectedCalendarsQuery.data?.connectedCalendars.length && !team && (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<Label className="font-medium">{t("add_to_calendar")}</Label>
|
<div>
|
||||||
|
<Label className="text-emphasis mb-0 font-medium">{t("add_to_calendar")}</Label>
|
||||||
|
</div>
|
||||||
<Link
|
<Link
|
||||||
href="/apps/categories/calendar"
|
href="/apps/categories/calendar"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -142,21 +144,20 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||||||
{t("add_another_calendar")}
|
{t("add_another_calendar")}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="-mt-1 w-full">
|
<Controller
|
||||||
<Controller
|
control={formMethods.control}
|
||||||
control={formMethods.control}
|
name="destinationCalendar"
|
||||||
name="destinationCalendar"
|
defaultValue={eventType.destinationCalendar || undefined}
|
||||||
defaultValue={eventType.destinationCalendar || undefined}
|
render={({ field: { onChange, value } }) => (
|
||||||
render={({ field: { onChange, value } }) => (
|
<DestinationCalendarSelector
|
||||||
<DestinationCalendarSelector
|
destinationCalendar={eventType.destinationCalendar}
|
||||||
destinationCalendar={eventType.destinationCalendar}
|
value={value ? value.externalId : undefined}
|
||||||
value={value ? value.externalId : undefined}
|
onChange={onChange}
|
||||||
onChange={onChange}
|
hidePlaceholder
|
||||||
hidePlaceholder
|
hideAdvancedText
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<p className="text-subtle text-sm">{t("select_which_cal")}</p>
|
<p className="text-subtle text-sm">{t("select_which_cal")}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const getAppDataSetter = (appId: EventTypeAppsList): SetAppData => {
|
const getAppDataSetter = (appId: EventTypeAppsList, credentialId?: number): SetAppData => {
|
||||||
return function (key, value) {
|
return function (key, value) {
|
||||||
// Always get latest data available in Form because consequent calls to setData would update the Form but not allAppsData(it would update during next render)
|
// Always get latest data available in Form because consequent calls to setData would update the Form but not allAppsData(it would update during next render)
|
||||||
const allAppsDataFromForm = methods.getValues("metadata")?.apps || {};
|
const allAppsDataFromForm = methods.getValues("metadata")?.apps || {};
|
||||||
@@ -57,6 +57,7 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
|
|||||||
[appId]: {
|
[appId]: {
|
||||||
...appData,
|
...appData,
|
||||||
[key]: value,
|
[key]: value,
|
||||||
|
credentialId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -76,7 +77,7 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
|
|||||||
appCards.push(
|
appCards.push(
|
||||||
<EventTypeAppCard
|
<EventTypeAppCard
|
||||||
getAppData={getAppDataGetter(app.slug as EventTypeAppsList)}
|
getAppData={getAppDataGetter(app.slug as EventTypeAppsList)}
|
||||||
setAppData={getAppDataSetter(app.slug as EventTypeAppsList)}
|
setAppData={getAppDataSetter(app.slug as EventTypeAppsList, app.userCredentialIds[0])}
|
||||||
key={app.slug}
|
key={app.slug}
|
||||||
app={app}
|
app={app}
|
||||||
eventType={eventType}
|
eventType={eventType}
|
||||||
@@ -90,7 +91,7 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
|
|||||||
appCards.push(
|
appCards.push(
|
||||||
<EventTypeAppCard
|
<EventTypeAppCard
|
||||||
getAppData={getAppDataGetter(app.slug as EventTypeAppsList)}
|
getAppData={getAppDataGetter(app.slug as EventTypeAppsList)}
|
||||||
setAppData={getAppDataSetter(app.slug as EventTypeAppsList)}
|
setAppData={getAppDataSetter(app.slug as EventTypeAppsList, team.credentialId)}
|
||||||
key={app.slug + team?.credentialId}
|
key={app.slug + team?.credentialId}
|
||||||
app={{
|
app={{
|
||||||
...app,
|
...app,
|
||||||
@@ -147,7 +148,7 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
|
|||||||
return (
|
return (
|
||||||
<EventTypeAppCard
|
<EventTypeAppCard
|
||||||
getAppData={getAppDataGetter(app.slug as EventTypeAppsList)}
|
getAppData={getAppDataGetter(app.slug as EventTypeAppsList)}
|
||||||
setAppData={getAppDataSetter(app.slug as EventTypeAppsList)}
|
setAppData={getAppDataSetter(app.slug as EventTypeAppsList, app.userCredentialIds[0])}
|
||||||
key={app.slug}
|
key={app.slug}
|
||||||
app={app}
|
app={app}
|
||||||
eventType={eventType}
|
eventType={eventType}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { Controller, useFormContext, useFieldArray } from "react-hook-form";
|
|||||||
import type { MultiValue } from "react-select";
|
import type { MultiValue } from "react-select";
|
||||||
|
|
||||||
import type { EventLocationType } from "@calcom/app-store/locations";
|
import type { EventLocationType } from "@calcom/app-store/locations";
|
||||||
import { getEventLocationType, LocationType, MeetLocationType } from "@calcom/app-store/locations";
|
import { getEventLocationType, MeetLocationType } from "@calcom/app-store/locations";
|
||||||
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
|
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
|
||||||
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
||||||
import { CAL_URL } from "@calcom/lib/constants";
|
import { CAL_URL } from "@calcom/lib/constants";
|
||||||
@@ -247,15 +247,7 @@ export const EventSetupTab = (
|
|||||||
<ul ref={animationRef} className="space-y-2">
|
<ul ref={animationRef} className="space-y-2">
|
||||||
{locationFields.map((field, index) => {
|
{locationFields.map((field, index) => {
|
||||||
const eventLocationType = getEventLocationType(field.type);
|
const eventLocationType = getEventLocationType(field.type);
|
||||||
const defaultLocation = formMethods
|
const defaultLocation = field;
|
||||||
.getValues("locations")
|
|
||||||
?.find((location: { type: EventLocationType["type"]; address?: string }) => {
|
|
||||||
if (location.type === LocationType.InPerson) {
|
|
||||||
return location.type === eventLocationType?.type && location.address === field?.address;
|
|
||||||
} else {
|
|
||||||
return location.type === eventLocationType?.type;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const option = getLocationFromType(field.type, locationOptions);
|
const option = getLocationFromType(field.type, locationOptions);
|
||||||
|
|
||||||
@@ -337,10 +329,12 @@ export const EventSetupTab = (
|
|||||||
name={eventLocationType.defaultValueVariable}
|
name={eventLocationType.defaultValueVariable}
|
||||||
className="text-error my-1 ml-6 text-sm"
|
className="text-error my-1 ml-6 text-sm"
|
||||||
as="div"
|
as="div"
|
||||||
|
id="location-error"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-6">
|
<div className="ml-6">
|
||||||
<CheckboxField
|
<CheckboxField
|
||||||
|
name={`locations[${index}].displayLocationPublicly`}
|
||||||
data-testid="display-location"
|
data-testid="display-location"
|
||||||
defaultChecked={defaultLocation?.displayLocationPublicly}
|
defaultChecked={defaultLocation?.displayLocationPublicly}
|
||||||
description={t("display_location_label")}
|
description={t("display_location_label")}
|
||||||
|
|||||||
@@ -76,6 +76,8 @@ const ChildrenEventTypesList = ({
|
|||||||
<div>
|
<div>
|
||||||
<Label>{t("assign_to")}</Label>
|
<Label>{t("assign_to")}</Label>
|
||||||
<ChildrenEventTypeSelect
|
<ChildrenEventTypeSelect
|
||||||
|
aria-label="assignment-dropdown"
|
||||||
|
data-testid="assignment-dropdown"
|
||||||
onChange={(options) => {
|
onChange={(options) => {
|
||||||
onChange &&
|
onChange &&
|
||||||
onChange(
|
onChange(
|
||||||
|
|||||||
@@ -14,6 +14,6 @@ type UserAvatarProps = Omit<React.ComponentProps<typeof Avatar>, "alt" | "imageS
|
|||||||
* It is aware of the user's organization to correctly show the avatar from the correct URL
|
* It is aware of the user's organization to correctly show the avatar from the correct URL
|
||||||
*/
|
*/
|
||||||
export function UserAvatar(props: UserAvatarProps) {
|
export function UserAvatar(props: UserAvatarProps) {
|
||||||
const { user, previewSrc, ...rest } = props;
|
const { user, previewSrc = getUserAvatarUrl(user), ...rest } = props;
|
||||||
return <Avatar {...rest} alt={user.name || ""} imageSrc={previewSrc ?? getUserAvatarUrl(user)} />;
|
return <Avatar {...rest} alt={user.name || "Nameless User"} imageSrc={previewSrc} />;
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { CAL_URL } from "@calcom/lib/constants";
|
||||||
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
|
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
|
||||||
import type { User } from "@calcom/prisma/client";
|
import type { User } from "@calcom/prisma/client";
|
||||||
import { AvatarGroup } from "@calcom/ui";
|
import { AvatarGroup } from "@calcom/ui";
|
||||||
@@ -11,6 +12,7 @@ export function UserAvatarGroup(props: UserAvatarProps) {
|
|||||||
<AvatarGroup
|
<AvatarGroup
|
||||||
{...rest}
|
{...rest}
|
||||||
items={users.map((user) => ({
|
items={users.map((user) => ({
|
||||||
|
href: `${CAL_URL}/${user.username}?redirect=false`,
|
||||||
alt: user.name || "",
|
alt: user.name || "",
|
||||||
title: user.name || "",
|
title: user.name || "",
|
||||||
image: getUserAvatarUrl(user),
|
image: getUserAvatarUrl(user),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
||||||
|
import { CAL_URL, WEBAPP_URL } from "@calcom/lib/constants";
|
||||||
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
|
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
|
||||||
import type { Team, User } from "@calcom/prisma/client";
|
import type { Team, User } from "@calcom/prisma/client";
|
||||||
import { AvatarGroup } from "@calcom/ui";
|
import { AvatarGroup } from "@calcom/ui";
|
||||||
@@ -10,8 +11,11 @@ type UserAvatarProps = Omit<React.ComponentProps<typeof AvatarGroup>, "items"> &
|
|||||||
|
|
||||||
export function UserAvatarGroupWithOrg(props: UserAvatarProps) {
|
export function UserAvatarGroupWithOrg(props: UserAvatarProps) {
|
||||||
const { users, organization, ...rest } = props;
|
const { users, organization, ...rest } = props;
|
||||||
|
const orgBranding = useOrgBranding();
|
||||||
|
const baseUrl = `${orgBranding?.fullDomain ?? CAL_URL}`;
|
||||||
const items = [
|
const items = [
|
||||||
{
|
{
|
||||||
|
href: baseUrl,
|
||||||
image: `${WEBAPP_URL}/team/${organization.slug}/avatar.png`,
|
image: `${WEBAPP_URL}/team/${organization.slug}/avatar.png`,
|
||||||
alt: organization.name || undefined,
|
alt: organization.name || undefined,
|
||||||
title: organization.name,
|
title: organization.name,
|
||||||
@@ -19,6 +23,7 @@ export function UserAvatarGroupWithOrg(props: UserAvatarProps) {
|
|||||||
].concat(
|
].concat(
|
||||||
users.map((user) => {
|
users.map((user) => {
|
||||||
return {
|
return {
|
||||||
|
href: `${baseUrl}/${user.username}/?redirect=false`,
|
||||||
image: getUserAvatarUrl(user),
|
image: getUserAvatarUrl(user),
|
||||||
alt: user.name || undefined,
|
alt: user.name || undefined,
|
||||||
title: user.name || user.username || "",
|
title: user.name || user.username || "",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { TooltipProvider } from "@radix-ui/react-tooltip";
|
import { TooltipProvider } from "@radix-ui/react-tooltip";
|
||||||
|
import { TrpcProvider } from "app/_trpc/trpc-provider";
|
||||||
import { dir } from "i18next";
|
import { dir } from "i18next";
|
||||||
import type { Session } from "next-auth";
|
import type { Session } from "next-auth";
|
||||||
import { SessionProvider, useSession } from "next-auth/react";
|
import { SessionProvider, useSession } from "next-auth/react";
|
||||||
@@ -255,26 +256,28 @@ const AppProviders = (props: PageWrapperProps) => {
|
|||||||
const isBookingPage = useIsBookingPage();
|
const isBookingPage = useIsBookingPage();
|
||||||
|
|
||||||
const RemainingProviders = (
|
const RemainingProviders = (
|
||||||
<EventCollectionProvider options={{ apiPath: "/api/collect-events" }}>
|
<TrpcProvider dehydratedState={props.dehydratedState}>
|
||||||
<SessionProvider>
|
<EventCollectionProvider options={{ apiPath: "/api/collect-events" }}>
|
||||||
<CustomI18nextProvider i18n={props.i18n}>
|
<SessionProvider>
|
||||||
<TooltipProvider>
|
<CustomI18nextProvider i18n={props.i18n}>
|
||||||
{/* color-scheme makes background:transparent not work which is required by embed. We need to ensure next-theme adds color-scheme to `body` instead of `html`(https://github.com/pacocoursey/next-themes/blob/main/src/index.tsx#L74). Once that's done we can enable color-scheme support */}
|
<TooltipProvider>
|
||||||
<CalcomThemeProvider
|
{/* color-scheme makes background:transparent not work which is required by embed. We need to ensure next-theme adds color-scheme to `body` instead of `html`(https://github.com/pacocoursey/next-themes/blob/main/src/index.tsx#L74). Once that's done we can enable color-scheme support */}
|
||||||
themeBasis={props.themeBasis}
|
<CalcomThemeProvider
|
||||||
nonce={props.nonce}
|
themeBasis={props.themeBasis}
|
||||||
isThemeSupported={props.isThemeSupported}
|
nonce={props.nonce}
|
||||||
isBookingPage={props.isBookingPage || isBookingPage}>
|
isThemeSupported={/* undefined gets treated as true */ props.isThemeSupported ?? true}
|
||||||
<FeatureFlagsProvider>
|
isBookingPage={props.isBookingPage || isBookingPage}>
|
||||||
<OrgBrandProvider>
|
<FeatureFlagsProvider>
|
||||||
<MetaProvider>{props.children}</MetaProvider>
|
<OrgBrandProvider>
|
||||||
</OrgBrandProvider>
|
<MetaProvider>{props.children}</MetaProvider>
|
||||||
</FeatureFlagsProvider>
|
</OrgBrandProvider>
|
||||||
</CalcomThemeProvider>
|
</FeatureFlagsProvider>
|
||||||
</TooltipProvider>
|
</CalcomThemeProvider>
|
||||||
</CustomI18nextProvider>
|
</TooltipProvider>
|
||||||
</SessionProvider>
|
</CustomI18nextProvider>
|
||||||
</EventCollectionProvider>
|
</SessionProvider>
|
||||||
|
</EventCollectionProvider>
|
||||||
|
</TrpcProvider>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isBookingPage) {
|
if (isBookingPage) {
|
||||||
|
|||||||
+25
-12
@@ -1,10 +1,11 @@
|
|||||||
import type { IncomingMessage, OutgoingMessage } from "http";
|
import type { IncomingMessage, OutgoingMessage } from "http";
|
||||||
|
import type { NextRequest, NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||||
|
|
||||||
import { buildNonce } from "@lib/buildNonce";
|
import { buildNonce } from "./buildNonce";
|
||||||
|
|
||||||
function getCspPolicy(nonce: string) {
|
function getCspPolicy(nonce: string) {
|
||||||
//TODO: Do we need to explicitly define it in turbo.json
|
//TODO: Do we need to explicitly define it in turbo.json
|
||||||
@@ -46,11 +47,12 @@ const isPagePathRequest = (url: URL) => {
|
|||||||
return !isNonPagePathPrefix.test(pathname) && !isFile.test(pathname);
|
return !isNonPagePathPrefix.test(pathname) && !isFile.test(pathname);
|
||||||
};
|
};
|
||||||
|
|
||||||
export function csp(req: IncomingMessage | null, res: OutgoingMessage | null) {
|
export function csp(req: IncomingMessage | NextRequest | null, res: OutgoingMessage | NextResponse | null) {
|
||||||
if (!req) {
|
if (!req) {
|
||||||
return { nonce: undefined };
|
return { nonce: undefined };
|
||||||
}
|
}
|
||||||
const existingNonce = req.headers["x-nonce"];
|
const existingNonce = "cache" in req ? req.headers.get("x-nonce") : req.headers["x-nonce"];
|
||||||
|
|
||||||
if (existingNonce) {
|
if (existingNonce) {
|
||||||
const existingNoneParsed = z.string().safeParse(existingNonce);
|
const existingNoneParsed = z.string().safeParse(existingNonce);
|
||||||
return { nonce: existingNoneParsed.success ? existingNoneParsed.data : "" };
|
return { nonce: existingNoneParsed.success ? existingNoneParsed.data : "" };
|
||||||
@@ -71,17 +73,28 @@ export function csp(req: IncomingMessage | null, res: OutgoingMessage | null) {
|
|||||||
}
|
}
|
||||||
// Set x-nonce request header to be used by `getServerSideProps` or similar fns and `Document.getInitialProps` to read the nonce from
|
// Set x-nonce request header to be used by `getServerSideProps` or similar fns and `Document.getInitialProps` to read the nonce from
|
||||||
// It is generated for all page requests but only used by pages that need CSP
|
// It is generated for all page requests but only used by pages that need CSP
|
||||||
req.headers["x-nonce"] = nonce;
|
|
||||||
|
if ("cache" in req) {
|
||||||
|
req.headers.set("x-nonce", nonce);
|
||||||
|
} else {
|
||||||
|
req.headers["x-nonce"] = nonce;
|
||||||
|
}
|
||||||
|
|
||||||
if (res) {
|
if (res) {
|
||||||
res.setHeader(
|
const enforced =
|
||||||
req.headers["x-csp-enforce"] === "true"
|
"cache" in req ? req.headers.get("x-csp-enforce") === "true" : req.headers["x-csp-enforce"] === "true";
|
||||||
? "Content-Security-Policy"
|
|
||||||
: "Content-Security-Policy-Report-Only",
|
const name = enforced ? "Content-Security-Policy" : "Content-Security-Policy-Report-Only";
|
||||||
getCspPolicy(nonce)
|
|
||||||
.replace(/\s{2,}/g, " ")
|
const value = getCspPolicy(nonce)
|
||||||
.trim()
|
.replace(/\s{2,}/g, " ")
|
||||||
);
|
.trim();
|
||||||
|
|
||||||
|
if ("body" in res) {
|
||||||
|
res.headers.set(name, value);
|
||||||
|
} else {
|
||||||
|
res.setHeader(name, value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { nonce };
|
return { nonce };
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { usePathname, useSearchParams } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
|
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||||
|
|
||||||
export default function useIsBookingPage(): boolean {
|
export default function useIsBookingPage(): boolean {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const isBookingPage = ["/booking/", "/cancel", "/reschedule"].some((route) => pathname?.startsWith(route));
|
const isBookingPage = ["/booking/", "/cancel", "/reschedule"].some((route) => pathname?.startsWith(route));
|
||||||
|
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useCompatSearchParams();
|
||||||
const userParam = Boolean(searchParams?.get("user"));
|
const userParam = Boolean(searchParams?.get("user"));
|
||||||
const teamParam = Boolean(searchParams?.get("team"));
|
const teamParam = Boolean(searchParams?.get("team"));
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
|
|
||||||
|
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||||
|
|
||||||
export default function useRouterQuery<T extends string>(name: T) {
|
export default function useRouterQuery<T extends string>(name: T) {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useCompatSearchParams();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useSearchParams } from "next/navigation";
|
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||||
|
|
||||||
export function useToggleQuery(name: string) {
|
export function useToggleQuery(name: string) {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useCompatSearchParams();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isOn: searchParams?.get(name) === "1",
|
isOn: searchParams?.get(name) === "1",
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
import { truncateOnWord } from "@calcom/lib/text";
|
||||||
|
|
||||||
|
type RootMetadataRecipe = Readonly<{
|
||||||
|
twitterCreator: string;
|
||||||
|
twitterSite: string;
|
||||||
|
robots: {
|
||||||
|
index: boolean;
|
||||||
|
follow: boolean;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type PageMetadataRecipe = Readonly<{
|
||||||
|
title: string;
|
||||||
|
canonical: string;
|
||||||
|
image: string;
|
||||||
|
description: string;
|
||||||
|
siteName: string;
|
||||||
|
metadataBase: URL;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export const prepareRootMetadata = (recipe: RootMetadataRecipe): Metadata => ({
|
||||||
|
icons: {
|
||||||
|
icon: "/favicon.icon",
|
||||||
|
apple: "/api/logo?type=apple-touch-icon",
|
||||||
|
other: [
|
||||||
|
{
|
||||||
|
rel: "icon-mask",
|
||||||
|
url: "/safari-pinned-tab.svg",
|
||||||
|
color: "#000000",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "/api/logo?type=favicon-16",
|
||||||
|
sizes: "16x16",
|
||||||
|
type: "image/png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "/api/logo?type=favicon-32",
|
||||||
|
sizes: "32x32",
|
||||||
|
type: "image/png",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
manifest: "/site.webmanifest",
|
||||||
|
viewport: "width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0",
|
||||||
|
robots: recipe.robots,
|
||||||
|
other: {
|
||||||
|
"application-TileColor": "#ff0000",
|
||||||
|
},
|
||||||
|
themeColor: [
|
||||||
|
{
|
||||||
|
media: "(prefers-color-scheme: light)",
|
||||||
|
color: "#f9fafb",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
media: "(prefers-color-scheme: dark)",
|
||||||
|
color: "#1C1C1C",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
twitter: {
|
||||||
|
site: recipe.twitterSite,
|
||||||
|
creator: recipe.twitterCreator,
|
||||||
|
card: "summary_large_image",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const preparePageMetadata = (recipe: PageMetadataRecipe): Metadata => ({
|
||||||
|
title: recipe.title,
|
||||||
|
alternates: {
|
||||||
|
canonical: recipe.canonical,
|
||||||
|
},
|
||||||
|
openGraph: {
|
||||||
|
description: truncateOnWord(recipe.description, 158),
|
||||||
|
url: recipe.canonical,
|
||||||
|
type: "website",
|
||||||
|
siteName: recipe.siteName,
|
||||||
|
title: recipe.title,
|
||||||
|
images: [recipe.image],
|
||||||
|
},
|
||||||
|
metadataBase: recipe.metadataBase,
|
||||||
|
});
|
||||||
+30
-3
@@ -1,14 +1,21 @@
|
|||||||
import { get } from "@vercel/edge-config";
|
import { get } from "@vercel/edge-config";
|
||||||
import { collectEvents } from "next-collect/server";
|
import { collectEvents } from "next-collect/server";
|
||||||
import type { NextMiddleware } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { getLocale } from "@calcom/features/auth/lib/getLocale";
|
||||||
import { extendEventData, nextCollectBasicSettings } from "@calcom/lib/telemetry";
|
import { extendEventData, nextCollectBasicSettings } from "@calcom/lib/telemetry";
|
||||||
|
|
||||||
const middleware: NextMiddleware = async (req) => {
|
import { csp } from "@lib/csp";
|
||||||
|
|
||||||
|
import { abTestMiddlewareFactory } from "./abTest/middlewareFactory";
|
||||||
|
|
||||||
|
const middleware = async (req: NextRequest): Promise<NextResponse<unknown>> => {
|
||||||
const url = req.nextUrl;
|
const url = req.nextUrl;
|
||||||
const requestHeaders = new Headers(req.headers);
|
const requestHeaders = new Headers(req.headers);
|
||||||
|
|
||||||
|
requestHeaders.set("x-url", req.url);
|
||||||
|
|
||||||
if (!url.pathname.startsWith("/api")) {
|
if (!url.pathname.startsWith("/api")) {
|
||||||
//
|
//
|
||||||
// NOTE: When tRPC hits an error a 500 is returned, when this is received
|
// NOTE: When tRPC hits an error a 500 is returned, when this is received
|
||||||
@@ -32,6 +39,18 @@ const middleware: NextMiddleware = async (req) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const res = routingForms.handle(url);
|
const res = routingForms.handle(url);
|
||||||
|
|
||||||
|
const { nonce } = csp(req, res ?? null);
|
||||||
|
|
||||||
|
if (!process.env.CSP_POLICY) {
|
||||||
|
req.headers.set("x-csp", "not-opted-in");
|
||||||
|
} else if (!req.headers.get("x-csp")) {
|
||||||
|
// If x-csp not set by gSSP, then it's initialPropsOnly
|
||||||
|
req.headers.set("x-csp", "initialPropsOnly");
|
||||||
|
} else {
|
||||||
|
req.headers.set("x-csp", nonce ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
if (res) {
|
if (res) {
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
@@ -45,6 +64,12 @@ const middleware: NextMiddleware = async (req) => {
|
|||||||
requestHeaders.set("x-csp-enforce", "true");
|
requestHeaders.set("x-csp-enforce", "true");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
requestHeaders.set("x-pathname", url.pathname);
|
||||||
|
|
||||||
|
const locale = await getLocale(req);
|
||||||
|
|
||||||
|
requestHeaders.set("x-locale", locale);
|
||||||
|
|
||||||
return NextResponse.next({
|
return NextResponse.next({
|
||||||
request: {
|
request: {
|
||||||
headers: requestHeaders,
|
headers: requestHeaders,
|
||||||
@@ -74,11 +99,13 @@ export const config = {
|
|||||||
* Paths required by routingForms.handle
|
* Paths required by routingForms.handle
|
||||||
*/
|
*/
|
||||||
"/apps/routing_forms/:path*",
|
"/apps/routing_forms/:path*",
|
||||||
|
"/event-types",
|
||||||
|
"/future/event-types/",
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default collectEvents({
|
export default collectEvents({
|
||||||
middleware,
|
middleware: abTestMiddlewareFactory(middleware),
|
||||||
...nextCollectBasicSettings,
|
...nextCollectBasicSettings,
|
||||||
cookieName: "__clnds",
|
cookieName: "__clnds",
|
||||||
extend: extendEventData,
|
extend: extendEventData,
|
||||||
|
|||||||
+12
-4
@@ -71,13 +71,21 @@ if (process.env.GOOGLE_API_CREDENTIALS && !validJson(process.env.GOOGLE_API_CRED
|
|||||||
}
|
}
|
||||||
|
|
||||||
const informAboutDuplicateTranslations = () => {
|
const informAboutDuplicateTranslations = () => {
|
||||||
const valueSet = new Set();
|
const valueMap = {};
|
||||||
|
|
||||||
for (const key in englishTranslation) {
|
for (const key in englishTranslation) {
|
||||||
if (valueSet.has(englishTranslation[key])) {
|
const value = englishTranslation[key];
|
||||||
console.warn("\x1b[33mDuplicate value found in:", "\x1b[0m", key);
|
|
||||||
|
if (valueMap[value]) {
|
||||||
|
console.warn(
|
||||||
|
"\x1b[33mDuplicate value found in common.json keys:",
|
||||||
|
"\x1b[0m ",
|
||||||
|
key,
|
||||||
|
"and",
|
||||||
|
valueMap[value]
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
valueSet.add(englishTranslation[key]);
|
valueMap[value] = key;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@calcom/web",
|
"name": "@calcom/web",
|
||||||
"version": "3.4.7",
|
"version": "3.5.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"analyze": "ANALYZE=true next build",
|
"analyze": "ANALYZE=true next build",
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
"@hookform/resolvers": "^2.9.7",
|
"@hookform/resolvers": "^2.9.7",
|
||||||
"@next-auth/prisma-adapter": "^1.0.4",
|
"@next-auth/prisma-adapter": "^1.0.4",
|
||||||
"@next/bundle-analyzer": "^13.1.6",
|
"@next/bundle-analyzer": "^13.1.6",
|
||||||
"@radix-ui/react-avatar": "^1.0.0",
|
"@radix-ui/react-avatar": "^1.0.4",
|
||||||
"@radix-ui/react-collapsible": "^1.0.0",
|
"@radix-ui/react-collapsible": "^1.0.0",
|
||||||
"@radix-ui/react-dialog": "^1.0.4",
|
"@radix-ui/react-dialog": "^1.0.4",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.0.5",
|
"@radix-ui/react-dropdown-menu": "^2.0.5",
|
||||||
@@ -168,8 +168,9 @@
|
|||||||
"module-alias": "^2.2.2",
|
"module-alias": "^2.2.2",
|
||||||
"msw": "^0.42.3",
|
"msw": "^0.42.3",
|
||||||
"node-html-parser": "^6.1.10",
|
"node-html-parser": "^6.1.10",
|
||||||
|
"node-mocks-http": "^1.11.0",
|
||||||
"postcss": "^8.4.18",
|
"postcss": "^8.4.18",
|
||||||
"tailwindcss": "^3.3.1",
|
"tailwindcss": "^3.3.3",
|
||||||
"tailwindcss-animate": "^1.0.6",
|
"tailwindcss-animate": "^1.0.6",
|
||||||
"ts-node": "^10.9.1",
|
"ts-node": "^10.9.1",
|
||||||
"typescript": "^4.9.4"
|
"typescript": "^4.9.4"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import { useSearchParams } from "next/navigation";
|
|
||||||
|
|
||||||
import { APP_NAME } from "@calcom/lib/constants";
|
import { APP_NAME } from "@calcom/lib/constants";
|
||||||
|
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { Button, showToast } from "@calcom/ui";
|
import { Button, showToast } from "@calcom/ui";
|
||||||
import { Copy } from "@calcom/ui/components/icon";
|
import { Copy } from "@calcom/ui/components/icon";
|
||||||
@@ -9,7 +9,7 @@ import { Copy } from "@calcom/ui/components/icon";
|
|||||||
import PageWrapper from "@components/PageWrapper";
|
import PageWrapper from "@components/PageWrapper";
|
||||||
|
|
||||||
export default function Error500() {
|
export default function Error500() {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useCompatSearchParams();
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export function UserPage(props: InferGetServerSidePropsType<typeof getServerSide
|
|||||||
// So it doesn't display in the Link (and make tests fail)
|
// So it doesn't display in the Link (and make tests fail)
|
||||||
user: _user,
|
user: _user,
|
||||||
orgSlug: _orgSlug,
|
orgSlug: _orgSlug,
|
||||||
|
redirect: _redirect,
|
||||||
...query
|
...query
|
||||||
} = useRouterQuery();
|
} = useRouterQuery();
|
||||||
|
|
||||||
@@ -82,7 +83,7 @@ export function UserPage(props: InferGetServerSidePropsType<typeof getServerSide
|
|||||||
description={markdownStrippedBio}
|
description={markdownStrippedBio}
|
||||||
meeting={{
|
meeting={{
|
||||||
title: markdownStrippedBio,
|
title: markdownStrippedBio,
|
||||||
profile: { name: `${profile.name}`, image: null },
|
profile: { name: `${profile.name}`, image: user.avatarUrl || null },
|
||||||
users: [{ username: `${user.username}`, name: `${user.name}` }],
|
users: [{ username: `${user.username}`, name: `${user.name}` }],
|
||||||
}}
|
}}
|
||||||
nextSeoProps={{
|
nextSeoProps={{
|
||||||
@@ -116,7 +117,7 @@ export function UserPage(props: InferGetServerSidePropsType<typeof getServerSide
|
|||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<h1 className="font-cal text-emphasis mb-1 text-3xl" data-testid="name-title">
|
<h1 className="font-cal text-emphasis my-1 text-3xl" data-testid="name-title">
|
||||||
{profile.name}
|
{profile.name}
|
||||||
{user.verified && (
|
{user.verified && (
|
||||||
<Verified className=" mx-1 -mt-1 inline h-6 w-6 fill-blue-500 text-white dark:text-black" />
|
<Verified className=" mx-1 -mt-1 inline h-6 w-6 fill-blue-500 text-white dark:text-black" />
|
||||||
@@ -245,7 +246,7 @@ export type UserPageProps = {
|
|||||||
allowSEOIndexing: boolean;
|
allowSEOIndexing: boolean;
|
||||||
username: string | null;
|
username: string | null;
|
||||||
};
|
};
|
||||||
users: Pick<User, "away" | "name" | "username" | "bio" | "verified">[];
|
users: Pick<User, "away" | "name" | "username" | "bio" | "verified" | "avatarUrl">[];
|
||||||
themeBasis: string | null;
|
themeBasis: string | null;
|
||||||
markdownStrippedBio: string;
|
markdownStrippedBio: string;
|
||||||
safeBio: string;
|
safeBio: string;
|
||||||
@@ -295,6 +296,7 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
|
|||||||
metadata: true,
|
metadata: true,
|
||||||
brandColor: true,
|
brandColor: true,
|
||||||
darkBrandColor: true,
|
darkBrandColor: true,
|
||||||
|
avatarUrl: true,
|
||||||
organizationId: true,
|
organizationId: true,
|
||||||
organization: {
|
organization: {
|
||||||
select: {
|
select: {
|
||||||
@@ -363,6 +365,7 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
|
|||||||
image: user.avatar,
|
image: user.avatar,
|
||||||
theme: user.theme,
|
theme: user.theme,
|
||||||
brandColor: user.brandColor,
|
brandColor: user.brandColor,
|
||||||
|
avatarUrl: user.avatarUrl,
|
||||||
darkBrandColor: user.darkBrandColor,
|
darkBrandColor: user.darkBrandColor,
|
||||||
allowSEOIndexing: user.allowSEOIndexing ?? true,
|
allowSEOIndexing: user.allowSEOIndexing ?? true,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
@@ -386,6 +389,16 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
|
|||||||
descriptionAsSafeHTML: markdownToSafeHTML(eventType.description),
|
descriptionAsSafeHTML: markdownToSafeHTML(eventType.description),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// if profile only has one public event-type, redirect to it
|
||||||
|
if (eventTypes.length === 1 && context.query.redirect !== "false") {
|
||||||
|
return {
|
||||||
|
redirect: {
|
||||||
|
permanent: false,
|
||||||
|
destination: `/${user.username}/${eventTypes[0].slug}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const safeBio = markdownToSafeHTML(user.bio) || "";
|
const safeBio = markdownToSafeHTML(user.bio) || "";
|
||||||
|
|
||||||
const markdownStrippedBio = stripMarkdown(user?.bio || "");
|
const markdownStrippedBio = stripMarkdown(user?.bio || "");
|
||||||
@@ -397,6 +410,7 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
|
|||||||
name: user.name,
|
name: user.name,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
bio: user.bio,
|
bio: user.bio,
|
||||||
|
avatarUrl: user.avatarUrl,
|
||||||
away: user.away,
|
away: user.away,
|
||||||
verified: user.verified,
|
verified: user.verified,
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -1,218 +1,53 @@
|
|||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiResponse } from "next";
|
||||||
|
|
||||||
import dayjs from "@calcom/dayjs";
|
import calcomSignupHandler from "@calcom/feature-auth/signup/handlers/calcomHandler";
|
||||||
import { checkPremiumUsername } from "@calcom/ee/common/lib/checkPremiumUsername";
|
import selfHostedSignupHandler from "@calcom/feature-auth/signup/handlers/selfHostedHandler";
|
||||||
import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
|
import { type RequestWithUsernameStatus } from "@calcom/features/auth/signup/username";
|
||||||
import { sendEmailVerification } from "@calcom/features/auth/lib/verifyEmail";
|
import { IS_PREMIUM_USERNAME_ENABLED } from "@calcom/lib/constants";
|
||||||
import { IS_CALCOM } from "@calcom/lib/constants";
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
import slugify from "@calcom/lib/slugify";
|
import logger from "@calcom/lib/logger";
|
||||||
import { closeComUpsertTeamUser } from "@calcom/lib/sync/SyncServiceManager";
|
|
||||||
import { validateUsernameInTeam, validateUsername } from "@calcom/lib/validateUsername";
|
|
||||||
import prisma from "@calcom/prisma";
|
|
||||||
import { IdentityProvider } from "@calcom/prisma/enums";
|
|
||||||
import { MembershipRole } from "@calcom/prisma/enums";
|
|
||||||
import { signupSchema } from "@calcom/prisma/zod-utils";
|
|
||||||
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
|
||||||
|
|
||||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|
||||||
if (req.method !== "POST") {
|
|
||||||
return res.status(405).end();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
function ensureSignupIsEnabled() {
|
||||||
if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === "true") {
|
if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === "true") {
|
||||||
res.status(403).json({ message: "Signup is disabled" });
|
throw new HttpError({
|
||||||
return;
|
statusCode: 403,
|
||||||
}
|
message: "Signup is disabled",
|
||||||
|
|
||||||
const data = req.body;
|
|
||||||
const { email, password, language, token } = signupSchema.parse(data);
|
|
||||||
|
|
||||||
const username = slugify(data.username);
|
|
||||||
const userEmail = email.toLowerCase();
|
|
||||||
|
|
||||||
if (!username) {
|
|
||||||
res.status(422).json({ message: "Invalid username" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let foundToken: { id: number; teamId: number | null; expires: Date } | null = null;
|
|
||||||
if (token) {
|
|
||||||
foundToken = await prisma.verificationToken.findFirst({
|
|
||||||
where: {
|
|
||||||
token,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
expires: true,
|
|
||||||
teamId: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!foundToken) {
|
|
||||||
return res.status(401).json({ message: "Invalid Token" });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dayjs(foundToken?.expires).isBefore(dayjs())) {
|
|
||||||
return res.status(401).json({ message: "Token expired" });
|
|
||||||
}
|
|
||||||
if (foundToken?.teamId) {
|
|
||||||
const teamUserValidation = await validateUsernameInTeam(username, userEmail, foundToken?.teamId);
|
|
||||||
if (!teamUserValidation.isValid) {
|
|
||||||
return res.status(409).json({ message: "Username or email is already taken" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const userValidation = await validateUsername(username, userEmail);
|
|
||||||
if (!userValidation.isValid) {
|
|
||||||
return res.status(409).json({ message: "Username or email is already taken" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const hashedPassword = await hashPassword(password);
|
|
||||||
|
|
||||||
if (foundToken && foundToken?.teamId) {
|
|
||||||
const team = await prisma.team.findUnique({
|
|
||||||
where: {
|
|
||||||
id: foundToken.teamId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (team) {
|
|
||||||
const teamMetadata = teamMetadataSchema.parse(team?.metadata);
|
|
||||||
|
|
||||||
if (IS_CALCOM && (!teamMetadata?.isOrganization || !!team.parentId)) {
|
|
||||||
const checkUsername = await checkPremiumUsername(username);
|
|
||||||
if (checkUsername.premium) {
|
|
||||||
// This signup page is ONLY meant for team invites and local setup. Not for every day users.
|
|
||||||
// In singup redesign/refactor coming up @sean will tackle this to make them the same API/page instead of two.
|
|
||||||
return res.status(422).json({
|
|
||||||
message: "Sign up from https://cal.com/signup to claim your premium username",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Identify the org id in an org context signup, either the invited team is an org
|
|
||||||
// or has a parentId, otherwise parentId will be null, making orgId null
|
|
||||||
const orgId = teamMetadata?.isOrganization ? team.id : team.parentId;
|
|
||||||
|
|
||||||
const user = await prisma.user.upsert({
|
|
||||||
where: { email: userEmail },
|
|
||||||
update: {
|
|
||||||
username,
|
|
||||||
password: hashedPassword,
|
|
||||||
emailVerified: new Date(Date.now()),
|
|
||||||
identityProvider: IdentityProvider.CAL,
|
|
||||||
organizationId: orgId,
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
username,
|
|
||||||
email: userEmail,
|
|
||||||
password: hashedPassword,
|
|
||||||
identityProvider: IdentityProvider.CAL,
|
|
||||||
organizationId: orgId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const membership = await prisma.membership.upsert({
|
|
||||||
where: {
|
|
||||||
userId_teamId: { userId: user.id, teamId: team.id },
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
accepted: true,
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
userId: user.id,
|
|
||||||
teamId: team.id,
|
|
||||||
accepted: true,
|
|
||||||
role: MembershipRole.MEMBER,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
closeComUpsertTeamUser(team, user, membership.role);
|
|
||||||
|
|
||||||
// Accept any child team invites for orgs and create a membership for the org itself
|
|
||||||
if (team.parentId) {
|
|
||||||
// Create (when invite link is used) or Update (when regular email invitation is used) membership for the organization itself
|
|
||||||
await prisma.membership.upsert({
|
|
||||||
where: {
|
|
||||||
userId_teamId: { userId: user.id, teamId: team.parentId },
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
accepted: true,
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
userId: user.id,
|
|
||||||
teamId: team.parentId,
|
|
||||||
accepted: true,
|
|
||||||
role: MembershipRole.MEMBER,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// We do a membership update twice so we can join the ORG invite if the user is invited to a team witin a ORG
|
|
||||||
await prisma.membership.updateMany({
|
|
||||||
where: {
|
|
||||||
userId: user.id,
|
|
||||||
team: {
|
|
||||||
id: team.parentId,
|
|
||||||
},
|
|
||||||
accepted: false,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
accepted: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Join any other invites
|
|
||||||
await prisma.membership.updateMany({
|
|
||||||
where: {
|
|
||||||
userId: user.id,
|
|
||||||
team: {
|
|
||||||
parentId: team.parentId,
|
|
||||||
},
|
|
||||||
accepted: false,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
accepted: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cleanup token after use
|
|
||||||
await prisma.verificationToken.delete({
|
|
||||||
where: {
|
|
||||||
id: foundToken.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
if (IS_CALCOM) {
|
|
||||||
const checkUsername = await checkPremiumUsername(username);
|
|
||||||
if (checkUsername.premium) {
|
|
||||||
res.status(422).json({
|
|
||||||
message: "Sign up from https://cal.com/signup to claim your premium username",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await prisma.user.upsert({
|
|
||||||
where: { email: userEmail },
|
|
||||||
update: {
|
|
||||||
username,
|
|
||||||
password: hashedPassword,
|
|
||||||
emailVerified: new Date(Date.now()),
|
|
||||||
identityProvider: IdentityProvider.CAL,
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
username,
|
|
||||||
email: userEmail,
|
|
||||||
password: hashedPassword,
|
|
||||||
identityProvider: IdentityProvider.CAL,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await sendEmailVerification({
|
|
||||||
email: userEmail,
|
|
||||||
username,
|
|
||||||
language,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
res.status(201).json({ message: "Created user" });
|
function ensureReqIsPost(req: RequestWithUsernameStatus) {
|
||||||
|
if (req.method !== "POST") {
|
||||||
|
throw new HttpError({
|
||||||
|
statusCode: 405,
|
||||||
|
message: "Method not allowed",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function handler(req: RequestWithUsernameStatus, res: NextApiResponse) {
|
||||||
|
// Use a try catch instead of returning res every time
|
||||||
|
try {
|
||||||
|
ensureReqIsPost(req);
|
||||||
|
ensureSignupIsEnabled();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Im not sure its worth merging these two handlers. They are different enough to be separate.
|
||||||
|
* Calcom handles things like creating a stripe customer - which we don't need to do for self hosted.
|
||||||
|
* It also handles things like premium username.
|
||||||
|
* TODO: (SEAN) - Extract a lot of the logic from calcomHandler into a separate file and import it into both handlers.
|
||||||
|
* @zomars: We need to be able to test this with E2E. They way it's done RN it will never run on CI.
|
||||||
|
*/
|
||||||
|
if (IS_PREMIUM_USERNAME_ENABLED) {
|
||||||
|
return await calcomSignupHandler(req, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await selfHostedSignupHandler(req, res);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof HttpError) {
|
||||||
|
return res.status(e.statusCode).json({ message: e.message });
|
||||||
|
}
|
||||||
|
logger.error(e);
|
||||||
|
return res.status(500).json({ message: "Internal server error" });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { AVATAR_FALLBACK } from "@calcom/lib/constants";
|
||||||
|
import prisma from "@calcom/prisma";
|
||||||
|
|
||||||
|
const querySchema = z.object({
|
||||||
|
uuid: z.string().transform((objectKey) => objectKey.split(".")[0]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleValidationError = (res: NextApiResponse, error: z.ZodError): void => {
|
||||||
|
const errors = error.errors.map((err) => ({
|
||||||
|
path: err.path.join("."),
|
||||||
|
errorCode: `error.validation.${err.code}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.status(400).json({
|
||||||
|
message: "VALIDATION_ERROR",
|
||||||
|
errors,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
const result = querySchema.safeParse(req.query);
|
||||||
|
if (!result.success) {
|
||||||
|
return handleValidationError(res, result.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { uuid: objectKey } = result.data;
|
||||||
|
|
||||||
|
let img;
|
||||||
|
try {
|
||||||
|
const { data } = await prisma.avatar.findUniqueOrThrow({
|
||||||
|
where: {
|
||||||
|
objectKey,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
data: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
img = data;
|
||||||
|
} catch (e) {
|
||||||
|
// If anything goes wrong or avatar is not found, use default avatar
|
||||||
|
res.writeHead(302, {
|
||||||
|
Location: AVATAR_FALLBACK,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoded = img.toString().replace("data:image/png;base64,", "").replace("data:image/jpeg;base64,", "");
|
||||||
|
const imageResp = Buffer.from(decoded, "base64");
|
||||||
|
|
||||||
|
res.writeHead(200, {
|
||||||
|
"Content-Type": "image/png",
|
||||||
|
"Content-Length": imageResp.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.end(imageResp);
|
||||||
|
}
|
||||||
@@ -2,9 +2,18 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
|||||||
|
|
||||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||||
import handleNewBooking from "@calcom/features/bookings/lib/handleNewBooking";
|
import handleNewBooking from "@calcom/features/bookings/lib/handleNewBooking";
|
||||||
|
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||||
|
import getIP from "@calcom/lib/getIP";
|
||||||
import { defaultResponder } from "@calcom/lib/server";
|
import { defaultResponder } from "@calcom/lib/server";
|
||||||
|
|
||||||
async function handler(req: NextApiRequest & { userId?: number }, res: NextApiResponse) {
|
async function handler(req: NextApiRequest & { userId?: number }, res: NextApiResponse) {
|
||||||
|
const userIp = getIP(req);
|
||||||
|
|
||||||
|
await checkRateLimitAndThrowError({
|
||||||
|
rateLimitingType: "core",
|
||||||
|
identifier: userIp,
|
||||||
|
});
|
||||||
|
|
||||||
const session = await getServerSession({ req, res });
|
const session = await getServerSession({ req, res });
|
||||||
/* To mimic API behavior and comply with types */
|
/* To mimic API behavior and comply with types */
|
||||||
req.userId = session?.user?.id || -1;
|
req.userId = session?.user?.id || -1;
|
||||||
|
|||||||
@@ -3,39 +3,49 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
|||||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||||
import handleNewBooking from "@calcom/features/bookings/lib/handleNewBooking";
|
import handleNewBooking from "@calcom/features/bookings/lib/handleNewBooking";
|
||||||
import type { BookingResponse, RecurringBookingCreateBody } from "@calcom/features/bookings/types";
|
import type { BookingResponse, RecurringBookingCreateBody } from "@calcom/features/bookings/types";
|
||||||
|
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||||
|
import getIP from "@calcom/lib/getIP";
|
||||||
import { defaultResponder } from "@calcom/lib/server";
|
import { defaultResponder } from "@calcom/lib/server";
|
||||||
import type { AppsStatus } from "@calcom/types/Calendar";
|
import type { AppsStatus } from "@calcom/types/Calendar";
|
||||||
|
|
||||||
// @TODO: Didn't look at the contents of this function in order to not break old booking page.
|
// @TODO: Didn't look at the contents of this function in order to not break old booking page.
|
||||||
|
|
||||||
async function handler(req: NextApiRequest & { userId?: number }, res: NextApiResponse) {
|
async function handler(req: NextApiRequest & { userId?: number }, res: NextApiResponse) {
|
||||||
|
const userIp = getIP(req);
|
||||||
|
|
||||||
|
await checkRateLimitAndThrowError({
|
||||||
|
rateLimitingType: "core",
|
||||||
|
identifier: userIp,
|
||||||
|
});
|
||||||
const data: RecurringBookingCreateBody[] = req.body;
|
const data: RecurringBookingCreateBody[] = req.body;
|
||||||
const session = await getServerSession({ req, res });
|
const session = await getServerSession({ req, res });
|
||||||
const createdBookings: BookingResponse[] = [];
|
const createdBookings: BookingResponse[] = [];
|
||||||
const allRecurringDates: string[] = data.map((booking) => booking.start);
|
const allRecurringDates: string[] = data.map((booking) => booking.start);
|
||||||
let appsStatus: AppsStatus[] | undefined = undefined;
|
const appsStatus: AppsStatus[] | undefined = undefined;
|
||||||
|
|
||||||
/* To mimic API behavior and comply with types */
|
/* To mimic API behavior and comply with types */
|
||||||
req.userId = session?.user?.id || -1;
|
req.userId = session?.user?.id || -1;
|
||||||
|
const numSlotsToCheckForAvailability = 2;
|
||||||
|
|
||||||
// Reversing to accumulate results for noEmail instances first, to then lastly, create the
|
for (let key = 0; key < data.length; key++) {
|
||||||
// emailed booking taking into account accumulated results to send app status accurately
|
|
||||||
for (let key = data.length - 1; key >= 0; key--) {
|
|
||||||
const booking = data[key];
|
const booking = data[key];
|
||||||
if (key === 0) {
|
// Disable AppStatus in Recurring Booking Email as it requires us to iterate backwards to be able to compute the AppsStatus for all the bookings except the very first slot and then send that slot's email with statuses
|
||||||
const calcAppsStatus: { [key: string]: AppsStatus } = createdBookings
|
// It is also doubtful that how useful is to have the AppsStatus of all the bookings in the email.
|
||||||
.flatMap((book) => (book.appsStatus !== undefined ? book.appsStatus : []))
|
// It is more important to iterate forward and check for conflicts for only first few bookings defined by 'numSlotsToCheckForAvailability'
|
||||||
.reduce((prev, curr) => {
|
// if (key === 0) {
|
||||||
if (prev[curr.type]) {
|
// const calcAppsStatus: { [key: string]: AppsStatus } = createdBookings
|
||||||
prev[curr.type].failures += curr.failures;
|
// .flatMap((book) => (book.appsStatus !== undefined ? book.appsStatus : []))
|
||||||
prev[curr.type].success += curr.success;
|
// .reduce((prev, curr) => {
|
||||||
} else {
|
// if (prev[curr.type]) {
|
||||||
prev[curr.type] = curr;
|
// prev[curr.type].failures += curr.failures;
|
||||||
}
|
// prev[curr.type].success += curr.success;
|
||||||
return prev;
|
// } else {
|
||||||
}, {} as { [key: string]: AppsStatus });
|
// prev[curr.type] = curr;
|
||||||
appsStatus = Object.values(calcAppsStatus);
|
// }
|
||||||
}
|
// return prev;
|
||||||
|
// }, {} as { [key: string]: AppsStatus });
|
||||||
|
// appsStatus = Object.values(calcAppsStatus);
|
||||||
|
// }
|
||||||
|
|
||||||
const recurringEventReq: NextApiRequest & { userId?: number } = req;
|
const recurringEventReq: NextApiRequest & { userId?: number } = req;
|
||||||
|
|
||||||
@@ -49,6 +59,7 @@ async function handler(req: NextApiRequest & { userId?: number }, res: NextApiRe
|
|||||||
|
|
||||||
const eachRecurringBooking = await handleNewBooking(recurringEventReq, {
|
const eachRecurringBooking = await handleNewBooking(recurringEventReq, {
|
||||||
isNotAnApiCall: true,
|
isNotAnApiCall: true,
|
||||||
|
skipAvailabilityCheck: key >= numSlotsToCheckForAvailability,
|
||||||
});
|
});
|
||||||
|
|
||||||
createdBookings.push(eachRecurringBooking);
|
createdBookings.push(eachRecurringBooking);
|
||||||
@@ -56,4 +67,6 @@ async function handler(req: NextApiRequest & { userId?: number }, res: NextApiRe
|
|||||||
return createdBookings;
|
return createdBookings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const handleRecurringEventBooking = handler;
|
||||||
|
|
||||||
export default defaultResponder(handler);
|
export default defaultResponder(handler);
|
||||||
@@ -73,7 +73,8 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
|||||||
redirectUrl = handler.redirect?.url || getInstalledAppPath(handler);
|
redirectUrl = handler.redirect?.url || getInstalledAppPath(handler);
|
||||||
res.json({ url: redirectUrl, newTab: handler.redirect?.newTab });
|
res.json({ url: redirectUrl, newTab: handler.redirect?.newTab });
|
||||||
}
|
}
|
||||||
return res.status(200);
|
if (!res.writableEnded) return res.status(200);
|
||||||
|
return res;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
if (error instanceof HttpError) {
|
if (error instanceof HttpError) {
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { buffer } from "micro";
|
||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import type Stripe from "stripe";
|
||||||
|
|
||||||
|
import stripe from "@calcom/app-store/stripepayment/lib/server";
|
||||||
|
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||||
|
import { getErrorFromUnknown } from "@calcom/lib/errors";
|
||||||
|
import { HttpError as HttpCode } from "@calcom/lib/http-error";
|
||||||
|
import prisma from "@calcom/prisma";
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
api: {
|
||||||
|
bodyParser: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// This file is a catch-all for any integration related subscription/paid app.
|
||||||
|
|
||||||
|
const handleSubscriptionUpdate = async (event: Stripe.Event) => {
|
||||||
|
const subscription = event.data.object as Stripe.Subscription;
|
||||||
|
if (!subscription.id) throw new HttpCode({ statusCode: 400, message: "Subscription ID not found" });
|
||||||
|
|
||||||
|
const app = await prisma.credential.findFirst({
|
||||||
|
where: {
|
||||||
|
subscriptionId: subscription.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!app) {
|
||||||
|
throw new HttpCode({ statusCode: 202, message: "Received and discarded" });
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.credential.update({
|
||||||
|
where: {
|
||||||
|
id: app.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
paymentStatus: subscription.status,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubscriptionDeleted = async (event: Stripe.Event) => {
|
||||||
|
const subscription = event.data.object as Stripe.Subscription;
|
||||||
|
if (!subscription.id) throw new HttpCode({ statusCode: 400, message: "Subscription ID not found" });
|
||||||
|
|
||||||
|
const app = await prisma.credential.findFirst({
|
||||||
|
where: {
|
||||||
|
subscriptionId: subscription.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!app) {
|
||||||
|
throw new HttpCode({ statusCode: 202, message: "Received and discarded" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// should we delete the credential here rather than marking as inactive?
|
||||||
|
await prisma.credential.update({
|
||||||
|
where: {
|
||||||
|
id: app.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
paymentStatus: "inactive",
|
||||||
|
billingCycleStart: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
type WebhookHandler = (event: Stripe.Event) => Promise<void>;
|
||||||
|
|
||||||
|
const webhookHandlers: Record<string, WebhookHandler | undefined> = {
|
||||||
|
"customer.subscription.updated": handleSubscriptionUpdate,
|
||||||
|
"customer.subscription.deleted": handleSubscriptionDeleted,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
try {
|
||||||
|
if (req.method !== "POST") {
|
||||||
|
throw new HttpCode({ statusCode: 405, message: "Method Not Allowed" });
|
||||||
|
}
|
||||||
|
const sig = req.headers["stripe-signature"];
|
||||||
|
if (!sig) {
|
||||||
|
throw new HttpCode({ statusCode: 400, message: "Missing stripe-signature" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!process.env.STRIPE_WEBHOOK_SECRET_APPS) {
|
||||||
|
throw new HttpCode({ statusCode: 500, message: "Missing process.env.STRIPE_WEBHOOK_SECRET_APPS" });
|
||||||
|
}
|
||||||
|
const requestBuffer = await buffer(req);
|
||||||
|
const payload = requestBuffer.toString();
|
||||||
|
|
||||||
|
const event = stripe.webhooks.constructEvent(payload, sig, process.env.STRIPE_WEBHOOK_SECRET_APPS);
|
||||||
|
|
||||||
|
const handler = webhookHandlers[event.type];
|
||||||
|
if (handler) {
|
||||||
|
await handler(event);
|
||||||
|
} else {
|
||||||
|
/** Not really an error, just letting Stripe know that the webhook was received but unhandled */
|
||||||
|
throw new HttpCode({
|
||||||
|
statusCode: 202,
|
||||||
|
message: `Unhandled Stripe Webhook event type ${event.type}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (_err) {
|
||||||
|
const err = getErrorFromUnknown(_err);
|
||||||
|
console.error(`Webhook Error: ${err.message}`);
|
||||||
|
res.status(err.statusCode ?? 500).send({
|
||||||
|
message: err.message,
|
||||||
|
stack: IS_PRODUCTION ? undefined : err.stack,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return a response to acknowledge receipt of the event
|
||||||
|
res.json({ received: true });
|
||||||
|
}
|
||||||
@@ -105,8 +105,6 @@ async function getIdentityData(req: NextApiRequest) {
|
|||||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const identity = await getIdentityData(req);
|
const identity = await getIdentityData(req);
|
||||||
const img = identity?.avatar;
|
const img = identity?.avatar;
|
||||||
// We cache for one day
|
|
||||||
res.setHeader("Cache-Control", "s-maxage=86400, stale-while-revalidate=60");
|
|
||||||
// If image isn't set or links to this route itself, use default avatar
|
// If image isn't set or links to this route itself, use default avatar
|
||||||
if (!img) {
|
if (!img) {
|
||||||
if (identity?.org) {
|
if (identity?.org) {
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ function SingleAppPage(props: inferSSRProps<typeof getStaticProps>) {
|
|||||||
isTemplate={data.isTemplate}
|
isTemplate={data.isTemplate}
|
||||||
dependencies={data.dependencies}
|
dependencies={data.dependencies}
|
||||||
concurrentMeetings={data.concurrentMeetings}
|
concurrentMeetings={data.concurrentMeetings}
|
||||||
|
paid={data.paid}
|
||||||
// tos="https://zoom.us/terms"
|
// tos="https://zoom.us/terms"
|
||||||
// privacy="https://zoom.us/privacy"
|
// privacy="https://zoom.us/privacy"
|
||||||
body={
|
body={
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import type { InferGetServerSidePropsType } from "next";
|
import type { InferGetServerSidePropsType } from "next";
|
||||||
import { useSession } from "next-auth/react";
|
import { useSession } from "next-auth/react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
import { AppSetupPage } from "@calcom/app-store/_pages/setup";
|
import { AppSetupPage } from "@calcom/app-store/_pages/setup";
|
||||||
import { getServerSideProps } from "@calcom/app-store/_pages/setup/_getServerSideProps";
|
import { getServerSideProps } from "@calcom/app-store/_pages/setup/_getServerSideProps";
|
||||||
|
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||||
import { HeadSeo } from "@calcom/ui";
|
import { HeadSeo } from "@calcom/ui";
|
||||||
|
|
||||||
import PageWrapper from "@components/PageWrapper";
|
import PageWrapper from "@components/PageWrapper";
|
||||||
|
|
||||||
export default function SetupInformation(props: InferGetServerSidePropsType<typeof getServerSideProps>) {
|
export default function SetupInformation(props: InferGetServerSidePropsType<typeof getServerSideProps>) {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useCompatSearchParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const slug = searchParams?.get("slug") as string;
|
const slug = searchParams?.get("slug") as string;
|
||||||
const { status } = useSession();
|
const { status } = useSession();
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { Prisma } from "@prisma/client";
|
import { Prisma } from "@prisma/client";
|
||||||
import type { GetStaticPropsContext, InferGetStaticPropsType } from "next";
|
import type { GetStaticPropsContext, InferGetStaticPropsType } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useSearchParams } from "next/navigation";
|
|
||||||
|
|
||||||
import { getAppRegistry } from "@calcom/app-store/_appRegistry";
|
import { getAppRegistry } from "@calcom/app-store/_appRegistry";
|
||||||
import Shell from "@calcom/features/shell/Shell";
|
import Shell from "@calcom/features/shell/Shell";
|
||||||
|
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
import { AppCategories } from "@calcom/prisma/enums";
|
import { AppCategories } from "@calcom/prisma/enums";
|
||||||
@@ -13,7 +13,7 @@ import { AppCard, SkeletonText } from "@calcom/ui";
|
|||||||
import PageWrapper from "@components/PageWrapper";
|
import PageWrapper from "@components/PageWrapper";
|
||||||
|
|
||||||
export default function Apps({ apps }: InferGetStaticPropsType<typeof getStaticProps>) {
|
export default function Apps({ apps }: InferGetStaticPropsType<typeof getStaticProps>) {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useCompatSearchParams();
|
||||||
const { t, isLocaleReady } = useLocale();
|
const { t, isLocaleReady } = useLocale();
|
||||||
const category = searchParams?.get("category");
|
const category = searchParams?.get("category");
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useSearchParams } from "next/navigation";
|
|
||||||
import { useReducer } from "react";
|
import { useReducer } from "react";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import DisconnectIntegrationModal from "@calcom/features/apps/components/DisconnectIntegrationModal";
|
import DisconnectIntegrationModal from "@calcom/features/apps/components/DisconnectIntegrationModal";
|
||||||
|
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { AppCategories } from "@calcom/prisma/enums";
|
import { AppCategories } from "@calcom/prisma/enums";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
@@ -122,7 +122,7 @@ type ModalState = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function InstalledApps() {
|
export default function InstalledApps() {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useCompatSearchParams();
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const category = searchParams?.get("category") as querySchemaType["category"];
|
const category = searchParams?.get("category") as querySchemaType["category"];
|
||||||
const categoryList: AppCategories[] = Object.values(AppCategories).filter((category) => {
|
const categoryList: AppCategories[] = Object.values(AppCategories).filter((category) => {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { GetStaticPropsContext } from "next";
|
import type { GetStaticPropsContext } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useSearchParams } from "next/navigation";
|
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
|
|
||||||
|
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { Button } from "@calcom/ui";
|
import { Button } from "@calcom/ui";
|
||||||
import { X } from "@calcom/ui/components/icon";
|
import { X } from "@calcom/ui/components/icon";
|
||||||
@@ -18,7 +18,7 @@ const querySchema = z.object({
|
|||||||
|
|
||||||
export default function Error() {
|
export default function Error() {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useCompatSearchParams();
|
||||||
const { error } = querySchema.parse(searchParams);
|
const { error } = querySchema.parse(searchParams);
|
||||||
const isTokenVerificationError = error?.toLowerCase() === "verification";
|
const isTokenVerificationError = error?.toLowerCase() === "verification";
|
||||||
const errorMsg = isTokenVerificationError ? t("token_invalid_expired") : t("error_during_login");
|
const errorMsg = isTokenVerificationError ? t("token_invalid_expired") : t("error_during_login");
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { jwtVerify } from "jose";
|
|||||||
import type { GetServerSidePropsContext } from "next";
|
import type { GetServerSidePropsContext } from "next";
|
||||||
import { getCsrfToken, signIn } from "next-auth/react";
|
import { getCsrfToken, signIn } from "next-auth/react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { FormProvider, useForm } from "react-hook-form";
|
import { FormProvider, useForm } from "react-hook-form";
|
||||||
@@ -17,6 +17,7 @@ import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
|||||||
import { isSAMLLoginEnabled, samlProductID, samlTenantID } from "@calcom/features/ee/sso/lib/saml";
|
import { isSAMLLoginEnabled, samlProductID, samlTenantID } from "@calcom/features/ee/sso/lib/saml";
|
||||||
import { WEBAPP_URL, WEBSITE_URL, HOSTED_CAL_FEATURES } from "@calcom/lib/constants";
|
import { WEBAPP_URL, WEBSITE_URL, HOSTED_CAL_FEATURES } from "@calcom/lib/constants";
|
||||||
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
|
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
|
||||||
|
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
@@ -53,7 +54,7 @@ export default function Login({
|
|||||||
totpEmail,
|
totpEmail,
|
||||||
}: // eslint-disable-next-line @typescript-eslint/ban-types
|
}: // eslint-disable-next-line @typescript-eslint/ban-types
|
||||||
inferSSRProps<typeof _getServerSideProps> & WithNonceProps<{}>) {
|
inferSSRProps<typeof _getServerSideProps> & WithNonceProps<{}>) {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useCompatSearchParams();
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const formSchema = z
|
const formSchema = z
|
||||||
@@ -97,9 +98,9 @@ inferSSRProps<typeof _getServerSideProps> & WithNonceProps<{}>) {
|
|||||||
callbackUrl = safeCallbackUrl || "";
|
callbackUrl = safeCallbackUrl || "";
|
||||||
|
|
||||||
const LoginFooter = (
|
const LoginFooter = (
|
||||||
<a href={`${WEBSITE_URL}/signup`} className="text-brand-500 font-medium">
|
<Link href={`${WEBSITE_URL}/signup`} className="text-brand-500 font-medium">
|
||||||
{t("dont_have_an_account")}
|
{t("dont_have_an_account")}
|
||||||
</a>
|
</Link>
|
||||||
);
|
);
|
||||||
|
|
||||||
const TwoFactorFooter = (
|
const TwoFactorFooter = (
|
||||||
@@ -278,7 +279,7 @@ inferSSRProps<typeof _getServerSideProps> & WithNonceProps<{}>) {
|
|||||||
|
|
||||||
// TODO: Once we understand how to retrieve prop types automatically from getServerSideProps, remove this temporary variable
|
// TODO: Once we understand how to retrieve prop types automatically from getServerSideProps, remove this temporary variable
|
||||||
const _getServerSideProps = async function getServerSideProps(context: GetServerSidePropsContext) {
|
const _getServerSideProps = async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||||
const { req, res } = context;
|
const { req, res, query } = context;
|
||||||
|
|
||||||
const session = await getServerSession({ req, res });
|
const session = await getServerSession({ req, res });
|
||||||
const ssr = await ssrInit(context);
|
const ssr = await ssrInit(context);
|
||||||
@@ -318,6 +319,24 @@ const _getServerSideProps = async function getServerSideProps(context: GetServer
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (session) {
|
if (session) {
|
||||||
|
const { callbackUrl } = query;
|
||||||
|
|
||||||
|
if (callbackUrl) {
|
||||||
|
try {
|
||||||
|
const destination = getSafeRedirectUrl(callbackUrl as string);
|
||||||
|
if (destination) {
|
||||||
|
return {
|
||||||
|
redirect: {
|
||||||
|
destination,
|
||||||
|
permanent: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
destination: "/",
|
destination: "/",
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useSession } from "next-auth/react";
|
import { useSession } from "next-auth/react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useSearchParams } from "next/navigation";
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
import { APP_NAME } from "@calcom/lib/constants";
|
import { APP_NAME } from "@calcom/lib/constants";
|
||||||
|
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import { Avatar, Button, Select } from "@calcom/ui";
|
import { Avatar, Button, Select } from "@calcom/ui";
|
||||||
@@ -16,7 +16,7 @@ export default function Authorize() {
|
|||||||
const { status } = useSession();
|
const { status } = useSession();
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useCompatSearchParams();
|
||||||
|
|
||||||
const client_id = searchParams?.get("client_id") as string;
|
const client_id = searchParams?.get("client_id") as string;
|
||||||
const state = searchParams?.get("state") as string;
|
const state = searchParams?.get("state") as string;
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { signIn } from "next-auth/react";
|
import { signIn } from "next-auth/react";
|
||||||
import { useSearchParams } from "next/navigation";
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||||
|
|
||||||
import PageWrapper from "@components/PageWrapper";
|
import PageWrapper from "@components/PageWrapper";
|
||||||
|
|
||||||
// To handle the IdP initiated login flow callback
|
// To handle the IdP initiated login flow callback
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useCompatSearchParams();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const code = searchParams?.get("code");
|
const code = searchParams?.get("code");
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import type { GetServerSidePropsContext } from "next";
|
import type { GetServerSidePropsContext } from "next";
|
||||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import AdminAppsList from "@calcom/features/apps/AdminAppsList";
|
import AdminAppsList from "@calcom/features/apps/AdminAppsList";
|
||||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||||
import { getDeploymentKey } from "@calcom/features/ee/deployment/lib/getDeploymentKey";
|
import { getDeploymentKey } from "@calcom/features/ee/deployment/lib/getDeploymentKey";
|
||||||
import { APP_NAME } from "@calcom/lib/constants";
|
import { APP_NAME } from "@calcom/lib/constants";
|
||||||
|
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
import { UserPermissionRole } from "@calcom/prisma/enums";
|
import { UserPermissionRole } from "@calcom/prisma/enums";
|
||||||
@@ -21,7 +22,7 @@ import { ssrInit } from "@server/lib/ssr";
|
|||||||
|
|
||||||
function useSetStep() {
|
function useSetStep() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useCompatSearchParams();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const setStep = (newStep = 1) => {
|
const setStep = (newStep = 1) => {
|
||||||
const _searchParams = new URLSearchParams(searchParams ?? undefined);
|
const _searchParams = new URLSearchParams(searchParams ?? undefined);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { GetServerSidePropsContext } from "next";
|
import type { GetServerSidePropsContext } from "next";
|
||||||
import { signIn } from "next-auth/react";
|
import { signIn } from "next-auth/react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
|
||||||
import { getPremiumMonthlyPlanPriceId } from "@calcom/app-store/stripepayment/lib/utils";
|
import { getPremiumMonthlyPlanPriceId } from "@calcom/app-store/stripepayment/lib/utils";
|
||||||
@@ -9,6 +9,8 @@ import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomain
|
|||||||
import stripe from "@calcom/features/ee/payments/server/stripe";
|
import stripe from "@calcom/features/ee/payments/server/stripe";
|
||||||
import { hostedCal, isSAMLLoginEnabled, samlProductID, samlTenantID } from "@calcom/features/ee/sso/lib/saml";
|
import { hostedCal, isSAMLLoginEnabled, samlProductID, samlTenantID } from "@calcom/features/ee/sso/lib/saml";
|
||||||
import { ssoTenantProduct } from "@calcom/features/ee/sso/lib/sso";
|
import { ssoTenantProduct } from "@calcom/features/ee/sso/lib/sso";
|
||||||
|
import { IS_PREMIUM_USERNAME_ENABLED } from "@calcom/lib/constants";
|
||||||
|
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||||
import { checkUsername } from "@calcom/lib/server/checkUsername";
|
import { checkUsername } from "@calcom/lib/server/checkUsername";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
|
|
||||||
@@ -22,7 +24,7 @@ import { ssrInit } from "@server/lib/ssr";
|
|||||||
export type SSOProviderPageProps = inferSSRProps<typeof getServerSideProps>;
|
export type SSOProviderPageProps = inferSSRProps<typeof getServerSideProps>;
|
||||||
|
|
||||||
export default function Provider(props: SSOProviderPageProps) {
|
export default function Provider(props: SSOProviderPageProps) {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useCompatSearchParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -71,7 +73,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||||||
// Validating if username is Premium, while this is true an email its required for stripe user confirmation
|
// Validating if username is Premium, while this is true an email its required for stripe user confirmation
|
||||||
if (usernameParam && session.user.email) {
|
if (usernameParam && session.user.email) {
|
||||||
const availability = await checkUsername(usernameParam, currentOrgDomain);
|
const availability = await checkUsername(usernameParam, currentOrgDomain);
|
||||||
if (availability.available && availability.premium) {
|
if (availability.available && availability.premium && IS_PREMIUM_USERNAME_ENABLED) {
|
||||||
const stripePremiumUrl = await getStripePremiumUsernameUrl({
|
const stripePremiumUrl = await getStripePremiumUsernameUrl({
|
||||||
userEmail: session.user.email,
|
userEmail: session.user.email,
|
||||||
username: usernameParam,
|
username: usernameParam,
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { signIn } from "next-auth/react";
|
import { signIn } from "next-auth/react";
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
|
|
||||||
import { APP_NAME, WEBAPP_URL } from "@calcom/lib/constants";
|
import { APP_NAME, WEBAPP_URL } from "@calcom/lib/constants";
|
||||||
|
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||||
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
|
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import { Button, showToast } from "@calcom/ui";
|
import { Button, showToast } from "@calcom/ui";
|
||||||
@@ -54,7 +55,7 @@ const querySchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export default function Verify() {
|
export default function Verify() {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useCompatSearchParams();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const routerQuery = useRouterQuery();
|
const routerQuery = useRouterQuery();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Controller, useFieldArray, useForm } from "react-hook-form";
|
import { Controller, useFieldArray, useForm } from "react-hook-form";
|
||||||
|
|
||||||
@@ -8,6 +8,7 @@ import Schedule from "@calcom/features/schedules/components/Schedule";
|
|||||||
import Shell from "@calcom/features/shell/Shell";
|
import Shell from "@calcom/features/shell/Shell";
|
||||||
import { classNames } from "@calcom/lib";
|
import { classNames } from "@calcom/lib";
|
||||||
import { availabilityAsString } from "@calcom/lib/availability";
|
import { availabilityAsString } from "@calcom/lib/availability";
|
||||||
|
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { HttpError } from "@calcom/lib/http-error";
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
@@ -83,7 +84,7 @@ const DateOverride = ({ workingHours }: { workingHours: WorkingHours[] }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function Availability() {
|
export default function Availability() {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useCompatSearchParams();
|
||||||
const { t, i18n } = useLocale();
|
const { t, i18n } = useLocale();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const utils = trpc.useContext();
|
const utils = trpc.useContext();
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||||
import { useRouter, useSearchParams, usePathname } from "next/navigation";
|
import { useRouter, usePathname } from "next/navigation";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
|
|
||||||
import { getLayout } from "@calcom/features/MainLayout";
|
import { getLayout } from "@calcom/features/MainLayout";
|
||||||
import { NewScheduleButton, ScheduleListItem } from "@calcom/features/schedules";
|
import { NewScheduleButton, ScheduleListItem } from "@calcom/features/schedules";
|
||||||
import { ShellMain } from "@calcom/features/shell/Shell";
|
import { ShellMain } from "@calcom/features/shell/Shell";
|
||||||
import { AvailabilitySliderTable } from "@calcom/features/timezone-buddy/components/AvailabilitySliderTable";
|
import { AvailabilitySliderTable } from "@calcom/features/timezone-buddy/components/AvailabilitySliderTable";
|
||||||
|
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { HttpError } from "@calcom/lib/http-error";
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||||
@@ -131,7 +132,7 @@ const WithQuery = withQuery(trpc.viewer.availability.list as any);
|
|||||||
|
|
||||||
export default function AvailabilityPage() {
|
export default function AvailabilityPage() {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useCompatSearchParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
|||||||
@@ -1,139 +1,20 @@
|
|||||||
import dayjs from "@calcom/dayjs";
|
import { Troubleshooter } from "@calcom/features/troubleshooter/Troubleshooter";
|
||||||
import Shell from "@calcom/features/shell/Shell";
|
import { getLayout } from "@calcom/features/troubleshooter/layout";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
import { HeadSeo } from "@calcom/ui";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
|
||||||
import { SkeletonText } from "@calcom/ui";
|
|
||||||
|
|
||||||
import useRouterQuery from "@lib/hooks/useRouterQuery";
|
|
||||||
|
|
||||||
import PageWrapper from "@components/PageWrapper";
|
import PageWrapper from "@components/PageWrapper";
|
||||||
|
|
||||||
type User = RouterOutputs["viewer"]["me"];
|
function TroubleshooterPage() {
|
||||||
|
|
||||||
export interface IBusySlot {
|
|
||||||
start: string | Date;
|
|
||||||
end: string | Date;
|
|
||||||
title?: string;
|
|
||||||
source?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const AvailabilityView = ({ user }: { user: User }) => {
|
|
||||||
const { t } = useLocale();
|
|
||||||
const { date, setQuery: setSelectedDate } = useRouterQuery("date");
|
|
||||||
const selectedDate = dayjs(date);
|
|
||||||
const formattedSelectedDate = selectedDate.format("YYYY-MM-DD");
|
|
||||||
|
|
||||||
const { data, isLoading } = trpc.viewer.availability.user.useQuery(
|
|
||||||
{
|
|
||||||
username: user.username || "",
|
|
||||||
dateFrom: selectedDate.startOf("day").utc().format(),
|
|
||||||
dateTo: selectedDate.endOf("day").utc().format(),
|
|
||||||
withSource: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
enabled: !!user.username,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const overrides =
|
|
||||||
data?.dateOverrides.reduce((acc, override) => {
|
|
||||||
if (
|
|
||||||
formattedSelectedDate !== dayjs(override.start).format("YYYY-MM-DD") &&
|
|
||||||
formattedSelectedDate !== dayjs(override.end).format("YYYY-MM-DD")
|
|
||||||
)
|
|
||||||
return acc;
|
|
||||||
acc.push({ ...override, source: "Date override" });
|
|
||||||
return acc;
|
|
||||||
}, [] as IBusySlot[]) || [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-default max-w-xl overflow-hidden rounded-md shadow">
|
|
||||||
<div className="px-4 py-5 sm:p-6">
|
|
||||||
{t("overview_of_day")}{" "}
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
className="inline h-8 border-none bg-inherit p-0"
|
|
||||||
defaultValue={formattedSelectedDate}
|
|
||||||
onChange={(e) => {
|
|
||||||
if (e.target.value) setSelectedDate(e.target.value);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<small className="text-muted block">{t("hover_over_bold_times_tip")}</small>
|
|
||||||
<div className="mt-4 space-y-4">
|
|
||||||
<div className="bg-brand dark:bg-darkmodebrand overflow-hidden rounded-md">
|
|
||||||
<div className="text-brandcontrast dark:text-darkmodebrandcontrast px-4 py-2 sm:px-6">
|
|
||||||
{t("your_day_starts_at")} {convertMinsToHrsMins(user.startTime)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{(() => {
|
|
||||||
if (isLoading)
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<SkeletonText className="block h-16 w-full" />
|
|
||||||
<SkeletonText className="block h-16 w-full" />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (data && (data.busy.length > 0 || overrides.length > 0))
|
|
||||||
return [...data.busy, ...overrides]
|
|
||||||
.sort((a: IBusySlot, b: IBusySlot) => (a.start > b.start ? -1 : 1))
|
|
||||||
.map((slot: IBusySlot) => (
|
|
||||||
<div
|
|
||||||
key={dayjs(slot.start).format("HH:mm")}
|
|
||||||
className="bg-subtle overflow-hidden rounded-md"
|
|
||||||
data-testid="troubleshooter-busy-time">
|
|
||||||
<div className="text-emphasis px-4 py-5 sm:p-6">
|
|
||||||
{t("calendar_shows_busy_between")}{" "}
|
|
||||||
<span className="text-default font-medium" title={dayjs(slot.start).format("HH:mm")}>
|
|
||||||
{dayjs(slot.start).format("HH:mm")}
|
|
||||||
</span>{" "}
|
|
||||||
{t("and")}{" "}
|
|
||||||
<span className="text-default font-medium" title={dayjs(slot.end).format("HH:mm")}>
|
|
||||||
{dayjs(slot.end).format("HH:mm")}
|
|
||||||
</span>{" "}
|
|
||||||
{t("on")} {dayjs(slot.start).format("D")}{" "}
|
|
||||||
{t(dayjs(slot.start).format("MMMM").toLowerCase())} {dayjs(slot.start).format("YYYY")}
|
|
||||||
{slot.title && ` - (${slot.title})`}
|
|
||||||
{slot.source && <small>{` - (source: ${slot.source})`}</small>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
));
|
|
||||||
return (
|
|
||||||
<div className="bg-subtle overflow-hidden rounded-md">
|
|
||||||
<div className="text-emphasis px-4 py-5 sm:p-6">{t("calendar_no_busy_slots")}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
|
|
||||||
<div className="bg-brand dark:bg-darkmodebrand overflow-hidden rounded-md">
|
|
||||||
<div className="text-brandcontrast dark:text-darkmodebrandcontrast px-4 py-2 sm:px-6">
|
|
||||||
{t("your_day_ends_at")} {convertMinsToHrsMins(user.endTime)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function Troubleshoot() {
|
|
||||||
const { data, isLoading } = trpc.viewer.me.useQuery();
|
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
return (
|
return (
|
||||||
<div>
|
<>
|
||||||
<Shell heading={t("troubleshoot")} hideHeadingOnMobile subtitle={t("troubleshoot_description")}>
|
<HeadSeo title={t("troubleshoot")} description={t("troubleshoot_availability")} />
|
||||||
{!isLoading && data && <AvailabilityView user={data} />}
|
<Troubleshooter month={null} />
|
||||||
</Shell>
|
</>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Troubleshoot.PageWrapper = PageWrapper;
|
|
||||||
|
|
||||||
function convertMinsToHrsMins(mins: number) {
|
TroubleshooterPage.getLayout = getLayout;
|
||||||
const h = Math.floor(mins / 60);
|
TroubleshooterPage.PageWrapper = PageWrapper;
|
||||||
const m = mins % 60;
|
export default TroubleshooterPage;
|
||||||
const hs = h < 10 ? `0${h}` : h;
|
|
||||||
const ms = m < 10 ? `0${m}` : m;
|
|
||||||
return `${hs}:${ms}`;
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,7 @@ import { createEvent } from "ics";
|
|||||||
import type { GetServerSidePropsContext } from "next";
|
import type { GetServerSidePropsContext } from "next";
|
||||||
import { useSession } from "next-auth/react";
|
import { useSession } from "next-auth/react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { RRule } from "rrule";
|
import { RRule } from "rrule";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
} from "@calcom/lib/date-fns";
|
} from "@calcom/lib/date-fns";
|
||||||
import { getDefaultEvent } from "@calcom/lib/defaultEvents";
|
import { getDefaultEvent } from "@calcom/lib/defaultEvents";
|
||||||
import useGetBrandingColours from "@calcom/lib/getBrandColours";
|
import useGetBrandingColours from "@calcom/lib/getBrandColours";
|
||||||
|
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
|
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
|
||||||
import useTheme from "@calcom/lib/hooks/useTheme";
|
import useTheme from "@calcom/lib/hooks/useTheme";
|
||||||
@@ -98,7 +99,7 @@ export default function Success(props: SuccessProps) {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const routerQuery = useRouterQuery();
|
const routerQuery = useRouterQuery();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useCompatSearchParams();
|
||||||
const {
|
const {
|
||||||
allRemainingBookings,
|
allRemainingBookings,
|
||||||
isSuccessBookingPage,
|
isSuccessBookingPage,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import React from "react";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { WipeMyCalActionButton } from "@calcom/app-store/wipemycalother/components";
|
import { WipeMyCalActionButton } from "@calcom/app-store/wipemycalother/components";
|
||||||
|
import dayjs from "@calcom/dayjs";
|
||||||
import { getLayout } from "@calcom/features/MainLayout";
|
import { getLayout } from "@calcom/features/MainLayout";
|
||||||
import { FiltersContainer } from "@calcom/features/bookings/components/FiltersContainer";
|
import { FiltersContainer } from "@calcom/features/bookings/components/FiltersContainer";
|
||||||
import type { filterQuerySchema } from "@calcom/features/bookings/lib/useFilterQuery";
|
import type { filterQuerySchema } from "@calcom/features/bookings/lib/useFilterQuery";
|
||||||
@@ -20,6 +21,7 @@ import { Alert, Button, EmptyScreen } from "@calcom/ui";
|
|||||||
import { Calendar } from "@calcom/ui/components/icon";
|
import { Calendar } from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
import { useInViewObserver } from "@lib/hooks/useInViewObserver";
|
import { useInViewObserver } from "@lib/hooks/useInViewObserver";
|
||||||
|
import useMeQuery from "@lib/hooks/useMeQuery";
|
||||||
|
|
||||||
import PageWrapper from "@components/PageWrapper";
|
import PageWrapper from "@components/PageWrapper";
|
||||||
import BookingListItem from "@components/booking/BookingListItem";
|
import BookingListItem from "@components/booking/BookingListItem";
|
||||||
@@ -78,6 +80,7 @@ export default function Bookings() {
|
|||||||
const { data: filterQuery } = useFilterQuery();
|
const { data: filterQuery } = useFilterQuery();
|
||||||
const { status } = params ? querySchema.parse(params) : { status: "upcoming" as const };
|
const { status } = params ? querySchema.parse(params) : { status: "upcoming" as const };
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
|
const user = useMeQuery().data;
|
||||||
|
|
||||||
const query = trpc.viewer.bookings.get.useInfiniteQuery(
|
const query = trpc.viewer.bookings.get.useInfiniteQuery(
|
||||||
{
|
{
|
||||||
@@ -94,7 +97,7 @@ export default function Bookings() {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Animate page (tab) tranistions to look smoothing
|
// Animate page (tab) transitions to look smoothing
|
||||||
|
|
||||||
const buttonInView = useInViewObserver(() => {
|
const buttonInView = useInViewObserver(() => {
|
||||||
if (!query.isFetching && query.hasNextPage && query.status === "success") {
|
if (!query.isFetching && query.hasNextPage && query.status === "success") {
|
||||||
@@ -119,7 +122,10 @@ export default function Bookings() {
|
|||||||
}
|
}
|
||||||
shownBookings[booking.recurringEventId] = [booking];
|
shownBookings[booking.recurringEventId] = [booking];
|
||||||
} else if (status === "upcoming") {
|
} else if (status === "upcoming") {
|
||||||
return new Date(booking.startTime).toDateString() !== new Date().toDateString();
|
return (
|
||||||
|
dayjs(booking.startTime).tz(user?.timeZone).format("YYYY-MM-DD") !==
|
||||||
|
dayjs().tz(user?.timeZone).format("YYYY-MM-DD")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
@@ -132,7 +138,11 @@ export default function Bookings() {
|
|||||||
recurringInfoToday = page.recurringInfo.find(
|
recurringInfoToday = page.recurringInfo.find(
|
||||||
(info) => info.recurringEventId === booking.recurringEventId
|
(info) => info.recurringEventId === booking.recurringEventId
|
||||||
);
|
);
|
||||||
return new Date(booking.startTime).toDateString() === new Date().toDateString();
|
|
||||||
|
return (
|
||||||
|
dayjs(booking.startTime).tz(user?.timeZone).format("YYYY-MM-DD") ===
|
||||||
|
dayjs().tz(user?.timeZone).format("YYYY-MM-DD")
|
||||||
|
);
|
||||||
})
|
})
|
||||||
)[0] || [];
|
)[0] || [];
|
||||||
|
|
||||||
@@ -166,6 +176,12 @@ export default function Bookings() {
|
|||||||
{bookingsToday.map((booking: BookingOutput) => (
|
{bookingsToday.map((booking: BookingOutput) => (
|
||||||
<BookingListItem
|
<BookingListItem
|
||||||
key={booking.id}
|
key={booking.id}
|
||||||
|
loggedInUser={{
|
||||||
|
userId: user?.id,
|
||||||
|
userTimeZone: user?.timeZone,
|
||||||
|
userTimeFormat: user?.timeFormat,
|
||||||
|
userEmail: user?.email,
|
||||||
|
}}
|
||||||
listingStatus={status}
|
listingStatus={status}
|
||||||
recurringInfo={recurringInfoToday}
|
recurringInfo={recurringInfoToday}
|
||||||
{...booking}
|
{...booking}
|
||||||
@@ -190,6 +206,12 @@ export default function Bookings() {
|
|||||||
return (
|
return (
|
||||||
<BookingListItem
|
<BookingListItem
|
||||||
key={booking.id}
|
key={booking.id}
|
||||||
|
loggedInUser={{
|
||||||
|
userId: user?.id,
|
||||||
|
userTimeZone: user?.timeZone,
|
||||||
|
userTimeFormat: user?.timeFormat,
|
||||||
|
userEmail: user?.email,
|
||||||
|
}}
|
||||||
listingStatus={status}
|
listingStatus={status}
|
||||||
recurringInfo={recurringInfo}
|
recurringInfo={recurringInfo}
|
||||||
{...booking}
|
{...booking}
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { getLayout } from "@calcom/features/MainLayout";
|
||||||
|
import { ShellMain } from "@calcom/features/shell/Shell";
|
||||||
|
import { UpgradeTip } from "@calcom/features/tips";
|
||||||
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import { Button, ButtonGroup } from "@calcom/ui";
|
||||||
|
import { BarChart, CreditCard, Globe, Lock, Paintbrush, Users } from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
|
import PageWrapper from "@components/PageWrapper";
|
||||||
|
|
||||||
|
export default function EnterprisePage() {
|
||||||
|
const { t } = useLocale();
|
||||||
|
|
||||||
|
const features = [
|
||||||
|
{
|
||||||
|
icon: <Globe className="h-5 w-5 text-red-500" />,
|
||||||
|
title: t("branded_subdomain"),
|
||||||
|
description: t("branded_subdomain_description"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <BarChart className="h-5 w-5 text-blue-500" />,
|
||||||
|
title: t("org_insights"),
|
||||||
|
description: t("org_insights_description"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Paintbrush className="h-5 w-5 text-pink-500" />,
|
||||||
|
title: t("extensive_whitelabeling"),
|
||||||
|
description: t("extensive_whitelabeling_description"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Users className="h-5 w-5 text-orange-500" />,
|
||||||
|
title: t("unlimited_teams"),
|
||||||
|
description: t("unlimited_teams_description"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <CreditCard className="h-5 w-5 text-green-500" />,
|
||||||
|
title: t("unified_billing"),
|
||||||
|
description: t("unified_billing_description"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Lock className="h-5 w-5 text-purple-500" />,
|
||||||
|
title: t("advanced_managed_events"),
|
||||||
|
description: t("advanced_managed_events_description"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<ShellMain heading="Enterprise" subtitle={t("enterprise_description")}>
|
||||||
|
<UpgradeTip
|
||||||
|
plan="enterprise"
|
||||||
|
title={t("create_your_org")}
|
||||||
|
description={t("create_your_org_description")}
|
||||||
|
features={features}
|
||||||
|
background="/tips/enterprise"
|
||||||
|
buttons={
|
||||||
|
<div className="space-y-2 rtl:space-x-reverse sm:space-x-2">
|
||||||
|
<ButtonGroup>
|
||||||
|
<Button color="primary" href="https://i.cal.com/sales/enterprise?duration=25" target="_blank">
|
||||||
|
{t("contact_sales")}
|
||||||
|
</Button>
|
||||||
|
<Button color="minimal" href="https://cal.com/enterprise" target="_blank">
|
||||||
|
{t("learn_more")}
|
||||||
|
</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
<>Create Org</>
|
||||||
|
</UpgradeTip>
|
||||||
|
</ShellMain>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
EnterprisePage.PageWrapper = PageWrapper;
|
||||||
|
EnterprisePage.getLayout = getLayout;
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||||
import type { User } from "@prisma/client";
|
import type { User } from "@prisma/client";
|
||||||
import { Trans } from "next-i18next";
|
import { Trans } from "next-i18next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { memo, useEffect, useState } from "react";
|
import { memo, useEffect, useState } from "react";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -11,7 +13,7 @@ import { getLayout } from "@calcom/features/MainLayout";
|
|||||||
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
||||||
import useIntercom from "@calcom/features/ee/support/lib/intercom/useIntercom";
|
import useIntercom from "@calcom/features/ee/support/lib/intercom/useIntercom";
|
||||||
import { EventTypeEmbedButton, EventTypeEmbedDialog } from "@calcom/features/embed/EventTypeEmbed";
|
import { EventTypeEmbedButton, EventTypeEmbedDialog } from "@calcom/features/embed/EventTypeEmbed";
|
||||||
import { EventTypeDescriptionLazy as EventTypeDescription } from "@calcom/features/eventtypes/components";
|
import { EventTypeDescription } from "@calcom/features/eventtypes/components";
|
||||||
import CreateEventTypeDialog from "@calcom/features/eventtypes/components/CreateEventTypeDialog";
|
import CreateEventTypeDialog from "@calcom/features/eventtypes/components/CreateEventTypeDialog";
|
||||||
import { DuplicateDialog } from "@calcom/features/eventtypes/components/DuplicateDialog";
|
import { DuplicateDialog } from "@calcom/features/eventtypes/components/DuplicateDialog";
|
||||||
import { TeamsFilter } from "@calcom/features/filters/components/TeamsFilter";
|
import { TeamsFilter } from "@calcom/features/filters/components/TeamsFilter";
|
||||||
@@ -19,6 +21,7 @@ import { getTeamsFiltersFromQuery } from "@calcom/features/filters/lib/getTeamsF
|
|||||||
import { ShellMain } from "@calcom/features/shell/Shell";
|
import { ShellMain } from "@calcom/features/shell/Shell";
|
||||||
import { APP_NAME, CAL_URL, WEBAPP_URL } from "@calcom/lib/constants";
|
import { APP_NAME, CAL_URL, WEBAPP_URL } from "@calcom/lib/constants";
|
||||||
import { useBookerUrl } from "@calcom/lib/hooks/useBookerUrl";
|
import { useBookerUrl } from "@calcom/lib/hooks/useBookerUrl";
|
||||||
|
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import useMediaQuery from "@calcom/lib/hooks/useMediaQuery";
|
import useMediaQuery from "@calcom/lib/hooks/useMediaQuery";
|
||||||
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
|
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
|
||||||
@@ -208,7 +211,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
|||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useCompatSearchParams();
|
||||||
const orgBranding = useOrgBranding();
|
const orgBranding = useOrgBranding();
|
||||||
const [parent] = useAutoAnimate<HTMLUListElement>();
|
const [parent] = useAutoAnimate<HTMLUListElement>();
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
@@ -386,7 +389,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
|||||||
type.metadata?.managedEventConfig !== undefined && type.schedulingType !== SchedulingType.MANAGED;
|
type.metadata?.managedEventConfig !== undefined && type.schedulingType !== SchedulingType.MANAGED;
|
||||||
return (
|
return (
|
||||||
<li key={type.id}>
|
<li key={type.id}>
|
||||||
<div className="hover:bg-muted flex w-full items-center justify-between">
|
<div className="hover:bg-muted flex w-full items-center justify-between transition">
|
||||||
<div className="group flex w-full max-w-full items-center justify-between overflow-hidden px-4 py-4 sm:px-6">
|
<div className="group flex w-full max-w-full items-center justify-between overflow-hidden px-4 py-4 sm:px-6">
|
||||||
{!(firstItem && firstItem.id === type.id) && (
|
{!(firstItem && firstItem.id === type.id) && (
|
||||||
<ArrowButton onClick={() => moveEventType(index, -1)} arrowDirection="up" />
|
<ArrowButton onClick={() => moveEventType(index, -1)} arrowDirection="up" />
|
||||||
@@ -840,7 +843,7 @@ const Main = ({
|
|||||||
filters: ReturnType<typeof getTeamsFiltersFromQuery>;
|
filters: ReturnType<typeof getTeamsFiltersFromQuery>;
|
||||||
}) => {
|
}) => {
|
||||||
const isMobile = useMediaQuery("(max-width: 768px)");
|
const isMobile = useMediaQuery("(max-width: 768px)");
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useCompatSearchParams();
|
||||||
const orgBranding = useOrgBranding();
|
const orgBranding = useOrgBranding();
|
||||||
|
|
||||||
if (!data || status === "loading") {
|
if (!data || status === "loading") {
|
||||||
@@ -904,7 +907,7 @@ const Main = ({
|
|||||||
|
|
||||||
const EventTypesPage = () => {
|
const EventTypesPage = () => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useCompatSearchParams();
|
||||||
const { open } = useIntercom();
|
const { open } = useIntercom();
|
||||||
const { data: user } = useMeQuery();
|
const { data: user } = useMeQuery();
|
||||||
const [showProfileBanner, setShowProfileBanner] = useState(false);
|
const [showProfileBanner, setShowProfileBanner] = useState(false);
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export default function InsightsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<ShellMain heading="Insights" subtitle={t("insights_subtitle")}>
|
<ShellMain heading="Insights" subtitle={t("insights_subtitle")}>
|
||||||
<UpgradeTip
|
<UpgradeTip
|
||||||
|
plan="team"
|
||||||
title={t("make_informed_decisions")}
|
title={t("make_informed_decisions")}
|
||||||
description={t("make_informed_decisions_description")}
|
description={t("make_informed_decisions_description")}
|
||||||
features={features}
|
features={features}
|
||||||
@@ -68,17 +69,17 @@ export default function InsightsPage() {
|
|||||||
<FiltersProvider>
|
<FiltersProvider>
|
||||||
<Filters />
|
<Filters />
|
||||||
|
|
||||||
<div className="mb-4 space-y-6">
|
<div className="mb-4 space-y-4">
|
||||||
<BookingKPICards />
|
<BookingKPICards />
|
||||||
|
|
||||||
<BookingStatusLineChart />
|
<BookingStatusLineChart />
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-5 md:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<PopularEventsTable />
|
<PopularEventsTable />
|
||||||
|
|
||||||
<AverageEventDurationChart />
|
<AverageEventDurationChart />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 gap-5 md:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<MostBookedTeamMembersTable />
|
<MostBookedTeamMembersTable />
|
||||||
<LeastBookedTeamMembersTable />
|
<LeastBookedTeamMembersTable />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
|
|||||||
if (team) {
|
if (team) {
|
||||||
return GSSTeamPage({ ...ctx, query: { slug: ctx.query.user } });
|
return GSSTeamPage({ ...ctx, query: { slug: ctx.query.user } });
|
||||||
}
|
}
|
||||||
return GSSUserPage({ ...ctx, query: { user: ctx.query.user } });
|
return GSSUserPage({ ...ctx, query: { user: ctx.query.user, redirect: ctx.query.redirect } });
|
||||||
};
|
};
|
||||||
|
|
||||||
type Props = UserPageProps | TeamPageProps;
|
type Props = UserPageProps | TeamPageProps;
|
||||||
|
|||||||
@@ -250,10 +250,7 @@ const AppearanceView = ({
|
|||||||
/>
|
/>
|
||||||
{lightModeError ? (
|
{lightModeError ? (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<Alert
|
<Alert severity="warning" message={t("light_theme_contrast_error")} />
|
||||||
severity="warning"
|
|
||||||
message="Light Theme color doesn't pass contrast check. We recommend you change this colour so your buttons will be more visible."
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -282,10 +279,7 @@ const AppearanceView = ({
|
|||||||
/>
|
/>
|
||||||
{darkModeError ? (
|
{darkModeError ? (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<Alert
|
<Alert severity="warning" message={t("dark_theme_contrast_error")} />
|
||||||
severity="warning"
|
|
||||||
message="Dark Theme color doesn't pass contrast check. We recommend you change this colour so your buttons will be more visible."
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
SkeletonContainer,
|
SkeletonContainer,
|
||||||
SkeletonText,
|
SkeletonText,
|
||||||
showToast,
|
showToast,
|
||||||
|
Label,
|
||||||
} from "@calcom/ui";
|
} from "@calcom/ui";
|
||||||
import { Plus, Calendar } from "@calcom/ui/components/icon";
|
import { Plus, Calendar } from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
@@ -113,17 +114,20 @@ const CalendarsView = () => {
|
|||||||
<h2 className="text-emphasis mb-1 text-base font-bold leading-5 tracking-wide">
|
<h2 className="text-emphasis mb-1 text-base font-bold leading-5 tracking-wide">
|
||||||
{t("add_to_calendar")}
|
{t("add_to_calendar")}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-default text-sm leading-tight">{t("add_to_calendar_description")}</p>
|
<p className="text-subtle text-sm leading-tight">{t("add_to_calendar_description")}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="border-subtle flex w-full flex-col space-y-3 border border-x border-y-0 px-4 py-6 sm:px-6">
|
<div className="border-subtle flex w-full flex-col space-y-3 border border-x border-y-0 px-4 py-6 sm:px-6">
|
||||||
<DestinationCalendarSelector
|
<div>
|
||||||
hidePlaceholder
|
<Label className="text-default mb-0 font-medium">{t("add_events_to")}</Label>
|
||||||
value={selectedDestinationCalendarOption?.externalId}
|
<DestinationCalendarSelector
|
||||||
onChange={(option) => {
|
hidePlaceholder
|
||||||
setSelectedDestinationCalendar(option);
|
value={selectedDestinationCalendarOption?.externalId}
|
||||||
}}
|
onChange={(option) => {
|
||||||
isLoading={mutation.isLoading}
|
setSelectedDestinationCalendar(option);
|
||||||
/>
|
}}
|
||||||
|
isLoading={mutation.isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<SectionBottomActions align="end">
|
<SectionBottomActions align="end">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import OrganizationMemberAvatar from "@calcom/features/ee/organizations/componen
|
|||||||
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
|
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
|
||||||
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
||||||
import { APP_NAME, FULL_NAME_LENGTH_MAX_LIMIT } from "@calcom/lib/constants";
|
import { APP_NAME, FULL_NAME_LENGTH_MAX_LIMIT } from "@calcom/lib/constants";
|
||||||
import { AVATAR_FALLBACK } from "@calcom/lib/constants";
|
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { md } from "@calcom/lib/markdownIt";
|
import { md } from "@calcom/lib/markdownIt";
|
||||||
import turndown from "@calcom/lib/turndownService";
|
import turndown from "@calcom/lib/turndownService";
|
||||||
@@ -72,37 +72,22 @@ interface DeleteAccountValues {
|
|||||||
|
|
||||||
type FormValues = {
|
type FormValues = {
|
||||||
username: string;
|
username: string;
|
||||||
avatar: string | null;
|
avatar: string;
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
bio: string;
|
bio: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const checkIfItFallbackImage = (fetchedImgSrc?: string) => {
|
|
||||||
return !fetchedImgSrc || fetchedImgSrc.endsWith(AVATAR_FALLBACK);
|
|
||||||
};
|
|
||||||
|
|
||||||
const ProfileView = () => {
|
const ProfileView = () => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const utils = trpc.useContext();
|
const utils = trpc.useContext();
|
||||||
const { update } = useSession();
|
const { update } = useSession();
|
||||||
|
const { data: user, isLoading } = trpc.viewer.me.useQuery();
|
||||||
|
|
||||||
const [fetchedImgSrc, setFetchedImgSrc] = useState<string | undefined>(undefined);
|
const { data: avatarData } = trpc.viewer.avatar.useQuery(undefined, {
|
||||||
|
enabled: !isLoading && !user?.avatarUrl,
|
||||||
const { data: user, isLoading } = trpc.viewer.me.useQuery(undefined, {
|
|
||||||
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({
|
const updateProfileMutation = trpc.viewer.updateProfile.useMutation({
|
||||||
onSuccess: async (res) => {
|
onSuccess: async (res) => {
|
||||||
await update(res);
|
await update(res);
|
||||||
@@ -234,7 +219,7 @@ const ProfileView = () => {
|
|||||||
|
|
||||||
const defaultValues = {
|
const defaultValues = {
|
||||||
username: user.username || "",
|
username: user.username || "",
|
||||||
avatar: user.avatar || "",
|
avatar: getUserAvatarUrl(user),
|
||||||
name: user.name || "",
|
name: user.name || "",
|
||||||
email: user.email || "",
|
email: user.email || "",
|
||||||
bio: user.bio || "",
|
bio: user.bio || "",
|
||||||
@@ -251,8 +236,7 @@ const ProfileView = () => {
|
|||||||
key={JSON.stringify(defaultValues)}
|
key={JSON.stringify(defaultValues)}
|
||||||
defaultValues={defaultValues}
|
defaultValues={defaultValues}
|
||||||
isLoading={updateProfileMutation.isLoading}
|
isLoading={updateProfileMutation.isLoading}
|
||||||
isFallbackImg={checkIfItFallbackImage(fetchedImgSrc)}
|
isFallbackImg={!user.avatarUrl && !avatarData?.avatar}
|
||||||
userAvatar={user.avatar}
|
|
||||||
user={user}
|
user={user}
|
||||||
userOrganization={user.organization}
|
userOrganization={user.organization}
|
||||||
onSubmit={(values) => {
|
onSubmit={(values) => {
|
||||||
@@ -398,7 +382,6 @@ const ProfileForm = ({
|
|||||||
extraField,
|
extraField,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
isFallbackImg,
|
isFallbackImg,
|
||||||
userAvatar,
|
|
||||||
user,
|
user,
|
||||||
userOrganization,
|
userOrganization,
|
||||||
}: {
|
}: {
|
||||||
@@ -407,7 +390,6 @@ const ProfileForm = ({
|
|||||||
extraField?: React.ReactNode;
|
extraField?: React.ReactNode;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isFallbackImg: boolean;
|
isFallbackImg: boolean;
|
||||||
userAvatar: string;
|
|
||||||
user: RouterOutputs["viewer"]["me"];
|
user: RouterOutputs["viewer"]["me"];
|
||||||
userOrganization: RouterOutputs["viewer"]["me"]["organization"];
|
userOrganization: RouterOutputs["viewer"]["me"]["organization"];
|
||||||
}) => {
|
}) => {
|
||||||
@@ -416,7 +398,7 @@ const ProfileForm = ({
|
|||||||
|
|
||||||
const profileFormSchema = z.object({
|
const profileFormSchema = z.object({
|
||||||
username: z.string(),
|
username: z.string(),
|
||||||
avatar: z.string().nullable(),
|
avatar: z.string(),
|
||||||
name: z
|
name: z
|
||||||
.string()
|
.string()
|
||||||
.trim()
|
.trim()
|
||||||
@@ -438,7 +420,6 @@ const ProfileForm = ({
|
|||||||
} = formMethods;
|
} = formMethods;
|
||||||
|
|
||||||
const isDisabled = isSubmitting || !isDirty;
|
const isDisabled = isSubmitting || !isDirty;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form form={formMethods} handleSubmit={onSubmit}>
|
<Form form={formMethods} handleSubmit={onSubmit}>
|
||||||
<div className="border-subtle border-x px-4 pb-10 pt-8 sm:px-6">
|
<div className="border-subtle border-x px-4 pb-10 pt-8 sm:px-6">
|
||||||
@@ -447,7 +428,7 @@ const ProfileForm = ({
|
|||||||
control={formMethods.control}
|
control={formMethods.control}
|
||||||
name="avatar"
|
name="avatar"
|
||||||
render={({ field: { value } }) => {
|
render={({ field: { value } }) => {
|
||||||
const showRemoveAvatarButton = !isFallbackImg || (value && userAvatar !== value);
|
const showRemoveAvatarButton = value === null ? false : !isFallbackImg;
|
||||||
const organization =
|
const organization =
|
||||||
userOrganization && userOrganization.id
|
userOrganization && userOrganization.id
|
||||||
? {
|
? {
|
||||||
@@ -474,7 +455,7 @@ const ProfileForm = ({
|
|||||||
handleAvatarChange={(newAvatar) => {
|
handleAvatarChange={(newAvatar) => {
|
||||||
formMethods.setValue("avatar", newAvatar, { shouldDirty: true });
|
formMethods.setValue("avatar", newAvatar, { shouldDirty: true });
|
||||||
}}
|
}}
|
||||||
imageSrc={value || undefined}
|
imageSrc={value}
|
||||||
triggerButtonColor={showRemoveAvatarButton ? "secondary" : "primary"}
|
triggerButtonColor={showRemoveAvatarButton ? "secondary" : "primary"}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -482,7 +463,7 @@ const ProfileForm = ({
|
|||||||
<Button
|
<Button
|
||||||
color="secondary"
|
color="secondary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
formMethods.setValue("avatar", null, { shouldDirty: true });
|
formMethods.setValue("avatar", "", { shouldDirty: true });
|
||||||
}}>
|
}}>
|
||||||
{t("remove")}
|
{t("remove")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
+393
-105
@@ -1,25 +1,34 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { CalendarHeart, Info, Link2, ShieldCheckIcon, StarIcon, Users } from "lucide-react";
|
||||||
import type { GetServerSidePropsContext } from "next";
|
import type { GetServerSidePropsContext } from "next";
|
||||||
import { signIn } from "next-auth/react";
|
import { signIn } from "next-auth/react";
|
||||||
import { useSearchParams } from "next/navigation";
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
import type { SubmitHandler } from "react-hook-form";
|
import type { SubmitHandler } from "react-hook-form";
|
||||||
import { FormProvider, useForm } from "react-hook-form";
|
import { useForm, useFormContext } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import getStripe from "@calcom/app-store/stripepayment/lib/client";
|
||||||
|
import { getPremiumPlanPriceValue } from "@calcom/app-store/stripepayment/lib/utils";
|
||||||
import { checkPremiumUsername } from "@calcom/features/ee/common/lib/checkPremiumUsername";
|
import { checkPremiumUsername } from "@calcom/features/ee/common/lib/checkPremiumUsername";
|
||||||
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
|
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||||
import { isSAMLLoginEnabled } from "@calcom/features/ee/sso/lib/saml";
|
import { isSAMLLoginEnabled } from "@calcom/features/ee/sso/lib/saml";
|
||||||
import { useFlagMap } from "@calcom/features/flags/context/provider";
|
import { useFlagMap } from "@calcom/features/flags/context/provider";
|
||||||
import { getFeatureFlagMap } from "@calcom/features/flags/server/utils";
|
import { getFeatureFlagMap } from "@calcom/features/flags/server/utils";
|
||||||
import { IS_SELF_HOSTED, WEBAPP_URL } from "@calcom/lib/constants";
|
import { classNames } from "@calcom/lib";
|
||||||
|
import { APP_NAME, IS_CALCOM, IS_SELF_HOSTED, WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants";
|
||||||
|
import { fetchUsername } from "@calcom/lib/fetchUsername";
|
||||||
|
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||||
|
import { useDebounce } from "@calcom/lib/hooks/useDebounce";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import slugify from "@calcom/lib/slugify";
|
import slugify from "@calcom/lib/slugify";
|
||||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||||
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||||
import { signupSchema as apiSignupSchema } from "@calcom/prisma/zod-utils";
|
import { signupSchema as apiSignupSchema } from "@calcom/prisma/zod-utils";
|
||||||
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
|
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
|
||||||
import { Alert, Button, EmailField, HeadSeo, PasswordField, TextField } from "@calcom/ui";
|
import { Button, HeadSeo, PasswordField, TextField, Form, Alert } from "@calcom/ui";
|
||||||
|
|
||||||
import PageWrapper from "@components/PageWrapper";
|
import PageWrapper from "@components/PageWrapper";
|
||||||
|
|
||||||
@@ -34,10 +43,32 @@ type FormValues = z.infer<typeof signupSchema>;
|
|||||||
|
|
||||||
type SignupProps = inferSSRProps<typeof getServerSideProps>;
|
type SignupProps = inferSSRProps<typeof getServerSideProps>;
|
||||||
|
|
||||||
const checkValidEmail = (email: string) => z.string().email().safeParse(email).success;
|
const FEATURES = [
|
||||||
|
{
|
||||||
|
title: "connect_all_calendars",
|
||||||
|
description: "connect_all_calendars_description",
|
||||||
|
i18nOptions: {
|
||||||
|
appName: APP_NAME,
|
||||||
|
},
|
||||||
|
icon: CalendarHeart,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "set_availability",
|
||||||
|
description: "set_availbility_description",
|
||||||
|
icon: Users,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "share_a_link_or_embed",
|
||||||
|
description: "share_a_link_or_embed_description",
|
||||||
|
icon: Link2,
|
||||||
|
i18nOptions: {
|
||||||
|
appName: APP_NAME,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const getOrgUsernameFromEmail = (email: string, autoAcceptEmailDomain: string) => {
|
const getOrgUsernameFromEmail = (email: string, autoAcceptEmailDomain: string) => {
|
||||||
const [emailUser, emailDomain] = email.split("@");
|
const [emailUser, emailDomain = ""] = email.split("@");
|
||||||
const username =
|
const username =
|
||||||
emailDomain === autoAcceptEmailDomain
|
emailDomain === autoAcceptEmailDomain
|
||||||
? slugify(emailUser)
|
? slugify(emailUser)
|
||||||
@@ -46,31 +77,124 @@ const getOrgUsernameFromEmail = (email: string, autoAcceptEmailDomain: string) =
|
|||||||
return username;
|
return username;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function UsernameField({
|
||||||
|
username,
|
||||||
|
setPremium,
|
||||||
|
premium,
|
||||||
|
setUsernameTaken,
|
||||||
|
usernameTaken,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TextField> & {
|
||||||
|
username: string;
|
||||||
|
setPremium: (value: boolean) => void;
|
||||||
|
premium: boolean;
|
||||||
|
usernameTaken: boolean;
|
||||||
|
setUsernameTaken: (value: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useLocale();
|
||||||
|
const { register, formState } = useFormContext<FormValues>();
|
||||||
|
const debouncedUsername = useDebounce(username, 600);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (formState.isSubmitting || formState.isSubmitSuccessful) return;
|
||||||
|
|
||||||
|
async function checkUsername() {
|
||||||
|
if (!debouncedUsername) {
|
||||||
|
setPremium(false);
|
||||||
|
setUsernameTaken(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fetchUsername(debouncedUsername).then(({ data }) => {
|
||||||
|
setPremium(data.premium);
|
||||||
|
setUsernameTaken(!data.available);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
checkUsername();
|
||||||
|
}, [debouncedUsername, setPremium, setUsernameTaken, formState.isSubmitting, formState.isSubmitSuccessful]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<TextField
|
||||||
|
{...props}
|
||||||
|
{...register("username")}
|
||||||
|
data-testid="signup-usernamefield"
|
||||||
|
addOnFilled={false}
|
||||||
|
/>
|
||||||
|
{(!formState.isSubmitting || !formState.isSubmitted) && (
|
||||||
|
<div className="text-gray text-default flex items-center text-sm">
|
||||||
|
<p className="flex items-center text-sm ">
|
||||||
|
{usernameTaken ? (
|
||||||
|
<div className="text-error">
|
||||||
|
<Info className="mr-1 inline-block h-4 w-4" />
|
||||||
|
{t("already_in_use_error")}
|
||||||
|
</div>
|
||||||
|
) : premium ? (
|
||||||
|
<div data-testid="premium-username-warning">
|
||||||
|
<StarIcon className="mr-1 inline-block h-4 w-4" />
|
||||||
|
{t("premium_username", {
|
||||||
|
price: getPremiumPlanPriceValue(),
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkValidEmail = (email: string) => z.string().email().safeParse(email).success;
|
||||||
|
|
||||||
function addOrUpdateQueryParam(url: string, key: string, value: string) {
|
function addOrUpdateQueryParam(url: string, key: string, value: string) {
|
||||||
const separator = url.includes("?") ? "&" : "?";
|
const separator = url.includes("?") ? "&" : "?";
|
||||||
const param = `${key}=${encodeURIComponent(value)}`;
|
const param = `${key}=${encodeURIComponent(value)}`;
|
||||||
return `${url}${separator}${param}`;
|
return `${url}${separator}${param}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Signup({ prepopulateFormValues, token, orgSlug, orgAutoAcceptEmail }: SignupProps) {
|
export default function Signup({
|
||||||
const searchParams = useSearchParams();
|
prepopulateFormValues,
|
||||||
|
token,
|
||||||
|
orgSlug,
|
||||||
|
isGoogleLoginEnabled,
|
||||||
|
isSAMLLoginEnabled,
|
||||||
|
orgAutoAcceptEmail,
|
||||||
|
}: SignupProps) {
|
||||||
|
const [premiumUsername, setPremiumUsername] = useState(false);
|
||||||
|
const [usernameTaken, setUsernameTaken] = useState(false);
|
||||||
|
|
||||||
|
const searchParams = useCompatSearchParams();
|
||||||
const telemetry = useTelemetry();
|
const telemetry = useTelemetry();
|
||||||
const { t, i18n } = useLocale();
|
const { t, i18n } = useLocale();
|
||||||
|
const router = useRouter();
|
||||||
const flags = useFlagMap();
|
const flags = useFlagMap();
|
||||||
const methods = useForm<FormValues>({
|
const formMethods = useForm<FormValues>({
|
||||||
mode: "onChange",
|
|
||||||
resolver: zodResolver(signupSchema),
|
resolver: zodResolver(signupSchema),
|
||||||
defaultValues: prepopulateFormValues,
|
defaultValues: prepopulateFormValues satisfies FormValues,
|
||||||
|
mode: "onChange",
|
||||||
});
|
});
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
formState: { errors, isSubmitting },
|
watch,
|
||||||
} = methods;
|
formState: { isSubmitting, errors, isSubmitSuccessful },
|
||||||
|
} = formMethods;
|
||||||
|
|
||||||
const handleErrors = async (resp: Response) => {
|
const loadingSubmitState = isSubmitSuccessful || isSubmitting;
|
||||||
|
|
||||||
|
const handleErrorsAndStripe = async (resp: Response) => {
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
const err = await resp.json();
|
const err = await resp.json();
|
||||||
throw new Error(err.message);
|
if (err.checkoutSessionId) {
|
||||||
|
const stripe = await getStripe();
|
||||||
|
if (stripe) {
|
||||||
|
console.log("Redirecting to stripe checkout");
|
||||||
|
const { error } = await stripe.redirectToCheckout({
|
||||||
|
sessionId: err.checkoutSessionId,
|
||||||
|
});
|
||||||
|
console.warn(error.message);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error(err.message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -88,7 +212,7 @@ export default function Signup({ prepopulateFormValues, token, orgSlug, orgAutoA
|
|||||||
},
|
},
|
||||||
method: "POST",
|
method: "POST",
|
||||||
})
|
})
|
||||||
.then(handleErrors)
|
.then(handleErrorsAndStripe)
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
telemetry.event(telemetryEventTypes.signup, collectPageParameters());
|
telemetry.event(telemetryEventTypes.signup, collectPageParameters());
|
||||||
const verifyOrGettingStarted = flags["email-verification"] ? "auth/verify-email" : "getting-started";
|
const verifyOrGettingStarted = flags["email-verification"] ? "auth/verify-email" : "getting-started";
|
||||||
@@ -106,109 +230,262 @@ export default function Signup({ prepopulateFormValues, token, orgSlug, orgAutoA
|
|||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
methods.setError("apiError", { message: err.message });
|
formMethods.setError("apiError", { message: err.message });
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div
|
||||||
<div
|
className="light bg-muted 2xl:bg-default flex min-h-screen w-full flex-col items-center justify-center"
|
||||||
className="bg-muted flex min-h-screen flex-col justify-center "
|
style={
|
||||||
style={
|
{
|
||||||
{
|
"--cal-brand": "#111827",
|
||||||
"--cal-brand": "#111827",
|
"--cal-brand-emphasis": "#101010",
|
||||||
"--cal-brand-emphasis": "#101010",
|
"--cal-brand-text": "white",
|
||||||
"--cal-brand-text": "white",
|
"--cal-brand-subtle": "#9CA3AF",
|
||||||
"--cal-brand-subtle": "#9CA3AF",
|
} as CSSProperties
|
||||||
} as CSSProperties
|
}>
|
||||||
}
|
<div className="bg-muted 2xl:border-subtle grid max-h-[800px] w-full max-w-[1440px] grid-cols-1 grid-rows-1 lg:grid-cols-2 2xl:rounded-lg 2xl:border ">
|
||||||
aria-labelledby="modal-title"
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true">
|
|
||||||
<HeadSeo title={t("sign_up")} description={t("sign_up")} />
|
<HeadSeo title={t("sign_up")} description={t("sign_up")} />
|
||||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
<div className="flex w-full flex-col px-4 py-6 sm:px-16 md:px-24 2xl:px-28">
|
||||||
<h2 className="font-cal text-emphasis text-center text-3xl font-extrabold">
|
{/* Header */}
|
||||||
{t("create_your_account")}
|
{errors.apiError && (
|
||||||
</h2>
|
<Alert severity="error" message={errors.apiError?.message} data-testid="signup-error-message" />
|
||||||
</div>
|
)}
|
||||||
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
<div className="flex flex-col gap-1">
|
||||||
<div className="bg-default mx-2 p-6 shadow sm:rounded-lg lg:p-8">
|
<h1 className="font-cal text-[28px] ">
|
||||||
<FormProvider {...methods}>
|
{IS_CALCOM ? t("create_your_calcom_account") : t("create_your_account")}
|
||||||
<form
|
</h1>
|
||||||
onSubmit={(event) => {
|
{IS_CALCOM ? (
|
||||||
event.preventDefault();
|
<p className="text-subtle text-base font-medium leading-6">{t("cal_signup_description")}</p>
|
||||||
event.stopPropagation();
|
) : (
|
||||||
|
<p className="text-subtle text-base font-medium leading-6">
|
||||||
|
{t("calcom_explained", {
|
||||||
|
appName: APP_NAME,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Form Container */}
|
||||||
|
<div className="mt-10">
|
||||||
|
<Form
|
||||||
|
className="flex flex-col gap-4"
|
||||||
|
form={formMethods}
|
||||||
|
handleSubmit={async (values) => {
|
||||||
|
let updatedValues = values;
|
||||||
|
if (!formMethods.getValues().username && isOrgInviteByLink && orgAutoAcceptEmail) {
|
||||||
|
updatedValues = {
|
||||||
|
...values,
|
||||||
|
username: getOrgUsernameFromEmail(values.email, orgAutoAcceptEmail),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
await signUp(updatedValues);
|
||||||
|
}}>
|
||||||
|
{/* Username */}
|
||||||
|
<UsernameField
|
||||||
|
label={t("username")}
|
||||||
|
username={watch("username")}
|
||||||
|
premium={premiumUsername}
|
||||||
|
usernameTaken={usernameTaken}
|
||||||
|
setUsernameTaken={(value) => setUsernameTaken(value)}
|
||||||
|
data-testid="signup-usernamefield"
|
||||||
|
setPremium={(value) => setPremiumUsername(value)}
|
||||||
|
addOnLeading={
|
||||||
|
orgSlug
|
||||||
|
? `${getOrgFullOrigin(orgSlug, { protocol: true })}/`
|
||||||
|
: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/`
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{/* Email */}
|
||||||
|
<TextField
|
||||||
|
{...register("email")}
|
||||||
|
label={t("email")}
|
||||||
|
type="email"
|
||||||
|
data-testid="signup-emailfield"
|
||||||
|
/>
|
||||||
|
|
||||||
if (methods.formState?.errors?.apiError) {
|
{/* Password */}
|
||||||
methods.clearErrors("apiError");
|
<PasswordField
|
||||||
}
|
data-testid="signup-passwordfield"
|
||||||
|
label={t("password")}
|
||||||
if (methods.getValues().username === undefined && isOrgInviteByLink && orgAutoAcceptEmail) {
|
{...register("password")}
|
||||||
methods.setValue(
|
hintErrors={["caplow", "min", "num"]}
|
||||||
"username",
|
/>
|
||||||
getOrgUsernameFromEmail(methods.getValues().email, orgAutoAcceptEmail)
|
<Button
|
||||||
);
|
type="submit"
|
||||||
}
|
className="my-2 w-full justify-center"
|
||||||
methods.handleSubmit(signUp)(event);
|
loading={loadingSubmitState}
|
||||||
}}
|
disabled={
|
||||||
className="bg-default space-y-6">
|
!!formMethods.formState.errors.username ||
|
||||||
{errors.apiError && <Alert severity="error" message={errors.apiError?.message} />}
|
!!formMethods.formState.errors.email ||
|
||||||
{}
|
usernameTaken
|
||||||
<div className="space-y-4">
|
}>
|
||||||
{!isOrgInviteByLink && (
|
{premiumUsername && !usernameTaken
|
||||||
<TextField
|
? `Create Account for ${getPremiumPlanPriceValue()}`
|
||||||
addOnLeading={
|
: t("create_account")}
|
||||||
orgSlug
|
</Button>
|
||||||
? `${getOrgFullOrigin(orgSlug, { protocol: true })}/`
|
</Form>
|
||||||
: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/`
|
{/* Continue with Social Logins */}
|
||||||
|
{token || (!isGoogleLoginEnabled && !isSAMLLoginEnabled) ? null : (
|
||||||
|
<div className="mt-6">
|
||||||
|
<div className="relative flex items-center">
|
||||||
|
<div className="border-subtle flex-grow border-t" />
|
||||||
|
<span className="text-subtle leadning-none mx-2 flex-shrink text-sm font-normal ">
|
||||||
|
{t("or_continue_with")}
|
||||||
|
</span>
|
||||||
|
<div className="border-subtle flex-grow border-t" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Social Logins */}
|
||||||
|
{!token && (
|
||||||
|
<div className="mt-6 flex flex-col gap-2 md:flex-row">
|
||||||
|
{isGoogleLoginEnabled ? (
|
||||||
|
<Button
|
||||||
|
color="secondary"
|
||||||
|
disabled={!!formMethods.formState.errors.username || premiumUsername}
|
||||||
|
className={classNames(
|
||||||
|
"w-full justify-center rounded-md text-center",
|
||||||
|
formMethods.formState.errors.username ? "opacity-50" : ""
|
||||||
|
)}
|
||||||
|
onClick={async () => {
|
||||||
|
const username = formMethods.getValues("username");
|
||||||
|
const baseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL;
|
||||||
|
const GOOGLE_AUTH_URL = `${baseUrl}/auth/sso/google`;
|
||||||
|
if (username) {
|
||||||
|
// If username is present we save it in query params to check for premium
|
||||||
|
const searchQueryParams = new URLSearchParams();
|
||||||
|
searchQueryParams.set("username", formMethods.getValues("username"));
|
||||||
|
localStorage.setItem("username", username);
|
||||||
|
router.push(`${GOOGLE_AUTH_URL}?${searchQueryParams.toString()}`);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
{...register("username")}
|
router.push(GOOGLE_AUTH_URL);
|
||||||
disabled={!!orgSlug}
|
}}>
|
||||||
required
|
<img
|
||||||
|
className={classNames("text-emphasis mr-2 h-5 w-5", premiumUsername && "opacity-50")}
|
||||||
|
src="/google-icon.svg"
|
||||||
|
alt=""
|
||||||
/>
|
/>
|
||||||
)}
|
Google
|
||||||
<EmailField
|
|
||||||
{...register("email")}
|
|
||||||
disabled={prepopulateFormValues?.email}
|
|
||||||
className="disabled:bg-emphasis disabled:hover:cursor-not-allowed"
|
|
||||||
/>
|
|
||||||
<PasswordField
|
|
||||||
labelProps={{
|
|
||||||
className: "block text-sm font-medium text-default",
|
|
||||||
}}
|
|
||||||
{...register("password")}
|
|
||||||
hintErrors={["caplow", "min", "num"]}
|
|
||||||
className="border-default mt-1 block w-full rounded-md border px-3 py-2 shadow-sm focus:border-black focus:outline-none focus:ring-black sm:text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex space-x-2 rtl:space-x-reverse">
|
|
||||||
<Button type="submit" loading={isSubmitting} className="w-full justify-center">
|
|
||||||
{t("create_account")}
|
|
||||||
</Button>
|
</Button>
|
||||||
{!token && (
|
) : null}
|
||||||
<Button
|
{isSAMLLoginEnabled ? (
|
||||||
color="secondary"
|
<Button
|
||||||
className="w-full justify-center"
|
color="secondary"
|
||||||
onClick={() =>
|
disabled={
|
||||||
signIn("Cal.com", {
|
!!formMethods.formState.errors.username ||
|
||||||
callbackUrl: searchParams?.get("callbackUrl")
|
!!formMethods.formState.errors.email ||
|
||||||
? `${WEBAPP_URL}/${searchParams.get("callbackUrl")}`
|
premiumUsername
|
||||||
: `${WEBAPP_URL}/getting-started`,
|
}
|
||||||
})
|
className={classNames(
|
||||||
}>
|
"w-full justify-center rounded-md text-center",
|
||||||
{t("login_instead")}
|
formMethods.formState.errors.username && formMethods.formState.errors.email
|
||||||
</Button>
|
? "opacity-50"
|
||||||
)}
|
: ""
|
||||||
</div>
|
)}
|
||||||
</form>
|
onClick={() => {
|
||||||
</FormProvider>
|
if (!formMethods.getValues("username")) {
|
||||||
|
formMethods.trigger("username");
|
||||||
|
}
|
||||||
|
if (!formMethods.getValues("email")) {
|
||||||
|
formMethods.trigger("email");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const username = formMethods.getValues("username");
|
||||||
|
localStorage.setItem("username", username);
|
||||||
|
const sp = new URLSearchParams();
|
||||||
|
// @NOTE: don't remove username query param as it's required right now for stripe payment page
|
||||||
|
sp.set("username", formMethods.getValues("username"));
|
||||||
|
sp.set("email", formMethods.getValues("email"));
|
||||||
|
router.push(
|
||||||
|
`${process.env.NEXT_PUBLIC_WEBAPP_URL}/auth/sso/saml` + `?${sp.toString()}`
|
||||||
|
);
|
||||||
|
}}>
|
||||||
|
<ShieldCheckIcon className="mr-2 h-5 w-5" />
|
||||||
|
{t("saml_sso")}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Already have an account & T&C */}
|
||||||
|
<div className="mt-6">
|
||||||
|
<div className="flex flex-col text-sm">
|
||||||
|
<Link href="/auth/login" className="text-emphasis hover:underline">
|
||||||
|
{t("already_have_account")}
|
||||||
|
</Link>
|
||||||
|
<div className="text-subtle">
|
||||||
|
By signing up, you agree to our{" "}
|
||||||
|
<Link className="text-emphasis hover:underline" href={`${WEBSITE_URL}/terms`}>
|
||||||
|
Terms of Service{" "}
|
||||||
|
</Link>
|
||||||
|
<span>and</span>{" "}
|
||||||
|
<Link className="text-emphasis hover:underline" href={`${WEBSITE_URL}/privacy`}>
|
||||||
|
Privacy Policy.
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-subtle border-subtle hidden w-full flex-col justify-between rounded-l-2xl py-12 pl-12 lg:flex">
|
||||||
|
{IS_CALCOM && (
|
||||||
|
<div className="mb-12 mr-12 grid h-full w-full grid-cols-4 gap-4 ">
|
||||||
|
<div className="">
|
||||||
|
<img src="/product-cards/trustpilot.svg" className="h-[54px] w-full" alt="#" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<img src="/product-cards/g2.svg" className="h-[54px] w-full" alt="#" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<img src="/product-cards/producthunt.svg" className="h-[54px] w-full" alt="#" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className="rounded-2xl border-y border-l border-dashed border-[#D1D5DB5A] py-[6px] pl-[6px]"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "rgba(236,237,239,0.9)",
|
||||||
|
}}>
|
||||||
|
<img src="/mock-event-type-list.svg" alt="#" className="" />
|
||||||
|
</div>
|
||||||
|
<div className="mr-12 mt-8 grid h-full w-full grid-cols-3 gap-4 overflow-hidden">
|
||||||
|
{!IS_CALCOM &&
|
||||||
|
FEATURES.map((feature) => (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col leading-none">
|
||||||
|
<div className="text-emphasis items-center">
|
||||||
|
<feature.icon className="mb-1 h-4 w-4" />
|
||||||
|
<span className="text-sm font-medium">{t(feature.title)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-subtle text-sm">
|
||||||
|
<p>
|
||||||
|
{t(
|
||||||
|
feature.description,
|
||||||
|
feature.i18nOptions && {
|
||||||
|
...feature.i18nOptions,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const querySchema = z.object({
|
||||||
|
username: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform((val) => val || ""),
|
||||||
|
email: z.string().email().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
|
export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
|
||||||
const prisma = await import("@calcom/prisma").then((mod) => mod.default);
|
const prisma = await import("@calcom/prisma").then((mod) => mod.default);
|
||||||
const flags = await getFeatureFlagMap(prisma);
|
const flags = await getFeatureFlagMap(prisma);
|
||||||
@@ -222,6 +499,9 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
|
|||||||
prepopulateFormValues: undefined,
|
prepopulateFormValues: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// username + email prepopulated from query params
|
||||||
|
const { username: preFillusername, email: prefilEmail } = querySchema.parse(ctx.query);
|
||||||
|
|
||||||
if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === "true" || flags["disable-signup"]) {
|
if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === "true" || flags["disable-signup"]) {
|
||||||
return {
|
return {
|
||||||
notFound: true,
|
notFound: true,
|
||||||
@@ -231,7 +511,15 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
|
|||||||
// no token given, treat as a normal signup without verification token
|
// no token given, treat as a normal signup without verification token
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return {
|
return {
|
||||||
props: JSON.parse(JSON.stringify(props)),
|
props: JSON.parse(
|
||||||
|
JSON.stringify({
|
||||||
|
...props,
|
||||||
|
prepopulateFormValues: {
|
||||||
|
username: preFillusername || null,
|
||||||
|
email: prefilEmail || null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -214,7 +214,7 @@ export function VideoMeetingInfo(props: VideoMeetingInfo) {
|
|||||||
<h3>{t("invitee_timezone")}:</h3>
|
<h3>{t("invitee_timezone")}:</h3>
|
||||||
<p>{booking.user?.timeZone}</p>
|
<p>{booking.user?.timeZone}</p>
|
||||||
<h3>{t("when")}:</h3>
|
<h3>{t("when")}:</h3>
|
||||||
<p>
|
<p suppressHydrationWarning={true}>
|
||||||
{formatToLocalizedDate(startTime)} <br />
|
{formatToLocalizedDate(startTime)} <br />
|
||||||
{formatToLocalizedTime(startTime)}
|
{formatToLocalizedTime(startTime)}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -11,10 +11,6 @@ let pages = (exports.pages = glob
|
|||||||
)
|
)
|
||||||
.filter((v, i, self) => self.indexOf(v) === i && !v.startsWith("[user]")));
|
.filter((v, i, self) => self.indexOf(v) === i && !v.startsWith("[user]")));
|
||||||
|
|
||||||
// Following routes don't exist but they work by doing rewrite. Thus they need to be excluded from matching the orgRewrite patterns
|
|
||||||
// Make sure to keep it upto date as more nonExistingRouteRewrites are added.
|
|
||||||
const otherNonExistingRoutePrefixes = ["forms", "router", "success", "cancel"];
|
|
||||||
|
|
||||||
// .* matches / as well(Note: *(i.e wildcard) doesn't match / but .*(i.e. RegExp) does)
|
// .* matches / as well(Note: *(i.e wildcard) doesn't match / but .*(i.e. RegExp) does)
|
||||||
// It would match /free/30min but not /bookings/upcoming because 'bookings' is an item in pages
|
// It would match /free/30min but not /bookings/upcoming because 'bookings' is an item in pages
|
||||||
// It would also not match /free/30min/embed because we are ensuring just two slashes
|
// It would also not match /free/30min/embed because we are ensuring just two slashes
|
||||||
@@ -27,11 +23,26 @@ let subdomainRegExp = (exports.subdomainRegExp = getSubdomainRegExp(
|
|||||||
));
|
));
|
||||||
exports.orgHostPath = `^(?<orgSlug>${subdomainRegExp})\\.(?!vercel\.app).*`;
|
exports.orgHostPath = `^(?<orgSlug>${subdomainRegExp})\\.(?!vercel\.app).*`;
|
||||||
|
|
||||||
let beforeRewriteExcludePages = pages.concat(otherNonExistingRoutePrefixes);
|
/**
|
||||||
exports.orgUserRoutePath = `/:user((?!${beforeRewriteExcludePages.join("|")}|_next|public)[a-zA-Z0-9\-_]+)`;
|
* Returns a regex that matches all existing routes, virtual routes (like /forms, /router, /success etc) and nextjs special paths (_next, public)
|
||||||
exports.orgUserTypeRoutePath = `/:user((?!${beforeRewriteExcludePages.join(
|
*/
|
||||||
"/|"
|
function getRegExpMatchingAllReservedRoutes(suffix) {
|
||||||
)}|_next/|public/)[^/]+)/:type((?!avatar\.png)[^/]+)`;
|
// Following routes don't exist but they work by doing rewrite. Thus they need to be excluded from matching the orgRewrite patterns
|
||||||
exports.orgUserTypeEmbedRoutePath = `/:user((?!${beforeRewriteExcludePages.join(
|
// Make sure to keep it upto date as more nonExistingRouteRewrites are added.
|
||||||
"/|"
|
const otherNonExistingRoutePrefixes = ["forms", "router", "success", "cancel"];
|
||||||
)}|_next/|public/)[^/]+)/:type/embed`;
|
const nextJsSpecialPaths = ["_next", "public"];
|
||||||
|
|
||||||
|
let beforeRewriteExcludePages = pages.concat(otherNonExistingRoutePrefixes).concat(nextJsSpecialPaths);
|
||||||
|
return beforeRewriteExcludePages.join(`${suffix}|`) + suffix;
|
||||||
|
}
|
||||||
|
|
||||||
|
// To handle /something
|
||||||
|
exports.orgUserRoutePath = `/:user((?!${getRegExpMatchingAllReservedRoutes("/?$")})[a-zA-Z0-9\-_]+)`;
|
||||||
|
|
||||||
|
// To handle /something/somethingelse
|
||||||
|
exports.orgUserTypeRoutePath = `/:user((?!${getRegExpMatchingAllReservedRoutes(
|
||||||
|
"/"
|
||||||
|
)})[^/]+)/:type((?!avatar\.png)[^/]+)`;
|
||||||
|
|
||||||
|
// To handle /something/somethingelse/embed
|
||||||
|
exports.orgUserTypeEmbedRoutePath = `/:user((?!${getRegExpMatchingAllReservedRoutes("/")})[^/]+)/:type/embed`;
|
||||||
@@ -40,6 +40,7 @@ test.describe("Availablity tests", () => {
|
|||||||
const date = json[0].result.data.json.schedule.availability.find((a) => !!a.date);
|
const date = json[0].result.data.json.schedule.availability.find((a) => !!a.date);
|
||||||
const troubleshooterURL = `/availability/troubleshoot?date=${dayjs(date.date).format("YYYY-MM-DD")}`;
|
const troubleshooterURL = `/availability/troubleshoot?date=${dayjs(date.date).format("YYYY-MM-DD")}`;
|
||||||
await page.goto(troubleshooterURL);
|
await page.goto(troubleshooterURL);
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
await expect(page.locator('[data-testid="troubleshooter-busy-time"]')).toHaveCount(1);
|
await expect(page.locator('[data-testid="troubleshooter-busy-time"]')).toHaveCount(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { expect } from "@playwright/test";
|
import { expect } from "@playwright/test";
|
||||||
|
|
||||||
import { randomString } from "@calcom/lib/random";
|
import { randomString } from "@calcom/lib/random";
|
||||||
|
import { SchedulingType } from "@calcom/prisma/client";
|
||||||
|
import type { Schedule, TimeRange } from "@calcom/types/schedule";
|
||||||
|
|
||||||
import { test } from "./lib/fixtures";
|
import { test } from "./lib/fixtures";
|
||||||
import {
|
import {
|
||||||
@@ -342,3 +344,92 @@ test.describe("Booking on different layouts", () => {
|
|||||||
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.describe("Booking round robin event", () => {
|
||||||
|
test.beforeEach(async ({ page, users }) => {
|
||||||
|
const teamMatesObj = [{ name: "teammate-1" }];
|
||||||
|
|
||||||
|
const dateRanges: TimeRange = {
|
||||||
|
start: new Date(new Date().setUTCHours(10, 0, 0, 0)), //one hour after default schedule (teammate-1's schedule)
|
||||||
|
end: new Date(new Date().setUTCHours(17, 0, 0, 0)),
|
||||||
|
};
|
||||||
|
|
||||||
|
const schedule: Schedule = [[], [dateRanges], [dateRanges], [dateRanges], [dateRanges], [dateRanges], []];
|
||||||
|
|
||||||
|
const testUser = await users.create(
|
||||||
|
{ username: "test-user", name: "Test User", email: "testuser@example.com", schedule },
|
||||||
|
{
|
||||||
|
hasTeam: true,
|
||||||
|
schedulingType: SchedulingType.ROUND_ROBIN,
|
||||||
|
teamEventLength: 120,
|
||||||
|
teammates: teamMatesObj,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const team = await testUser.getFirstTeam();
|
||||||
|
await page.goto(`/team/${team.team.slug}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Does not book round robin host outside availability with date override", async ({ page, users }) => {
|
||||||
|
const [testUser] = users.get();
|
||||||
|
testUser.apiLogin();
|
||||||
|
|
||||||
|
const team = await testUser.getFirstTeam();
|
||||||
|
|
||||||
|
// Click first event type (round robin)
|
||||||
|
await page.click('[data-testid="event-type-link"]');
|
||||||
|
|
||||||
|
await page.click('[data-testid="incrementMonth"]');
|
||||||
|
|
||||||
|
// books 9AM slots for 120 minutes (test-user is not available at this time, availability starts at 10)
|
||||||
|
await page.locator('[data-testid="time"]').nth(0).click();
|
||||||
|
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
await page.locator('[name="name"]').fill("Test name");
|
||||||
|
await page.locator('[name="email"]').fill(`${randomString(4)}@example.com`);
|
||||||
|
|
||||||
|
await page.click('[data-testid="confirm-book-button"]');
|
||||||
|
|
||||||
|
await page.waitForURL((url) => {
|
||||||
|
return url.pathname.startsWith("/booking");
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||||
|
|
||||||
|
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||||
|
|
||||||
|
const host = await page.locator('[data-testid="booking-host-name"]');
|
||||||
|
const hostName = await host.innerText();
|
||||||
|
|
||||||
|
//expect teammate-1 to be booked, test-user is not available at this time
|
||||||
|
expect(hostName).toBe("teammate-1");
|
||||||
|
|
||||||
|
// make another booking to see if also for the second booking teammate-1 is booked
|
||||||
|
await page.goto(`/team/${team.team.slug}`);
|
||||||
|
|
||||||
|
await page.click('[data-testid="event-type-link"]');
|
||||||
|
|
||||||
|
await page.click('[data-testid="incrementMonth"]');
|
||||||
|
await page.click('[data-testid="incrementMonth"]');
|
||||||
|
|
||||||
|
// Again book a 9AM slot for 120 minutes where test-user is not available
|
||||||
|
await page.locator('[data-testid="time"]').nth(0).click();
|
||||||
|
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
await page.locator('[name="name"]').fill("Test name");
|
||||||
|
await page.locator('[name="email"]').fill(`${randomString(4)}@example.com`);
|
||||||
|
|
||||||
|
await page.click('[data-testid="confirm-book-button"]');
|
||||||
|
|
||||||
|
await page.waitForURL((url) => {
|
||||||
|
return url.pathname.startsWith("/booking");
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||||
|
|
||||||
|
const hostSecondBooking = await page.locator('[data-testid="booking-host-name"]');
|
||||||
|
const hostNameSecondBooking = await hostSecondBooking.innerText();
|
||||||
|
expect(hostNameSecondBooking).toBe("teammate-1"); // teammate-1 should be booked again
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,19 +1,337 @@
|
|||||||
import type { Page } from "@playwright/test";
|
import type { Page } from "@playwright/test";
|
||||||
import { expect } from "@playwright/test";
|
import { expect } from "@playwright/test";
|
||||||
|
import { Linter } from "eslint";
|
||||||
|
import { parse } from "node-html-parser";
|
||||||
|
|
||||||
|
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||||
import { EMBED_LIB_URL, WEBAPP_URL } from "@calcom/lib/constants";
|
import { EMBED_LIB_URL, WEBAPP_URL } from "@calcom/lib/constants";
|
||||||
|
import { MembershipRole } from "@calcom/prisma/client";
|
||||||
|
|
||||||
import { test } from "./lib/fixtures";
|
import { test } from "./lib/fixtures";
|
||||||
|
|
||||||
function chooseEmbedType(page: Page, embedType: string) {
|
const linter = new Linter();
|
||||||
|
const eslintRules = {
|
||||||
|
"no-undef": "error",
|
||||||
|
"no-unused-vars": "off",
|
||||||
|
} as const;
|
||||||
|
test.describe.configure({ mode: "parallel" });
|
||||||
|
|
||||||
|
test.afterEach(({ users }) => users.deleteAll());
|
||||||
|
|
||||||
|
test.describe("Embed Code Generator Tests", () => {
|
||||||
|
test.describe("Non-Organization", () => {
|
||||||
|
test.beforeEach(async ({ users }) => {
|
||||||
|
const pro = await users.create();
|
||||||
|
await pro.apiLogin();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Event Types Page", () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto("/event-types");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("open Embed Dialog and choose Inline for First Event Type", async ({ page, users }) => {
|
||||||
|
const [pro] = users.get();
|
||||||
|
const embedUrl = await clickFirstEventTypeEmbedButton(page);
|
||||||
|
await expectToBeNavigatingToEmbedTypesDialog(page, {
|
||||||
|
embedUrl,
|
||||||
|
basePage: "/event-types",
|
||||||
|
});
|
||||||
|
|
||||||
|
chooseEmbedType(page, "inline");
|
||||||
|
|
||||||
|
await expectToBeNavigatingToEmbedCodeAndPreviewDialog(page, {
|
||||||
|
embedUrl,
|
||||||
|
embedType: "inline",
|
||||||
|
basePage: "/event-types",
|
||||||
|
});
|
||||||
|
|
||||||
|
await expectToContainValidCode(page, {
|
||||||
|
language: "html",
|
||||||
|
embedType: "inline",
|
||||||
|
orgSlug: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await goToReactCodeTab(page);
|
||||||
|
await expectToContainValidCode(page, {
|
||||||
|
language: "react",
|
||||||
|
embedType: "inline",
|
||||||
|
orgSlug: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await goToPreviewTab(page);
|
||||||
|
|
||||||
|
await expectToContainValidPreviewIframe(page, {
|
||||||
|
embedType: "inline",
|
||||||
|
calLink: `${pro.username}/30-min`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("open Embed Dialog and choose floating-popup for First Event Type", async ({ page, users }) => {
|
||||||
|
const [pro] = users.get();
|
||||||
|
const embedUrl = await clickFirstEventTypeEmbedButton(page);
|
||||||
|
|
||||||
|
await expectToBeNavigatingToEmbedTypesDialog(page, {
|
||||||
|
embedUrl,
|
||||||
|
basePage: "/event-types",
|
||||||
|
});
|
||||||
|
|
||||||
|
chooseEmbedType(page, "floating-popup");
|
||||||
|
|
||||||
|
await expectToBeNavigatingToEmbedCodeAndPreviewDialog(page, {
|
||||||
|
embedUrl,
|
||||||
|
embedType: "floating-popup",
|
||||||
|
basePage: "/event-types",
|
||||||
|
});
|
||||||
|
await expectToContainValidCode(page, {
|
||||||
|
language: "html",
|
||||||
|
embedType: "floating-popup",
|
||||||
|
orgSlug: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await goToReactCodeTab(page);
|
||||||
|
await expectToContainValidCode(page, {
|
||||||
|
language: "react",
|
||||||
|
embedType: "floating-popup",
|
||||||
|
orgSlug: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await goToPreviewTab(page);
|
||||||
|
await expectToContainValidPreviewIframe(page, {
|
||||||
|
embedType: "floating-popup",
|
||||||
|
calLink: `${pro.username}/30-min`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("open Embed Dialog and choose element-click for First Event Type", async ({ page, users }) => {
|
||||||
|
const [pro] = users.get();
|
||||||
|
const embedUrl = await clickFirstEventTypeEmbedButton(page);
|
||||||
|
|
||||||
|
await expectToBeNavigatingToEmbedTypesDialog(page, {
|
||||||
|
embedUrl,
|
||||||
|
basePage: "/event-types",
|
||||||
|
});
|
||||||
|
|
||||||
|
chooseEmbedType(page, "element-click");
|
||||||
|
|
||||||
|
await expectToBeNavigatingToEmbedCodeAndPreviewDialog(page, {
|
||||||
|
embedUrl,
|
||||||
|
embedType: "element-click",
|
||||||
|
basePage: "/event-types",
|
||||||
|
});
|
||||||
|
await expectToContainValidCode(page, {
|
||||||
|
language: "html",
|
||||||
|
embedType: "element-click",
|
||||||
|
orgSlug: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await goToReactCodeTab(page);
|
||||||
|
await expectToContainValidCode(page, {
|
||||||
|
language: "react",
|
||||||
|
embedType: "element-click",
|
||||||
|
orgSlug: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await goToPreviewTab(page);
|
||||||
|
await expectToContainValidPreviewIframe(page, {
|
||||||
|
embedType: "element-click",
|
||||||
|
calLink: `${pro.username}/30-min`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
test.describe("Event Type Edit Page", () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto(`/event-types`);
|
||||||
|
await Promise.all([
|
||||||
|
page.locator('a[href*="/event-types/"]').first().click(),
|
||||||
|
page.waitForURL((url) => url.pathname.startsWith("/event-types/")),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("open Embed Dialog for the Event Type", async ({ page }) => {
|
||||||
|
const basePage = new URL(page.url()).pathname;
|
||||||
|
const embedUrl = await clickEmbedButton(page);
|
||||||
|
await expectToBeNavigatingToEmbedTypesDialog(page, {
|
||||||
|
embedUrl,
|
||||||
|
basePage,
|
||||||
|
});
|
||||||
|
|
||||||
|
chooseEmbedType(page, "inline");
|
||||||
|
|
||||||
|
await expectToBeNavigatingToEmbedCodeAndPreviewDialog(page, {
|
||||||
|
embedUrl,
|
||||||
|
basePage,
|
||||||
|
embedType: "inline",
|
||||||
|
});
|
||||||
|
|
||||||
|
await expectToContainValidCode(page, {
|
||||||
|
language: "html",
|
||||||
|
embedType: "inline",
|
||||||
|
orgSlug: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await goToPreviewTab(page);
|
||||||
|
|
||||||
|
await expectToContainValidPreviewIframe(page, {
|
||||||
|
embedType: "inline",
|
||||||
|
calLink: decodeURIComponent(embedUrl),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Organization", () => {
|
||||||
|
test.beforeEach(async ({ users, orgs }) => {
|
||||||
|
const org = await orgs.create({
|
||||||
|
name: "TestOrg",
|
||||||
|
});
|
||||||
|
const user = await users.create({
|
||||||
|
organizationId: org.id,
|
||||||
|
roleInOrganization: MembershipRole.MEMBER,
|
||||||
|
});
|
||||||
|
await user.apiLogin();
|
||||||
|
});
|
||||||
|
test.describe("Event Types Page", () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto("/event-types");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("open Embed Dialog and choose Inline for First Event Type", async ({ page, users }) => {
|
||||||
|
const [user] = users.get();
|
||||||
|
const { team: org } = await user.getOrgMembership();
|
||||||
|
const embedUrl = await clickFirstEventTypeEmbedButton(page);
|
||||||
|
await expectToBeNavigatingToEmbedTypesDialog(page, {
|
||||||
|
embedUrl,
|
||||||
|
basePage: "/event-types",
|
||||||
|
});
|
||||||
|
|
||||||
|
chooseEmbedType(page, "inline");
|
||||||
|
|
||||||
|
await expectToBeNavigatingToEmbedCodeAndPreviewDialog(page, {
|
||||||
|
embedUrl,
|
||||||
|
embedType: "inline",
|
||||||
|
basePage: "/event-types",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Default tab is HTML code tab
|
||||||
|
await expectToContainValidCode(page, {
|
||||||
|
language: "html",
|
||||||
|
embedType: "inline",
|
||||||
|
orgSlug: org.slug,
|
||||||
|
});
|
||||||
|
|
||||||
|
await goToReactCodeTab(page);
|
||||||
|
await expectToContainValidCode(page, {
|
||||||
|
language: "react",
|
||||||
|
embedType: "inline",
|
||||||
|
orgSlug: org.slug,
|
||||||
|
});
|
||||||
|
|
||||||
|
await goToPreviewTab(page);
|
||||||
|
await expectToContainValidPreviewIframe(page, {
|
||||||
|
embedType: "inline",
|
||||||
|
calLink: `${user.username}/30-min`,
|
||||||
|
bookerUrl: getOrgFullOrigin(org?.slug ?? ""),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("open Embed Dialog and choose floating-popup for First Event Type", async ({ page, users }) => {
|
||||||
|
const [user] = users.get();
|
||||||
|
const { team: org } = await user.getOrgMembership();
|
||||||
|
|
||||||
|
const embedUrl = await clickFirstEventTypeEmbedButton(page);
|
||||||
|
|
||||||
|
await expectToBeNavigatingToEmbedTypesDialog(page, {
|
||||||
|
embedUrl,
|
||||||
|
basePage: "/event-types",
|
||||||
|
});
|
||||||
|
|
||||||
|
chooseEmbedType(page, "floating-popup");
|
||||||
|
|
||||||
|
await expectToBeNavigatingToEmbedCodeAndPreviewDialog(page, {
|
||||||
|
embedUrl,
|
||||||
|
embedType: "floating-popup",
|
||||||
|
basePage: "/event-types",
|
||||||
|
});
|
||||||
|
await expectToContainValidCode(page, {
|
||||||
|
language: "html",
|
||||||
|
embedType: "floating-popup",
|
||||||
|
orgSlug: org.slug,
|
||||||
|
});
|
||||||
|
|
||||||
|
await goToReactCodeTab(page);
|
||||||
|
await expectToContainValidCode(page, {
|
||||||
|
language: "react",
|
||||||
|
embedType: "floating-popup",
|
||||||
|
orgSlug: org.slug,
|
||||||
|
});
|
||||||
|
|
||||||
|
await goToPreviewTab(page);
|
||||||
|
await expectToContainValidPreviewIframe(page, {
|
||||||
|
embedType: "floating-popup",
|
||||||
|
calLink: `${user.username}/30-min`,
|
||||||
|
bookerUrl: getOrgFullOrigin(org?.slug ?? ""),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("open Embed Dialog and choose element-click for First Event Type", async ({ page, users }) => {
|
||||||
|
const [user] = users.get();
|
||||||
|
const embedUrl = await clickFirstEventTypeEmbedButton(page);
|
||||||
|
const { team: org } = await user.getOrgMembership();
|
||||||
|
|
||||||
|
await expectToBeNavigatingToEmbedTypesDialog(page, {
|
||||||
|
embedUrl,
|
||||||
|
basePage: "/event-types",
|
||||||
|
});
|
||||||
|
|
||||||
|
chooseEmbedType(page, "element-click");
|
||||||
|
|
||||||
|
await expectToBeNavigatingToEmbedCodeAndPreviewDialog(page, {
|
||||||
|
embedUrl,
|
||||||
|
embedType: "element-click",
|
||||||
|
basePage: "/event-types",
|
||||||
|
});
|
||||||
|
await expectToContainValidCode(page, {
|
||||||
|
language: "html",
|
||||||
|
embedType: "element-click",
|
||||||
|
orgSlug: org.slug,
|
||||||
|
});
|
||||||
|
|
||||||
|
await goToReactCodeTab(page);
|
||||||
|
await expectToContainValidCode(page, {
|
||||||
|
language: "react",
|
||||||
|
embedType: "element-click",
|
||||||
|
orgSlug: org.slug,
|
||||||
|
});
|
||||||
|
|
||||||
|
await goToPreviewTab(page);
|
||||||
|
await expectToContainValidPreviewIframe(page, {
|
||||||
|
embedType: "element-click",
|
||||||
|
calLink: `${user.username}/30-min`,
|
||||||
|
bookerUrl: getOrgFullOrigin(org?.slug ?? ""),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
type EmbedType = "inline" | "floating-popup" | "element-click";
|
||||||
|
function chooseEmbedType(page: Page, embedType: EmbedType) {
|
||||||
page.locator(`[data-testid=${embedType}]`).click();
|
page.locator(`[data-testid=${embedType}]`).click();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function gotToPreviewTab(page: Page) {
|
async function goToPreviewTab(page: Page) {
|
||||||
// To prevent early timeouts
|
// To prevent early timeouts
|
||||||
// eslint-disable-next-line playwright/no-wait-for-timeout
|
// eslint-disable-next-line playwright/no-wait-for-timeout
|
||||||
await page.waitForTimeout(1000);
|
await page.waitForTimeout(1000);
|
||||||
await page.locator("[data-testid=embed-tabs]").locator("text=Preview").click();
|
await page.locator("[data-testid=horizontal-tab-Preview]").click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function goToReactCodeTab(page: Page) {
|
||||||
|
// To prevent early timeouts
|
||||||
|
// eslint-disable-next-line playwright/no-wait-for-timeout
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
await page.locator("[data-testid=horizontal-tab-React]").click();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function clickEmbedButton(page: Page) {
|
async function clickEmbedButton(page: Page) {
|
||||||
@@ -55,7 +373,7 @@ async function expectToBeNavigatingToEmbedCodeAndPreviewDialog(
|
|||||||
basePage,
|
basePage,
|
||||||
}: {
|
}: {
|
||||||
embedUrl: string | null;
|
embedUrl: string | null;
|
||||||
embedType: string;
|
embedType: EmbedType;
|
||||||
basePage: string;
|
basePage: string;
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
@@ -73,10 +391,108 @@ async function expectToBeNavigatingToEmbedCodeAndPreviewDialog(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function expectToContainValidCode(page: Page, { embedType }: { embedType: string }) {
|
async function expectToContainValidCode(
|
||||||
|
page: Page,
|
||||||
|
{
|
||||||
|
embedType,
|
||||||
|
language,
|
||||||
|
orgSlug,
|
||||||
|
}: { embedType: EmbedType; language: "html" | "react"; orgSlug: string | null }
|
||||||
|
) {
|
||||||
|
if (language === "react") {
|
||||||
|
return expectValidReactEmbedSnippet(page, { embedType, orgSlug });
|
||||||
|
}
|
||||||
|
if (language === "html") {
|
||||||
|
return expectValidHtmlEmbedSnippet(page, { embedType, orgSlug });
|
||||||
|
}
|
||||||
|
throw new Error("Unknown language");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectValidHtmlEmbedSnippet(
|
||||||
|
page: Page,
|
||||||
|
{ embedType, orgSlug }: { embedType: EmbedType; orgSlug: string | null }
|
||||||
|
) {
|
||||||
const embedCode = await page.locator("[data-testid=embed-code]").inputValue();
|
const embedCode = await page.locator("[data-testid=embed-code]").inputValue();
|
||||||
expect(embedCode.includes("(function (C, A, L)")).toBe(true);
|
expect(embedCode).toContain("function (C, A, L)");
|
||||||
expect(embedCode.includes(`Cal ${embedType} embed code begins`)).toBe(true);
|
expect(embedCode).toContain(`Cal ${embedType} embed code begins`);
|
||||||
|
if (orgSlug) {
|
||||||
|
expect(embedCode).toContain(orgSlug);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dom = parse(embedCode);
|
||||||
|
const scripts = dom.getElementsByTagName("script");
|
||||||
|
assertThatCodeIsValidVanillaJsCode(scripts[0].innerText);
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: () => `passed`,
|
||||||
|
pass: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertThatCodeIsValidVanillaJsCode(code: string) {
|
||||||
|
const lintResult = linter.verify(code, {
|
||||||
|
env: {
|
||||||
|
browser: true,
|
||||||
|
},
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 2021,
|
||||||
|
},
|
||||||
|
globals: {
|
||||||
|
Cal: "readonly",
|
||||||
|
},
|
||||||
|
rules: eslintRules,
|
||||||
|
});
|
||||||
|
if (lintResult.length) {
|
||||||
|
console.log(
|
||||||
|
JSON.stringify({
|
||||||
|
lintResult,
|
||||||
|
code,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
expect(lintResult.length).toBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertThatCodeIsValidReactCode(code: string) {
|
||||||
|
const lintResult = linter.verify(code, {
|
||||||
|
env: {
|
||||||
|
browser: true,
|
||||||
|
},
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 2021,
|
||||||
|
ecmaFeatures: {
|
||||||
|
jsx: true,
|
||||||
|
},
|
||||||
|
sourceType: "module",
|
||||||
|
},
|
||||||
|
rules: eslintRules,
|
||||||
|
});
|
||||||
|
if (lintResult.length) {
|
||||||
|
console.log(
|
||||||
|
JSON.stringify({
|
||||||
|
lintResult,
|
||||||
|
code,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
expect(lintResult.length).toBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectValidReactEmbedSnippet(
|
||||||
|
page: Page,
|
||||||
|
{ embedType, orgSlug }: { embedType: EmbedType; orgSlug: string | null }
|
||||||
|
) {
|
||||||
|
const embedCode = await page.locator("[data-testid=embed-react]").inputValue();
|
||||||
|
expect(embedCode).toContain("export default function MyApp(");
|
||||||
|
expect(embedCode).toContain(
|
||||||
|
embedType === "floating-popup" ? "floatingButton" : embedType === "inline" ? `<Cal` : "data-cal-link"
|
||||||
|
);
|
||||||
|
if (orgSlug) {
|
||||||
|
expect(embedCode).toContain(orgSlug);
|
||||||
|
}
|
||||||
|
|
||||||
|
assertThatCodeIsValidReactCode(embedCode);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
message: () => `passed`,
|
message: () => `passed`,
|
||||||
pass: true,
|
pass: true,
|
||||||
@@ -88,141 +504,10 @@ async function expectToContainValidCode(page: Page, { embedType }: { embedType:
|
|||||||
*/
|
*/
|
||||||
async function expectToContainValidPreviewIframe(
|
async function expectToContainValidPreviewIframe(
|
||||||
page: Page,
|
page: Page,
|
||||||
{ embedType, calLink }: { embedType: string; calLink: string }
|
{ embedType, calLink, bookerUrl }: { embedType: EmbedType; calLink: string; bookerUrl?: string }
|
||||||
) {
|
) {
|
||||||
const bookerUrl = `${WEBAPP_URL}`;
|
bookerUrl = bookerUrl || `${WEBAPP_URL}`;
|
||||||
expect(await page.locator("[data-testid=embed-preview]").getAttribute("src")).toContain(
|
expect(await page.locator("[data-testid=embed-preview]").getAttribute("src")).toContain(
|
||||||
`/preview.html?embedType=${embedType}&calLink=${calLink}&embedLibUrl=${EMBED_LIB_URL}&bookerUrl=${bookerUrl}`
|
`/preview.html?embedType=${embedType}&calLink=${calLink}&embedLibUrl=${EMBED_LIB_URL}&bookerUrl=${bookerUrl}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
test.describe.configure({ mode: "parallel" });
|
|
||||||
|
|
||||||
test.afterEach(({ users }) => users.deleteAll());
|
|
||||||
|
|
||||||
test.describe("Embed Code Generator Tests", () => {
|
|
||||||
test.beforeEach(async ({ users }) => {
|
|
||||||
const pro = await users.create();
|
|
||||||
await pro.apiLogin();
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe("Event Types Page", () => {
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
await page.goto("/event-types");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("open Embed Dialog and choose Inline for First Event Type", async ({ page, users }) => {
|
|
||||||
const [pro] = users.get();
|
|
||||||
const embedUrl = await clickFirstEventTypeEmbedButton(page);
|
|
||||||
await expectToBeNavigatingToEmbedTypesDialog(page, {
|
|
||||||
embedUrl,
|
|
||||||
basePage: "/event-types",
|
|
||||||
});
|
|
||||||
|
|
||||||
chooseEmbedType(page, "inline");
|
|
||||||
|
|
||||||
await expectToBeNavigatingToEmbedCodeAndPreviewDialog(page, {
|
|
||||||
embedUrl,
|
|
||||||
embedType: "inline",
|
|
||||||
basePage: "/event-types",
|
|
||||||
});
|
|
||||||
|
|
||||||
await expectToContainValidCode(page, { embedType: "inline" });
|
|
||||||
|
|
||||||
await gotToPreviewTab(page);
|
|
||||||
|
|
||||||
await expectToContainValidPreviewIframe(page, {
|
|
||||||
embedType: "inline",
|
|
||||||
calLink: `${pro.username}/30-min`,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("open Embed Dialog and choose floating-popup for First Event Type", async ({ page, users }) => {
|
|
||||||
const [pro] = users.get();
|
|
||||||
const embedUrl = await clickFirstEventTypeEmbedButton(page);
|
|
||||||
|
|
||||||
await expectToBeNavigatingToEmbedTypesDialog(page, {
|
|
||||||
embedUrl,
|
|
||||||
basePage: "/event-types",
|
|
||||||
});
|
|
||||||
|
|
||||||
chooseEmbedType(page, "floating-popup");
|
|
||||||
|
|
||||||
await expectToBeNavigatingToEmbedCodeAndPreviewDialog(page, {
|
|
||||||
embedUrl,
|
|
||||||
embedType: "floating-popup",
|
|
||||||
basePage: "/event-types",
|
|
||||||
});
|
|
||||||
await expectToContainValidCode(page, { embedType: "floating-popup" });
|
|
||||||
|
|
||||||
await gotToPreviewTab(page);
|
|
||||||
await expectToContainValidPreviewIframe(page, {
|
|
||||||
embedType: "floating-popup",
|
|
||||||
calLink: `${pro.username}/30-min`,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("open Embed Dialog and choose element-click for First Event Type", async ({ page, users }) => {
|
|
||||||
const [pro] = users.get();
|
|
||||||
const embedUrl = await clickFirstEventTypeEmbedButton(page);
|
|
||||||
|
|
||||||
await expectToBeNavigatingToEmbedTypesDialog(page, {
|
|
||||||
embedUrl,
|
|
||||||
basePage: "/event-types",
|
|
||||||
});
|
|
||||||
|
|
||||||
chooseEmbedType(page, "element-click");
|
|
||||||
|
|
||||||
await expectToBeNavigatingToEmbedCodeAndPreviewDialog(page, {
|
|
||||||
embedUrl,
|
|
||||||
embedType: "element-click",
|
|
||||||
basePage: "/event-types",
|
|
||||||
});
|
|
||||||
await expectToContainValidCode(page, { embedType: "element-click" });
|
|
||||||
|
|
||||||
await gotToPreviewTab(page);
|
|
||||||
await expectToContainValidPreviewIframe(page, {
|
|
||||||
embedType: "element-click",
|
|
||||||
calLink: `${pro.username}/30-min`,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe("Event Type Edit Page", () => {
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
await page.goto(`/event-types`);
|
|
||||||
await Promise.all([
|
|
||||||
page.locator('a[href*="/event-types/"]').first().click(),
|
|
||||||
page.waitForURL((url) => url.pathname.startsWith("/event-types/")),
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("open Embed Dialog for the Event Type", async ({ page }) => {
|
|
||||||
const basePage = new URL(page.url()).pathname;
|
|
||||||
const embedUrl = await clickEmbedButton(page);
|
|
||||||
await expectToBeNavigatingToEmbedTypesDialog(page, {
|
|
||||||
embedUrl,
|
|
||||||
basePage,
|
|
||||||
});
|
|
||||||
|
|
||||||
chooseEmbedType(page, "inline");
|
|
||||||
|
|
||||||
await expectToBeNavigatingToEmbedCodeAndPreviewDialog(page, {
|
|
||||||
embedUrl,
|
|
||||||
basePage,
|
|
||||||
embedType: "inline",
|
|
||||||
});
|
|
||||||
|
|
||||||
await expectToContainValidCode(page, {
|
|
||||||
embedType: "inline",
|
|
||||||
});
|
|
||||||
|
|
||||||
await gotToPreviewTab(page);
|
|
||||||
|
|
||||||
await expectToContainValidPreviewIframe(page, {
|
|
||||||
embedType: "inline",
|
|
||||||
calLink: decodeURIComponent(embedUrl),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -5,16 +5,47 @@ import { WEBAPP_URL } from "@calcom/lib/constants";
|
|||||||
import { randomString } from "@calcom/lib/random";
|
import { randomString } from "@calcom/lib/random";
|
||||||
|
|
||||||
import { test } from "./lib/fixtures";
|
import { test } from "./lib/fixtures";
|
||||||
|
import { testBothFutureAndLegacyRoutes } from "./lib/future-legacy-routes";
|
||||||
import { bookTimeSlot, createNewEventType, selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils";
|
import { bookTimeSlot, createNewEventType, selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils";
|
||||||
|
|
||||||
test.describe.configure({ mode: "parallel" });
|
test.describe.configure({ mode: "parallel" });
|
||||||
|
|
||||||
|
test.describe("Event Types A/B tests", () => {
|
||||||
|
test("should point to the /future/event-types page", async ({ page, users, context }) => {
|
||||||
|
await context.addCookies([
|
||||||
|
{
|
||||||
|
name: "x-calcom-future-routes-override",
|
||||||
|
value: "1",
|
||||||
|
url: "http://localhost:3000",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const user = await users.create();
|
||||||
|
|
||||||
|
await user.apiLogin();
|
||||||
|
|
||||||
|
await page.goto("/event-types");
|
||||||
|
|
||||||
|
await page.waitForLoadState();
|
||||||
|
|
||||||
|
const dataNextJsRouter = await page.evaluate(() =>
|
||||||
|
window.document.documentElement.getAttribute("data-nextjs-router")
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(dataNextJsRouter).toEqual("app");
|
||||||
|
|
||||||
|
const locator = page.getByRole("heading", { name: "Event Types" });
|
||||||
|
|
||||||
|
await expect(locator).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test.describe("Event Types tests", () => {
|
test.describe("Event Types tests", () => {
|
||||||
test.describe("user", () => {
|
testBothFutureAndLegacyRoutes.describe("user", () => {
|
||||||
test.beforeEach(async ({ page, users }) => {
|
test.beforeEach(async ({ page, users }) => {
|
||||||
const user = await users.create();
|
const user = await users.create();
|
||||||
await user.apiLogin();
|
await user.apiLogin();
|
||||||
await page.goto("/event-types");
|
await page.goto("/event-types");
|
||||||
|
|
||||||
// We wait until loading is finished
|
// We wait until loading is finished
|
||||||
await page.waitForSelector('[data-testid="event-types"]');
|
await page.waitForSelector('[data-testid="event-types"]');
|
||||||
});
|
});
|
||||||
@@ -254,6 +285,87 @@ test.describe("Event Types tests", () => {
|
|||||||
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||||
await expect(page.locator("[data-testid=where]")).toHaveText(/Cal Video/);
|
await expect(page.locator("[data-testid=where]")).toHaveText(/Cal Video/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("can add single organizer address location without display location public option", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const $eventTypes = page.locator("[data-testid=event-types] > li a");
|
||||||
|
const firstEventTypeElement = $eventTypes.first();
|
||||||
|
await firstEventTypeElement.click();
|
||||||
|
await page.waitForURL((url) => {
|
||||||
|
return !!url.pathname.match(/\/event-types\/.+/);
|
||||||
|
});
|
||||||
|
|
||||||
|
const locationAddress = "New Delhi";
|
||||||
|
|
||||||
|
await fillLocation(page, locationAddress, 0, false);
|
||||||
|
await page.locator("[data-testid=update-eventtype]").click();
|
||||||
|
|
||||||
|
await page.goto("/event-types");
|
||||||
|
|
||||||
|
const previewLink = await page
|
||||||
|
.locator("[data-testid=preview-link-button]")
|
||||||
|
.first()
|
||||||
|
.getAttribute("href");
|
||||||
|
|
||||||
|
await page.goto(previewLink ?? "");
|
||||||
|
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||||
|
await bookTimeSlot(page);
|
||||||
|
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||||
|
await expect(page.locator(`[data-testid="where"]`)).toHaveText(locationAddress);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("can select 'display on booking page' option when multiple organizer input type are present", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await gotoFirstEventType(page);
|
||||||
|
|
||||||
|
await page.locator("#location-select").click();
|
||||||
|
await page.locator(`text="Link meeting"`).click();
|
||||||
|
|
||||||
|
const locationInputName = (idx: number) => `locations[${idx}].link`;
|
||||||
|
|
||||||
|
const testUrl1 = "https://cal.ai/";
|
||||||
|
await page.locator(`input[name="${locationInputName(0)}"]`).fill(testUrl1);
|
||||||
|
await page.locator("[data-testid=display-location]").last().check();
|
||||||
|
await checkDisplayLocation(page);
|
||||||
|
await unCheckDisplayLocation(page);
|
||||||
|
|
||||||
|
await page.locator("[data-testid=add-location]").click();
|
||||||
|
|
||||||
|
const testUrl2 = "https://cal.com/ai";
|
||||||
|
await page.locator(`text="Link meeting"`).last().click();
|
||||||
|
await page.locator(`input[name="${locationInputName(1)}"]`).waitFor();
|
||||||
|
await page.locator(`input[name="${locationInputName(1)}"]`).fill(testUrl2);
|
||||||
|
await checkDisplayLocation(page);
|
||||||
|
await unCheckDisplayLocation(page);
|
||||||
|
|
||||||
|
// Remove Both of the locations
|
||||||
|
const removeButtomId = "delete-locations.0.type";
|
||||||
|
await page.getByTestId(removeButtomId).click();
|
||||||
|
await page.getByTestId(removeButtomId).click();
|
||||||
|
|
||||||
|
// Add Multiple Organizer Phone Number options
|
||||||
|
await page.locator("#location-select").click();
|
||||||
|
await page.locator(`text="Organizer Phone Number"`).click();
|
||||||
|
|
||||||
|
const organizerPhoneNumberInputName = (idx: number) => `locations[${idx}].hostPhoneNumber`;
|
||||||
|
|
||||||
|
const testPhoneInputValue1 = "9199999999";
|
||||||
|
await page.locator(`input[name="${organizerPhoneNumberInputName(0)}"]`).waitFor();
|
||||||
|
await page.locator(`input[name="${organizerPhoneNumberInputName(0)}"]`).fill(testPhoneInputValue1);
|
||||||
|
await page.locator("[data-testid=display-location]").last().check();
|
||||||
|
await checkDisplayLocation(page);
|
||||||
|
await unCheckDisplayLocation(page);
|
||||||
|
await page.locator("[data-testid=add-location]").click();
|
||||||
|
|
||||||
|
const testPhoneInputValue2 = "9188888888";
|
||||||
|
await page.locator(`text="Organizer Phone Number"`).last().click();
|
||||||
|
await page.locator(`input[name="${organizerPhoneNumberInputName(1)}"]`).waitFor();
|
||||||
|
await page.locator(`input[name="${organizerPhoneNumberInputName(1)}"]`).fill(testPhoneInputValue2);
|
||||||
|
await checkDisplayLocation(page);
|
||||||
|
await unCheckDisplayLocation(page);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -293,7 +405,7 @@ async function addAnotherLocation(page: Page, locationOptionText: string) {
|
|||||||
await page.locator(`text="${locationOptionText}"`).click();
|
await page.locator(`text="${locationOptionText}"`).click();
|
||||||
}
|
}
|
||||||
|
|
||||||
const fillLocation = async (page: Page, inputText: string, index: number) => {
|
const fillLocation = async (page: Page, inputText: string, index: number, selectDisplayLocation = true) => {
|
||||||
// Except the first location, dropdown automatically opens when adding another location
|
// Except the first location, dropdown automatically opens when adding another location
|
||||||
if (index == 0) {
|
if (index == 0) {
|
||||||
await page.locator("#location-select").last().click();
|
await page.locator("#location-select").last().click();
|
||||||
@@ -303,5 +415,17 @@ const fillLocation = async (page: Page, inputText: string, index: number) => {
|
|||||||
const locationInputName = `locations[${index}].address`;
|
const locationInputName = `locations[${index}].address`;
|
||||||
await page.locator(`input[name="${locationInputName}"]`).waitFor();
|
await page.locator(`input[name="${locationInputName}"]`).waitFor();
|
||||||
await page.locator(`input[name="locations[${index}].address"]`).fill(inputText);
|
await page.locator(`input[name="locations[${index}].address"]`).fill(inputText);
|
||||||
|
if (selectDisplayLocation) {
|
||||||
|
await page.locator("[data-testid=display-location]").last().check();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkDisplayLocation = async (page: Page) => {
|
||||||
await page.locator("[data-testid=display-location]").last().check();
|
await page.locator("[data-testid=display-location]").last().check();
|
||||||
|
await expect(page.locator("[data-testid=display-location]").last()).toBeChecked();
|
||||||
|
};
|
||||||
|
|
||||||
|
const unCheckDisplayLocation = async (page: Page) => {
|
||||||
|
await page.locator("[data-testid=display-location]").last().uncheck();
|
||||||
|
await expect(page.locator("[data-testid=display-location]").last()).toBeChecked({ checked: false });
|
||||||
};
|
};
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
@@ -0,0 +1,50 @@
|
|||||||
|
import type { Page } from "@playwright/test";
|
||||||
|
import type { Feature } from "@prisma/client";
|
||||||
|
|
||||||
|
import type { AppFlags } from "@calcom/features/flags/config";
|
||||||
|
import { prisma } from "@calcom/prisma";
|
||||||
|
|
||||||
|
type FeatureSlugs = keyof AppFlags;
|
||||||
|
|
||||||
|
export const createFeatureFixture = (page: Page) => {
|
||||||
|
const store = { features: [], page } as { features: Feature[]; page: typeof page };
|
||||||
|
let initalFeatures: Feature[] = [];
|
||||||
|
|
||||||
|
// IIF to add all feautres to store on creation
|
||||||
|
return {
|
||||||
|
init: async () => {
|
||||||
|
const features = await prisma.feature.findMany();
|
||||||
|
store.features = features;
|
||||||
|
initalFeatures = features;
|
||||||
|
return features;
|
||||||
|
},
|
||||||
|
getAll: () => store.features,
|
||||||
|
get: (slug: FeatureSlugs) => store.features.find((b) => b.slug === slug),
|
||||||
|
deleteAll: async () => {
|
||||||
|
await prisma.feature.deleteMany({
|
||||||
|
where: { slug: { in: store.features.map((feature) => feature.slug) } },
|
||||||
|
});
|
||||||
|
store.features = [];
|
||||||
|
},
|
||||||
|
delete: async (slug: FeatureSlugs) => {
|
||||||
|
await prisma.feature.delete({ where: { slug } });
|
||||||
|
store.features = store.features.filter((b) => b.slug !== slug);
|
||||||
|
},
|
||||||
|
toggleFeature: async (slug: FeatureSlugs) => {
|
||||||
|
const feature = store.features.find((b) => b.slug === slug);
|
||||||
|
if (feature) {
|
||||||
|
const enabled = !feature.enabled;
|
||||||
|
await prisma.feature.update({ where: { slug }, data: { enabled } });
|
||||||
|
store.features = store.features.map((b) => (b.slug === slug ? { ...b, enabled } : b));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
set: async (slug: FeatureSlugs, enabled: boolean) => {
|
||||||
|
const feature = store.features.find((b) => b.slug === slug);
|
||||||
|
if (feature) {
|
||||||
|
store.features = store.features.map((b) => (b.slug === slug ? { ...b, enabled } : b));
|
||||||
|
await prisma.feature.update({ where: { slug }, data: { enabled } });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
reset: () => (store.features = initalFeatures),
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -73,10 +73,10 @@ const fillQuestion = async (eventTypePage: Page, questionType: string, customLoc
|
|||||||
},
|
},
|
||||||
multiselect: async () => {
|
multiselect: async () => {
|
||||||
if (customLocators.shouldChangeMultiSelectLocator) {
|
if (customLocators.shouldChangeMultiSelectLocator) {
|
||||||
await eventTypePage.locator("form svg").nth(1).click();
|
await eventTypePage.getByLabel("multi-select-dropdown").click();
|
||||||
await eventTypePage.getByTestId("select-option-Option 1").click();
|
await eventTypePage.getByTestId("select-option-Option 1").click();
|
||||||
} else {
|
} else {
|
||||||
await eventTypePage.locator("form svg").last().click();
|
await eventTypePage.getByLabel("multi-select-dropdown").last().click();
|
||||||
await eventTypePage.getByTestId("select-option-Option 1").click();
|
await eventTypePage.getByTestId("select-option-Option 1").click();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -88,10 +88,10 @@ const fillQuestion = async (eventTypePage: Page, questionType: string, customLoc
|
|||||||
},
|
},
|
||||||
select: async () => {
|
select: async () => {
|
||||||
if (customLocators.shouldChangeSelectLocator) {
|
if (customLocators.shouldChangeSelectLocator) {
|
||||||
await eventTypePage.locator("form svg").nth(1).click();
|
await eventTypePage.getByLabel("select-dropdown").first().click();
|
||||||
await eventTypePage.getByTestId("select-option-Option 1").click();
|
await eventTypePage.getByTestId("select-option-Option 1").click();
|
||||||
} else {
|
} else {
|
||||||
await eventTypePage.locator("form svg").last().click();
|
await eventTypePage.getByLabel("select-dropdown").last().click();
|
||||||
await eventTypePage.getByTestId("select-option-Option 1").click();
|
await eventTypePage.getByTestId("select-option-Option 1").click();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -138,11 +138,12 @@ const fillAllQuestions = async (eventTypePage: Page, questions: string[], option
|
|||||||
await eventTypePage.getByPlaceholder("Textarea test").fill("This is a sample text for textarea.");
|
await eventTypePage.getByPlaceholder("Textarea test").fill("This is a sample text for textarea.");
|
||||||
break;
|
break;
|
||||||
case "select":
|
case "select":
|
||||||
await eventTypePage.locator("form svg").last().click();
|
await eventTypePage.getByLabel("select-dropdown").last().click();
|
||||||
await eventTypePage.getByTestId("select-option-Option 1").click();
|
await eventTypePage.getByTestId("select-option-Option 1").click();
|
||||||
break;
|
break;
|
||||||
case "multiselect":
|
case "multiselect":
|
||||||
await eventTypePage.locator("form svg").nth(4).click();
|
// select-dropdown
|
||||||
|
await eventTypePage.getByLabel("multi-select-dropdown").click();
|
||||||
await eventTypePage.getByTestId("select-option-Option 1").click();
|
await eventTypePage.getByTestId("select-option-Option 1").click();
|
||||||
break;
|
break;
|
||||||
case "number":
|
case "number":
|
||||||
@@ -204,7 +205,7 @@ export function createBookingPageFixture(page: Page) {
|
|||||||
placeholder?: string
|
placeholder?: string
|
||||||
) => {
|
) => {
|
||||||
await page.getByTestId("add-field").click();
|
await page.getByTestId("add-field").click();
|
||||||
await page.locator("#test-field-type > .bg-default > div > div:nth-child(2)").first().click();
|
await page.getByTestId("test-field-type").click();
|
||||||
await page.getByTestId(`select-option-${questionType}`).click();
|
await page.getByTestId(`select-option-${questionType}`).click();
|
||||||
await page.getByLabel("Identifier").dblclick();
|
await page.getByLabel("Identifier").dblclick();
|
||||||
await page.getByLabel("Identifier").fill(identifier);
|
await page.getByLabel("Identifier").fill(identifier);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { WEBAPP_URL } from "@calcom/lib/constants";
|
|||||||
import { prisma } from "@calcom/prisma";
|
import { prisma } from "@calcom/prisma";
|
||||||
import { MembershipRole, SchedulingType } from "@calcom/prisma/enums";
|
import { MembershipRole, SchedulingType } from "@calcom/prisma/enums";
|
||||||
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||||
|
import type { Schedule } from "@calcom/types/schedule";
|
||||||
|
|
||||||
import { selectFirstAvailableTimeSlotNextMonth, teamEventSlug, teamEventTitle } from "../lib/testUtils";
|
import { selectFirstAvailableTimeSlotNextMonth, teamEventSlug, teamEventTitle } from "../lib/testUtils";
|
||||||
import { TimeZoneEnum } from "./types";
|
import { TimeZoneEnum } from "./types";
|
||||||
@@ -46,6 +47,7 @@ const createTeamEventType = async (
|
|||||||
schedulingType?: SchedulingType;
|
schedulingType?: SchedulingType;
|
||||||
teamEventTitle?: string;
|
teamEventTitle?: string;
|
||||||
teamEventSlug?: string;
|
teamEventSlug?: string;
|
||||||
|
teamEventLength?: number;
|
||||||
}
|
}
|
||||||
) => {
|
) => {
|
||||||
return await prisma.eventType.create({
|
return await prisma.eventType.create({
|
||||||
@@ -65,10 +67,16 @@ const createTeamEventType = async (
|
|||||||
id: user.id,
|
id: user.id,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
hosts: {
|
||||||
|
create: {
|
||||||
|
userId: user.id,
|
||||||
|
isFixed: scenario?.schedulingType === SchedulingType.COLLECTIVE ? true : false,
|
||||||
|
},
|
||||||
|
},
|
||||||
schedulingType: scenario?.schedulingType ?? SchedulingType.COLLECTIVE,
|
schedulingType: scenario?.schedulingType ?? SchedulingType.COLLECTIVE,
|
||||||
title: scenario?.teamEventTitle ?? `${teamEventTitle}-team-id-${team.id}`,
|
title: scenario?.teamEventTitle ?? `${teamEventTitle}-team-id-${team.id}`,
|
||||||
slug: scenario?.teamEventSlug ?? `${teamEventSlug}-team-id-${team.id}`,
|
slug: scenario?.teamEventSlug ?? `${teamEventSlug}-team-id-${team.id}`,
|
||||||
length: 30,
|
length: scenario?.teamEventLength ?? 30,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -78,12 +86,14 @@ const createTeamAndAddUser = async (
|
|||||||
user,
|
user,
|
||||||
isUnpublished,
|
isUnpublished,
|
||||||
isOrg,
|
isOrg,
|
||||||
|
isOrgVerified,
|
||||||
hasSubteam,
|
hasSubteam,
|
||||||
organizationId,
|
organizationId,
|
||||||
}: {
|
}: {
|
||||||
user: { id: number; username: string | null; role?: MembershipRole };
|
user: { id: number; email: string; username: string | null; role?: MembershipRole };
|
||||||
isUnpublished?: boolean;
|
isUnpublished?: boolean;
|
||||||
isOrg?: boolean;
|
isOrg?: boolean;
|
||||||
|
isOrgVerified?: boolean;
|
||||||
hasSubteam?: true;
|
hasSubteam?: true;
|
||||||
organizationId?: number | null;
|
organizationId?: number | null;
|
||||||
},
|
},
|
||||||
@@ -95,7 +105,14 @@ const createTeamAndAddUser = async (
|
|||||||
};
|
};
|
||||||
data.metadata = {
|
data.metadata = {
|
||||||
...(isUnpublished ? { requestedSlug: slug } : {}),
|
...(isUnpublished ? { requestedSlug: slug } : {}),
|
||||||
...(isOrg ? { isOrganization: true } : {}),
|
...(isOrg
|
||||||
|
? {
|
||||||
|
isOrganization: true,
|
||||||
|
isOrganizationVerified: !!isOrgVerified,
|
||||||
|
orgAutoAcceptEmail: user.email.split("@")[1],
|
||||||
|
isOrganizationConfigured: false,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
data.slug = !isUnpublished ? slug : undefined;
|
data.slug = !isUnpublished ? slug : undefined;
|
||||||
if (isOrg && hasSubteam) {
|
if (isOrg && hasSubteam) {
|
||||||
@@ -126,6 +143,17 @@ const createTeamAndAddUser = async (
|
|||||||
export const createUsersFixture = (page: Page, emails: API | undefined, workerInfo: WorkerInfo) => {
|
export const createUsersFixture = (page: Page, emails: API | undefined, workerInfo: WorkerInfo) => {
|
||||||
const store = { users: [], page } as { users: UserFixture[]; page: typeof page };
|
const store = { users: [], page } as { users: UserFixture[]; page: typeof page };
|
||||||
return {
|
return {
|
||||||
|
buildForSignup: (opts?: Pick<CustomUserOpts, "email" | "username" | "useExactUsername" | "password">) => {
|
||||||
|
const uname =
|
||||||
|
opts?.useExactUsername && opts?.username
|
||||||
|
? opts.username
|
||||||
|
: `${opts?.username || "user"}-${workerInfo.workerIndex}-${Date.now()}`;
|
||||||
|
return {
|
||||||
|
username: uname,
|
||||||
|
email: opts?.email ?? `${uname}@example.com`,
|
||||||
|
password: opts?.password ?? uname,
|
||||||
|
};
|
||||||
|
},
|
||||||
create: async (
|
create: async (
|
||||||
opts?: CustomUserOpts | null,
|
opts?: CustomUserOpts | null,
|
||||||
scenario: {
|
scenario: {
|
||||||
@@ -135,7 +163,9 @@ export const createUsersFixture = (page: Page, emails: API | undefined, workerIn
|
|||||||
schedulingType?: SchedulingType;
|
schedulingType?: SchedulingType;
|
||||||
teamEventTitle?: string;
|
teamEventTitle?: string;
|
||||||
teamEventSlug?: string;
|
teamEventSlug?: string;
|
||||||
|
teamEventLength?: number;
|
||||||
isOrg?: boolean;
|
isOrg?: boolean;
|
||||||
|
isOrgVerified?: boolean;
|
||||||
hasSubteam?: true;
|
hasSubteam?: true;
|
||||||
isUnpublished?: true;
|
isUnpublished?: true;
|
||||||
} = {}
|
} = {}
|
||||||
@@ -283,9 +313,10 @@ export const createUsersFixture = (page: Page, emails: API | undefined, workerIn
|
|||||||
if (scenario.hasTeam) {
|
if (scenario.hasTeam) {
|
||||||
const team = await createTeamAndAddUser(
|
const team = await createTeamAndAddUser(
|
||||||
{
|
{
|
||||||
user: { id: user.id, username: user.username, role: "OWNER" },
|
user: { id: user.id, email: user.email, username: user.username, role: "OWNER" },
|
||||||
isUnpublished: scenario.isUnpublished,
|
isUnpublished: scenario.isUnpublished,
|
||||||
isOrg: scenario.isOrg,
|
isOrg: scenario.isOrg,
|
||||||
|
isOrgVerified: scenario.isOrgVerified,
|
||||||
hasSubteam: scenario.hasSubteam,
|
hasSubteam: scenario.hasSubteam,
|
||||||
organizationId: opts?.organizationId,
|
organizationId: opts?.organizationId,
|
||||||
},
|
},
|
||||||
@@ -376,6 +407,24 @@ export const createUsersFixture = (page: Page, emails: API | undefined, workerIn
|
|||||||
await prisma.user.delete({ where: { id } });
|
await prisma.user.delete({ where: { id } });
|
||||||
store.users = store.users.filter((b) => b.id !== id);
|
store.users = store.users.filter((b) => b.id !== id);
|
||||||
},
|
},
|
||||||
|
deleteByEmail: async (email: string) => {
|
||||||
|
// Use deleteMany instead of delete to avoid the findUniqueOrThrow error that happens before the delete
|
||||||
|
await prisma.user.deleteMany({
|
||||||
|
where: {
|
||||||
|
email,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
store.users = store.users.filter((b) => b.email !== email);
|
||||||
|
},
|
||||||
|
set: async (email: string) => {
|
||||||
|
const user = await prisma.user.findUniqueOrThrow({
|
||||||
|
where: { email },
|
||||||
|
include: userIncludes,
|
||||||
|
});
|
||||||
|
const userFixture = createUserFixture(user, store.page);
|
||||||
|
store.users.push(userFixture);
|
||||||
|
return userFixture;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -400,7 +449,11 @@ const createUserFixture = (user: UserWithIncludes, page: Page) => {
|
|||||||
eventTypes: user.eventTypes,
|
eventTypes: user.eventTypes,
|
||||||
routingForms: user.routingForms,
|
routingForms: user.routingForms,
|
||||||
self,
|
self,
|
||||||
apiLogin: async () => apiLogin({ ...(await self()), password: user.username }, store.page),
|
apiLogin: async (password?: string) =>
|
||||||
|
apiLogin({ ...(await self()), password: password || user.username }, store.page),
|
||||||
|
/**
|
||||||
|
* @deprecated use apiLogin instead
|
||||||
|
*/
|
||||||
login: async () => login({ ...(await self()), password: user.username }, store.page),
|
login: async () => login({ ...(await self()), password: user.username }, store.page),
|
||||||
logout: async () => {
|
logout: async () => {
|
||||||
await page.goto("/auth/logout");
|
await page.goto("/auth/logout");
|
||||||
@@ -427,7 +480,7 @@ const createUserFixture = (user: UserWithIncludes, page: Page) => {
|
|||||||
}
|
}
|
||||||
return membership;
|
return membership;
|
||||||
},
|
},
|
||||||
getOrg: async () => {
|
getOrgMembership: async () => {
|
||||||
return prisma.membership.findFirstOrThrow({
|
return prisma.membership.findFirstOrThrow({
|
||||||
where: {
|
where: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
@@ -438,7 +491,13 @@ const createUserFixture = (user: UserWithIncludes, page: Page) => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
include: { team: { select: { children: true, metadata: true, name: true } } },
|
include: {
|
||||||
|
team: {
|
||||||
|
include: {
|
||||||
|
children: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
getFirstEventAsOwner: async () =>
|
getFirstEventAsOwner: async () =>
|
||||||
@@ -489,6 +548,7 @@ type CustomUserOpts = Partial<Pick<Prisma.User, CustomUserOptsKeys>> & {
|
|||||||
// ignores adding the worker-index after username
|
// ignores adding the worker-index after username
|
||||||
useExactUsername?: boolean;
|
useExactUsername?: boolean;
|
||||||
roleInOrganization?: MembershipRole;
|
roleInOrganization?: MembershipRole;
|
||||||
|
schedule?: Schedule;
|
||||||
};
|
};
|
||||||
|
|
||||||
// creates the actual user in the db.
|
// creates the actual user in the db.
|
||||||
@@ -520,7 +580,7 @@ const createUser = (
|
|||||||
timeZone: opts?.timeZone ?? TimeZoneEnum.UK,
|
timeZone: opts?.timeZone ?? TimeZoneEnum.UK,
|
||||||
availability: {
|
availability: {
|
||||||
createMany: {
|
createMany: {
|
||||||
data: getAvailabilityFromSchedule(DEFAULT_SCHEDULE),
|
data: getAvailabilityFromSchedule(opts?.schedule ?? DEFAULT_SCHEDULE),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -641,7 +701,7 @@ export async function apiLogin(
|
|||||||
|
|
||||||
export async function setupEventWithPrice(eventType: Pick<Prisma.EventType, "id">, page: Page) {
|
export async function setupEventWithPrice(eventType: Pick<Prisma.EventType, "id">, page: Page) {
|
||||||
await page.goto(`/event-types/${eventType?.id}?tabName=apps`);
|
await page.goto(`/event-types/${eventType?.id}?tabName=apps`);
|
||||||
await page.locator("div > .ml-auto").first().click();
|
await page.locator("[data-testid='app-switch']").first().click();
|
||||||
await page.getByPlaceholder("Price").fill("100");
|
await page.getByPlaceholder("Price").fill("100");
|
||||||
await page.getByTestId("update-eventtype").click();
|
await page.getByTestId("update-eventtype").click();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { expect } from "@playwright/test";
|
import { expect } from "@playwright/test";
|
||||||
import type Prisma from "@prisma/client";
|
import type Prisma from "@prisma/client";
|
||||||
|
|
||||||
|
import prisma from "@calcom/prisma";
|
||||||
|
import { SchedulingType } from "@calcom/prisma/enums";
|
||||||
|
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||||
|
|
||||||
import { test } from "./lib/fixtures";
|
import { test } from "./lib/fixtures";
|
||||||
import type { Fixtures } from "./lib/fixtures";
|
import type { Fixtures } from "./lib/fixtures";
|
||||||
import { todo, selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils";
|
import { todo, selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils";
|
||||||
@@ -34,6 +38,95 @@ test.describe("Stripe integration", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("when enabling Stripe, credentialId is included", async ({ page, users }) => {
|
||||||
|
const user = await users.create();
|
||||||
|
await user.apiLogin();
|
||||||
|
await page.goto("/apps/installed");
|
||||||
|
|
||||||
|
await user.getPaymentCredential();
|
||||||
|
|
||||||
|
const eventType = user.eventTypes.find((e) => e.slug === "paid") as Prisma.EventType;
|
||||||
|
await user.setupEventWithPrice(eventType);
|
||||||
|
|
||||||
|
// Need to wait for the DB to be updated with the metadata
|
||||||
|
await page.waitForResponse((res) => res.url().includes("update") && res.status() === 200);
|
||||||
|
|
||||||
|
// Check event type metadata to see if credentialId is included
|
||||||
|
const eventTypeMetadata = await prisma.eventType.findFirst({
|
||||||
|
where: {
|
||||||
|
id: eventType.id,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
metadata: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const metadata = EventTypeMetaDataSchema.parse(eventTypeMetadata?.metadata);
|
||||||
|
|
||||||
|
const stripeAppMetadata = metadata?.apps?.stripe;
|
||||||
|
|
||||||
|
expect(stripeAppMetadata).toHaveProperty("credentialId");
|
||||||
|
expect(typeof stripeAppMetadata?.credentialId).toBe("number");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("when enabling Stripe, team credentialId is included", async ({ page, users }) => {
|
||||||
|
const ownerObj = { username: "pro-user", name: "pro-user" };
|
||||||
|
const teamMatesObj = [
|
||||||
|
{ name: "teammate-1" },
|
||||||
|
{ name: "teammate-2" },
|
||||||
|
{ name: "teammate-3" },
|
||||||
|
{ name: "teammate-4" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const owner = await users.create(ownerObj, {
|
||||||
|
hasTeam: true,
|
||||||
|
teammates: teamMatesObj,
|
||||||
|
schedulingType: SchedulingType.COLLECTIVE,
|
||||||
|
});
|
||||||
|
await owner.apiLogin();
|
||||||
|
const { team } = await owner.getFirstTeam();
|
||||||
|
const { title: teamEventTitle, slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id);
|
||||||
|
|
||||||
|
const teamEvent = await owner.getFirstTeamEvent(team.id);
|
||||||
|
|
||||||
|
await page.goto("/apps/stripe");
|
||||||
|
|
||||||
|
/** We start the Stripe flow */
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForURL("https://connect.stripe.com/oauth/v2/authorize?*"),
|
||||||
|
page.click('[data-testid="install-app-button"]'),
|
||||||
|
page.click('[data-testid="anything else"]'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForURL("/apps/installed/payment?hl=stripe"),
|
||||||
|
/** We skip filling Stripe forms (testing mode only) */
|
||||||
|
page.click('[id="skip-account-app"]'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await owner.setupEventWithPrice(teamEvent);
|
||||||
|
|
||||||
|
// Need to wait for the DB to be updated with the metadata
|
||||||
|
await page.waitForResponse((res) => res.url().includes("update") && res.status() === 200);
|
||||||
|
|
||||||
|
// Check event type metadata to see if credentialId is included
|
||||||
|
const eventTypeMetadata = await prisma.eventType.findFirst({
|
||||||
|
where: {
|
||||||
|
id: teamEvent.id,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
metadata: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const metadata = EventTypeMetaDataSchema.parse(eventTypeMetadata?.metadata);
|
||||||
|
|
||||||
|
const stripeAppMetadata = metadata?.apps?.stripe;
|
||||||
|
|
||||||
|
expect(stripeAppMetadata).toHaveProperty("credentialId");
|
||||||
|
expect(typeof stripeAppMetadata?.credentialId).toBe("number");
|
||||||
|
});
|
||||||
|
|
||||||
test("Can book a paid booking", async ({ page, users }) => {
|
test("Can book a paid booking", async ({ page, users }) => {
|
||||||
const user = await users.create();
|
const user = await users.create();
|
||||||
const eventType = user.eventTypes.find((e) => e.slug === "paid") as Prisma.EventType;
|
const eventType = user.eventTypes.find((e) => e.slug === "paid") as Prisma.EventType;
|
||||||
@@ -174,7 +267,7 @@ test.describe("Stripe integration", () => {
|
|||||||
await page.getByTestId("price-input-stripe").fill("200");
|
await page.getByTestId("price-input-stripe").fill("200");
|
||||||
|
|
||||||
// Select currency in dropdown
|
// Select currency in dropdown
|
||||||
await page.locator(".text-black > .bg-default > div > div:nth-child(2)").first().click();
|
await page.getByTestId("stripe-currency-select").click();
|
||||||
await page.locator("#react-select-2-input").fill("mexi");
|
await page.locator("#react-select-2-input").fill("mexi");
|
||||||
await page.locator("#react-select-2-option-81").click();
|
await page.locator("#react-select-2-option-81").click();
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ import type { API } from "mailhog";
|
|||||||
import mailhog from "mailhog";
|
import mailhog from "mailhog";
|
||||||
|
|
||||||
import { IS_MAILHOG_ENABLED } from "@calcom/lib/constants";
|
import { IS_MAILHOG_ENABLED } from "@calcom/lib/constants";
|
||||||
|
import logger from "@calcom/lib/logger";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
|
|
||||||
import type { ExpectedUrlDetails } from "../../../../playwright.config";
|
import type { ExpectedUrlDetails } from "../../../../playwright.config";
|
||||||
import { createBookingsFixture } from "../fixtures/bookings";
|
import { createBookingsFixture } from "../fixtures/bookings";
|
||||||
import { createEmbedsFixture } from "../fixtures/embeds";
|
import { createEmbedsFixture } from "../fixtures/embeds";
|
||||||
|
import { createFeatureFixture } from "../fixtures/features";
|
||||||
import { createOrgsFixture } from "../fixtures/orgs";
|
import { createOrgsFixture } from "../fixtures/orgs";
|
||||||
import { createPaymentsFixture } from "../fixtures/payments";
|
import { createPaymentsFixture } from "../fixtures/payments";
|
||||||
import { createBookingPageFixture } from "../fixtures/regularBookings";
|
import { createBookingPageFixture } from "../fixtures/regularBookings";
|
||||||
@@ -28,6 +30,7 @@ export interface Fixtures {
|
|||||||
emails?: API;
|
emails?: API;
|
||||||
routingForms: ReturnType<typeof createRoutingFormsFixture>;
|
routingForms: ReturnType<typeof createRoutingFormsFixture>;
|
||||||
bookingPage: ReturnType<typeof createBookingPageFixture>;
|
bookingPage: ReturnType<typeof createBookingPageFixture>;
|
||||||
|
features: ReturnType<typeof createFeatureFixture>;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
@@ -85,6 +88,8 @@ export const test = base.extend<Fixtures>({
|
|||||||
const mailhogAPI = mailhog();
|
const mailhogAPI = mailhog();
|
||||||
await use(mailhogAPI);
|
await use(mailhogAPI);
|
||||||
} else {
|
} else {
|
||||||
|
//FIXME: Ideally we should error out here. If someone is running tests with mailhog disabled, they should be aware of it
|
||||||
|
logger.warn("Mailhog is not enabled - Skipping Emails verification");
|
||||||
await use(undefined);
|
await use(undefined);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -92,4 +97,9 @@ export const test = base.extend<Fixtures>({
|
|||||||
const bookingPage = createBookingPageFixture(page);
|
const bookingPage = createBookingPageFixture(page);
|
||||||
await use(bookingPage);
|
await use(bookingPage);
|
||||||
},
|
},
|
||||||
|
features: async ({ page }, use) => {
|
||||||
|
const features = createFeatureFixture(page);
|
||||||
|
await features.init();
|
||||||
|
await use(features);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { test } from "./fixtures";
|
||||||
|
|
||||||
|
export type RouteVariant = "future" | "legacy";
|
||||||
|
|
||||||
|
const routeVariants = ["future", "legacy"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Small wrapper around test.describe().
|
||||||
|
* When using testbothFutureLegacyRoutes.describe() instead of test.describe(), this will run the specified
|
||||||
|
* tests twice. One with the pages route, and one with the new app dir "future" route. It will also add the route variant
|
||||||
|
* name to the test name for easier debugging.
|
||||||
|
* Finally it also adds a parameter routeVariant to your testBothFutureAndLegacyRoutes.describe() callback, which
|
||||||
|
* can be used to do any conditional rendering in the test for a specific route variant (should be as little
|
||||||
|
* as possible).
|
||||||
|
*
|
||||||
|
* See apps/web/playwright/event-types.e2e.ts for an example.
|
||||||
|
*/
|
||||||
|
export const testBothFutureAndLegacyRoutes = {
|
||||||
|
describe: (testName: string, testFn: (routeVariant: RouteVariant) => void) => {
|
||||||
|
routeVariants.forEach((routeVariant) => {
|
||||||
|
test.describe(`${testName} -- ${routeVariant}`, () => {
|
||||||
|
if (routeVariant === "future") {
|
||||||
|
test.beforeEach(({ context }) => {
|
||||||
|
context.addCookies([
|
||||||
|
{ name: "x-calcom-future-routes-override", value: "1", url: "http://localhost:3000" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
testFn(routeVariant as RouteVariant);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
import type { Frame, Page } from "@playwright/test";
|
import type { Frame, Page } from "@playwright/test";
|
||||||
import { expect } from "@playwright/test";
|
import { expect } from "@playwright/test";
|
||||||
|
import { createHash } from "crypto";
|
||||||
import EventEmitter from "events";
|
import EventEmitter from "events";
|
||||||
import type { IncomingMessage, ServerResponse } from "http";
|
import type { IncomingMessage, ServerResponse } from "http";
|
||||||
import { createServer } from "http";
|
import { createServer } from "http";
|
||||||
// eslint-disable-next-line no-restricted-imports
|
// eslint-disable-next-line no-restricted-imports
|
||||||
import { noop } from "lodash";
|
import { noop } from "lodash";
|
||||||
import type { API, Messages } from "mailhog";
|
import type { API, Messages } from "mailhog";
|
||||||
|
import { totp } from "otplib";
|
||||||
|
|
||||||
import type { Prisma } from "@calcom/prisma/client";
|
import type { Prisma } from "@calcom/prisma/client";
|
||||||
import { BookingStatus } from "@calcom/prisma/enums";
|
import { BookingStatus } from "@calcom/prisma/enums";
|
||||||
@@ -201,6 +203,12 @@ export async function installAppleCalendar(page: Page) {
|
|||||||
await page.click('[data-testid="install-app-button"]');
|
await page.click('[data-testid="install-app-button"]');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getInviteLink(page: Page) {
|
||||||
|
const response = await page.waitForResponse("**/api/trpc/teams/createInvite?batch=1");
|
||||||
|
const json = await response.json();
|
||||||
|
return json[0].result.data.json.inviteLink as string;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getEmailsReceivedByUser({
|
export async function getEmailsReceivedByUser({
|
||||||
emails,
|
emails,
|
||||||
userEmail,
|
userEmail,
|
||||||
@@ -278,3 +286,12 @@ export async function createUserWithSeatedEventAndAttendees(
|
|||||||
});
|
});
|
||||||
return { user, eventType, booking };
|
return { user, eventType, booking };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function generateTotpCode(email: string) {
|
||||||
|
const secret = createHash("md5")
|
||||||
|
.update(email + process.env.CALENDSO_ENCRYPTION_KEY)
|
||||||
|
.digest("hex");
|
||||||
|
|
||||||
|
totp.options = { step: 90 };
|
||||||
|
return totp.generate(secret);
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@ test.describe("2FA Tests", async () => {
|
|||||||
const user = await users.create();
|
const user = await users.create();
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
const userPassword = user.username!;
|
const userPassword = user.username!;
|
||||||
await user.login();
|
await user.apiLogin();
|
||||||
|
|
||||||
// expects the home page for an authorized user
|
// expects the home page for an authorized user
|
||||||
await page.goto("/settings/security/two-factor-auth");
|
await page.goto("/settings/security/two-factor-auth");
|
||||||
@@ -94,7 +94,7 @@ test.describe("2FA Tests", async () => {
|
|||||||
const user = await users.create();
|
const user = await users.create();
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
const userPassword = user.username!;
|
const userPassword = user.username!;
|
||||||
await user.login();
|
await user.apiLogin();
|
||||||
|
|
||||||
// expects the home page for an authorized user
|
// expects the home page for an authorized user
|
||||||
await page.goto("/settings/security/two-factor-auth");
|
await page.goto("/settings/security/two-factor-auth");
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { expect } from "@playwright/test";
|
import { expect } from "@playwright/test";
|
||||||
|
import type { Page } from "@playwright/test";
|
||||||
|
|
||||||
import { test } from "./lib/fixtures";
|
import { test } from "./lib/fixtures";
|
||||||
|
import { selectFirstAvailableTimeSlotNextMonth, bookTimeSlot } from "./lib/testUtils";
|
||||||
|
import { localize } from "./lib/testUtils";
|
||||||
|
|
||||||
test.afterEach(({ users }) => users.deleteAll());
|
test.afterEach(({ users }) => users.deleteAll());
|
||||||
|
|
||||||
@@ -69,15 +72,34 @@ test.describe("Managed Event Types tests", () => {
|
|||||||
await page.goto("/event-types");
|
await page.goto("/event-types");
|
||||||
await page.getByTestId("event-types").locator('a[title="managed"]').click();
|
await page.getByTestId("event-types").locator('a[title="managed"]').click();
|
||||||
await page.getByTestId("vertical-tab-assignment").click();
|
await page.getByTestId("vertical-tab-assignment").click();
|
||||||
await page.locator('[class$="control"]').filter({ hasText: "Select..." }).click();
|
await page.getByTestId("assignment-dropdown").click();
|
||||||
|
|
||||||
await page.getByTestId(`select-option-${memberUser.id}`).click();
|
await page.getByTestId(`select-option-${memberUser.id}`).click();
|
||||||
await page.locator('[type="submit"]').click();
|
await page.locator('[type="submit"]').click();
|
||||||
await page.getByTestId("toast-success").waitFor();
|
await page.getByTestId("toast-success").waitFor();
|
||||||
|
});
|
||||||
|
|
||||||
await adminUser.logout();
|
await test.step("Managed event type can use Organizer's default app as location", async () => {
|
||||||
|
await page.getByTestId("vertical-tab-event_setup_tab_title").click();
|
||||||
|
|
||||||
|
await page.locator("#location-select").click();
|
||||||
|
const optionText = (await localize("en"))("organizer_default_conferencing_app");
|
||||||
|
await page.locator(`text=${optionText}`).click();
|
||||||
|
await page.locator("[data-testid=update-eventtype]").click();
|
||||||
|
await page.getByTestId("toast-success").waitFor();
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
await page.getByTestId("vertical-tab-assignment").click();
|
||||||
|
await gotoBookingPage(page);
|
||||||
|
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||||
|
await bookTimeSlot(page);
|
||||||
|
|
||||||
|
await expect(page.getByTestId("success-page")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step("Managed event type has locked fields for added member", async () => {
|
await test.step("Managed event type has locked fields for added member", async () => {
|
||||||
|
await adminUser.logout();
|
||||||
|
|
||||||
// Coming back as member user to see if there is a managed event present after assignment
|
// Coming back as member user to see if there is a managed event present after assignment
|
||||||
await memberUser.apiLogin();
|
await memberUser.apiLogin();
|
||||||
await page.goto("/event-types");
|
await page.goto("/event-types");
|
||||||
@@ -91,3 +113,9 @@ test.describe("Managed Event Types tests", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function gotoBookingPage(page: Page) {
|
||||||
|
const previewLink = await page.getByTestId("preview-button").getAttribute("href");
|
||||||
|
|
||||||
|
await page.goto(previewLink ?? "");
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type { Page } from "@playwright/test";
|
||||||
|
import { expect } from "@playwright/test";
|
||||||
|
import { JSDOM } from "jsdom";
|
||||||
|
// eslint-disable-next-line no-restricted-imports
|
||||||
|
import type { API, Messages } from "mailhog";
|
||||||
|
|
||||||
|
import { getEmailsReceivedByUser } from "../lib/testUtils";
|
||||||
|
|
||||||
|
export async function expectInvitationEmailToBeReceived(
|
||||||
|
page: Page,
|
||||||
|
emails: API | undefined,
|
||||||
|
userEmail: string,
|
||||||
|
subject: string,
|
||||||
|
returnLink?: string
|
||||||
|
) {
|
||||||
|
if (!emails) return null;
|
||||||
|
// We need to wait for the email to go through, otherwise it will fail
|
||||||
|
// eslint-disable-next-line playwright/no-wait-for-timeout
|
||||||
|
await page.waitForTimeout(5000);
|
||||||
|
const receivedEmails = await getEmailsReceivedByUser({ emails, userEmail });
|
||||||
|
expect(receivedEmails?.total).toBe(1);
|
||||||
|
const [firstReceivedEmail] = (receivedEmails as Messages).items;
|
||||||
|
expect(firstReceivedEmail.subject).toBe(subject);
|
||||||
|
if (!returnLink) return;
|
||||||
|
const dom = new JSDOM(firstReceivedEmail.html);
|
||||||
|
const anchor = dom.window.document.querySelector(`a[href*="${returnLink}"]`);
|
||||||
|
return anchor?.getAttribute("href");
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { expect } from "@playwright/test";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
import { test } from "../lib/fixtures";
|
||||||
|
import { generateTotpCode } from "../lib/testUtils";
|
||||||
|
import { expectInvitationEmailToBeReceived } from "./expects";
|
||||||
|
|
||||||
|
test.afterAll(({ users, emails }) => {
|
||||||
|
users.deleteAll();
|
||||||
|
emails?.deleteAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
function capitalize(text: string) {
|
||||||
|
if (!text) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
return text.charAt(0).toUpperCase() + text.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("Organization", () => {
|
||||||
|
test("should be able to create an organization and complete onboarding", async ({
|
||||||
|
page,
|
||||||
|
users,
|
||||||
|
emails,
|
||||||
|
}) => {
|
||||||
|
const orgOwner = await users.create();
|
||||||
|
const orgDomain = `${orgOwner.username}-org`;
|
||||||
|
const orgName = capitalize(`${orgOwner.username}-org`);
|
||||||
|
await orgOwner.apiLogin();
|
||||||
|
await page.goto("/settings/organizations/new");
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
await test.step("Basic info", async () => {
|
||||||
|
// Check required fields
|
||||||
|
await page.locator("button[type=submit]").click();
|
||||||
|
await expect(page.locator(".text-red-700")).toHaveCount(3);
|
||||||
|
|
||||||
|
// Happy path
|
||||||
|
await page.locator("input[name=adminEmail]").fill(`john@${orgDomain}.com`);
|
||||||
|
expect(await page.locator("input[name=name]").inputValue()).toEqual(orgName);
|
||||||
|
expect(await page.locator("input[name=slug]").inputValue()).toEqual(orgDomain);
|
||||||
|
await page.locator("button[type=submit]").click();
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
// Check admin email about code verification
|
||||||
|
await expectInvitationEmailToBeReceived(
|
||||||
|
page,
|
||||||
|
emails,
|
||||||
|
`john@${orgOwner.username}-org.com`,
|
||||||
|
"Verify your email to create an organization"
|
||||||
|
);
|
||||||
|
|
||||||
|
await test.step("Verification", async () => {
|
||||||
|
// Code verification
|
||||||
|
await expect(page.locator("#modal-title")).toBeVisible();
|
||||||
|
await page.locator("input[name='2fa1']").fill(generateTotpCode(`john@${orgDomain}.com`));
|
||||||
|
await page.locator("button:text('Verify')").click();
|
||||||
|
|
||||||
|
// Check admin email about DNS pending action
|
||||||
|
await expectInvitationEmailToBeReceived(
|
||||||
|
page,
|
||||||
|
emails,
|
||||||
|
"admin@example.com",
|
||||||
|
"New organization created: pending action"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Waiting to be in next step URL
|
||||||
|
await page.waitForURL("/settings/organizations/*/set-password");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("Admin password", async () => {
|
||||||
|
// Check required fields
|
||||||
|
await page.locator("button[type=submit]").click();
|
||||||
|
await expect(page.locator(".text-red-700")).toHaveCount(3); // 3 password hints
|
||||||
|
|
||||||
|
// Happy path
|
||||||
|
await page.locator("input[name='password']").fill("ADMIN_user2023$");
|
||||||
|
await page.locator("button[type=submit]").click();
|
||||||
|
|
||||||
|
// Waiting to be in next step URL
|
||||||
|
await page.waitForURL("/settings/organizations/*/about");
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("About the organization", async () => {
|
||||||
|
// Choosing an avatar
|
||||||
|
await page.locator('button:text("Upload")').click();
|
||||||
|
const fileChooserPromise = page.waitForEvent("filechooser");
|
||||||
|
await page.getByText("Choose a file...").click();
|
||||||
|
const fileChooser = await fileChooserPromise;
|
||||||
|
await fileChooser.setFiles(path.join(__dirname, "../../public/apple-touch-icon.png"));
|
||||||
|
await page.locator('button:text("Save")').click();
|
||||||
|
|
||||||
|
// About text
|
||||||
|
await page.locator('textarea[name="about"]').fill("This is a testing org");
|
||||||
|
await page.locator("button[type=submit]").click();
|
||||||
|
|
||||||
|
// Waiting to be in next step URL
|
||||||
|
await page.waitForURL("/settings/organizations/*/onboard-admins");
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("On-board administrators", async () => {
|
||||||
|
// Required field
|
||||||
|
await page.locator("button[type=submit]").click();
|
||||||
|
|
||||||
|
// Happy path
|
||||||
|
await page.locator('textarea[name="emails"]').fill(`rick@${orgDomain}.com`);
|
||||||
|
await page.locator("button[type=submit]").click();
|
||||||
|
|
||||||
|
// Check if invited admin received the invitation email
|
||||||
|
await expectInvitationEmailToBeReceived(
|
||||||
|
page,
|
||||||
|
emails,
|
||||||
|
`rick@${orgDomain}.com`,
|
||||||
|
`${orgName}'s admin invited you to join the organization ${orgName} on Cal.com`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Waiting to be in next step URL
|
||||||
|
await page.waitForURL("/settings/organizations/*/add-teams");
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("Create teams", async () => {
|
||||||
|
// Initial state
|
||||||
|
await expect(page.locator('input[name="teams.0.name"]')).toHaveCount(1);
|
||||||
|
await expect(page.locator('button:text("Continue")')).toBeDisabled();
|
||||||
|
|
||||||
|
// Filling one team
|
||||||
|
await page.locator('input[name="teams.0.name"]').fill("Marketing");
|
||||||
|
await expect(page.locator('button:text("Continue")')).toBeEnabled();
|
||||||
|
|
||||||
|
// Adding another team
|
||||||
|
await page.locator('button:text("Add a team")').click();
|
||||||
|
await expect(page.locator('button:text("Continue")')).toBeDisabled();
|
||||||
|
await expect(page.locator('input[name="teams.1.name"]')).toHaveCount(1);
|
||||||
|
await page.locator('input[name="teams.1.name"]').fill("Sales");
|
||||||
|
await expect(page.locator('button:text("Continue")')).toBeEnabled();
|
||||||
|
|
||||||
|
// Finishing the creation wizard
|
||||||
|
await page.locator('button:text("Continue")').click();
|
||||||
|
await page.waitForURL("/event-types");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loaded 100 of 467 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user